235 lines
15 KiB
Markdown
235 lines
15 KiB
Markdown
# 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。大浪看得清,细碎波光是缓慢流动。
|
||
|