166 lines
19 KiB
Markdown
166 lines
19 KiB
Markdown
# 项目长期记忆
|
||
|
||
## 工程约定
|
||
|
||
- **`CLAUDE.md` 里写着「游戏内测试交给用户来,不要自己做」** —— 别用 `project_run` + 截图去自己验游戏,
|
||
编辑器里的 `test_run` 照常跑。画面问题让用户看,或者用数值探针验。
|
||
- **玩家根节点 `Player` 有意缩放 0.2**(用户明确说「就是想将玩家缩小到 0.2 倍」):胶囊 0.36m、相机 0.32m、
|
||
模型 0.23m。挂在玩家下的东西(`CameraRig/CarrySocket`、`Body/*`)**本地坐标会自动跟着缩**,所以抱持点不用改;
|
||
但**世界单位**的参数没跟着缩:`walk_speed/sprint_speed/jump_velocity`、`pickup_radius`、
|
||
滑索的 `attach_radius/hang_offset`、物品的 `throw_speed_*`、示例箱子尺寸(0.6m 比玩家还高)都还是老尺度。
|
||
- **滑索的曲线在「本节点局部空间」算**(`_curve_local()`),`sample/tangent/ride_point` 再转成世界 ——
|
||
父节点带缩放时整条缆一起缩、形状才不走样(早前混用 global 锚点 lerp + 未缩放的 sag,父节点一缩放
|
||
tangent 就歪 27°)。测试夹具同理**别挂在编辑中场景根下**:玩家的 0.2 缩放会把滑索几何带偏
|
||
(实测中点下垂 1.2 → 0.24)。滑索测试现在挂在编辑器根节点下的中性父节点里,另有一条
|
||
`test_shape_survives_parent_scale` 专门钉住缩放行为。
|
||
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `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 -3~3、y -3~3,**往下挪就减小 .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`(**不能带负号**,正面是本地 +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 显示。
|
||
|
||
## 抱起物品(world/items/ + character/player/carry_arms.gd)
|
||
|
||
- **门槛在物品自己身上**:物品根节点(RigidBody3D)挂 `Carryable`(`class_name`,进 `carryable` 组)才抱得起来,
|
||
玩家侧不认识任何具体物品。手感和滑索一个套路 —— `hold_offset` / `hold_rotation_deg` / `throw_speed_min/max`
|
||
都写在物品上,玩家只负责「找最近的、摆到抱持点、扔出去」。
|
||
- 示例物品:`world/items/carryable_box.tscn`(0.6³ 橙箱子),世界场景里放了两个。加新物品只要挂脚本。
|
||
- 玩家侧只有 `_find_carryable / _pick_up / _release_throw` 三个函数;**交互提示和按键判定同源**
|
||
(优先级:手上有东西 → 可抱物 → 缆上 → 可挂的缆),别再拆成两处。
|
||
- 抱持**不 reparent**:`Carryable.carried_by` 记状态,持有者每物理帧 `apply_hold_transform(socket)`。
|
||
抱起来时冻结物理 + **collision_layer 清 0**(不清会把玩家顶飞),放下时恢复并继承初速度。
|
||
- 输入动作:`carry` = 鼠标左键、`throw_carry` = Q(长按蓄力,松手出手)。鼠标没锁定时左键仍然只用来重新锁定视角。
|
||
- 抱东西时禁疾跑 + `carry_speed_scale` 降速;**抱着不给挂滑索**(`_attach_zipline()` 开头直接 return)。
|
||
- 抱持点 `CameraRig/CarrySocket`(Marker3D,本地 (0,-0.8,-1.0))。物品位置要跟着它走。
|
||
- `CarryArms`(挂在 `Body/CarryArms`)是**表现层**:`process_priority = 100` 保证跑在 AnimationPlayer 之后,
|
||
在动画姿态上**叠加**手臂旋转(只动 `arm_l_*` / `arm_r_*`),走路时胳膊和抱持点一起晃。
|
||
**参数按「世界方向」语义**(`rest_basis.inverse() * world * rest_basis` 换算到骨骼本地空间)——
|
||
直接写骨骼本地欧拉角会得到「手往上后方摆」这种莫名其妙的结果,换模型必错。
|
||
默认 swing 40 / tuck 50 / roll 0 / elbow 15 / stretch 1.25 → 手落在模型本地 (±1.17, 0.08, 0.95)。
|
||
- 本地玩家第一人称只藏身体、**留下两条胳膊**(`_hide_body_keep_arms()` 按 `arm_` 前缀筛,找不到就整个藏掉)——
|
||
抱东西时得看得见自己的手。
|
||
- **联机只同步 `carrier_peer` 一个整数**(物品自己带 `MultiplayerSynchronizer`,配置 `world/items/carryable_sync.tres`)。
|
||
**抱着的时候位置根本不走同步**:每一端都从「持有者的抱持点」把物品摆出来(`Carryable._physics_process` →
|
||
`_find_carrier_socket()`,靠玩家节点名 = peer id、组 `net_player` 找到),结果天然一致,
|
||
也避免了「持有者写位置 / 同步又把位置写回来」互相打架。空着的时候才同步 position/rotation。
|
||
- **权威永远在服务器**(不切换):物品位置只由服务器模拟,其余端 `freeze = true` 跟着同步走;
|
||
抱起/放下都是客户端 `rpc_id(1, ...)` 请求服务器,服务器改 `carrier_peer` 就完事。
|
||
- 抱起是乐观认领 + 超时松手:`_pick_up()` 先把自己记成持有者,`pickup_confirm_time`(默认 0.6s)内
|
||
服务器没把 `carrier_peer` 改成自己就自动松手 —— 联机下被别人先抢走时会走到这一步。
|
||
- 测试 `tests/test_carry.gd`:`Carryable` / `CarryArms` 都标了 `@tool`,否则编辑器里是 placeholder、
|
||
方法调不动、`_ready` 不跑(组也加不上);改完脚本要 `filesystem_manage(op="scan")` 才重新解析。
|
||
玩家脚本不是 @tool,所以「第一人称留胳膊」那条测试查的是源码 + 模型里真有 `arm_*` mesh。
|
||
|
||
## 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 脚本,**最省事的做法是直接给脚本加 `@tool`**(`Carryable` / `CarryArms` 就是这么办的,
|
||
运行时行为完全一样),其次才是临时生成一个 @tool 副本喂给测试。
|
||
加完要 `filesystem_manage(op="scan")` 一次,否则编辑器还在用旧的非 tool 版本,照样报 placeholder。
|
||
- **`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`,比手改文件稳。
|