This commit is contained in:
2026-09-22 17:45:29 +08:00
parent 2ecab991f8
commit b11da48e23
8 changed files with 328 additions and 197 deletions
+53
View File
@@ -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 使用注意(重要)
+23 -98
View File
@@ -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:
+78
View File
@@ -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"]
+102 -66
View File
@@ -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()
```
这样不用等半秒。
这样不用等半秒的定时器。
### 验证
+9 -3
View File
@@ -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\
+53 -19
View File
@@ -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 "这是【别人】的角色,我只负责回放",
])
+9 -10
View File
@@ -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"
+1 -1
View File
@@ -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]