126 lines
11 KiB
Markdown
126 lines
11 KiB
Markdown
# 2026-09-22
|
||
|
||
## 给 Godot 项目 agame 接上局域网联机(ENet 基础版)
|
||
|
||
全流程通过 `godot-ai` MCP 操作编辑器完成,`E:\GD_Projects\agame` 项目。
|
||
|
||
### 落地结果
|
||
|
||
- 新增 `autoload/net.gd`(Autoload 名 `Net`):传输层 + 开局/加入/断开 + 玩家生成回收 + 主菜单/HUD(代码建 UI,无 .tscn)。
|
||
- 新增 `world/main.gd`:把 `player.tscn` 注册给 `MultiplayerSpawner.spawn_function`,`spawn_function` 里 `set_multiplayer_authority(peer_id, true)` + `add_to_group("net_player")`。
|
||
- 新增 `player/player_sync.tres`(`SceneReplicationConfig`),同步字段:`.:position`、`Body:rotation`,`replication_mode = 1`(ALWAYS)。
|
||
- `player/player.tscn`:根下加 `Sync`(`MultiplayerSynchronizer`,`root_path` 保持默认 `..`),绑定上面那个 .tres。
|
||
- `world/main.tscn`:删掉硬编码的 Player 实例,改挂 `MultiplayerSpawner`(`spawn_path = ..`)。
|
||
- `player/player.gd`:加 `_is_local`;远端玩家 `camera_rig.queue_free()` + 关物理 + 关 unhandled input。
|
||
- 新增 `tests/test_net_replication.gd`(`McpTestSuite`),3 个用例 12 条断言全绿,钉住同步配置不被静默写坏。
|
||
|
||
### 关键结论 / 踩坑
|
||
|
||
- **节点 authority 不继承**:必须 `set_multiplayer_authority(id, true)`,否则子节点 `MultiplayerSynchronizer` 仍是服务器 authority,同步不发。
|
||
- **远端玩家必须拆相机并关物理**,否则多台 Camera3D 抢渲染、双重模拟导致抖动。
|
||
- 去中心化 authority 模型跑通(host=1 本地操控 / client 远端回放,client 侧镜像)+ ENet 中继正常。
|
||
- 命令行调试入口:`Net._apply_cmdline()` 支持 `-- --host` / `-- --join <ip>`。
|
||
- ~~项目已存在的无关报错:`res://addons/ziva_agent/ziva_agent.gdextension` 文件缺失,每次启动都刷 3 行 ERROR。~~ **已修复**:
|
||
插件目录已删除,但 `.godot/extension_list.cfg` 里残留了一行 `res://addons/ziva_agent/ziva_agent.gdextension`。
|
||
`filesystem_manage(op="scan")` 不会清掉它(Godot 只在运行中观测到 .gdextension 增删时才重写该文件),
|
||
直接删掉那一行即可。已用 headless 启动验证:3 行 ERROR 消失。
|
||
- AppID 仍是 480(Spacewar 测试,Steam 客户端未装 → `status:1`),换 Steam P2P 时只需改 `Net.host_game/join_game`。
|
||
|
||
### 输出文档
|
||
|
||
- 新增 `docs/联机框架使用指南.md`:面向零联机基础的人,含概念(peer/authority/复制 vs RPC)、文件地图、
|
||
三种跑法、四个「新功能怎么同步」配方、Steam P2P 迁移(直连 SteamID 版 + 大厅版)、调试与已知限制。
|
||
- **Steam API 签名已用探针脚本让 GDScript 分析器逐个校验**(本项目装的是 camelCase 风格 GodotSteam):
|
||
- `Steam.createLobby(type, max_members)` **返回 void**,结果走 `lobby_created(connect_result, lobby_id)` 信号
|
||
- `Steam.joinLobby(lobby_id)` → `lobby_joined(lobby_id, permissions, locked, response)` 信号
|
||
- `Steam.requestLobbyList()` → `lobby_match_list(lobbies: Array)` 信号;成功码 = 1
|
||
- `Steam.getSteamID()`;`setLobbyData/getLobbyData/getLobbyOwner/getNumLobbyMembers/getLobbyMemberByIndex`
|
||
- `SteamMultiplayerPeer`:`create_host(port=0)`、`create_client(steam_id, port=0)`、`host_with_lobby(lobby_id)`、
|
||
`connect_to_lobby(lobby_id)`、`add_peer(steam_id, port=0)`、`set_no_delay/set_no_nagle/set_debug_level`
|
||
- 校验手法(可复用):写临时 .gd 调用候选 API,`script_create` 返回的 diagnostics 会指出「方法不存在」「返回 void 不能赋值」等,比查文档更快更准。
|
||
- 新增 `docs/联机实战教程-由浅入深.md`:九关递进教程(血量同步 → 颜色认领 → 头顶名牌 → 挥手 RPC → 两段式攻击 →
|
||
子弹 Spawner → 服务器权威血量 → 远端插值 → 玩家列表)。按键约定 H/G/K/J。
|
||
- 教程里引入的两个工程改动(后续实现时要跟着做):`player.gd` 加 `class_name PlayerController`、
|
||
`main.gd` 加 `class_name GameWorld`、`bullet.gd` 加 `class_name Bullet`、`bullets.gd` 加 `class_name BulletPool`,
|
||
这样跨脚本调用才有编译期检查。
|
||
- **写教程时抓到一个真 bug**:`parent.broadcast_hit(...)` 是本地调用,伤害传不到对方机器,必须
|
||
`world.broadcast_hit.rpc(...)`。GDScript 对漏 `.rpc()` 不报任何错,这是最阴的一类联机 bug。
|
||
- **第二个发现**:`@rpc("authority")` 的检查是「发起方 peer id == 被调节点 authority」,所以服务器不能
|
||
调用别人玩家节点上的 authority 模式 RPC。广播函数必须放在 authority 为 1 的节点上(世界根节点 Main 天然满足)。
|
||
- 教程全部代码用「拼成一个文件做整体编译」的方式验证过:`diagnostics: []` 零错误。
|
||
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 结构图。
|
||
|
||
### 血条改到玩家头顶(Label3D)
|
||
|
||
- `player.tscn`:删掉 `Hud (CanvasLayer)`,改为 `HealthLabel (Label3D)` 直接挂在 `Player` 下,
|
||
position `(0, 2.05, 0)`、`billboard=1`、`no_depth_test=true`、`font_size=64`、`pixel_size=0.005`(默认值)、`outline_size=8`。
|
||
- `player.gd`:`@onready var _health_label: Label3D = $HealthLabel`,删掉 `_hud` 引用与 `_hud.visible = false`。
|
||
- **顺带消掉一个坑**:用 `Label3D` 后,血条天然待在各自头顶,不再需要「远端玩家隐藏血条」那一步
|
||
(屏幕坐标的 CanvasLayer 才需要,否则 N 份叠在同一屏幕位置)。
|
||
现在默认人人可见别人的血量 —— 因为 health 是同步字段。
|
||
- 尺寸换算:`Label3D` 的文字世界高度 ≈ `font_size × pixel_size`。64 × 0.005 ≈ 0.32 米。
|
||
- 若要与关卡 3 的头顶名牌共存:血量放 `y=2.05`,名牌放 `y=2.3`,不重叠。
|
||
- 验证:双实例 headless 联调,两个日志里 `ERROR|Invalid|null` 一条都没有。
|
||
|
||
### 反复出现的坑:编辑器把外部写盘的内容覆盖回去
|
||
|
||
`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 使用注意(重要)
|
||
|
||
- **autoload 的全局标识符在「正在运行的编辑器会话」里不可见**:新加的 autoload,脚本里直接写 `Net.xxx` 会报 `Identifier not found: Net`。改用 `get_node_or_null("/root/Net")`。
|
||
- Godot 在 Windows 上**不往重定向的 stdout 写日志**(`_console.exe` 更糟,它自己开控制台)。抓输出必须用引擎参数 `--log-file <path>`。
|
||
- `game_manage.get_ui_elements` / `get_scene_tree` 看不到 `/root` 下的 autoload 节点,只认当前场景树。
|
||
- 验证资源是否真被解析:写 `res://tests/test_*.gd`(继承 `McpTestSuite`,方法名 `test_*`,用 `assert_true/assert_eq/assert_gt`),再 `test_run`。
|