Files
agame/.workbuddy-ai/memory/MEMORY.md
T
2026-10-03 17:26:42 +08:00

14 KiB
Raw Blame History

项目长期记忆

工程约定

  • 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 player_animator.gd 直接挂在 Body/AnimationPlayer 上(animation_player 留空即自绑自身,所以两者是同一个节点)。 新增动作状态改 ANIM_CLIPS,不要往 player.gd 里塞片段名。
  • 组件的可调参数放在组件自己身上(如 Zipline 的速度 / 刹车 / 吸附半径),控制器只读不定义。
  • 头顶 UI(血条、交互提示)是 player.tscn 里的 Label3D 节点,不在代码里动态创建。
  • 测试用 McpTestSuite(addons/godot_ai/testing),脚本放 res://tests/。
  • 界面放 res://ui/。项目没有 Theme 资源,风格一律用节点上的 theme_override_* 内联写; 给容器(如 VBoxContainer)设 theme_override_styles/* 会向下继承给子按钮,不用逐个设。
  • 中文字体不用额外配:Godot 默认主题会回退到系统字体,实测中文正常渲染。
  • 入口场景是 res://ui/main_menu.tscn(不再是世界)。流程:开始菜单 →「开始游戏」→ 角色选择 → 「确定」= Net.host_game() → Net 切到 world/main.tscn;「联机」= 放出 Net 的创建/加入面板, 选完同样由 Net 切进世界。进世界 / 回菜单这两件事都归 Net 管(_enter_world / leave_game), 菜单只管「按了哪个按钮」。
  • 世界是开局之后才加载的,所以 MultiplayerSpawner 的绑定时机跟着变了: Net.bind_spawner() 里要把已经连上的玩家补生成一遍,否则主机在菜单里就开好服、 切进世界时一个角色都不会出现。
  • Net 是 autoload,它的「联机测试」面板在 _ready() 里就 visible = true(CanvasLayer layer=20), 会盖在菜单正中间 —— 菜单调 Net.set_menu_visible(false) 收起来。

动画(AnimationPlayer)

  • 多 AnimationLibrary 下,片段必须用「库名/片段名」的限定键。实测(4.7.2): has_animation("idle") 为假,has_animation("idle/idle") 才为真,get_animation 同理。 本项目每个 glb 导入成一个库、库名与片段同名,所以真键形如 walk/walk。 短名播不出来且不报错,是典型的静默失效 —— PlayerAnimator._ready() 里扫库解析一次。
  • 导入的 GLB 片段 loop_mode 默认是 NONE,编辑器里改不了,只能运行时补。
  • 带 class_name 的脚本挂在 AnimationPlayer 上没问题,但不能写 self is AnimationPlayer: class_name X extends Node 之后 self 的静态类型就是 X,那句是编译期错误。 要拿「自己就是播放器」这个事实得绕一圈:遍历 get_parent().get_children() 找 is AnimationPlayer 的兄弟 —— 自己就在里面。见 PlayerAnimator._ready()。
  • 状态集 IDLE/WALK/CROUCH/RUN/FALL/SNEAK/JUMP。签名 set_motion(velocity, on_floor, sprinting, crouching): 第 4 个参数是「按住蹲键」这个输入事实(crouch 动作 = Ctrl),蹲着不动 → CROUCH、蹲着走 → SNEAK, 这个区分在表现层做,控制器不需要知道两者差别。优先级:空中 > 蹲 > 移动。

模型:mouse.glb

  • 2026-10-02 用户重建过模型:原来 body_mesh 的「lambert1 身体 + face 面部」两个材质槽已合并成 一个材质 mat_body,贴 mouse_body_basecolor.png(纯色底)。耳朵单独 ear_l_mesh/ear_r_mesh 用 mat_ear + mouse_ear_basecolor.png;手臂、腿、尾巴是另外的 mesh 节点(mat_arm/mat_leg/mat_body)。 旧的两张贴图 mouse_mouse_lambert1_BaseColor.png、mouse_表情测试.png 已被删除,别在代码或测试里再引用。
  • body_mesh 现在 1 个 surface、484 顶点、6 段 UV 孤岛,UV 整体落在 u[0.004,0.996] v[0.259,0.996]。 最高处那段孤岛(= 头部)的 UV 是 u[0.695,0.996] v[0.682,0.996];没有任何一段专门的「脸」孤岛, 头部是一整块连续展开。脸部矩形必须整块落在头部孤岛里,见 tests/test_face.gd 的 test_face_uv_rect_inside_head_island。
  • 量 UV 孤岛的办法:临时写一个 @tool extends McpTestSuite 的探针脚本,用并查集把 index buffer 连成 连通分量,再打印每段的 3D AABB + UV bbox;也可以把 UV 画成 64×32 的 #/. 占据图。跑完记得删掉脚本。

