From db60e35766128f693033bc4e1314712236ac50f2 Mon Sep 17 00:00:00 2001 From: yvmou Date: Tue, 22 Sep 2026 16:56:35 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AE=80=E6=98=93=E8=81=94=E6=9C=BA=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autoload/net.gd | 269 ++++++++++++++++++++++++++++++ autoload/net.gd.uid | 1 + player/player.gd | 14 ++ player/player.tscn | 4 + player/player_sync.tres | 9 + project.godot | 1 + tests/test_net_replication.gd | 54 ++++++ tests/test_net_replication.gd.uid | 1 + world/main.gd | 35 ++++ world/main.gd.uid | 1 + world/main.tscn | 11 +- world/sky/cartoon_sky.tres | 39 +++++ 12 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 autoload/net.gd create mode 100644 autoload/net.gd.uid create mode 100644 player/player_sync.tres create mode 100644 tests/test_net_replication.gd create mode 100644 tests/test_net_replication.gd.uid create mode 100644 world/main.gd create mode 100644 world/main.gd.uid diff --git a/autoload/net.gd b/autoload/net.gd new file mode 100644 index 0000000..866a982 --- /dev/null +++ b/autoload/net.gd @@ -0,0 +1,269 @@ +extends Node +## 联机总入口(Autoload 名:Net) +## +## 职责边界:只负责「选传输层 + 开局/加入/断开 + 玩家生成回收」。 +## 玩法代码不直接接触 MultiplayerPeer —— 以后要把 ENet 换成 Steam P2P, +## 只需要改本文件的 host_game / join_game 两个函数,RPC 与 Spawner 层不用动。 +## +## 本机双开调试(命令行): +## godot --path <项目目录> -- --host +## godot --path <项目目录> -- --join 127.0.0.1 + +const PORT: int = 7000 +const MAX_PLAYERS: int = 8 +const DEFAULT_ADDRESS: String = "127.0.0.1" +## 所有玩家节点都会加入这个组,方便整批清场 +const PLAYER_GROUP: String = "net_player" + +var _spawner: MultiplayerSpawner = null +var _menu_root: Control = null +var _hud_root: Control = null +var _address_edit: LineEdit = null +var _menu_status: Label = null +var _hud_status: Label = null + + +func _ready() -> void: + multiplayer.peer_connected.connect(_on_peer_connected) + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + multiplayer.connected_to_server.connect(_on_connected_to_server) + multiplayer.connection_failed.connect(_on_connection_failed) + multiplayer.server_disconnected.connect(_on_server_disconnected) + _build_ui() + call_deferred("_apply_cmdline") + + +# ---------------- 对外接口 ---------------- + +## 世界根节点在 _ready 里调用,把 MultiplayerSpawner 托管给 Net。 +func bind_spawner(spawner: MultiplayerSpawner) -> void: + _spawner = spawner + + +## 创建主机。ENet 是客户端-服务器模型,主机同时是服务器与玩家。 +func host_game() -> void: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(PORT, MAX_PLAYERS) + if err != OK: + _set_status("创建主机失败:%s" % error_string(err)) + return + multiplayer.multiplayer_peer = peer + _show_game_ui() + print("[Net] 主机已启动,端口 %d" % PORT) + # 服务器自己也要一个角色。延后一帧,等各端场景就绪。 + call_deferred("_spawn_player", 1) + + +## 加入主机。address 为主机在局域网内的内网 IP。 +func join_game(address: String = DEFAULT_ADDRESS) -> void: + var target := address.strip_edges() + if target.is_empty(): + target = DEFAULT_ADDRESS + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client(target, PORT) + if err != OK: + _set_status("连接失败:%s" % error_string(err)) + return + multiplayer.multiplayer_peer = peer + _set_status("正在连接 %s:%d …" % [target, PORT]) + print("[Net] 正在连接 %s:%d" % [target, PORT]) + + +## 断开并清场,回到主菜单。 +func leave_game() -> void: + multiplayer.multiplayer_peer = null + _clear_players() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _show_menu_ui() + _set_status("已断开") + + +# ---------------- 网络事件 ---------------- + +func _on_peer_connected(id: int) -> void: + # 只有服务器生成角色,客户端靠 MultiplayerSpawner 自动收到同一份。 + if multiplayer.is_server(): + call_deferred("_spawn_player", id) + + +func _on_peer_disconnected(id: int) -> void: + if multiplayer.is_server(): + _despawn_player(id) + + +func _on_connected_to_server() -> void: + _show_game_ui() + print("[Net] 已连接,等待主机生成角色") + + +func _on_connection_failed() -> void: + multiplayer.multiplayer_peer = null + _set_status("连接失败:主机无响应") + + +func _on_server_disconnected() -> void: + multiplayer.multiplayer_peer = null + _clear_players() + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _show_menu_ui() + _set_status("主机已关闭") + + +# ---------------- 玩家生成与回收 ---------------- + +func _spawn_player(peer_id: int) -> void: + if _spawner == null or not multiplayer.is_server(): + return + var parent := _spawner.get_node_or_null(_spawner.spawn_path) + if parent == null or parent.has_node(str(peer_id)): + return + _spawner.spawn(peer_id) + + +func _despawn_player(peer_id: int) -> void: + if _spawner == null: + return + var parent := _spawner.get_node_or_null(_spawner.spawn_path) + if parent == null: + return + var node := parent.get_node_or_null(str(peer_id)) + if node != null: + node.queue_free() + + +func _clear_players() -> void: + for node in get_tree().get_nodes_in_group(PLAYER_GROUP): + node.queue_free() + + +# ---------------- 命令行调试入口 ---------------- + +func _apply_cmdline() -> void: + var args := OS.get_cmdline_user_args() + if args.has("--host"): + host_game() + elif args.has("--join"): + var index := args.find("--join") + var address := DEFAULT_ADDRESS + if index + 1 < args.size(): + address = args[index + 1] + join_game(address) + + +# ---------------- 界面 ---------------- + +func _build_ui() -> void: + var menu_layer := CanvasLayer.new() + menu_layer.name = "NetMenu" + menu_layer.layer = 20 + add_child(menu_layer) + _menu_root = _build_menu() + menu_layer.add_child(_menu_root) + + var hud_layer := CanvasLayer.new() + hud_layer.name = "NetHud" + hud_layer.layer = 19 + add_child(hud_layer) + _hud_root = _build_hud() + _hud_root.visible = false + hud_layer.add_child(_hud_root) + + +func _build_menu() -> Control: + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + + var panel := PanelContainer.new() + center.add_child(panel) + + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 10) + panel.add_child(box) + + var title := Label.new() + title.text = "联机测试" + title.add_theme_font_size_override("font_size", 20) + box.add_child(title) + + _address_edit = LineEdit.new() + _address_edit.text = DEFAULT_ADDRESS + _address_edit.custom_minimum_size = Vector2(240, 0) + box.add_child(_address_edit) + + var host_button := Button.new() + host_button.text = "创建主机" + host_button.pressed.connect(host_game) + box.add_child(host_button) + + var join_button := Button.new() + join_button.text = "加入主机" + join_button.pressed.connect(_on_join_pressed) + box.add_child(join_button) + + _menu_status = Label.new() + _menu_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _menu_status.custom_minimum_size = Vector2(240, 0) + box.add_child(_menu_status) + + var hint := Label.new() + hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + hint.custom_minimum_size = Vector2(240, 0) + hint.modulate = Color(1, 1, 1, 0.7) + hint.text = "局域网内填主机的内网 IP;本机双开测试填 %s" % DEFAULT_ADDRESS + box.add_child(hint) + + return center + + +func _build_hud() -> Control: + var layer_root := Control.new() + layer_root.set_anchors_preset(Control.PRESET_FULL_RECT) + layer_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + + var box := VBoxContainer.new() + box.position = Vector2(16, 12) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + layer_root.add_child(box) + + _hud_status = Label.new() + box.add_child(_hud_status) + + var hint := Label.new() + hint.modulate = Color(1, 1, 1, 0.7) + hint.text = "F10 断开 · Esc 释放鼠标" + box.add_child(hint) + + return layer_root + + +func _on_join_pressed() -> void: + join_game(_address_edit.text) + + +func _unhandled_key_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo \ + and event.keycode == KEY_F10 and multiplayer.multiplayer_peer != null: + leave_game() + get_viewport().set_input_as_handled() + + +func _show_game_ui() -> void: + _menu_root.visible = false + _hud_root.visible = true + _update_hud() + + +func _show_menu_ui() -> void: + _menu_root.visible = true + _hud_root.visible = false + + +func _set_status(text: String) -> void: + if _menu_status != null: + _menu_status.text = text + + +func _update_hud() -> void: + var role := "主机" if multiplayer.is_server() else "客户端" + var players := multiplayer.get_peers().size() + 1 + _hud_status.text = "联机中 · %s · 玩家 %d" % [role, players] diff --git a/autoload/net.gd.uid b/autoload/net.gd.uid new file mode 100644 index 0000000..ee9e72a --- /dev/null +++ b/autoload/net.gd.uid @@ -0,0 +1 @@ +uid://tqratkoyv2up diff --git a/player/player.gd b/player/player.gd index d9bec9d..66f9316 100644 --- a/player/player.gd +++ b/player/player.gd @@ -45,9 +45,21 @@ var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_grav var _yaw: float = 0.0 var _pitch: float = 0.0 var _jump_buffer: int = 0 +## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。 +var _is_local: bool = true func _ready() -> void: + _is_local = is_multiplayer_authority() + if not _is_local: + # 远端玩家只是一具「回放身体」: + # 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染; + # 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。 + # 它的 position 与 Body.rotation 由子节点 Sync 同步过来。 + camera_rig.queue_free() + set_physics_process(false) + set_process_unhandled_input(false) + return _yaw = camera_rig.rotation.y _pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg)) spring_arm.rotation.x = _pitch @@ -77,6 +89,8 @@ func set_mouse_captured(captured: bool) -> void: func _physics_process(delta: float) -> void: + if not _is_local: + return # --- 重力 --- if is_on_floor(): if velocity.y < 0.0: diff --git a/player/player.tscn b/player/player.tscn index 1b0e0dc..9c2c0a1 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -1,6 +1,7 @@ [gd_scene format=3 uid="uid://bekuuw153mv03"] [ext_resource type="Script" uid="uid://f5ptp3is4agf" path="res://player/player.gd" id="1_oul6g"] +[ext_resource type="SceneReplicationConfig" path="res://player/player_sync.tres" id="2_rkbax"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"] radius = 0.4 @@ -39,3 +40,6 @@ fov = 82.2 transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0) mesh = SubResource("CapsuleMesh_g1dw6") surface_material_override/0 = SubResource("StandardMaterial3D_yw30f") + +[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] +replication_config = ExtResource("2_rkbax") diff --git a/player/player_sync.tres b/player/player_sync.tres new file mode 100644 index 0000000..c217763 --- /dev/null +++ b/player/player_sync.tres @@ -0,0 +1,9 @@ +[gd_resource type="SceneReplicationConfig" format=3] + +[resource] +properties/0/path = NodePath(".:position") +properties/0/spawn = true +properties/0/replication_mode = 1 +properties/1/path = NodePath("Body:rotation") +properties/1/spawn = true +properties/1/replication_mode = 1 diff --git a/project.godot b/project.godot index c4f5a41..905c7b7 100644 --- a/project.godot +++ b/project.godot @@ -19,6 +19,7 @@ config/icon="res://icon.svg" Steamworks="*uid://w087kpi6dipk" _mcp_game_helper="*res://addons/godot_ai/runtime/game_helper.gd" +Net="*res://autoload/net.gd" [debug] diff --git a/tests/test_net_replication.gd b/tests/test_net_replication.gd new file mode 100644 index 0000000..ffaf4d2 --- /dev/null +++ b/tests/test_net_replication.gd @@ -0,0 +1,54 @@ +@tool +extends McpTestSuite +## 回归测试:Player 的同步配置必须真的被解析成条目。 +## +## 这是一处典型的「静默失效」:如果 .tres 里的键名写错,或 replication_mode +## 落到 NEVER,远端玩家会僵在原地不动,而且不打任何错误。用测试钉住它。 + +const CONFIG_PATH := "res://player/player_sync.tres" +const PLAYER_SCENE_PATH := "res://player/player.tscn" + + +func suite_name() -> String: + return "net" + + +func test_config_has_expected_paths() -> void: + var config := load(CONFIG_PATH) as SceneReplicationConfig + assert_true(config != null, "player_sync.tres 未能加载为 SceneReplicationConfig") + if config == null: + return + var paths := config.get_properties() + assert_eq(paths.size(), 2, "同步字段数量不对:%s" % [paths]) + assert_true(config.has_property(^".:position"), "缺少 .:position") + assert_true(config.has_property(^"Body:rotation"), "缺少 Body:rotation") + + +func test_config_replication_mode_is_always() -> void: + var config := load(CONFIG_PATH) as SceneReplicationConfig + assert_true(config != null, "player_sync.tres 未能加载") + if config == null: + return + var paths := config.get_properties() + assert_gt(paths.size(), 0, "同步字段为空,远端玩家不会移动") + for path in paths: + assert_eq( + config.property_get_replication_mode(path), + SceneReplicationConfig.REPLICATION_MODE_ALWAYS, + "%s 的 replication_mode 不是 ALWAYS" % path + ) + + +func test_player_scene_wires_synchronizer() -> void: + var packed := load(PLAYER_SCENE_PATH) as PackedScene + assert_true(packed != null, "player.tscn 未能加载") + if packed == null: + return + var player := packed.instantiate() + track(player) + var sync := player.get_node_or_null("Sync") as MultiplayerSynchronizer + assert_true(sync != null, "Player 下缺少 Sync 节点") + if sync == null: + return + assert_true(sync.replication_config != null, "Sync 没有绑定 SceneReplicationConfig") + assert_eq(sync.root_path, ^"..", "Sync.root_path 应为 .. (相对 Player)") diff --git a/tests/test_net_replication.gd.uid b/tests/test_net_replication.gd.uid new file mode 100644 index 0000000..fe6d170 --- /dev/null +++ b/tests/test_net_replication.gd.uid @@ -0,0 +1 @@ +uid://nvx3ojicfxh5 diff --git a/world/main.gd b/world/main.gd new file mode 100644 index 0000000..4269e21 --- /dev/null +++ b/world/main.gd @@ -0,0 +1,35 @@ +extends Node3D +## 世界根节点。 +## +## 只做一件事:把玩家场景注册给 MultiplayerSpawner,并把 spawner 托管给 Net 单例。 +## 玩家的生成/回收时机完全由 Net 决定,本文件不主动调用 spawn。 + +const PLAYER_SCENE: PackedScene = preload("res://player/player.tscn") +const PLAYER_GROUP: String = "net_player" +## 用节点路径而不是全局标识符访问单例: +## autoload 的全局名只在引擎启动时注册,这样写不依赖注册时机。 +const NET_PATH: String = "/root/Net" + +@onready var spawner: MultiplayerSpawner = $MultiplayerSpawner + + +func _ready() -> void: + spawner.spawn_function = _create_player + var net := get_node_or_null(NET_PATH) + if net != null: + net.bind_spawner(spawner) + else: + push_error("Net 单例未注册,联机不可用。请确认 project.godot 的 [autoload] 里有 Net") + + +## MultiplayerSpawner 会在**每一端**都调用这个函数,因此 authority 必须在这里设置: +## 这样「谁是本地玩家」在各端结论一致,玩家脚本里的 is_multiplayer_authority() 才可靠。 +## recursive=true 很关键 —— 节点 authority 不自动继承,Sync 子节点必须一起设。 +func _create_player(peer_id: int) -> Node: + var player := PLAYER_SCENE.instantiate() + player.name = str(peer_id) + player.add_to_group(PLAYER_GROUP) + player.set_multiplayer_authority(peer_id, true) + var here := multiplayer.get_unique_id() + print("[Net] 本端 %d 生成玩家 %d%s" % [here, peer_id, "(本地操控)" if peer_id == here else "(远端回放)"]) + return player diff --git a/world/main.gd.uid b/world/main.gd.uid new file mode 100644 index 0000000..1a61e12 --- /dev/null +++ b/world/main.gd.uid @@ -0,0 +1 @@ +uid://11rbhffe5dhv diff --git a/world/main.tscn b/world/main.tscn index 45e99dd..e2cd93f 100644 --- a/world/main.tscn +++ b/world/main.tscn @@ -1,7 +1,7 @@ [gd_scene format=3 uid="uid://bp8l1uwgshc86"] -[ext_resource type="PackedScene" uid="uid://bekuuw153mv03" path="res://player/player.tscn" id="1_gjxf3"] -[ext_resource type="Sky" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"] +[ext_resource type="Script" uid="uid://11rbhffe5dhv" path="res://world/main.gd" id="1_hyim0"] +[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"] [sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"] size = Vector3(40, 0.5, 40) @@ -22,6 +22,7 @@ ambient_light_source = 3 reflected_light_source = 2 [node name="Main" type="Node3D" unique_id=58155864] +script = ExtResource("1_hyim0") [node name="Ground" type="StaticBody3D" parent="." unique_id=305091665] @@ -40,8 +41,8 @@ shadow_enabled = true directional_shadow_blend_splits = true directional_shadow_max_distance = 40.0 -[node name="Player" parent="." unique_id=869687860 instance=ExtResource("1_gjxf3")] -transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.3, 0) - [node name="Environment" type="WorldEnvironment" parent="." unique_id=794103784] environment = SubResource("Environment_gu74f") + +[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1410622657] +spawn_path = NodePath("..") diff --git a/world/sky/cartoon_sky.tres b/world/sky/cartoon_sky.tres index 9b7f708..3c7a1fd 100644 --- a/world/sky/cartoon_sky.tres +++ b/world/sky/cartoon_sky.tres @@ -4,6 +4,45 @@ [sub_resource type="ShaderMaterial" id="ShaderMaterial_cartoon"] shader = ExtResource("1_cartoon") +shader_parameter/zenith_color = Color(0.09, 0.3, 0.78, 1) +shader_parameter/mid_color = Color(0.28, 0.6, 0.95, 1) +shader_parameter/horizon_color = Color(0.78, 0.88, 1, 1) +shader_parameter/ground_color = Color(0.29, 0.329, 0.384, 1) +shader_parameter/gradient_bias = 1.4 +shader_parameter/band_count = 0.0 +shader_parameter/band_sharpness = 0.9 +shader_parameter/follow_scene_light = 1.0 +shader_parameter/sun_direction = Vector3(0.35, 0.55, 0.35) +shader_parameter/sun_core_color = Color(1, 0.976, 0.878, 1) +shader_parameter/sun_halo_color = Color(1, 0.827, 0.478, 1) +shader_parameter/sun_disk_size = 0.035 +shader_parameter/sun_disk_softness = 0.2 +shader_parameter/sun_halo_size = 0.55 +shader_parameter/sun_halo_strength = 0.65 +shader_parameter/sun_bloom_strength = 0.35 +shader_parameter/sun_intensity = 1.7 +shader_parameter/star_amount = 0.0 +shader_parameter/star_density = 60.0 +shader_parameter/star_size = 0.05 +shader_parameter/star_twinkle = 0.65 +shader_parameter/star_color = Color(0.85, 0.9, 1, 1) +shader_parameter/cloud_color = Color(1, 1, 1, 1) +shader_parameter/cloud_shadow_color = Color(0.45, 0.62, 0.88, 1) +shader_parameter/cloud_rim_color = Color(1, 0.93, 0.76, 1) +shader_parameter/cloud_coverage = 0.55 +shader_parameter/cloud_scale = 4.0 +shader_parameter/cloud_plane_offset = 0.6 +shader_parameter/cloud_horizon_fade = 0.08 +shader_parameter/cloud_edge = 0.16 +shader_parameter/cloud_warp = 0.3 +shader_parameter/cloud_speed = 0.01 +shader_parameter/cloud_direction = Vector2(1, 0.25) +shader_parameter/cloud_shading = 0.55 +shader_parameter/cloud_rim = 0.4 +shader_parameter/cloud_opacity = 1.0 +shader_parameter/haze_color = Color(0.8, 0.89, 1, 1) +shader_parameter/haze_strength = 0.35 +shader_parameter/haze_height = 0.09 [resource] sky_material = SubResource("ShaderMaterial_cartoon")