Files
agame/.workbuddy/memory/2026-09-23.md
T
2026-09-23 16:39:12 +08:00

144 lines
8.8 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 才发现)。直接传字面中文更稳。