面部系统(character/player/face/ + ui/)

  • face_paint.gdshader:将 face surface 的 UV remap 到 0~1,再映射到内置图集 rect 或本地 PNG;贴图 alpha 只代表墨线覆盖率,混到 skin_color 上,不写空间 ALPHA(否则脸片变洞)。
  • mouse_mouse_lambert1_BaseColor.png 和 mouse_表情测试.png 的背景都精确为 RGB(137,179,255) / #89B3FF;表情图源 alpha 全 255,不能直接当透明度。
  • 最终做法:全身一个 shader,脸是「贴在身体正面的贴花」。face_paint.gdshader 用 body_tex(= mat_body 的 BaseColor)采样全身底色,再在 face_center / face_size 圈出的矩形里,把表情贴图(内置图集或本地 PNG)和玩家手绘按 alpha 叠上去。透明处保持身体底色,所以脸和周围不可能是两块不同的皮肤。FaceRig 把 mat_body 的 albedo_texture / albedo_color / roughness / metallic 抄进 shader,并用 set_surface_override_material(0, mat)(surface 0,不再是 1)。
  • 贴花用模型本地坐标,不用 UV:varying vec3 object_position / object_normal 在 vertex() 里取 VERTEX / NORMAL,fragment() 里用 object_position.xy 定位。这样和 UV 孤岛怎么排无关 —— 之前用 UV 矩形时,矩形一越界就会把表情糊到肚子/背上(头部孤岛和胸腹孤岛只差 0.008),换贴花后这类问题从根上没了。
  • 模型自己的正面是本地 -Z:character_select.tscn 的 Model 节点和 player.tscn 的 mouse 节点都把它转了 180°。所以正面遮罩写 -normalize(object_normal).z,写反了表情会跑到背上(踩过)。背面同 (x,y) 的点也会落进矩形,所以这个遮罩是必须的。
  • 不再需要 skin_color 当 shader 输入:FaceRig 反过来从身体贴图左上角取色写进 skin_color,只给画布和缩略图垫底用(导入贴图是压缩的,get_pixel 前要先 decompress(),否则报 "Can't get_pixel() on compressed image")。
  • 表情遮罩用 step() 相乘而不是 if:采样留在统一控制流里,免得 mipmap 导数在分支里未定义。
  • 仍然不能写空间 ALPHA:alpha 只代表墨线覆盖率,写进 ALPHA 会让面片变洞。
  • face_atlas_transparent.png 是我们自己的素材(从已删除的 mouse_表情测试.png 去蓝底、反混合出来的 1024² 副本),保留原图坐标排布;内置 ID builtin:face_01/02 对应各自的图集 rect。原始表情图已随模型删除,这个副本现在是唯一来源,别删。
  • 表情位置/大小/翻转只写在 face_paint.gdshader 顶部那段「调表情就改这一段」里:face_center(中心,模型本地坐标,模型大约 x -33、y -33,往下挪就减小 .y)、face_size(.x 宽 .y 高,默认 4.0×4.0)、face_front_edge(法线朝前的阈值,调大只留最正面)、face_flip_y(默认 true)。 FaceRig 故意不传这几个 uniform —— 早期版本从 @export 传过去,会把 shader 里调好的值盖掉;现在 face_rig.gd 里没有对应的 export,测试里有一条断言禁止再传。
  • 贴图第 0 行在上边,而头部 UV 的 v 朝上,所以默认必须 face_flip_y = true,否则表情上下颠倒(用户实测确认过)。
  • 内置表情在各自图集 tile 里的墨线占比:face_01 68.9%×74.4%、face_02 83.9%×65.4%,且都居中 —— 所以 face_size 要按「墨线只占 tile 的七成」来估:4.0 的矩形给出约 2.8 单位的墨线宽度。
  • test_face_decal_inside_body_bounds 校验贴花矩形落在模型包围盒里(模型换尺度/原点会红);test_face_shader_has_expected_uniforms 里钉住 -normalize(object_normal).z 和 object_position.xy,防止有人改回 UV 方案或写反正面。
  • 两个 FaceRig 节点(player.tscn 的 Player/FaceRig、character_select.tscn 的预览 FaceRig)都不带表情参数 —— 值只有一份,在 shader 里。
  • FaceRig 保存 user://face_preset.json 中的 expression ID 和 user://face_paint.png 暂存笔画;已保存的自绘表情用 user://face_library/index.json + 各自透明 PNG 持久化,角色选择页动态显示内置 atlas 表情和本地表情。
  • 联机当前表情同步:内置表情发送 ID;user: 本地表情发送当前 PNG 字节;笔画层另外发送。不广播整份本地表情库。
  • FacePainter:直接改 RGBA8 字节缓冲。画 = source-over alpha 混合,擦 = 把目标 alpha 乘掉;512² 一次 1MB,按一笔/动作同步一次。
  • 「自己画」= 空白底起手;保存到本地表情库成为独立项目,再次选择可加载。取消会恢复旧 expression ID 与笔画。
  • 界面链路:ui/main_menu.tscn →「开始游戏」→ ui/character_select.tscn(3D 预览 + 动态表情列表 + 自己画)→ ui/paint_panel.tscn(透明笔画层叠在肤色底上)。本地列表用 HFlow + ScrollContainer 显示。

