Files
agame/.workbuddy/memory/2026-09-23.md
T
2026-09-23 17:11:13 +08:00

235 lines
15 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.
# 2026-09-23
## 玩家动画(KayKit character-male-e 动作库)
用户把玩家模型换成 `character/player/assets/character-male-e.glb`,并在
`Player/Body/character-male-e2/` 下加了 `AnimationPlayer`(动作库内嵌在 player.tscn 里,
共 32 个片段,11 条 track,root_node = `..`)。
### 关键结构事实(别猜,实测)
- track 路径形如 `character-male-e/Skeleton3D:root`,比玩家本体多一层
`character-male-e/` 前缀 —— 换模型/改层级会让整组 track 静默失效。
- glb 里所有片段的 `loop_mode` 全是 `NONE`,`idle/walk/sprint` 必须在运行时补
`Animation.LOOP_LINEAR`,否则播一遍就定在最后一帧。
- 片段长度:idle 1.333s / walk 0.667s / sprint 0.5s / jump 0.5s / fall 0.333s。
jump 时长恰好约等于 `jump_velocity=5 / gravity=9.8` 的上升时间。
### 实现(`character/player/player.gd`)
- `enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }` + `ANIM_CLIPS` / `ANIM_LOOPED` 常量。
- `_prepare_animation()` 在 `_ready` 里、**本地/远端分支之前**调用(两端都要跑)。
- `_update_animation()` 在 `_physics_process` 末尾调用,只读 `velocity` / `is_on_floor()`。
- 两个被同步的新属性:
- `anim_state: int` —— setter 里切片段(`play(name, anim_blend_time)`),
状态不变时 setter 不触发,所以 `_prepare_animation` 里必须显式 `play("idle")`。
- `anim_speed: float` —— 播放倍率 = `水平速度 / 该状态设计速度`(walk→walk_speed,
sprint→sprint_speed),clamp 到 `[0.65, 1.6]`。远端只有 position 算不出速度,必须复制。
- 远端玩家物理与输入都关着,动画完全靠复制过来的 `anim_state` / `anim_speed` 回放。
### 同步配置
`character/player/player_sync.tres` 从 3 项扩到 5 项,新增
`.:anim_state` 与 `.:anim_speed`,`replication_mode` 全部保持 1 (ALWAYS)。
## 踩到的坑(可复用)
1. **编辑器进程里 `.tres` 有 ResourceCache。** 用 MCP `filesystem_manage(op="write_text")`
改完 `player_sync.tres` 后,`filesystem_manage(op="scan")` 也刷新不掉缓存 ——
`test_run` 里的 `load()` 仍读到旧的内存副本(只认 3 个字段)。
→ 测试里改用 `ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)`;
它同时把正确的实例灌回缓存,之后再 `node_set_property` 赋值就拿到新实例了。
2. **`Animation` 的 track 路径含 `:骨骼名`,不能直接 `get_node(track_path)`**
(get_node 只认节点段)。要用
`root.get_node(NodePath(track_path.get_concatenated_names()))`,
骨骼名单独用 `get_concatenated_subnames()` + `Skeleton3D.find_bone()` 校验。
3. **`game_eval` 的真实签名是 `{"op": "game_eval", "params": {"code": "..."}}`**,
不是顶层 `code`。它支持多语句 + 内部 `await`,所以能在编辑器跑着的游戏里
直接 `net.host_game()` → 等物理帧 → 驱动状态机 → 读回结果,是最强的端到端验证手段。
4. 编辑器报告的 `play_state/is_playing=playing` 可能是陈旧的(`project_manage(op="stop")`
回「was_running: false」即证)。
## 顺带修的东西
- `tests/test_net_replication.gd` 之前就坏了:路径还写着 `res://player/`(项目已迁到
`res://character/player/`),且断言同步字段数为 2(实际 3)。已修正路径、改为断言
5 个必需字段,并新增「片段存在」「5 个片段的每条 track 路径都能解析 + 骨骼名存在」两类测试。
现在 5 个测试全过(143 条断言)。
## 未处理的既有问题
`res://addons/srcoder_thirdperson_controller/assets/mouse_lock.gd` 与 `res://mouse_lock.tscn`
缺失,编辑器日志持续刷 `File not found`(约 60 条)。与本任务无关,未动。
## 模型朝向修正(按 W 却面朝相机)
**原因**:`player.gd` 的约定是「`body.rotation.y = 0` 即朝 -Z」(按 W 时
`wish=(0,0,-1)` → `target_yaw = atan2(-wish.x,-wish.z) = 0`)。
但 KayKit 的 `character-male-e.glb` 模型正面朝 **+Z**,
场景里 `Body / character-male-e2 / character-male-e` 三个节点的 rotation 全是 0,
没有做任何补偿 → 视觉上倒着走。
**修法**:给模型包装节点 `Body/character-male-e2` 加 `rotation.y = PI`(已 `scene_save`,
磁盘上是 `transform = Transform3D(-2.5, 0, -2.19e-7, 0, 2.5, 0, 2.19e-7, 0, -2.5, 0, 0, 0)`)。
**为什么不是改代码**:
- 往场景里给 `Body` 加 180° 没用 —— `body.rotation.y` 每帧被 `lerp_angle` 覆写。
- 往 `target_yaw` 上加 PI 会污染 `Body` 的语义(`Body:rotation` 是要同步的「世界朝向」),
而且静止时的初始朝向还得额外补一笔。
- 加在模型根节点上:代码里的 -Z 约定保持干净,静止与移动两种状态同时正确。
- 动画 track 是 `character-male-e/Skeleton3D:骨骼` 的相对路径,旋转包装节点不会破坏它们。
**验证**(`game_eval`,实测通过):`Input.action_press("move_forward")` 等 45 物理帧,
用左右手臂骨算世界空间的 model forward(`Vector3.UP.cross(右轴)`),结果:
- `move_dir` = (0, -1)、`model_fwd` = (0.05, -1.0) → `dot = 0.999`
- 与相机前向 `dot = 0.999` → 看到的是角色背面 ✅
- `body_rot_deg = 0`、位移 3.51 m(0.75s × 5 m/s)✅
## 重构:动画状态机抽成 PlayerAnimator(与移动解耦)
用户先问「动画状态机和玩家代码耦合吗」。结论:是**结构性耦合**(5 个点),但只有**单向**
—— `_update_animation()` 只读 `velocity`/`is_on_floor()`/`Input`,从不回写物理量。
真实代价只有一条:这套状态机没法给 NPC 复用。用户选择抽成独立组件。
**新结构**(`character/player/player.tscn`):
Player (CharacterBody3D, player.gd) ← 输入 / 物理 / 相机 / 血条 / 联机分叉
└─ Body ← 视觉层,换模型整棵替换
├─ PlayerAnimator (Node, player_animator.gd) ← 动作状态机
└─ character-male-e2 / character-male-e / Skeleton3D + AnimationPlayer
**接口只有一个**:`animator.set_motion(velocity, is_on_floor(), sprinting)`,在
`move_and_slide()` 之后调用。控制器不认识任何片段名,动画器不认识输入。
动画准备(补 loop、播 idle)搬到 `PlayerAnimator._ready()` —— 子节点先于父节点 ready,
所以 player.gd 的 `_ready()` 里不再需要任何动画代码。
**关键设计决定:片段基准速度属于片段,不属于控制器。**
`PlayerAnimator.walk_ref_speed = 5.0` / `sprint_ref_speed = 8.5` 语义是「这段动画在多少
米/秒下按原速播」。控制器把 `walk_speed` 调到 6,动画自然播成 1.2 倍,两边不用同步改 ——
这才是真正解掉了耦合,而不是把常量搬个家。
**同步路径变了**:`Body/PlayerAnimator:anim_state` 与 `Body/PlayerAnimator:anim_speed`
(`Sync.root_path` 仍是 `..` 即 Player)。`anim_state` / `anim_speed` 现在是
PlayerAnimator 的成员,player.gd 里已无这两个属性(测试里有一条专门守住这个边界)。
**`animation_player` 接线**:声明成 `@export var animation_player: AnimationPlayer`,
场景里留空(原因见下),`_ready()` 用
`get_parent().find_child("AnimationPlayer", true, false)` 自动找。实机确认能解析。
**player.gd 306 → 206 行**;测试 5 → 7 条 / 154 断言,全过。
**实机验证**(game_eval,跑着的游戏里):
- `animator_node = /root/Main/1/Body/PlayerAnimator`,`auto_find_resolved = true`
- loop 模式 `[idle, walk, sprint, jump] = [LINEAR, LINEAR, LINEAR, NONE]`
- idle / walk@2.5(0.65) / walk@5(1.0) / sprint@8.5(1.0) / jump / fall 全部正确
- `controller_drives_animator = [WALK, "walk", 1.0]` —— 控制器→动画器链路真的在跑
## 本轮新增的 MCP 坑
1. **`node_set_property` 设不了 Node 类型的属性。** 传
`/Player/Body/character-male-e2/AnimationPlayer` 会报
`VALUE_OUT_OF_RANGE: value: Path must start with res://, uid://, or user://`
—— 该工具只按「字符串=资源路径」加载。所以 `@export var x: AnimationPlayer`
在场景里只能留空、靠代码兜底(或改用 `NodePath` 类型 export,那个能收字符串)。
2. **`project_run` 会把编辑器当前场景切成主场景。** 跑完游戏再 `node_create` 会报
`NODE_NOT_FOUND: Parent not found: /Player/Body. Scene root is "/Main"`。
写场景节点前先 `scene_open("res://character/player/player.tscn")`。
3. **删掉某个脚本里的 enum/常量后,所有引用处会立刻刷一屏 parse error。**
分步 patch 时这是正常的中间态,把引用清完 diagnostics 自动归零 —— 别当成自己写错了。
4. **中文字符串用 `\uXXXX` 转义极易打错字**(本轮把「抽搐」打成「抽搝」、「照抄」打成
「照拄」,都要事后 grep 才发现)。直接传字面中文更稳。
## 海洋效果场景(孤岛环海 · 纯视觉)
新增 `world/ocean/`:
| 文件 | 作用 |
|---|---|
| `water.gdshader` | 卡通海面:底色 / 菲涅尔 / 波光 / 泡沫 + 地平线雾霭 |
| `island.gdshader` | 卡通小岛:径向剖面 + 噪声海岸线,按高度与坡度上色 |
| `water_far_material.tres` | 远平面材质(同一个 shader,`swell_amount = 0`) |
| `ocean_demo.gd` | 驱动脚本:推 world_time / 太阳方向 / 相机环绕 / 近平面跟随 |
| `ocean_demo.tscn` | 效果场景,**F6 直接跑**(会把 Net 的联机菜单整层藏掉) |
## 关键设计(都是被逼出来的)
- **两层水面:近处细平面(520×520 / 208 细分 / 跟随相机)+ 远处大平面(6000×6000 / 12 细分 / 纯平)。**
均匀网格做不了「近处要密」和「覆盖到地平线」这两件事。而天空在地平线以下是
一片深色半球,**有限大的水面与真地平线之间必然露出深色带**(带宽 ≈ atan(相机高 / 平面半宽))。
两层结构把带宽压到 0.25° 以内。近平面网格对齐到顶点间距(2.5m),否则会抖。
- **波形用世界坐标锚定**(vertex 里先 `MODEL_MATRIX * VERTEX` 取世界 xz 再算高度)。
这样近平面跟着相机平移时,海浪不会「跟着滑」。
- **位移只用 3 组低频正弦(幅度 0.38m),细纹 + 噪点只动法线、不给位移。**
所以顶点密度只影响大浪的圆滑度,细节全在法线里 —— 这是让粗网格也能看的关键。
- `world_time` 由脚本推进,不用内置 `TIME`:联机两端启动时刻不同,同一时刻的浪不是同一片。
脚本里用「绝对时钟差」而不是逐帧累加 delta(帧率不同会越走越偏)。
- 岸线泡沫靠 `DEPTH_TEXTURE` 拿真实水深(水是半透明的,走透明队列、在不透明物之后绘制)。
## 踩的四个坑
1. **`TAU` / `PI` / `E` 是着色器语言的内置常量。** 自己 `const float TAU` 直接编译失败。
更坑的是:**编译失败的外观是「没着色器的一团白」**,看起来像参数没调好,会往错的方向查。
2. **只有正弦、没有噪声的法线 → 太阳波光变成一张规则网格。**
高光落在「法线刚好平分视线与阳光」的位置,周期性法线场的高光自然是周期性的。
3. **打散它的噪声必须用梯度噪声,不能用值噪声。** 值噪声当「高度」用看不出来,
但拿来求导当法线就是一块块方格。(卡通天空挑 Perlin 也是这个理由,直接沿用。)
4. **片元阶段的 `VERTEX` 是视图空间,不是世界空间。** 小岛 shader 一开始写
`(MODEL_MATRIX * vec4(VERTEX, 1.0)).y` 当海拔用,算出来其实是相机相对高度 ——
整座岛被染成一大块沙滩,而且「看上去好像也对」。改成 varying 从顶点传海拔。
另外:两组正弦方向**接近平行**时会相消,把整层退化成 1D 驻波(表现为规则条纹)。
方向要显式排开、夹角 90° 以上,别拿「看起来不一样」的坐标去拼。
## 调参结论(实测)
- 浪尖泡沫的分母必须是**三组振幅之和**;拿 `wave_height` 当分母永远溢出到 1,泡沫铺满海面。
- 岸线泡沫 `shore_amount` 别超过 ~0.7,否则是一圈白色描边(像贴纸不像浪)。
- `band_count` = 3 是「有色块感」与「不变斑马线」的平衡点。
- 细纹和噪点必须**按距离淡出**(`ripple_fade_distance`),不然远处满屏闪烁。
## MCP 相关
- 编辑器在 play 模式下**拒绝一切写操作**(`EDITOR_NOT_READY`)→ 改文件前先
`project_manage(op="stop")`。
- `.gdshader` 只能用 `filesystem_manage(op="write_text")` 写,**没有 patch 能力**,
每次改都要重写整个文件。
- `node_set_property` **设不了 `shader_parameter/*`**(报 `PROPERTY_NOT_ON_CLASS`,
这些是虚拟属性)。要么改 shader 里的默认值,要么写一个 `.tres` 引用它。
- 但 `node_set_property` **支持内嵌资源 + 嵌套资源路径**:
`{"__class__": "Environment", "background_mode": 2, "sky": "res://..."}` 是可行的。
- 最强的验证手段是 `editor_screenshot(source="game")` —— 把运行中的画面截回来直接看,
比自己推断「应该没问题」可靠得多。三个真 bug 全是靠它发现的。
## 既有问题(未动)
`res://addons/ocean3d_lite/` 是坏的:`ocean_surface.gd` / `floating_body.gd` 都报
`Identifier "Ocean" not declared`。看起来是以前试过的第三方海洋插件,与本次无关。
## 海洋 · 补充(回答「海是不是静态的」时顺手修的)
**海是动的**,只是编辑器预览里曾经完全冻结:
- 实测运行时 `world_time` 1.5 秒走 1.502(`time_scale = 1.0`),波形确实在推进。
- 但 `_ready()` / `_process()` 在编辑器里不跑 → 没人推 `world_time` → **未运行时海是死帧**。
这就是「看着像静态」的原因。
- 修法:`world_time` 默认值改成 `-1`,加 `wave_time()`:`world_time >= 0 ? world_time : TIME`。
编辑器用 TIME(自动动),运行时脚本一接管就换成同步时钟。
**比加 `@tool` 安全** —— `@tool` 会让 `_process` 在编辑器里跑,脚本一写相机 transform
就不断标脏场景、还会挪动编辑器里的摆位。
- 同理把静态相机取景(`position` + `rotation = (-0.235, 0.96, 0)`)烘进场景,
否则编辑器预览里相机朝向是 identity,看着是一片空海。
**新发现的 Godot 行为:`ShaderMaterial.get_shader_parameter()` 对没显式改过的参数返回 `null`。**
Godot 不会把 shader 的 uniform 默认值灌进材质实例 —— 所以别用它读默认值。
反过来也证明「只改 shader 默认值就能生效」。
**编辑器视口会持续重绘**(连拍两张 `editor_screenshot(source="cinematic")`,波形和云都位移了),
所以能用截两张对比的办法验证「编辑器里动没动」。
海水运动速度(按默认值算):大浪相位速度 ≈ 6.7 m/s(波长 42 m),
细纹漂移 ≈ 0.5 m/s,噪点漂移 ≈ 0.49 m/s。大浪看得清,细碎波光是缓慢流动。