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

8.8 KiB
Raw Blame History

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