Files
2026-10-10 16:07:53 +08:00

221 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-10-10
## 新增右上角调试面板(DebugPanel)
**需求**:右上角显示玩家移动速度、相机高度等信息。
**新增文件**
- `ui/debug_panel.gd`(`class_name DebugPanel extends Control`)—— 单节点 `_draw()` 实现,
不用 Label 节点。理由:要自己控制两列对齐(标签左对齐、数值左对齐、列间距固定),
不必去猜字体度量。总刷屏用 `draw_rect` 画底板。
**显示内容**(10 Hz 刷新,`refresh_interval` 可调)
| 行 | 内容 |
|---|---|
| FPS | `Engine.get_frames_per_second()` |
| 移动速度 | 水平速度 m/s + 身长/秒 |
| 垂直速度 | `velocity.y` |
| 相机高度 | 相机相对脚底的世界高度 m + 倍身高 |
| 状态 | 站立/蹲下/疾跑 · 地面/空中 |
| 位置 | 世界 X, Z |
| 手持 | 拖拽中 / 可抓取 / — |
**关键设计点**
- 相机高度用 `_camera.global_position.y - _player.global_position.y`(世界 y 相减),
**不是**读 `CameraRig.position.y` —— 后者是本地坐标还要乘玩家缩放,且相减法对
「蹲伏过渡中途」这种非稳态也直接成立。
- `body_height`(默认 0.15)只用于换算「身长/秒」和「倍身高」,**和 `player.tscn` 的
`Player.scale` 绑定,改角色尺寸时要同步改**。
- `mouse_filter = IGNORE` 在 `_ready()` 里显式设置(见下方 MCP 限制),
否则这个全屏 Control 会吃掉所有鼠标事件,鼠标视角和拖拽全会废掉。
- `visible` 默认 false,`setup()` 里才置 true —— 避免远端玩家的「回放身体」
闪出一块「未接线」面板。
- F3 切换显隐(`toggle_key`)。`Key` 类型可以直接 `@export`。
- 挂在 `CameraRig/HUD` 下(和准心同级)→ 远端玩家的 HUD 随 `camera_rig.queue_free()` 一起释放,
天然只有本地玩家显示。
**改动文件**
- `character/player/player.tscn`:`HUD` 下新增 `DebugPanel (Control)`,
锚点 Full Rect(`anchor_right`/`anchor_bottom` = 1.0),挂 `ui/debug_panel.gd`。
- `character/player/player.gd`:加 `@onready var debug_panel: DebugPanel = $CameraRig/HUD/DebugPanel`
和 `_ready()` 里 `debug_panel.setup(self, motor, pickup, camera)`。
- `character/player/player_pickup.gd`:新增公开 getter `is_hovering()`(配 `has_object()`)。
---
## ⚠ 本次踩到的 Godot MCP 限制(重要,可复用)
### 1. 编辑器在 play 模式时 MCP 拒绝一切写入
`EDITOR_NOT_READY / EDITOR_PLAYING`。必须先 `project_manage(op="stop")`。
(本次用户选择「我来停」,停完 `editor_manage(op="state")` 确认 `readiness: ready`。)
### 2. MCP 写不了整型属性
`node_set_property` 传整数会变成 String → `WRONG_TYPE: Cannot write String to a int property`。
**浮点/字符串正常**。受影响的:`mouse_filter`、`grow_horizontal`、`anchors_preset`。
→ 绕过办法:
- 锚点:改设 `anchor_right` / `anchor_bottom` = **1.0**(浮点可写);编辑器保存时会自动补
`anchors_preset = 15` + `layout_mode = 3`,效果与准心节点完全一致。
- 整型语义(如 `mouse_filter`):**写进脚本里**(`_ready()` 里设),别依赖场景属性。
### 3. 假 parse error(本项目的顽固问题,第二次遇到)
`script_create` / `script_patch` / `filesystem_manage(write_text)` 的 `diagnostics` 会对
**完全合法的文件**报 `gdscript_reload_failed, error_code 43 (ERR_PARSE_ERROR)`,
`line` 是 `fallback_line: true`(≈ 文件总行数,不可信)。
本次 `ui/debug_panel.gd`、`player_pickup.gd` 都被这样误报过,而真实项目上
`--check-only` 全部通过。**判定:这是编辑器热重载产物,不是语法错误。**
教训:**MCP 的 diagnostics 只能当提示,不能用它判定脚本坏了。**
### 4. 可靠的脚本语法校验方法(离线,不污染项目)
Godot 可执行文件路径:
`D:\_yvmou\Godot\Godot_v4.7.2-stable_mono_win64\Godot_v4.7.2-stable_mono_win64.exe`
(从 `Get-Process | Where ProcessName -like "*godot*" | Select Path` 拿到)
直接对真实项目跑(安全,`--check-only` 解析完即退):
```bash
"$GODOT" --headless --path "E:/Godot/agame" --check-only --script "res://ui/debug_panel.gd"
```
**输出为空 = 通过**。
沙箱法(想彻底不碰项目时):临时目录建最小 `project.godot`,拷入脚本及其依赖,
先 `--editor --quit` 生成 `.godot/global_script_class_cache.cfg`(否则会误报
「Could not find type」——注意**类型解析失败也是报 Parse Error**),再 `--check-only`。
### 5. 字体(中文能否渲染)
实测:`ThemeDB.fallback_font` **就是**默认主题字体(`FontFile` / "Open Sans SemiBold"),
`allow_system_fallback = true`,系统有 Microsoft YaHei/SimSun。
注意 `has_char("点")` 返回 **false**(`has_char` 不查系统回退链),但排版时回退生效:
「移动速度相机高度」8 字量出 128px = 16px/字,字形确实取到了。
→ **结论:`_draw()` + `ThemeDB.fallback_font` 画中文是可行的,不必额外挂字体资源。**
### 6. 其他
- `custom_manage(op="list")` 可列出第三方 addon 注册的自定义工具。
- `editor_manage(op="state")` 能看 play 状态;`logs_read(source="editor")` 只看得到
脚本 reload 的 warning,拿不到 GDScript 的详细 parse 报错文本(stderr 到编辑器控制台)。
- 用户 `world/main2.tscn` 是编辑器常驻场景,用完要**还原回去**(本次已还原)。
---
## `player_sync.tres` 为什么在 Godot 里「不可编辑」
- `SceneReplicationConfig` **没有任何 `@export` 属性**,类上只有 `add_property` / `remove_property` /
`property_set_*` 这些**方法** → 通用 Inspector 渲染出来是空的,所以双击 `.tres` 看起来「不能编辑」。
- 这不是错觉:Godot 4.7 二进制里有 `ReplicationEditor` / `Replication Dock` / `Remove Property` /
`Replication Mode`(下拉枚举 `Never,Always,On Change`)/ `ReplicationInterface`,
但**没有** `SceneReplicationConfigEditor` —— 即不存在独立的资源编辑器。
- **正确改法**:选中场景里的 `Sync` 节点(`MultiplayerSynchronizer`)→ Inspector 的 Replication 区
→ 添加/删除属性、勾 Spawn、选 Replication Mode → 保存场景(外部 .tres 会被一并写盘)。
- 运行时可在调试器里看同步情况(`ReplicationInterface` / `ReplicationProfiler`)。
### 顺带发现:`.:health` 是失效路径
`character/player/player.gd` 上**没有** `health`(`health` 只存在于 `demo/` 那套
`agent_base.gd` + `Health` 节点)。同类「路径过期」问题已第三次出现
(前两次是 `Body/AnimationPlayer:anim_state` / `anim_speed`)。
当前 5 条里 4 条有效:`.:position`、`Body:rotation`、`AnimationPlayer:anim_state`、`AnimationPlayer:anim_speed`。
原 5 条全是 `replication_mode = 1`(Always,每处理帧持续发)。`anim_state` / `anim_speed`
是低频变化量,改用 `On Change`(2,可靠传输、变化才发)更省带宽。
### 已处理(用户确认后动手)
`player_sync.tres` 现为 4 条:
| # | 路径 | spawn | mode |
|---|---|---|---|
| 0 | `.:position` | true | 1 Always |
| 1 | `Body:rotation` | true | 1 Always |
| 2 | `AnimationPlayer:anim_state` | true | 2 On Change |
| 3 | `AnimationPlayer:anim_speed` | true | 2 On Change |
- **改法**:MCP `filesystem_manage(write_text)` 直接写 .tres 文本 → `scan()` → `reimport()`。
这个资源**没有编辑器 UI,也不存在「编辑器打开着」的竞态**,所以写文本是安全的
(与 .tscn 的情况不同)。`reimport` 对 .tres 只会刷新文件条目(`skipped_non_imported`)。
- **验证手段(值得复用)**:把手写的 `.tres` 拷进最小沙箱工程,用
`--headless --path <沙箱> --script res://check.gd` 跑一段 `extends SceneTree` 的脚本,
`load()` 它并打印 `get_properties()` / `property_get_spawn()` /
`property_get_replication_mode()` —— 这是**唯一能验证手写 .tres 真能解析**的办法。
实测输出:4 条,mode = 1,1,2,2 ✓
- `resource_manage(op="load")` 对 `SceneReplicationConfig` 只返回 3 个属性
(`resource_local_to_scene` / `resource_path` / `resource_name`),
`op="inspect"` 直接拒绝(不在它支持的原生资源族里)。
- `Always`(1) 用不可靠传输、每帧持续发;`On Change`(2) 用可靠传输、值变了才发。
连续变化的量(position / rotation)用 `Always` 是对的,低频量才换 `On Change`。
### 决定:保持外部 .tres,不内嵌(用户明确选择「不改」)
调研过三条替代路,全部放弃,**以后不必再提**:
- **A 内嵌 `sub_resource`**:功能零损失、可少一个文件,但给配置加条目要调 `add_property()`
—— 那是**方法不是属性**,MCP 只能读写属性(`custom_manage` 也返回 0 个自定义工具),
最后 4 行只能用户在 Replication 面板手点。不值得为省一个文件付这个手工成本。
- **B 脚本里构造**(`MultiplayerSynchronizer` 上挂脚本,`_ready()` 里 new 一个 config 再赋给
`replication_config`):会让编辑器 Replication 面板与实际同步内容**脱节**,
且运行时赋值能否赶在同步器开始工作前生效**未实测**。放弃。
- **C 弃用 Synchronizer 改 `@rpc`**:`world/main.tscn` 用 `MultiplayerSpawner` + `main.gd` 的
`spawn_function` 生成玩家,「新 peer 拿到当前动画状态」是 `spawn = true` 白送的;
换 RPC 要自己补初始状态 / 节流 / 丢包策略。为 4 个值不划算。
- 根本原因:`.tres` 的"缺点"只是不能双击打开,而**编辑入口一直是 Sync 节点的 Replication
面板**(换成 A 也是同一个面板)。
### `/Player/Sync` 节点实测值
`root_path = ".."`(默认值 = 父节点 Player,这就是 `.:position` 指 Player 的原因)、
`delta_interval = 0.0`、`public_visibility = true`、`visibility_update_mode = 0`。
`replication_config` **不在** `node_get_properties` 可读属性里(被 ReplicationEditor 面板接管)。
### 补充:外部 .tres 改动的落盘机制(用户追问「tres 不是不可编辑吗」)
- 准确说法:`player_sync.tres` **没有自己的编辑界面**,但**内容可以改**,入口是 Sync 节点的
Replication 面板。这是两件事,别混。
- 根因链:`SceneReplicationConfig` 没有任何 `@export` 属性(只有方法)→ 通用 Inspector 渲染不出
可编辑项 → Godot 只好把专用 UI(`ReplicationEditor`)挂在**使用它的节点**
(`MultiplayerSynchronizer`)上。实测证据:`resource_manage(op="load")` 只回 3 个元数据属性
(`resource_local_to_scene` / `resource_path` / `resource_name`)—— 这就是你双击它看到的全部内容。
- **编辑器加载的那份 .tres 实例就是项目文件本身**,所以面板里改它会**写回 `.tres`**,重启后仍在
(与 `duplicate()` 出来的副本不同 —— 这既是它"生效"的原因,也是 Godot 著名的「资源被意外共享
修改」陷阱的同一机制)。改完记得保存场景。
- 真正做不到的只有一件事:**手改文本时没有校验**。NodePath 写错编辑器不会拦 ——
`.:health`、`Body/AnimationPlayer:anim_state` 两次事故都源于此。要改路径请走面板下拉选,
别手打。
### 项目事实:资源类的三种来源(排查中确认)
- `demo/ai/trees/*.tres` 的类型是 `BehaviorTree` / `BlackboardPlan`,**但这两个类不在任何 .gd 里** ——
`addons/limboai/` 只有 `bin/*.so`(GDExtension,C++ 编译产物),一个 `.gd` 都没有。
所以它们是**插件用 C++ 注册的 Resource 类**,用起来和引擎内置类没区别(LimboAI 的 C++ 版)。
- 项目里目前**没有任何自己写的 `extends Resource` 脚本**(排除 addons)。
已存在的 `.tres` 类型:`StandardMaterial3D`(素材包材质)、`SceneReplicationConfig`(player_sync)、
`BehaviorTree`(limboai)。
- 没有二进制 `.res`。
- 判据:**`@export` 就是「能不能在 Inspector 里编辑」的分水岭**。`SceneReplicationConfig` 一个
`@export` 都没有 → 打开是空的;自定义 Resource 有 → 双击就能编辑。
### 调试器面板截图排查(15:58 用户贴图)
**三条警告**都是历史遗留、与近期改动无关,来自项目加载时的 `GDScript::reload` 全量重载:
- `face_painter.gd:153` 整数除法 —— **不是 bug**。`_stamp_d` 恒为偶数(第 138 行 `d = r * 2`),
整除不丢精度。要消警告用 `@warning_ignore("integer_division")` + 注释说明,**不要**改成浮点。
- `face_rig.gd:190` 局部变量 `name` 遮蔽 `Node.name` → 改名(如 `entry_name`)即可。
- `paint_panel.gd:69` 局部变量 `scale` 遮蔽 `Control.scale` → 改名(如 `factor`)即可。
**那条错误 `net.gd:66 host_game(): Couldn't create an ENet host.` 也不是 bug**:
日志证据显示当时**两个实例同时在跑** —— 主机(peer 1)已占住 7000 端口,第二个实例点「创建主机」
必然失败(这是预期路径),随后它点「加入」**成功连上**(日志里有完整握手 + 双方各自生成角色)。
功能正常,唯一可改进的是错误消息不够人话。
排查要点:**`user://logs/godot.log` 是「按项目」共享的,多实例会并发写同一个文件**,
所以日志行会交错(截图里 `enet_connection.cp` 被从中间截断就是并发写的痕迹)。
判据:`[Net] 本端 1 …` 和 `[Net] 本端 1484785418 …` 同时出现 = 两个进程。
### ⚠ 两条重要的方法论修正(都靠实测证伪)
**1. 之前说的「假 parse error」成因找到了 —— 不是假错,是热重载场景内的脚本。**
`script_patch`/`script_create` 返回 `gdscript_reload_failed (error_code 43)` 的文件,
全部都是**挂在「当前编辑器打开的那个场景」里的节点上的脚本**:
`debug_panel.gd`(HUD/DebugPanel)、`player_pickup.gd`(/Player/Pickup)、
`face_painter.gd`、`face_rig.gd`(FaceRig) —— 当时开着的正是 `player.tscn`。
而 `paint_panel.gd`(在 paint_panel.tscn 里)、`net.gd`(autoload,net.tscn 没开)
**都返回 `diagnostics: []` 干净结果**。
结论:场景里有活实例时热重载会被判定失败,**文件本身没问题**。
想拿干净诊断:先把编辑器切到一个**不含该脚本实例**的场景,或直接用下面的离线校验。
**2. `--check-only` 只报错、不报警告!**
实测探针(`var name := "hello"` + `5 / 2`,两条确定会触发的警告)→ `--check-only` **零输出**。
所以「无输出」只能证明**没有语法/分析错误**,**不能**作为「警告已消除」的证据。
要确认警告消掉了,必须看编辑器的 Errors 面板(重启编辑器或重新加载项目)。
本次三条警告的处理依据是「遮蔽变量已改名 + 所有引用点都改到」(`face_rig.gd` 的 `name`
在 190/191/192/202/208 共 5 处,**漏改后两处会静默解析成 `Node.name`、不报错但值全错**,
已全部改名 `entry_name`)。