Files
agame/.workbuddy/memory/2026-10-10.md
T
2026-10-10 16:07:53 +08:00

15 KiB
Raw Blame History

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 解析完即退):

"$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)。