diff --git a/.workbuddy/memory/2026-10-14.md b/.workbuddy/memory/2026-10-14.md new file mode 100644 index 0000000..be4fce0 --- /dev/null +++ b/.workbuddy/memory/2026-10-14.md @@ -0,0 +1,142 @@ +# 2026-10-14 + +## shader/toon_apply.gd 自动刷新可行性咨询(未改动任何文件) + +- 项目 Godot 4.7 Forward+。 +- 现状:`toon_apply.gd` 是 @tool Node3D,`_apply_materials()` 对每个子 MeshInstance3D `duplicate()` 一份 ShaderMaterial 并 `material_override`。 +- 结论: + 1. 保存/编辑 `toon_apply.gd` 本身 → 编辑器热重载 @tool 脚本会重跑 `_ready()`,已能自动刷新(4.3 有热重载失效 bug,4.4+ 已修,本项目 4.7 无此问题)。 + 2. 编辑 `shader/toon.tres` 的 shader 参数(color / uv_scale 等)→ **不会**自动刷新。原因:ShaderMaterial 的 `Resource.changed` 仅在 resource_name 变化或 shader 本身变化时发出,参数改动不发出(官方文档明确);且每网格是 duplicate 的副本,与模板已无关联。 + 3. 若要 2 也自动刷新,需 EditorPlugin 监听 `EditorInterface.get_inspector().property_edited`(或 EditorFileSystem),回调里调用 `_apply_materials()`。 +- 备选架构:改用 `GeometryInstance3D.set_instance_shader_parameter()` 的 per-instance uniform,可不 duplicate、直接共享 toon.tres,改参数即实时生效;但限制是**不支持贴图/数组**、每 shader 上限 16 个。本项目需要 per-mesh 的 `base_texture`,因此该方案只能覆盖 color 之类的标量/向量参数,贴图仍需 duplicate 或走 Texture2DArray + index。 + +## 追加:评估 per-instance uniform + Texture2DArray 方案(结论:不适合本项目) + +- 实测贴图尺寸(脚本扫 PNG 头):全项目 289 张 PNG / 24 种尺寸。 + - `assets` 180 张:1024×1024 ×108、512×512 ×40、4096×4096 ×25、2048×2048 ×5 + - `assets/自建资产` 18 张:4096×4096 ×13(地板、地毯 PBR 套图) + - `character` 81 张:32×32 ×70、512×512 ×4、1024×1024 ×1 + - `demo` 28 张:15 种尺寸混杂 +- 结论:Texture2DArray 要求所有层同尺寸/同格式/同 mipmap,只能统一到 4096(单层带 mipmap 约 85 MiB 无损 / 21 MiB VRAM 压缩),几百张即 GB 级显存,且需额外打包流水线 → **per-instance uniform 路线不划算**,除非贴图少且尺寸统一。 +- 推荐路线(未实施): + - A:保留 duplicate,加 EditorPlugin 监听 `EditorInspector.property_edited`,命中 toon.tres 时重刷 `_apply_materials()`(可加防抖)。 + - B:把公共外观参数(color/specular/fresnel/normal_scale/uv_scale/uv_offset)提升为 `global uniform`(项目设置 → Shader Globals 定义),调参全局实时生效、无需重刷;贴图与 per-model 覆盖仍留在 duplicate 材质上。代价:需改 cel-shader-base.gdshader / outline.gdshader 并清掉 toon.tres 里的同名参数;全局值会写入 project.godot,需团队一起提交。 + +## 已实施:路线 A(新增 addons/toon_refresh 编辑器插件) + +用户选择 A。改动: +- 新增 `addons/toon_refresh/plugin.cfg` + `plugin.gd`(EditorPlugin)。 +- `project.godot` 的 `[editor_plugins] enabled` 追加 `res://addons/toon_refresh/plugin.cfg`。 + +实现要点: +- 监听 `EditorInterface.get_inspector().property_edited`,命中 `res://shader/toon.tres` 或其内联子资源时,0.2s 防抖后遍历当前编辑场景,对脚本文件名为 `toon_apply.gd` 且有 `_apply_materials` 的节点调用该方法。 +- 另加 项目 > 工具 菜单项「刷新 toon 材质(当前场景)」作手动兜底。 +- 只影响当前编辑场景;不覆盖手动改过的单个材质副本。 + +已实测确认的事实(重要,别再靠猜): +- **`toon.tres` 的 `next_pass` 内联子资源,`resource_path` 不是空串**,而是 `res://shader/toon.tres::ShaderMaterial_m5gih` 这种带 `::` 后缀的形式 → 匹配逻辑用 `begins_with(TARGET + "::")`。 +- `ShaderMaterial.changed` **不会**因 `set_shader_parameter()` 触发;因改 `resource_name` 触发(与官方文档一致)。 +- 编辑器路径:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64.exe`(另有 mono 版)。可用 `--headless --editor --path E:/Godot/agame --quit` 做插件加载冒烟测试;用 `--check-only --script res://xxx.gd` 做语法校验。 +- 该项目 headless 启动时 godotsteam / limboai 的 GDExtension DLL 会报加载失败(既有问题,与本次改动无关)。 + +项目相关:`[shader_globals]` 里已有 diffuse_curve、specular_smoothness、fresnel_smoothness、outline_width、outline_color —— 描边与光照曲线本来就是全局 uniform,调参实时生效;真正需要重刷的是 toon.tres 上的材质级参数。 + +## 修复:USE_NORMAL_MAP=1 时"平视全场景变暗、俯瞰才亮" + +用户报告的症状。根因是 `cel-shader-base.gdshader` 里的一行: +`NORMAL = normal.vector;`(原第 121 行)——`normal.vector` 是**切线空间**的法线贴图值, +而 Godot 4 的 `fragment()` 里 `NORMAL` 是**视图空间**(官方文档明确写 +"inout vec3 NORMAL ... in view space")。把切线空间值写进视图空间的 NORMAL, +等于让所有像素的法线都指向"视图空间 +Z"≈ 朝向相机的一个常量 → +整个场景明暗只由相机朝向决定:平视时与光方向近乎垂直/反向 → 全黑且无明暗起伏; +俯瞰时与向下打的光对齐 → 恢复正常。`normal_map_fragment()` 里算好的正确视图空间法线 +`ref_normal` 被算出来后就丢掉了(死代码)。 + +连带问题:`normal_map` 这个 uniform 从来没被赋过值(toon_apply.gd 只抓 base_texture), +采样到引擎默认纹理后算出的法线同样是垃圾。main2.tscn 里 413 个 toon_apply 节点刷出的 +434 个网格中,430 个都没有法线贴图。 + +改动: +- `shader/includes/normal-map.gdshaderinc`:删掉 `ref_normal` / `vector.xy*2-1` / `vector.z` + 重建等死代码,改为返回 `NormalData(map, normal_scale)`,map 就是原始纹理值(切线空间), + 蓝通道交给引擎重建;补上坐标空间说明。 +- `shader/cel-shader-base.gdshader`:`USE_NORMAL_MAP` 分支只写 + `NORMAL_MAP = normal.map; NORMAL_MAP_DEPTH = normal.depth;`,不再碰 `NORMAL`。 +- `shader/toon_apply.gd`:抓 `normal_texture`(StandardMaterial3D)或 `normal_map` + (ShaderMaterial)→ 有就赋给副本;**没有就显式把 `normal_scale` 设为 0.0**,否则会采样默认纹理。 + +验证(真实场景跑法,见 MEMORY.md): +- 对照实验确认 dummy 渲染后端仍会报 `SHADER ERROR`,所以无头运行可校验 shader 语法。 +- 加载 `world/main2.tscn` 零 shader 错误、零脚本错误。 +- 功能断言:toon_apply 子树 434 个网格全部应用成功;430 个 `normal_scale=0.0`(无贴图), + 4 个 `normal_scale=1.0` 且 `normal_map` 已设置(源材质确实带法线贴图)。 + +遗留待用户确认(未改动): +- `world/main2.tscn` 的 `model/floor1`(不归 toon_apply 管,用自建 `surface_material_override/0`) + 上 `normal_scale = -1.7749993243124995`,在新语义下等于 `NORMAL_MAP_DEPTH = -1.775`(反向外插), + 几乎不可能是想要的,建议改成 1.0。 +- `toon_apply.gd` 只读 `mesh.surface_get_material(0)`,忽略了 `surface_material_override`, + 对把材质放在节点覆盖槽上的模型会抓不到贴图(本次未改,属潜在问题)。 + +## 排查:亮色模型"像自发光"(未改动任何项目文件) + +用户问"没开自发光,为什么颜色较亮时有自发光感"。用 Godot 无头脚本采样截图 +(`Image.load_from_file` + 逐像素统计,临时脚本已删): +- 红篮区域(25988 样本):**37.1% 的像素有通道被钉在 255**,均值 (164,57,56) +- 抽屉顶面(7616 样本):**63.0% 触顶,4758 个纯白像素**,均值 (212,212,205) +- 深色柜体正面(10048 样本):仅 **3.3% 触顶**,均值 (108,89,74) +- 背景:66452 样本 min==max==(54,72,87) → **纯色平涂,不是天空盒** + +结论:是**通道削顶(clipping)**,不是自发光。shader 里 `USE_EMISSION 0`,且 toon.tres +的 specular/fresnel 的 rgb 都是 0 → 唯一光照项就是 `ALBEDO × LIGHT_COLOR × 阶梯`。 +亮色/高饱和色在线性空间里本来就有通道接近 1.0,光照只要 >1 就把它钉死 → +整片变成没有渐变的满色 → 观感就像发光;中等亮度色需要 3 倍以上才触顶,所以正常。 +阶梯曲线本身是平的(`new_gradient_texture_1d.tres`:u<0.127→0,0.14~0.50→0.198, +u>0.523→1.0),削顶后连最后一点层次也没了。 + +项目里的过曝来源候选: +- `world/main2.tscn` 的 SpotLight3D `sconce`:`light_energy = 2.655`、`light_indirect_energy = 2.41` +- `world/main.tscn` 的 Environment:`ambient_light_source = 3`(天空)+ 自定义天空 + `atm_sun_intensity = 18`、`sun_disk_intensity = 30` → 环境光辐照度可远超 1 +- **矛盾点(可用来定位)**:main.tscn 的 Environment 是 `tonemap_mode = 3`(ACES) + `tonemap_white = 6.0`, + ACES 会把高光滚降,理论上几乎不可能出现纯 255(输入 4.0 也只到 ~253)。但截图里大面积纯 255 + → 说明被观察的视口**没有套用那套 ACES 环境**:要么看的是 main2.tscn(自身无 WorldEnvironment、 + 无 tonemap → 硬削顶),要么编辑器 3D 视口没启用场景环境。 +- 另一个坑:toon.tres 的 `color = 0.549` 带 `source_color`,线性空间里只有 **0.26**, + toon 管线把反照率整体压掉约 4 倍 → 亮暗反差被拉得很大。 +- `world/main2.tscn` 里没有任何 `use_custom_color = true`,所以不存在自定义颜色覆盖。 +- `world/main.tscn` 通过 ext_resource 把 `main2.tscn` 作为子场景实例化。 + +待用户确认:具体是哪个场景/哪个物体,以便定位"材质来源 + 光源 + 环境"这条链路。 + +## 重要:上游 godot4-cel-shader 的法线贴图路径是"从未运行过的死代码" + +用户质疑"着色器源自 https://github.com/eldskald/godot4-cel-shader,理应无需修改"。 +本地有干净的上游副本 `E:/Godot/godot4-cel-shader-main/godot4-cel-shader-main/`,比对结果: + +- 本项目改动前的 `cel-shader-base.gdshader` / `includes/normal-map.gdshaderinc` + **与上游逐字一致**(包括 `NORMAL = normal.vector;` 和 `ref_normal` 算完即丢)。→ 当时不是本项目改坏的。 +- 但上游 5 个 shader 变体**全部是 `#define USE_NORMAL_MAP 0`**,全仓库没有任何一处写成 1, + README 里 "normal" 出现 0 次 → **这条路径上游从未启用、从未测试过**。 +- 本项目把它开成 1,才第一次走进这段代码,于是坐标空间错误暴露。 +- 结论:**只要 `USE_NORMAL_MAP 1`,那行就必须改**;想严格保持上游 1:1,唯一办法是把开关保持 0。 +- Godot 侧证据:引擎自己也会再做一次 NORMAL_MAP 合成 + (Godot 源码 clearcoat 等价实现:`normalize(tangent*map.x + binormal*map.y + normal*map.z)`), + 公式里的 `normal` 就是被污染过的 NORMAL → 污染会传播;且 depth=0 也无法"中和", + 因为 mix 的基值本身就是被写坏的 NORMAL。 + +给用户的三个选项(待其选择): +A. `USE_NORMAL_MAP` 改回 0,并把两个 shader 文件完全还原成上游(代价:实测 434 个网格里那 4 个带法线贴图的会失去效果) +B. 只保留 `cel-shader-base.gdshader` 删掉 `NORMAL = normal.vector;` 这一行的差异,include 还原上游(1 行差异) +C. 保留当前版本(include 里的死代码也清掉,与上游差异最大但最干净) + +### 已实施:用户选择 A + +- 用 git 精确还原:`git checkout -- shader/cel-shader-base.gdshader shader/includes/normal-map.gdshaderinc shader/toon_apply.gd` + (先 `git diff` 逐 hunk 确认过全部改动都是本次会话产生的,没有混入用户未提交的修改)。 +- 再把 `#define USE_NORMAL_MAP 1` 改成 `0`(HEAD 里本来就是 1,是用户自己开的)。 +- 结果:`normal-map.gdshaderinc` 与上游**逐字一致**;`cel-shader-base.gdshader` 与上游的差异 + 只剩项目自己加的中文注释行(值已与上游相同);`toon_apply.gd` 无改动。 +- 验证:`--headless res://world/main2.tscn --quit-after 60` 无 shader / 脚本 / 解析错误。 +- 注意:`toon.tres` 里仍有 `shader_parameter/normal_scale = 1.0`,宏关闭时被忽略,无副作用; + `world/main2.tscn` 里 `model/floor1` 那份自建材质的 `normal_scale = -1.775` 同样被忽略。 diff --git a/.workbuddy/memory/2026-10-15.md b/.workbuddy/memory/2026-10-15.md new file mode 100644 index 0000000..41ad3cd --- /dev/null +++ b/.workbuddy/memory/2026-10-15.md @@ -0,0 +1,62 @@ +# 2026-10-15 + +## 玩家状态管理迁移到 LimboAI LimboHSM + +- 需求:玩家场景的状态管理完全用 LimboHSM 实现(addons/limboai v1.8.1,GDExtension 版, + **无需启用 editor plugin**,ClassDB 里没有 HSMPlayer —— LimboHSM/LimboState 本身就是 Node)。 +- 方案讨论:BT vs HSM 选型 → 玩家是人操作的(决策已由人完成),用 HSM 建模身体状态; + BT 留给将来的 NPC/autopilot。 +- 新增 `character/player/hsm/`(11 个脚本): + - `player_hsm.gd`(根,转换表 Grounded⇄Airborne:`airborne`/`landed` 事件) + - `grounded.gd`(Grounded 子机:12 条转换 + `_enter` 按当下输入挑初始子状态) + - `airborne.gd`(Jump→Fall on `falling`;`_enter` 按 vy>0 挑 Jump 否则 Fall) + - `ground_state.gd`(地面叶子基类:统一判 try_jump/踏空 → `airborne`,再走 `_think()`) + - idle/walk/sprint/crouch/sneak.gd(写 motor.target_speed/crouching/sprinting + 动画) + - jump.gd/fall.gd(落地判定 `is_on_floor and vy<=0`,带 vy 门槛防起跳帧弹回) +- 改动: + - `player.gd`:加 `class_name Player`;挂 HSM;`_ready` 末尾 `hsm.initialize(self)` + + `hsm.set_active(true)`(仅权威端);管线第一位 `hsm.update(delta)`;animator 改调 `set_gait`。 + - `player_motor.gd`:删掉从输入推断 sprint/crouch/target 的逻辑,暴露 + `target_speed`(状态写)、`has_move_input()`、`try_jump()`(起跳在状态判定处就地执行, + 同帧转 Jump);crouching/sprinting 变成状态写入的事实镜像(Look/DebugPanel 继续用)。 + - `player_animator.gd`:`set_motion` 拆成 `play_state(state)`(状态 _enter 调)+ + `set_gait(velocity)`(每帧刷倍率);删 `_resolve_state` 和 `idle_threshold`。 + anim_state/anim_speed 同步属性保持不变(远端靠 Sync 复制驱动)。 + - `player.tscn`:HSM 节点子树(LimboHSM root + Grounded{Idle,Walk,Sprint,Crouch,Sneak} + + Airborne{Jump,Fall}),根机器 `update_mode = 2`(MANUAL,编辑器里写入 int 失败, + 手改 tscn 后 force_reload 场景生效)。 +- 行为变化(有意为之):行走/站立切换改按**输入**判定(旧版按速度阈值 0.2 m/s), + 顶墙时播 walk 而非 idle;其余(蹲伏优先于疾跑、跳跃缓冲、落地按输入挑状态)与旧版一致。 +- 验证:全部脚本 `--check-only` 通过;无头探针场景模拟输入跑完整状态链 + (Idle→Walk→Sprint→Sneak→Crouch→Idle→Walk→Jump→落地Walk→Idle),数值与理论吻合。 + +## 二次重写:小人国定标(同日) + +- 用户明确:不保旧手感、可重写;**小人国玩法,角色身高约 0.2 m**;旧代码可删。 +- 定标(全部固化进 player_motor.gd 默认值,删掉了 tscn 里的 walk/sprint 覆盖): + walk 2.419(≈12 身长/s)、sprint 3.624(≈18 身长/s)、crouch 1.21(走速一半)、 + accel 22(0.11s 到全速)、air_accel 5.9(地面 1/4)、jump 1.5 / gravity_scale 0.5 + (跳高 0.23 m ≈ 1.15 身高、滞空 0.61 s)不变。DebugPanel body_height 0.15 → 0.2。 +- **修掉一个真 bug**:场景里 walk 被调到 2.419 后,动画器基准速度还是 0.4286, + walk/run 动画一直被 clamp 在 1.6× 上限。重构:删掉 animator 的 4 个 *_ref_speed 导出, + `play_state(state, ref_speed)` 由 HSM 状态传入基准速度(状态知道目标速度是多少), + `set_gait` 用存下的 `_ref_speed` 算倍率 —— 两处对表的坑没了。 +- 探针复测:三个位移动画全速倍率均 1.00,转换链不变。 + +### 踩坑(复用注意) + +1. **无头 `--check-only` 对新 class_name 报「Could not find type」**: + 全局类缓存 `.godot/global_script_class_cache.cfg` 没刷新。让编辑器跑一次 + `EditorFileSystem.scan()`(godot_ai MCP 的 filesystem scan)即解;批量循环检查时 + 同一批里前后结果不一致也是这个原因。 +2. **godot-ai MCP `node_set_property` 写 enum int 属性失败** + (WRONG_TYPE: Cannot write String to a int property,value 传 JSON number 也一样)。 + 变通:MCP scene_save 后直接手改 .tscn 补属性行,再 `scene_open force_reload=true`。 +3. **MCP 场景节点路径**要含场景根:`/Player/HSM`,不是 `/HSM`。 +4. **LimboHSM 嵌套用法**:子状态机的 `_enter`(脚本 override)先于其 initial_state 激活执行, + 所以可以在 `_enter` 里 `set_initial_state()` 动态挑入口子状态(落地按输入挑、空中按 vy 挑)。 +5. **转换触发的环境判定要放叶子状态**,不要放父机 `_update`:父机先于子状态执行, + 在父机里 dispatch 转换后,本轮级联还可能调到已退出的旧子状态(叶子自己判、dispatch 后 + 立即 return,没有这个窗口)。 +6. 无头模拟按键:`Input.action_press` 只改动作状态、**不发输入事件**(Motor 的跳跃缓冲在 + `_unhandled_input` 收事件,探不到);要用 `Input.parse_input_event(InputEventAction)`。 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md new file mode 100644 index 0000000..53f2f2b --- /dev/null +++ b/.workbuddy/memory/MEMORY.md @@ -0,0 +1,73 @@ +# agame 项目长期笔记 + +Godot 4.7.2(Forward+,Jolt Physics,D3D12)。编辑器位于 +`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64.exe`(另有 mono 版)。 + +## 代码约定 + +- **GDScript 警告被当作错误**(至少 `inference_on_variant`)。不要写成 + `var x := some_variant_returning_call()`;要显式写类型,例如 + `var s: Script = node.get_script() as Script`。改完脚本务必用 + `--check-only --script res://xxx.gd` 自查。 +- 缩进用 tab,注释用中文(项目既有风格)。 + +## 无头验证的正确姿势(踩过坑) + +- `--check-only --script res://x.gd`:GDScript 语法校验,有效。 +- `godot --headless --editor --path --quit`:验证编辑器插件能否加载,有效。 +- **不要**用 `extends SceneTree` 的 `--script` 脚本去测 `_ready()` 相关行为: + 这种模式下节点不会真正进入场景树(`is_inside_tree()` 为 false),`_ready` 不执行, + 会得出完全错误的结论。正确做法是造一个临时场景,用位置参数当主场景跑: + `godot --headless --path res://tools/_tmp_probe.tscn`。 +- 即使是 `--headless`(dummy 渲染后端),**shader 编译错误仍会被报告** + (`SHADER ERROR: ...`),所以可以用无头运行来校验 shader 语法。 + +## 玩家 = LimboAI LimboHSM(2026-10-15 起) + +- LimboAI v1.8.1 是 **GDExtension 版**(addons/limboai 只有 bin/,无 plugin.cfg), + **不需要启用 editor plugin**;ClassDB 里没有 HSMPlayer —— `LimboHSM`/`LimboState` 本身就是 Node。 +- 玩家状态机在 `character/player/hsm/`:根 `player_hsm.gd`(Grounded⇄Airborne)+ + Grounded{Idle,Walk,Sprint,Crouch,Sneak} + Airborne{Jump,Fall},全是场景节点、脚本挂状态上。 +- 分工:**HSM 管「是什么状态」**(写 motor.target_speed/crouching/sprinting + 调 + animator.play_state),**motor 只管物理**(重力/加速度/跳跃缓冲 + try_jump), + animator 只管「怎么播」(play_state + set_gait 刷倍率)。player.gd 管线第一位 + `hsm.update(delta)`(MANUAL 模式,仅权威端 initialize+set_active;远端靠 Sync 复制)。 +- 走/站立转换按**输入**判定(非旧版的速度阈值)——顶墙时播 walk 不播 idle,是预期行为。 +- 用法要点:子机 `_enter` 里可 `set_initial_state()` 动态挑入口子状态;环境判定 + (起跳/踏空/落地)放**叶子**状态的 _update,别放父机;落地判定要带 `vy<=0` 门槛。 +- 踩坑:新 class_name 后无头 `--check-only` 报「Could not find type」= 全局类缓存没刷新, + 让编辑器 scan 一次即解;MCP node_set_property 写 enum int 失败 → 手改 tscn + 场景 force_reload。 + +## toon 卡通渲染管线 + +- `shader/cel-shader-base.gdshader`(`USE_*` 宏开关各模块)+ `shader/includes/*.gdshaderinc`, + 上游是 godot4-cel-shader 项目。 +- `shader/toon.tres` 是材质模板;`shader/outline.gdshader` 作为 `next_pass` 描边。 +- `shader/toon_apply.gd`(@tool Node3D)给每个 MeshInstance3D 复制一份独立材质, + 因为**每个模型有各自的贴图**,所以必须独立(不能共享一份材质)。 +- 已在项目设置里用了 **global uniform**:`diffuse_curve`、`specular_smoothness`、 + `fresnel_smoothness`、`outline_width`、`outline_color` —— 这几项调参本来就是全局实时生效。 +- 贴图尺寸极不统一(289 张 PNG / 24 种尺寸,1024²、512²、4096²、32² 混杂), + 因此**不适合** Texture2DArray + per-instance uniform 方案(显存会爆且要额外打包流水线)。 +- 编辑器插件 `addons/toon_refresh/`:改 toon.tres 时自动重刷 toon_apply 材质。 + +## 坐标系陷阱 + 上游着色器的边界(重要) + +Godot 4 的 `fragment()` 里 `NORMAL` / `TANGENT` / `BINORMAL` 都处于**视图空间**, +而 `NORMAL_MAP` 要的是**切线空间**值、由引擎用 TANGENT/BINORMAL 还原。 +把切线空间值直接赋给 `NORMAL` 会让整个场景的法线变成一个跟着相机转的常量 —— +表现为平视全黑、俯瞰才亮。 + +但**不要**因此去改上游文件:`cel-shader-base.gdshader` 与 +`includes/normal-map.gdshaderinc` 来自 , +本项目的策略是**逐字跟随上游**。上游的法线贴图路径 `NORMAL = normal.vector;` +确实有上述坐标空间错误,但上游所有变体都是 `#define USE_NORMAL_MAP 0`, +全仓库从未启用过这条路径(README 也没提),所以是"能编译但结果错"的死代码。 +本项目保持 `USE_NORMAL_MAP 0`,与上游一致;`toon.tres` 里残留的 +`shader_parameter/normal_scale` 在宏关闭时会被忽略,无副作用。 + +**结论:要动上游 shader 之前,先用 `E:/Godot/godot4-cel-shader-main/` 里的干净副本 diff。** + +正确做法(若将来真要开法线贴图):只写 `NORMAL_MAP = 原始切线空间值` + +`NORMAL_MAP_DEPTH`,让引擎合成;不要自己写 `NORMAL`,也不要预先做 `*2-1` 重映射 +(引擎自己做,蓝通道由引擎重建)。 diff --git a/character/player/hsm/airborne.gd b/character/player/hsm/airborne.gd new file mode 100644 index 0000000..db14120 --- /dev/null +++ b/character/player/hsm/airborne.gd @@ -0,0 +1,17 @@ +extends LimboHSM +## 空中子机(挂在 HSM/Airborne 上)。 +## +## Jump(上升段)→ Fall(越过顶点开始下落)。进入时按当前竖直速度挑初始子状态: +## 起跳进来 vy > 0 → Jump;踏空掉进来 vy ≈ 0 → Fall(vy ≤ 0 一律算 Fall)。 + +@onready var jump: LimboState = $Jump +@onready var fall: LimboState = $Fall + + +func _setup() -> void: + add_transition(jump, fall, &"falling") + + +func _enter() -> void: + var player := get_agent() as Player + set_initial_state(fall if player == null or player.velocity.y <= 0.0 else jump) diff --git a/character/player/hsm/airborne.gd.uid b/character/player/hsm/airborne.gd.uid new file mode 100644 index 0000000..3a95c01 --- /dev/null +++ b/character/player/hsm/airborne.gd.uid @@ -0,0 +1 @@ +uid://43psuo6pb4wq diff --git a/character/player/hsm/crouch.gd b/character/player/hsm/crouch.gd new file mode 100644 index 0000000..0701422 --- /dev/null +++ b/character/player/hsm/crouch.gd @@ -0,0 +1,18 @@ +extends PlayerGroundState +## 原地蹲:目标速度 0,播 crouch(蹲着不动)。 +## 蹲着走是 Sneak —— 两者只差「有没有方向输入」,转换事件同为 move_start/stop。 + +func _enter() -> void: + var player := get_agent() as Player + player.motor.target_speed = 0.0 + player.motor.sprinting = false + player.motor.crouching = true + player.animation_player.play_state(PlayerAnimator.AnimState.CROUCH) + + +func _think(_delta: float) -> void: + var player := get_agent() as Player + if not Input.is_action_pressed("crouch"): + dispatch(&"crouch_end") + elif player.motor.has_move_input(): + dispatch(&"move_start") diff --git a/character/player/hsm/crouch.gd.uid b/character/player/hsm/crouch.gd.uid new file mode 100644 index 0000000..eef3c47 --- /dev/null +++ b/character/player/hsm/crouch.gd.uid @@ -0,0 +1 @@ +uid://bb8dvx7nwuiwo diff --git a/character/player/hsm/fall.gd b/character/player/hsm/fall.gd new file mode 100644 index 0000000..c0914e0 --- /dev/null +++ b/character/player/hsm/fall.gd @@ -0,0 +1,14 @@ +extends LimboState +## 下落段:播 fall。落地(is_on_floor 且不再上升)转回地面,由 Grounded._enter +## 按当下输入挑子状态(走/跑/蹲着落地不会闪站立)。 + +func _enter() -> void: + (get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.FALL) + + +func _update(_delta: float) -> void: + var player := get_agent() as Player + if player == null: + return + if player.is_on_floor() and player.velocity.y <= 0.0: + dispatch(&"landed") diff --git a/character/player/hsm/fall.gd.uid b/character/player/hsm/fall.gd.uid new file mode 100644 index 0000000..2fec7bd --- /dev/null +++ b/character/player/hsm/fall.gd.uid @@ -0,0 +1 @@ +uid://yadts2ydgfs6 diff --git a/character/player/hsm/ground_state.gd b/character/player/hsm/ground_state.gd new file mode 100644 index 0000000..a56a770 --- /dev/null +++ b/character/player/hsm/ground_state.gd @@ -0,0 +1,32 @@ +class_name PlayerGroundState +extends LimboState +## 地面叶子状态的公共基类(Idle / Walk / Sprint / Crouch / Sneak 共用)。 +## +## 每帧先统一判定「环境事实」,之后才轮到 _think() 里各状态自己的输入转换: +## 1. 跳跃缓冲成立(try_jump 当场起跳)→ 转空中(vy > 0 → Jump); +## 2. 脚下没地板(踏空)→ 转空中(Fall)。 +## 这两层判定放在**叶子**而不是父机:父机 _update 先于子状态执行, +## 若在父机里 dispatch,转换后父机本轮还会继续调(已被退出的)旧子状态, +## 存在「旧状态在已退出的机器里再次 dispatch」的顺序陷阱;叶子自己判、 +## dispatch 后立即 return,则没有这个窗口。 +## +## is_on_floor() 反映的是上一帧 move_and_slide() 的结果 —— +## 与推进的更新节奏一致,不引入额外的检测延迟。 + +func _update(delta: float) -> void: + var player := get_agent() as Player + if player == null: + return + if player.motor.try_jump(): + dispatch(&"airborne") + return + if not player.is_on_floor(): + dispatch(&"airborne") + return + _think(delta) + + +## 各状态自己的输入转换。要不要 dispatch 事件、往哪转,由具体状态决定 +## (转换表注册在 grounded.gd 的 _setup 里)。 +func _think(_delta: float) -> void: + pass diff --git a/character/player/hsm/ground_state.gd.uid b/character/player/hsm/ground_state.gd.uid new file mode 100644 index 0000000..e14e84b --- /dev/null +++ b/character/player/hsm/ground_state.gd.uid @@ -0,0 +1 @@ +uid://dss42q7c4kqui diff --git a/character/player/hsm/grounded.gd b/character/player/hsm/grounded.gd new file mode 100644 index 0000000..82d3885 --- /dev/null +++ b/character/player/hsm/grounded.gd @@ -0,0 +1,45 @@ +extends LimboHSM +## 地面子机(挂在 HSM/Grounded 上)。 +## +## 五个叶子状态: +## Idle 站立不动 Walk 步行 Sprint 疾跑 Crouch 原地蹲 Sneak 蹲走 +## 叶子各自的输入转换见 ground_state.gd 及各状态脚本;本机只注册转换表, +## 并在 _enter(首次激活 / 每次落地)时按「当下输入」挑初始子状态 —— +## 不然按着方向键落地会先闪一帧站立。 + +@onready var idle: LimboState = $Idle +@onready var walk: LimboState = $Walk +@onready var sprint: LimboState = $Sprint +@onready var crouch: LimboState = $Crouch +@onready var sneak: LimboState = $Sneak + + +func _setup() -> void: + # 事件口径:move_start/stop 有无方向输入,sprint_start/stop 疾跑键, + # crouch_start/end 蹲键。蹲伏优先于疾跑(蹲下时按 Shift 不算疾跑)。 + add_transition(idle, walk, &"move_start") + add_transition(idle, crouch, &"crouch_start") + add_transition(walk, idle, &"move_stop") + add_transition(walk, sprint, &"sprint_start") + add_transition(walk, sneak, &"crouch_start") + add_transition(sprint, walk, &"sprint_stop") + add_transition(sprint, idle, &"move_stop") + add_transition(sprint, sneak, &"crouch_start") + add_transition(crouch, sneak, &"move_start") + add_transition(crouch, idle, &"crouch_end") + add_transition(sneak, crouch, &"move_stop") + add_transition(sneak, walk, &"crouch_end") + + +func _enter() -> void: + var player := get_agent() as Player + if player == null: + set_initial_state(idle) + return + var moving := player.motor.has_move_input() + if Input.is_action_pressed("crouch"): + set_initial_state(sneak if moving else crouch) + elif moving: + set_initial_state(sprint if Input.is_action_pressed("sprint") else walk) + else: + set_initial_state(idle) diff --git a/character/player/hsm/grounded.gd.uid b/character/player/hsm/grounded.gd.uid new file mode 100644 index 0000000..c563df0 --- /dev/null +++ b/character/player/hsm/grounded.gd.uid @@ -0,0 +1 @@ +uid://2bnkxlycmqqu diff --git a/character/player/hsm/idle.gd b/character/player/hsm/idle.gd new file mode 100644 index 0000000..e24378e --- /dev/null +++ b/character/player/hsm/idle.gd @@ -0,0 +1,17 @@ +extends PlayerGroundState +## 站立:目标速度 0,播 idle。 + +func _enter() -> void: + var player := get_agent() as Player + player.motor.target_speed = 0.0 + player.motor.sprinting = false + player.motor.crouching = false + player.animation_player.play_state(PlayerAnimator.AnimState.IDLE) + + +func _think(_delta: float) -> void: + # 蹲键优先 —— 同帧又蹲又走时先蹲下,下一帧 Crouch 再转 Sneak,不会漏蹲。 + if Input.is_action_pressed("crouch"): + dispatch(&"crouch_start") + elif (get_agent() as Player).motor.has_move_input(): + dispatch(&"move_start") diff --git a/character/player/hsm/idle.gd.uid b/character/player/hsm/idle.gd.uid new file mode 100644 index 0000000..ef03ba4 --- /dev/null +++ b/character/player/hsm/idle.gd.uid @@ -0,0 +1 @@ +uid://2isemrs6ov3 diff --git a/character/player/hsm/jump.gd b/character/player/hsm/jump.gd new file mode 100644 index 0000000..31191d4 --- /dev/null +++ b/character/player/hsm/jump.gd @@ -0,0 +1,20 @@ +extends LimboState +## 上升段:播 jump。越过顶点(vy ≤ 0)转 Fall。 +## 水平目标速度保持离地前的值 —— 空中加速度本来就小(air_acceleration), +## 不由本状态改写。 + +func _enter() -> void: + (get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.JUMP) + + +func _update(_delta: float) -> void: + var player := get_agent() as Player + if player == null: + return + # 落地判定带 vy ≤ 0 门槛:起跳那一帧 is_on_floor() 还是 true + # (要等 move_and_slide() 之后才翻假),没有门槛会当帧弹回地面状态。 + if player.is_on_floor() and player.velocity.y <= 0.0: + dispatch(&"landed") + return + if player.velocity.y <= 0.0: + dispatch(&"falling") diff --git a/character/player/hsm/jump.gd.uid b/character/player/hsm/jump.gd.uid new file mode 100644 index 0000000..c002088 --- /dev/null +++ b/character/player/hsm/jump.gd.uid @@ -0,0 +1 @@ +uid://cvfcvo7k1i5jr diff --git a/character/player/hsm/player_hsm.gd b/character/player/hsm/player_hsm.gd new file mode 100644 index 0000000..826b82c --- /dev/null +++ b/character/player/hsm/player_hsm.gd @@ -0,0 +1,16 @@ +extends LimboHSM +## 玩家状态机根(挂在 Player/HSM 节点上,update_mode = MANUAL)。 +## +## 只管最粗的一层:「身体在地上还是空中」。细分状态在两个子机里: +## Grounded 地面 grounded.gd —— Idle / Walk / Sprint / Crouch / Sneak +## Airborne 空中 airborne.gd —— Jump / Fall +## 推进由 Player._physics_process 显式调 update(delta)(管线第一位)。 + +@onready var grounded: LimboHSM = $Grounded +@onready var airborne: LimboHSM = $Airborne + + +func _setup() -> void: + # 起跳或踏空 → 空中;落地 → 回地面。事件的发起在各叶子状态里。 + add_transition(grounded, airborne, &"airborne") + add_transition(airborne, grounded, &"landed") diff --git a/character/player/hsm/player_hsm.gd.uid b/character/player/hsm/player_hsm.gd.uid new file mode 100644 index 0000000..5d1d4a4 --- /dev/null +++ b/character/player/hsm/player_hsm.gd.uid @@ -0,0 +1 @@ +uid://cgjaw5xbg7gdj diff --git a/character/player/hsm/sneak.gd b/character/player/hsm/sneak.gd new file mode 100644 index 0000000..6e693e5 --- /dev/null +++ b/character/player/hsm/sneak.gd @@ -0,0 +1,19 @@ +extends PlayerGroundState +## 蹲走(潜行):目标速度 = crouch_speed,播 sneak。 +## 蹲伏优先于疾跑:蹲着按 Shift 也只是潜行。 + + +func _enter() -> void: + var player := get_agent() as Player + player.motor.target_speed = player.motor.crouch_speed + player.motor.sprinting = false + player.motor.crouching = true + player.animation_player.play_state(PlayerAnimator.AnimState.SNEAK, player.motor.crouch_speed) + + +func _think(_delta: float) -> void: + var player := get_agent() as Player + if not Input.is_action_pressed("crouch"): + dispatch(&"crouch_end") + elif not player.motor.has_move_input(): + dispatch(&"move_stop") diff --git a/character/player/hsm/sneak.gd.uid b/character/player/hsm/sneak.gd.uid new file mode 100644 index 0000000..4802785 --- /dev/null +++ b/character/player/hsm/sneak.gd.uid @@ -0,0 +1 @@ +uid://b4okw70vrah42 diff --git a/character/player/hsm/sprint.gd b/character/player/hsm/sprint.gd new file mode 100644 index 0000000..991783c --- /dev/null +++ b/character/player/hsm/sprint.gd @@ -0,0 +1,19 @@ +extends PlayerGroundState +## 疾跑:目标速度 = sprint_speed,播 run。只在按着方向键时成立(Sprint 由 Walk 进入)。 + +func _enter() -> void: + var player := get_agent() as Player + player.motor.target_speed = player.motor.sprint_speed + player.motor.sprinting = true + player.motor.crouching = false + player.animation_player.play_state(PlayerAnimator.AnimState.RUN, player.motor.sprint_speed) + + +func _think(_delta: float) -> void: + var player := get_agent() as Player + if Input.is_action_pressed("crouch"): + dispatch(&"crouch_start") + elif not player.motor.has_move_input(): + dispatch(&"move_stop") + elif not Input.is_action_pressed("sprint"): + dispatch(&"sprint_stop") diff --git a/character/player/hsm/sprint.gd.uid b/character/player/hsm/sprint.gd.uid new file mode 100644 index 0000000..6a80604 --- /dev/null +++ b/character/player/hsm/sprint.gd.uid @@ -0,0 +1 @@ +uid://cklw8uq6xahe diff --git a/character/player/hsm/walk.gd b/character/player/hsm/walk.gd new file mode 100644 index 0000000..db29253 --- /dev/null +++ b/character/player/hsm/walk.gd @@ -0,0 +1,19 @@ +extends PlayerGroundState +## 步行:目标速度 = walk_speed,播 walk。 + +func _enter() -> void: + var player := get_agent() as Player + player.motor.target_speed = player.motor.walk_speed + player.motor.sprinting = false + player.motor.crouching = false + player.animation_player.play_state(PlayerAnimator.AnimState.WALK, player.motor.walk_speed) + + +func _think(_delta: float) -> void: + var player := get_agent() as Player + if Input.is_action_pressed("crouch"): + dispatch(&"crouch_start") + elif not player.motor.has_move_input(): + dispatch(&"move_stop") + elif Input.is_action_pressed("sprint"): + dispatch(&"sprint_start") diff --git a/character/player/hsm/walk.gd.uid b/character/player/hsm/walk.gd.uid new file mode 100644 index 0000000..773948e --- /dev/null +++ b/character/player/hsm/walk.gd.uid @@ -0,0 +1 @@ +uid://6pa3038100o4 diff --git a/character/player/player.gd b/character/player/player.gd index e4cb9cb..d0e813b 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -1,3 +1,4 @@ +class_name Player extends CharacterBody3D ## 玩家协调器:只做三件事 —— 多人权限、依赖注入、按固定顺序编排各组件。 ## @@ -6,8 +7,12 @@ extends CharacterBody3D ## Motor 移动跳跃 character/player/player_motor.gd ## Pickup 拾取 character/player/player_pickup.gd ## Animator 动画表现 character/player/player_animator.gd(挂在 AnimationPlayer 上) +## HSM 状态机 character/player/hsm/(LimboAI LimboHSM,MANUAL 模式) ## ## 本脚本不实现任何操作,只负责「谁先谁后」和「谁有权跑」。 +## 「玩家处于什么状态」(走 / 跑 / 蹲 / 跳 / 落)的判定完全在 HSM 里: +## 状态在 _enter 时写 motor.target_speed、motor.crouching/sprinting 和动画片段, +## 这里只按固定顺序推进各组件。 @onready var body: Node3D = $Body ## 本地玩家自己的模型(鼠)。第一人称要藏掉它,见 _ready()。 @@ -22,6 +27,9 @@ extends CharacterBody3D @onready var look: PlayerLook = $Look @onready var motor: PlayerMotor = $Motor @onready var pickup: PlayerPickup = $Pickup +## LimboAI 层级状态机。update_mode = MANUAL,由 _physics_process 按管线顺序显式推进, +## 保证它永远排在 motor.step 之前、且只在权威端跑。 +@onready var hsm: LimboHSM = $HSM func _ready() -> void: @@ -60,6 +68,12 @@ func _ready() -> void: pickup.interact_state_changed.connect(_on_pickup_interact_state_changed) # 调试面板接线:把要读的组件注入进去,面板自己不 get_node。 debug_panel.setup(self, motor, pickup, camera) + # 状态机接线:initialize 递归调所有状态的 _setup(注册各自的转换表), + # set_active 进入 initial_state(Grounded 会按当下输入挑子状态)。 + # 只在权威端跑 —— 远端玩家由 Sync 复制 position / anim_state / anim_speed, + # 不需要也不允许本地再跑一套状态机;HSM 是 MANUAL 模式,不初始化就完全惰性。 + hsm.initialize(self) + hsm.set_active(true) set_mouse_captured(true) @@ -91,13 +105,16 @@ func _on_pickup_interact_state_changed(hovering: bool, holding: bool) -> void: func _physics_process(delta: float) -> void: - # 顺序固定:算速度 → 同步蹲伏高度 → 推进 → 播动画 → 搬物体。 - # 由根节点统一编排,各组件自己不许写 _physics_process —— + # 顺序固定:状态机(判定状态,写目标速度与动画)→ 算速度 → 同步蹲伏高度 → + # 推进 → 刷步频 → 搬物体。由根节点统一编排,各组件自己不许写 _physics_process —— # 否则顺序取决于节点树顺序,改个节点位置就会出现「先滑再算速度」这类隐形 bug。 + # HSM 排最前:状态写入的 target_speed 当帧就被 motor 消费; + # 起跳在 Grounded 状态里就地执行(motor.try_jump),同一帧转进 Jump。 + hsm.update(delta) motor.step(delta) - # 蹲伏状态由 Motor 单点判定,再转交给 Look 去升降摄像机 —— - # 不让 Look 自己去读蹲键,免得两个组件各判一次、将来不同步。 + # 蹲伏事实由 HSM 的 Crouch/Sneak 状态写进 motor.crouching —— + # 不让 Look 自己去读蹲键,免得两边各判一次、将来不同步。 look.set_crouching(motor.crouching) move_and_slide() - animation_player.set_motion(velocity, is_on_floor(), motor.sprinting, motor.crouching) + animation_player.set_gait(velocity) pickup.physics_step() diff --git a/character/player/player.tscn b/character/player/player.tscn index b67f726..ae4a634 100644 --- a/character/player/player.tscn +++ b/character/player/player.tscn @@ -19,6 +19,16 @@ [ext_resource type="Script" uid="uid://bo1hob2whrlu0" path="res://character/player/player_look.gd" id="13_look"] [ext_resource type="Script" uid="uid://xta8h2snalsd" path="res://character/player/player_motor.gd" id="14_motor"] [ext_resource type="Script" uid="uid://b3ko4cfjf0vgu" path="res://character/player/player_pickup.gd" id="15_pickup"] +[ext_resource type="Script" uid="uid://cgjaw5xbg7gdj" path="res://character/player/hsm/player_hsm.gd" id="20_7gte1"] +[ext_resource type="Script" uid="uid://2bnkxlycmqqu" path="res://character/player/hsm/grounded.gd" id="21_k6mlu"] +[ext_resource type="Script" uid="uid://2isemrs6ov3" path="res://character/player/hsm/idle.gd" id="22_r212m"] +[ext_resource type="Script" uid="uid://6pa3038100o4" path="res://character/player/hsm/walk.gd" id="23_8bmvw"] +[ext_resource type="Script" uid="uid://bb8dvx7nwuiwo" path="res://character/player/hsm/crouch.gd" id="24_2pong"] +[ext_resource type="Script" uid="uid://b4okw70vrah42" path="res://character/player/hsm/sneak.gd" id="25_tkl2s"] +[ext_resource type="Script" uid="uid://cklw8uq6xahe" path="res://character/player/hsm/sprint.gd" id="26_vhygx"] +[ext_resource type="Script" uid="uid://43psuo6pb4wq" path="res://character/player/hsm/airborne.gd" id="27_s2r15"] +[ext_resource type="Script" uid="uid://cvfcvo7k1i5jr" path="res://character/player/hsm/jump.gd" id="28_ed2qs"] +[ext_resource type="Script" uid="uid://yadts2ydgfs6" path="res://character/player/hsm/fall.gd" id="29_u55fa"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"] radius = 0.54541016 @@ -98,8 +108,37 @@ script = ExtResource("13_look") [node name="Motor" type="Node" parent="." unique_id=1043927815] script = ExtResource("14_motor") -walk_speed = 2.419 -sprint_speed = 3.624 [node name="Pickup" type="Node" parent="." unique_id=1587230140] script = ExtResource("15_pickup") + +[node name="HSM" type="LimboHSM" parent="." unique_id=100369043] +update_mode = 2 +script = ExtResource("20_7gte1") + +[node name="Grounded" type="LimboHSM" parent="HSM" unique_id=159625644] +script = ExtResource("21_k6mlu") + +[node name="Idle" type="LimboState" parent="HSM/Grounded" unique_id=1876145612] +script = ExtResource("22_r212m") + +[node name="Walk" type="LimboState" parent="HSM/Grounded" unique_id=689938768] +script = ExtResource("23_8bmvw") + +[node name="Crouch" type="LimboState" parent="HSM/Grounded" unique_id=1470963204] +script = ExtResource("24_2pong") + +[node name="Sneak" type="LimboState" parent="HSM/Grounded" unique_id=988596449] +script = ExtResource("25_tkl2s") + +[node name="Sprint" type="LimboState" parent="HSM/Grounded" unique_id=674736640] +script = ExtResource("26_vhygx") + +[node name="Airborne" type="LimboHSM" parent="HSM" unique_id=1349311867] +script = ExtResource("27_s2r15") + +[node name="Jump" type="LimboState" parent="HSM/Airborne" unique_id=2079971118] +script = ExtResource("28_ed2qs") + +[node name="Fall" type="LimboState" parent="HSM/Airborne" unique_id=233377038] +script = ExtResource("29_u55fa") diff --git a/character/player/player_animator.gd b/character/player/player_animator.gd index ae137bf..3b32b41 100644 --- a/character/player/player_animator.gd +++ b/character/player/player_animator.gd @@ -2,8 +2,10 @@ class_name PlayerAnimator extends Node ## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。 ## -## 控制器每物理帧调一次 set_motion(),本节点据此决定 AnimState 并切片段。 -## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,控制器不认识任何片段名。 +## 「现在该播哪个状态、按什么速度为基准」由 hsm/ 的 LimboHSM 状态机决定: +## 状态在 _enter 时调 play_state(状态, 基准速度) 切片段, +## 控制器每物理帧调 set_gait() 按实际水平速度刷播放倍率。 +## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,状态机不认识任何片段名。 ## ## 节点接法:本脚本直接挂在 Body/AnimationPlayer 上 —— 这时 animation_player 留空, ## _ready() 会在父节点的子节点里找 AnimationPlayer,找到的就是它自己。 @@ -36,32 +38,21 @@ const ANIM_LOOPED: Array[int] = [ @export_group("播放") ## 动作切换的交叉淡入时间(秒)。0 为硬切 @export var blend_time: float = 0.15 -## 低于此水平速度就视为站立(米/秒) -@export var idle_threshold: float = 0.2 ## 播放倍率下限:被墙抵住、起步那一瞬,脚步不该慢到像在滑冰 @export var speed_scale_min: float = 0.65 ## 播放倍率上限:防止速度被外力拉高时脚步快到抽搐 @export var speed_scale_max: float = 1.6 -@export_group("片段基准速度") -## 各片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数: -## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,两边不用对齐。 -## -## 注意:这几个数和「模型缩放」严格绑定 —— 角色尺寸一变,同一个片段一个循环跨出的 -## 世界距离也按同比例变,所以基准速度必须跟着乘同一系数,否则脚步会明显打滑。 -## 当前值对应 0.15 m 身高(基准系数 k ≈ 0.0857,推导见 player_motor.gd 顶部的说明)。 -## -## sneak_ref_speed 要和 player_motor.gd 的 crouch_speed 保持一致 —— -## 蹲着走时播的就是 SNEAK 片段,两边不等就会打滑。(当前两者都是 0.2143) -@export var walk_ref_speed: float = 0.4286 -@export var run_ref_speed: float = 0.7286 -@export var crouch_ref_speed: float = 0.2143 -@export var sneak_ref_speed: float = 0.2143 - ## 状态 → AnimationPlayer 里真正的片段键(形如 "walk/walk")。_ready() 里解析一次。 ## 不直接拿 ANIM_CLIPS 的值去播,是因为多库时短名查不到 —— 见 _ready() 里的说明。 var _clips: Dictionary = {} +## 当前片段的基准速度(米/秒):play_state() 时由 HSM 状态传入。 +## 倍率 = 实际水平速度 / 它。状态自己清楚它把 motor 的哪个速度当目标, +## 由它传入就不会出现「两处各存一份基准速度、改了一边忘了另一边」的打滑。 +## 0(或负)= 该片段不参与速度缩放(站立 / 原地蹲 / 跳 / 落一律原速)。 +var _ref_speed: float = 0.0 + ## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。 var anim_state: int = AnimState.IDLE: set(value): @@ -115,17 +106,23 @@ func _ready() -> void: # ---------------- 对外接口 ---------------- +## HSM 状态在 _enter 时调用:切到该动作状态(setter 负责切片段),并登记 +## 该片段的基准速度。「播什么」是状态机的决定,「怎么播」(交叉淡入、循环、倍率) +## 才是本节点的职责。 +func play_state(state: AnimState, ref_speed: float = 0.0) -> void: + # 先写基准速度再切片段:万一 setter 里有人读倍率,也不会用上旧的。 + _ref_speed = maxf(ref_speed, 0.0) + anim_state = state + + ## 控制器每物理帧调用一次(放在 move_and_slide() 之后)。 -## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。 -## crouching 就是「按住了蹲键」这个输入事实;蹲着不动算 CROUCH、蹲着走算 SNEAK, -## 这个区分在这里做,控制器不需要知道两个状态有什么不一样。不传就是普通站立。 -func set_motion( - velocity: Vector3, on_floor: bool, sprinting: bool = false, crouching: bool = false -) -> void: - var state := _resolve_state(velocity, on_floor, sprinting, crouching) - # 先写倍率再切片段 —— 顺序反了会让新片段用一帧旧倍率。 - anim_speed = _speed_scale_for(state, velocity) - anim_state = state # setter 负责切片段 +## 只按当前水平速度刷播放倍率 —— 不切片段、不回写任何物理量,单向耦合的边界不变。 +## play_state() 在状态 _enter(本帧更早)时执行,这里只会更新倍率。 +func set_gait(velocity: Vector3) -> void: + if _ref_speed <= 0.0: + anim_speed = 1.0 + return + anim_speed = Vector2(velocity.x, velocity.z).length() / _ref_speed ## 当前正在播的片段名。调试与测试用。 @@ -137,46 +134,6 @@ func current_clip() -> String: # ---------------- 内部 ---------------- -## 优先级:空中 > 蹲 > 移动。 -## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。 -## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。 -## 蹲着不动是 CROUCH(原地蹲),蹲着走是 SNEAK(潜行)—— 两者只差「有没有在移动」。 -func _resolve_state( - velocity: Vector3, on_floor: bool, sprinting: bool, crouching: bool -) -> int: - if not on_floor: - # 上升用 jump、越过顶点用 fall。jump 片段长 0.5s,而上升时间是 - # jump_velocity / 有效重力(1.5 / 4.9 ≈ 0.31s),片段只播得完约六成 —— - # 越过顶点立刻切 fall,观感上是连贯的。 - return AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL - var moving := Vector2(velocity.x, velocity.z).length() >= idle_threshold - if crouching: - return AnimState.SNEAK if moving else AnimState.CROUCH - if not moving: - return AnimState.IDLE - return AnimState.RUN if sprinting else AnimState.WALK - - -## 播放倍率 = 实际水平速度 / 该片段的基准速度。 -## 只有循环的位移动作参与缩放,跳跃 / 下落一律原速(它们不该被速度拉扯)。 -func _speed_scale_for(state: int, velocity: Vector3) -> float: - var ref := 0.0 - match state: - AnimState.WALK: - ref = walk_ref_speed - AnimState.RUN: - ref = run_ref_speed - AnimState.CROUCH: - ref = crouch_ref_speed - AnimState.SNEAK: - ref = sneak_ref_speed - _: - return 1.0 - if ref <= 0.0: - return 1.0 - return Vector2(velocity.x, velocity.z).length() / ref - - func _play(clip: String, blend: float) -> void: if animation_player == null or clip.is_empty(): return diff --git a/character/player/player_motor.gd b/character/player/player_motor.gd index d3f15e1..c09da1a 100644 --- a/character/player/player_motor.gd +++ b/character/player/player_motor.gd @@ -4,43 +4,41 @@ extends Node ## ## 只算速度写回 body.velocity,**不调 move_and_slide()** —— 推进由 Player 统一做。 ## 这样「算速度 → 推进 → 播动画」的顺序永远固定,不受节点树顺序影响。 +## +## 「走多快、蹲不蹲、跑不跑」由 hsm/ 的 LimboHSM(LimboAI)决定: +## 状态在 _enter 时写 target_speed / crouching / sprinting, +## 本组件只负责把这份「意图」用物理方式变成速度。 +## +## 设计基准:**小人国玩法,角色身高约 0.2 m**。速度不按「人类等比缩放」推导, +## 直接按鼠类疾走的手感定标:走 ≈ 12 身长/秒、疾跑 ≈ 18 身长/秒、 +## 跳高 ≈ 1.15 身高。速度/加速度按手感单独调即可,不必成套换算; +## 唯独 jump_velocity 与 gravity_scale 是**配对**的(反解公式见重力一节)。 ## 跳跃输入缓冲(秒):按下跳跃后这段时间内若落地,自动补上一次起跳 const JUMP_BUFFER_TIME: float = 0.1 @export_group("移动") -## 【设计基准】以人类为参照做等比缩放:人 1.75 m,游戏惯例 walk 5.0 / sprint 8.5 m/s。 -## 本角色身高 0.15 m,系数 k = 0.15 / 1.75 ≈ 0.0857,下面所有线性量都是原值 × k。 -## 这样「每秒走过几个自身体长」和人类一致,动作比例看起来才对。 -## -## 换尺寸时的公式:线性量(速度/加速度)× k';跳跃那两个数(gravity_scale 与 jump_velocity)用下面的反解公式重算。 -## -## 常规移动速度(米/秒)—— 约 2.9 身长/秒,和人类走路同一比例 -@export var walk_speed: float = 0.4286 -## 疾跑速度(米/秒)—— 约 4.9 身长/秒 -@export var sprint_speed: float = 0.7286 -## 蹲行速度(米/秒)—— 默认是走速的一半。蹲下时按住 Shift 也不会疾跑。 -## 注意:改这个值要同步改 player_animator.gd 的 sneak_ref_speed,否则蹲走时脚步会打滑。 -@export var crouch_speed: float = 0.2143 -## 地面加速度(米/秒²)—— 约 0.11 秒加到全速,起步干脆 -@export var acceleration: float = 3.8571 -## 空中加速度(米/秒²) -@export var air_acceleration: float = 1.0286 -## 起跳初速度(米/秒)—— 配下面的 gravity_scale 0.5(g = 4.9)时, -## 跳跃高度 ≈ 0.230 m(约 1.53 身高)、滞空 ≈ 0.61 s。 -## 这两个数是**配对**的:改重力必须按「反解公式」重算它,否则跳跃高度按 1/g 暴涨。 +## 常规移动速度(米/秒)—— ≈ 12 身长/秒(0.2 m 身高),小鼠疾走的步频感 +@export var walk_speed: float = 2.419 +## 疾跑速度(米/秒)—— ≈ 18 身长/秒 +@export var sprint_speed: float = 3.624 +## 蹲行速度(米/秒)—— 走速的一半。蹲下时按住 Shift 也不疾跑(蹲伏优先)。 +@export var crouch_speed: float = 1.21 +## 地面加速度(米/秒²)—— 约 0.11 秒加到走速全速,起步干脆(鼠类的一窜一动) +@export var acceleration: float = 22.0 +## 空中加速度(米/秒²)—— 约地面的 1/4:空中能修正方向,但不至于完全失控 +@export var air_acceleration: float = 5.9 +## 起跳初速度(米/秒)—— 配 gravity_scale 0.5(有效 g = 4.9)时, +## 跳高 ≈ 0.23 m(约 1.15 身高)、滞空 ≈ 0.61 s。**与 gravity_scale 配对**,见下。 @export var jump_velocity: float = 1.5 @export_group("重力") -## 重力缩放系数。0.5 = 重力减半(有效 g = 4.9 m/s²)。 +## 重力缩放系数。0.5 = 有效重力 4.9 m/s²。 ## -## 为什么不用 1.0(物理真实的 9.8):重力加速度本身和物体大小无关,但我们的速度是按身材 -## 等比缩过的,于是落体相对过快。更关键的是跳跃 —— 由 h = v²/2g 和 T = 2v/g 可得 -## h = g·T²/8:**固定重力下滞空翻倍、高度要涨 4 倍**。所以「想跳得久又不想跳太高」 -## 只能降重力。取 0.5 时:跳高 0.230 m(1.53 身高)、滞空 0.61 s; -## 若维持 1.0,同样 0.23 m 的高度只有 0.43 s 滞空,落地明显仓促。 +## 小个子角色若用真实 9.8,同样的起跳速度滞空短一半、跳起来像被拍回去 —— +## 降重力换滞空,跳跃才有「腾空感」。0.5 时:跳高 0.23 m、滞空 0.61 s。 ## -## ⚠ 改这个值必须同步改 jump_velocity(跳跃高度 = v²/2g)。 +## ⚠ 与 jump_velocity **配对**:改一个必须按反解公式重算另一个(跳跃高度 = v²/2g)。 ## 反解公式 —— 定目标高度 h(米)与滞空 T(秒): ## g = 8h / T² → gravity_scale = g / 9.8 ## jump_velocity = 4h / T @@ -49,8 +47,12 @@ const JUMP_BUFFER_TIME: float = 0.1 ## 那是全局的,会连带把拖拽/投掷的刚体一起变飘。 @export_range(0.0, 2.0, 0.01) var gravity_scale: float = 0.5 -## 本帧是否在疾跑 / 是否按住蹲键。 -## Player 读这两个值转交给 PlayerAnimator 切片段,crouching 还用来压低摄像机。 +## 当前目标水平速度(米/秒)—— 由 HSM 状态在 _enter 时写入,step() 朝它加减速。 +## Idle / Crouch 写 0(自然减速到停),Walk / Sprint / Sneak 写各自速度。 +var target_speed: float = 0.0 + +## 是否蹲着 / 是否疾跑 —— 由 HSM 状态写入的**事实镜像**,本组件不再自己读输入推断: +## crouching 给 Look 压低摄像机,两者都给 DebugPanel 显示。 var sprinting: bool = false var crouching: bool = false @@ -73,6 +75,23 @@ func _unhandled_input(event: InputEvent) -> void: _jump_buffer = JUMP_BUFFER_TIME +## 本帧是否按着方向键。HSM 状态(Idle/Walk 等的转换判定)与 step() 共用这个口径。 +func has_move_input() -> bool: + return Input.get_vector("move_left", "move_right", "move_back", "move_forward") \ + .length_squared() > 0.000001 + + +## 由 HSM 地面状态每帧调用:跳跃缓冲还有效且脚下有地板,就当场起跳并返回 true。 +## 起跳「动作」放在状态判定处执行(而不是 step() 里), +## 状态机同一帧就能拿到 vy > 0 这个事实、转进 Jump,动画不迟到一帧。 +func try_jump() -> bool: + if _jump_buffer > 0.0 and _body != null and _body.is_on_floor(): + _body.velocity.y = jump_velocity + _jump_buffer = 0.0 + return true + return false + + ## 每物理帧由 Player 调用一次,必须排在 move_and_slide() 之前。 ## 只写 _body.velocity,不做位移推进。 func step(delta: float) -> void: @@ -87,12 +106,9 @@ func step(delta: float) -> void: # gravity_scale 每帧现读,方便运行中拖着试手感(1.0 = 物理真实的 9.8) _body.velocity.y -= _gravity * gravity_scale * delta - # --- 跳跃(读取输入缓冲)--- + # --- 跳跃缓冲倒计时(执行挪到 try_jump(),由 HSM 地面状态调用)--- if _jump_buffer > 0.0: _jump_buffer -= delta - if _body.is_on_floor(): - _body.velocity.y = jump_velocity - _jump_buffer = 0.0 # --- 相对视线方向的水平输入 --- # 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。 @@ -103,16 +119,7 @@ func step(delta: float) -> void: if wish.length_squared() > 0.000001: wish = wish.normalized() - # 只有真正按了方向键才允许疾跑(避免原地"疾跑"触发跑动动画) - var wants_sprint := Input.is_action_pressed("sprint") and input_dir.length_squared() > 0.000001 - crouching = Input.is_action_pressed("crouch") - # 蹲伏优先于疾跑:蹲着时按住 Shift 不算疾跑,速度取蹲行速度。 - sprinting = wants_sprint and not crouching - var target_speed := walk_speed - if crouching: - target_speed = crouch_speed - elif sprinting: - target_speed = sprint_speed + # 目标速度来自 HSM 状态(target_speed),不在本组件里按蹲键/Shift 分档。 var accel := acceleration if _body.is_on_floor() else air_acceleration var horizontal := Vector2(_body.velocity.x, _body.velocity.z) diff --git a/ui/debug_panel.gd b/ui/debug_panel.gd index 990d211..fecde46 100644 --- a/ui/debug_panel.gd +++ b/ui/debug_panel.gd @@ -13,8 +13,8 @@ extends Control ## 每帧拼字符串会持续产生垃圾,而 10 Hz 对肉眼已经是连续的。 @export var refresh_interval: float = 0.1 ## 角色视觉身高(米)。只用来把世界单位换算成「身长」和「倍身高」两个相对量。 -## 它和 player.tscn 里 Player.scale 是绑定的,改角色尺寸时要一起改。 -@export var body_height: float = 0.15 +## 小人国玩法基准:约 0.2 m。 +@export var body_height: float = 0.2 @export_group("外观") ## 距屏幕右上角的边距(像素)