16 KiB
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)。
踩到的坑(可复用)
- 编辑器进程里
.tres有 ResourceCache。 用 MCPfilesystem_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赋值就拿到新实例了。 Animation的 track 路径含:骨骼名,不能直接get_node(track_path)(get_node 只认节点段)。要用root.get_node(NodePath(track_path.get_concatenated_names())), 骨骼名单独用get_concatenated_subnames()+Skeleton3D.find_bone()校验。game_eval的真实签名是{"op": "game_eval", "params": {"code": "..."}}, 不是顶层code。它支持多语句 + 内部await,所以能在编辑器跑着的游戏里 直接net.host_game()→ 等物理帧 → 驱动状态机 → 读回结果,是最强的端到端验证手段。- 编辑器报告的
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 坑
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,那个能收字符串)。project_run会把编辑器当前场景切成主场景。 跑完游戏再node_create会报NODE_NOT_FOUND: Parent not found: /Player/Body. Scene root is "/Main"。 写场景节点前先scene_open("res://character/player/player.tscn")。- 删掉某个脚本里的 enum/常量后,所有引用处会立刻刷一屏 parse error。 分步 patch 时这是正常的中间态,把引用清完 diagnostics 自动归零 —— 别当成自己写错了。
- 中文字符串用
\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拿真实水深(水是半透明的,走透明队列、在不透明物之后绘制)。
踩的四个坑
TAU/PI/E是着色器语言的内置常量。 自己const float TAU直接编译失败。 更坑的是:编译失败的外观是「没着色器的一团白」,看起来像参数没调好,会往错的方向查。- 只有正弦、没有噪声的法线 → 太阳波光变成一张规则网格。 高光落在「法线刚好平分视线与阳光」的位置,周期性法线场的高光自然是周期性的。
- 打散它的噪声必须用梯度噪声,不能用值噪声。 值噪声当「高度」用看不出来, 但拿来求导当法线就是一块块方格。(卡通天空挑 Perlin 也是这个理由,直接沿用。)
- 片元阶段的
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_time1.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。大浪看得清,细碎波光是缓慢流动。
海洋应用到主场景(world/main.tscn)
world/ocean/sea.tscn+sea.gd:可复用海面(Water 近平面 300/120 格 + WaterFar 6000/12 格 + water_far_material.tres),实例化即用、零接线。sea.gd 自动收集带world_timeuniform 的材质、自动找Sun节点、近平面按 2.5 m 步长跟随 viewport 相机。world/ocean/island_mesa.tres:台地岛材质(island.gdshader 新增plateauuniform,平顶半径占比;默认 0 = 原锥形岛,向后兼容)。island.gdshader:新增 plateau;主场景取值 radius 40 / plateau 0.72 / height 1.5 / dip 14 / detail 0.25。main.tscn重写:Ground 碰撞盒 40→58(与平顶 r≈28.8 对齐),删除 GroundMesh(灰色平板,会和岛平顶 z-fight),新增Island(y=-1.5)与Sea实例(y=-1.5)。- 关键坐标系:海平面节点摆在 y=-1.5,岛
island_height=1.5→ 平顶世界 y=0 = 原地面高度。玩家/碰撞/联机逻辑零改动,实测 player_y=0、on_floor=true。 - 修了一个真 bug(两个场景都有):「指向太阳」是平行光节点的 +Z 不是 -Z。之前 sea/ocean_demo 传的是 -basis.z(朝下),波光整片消失且不报错。实测 main 的 Sun basis.z y=+0.766 才是对的。
- 验证:实机 host_game,world_time 1.2 s 推进 1.201,近平面跟随相机 (0,3.86)→snap(0,5),玩家传送 (27.5,1,0) 仍站在平顶 y≈0,岸线沙带/泡沫/海天交界截图正常,日志无错误。