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

110 lines
12 KiB
Markdown
Raw 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.
# 项目长期记忆
## 工程约定
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `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,脸部靠 UV 矩形圈出来**。`face_paint.gdshader` 用 `body_tex`(= `mat_body` 的 BaseColor)采样全身底色,再在 `face_uv_min` / `face_uv_size` 圈出的矩形里,把表情贴图(内置图集或本地 PNG)和玩家手绘按 alpha 叠上去。透明处保持身体底色,所以**脸和周围不可能是两块不同的皮肤**。`FaceRig` 把 mat_body 的 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 抄进 shader,并用 `set_surface_override_material(0, mat)`(surface 0,不再是 1)。
- 不再需要 `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`(中心,UV 单位,**往下挪就减小 .y**)、`face_size`(.x 宽 .y 高)、`face_flip_y`(默认 true)。
**FaceRig 故意不传这三个 uniform** —— 早期版本从 `@export` 传过去,会把 shader 里调好的值盖掉;现在 `face_rig.gd` 里没有对应的 export,测试里有一条断言禁止再传。
- 头部孤岛实测:u 0.695~0.996(左右)、v 0.682~0.996(上下,**v 越大越靠头顶**)。贴图第 0 行在上边,所以默认必须 `face_flip_y = true`,否则表情上下颠倒(用户实测确认过)。
- **矩形中心 ± 半个大小不能溢出头部孤岛**:正下方还有一段孤岛(u 0.695~0.935 / v 0.293~0.674),量下来是胸腹那一带,溢出去表情就会糊到肚子上。`tests/test_face.gd` 的 `test_face_rect_inside_head_island` 会直接从 shader 源码里读 `face_center` / `face_size` 再判定,**所以随手调这两个值不会被测试误报,只有溢出才会红**。
- 两个 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 踩坑
- **数值标量经 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`,比手改文件稳。