Godot 4 踩坑

  • canvas 没有「擦除」混合模式:CanvasItemMaterial.BlendMode 只有 mix/add/sub/mul/premult, CanvasItem.draw_set_blend_mode() 在 4.x 已移除。橡皮只能自己管像素。
  • Image.set_data() 在 4.7 签名变了(要 5 个参数);改用 Image.create_from_data(w, h, mipmaps, format, data)。
  • 导出的节点引用解析不到:@export var mesh: MeshInstance3D 在 .tscn 里存成 NodePath, 实测场景实例化时拿到的是 null。改成 @export var mesh_path: NodePath + _ready() 里 get_node_or_null。
  • shader 里 source_color 只能用在 vec3 / vec4 / sampler 上,vec2 用了会编译失败。
  • 给容器(VBoxContainer 等)设 theme_override_styles/* 会向下继承给子按钮,不用逐个设。

Godot MCP 踩坑

  • 看画面:Read 工具读用户发的图片文件会报 Current model does not support reading images, 但 editor_screenshot(source="game") 返回的游戏画面能正常看 —— 排查画面问题直接自己截图对照, 比让用户描述快。没有画面时也可以用像素统计(PNG 手写解码 + 粗 ASCII 图)反推布局。

  • 用户在编辑器里开着的文件会覆盖磁盘改动:改完 face_paint.gdshader 后,磁盘上出现过 「我的新注释 + 旧的 +normal.z + 我从未写过的 face_center.y = 0.6」这种混合体 —— 用户在 Godot 里开着这个 shader、缓冲区是旧版本,他一保存就把我后来的改动冲掉了,而且没有任何提示。 所以改 .gdshader / .tscn 之后要在回复里提醒对方先关掉/重开那个文件再保存。

  • 数值标量经 MCP 会被字符串化:node_set_property 传 0.15 能写进去,但回显是 "0.15"; 而 batch_execute 里包 {"command": "set_property", "params": {...}} 直接报 INTERNAL_ERROR … handler returned malformed result(和旧版本文档里写的正好相反,以实测为准)。 改被编辑器占用的 .tscn / .tres 时别绕这个弯 —— 直接写文本更省事:.tscn 可写, .tres 报 EBUSY 就改用 filesystem_manage(op="write_text")。

  • node_get_properties 读不到脚本的 @export 属性(会列进 unknown_fields),但 node_set_property 能写。 改完场景文件要 scene_open(force_reload=true) 让编辑器重读盘,否则节点上还挂着旧脚本实例。

  • @tool 脚本现造的节点不会写进 .tscn:add_child 后不给 owner, PackedScene::pack() 就会跳过它。缆绳网格因此可以纯由锚点推导,不存在「文件和锚点对不上」的脏数据。

  • 新 class_name 要跑一次 filesystem_manage(op="scan") 才进全局类表, 否则别的脚本把它当类型引用会解析不到。

  • game_manage(op="suspend") 期间输入事件不送达,next_frame 也拦不住物理帧; 想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。

  • 测试跑在编辑器进程里,@tool 节点必须真的 add_child 进编辑中场景才会跑 _ready(); 用完在 teardown() 摘掉,节点名以 _McpTest 开头可被测试运行器兜底清理。

  • 非 @tool 脚本在编辑器里是「placeholder instance」:load(path).can_instantiate() 返回 false, .new() 报 Nonexistent function 'new' in base 'GDScript',set_script 后调方法报 Attempt to call a method on a placeholder instance(player.gd 也一样,不是脚本本身的问题)。 想在 test_run 里验非 @tool 脚本,就临时生成一个「去掉 class_name、加上 @tool」的副本喂给测试,跑完删掉。

  • test_run 的 GDScript 编译缓存是按路径的:删掉再重建同名脚本,跑到的可能还是旧版本 (现象:逻辑明明改了,测试却按旧逻辑失败)。要验新代码就换个文件名,或者重启编辑器。

  • 编辑器进程的 InputMap 只有内建动作(ui_accept 有,interact / jump 没有)—— 项目动作要等游戏进程启动时才 load 进去。所以断言 InputMap.has_action(...) 的测试在编辑器里必然假红, 先调一次 InputMap.load_from_project_settings()。

  • 改输入动作走 input_map_manage(op="ensure_binding", params={action, event_type="key", keycode="Ctrl"}), 幂等且直接落 project.godot,比手改文件稳。