From b11da48e2379789e516ed63fbfa2fd42bfdad352 Mon Sep 17 00:00:00 2001 From: yvmou Date: Tue, 22 Sep 2026 17:45:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=A6=BBUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/2026-09-22.md | 53 ++++++++++ autoload/net.gd | 121 +++++------------------ autoload/net.tscn | 78 +++++++++++++++ docs/联机实战教程-由浅入深.md | 168 +++++++++++++++++++------------- docs/联机框架使用指南.md | 12 ++- player/player.gd | 72 ++++++++++---- player/player.tscn | 19 ++-- project.godot | 2 +- 8 files changed, 328 insertions(+), 197 deletions(-) create mode 100644 autoload/net.tscn diff --git a/.workbuddy/memory/2026-09-22.md b/.workbuddy/memory/2026-09-22.md index 0f3231e..1053426 100644 --- a/.workbuddy/memory/2026-09-22.md +++ b/.workbuddy/memory/2026-09-22.md @@ -51,6 +51,59 @@ GDScript 对动态成员访问极其宽容(`Node` 上调脚本方法、`Node3D.new()` 设脚本属性都不报错), 所以「编译通过」不等于「语义正确」,逻辑对错还得靠人读。 +### UI 改为节点配置(用户要求:不要代码生成 UI) + +- 新增 `autoload/net.tscn`(根 `Net` 是 CanvasLayer,layer=20): + `Menu`(CenterContainer→PanelContainer→VBox: Title/AddressEdit/HostButton/JoinButton/StatusLabel/HintLabel) + + `Hud`(Control→VBox: StatusLabel/HintLabel)。两个按钮的 `pressed` 在 .tscn 里连接到 `Net`。 +- `autoload/net.gd` 重写:`extends CanvasLayer`,删掉全部 `_build_*`,改为 `@onready` 节点引用 + 状态更新 + (`_on_host_button_pressed` / `_on_join_button_pressed` 由场景信号触发)。 +- **`project.godot` 的 autoload 改成指向场景**:`Net="*res://autoload/net.tscn"`(原来指向 net.gd)。 + 这样 `Net` 这个全局名就是那个 CanvasLayer 根节点,`/root/Net` 路径不变,`main.gd`/`player.gd` 无需改动。 +- 建场景的做法:`scene_manage(create)` → `batch_execute`(13 个 create_node) → `batch_execute`(29 个 set_property,`undo=false`) + → `signal_manage(connect)` ×2 → `script_attach` → `scene_save`。Godot 自己序列化,比手写 .tscn 可靠。 +- 验证:双实例 headless 联调通过(主机/客户端各自一个「本地操控」,日志镜像);编辑器内运行零报错。 +- 文档同步:`联机框架使用指南.md` 文件地图;`联机实战教程-由浅入深.md` 关卡 3 / 关卡 9 改成「在编辑器加节点」写法。 + +### 玩家的 UI 也改成节点配置(同一轮完成) + +- `player.tscn` 新增 `Hud (CanvasLayer)` → `HealthLabel (Label)`(位置 16,56)。 +- `player.gd`:删掉 `_make_health_label()`(原来用代码建 CanvasLayer + Label), + 改为 `@onready var _hud: CanvasLayer = $Hud` / `@onready var _health_label: Label = $Hud/HealthLabel`; + 远端分支加 `_hud.visible = false`(否则 N 份血条叠在同一屏幕坐标,显示的是别人的血量); + 本地分支末尾补 `_refresh_health_label()`(`@onready` 赋值晚于同步写入,那次 setter 会被 null 判断跳过)。 +- `_report_identity()` 详细自检已恢复。 +- 确认 `player_sync.tres` 现在有 3 个字段:`.:position`、`Body:rotation`、`.:health`(用户自己加的,正确)。 +- 文档同步:`联机实战教程-由浅入深.md` 关卡 1 改成「先在 player.tscn 里加 Hud/HealthLabel 节点」; + `联机框架使用指南.md` 的 player.tscn 结构图。 + +### 反复出现的坑:编辑器把外部写盘的内容覆盖回去 + +`net.gd` 用 MCP `script_create` 写成功后,磁盘上又变回了旧版(`extends Node` + `_build_ui` 全在)。 +原因:编辑器的**脚本编辑器标签页里缓存着旧文本**,资源 reload ≠ 标签页文本刷新, +之后一次保存就把旧文本写回去了。而且工具返回的 `reloaded: true` 会让人误以为已经生效。 + +**规避办法**: +1. 每次写完立刻从磁盘回读校验(Grep/Read 关键行),别信工具的 `reloaded`。 +2. 改 Godot 项目里的脚本/场景一律走 godot-ai MCP,不要用外部写盘。 +3. 交付时提醒用户:如果编辑器标签页里还是旧内容,关掉标签页重开(或重启编辑器), + 并且在确认内容更新之前不要按 Ctrl+S。 + +`net.gd` 一共被还原了**两次**(第二次在 17:41~17:43 之间,导致 `git status` 里 +`autoload/net.gd` 一度没有差异)。第二次修复后连续观察 30 秒无变化才确认落地。 +排查时用:读文件首行 + 查 `_build_ui` + 看 size/mtime,隔一段时间对比。 + +### 仓库状态(供用户查证) + +- `.gitignore` 只忽略 `.godot/` 和 `/android/`,业务文件不会被忽略。 +- 最后一个提交是 `2ecab99`(17:32「扣血同步」,用户的关卡 1);此后所有改动**都还没提交**, + 且 `origin/master...master` = `0 0`,即**一个都没推送**。 +- 未提交清单:`autoload/net.gd`(-121 行)、`player/player.gd`、`player/player.tscn`、 + `project.godot`、两份 docs、`.workbuddy/memory/...`;**`autoload/net.tscn` 是 untracked(新文件)**。 +- 用户说「git 上看不到差异」,很可能是因为:① 看的是远端网页(没推送); + ② GUI 客户端默认不显示 untracked 文件,而 `autoload/net.tscn` 正是新文件。 + + ### godot-ai MCP 使用注意(重要) diff --git a/autoload/net.gd b/autoload/net.gd index 866a982..e19d51e 100644 --- a/autoload/net.gd +++ b/autoload/net.gd @@ -1,10 +1,14 @@ -extends Node -## 联机总入口(Autoload 名:Net) +extends CanvasLayer +## 联机总入口(Autoload 名:Net,指向 res://autoload/net.tscn) ## ## 职责边界:只负责「选传输层 + 开局/加入/断开 + 玩家生成回收」。 ## 玩法代码不直接接触 MultiplayerPeer —— 以后要把 ENet 换成 Steam P2P, ## 只需要改本文件的 host_game / join_game 两个函数,RPC 与 Spawner 层不用动。 ## +## 界面是**节点配置**:整个 UI 在 res://autoload/net.tscn 里用节点搭好, +## 本脚本只做「改文字、切显隐」这类状态更新,不动态创建任何 Control。 +## 按钮的 pressed 信号也在 net.tscn 里连接(打开场景 → 选按钮 → 节点面板 → 信号)。 +## ## 本机双开调试(命令行): ## godot --path <项目目录> -- --host ## godot --path <项目目录> -- --join 127.0.0.1 @@ -16,11 +20,12 @@ 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 + +@onready var _menu: CenterContainer = $Menu +@onready var _address_edit: LineEdit = $Menu/Panel/Box/AddressEdit +@onready var _menu_status: Label = $Menu/Panel/Box/StatusLabel +@onready var _hud: Control = $Hud +@onready var _hud_status: Label = $Hud/Box/StatusLabel func _ready() -> void: @@ -29,7 +34,7 @@ func _ready() -> void: 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() + _show_menu_ui() call_deferred("_apply_cmdline") @@ -150,93 +155,14 @@ func _apply_cmdline() -> void: join_game(address) -# ---------------- 界面 ---------------- +# ---------------- 界面(节点都在 net.tscn 里,这里只管状态) ---------------- -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) +## 两个按钮的 pressed 信号在 net.tscn 里连到这里 +func _on_host_button_pressed() -> void: + host_game() -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: +func _on_join_button_pressed() -> void: join_game(_address_edit.text) @@ -248,19 +174,18 @@ func _unhandled_key_input(event: InputEvent) -> void: func _show_game_ui() -> void: - _menu_root.visible = false - _hud_root.visible = true + _menu.visible = false + _hud.visible = true _update_hud() func _show_menu_ui() -> void: - _menu_root.visible = true - _hud_root.visible = false + _menu.visible = true + _hud.visible = false func _set_status(text: String) -> void: - if _menu_status != null: - _menu_status.text = text + _menu_status.text = text func _update_hud() -> void: diff --git a/autoload/net.tscn b/autoload/net.tscn new file mode 100644 index 0000000..9017cba --- /dev/null +++ b/autoload/net.tscn @@ -0,0 +1,78 @@ +[gd_scene format=3 uid="uid://l7aug4qrbosl"] + +[ext_resource type="Script" uid="uid://tqratkoyv2up" path="res://autoload/net.gd" id="1_ftux4"] + +[node name="Net" type="CanvasLayer" unique_id=707762690] +layer = 20 +script = ExtResource("1_ftux4") + +[node name="Menu" type="CenterContainer" parent="." unique_id=1952727206] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Panel" type="PanelContainer" parent="Menu" unique_id=1368615243] +layout_mode = 2 + +[node name="Box" type="VBoxContainer" parent="Menu/Panel" unique_id=230570449] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Title" type="Label" parent="Menu/Panel/Box" unique_id=435547235] +layout_mode = 2 +theme_override_font_sizes/font_size = 20 +text = "联机测试" + +[node name="AddressEdit" type="LineEdit" parent="Menu/Panel/Box" unique_id=1928211362] +custom_minimum_size = Vector2(240, 0) +layout_mode = 2 +text = "127.0.0.1" + +[node name="HostButton" type="Button" parent="Menu/Panel/Box" unique_id=2046876747] +layout_mode = 2 +text = "创建主机" + +[node name="JoinButton" type="Button" parent="Menu/Panel/Box" unique_id=440448657] +layout_mode = 2 +text = "加入主机" + +[node name="StatusLabel" type="Label" parent="Menu/Panel/Box" unique_id=273872406] +custom_minimum_size = Vector2(240, 0) +layout_mode = 2 +autowrap_mode = 3 + +[node name="HintLabel" type="Label" parent="Menu/Panel/Box" unique_id=1270685438] +modulate = Color(1, 1, 1, 0.7) +custom_minimum_size = Vector2(240, 0) +layout_mode = 2 +text = "局域网内填主机的内网 IP;本机双开测试填 127.0.0.1" +autowrap_mode = 3 + +[node name="Hud" type="Control" parent="." unique_id=1778234630] +visible = false +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 + +[node name="Box" type="VBoxContainer" parent="Hud" unique_id=623926462] +layout_mode = 0 +offset_left = 16.0 +offset_top = 12.0 +mouse_filter = 2 + +[node name="StatusLabel" type="Label" parent="Hud/Box" unique_id=560394958] +layout_mode = 2 + +[node name="HintLabel" type="Label" parent="Hud/Box" unique_id=183207570] +modulate = Color(1, 1, 1, 0.7) +layout_mode = 2 +text = "F10 断开 · Esc 释放鼠标" + +[connection signal="pressed" from="Menu/Panel/Box/HostButton" to="." method="_on_host_button_pressed"] +[connection signal="pressed" from="Menu/Panel/Box/JoinButton" to="." method="_on_join_button_pressed"] diff --git a/docs/联机实战教程-由浅入深.md b/docs/联机实战教程-由浅入深.md index a09e61d..67cb8cf 100644 --- a/docs/联机实战教程-由浅入深.md +++ b/docs/联机实战教程-由浅入深.md @@ -13,15 +13,18 @@ | 关卡 | 难度 | 你要实现的 | 新概念 | 改动文件 | |---|---|---|---|---| | 0 | 热身 | 跑起来并看懂日志 | peer / authority | 无 | -| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.gd`、`player_sync.tres` | +| 1 | ★ | 自己按 H 扣血,两端血条一致 | 属性复制 | `player.tscn`、`player.gd`、`player_sync.tres` | | 2 | ★ | 每个玩家一个随机颜色 | authority 归属 | `player.gd`、`player_sync.tres` | -| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.gd`、`player.gd`、`player_sync.tres` | +| 3 | ★★ | 头顶显示自己的名牌 | 本地输入如何进网络 | `net.tscn`、`net.gd`、`player.gd`、`player_sync.tres` | | 4 | ★★ | 按 G 让所有端看到挥手光球 | 第一个 RPC | `player.gd` | | 5 | ★★★ | 按 K 打最近的玩家,对方掉血 | 两段式 RPC + 服务器校验 | `player.gd`、`main.gd` | | 6 | ★★★ | 按 J 发射子弹 | 第二个 MultiplayerSpawner | `main.tscn`、`world/bullets.gd` 等 | | 7 | ★★★★ | 子弹命中扣血,且改不掉 | 服务器权威 + 多 Synchronizer | `player.tscn`、`main.gd`、`player.gd` | | 8 | ★★★★ | 走动的远端玩家不再一跳一跳 | 自己写插值 | `player.gd`、`player_sync.tres` | -| 9 | ★★★ | 左上角显示所有玩家名字 | 把整条链路串起来 | `net.gd` | +| 9 | ★★★ | 左上角显示所有玩家名字 | 把整条链路串起来 | `net.tscn`、`net.gd` | + +> **UI 一律用节点配置,不用代码创建。** 联机菜单和 HUD 都在 `res://autoload/net.tscn` 里搭好, +> 涉及 UI 的关卡(3、9)都是「在编辑器里加节点 + 连信号」,脚本里只写 `@onready` 引用和状态更新。 **按键占用表**(教程自己定的,别和已有的冲突): @@ -62,9 +65,9 @@ func _ready() -> void: ``` 主机: [观察] 我是 1,这个节点归我管吗?true,我的名字是 1 - [观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715 + [观察] 我是 1,这个节点归我管吗?false,我的名字是 1334014715 客户端:[观察] 我是 1334014715,这个节点归我管吗?false,我的名字是 1 - [观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715 + [观察] 我是 1334014715,这个节点归我管吗?true,我的名字是 1334014715 ``` **你要记住的三件事**: @@ -81,52 +84,84 @@ func _ready() -> void: **目标**:按 `H` 给自己扣 10 点血,两台机器的血条显示一致。 -**改动**:`player/player.gd`、`player/player_sync.tres` +**改动**:`player/player.tscn`、`player/player.gd`、`player/player_sync.tres` -### 第 1 步:加属性 + 一个只给本地玩家看的血条 +### 第 1 步:加属性 + 血条(节点配置) -在 `player.gd` 顶部的导出变量区(`@export_group("移动")` 前面)加: +**先说 UI 的规矩**:本项目所有 UI 都用**节点配置**,不在代码里 `Label.new()`。所以血条要先在编辑器里搭出来。 + +**1a. 在 `player.tscn` 里加血条节点** + +1. 打开 `res://player/player.tscn`。 +2. 选中根节点 `Player`,添加子节点,类型 **`CanvasLayer`**,改名 `Hud`。 + > `CanvasLayer` 让节点脱离 3D 世界、按屏幕坐标渲染。`Net` 那个菜单也是这么做的。 +3. 选中 `Hud`,添加子节点,类型 `Label`,改名 `HealthLabel`。 +4. 在 Inspector 里设 `HealthLabel` 的 `Position` = `(16, 56)`,`Text` = `HP 100 / 100`。 +5. `Ctrl+S` 保存。 + +层级应该是: + +``` +Player (CharacterBody3D) +├─ Collision / Body / CameraRig / Sync +└─ Hud (CanvasLayer) + └─ HealthLabel (Label) +``` + +**1b. 在 `player.gd` 里加属性和节点引用** ```gdscript +@export_group("属性") ## 血量。用 setter 是为了「值一变就刷新 UI」,不管这个值是本地改的还是同步过来的 @export var health: int = 100: set(value): health = clampi(value, 0, 100) _refresh_health_label() -var _health_label: Label = null +@onready var _hud: CanvasLayer = $Hud +@onready var _health_label: Label = $Hud/HealthLabel ``` `clampi(value, 0, 100)` 把值夹在 0~100,省得到处写边界判断。 -在 `_ready()` 的**本地分支**里(`set_mouse_captured(true)` 之后)加上建血条的代码: +**1c. 处理远端玩家** + +血条节点现在**每个玩家实例上都有**(因为它是场景的一部分)。远端玩家不需要显示,所以在 `_ready()` 的远端分支里关掉: ```gdscript - _make_health_label() +func _ready() -> void: + _is_local = is_multiplayer_authority() + if not _is_local: + camera_rig.queue_free() + _hud.visible = false # ← 血条同理:N 份叠在同一位置会显示错的血量 + set_physics_process(false) + set_process_unhandled_input(false) + return + # ...相机初始化 + set_mouse_captured(true) + _refresh_health_label() # 补刷一次(原因见下面的提示) ``` -然后在文件底部加这三个函数: +> **这一步千万别漏。** 血条节点的坐标是屏幕坐标,如果没有 `_hud.visible = false`, +> 那么每个玩家(包括别人)都会在左上角画一份血条,**全部重叠在同一位置**, +> 你看到的是最后生成的那个玩家的血量。现象是:主机按 `H` 扣自己的血,屏幕上的数字**纹丝不动**。 + +**1d. 刷新函数** + +放在文件底部: ```gdscript -func _make_health_label() -> void: - var layer := CanvasLayer.new() - _health_label = Label.new() - _health_label.position = Vector2(16, 56) - layer.add_child(_health_label) - add_child(layer) - _refresh_health_label() - - func _refresh_health_label() -> void: - # 远端玩家没有血条,_health_label 是 null,这里直接跳过 + # 同步可能在 _ready() 之前就把 health 写进来了,那时 _health_label 还是 null,直接跳过 if _health_label == null: return _health_label.text = "HP %d / 100" % health ``` -> **为什么 setter 和 `_health_label` 要判空?** -> 因为同步是在节点刚进场景树时就把值写进来的,可能**早于** `_ready()` 里建血条那一步。 -> 所以 setter 里的东西必须能容忍「UI 还不存在」。 +> **为什么 setter 要判空、`_ready()` 末尾还要再刷一次?** +> `@onready` 变量是在 `_ready()` 被调用**之前**才赋值的。而同步写入可能来得更早 +> (节点刚进场景树时)。那一次 setter 会因为 `_health_label == null` 被跳过, +> 所以本地分支末尾要补一次 `_refresh_health_label()`,把当前值显示出来。 ### 第 2 步:把 health 加进同步配置 @@ -249,7 +284,7 @@ func _apply_body_color() -> void: **目标**:菜单里填一个昵称,进入游戏后每个玩家头顶显示自己的名字。 -**改动**:`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres` +**改动**:`autoload/net.tscn`、`autoload/net.gd`、`player/player.gd`、`player/player_sync.tres` **新概念**:**本地输入 → 写进 authority 端自己的同步字段 → 自动传播。** @@ -257,30 +292,34 @@ func _apply_body_color() -> void: ### 第 1 步:菜单加一个昵称输入框 -`net.gd` 里加一个变量(放在 `var _address_edit` 旁边): +UI 是**节点配置**(在 `res://autoload/net.tscn` 里搭好的),所以这一步不写创建控件的代码,是在编辑器里加一个节点。 + +1. 在文件系统面板双击打开 `res://autoload/net.tscn`。 +2. 选中 `Menu/Panel/Box/AddressEdit`(地址输入框),在场景菜单里选 **添加子节点**,类型 `LineEdit`,改名 `NameEdit`。 +3. 在 Inspector 里设: + - `Placeholder Text` = `你的名字` + - `Custom Minimum Size` = `240 × 0`(和上面的地址框对齐) +4. 拖动节点,把它排到 `AddressEdit` **下面**(同级节点的顺序就是显示顺序)。 +5. `Ctrl+S` 保存场景。 + +然后在 `net.gd` 里加引用和状态变量: ```gdscript -var _name_edit: LineEdit = null ## 玩家给自己起的名字,空的话用 peer id var player_name: String = "" + +@onready var _name_edit: LineEdit = $Menu/Panel/Box/NameEdit ``` -在 `_build_menu()` 里,`_address_edit` 那段**下面**插入: +> `$Menu/Panel/Box/NameEdit` 就是你在场景里搭出来的节点路径。改完 UI 后如果路径变了(比如移动了节点),这里也要跟着改——否则运行时报 `null` 实例访问错误。 -```gdscript - _name_edit = LineEdit.new() - _name_edit.placeholder_text = "你的名字" - _name_edit.custom_minimum_size = Vector2(240, 0) - box.add_child(_name_edit) -``` - -然后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来: +最后在 `host_game()` 和 `join_game()` 的**开头**各加一行,把输入框的值记下来: ```gdscript player_name = _name_edit.text.strip_edges() ``` -> 加在开头是因为:加入成功后菜单会被隐藏,那时就读不到输入框了。 +> 加在开头是因为:加入成功后菜单会被隐藏,那时再读输入框就不合适了。 ### 第 2 步:Player 加昵称字段 @@ -616,10 +655,10 @@ A 按 K ├─ 发起者是不是 B 节点的 owner?✓ ├─ 距离 ≤ 3 米?✓ └─ world.broadcast_hit.rpc("B", 10) ← 广播给所有人 - │ - ├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做 - ├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10 - └─ 其他机器:同上,什么都不做 + │ + ├─ A 的机器:找到 B 节点 → receive_damage() → B 不是本地的 → 什么都不做 + ├─ B 的机器:找到 B 节点 → receive_damage() → B 是本地的 → health -= 10 + └─ 其他机器:同上,什么都不做 ``` ### 这里埋着一个坑(关卡 7 会修) @@ -1049,7 +1088,7 @@ func _find_hit() -> PlayerController: ``` 权威端: _net_position ← 真实位置 (每物理帧) - ↓ 同步 + ↓ 同步 远端: 真实位置 ← 朝 _net_position 平滑逼近 (每渲染帧) ``` @@ -1146,40 +1185,37 @@ func _process(delta: float) -> void: **目标**:左上角实时显示「谁在房间里」,用昵称。 -**改动**:`autoload/net.gd` +**改动**:`autoload/net.tscn`、`autoload/net.gd` 这一关没有新概念,但把前面所有链路串起来:**spawn → 同步昵称 → UI 显示**。 ### 第 1 步:HUD 加一行列表 -`net.gd` 里加变量: +还是在 `res://autoload/net.tscn` 里加节点: + +1. 打开 `res://autoload/net.tscn`。 +2. 选中 `Hud/Box/StatusLabel`,添加子节点,类型 `Label`,改名 `PlayerList`。 +3. 把它排到 `StatusLabel` **下面**。 +4. 再给 `Hud/Box` 加一个子节点,类型 `Timer`: + - `Wait Time` = `0.5` + - `Autostart` = `true` +5. 在 Inspector 的 **节点 → 信号** 面板里,把 `Hud/Box/Timer` 的 `timeout` 连到 `Net` 的 `_refresh_player_list`。 +6. `Ctrl+S` 保存。 + +然后在 `net.gd` 里加引用(**不用再写 `Timer.new()` / `Label.new()`**): ```gdscript -var _player_list: Label = null +@onready var _player_list: Label = $Hud/Box/PlayerList ``` -`_build_hud()` 里,在 `_hud_status` 后面插入: - -```gdscript - _player_list = Label.new() - box.add_child(_player_list) -``` - -再在 `_build_hud()` 末尾加一个定时刷新(别每帧拼字符串): - -```gdscript - var timer := Timer.new() - timer.wait_time = 0.5 - timer.autostart = true - timer.timeout.connect(_refresh_player_list) - add_child(timer) -``` +> **为什么用 Timer 而不是每帧刷新**:字符串拼接 + 排序是很便宜但不必要的开销, +> 一秒钟刷两次已经足够。养成「UI 按需刷新」的习惯,比事后优化省事得多。 ### 第 2 步:实现刷新 ```gdscript func _refresh_player_list() -> void: - if _player_list == null or _hud_root == null or not _hud_root.visible: + if not _hud.visible: return # 用 PackedStringArray 而不是 Array[String]: # String.join() 要的就是 PackedStringArray,传 Array[String] 会在运行时转换失败 @@ -1195,15 +1231,15 @@ func _refresh_player_list() -> void: _player_list.text = "房间里 %d 人\n" % lines.size() + "\n".join(lines) ``` -### 第 3 步:进出房间也刷一下 +### 第 3 步:进出房间也立刻刷一下 -在 `_enter_game()` 和 `_leave_game_ui()` 末尾都加: +在 `_show_game_ui()` 和 `_show_menu_ui()` 末尾都加: ```gdscript _refresh_player_list() ``` -这样不用等半秒。 +这样不用等半秒的定时器。 ### 验证 diff --git a/docs/联机框架使用指南.md b/docs/联机框架使用指南.md index 707c4ec..4da72ba 100644 --- a/docs/联机框架使用指南.md +++ b/docs/联机框架使用指南.md @@ -70,8 +70,12 @@ RPC 和复制都是靠**节点路径**找到对面对应的节点。所以: ``` E:\GD_Projects\agame\ ├── autoload\ -│ └── net.gd ← 联机总入口(Autoload 名:Net) -│ 管传输层、开局/加入/断开、玩家生成回收、菜单和 HUD +│ ├── net.tscn ← 联机总入口(Autoload 名:Net,指向这个场景) +│ │ Net (CanvasLayer) ← 挂 net.gd +│ │ ├─ Menu (CenterContainer) ← 主菜单:标题/地址框/两个按钮/状态/提示 +│ │ └─ Hud (Control) ← 游戏内 HUD:状态 + 操作提示 +│ └── net.gd ← 管传输层、开局/加入/断开、玩家生成回收; +│ UI 是节点配置,这里只改文字、切显隐 ├── world\ │ ├── main.tscn ← 世界根场景 │ │ Main (Node3D) @@ -85,7 +89,9 @@ E:\GD_Projects\agame\ │ │ ├─ Collision │ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支 │ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里 -│ │ └─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向 +│ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向 +│ │ └─ Hud (CanvasLayer) ← 本地玩家的血条,远端玩家会被隐藏 +│ │ └─ HealthLabel (Label) │ ├── player.gd ← 加了「本地 / 远端」分支 │ └── player_sync.tres ← 同步配置:到底同步哪几个属性 └── tests\ diff --git a/player/player.gd b/player/player.gd index 879fe8a..b7475cc 100644 --- a/player/player.gd +++ b/player/player.gd @@ -6,9 +6,12 @@ extends CharacterBody3D ## ├─ Collision (CollisionShape3D, CapsuleShape3D) ## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面 ## │ └─ Mesh (MeshInstance3D) -## └─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转 -## └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障 -## └─ Camera (Camera3D) +## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转 +## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障 +## │ └─ Camera (Camera3D) +## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation +## └─ Hud (CanvasLayer) ← 本地玩家的血条(节点配置,不是代码建的) +## └─ HealthLabel (Label) ## ## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标 @@ -47,6 +50,9 @@ const JUMP_BUFFER_FRAMES: int = 6 @onready var body: Node3D = $Body @onready var camera_rig: Node3D = $CameraRig @onready var spring_arm: SpringArm3D = $CameraRig/SpringArm +## 血条是 player.tscn 里的节点,不是代码建的 UI +@onready var _hud: CanvasLayer = $Hud +@onready var _health_label: Label = $Hud/HealthLabel var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) var _yaw: float = 0.0 @@ -54,23 +60,19 @@ var _pitch: float = 0.0 var _jump_buffer: int = 0 ## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。 var _is_local: bool = true -# 血条 -var _health_label: Label = null func _ready() -> void: - print("[观察] 我是 %d,这个节点归我管吗?%s,我的名字是 %s" - % [multiplayer.get_unique_id(), is_multiplayer_authority(), name]) - # 生成血条 - _make_health_label() - + _report_identity() _is_local = is_multiplayer_authority() if not _is_local: # 远端玩家只是一具「回放身体」: # 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染; + # 关掉血条 —— 否则 N 份血条叠在同一位置,显示的血量可能是别人的; # 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。 # 它的 position 与 Body.rotation 由子节点 Sync 同步过来。 camera_rig.queue_free() + _hud.visible = false set_physics_process(false) set_process_unhandled_input(false) return @@ -78,6 +80,8 @@ func _ready() -> void: _pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg)) spring_arm.rotation.x = _pitch set_mouse_captured(true) + # 同步可能在 _ready() 之前就把 health 写进来了,那时血条还没刷新,这里补一次 + _refresh_health_label() func _unhandled_input(event: InputEvent) -> void: @@ -148,17 +152,47 @@ func _physics_process(delta: float) -> void: move_and_slide() -func _make_health_label() -> void: - var layer := CanvasLayer.new() - _health_label = Label.new() - _health_label.position = Vector2(16, 56) - layer.add_child(_health_label) - add_child(layer) - _refresh_health_label() - func _refresh_health_label() -> void: - # 远端玩家没有血条,_health_label 是 null,这里直接跳过 + # 血条节点在 player.tscn 里(Hud/HealthLabel)。 + # 同步可能在 _ready() 之前就把 health 写进来,那时 _health_label 还是 null,直接跳过。 if _health_label == null: return _health_label.text = "HP %d / 100" % health + + +## 关卡 0 的临时自检:把「我这一端」和「这个节点」分层打印出来。 +## 联机框架调通后可以整段删掉(连同 _ready() 里的那一行调用)。 +func _report_identity() -> void: + var me: int = multiplayer.get_unique_id() + var authority: int = get_multiplayer_authority() + + # 传输层类型:以后换成 Steam 时这里会变成 SteamMultiplayerPeer + var transport := "无" + if multiplayer.multiplayer_peer != null: + transport = multiplayer.multiplayer_peer.get_class() + + # 同步配置:字段为空或未绑定是「远端玩家僵在原地」的头号原因,所以顺手打出来 + var sync := get_node_or_null("Sync") as MultiplayerSynchronizer + var fields := "未绑定" + if sync != null and sync.replication_config != null: + fields = str(sync.replication_config.get_properties()) + + print("""┌─ [节点自检] %s +│ 进程级 我是 peer %d | 服务器?%s | 传输 %s | 我知道的 peer:%s +│ 节点级 全路径 %s +│ 节点名 %s | 归属 peer %d | 归我管?%s +│ 同步 Sync 字段 %s +└─ 结论 %s""" % [ + name, + me, + multiplayer.is_server(), + transport, + multiplayer.get_peers(), + get_path(), + name, + authority, + is_multiplayer_authority(), + fields, + "这是【我操控】的角色" if is_multiplayer_authority() else "这是【别人】的角色,我只负责回放", + ]) diff --git a/player/player.tscn b/player/player.tscn index 4eac4dc..d1eb740 100644 --- a/player/player.tscn +++ b/player/player.tscn @@ -18,16 +18,6 @@ roughness = 0.6 [node name="Player" type="CharacterBody3D" unique_id=869687860] script = ExtResource("1_oul6g") -health = null -walk_speed = null -sprint_speed = null -acceleration = null -air_acceleration = null -jump_velocity = null -turn_speed = null -mouse_sensitivity = null -pitch_min_deg = null -pitch_max_deg = null [node name="Collision" type="CollisionShape3D" parent="." unique_id=215132582] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0) @@ -53,3 +43,12 @@ surface_material_override/0 = SubResource("StandardMaterial3D_yw30f") [node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] replication_config = ExtResource("2_rkbax") + +[node name="Hud" type="CanvasLayer" parent="." unique_id=425326662] + +[node name="HealthLabel" type="Label" parent="Hud" unique_id=348125801] +offset_left = 16.0 +offset_top = 56.0 +offset_right = 17.0 +offset_bottom = 79.0 +text = "HP 100 / 100" diff --git a/project.godot b/project.godot index 905c7b7..58c726d 100644 --- a/project.godot +++ b/project.godot @@ -19,7 +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" +Net="*res://autoload/net.tscn" [debug]