From d367c8370be93d985fd6d7d7f01a8050f5b96516 Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Fri, 2 Oct 2026 09:32:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy-ai/memory/2026-10-02.md | 52 ++++++++++++++ .workbuddy-ai/memory/MEMORY.md | 42 +++++++++-- character/player/player.gd | 31 ++++---- character/player/player.tscn | 8 +-- character/player/player_animator.gd | 105 +++++++++++++++++++++------- character/player/player_sync.tres | 4 +- project.godot | 5 ++ tests/test_net_replication.gd | 50 ++++++++++--- tests/test_zipline.gd | 3 + world/zipline/zipline.gd | 8 +-- 10 files changed, 240 insertions(+), 68 deletions(-) diff --git a/.workbuddy-ai/memory/2026-10-02.md b/.workbuddy-ai/memory/2026-10-02.md index 9687cf1..cdd7c9a 100644 --- a/.workbuddy-ai/memory/2026-10-02.md +++ b/.workbuddy-ai/memory/2026-10-02.md @@ -14,3 +14,55 @@ 标定类问题:`get_bone_pose_position()` 是相对父骨的姿态,Z 轴受父骨朝向影响,GLTF 导入后与 Blender 不一致,z_min/z_max 必须实测标定;`face_index` 只做了亮度乘,没真正切换表情图层。 与本项目相关的判断:角色是 `character-male-e.glb`,需先确认有独立面部材质槽 + 展平 UV;多人下 SubViewport/材质必须每实例一份,远端玩家的画要按 PNG 字节流同步。 + +## 重写 player_animator.gd(7 状态) + +按新枚举 `{IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP}` 重写 `character/player/player_animator.gd`。 +改动:加 `class_name PlayerAnimator`(`player.gd` 用它当类型)、新增 `Stance{STAND,CROUCH,SNEAK}`、 +`set_motion(velocity, on_floor, sprinting=false, stance=STAND)`、片段基准速度扩到 walk/run/crouch/sneak。 + +实测结论(用 @tool 副本跑 McpTestSuite,14 条断言全过): +- 短片段名查不到,必须用 `"库名/片段名"`(`walk/walk`);`_ready()` 里扫库解析一次并写入 `_clips`。 +- 状态映射、自动绑兄弟 AnimationPlayer、`play("walk/walk")`、loop_mode 补丁、倍率 5/5≈1.0 均正确。 +- `class_name` 之后 `self is AnimationPlayer` 是编译期错误 → 脚本不能挂在 AnimationPlayer 节点上。 + +**未改、留给用户决定的场景问题**(`player.tscn`): +1. 脚本目前挂在 `Body/AnimationPlayer` 上,而 `player.gd`、`player_sync.tres`、`tests/test_net_replication.gd` 都找 `Body/PlayerAnimator` —— 需要新增独立 PlayerAnimator 节点。 +2. 该节点的 6 个 float 导出在 .tscn 里被序列化成 `null`(实测运行时是 `Nil`),会覆盖脚本默认值。 +3. `tests/test_net_replication.gd` 里的 `REQUIRED_CLIPS` 仍是旧的 `sprint`,需要同步成新状态集。 + +## 收尾:按用户决定接线 + 清场景脏数据 + +用户决定「脚本就挂在 `Body/AnimationPlayer` 上」,据此改完并验证(`test_run` 20/20,320 条断言;实机跑 player.tscn 确认 idle→fall 切换正常)。 + +- `player_animator.gd`:文档改成「脚本挂在 AnimationPlayer 上、`animation_player` 留空自绑自身」。 +- `player.gd`:`$Body/PlayerAnimator` → `$Body/AnimationPlayer`,场景结构注释同步;`_ride_zipline` 里 `tan` 改名 `tan_dir`(消 built-in 遮蔽警告)。 +- `player_sync.tres`:同步字段路径改 `Body/AnimationPlayer:anim_state` / `:anim_speed`。 +- `player.tscn`:删掉被序列化成 `null` 的 6 个 float 导出(`blend_time` 等)—— 实测运行时确实是 `Nil`,会覆盖脚本默认值。 +- `tests/test_net_replication.gd`:路径、`REQUIRED_CLIPS` 全部同步;新增 `_find_clip()` 按库解析(短名断言会假红)。 +- `tests/test_zipline.gd`:`test_interact_action_exists` 先调 `InputMap.load_from_project_settings()` —— 编辑器进程的 InputMap 不带项目动作(实测 `interact`/`jump` 均为 false,只有 `ui_accept`),这条一直假红。 +- `world/zipline/zipline.gd`:`tan` → `tan_dir`。 + +**Godot MCP 注意**:`node_get_properties` 读不到脚本的 @export 属性(`unknown_fields`),但 `node_set_property` 能写进去;数值标量经 MCP 会变成字符串(`node_set_property` 返回 `"0.15"`),`batch_execute` 里的 `set_property` 直接报 INTERNAL_ERROR。改 `.tscn` / `.tres` 这类被编辑器占用的文件,直接用文本工具写反而更省事(`.tscn` 可写、`.tres` 报 EBUSY,用 `filesystem_manage(op="write_text")`),写完 `scene_open(force_reload=true)` 让编辑器重新读盘。 + +## 接线:Ctrl 蹲下 / Ctrl+移动 潜行 + +用户定的规则:`crouch` 绑 Ctrl;**蹲着不动 = CROUCH,蹲着走 = SNEAK**。 + +- `project.godot`:用 `input_map_manage(op="ensure_binding", {action:"crouch", event_type:"key", keycode:"Ctrl"})` + 加的动作,落在 keycode 4194326,和已有的 `sprint`(Shift/4194325) 同一套写法。 +- `player_animator.gd`:**删掉 `Stance` 枚举**,`set_motion` 第 4 个参数改成 `crouching: bool`。 + 「蹲 / 潜行」的区分放回表现层(`moving := 水平速度 >= idle_threshold`),控制器只传「按没按 Ctrl」。 + 优先级改成 空中 > 蹲 > 移动。 +- `player.gd`:调用点传 `Input.is_action_pressed("crouch")`;操作说明补上 Ctrl。 +- `tests/test_net_replication.gd`:加 `test_crouch_action_exists`(先 `InputMap.load_from_project_settings()`)。 + +验证:`test_run` 21/21;另用 @tool 副本跑了 4 组状态映射断言(Ctrl+不动→CROUCH、Ctrl+移动→SNEAK、 +Ctrl+Shift→仍是 SNEAK、空中→JUMP/FALL 优先于蹲),片段也确认切到了 `crouch/crouch` / `sneak/sneak`。 + +**踩坑**:`test_run` 的脚本缓存按路径,删掉重建同名文件会跑旧版本 —— 第一次探针就因此假失败 +(旧版 `stance: int` 收到 `true` 被当成 `Stance.CROUCH`)。换文件名才拿到真实结果。 + +**未做**:蹲下目前不改移动速度、也不改碰撞胶囊高度 —— 潜行时用走速(5 m/s)播 sneak 片段, +`speed_scale` 会被 clamp 到上限 1.6。要不要减速/压低碰撞盒等用户定。 + diff --git a/.workbuddy-ai/memory/MEMORY.md b/.workbuddy-ai/memory/MEMORY.md index 76190f2..b8c30bb 100644 --- a/.workbuddy-ai/memory/MEMORY.md +++ b/.workbuddy-ai/memory/MEMORY.md @@ -2,18 +2,37 @@ ## 工程约定 -- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层在 `Body/PlayerAnimator`。 - 新增动作状态改 `player_animator.gd` 的 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。 +- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `player_animator.gd` 直接挂在 + `Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。 + 新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。 - 组件的可调参数放在组件自己身上(如 Zipline 的速度 / 刹车 / 吸附半径),控制器只读不定义。 - 头顶 UI(血条、交互提示)是 `player.tscn` 里的 Label3D 节点,不在代码里动态创建。 - 测试用 `McpTestSuite`(`addons/godot_ai/testing`),脚本放 `res://tests/`。 +## 动画(AnimationPlayer) + +- **多 AnimationLibrary 下,片段必须用「库名/片段名」的限定键**。实测(4.7.2): + `has_animation("idle")` 为假,`has_animation("idle/idle")` 才为真,`get_animation` 同理。 + 本项目每个 glb 导入成一个库、库名与片段同名,所以真键形如 `walk/walk`。 + 短名播不出来且不报错,是典型的静默失效 —— `PlayerAnimator._ready()` 里扫库解析一次。 +- 导入的 GLB 片段 `loop_mode` 默认是 `NONE`,编辑器里改不了,只能运行时补。 +- **带 `class_name` 的脚本挂在 AnimationPlayer 上没问题,但不能写 `self is AnimationPlayer`**: + `class_name X extends Node` 之后 `self` 的静态类型就是 X,那句是编译期错误。 + 要拿「自己就是播放器」这个事实得绕一圈:遍历 `get_parent().get_children()` 找 `is AnimationPlayer` 的兄弟 + —— 自己就在里面。见 `PlayerAnimator._ready()`。 +- 状态集 `IDLE/WALK/CROUCH/RUN/FALL/SNEAK/JUMP`。签名 `set_motion(velocity, on_floor, sprinting, crouching)`: + 第 4 个参数是「按住蹲键」这个输入事实(`crouch` 动作 = Ctrl),蹲着不动 → CROUCH、蹲着走 → SNEAK, + 这个区分在表现层做,控制器不需要知道两者差别。优先级:空中 > 蹲 > 移动。 + ## Godot MCP 踩坑 -- **数值标量会被字符串化**:直接调 `node_set_property` 传 `1` / `false` 会报 - `WRONG_TYPE: Cannot write String to a int/bool property`。 - 绕法:改用 `batch_execute`,把 value 放进每条 command 的 `params` 里(嵌套数字能正常保型)。 - 字符串、Vector / Color 对象不受影响。 +- **数值标量经 MCP 会被字符串化**:`node_set_property` 传 `0.15` 能写进去,但回显是 `"0.15"`; + 而 `batch_execute` 里包 `{"command": "set_property", "params": {...}}` 直接报 + `INTERNAL_ERROR … handler returned malformed result`(和旧版本文档里写的正好相反,以实测为准)。 + 改被编辑器占用的 `.tscn` / `.tres` 时别绕这个弯 —— 直接写文本更省事:`.tscn` 可写, + `.tres` 报 EBUSY 就改用 `filesystem_manage(op="write_text")`。 +- **`node_get_properties` 读不到脚本的 @export 属性**(会列进 `unknown_fields`),但 `node_set_property` 能写。 + 改完场景文件要 `scene_open(force_reload=true)` 让编辑器重读盘,否则节点上还挂着旧脚本实例。 - **@tool 脚本现造的节点不会写进 .tscn**:`add_child` 后不给 `owner`, `PackedScene::pack()` 就会跳过它。缆绳网格因此可以纯由锚点推导,不存在「文件和锚点对不上」的脏数据。 - **新 `class_name` 要跑一次 `filesystem_manage(op="scan")`** 才进全局类表, @@ -22,3 +41,14 @@ 想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。 - 测试跑在编辑器进程里,`@tool` 节点必须真的 `add_child` 进编辑中场景才会跑 `_ready()`; 用完在 `teardown()` 摘掉,节点名以 `_McpTest` 开头可被测试运行器兜底清理。 +- **非 `@tool` 脚本在编辑器里是「placeholder instance」**:`load(path).can_instantiate()` 返回 false, + `.new()` 报 `Nonexistent function 'new' in base 'GDScript'`,`set_script` 后调方法报 + `Attempt to call a method on a placeholder instance`(`player.gd` 也一样,不是脚本本身的问题)。 + 想在 `test_run` 里验非 @tool 脚本,就临时生成一个「去掉 class_name、加上 @tool」的副本喂给测试,跑完删掉。 +- **`test_run` 的 GDScript 编译缓存是按路径的**:删掉再重建同名脚本,跑到的可能还是旧版本 + (现象:逻辑明明改了,测试却按旧逻辑失败)。要验新代码就换个文件名,或者重启编辑器。 +- **编辑器进程的 `InputMap` 只有内建动作**(`ui_accept` 有,`interact` / `jump` 没有)—— + 项目动作要等游戏进程启动时才 load 进去。所以断言 `InputMap.has_action(...)` 的测试在编辑器里必然假红, + 先调一次 `InputMap.load_from_project_settings()`。 +- 改输入动作走 `input_map_manage(op="ensure_binding", params={action, event_type="key", keycode="Ctrl"})`, + 幂等且直接落 `project.godot`,比手改文件稳。 diff --git a/character/player/player.gd b/character/player/player.gd index 44c38b9..aebeb83 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -1,15 +1,13 @@ extends CharacterBody3D ## 第三人称控制器:只管「怎么动」—— 输入、移动物理、相机、血条、联机分叉。 -## 表现层(动画)在 Body/PlayerAnimator 里,本文件不认识任何片段名。 +## 表现层(动画)在 Body/AnimationPlayer 上(挂 player_animator.gd),本文件不认识任何片段名。 ## ## 场景结构: ## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转 ## ├─ Collision (CollisionShape3D, CapsuleShape3D) ## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面 -## │ ├─ PlayerAnimator (PlayerAnimator.gd) ← 动作状态机:只读运动、只写动画 -## │ └─ character-male-e2 (Node3D) -## │ ├─ character-male-e/Skeleton3D ← 骨骼 + 蒙皮网格(character-male-e.glb) -## │ └─ AnimationPlayer ← 动作库,由 PlayerAnimator 驱动 +## │ ├─ mouse (Node3D) ← 模型(mouse.glb):MouseRig/Skeleton3D + 各部位 MeshInstance3D +## │ └─ AnimationPlayer ← 动作库 + 表现层(挂 player_animator.gd):只读运动、只写动画 ## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转 ## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障 ## │ └─ Camera (Camera3D) @@ -20,7 +18,7 @@ extends CharacterBody3D ## 滑索:走到缆下按 E 挂上,沿缆滑向低端,缆上按 S 刹车、空格跳离。 ## 手感参数(速度/刹车/吸附半径)全在 Zipline 组件上,本文件只读不定义 —— 见文件末尾。 ## -## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,E 滑索,Esc 释放/锁定鼠标 +## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,Ctrl 蹲下(蹲着走即潜行),空格跳跃,E 滑索,Esc 释放/锁定鼠标 ## 跳跃输入缓冲帧数(输入事件在渲染帧到达,物理帧读取,避免漏跳) const JUMP_BUFFER_FRAMES: int = 6 @@ -62,8 +60,8 @@ const JUMP_BUFFER_FRAMES: int = 6 ## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏 @onready var _hint: Label3D = $InteractHint -## 动画状态机(挂在 Body 下)。只把「运动结果」交给它,也只知道它会自己播动画。 -@onready var animator: PlayerAnimator = $Body/PlayerAnimator +## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。 +@onready var animator: PlayerAnimator = $Body/AnimationPlayer var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) var _yaw: float = 0.0 @@ -197,7 +195,12 @@ func _physics_process(delta: float) -> void: move_and_slide() # 动画只读运动结果。控制器不认识任何片段名,动画器也不认识输入。 - animator.set_motion(velocity, is_on_floor(), Input.is_action_pressed("sprint")) + animator.set_motion( + velocity, + is_on_floor(), + Input.is_action_pressed("sprint"), + Input.is_action_pressed("crouch"), + ) # ---------------- 滑索 ---------------- @@ -256,10 +259,10 @@ func _detach_zipline(hop: bool = false) -> void: ## 让物理引擎去推反而会和「贴着曲线走」这个硬约束打架。 ## velocity 仍然维护 —— 一是给动画器读,二是脱手后能带着惯性飞出去。 func _ride_zipline(delta: float) -> void: - var tan := _zipline.tangent(_zip_t) * float(_zip_dir) + var tan_dir := _zipline.tangent(_zip_t) * float(_zip_dir) # 重力沿缆方向的分量:缆越陡加速越猛,平缆上几乎不加速。 - # 下坡时 tan.y 是负的,所以前面要加负号。 - _zip_speed += -_gravity * _zipline.gravity_scale * tan.y * delta + # 下坡时 tan_dir.y 是负的,所以前面要加负号。 + _zip_speed += -_gravity * _zipline.gravity_scale * tan_dir.y * delta if Input.is_action_pressed("move_back"): _zip_speed -= _zipline.brake_accel * delta _zip_speed = clampf(_zip_speed, 0.0, _zipline.max_speed) @@ -276,8 +279,8 @@ func _ride_zipline(delta: float) -> void: _zip_t = next_t _zip_grab_offset = _zip_grab_offset.lerp(Vector3.ZERO, clampf(delta / 0.12, 0.0, 1.0)) global_position = _zipline.ride_point(_zip_t) + _zip_grab_offset - velocity = tan * _zip_speed - var travel_yaw := atan2(-tan.x, -tan.z) + velocity = tan_dir * _zip_speed + var travel_yaw := atan2(-tan_dir.x, -tan_dir.z) body.rotation.y = lerp_angle(body.rotation.y, travel_yaw, clampf(turn_speed * delta, 0.0, 1.0)) # 相机也跟着摆到行进方向背后 —— 否则人是横着飞的,看不见前面要去哪。 # 速率刻意比转身慢:玩家用鼠标看的视角不会被立刻拽回去。 diff --git a/character/player/player.tscn b/character/player/player.tscn index 1e85561..44a4cf6 100644 --- a/character/player/player.tscn +++ b/character/player/player.tscn @@ -37,7 +37,7 @@ fov = 82.2 [node name="Body" type="Node3D" parent="." unique_id=394283421] [node name="mouse" parent="Body" unique_id=1995806863 instance=ExtResource("2_wu8e1")] -transform = Transform3D(0.18240133, 0, 0, 0, 0.18240133, 0, 0, 0, 0.18240133, 0, 0.8705118, 0) +transform = Transform3D(-0.18240134, 0, -1.5946032e-08, 0, 0.18240134, 0, 1.5946032e-08, 0, -0.18240134, 0, 0.8705118, 0) [node name="AnimationPlayer" type="AnimationPlayer" parent="Body" unique_id=1656879850] root_node = NodePath("../mouse") @@ -49,12 +49,6 @@ libraries/run = ExtResource("7_muf2e") libraries/sneak = ExtResource("8_jo6ux") libraries/walk = ExtResource("9_fkbv7") script = ExtResource("3_g20gq") -blend_time = null -idle_threshold = null -speed_scale_min = null -speed_scale_max = null -walk_ref_speed = null -sprint_ref_speed = null [node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] replication_config = ExtResource("3_wu8e1") diff --git a/character/player/player_animator.gd b/character/player/player_animator.gd index ee50a19..f62b197 100644 --- a/character/player/player_animator.gd +++ b/character/player/player_animator.gd @@ -1,23 +1,36 @@ +class_name PlayerAnimator extends Node +## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。 +## +## 控制器每物理帧调一次 set_motion(),本节点据此决定 AnimState 并切片段。 +## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,控制器不认识任何片段名。 +## +## 节点接法:本脚本直接挂在 Body/AnimationPlayer 上 —— 这时 animation_player 留空, +## _ready() 会在父节点的子节点里找 AnimationPlayer,找到的就是它自己。 +## 以后想把表现层拆成独立节点也行:挂到一个普通 Node 上、用 animation_player 指过去即可,代码不用改。 enum AnimState { IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP } +## 状态 → 片段名。这里写短名就行,_ready() 会把它们解析成 AnimationPlayer 里真正的键。 const ANIM_CLIPS: Dictionary = { AnimState.IDLE: "idle", AnimState.WALK: "walk", AnimState.CROUCH: "crouch", AnimState.RUN: "run", - AnimState.SNEAK: "SNEAK", + AnimState.SNEAK: "sneak", AnimState.JUMP: "jump", AnimState.FALL: "fall", } -## 需要循环的状态。 -const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.RUN] +## 需要循环的状态。其余(jump / fall)播完停在最后一帧。 +## crouch / sneak 也是循环的位移动作 —— 漏掉它们,蹲着走一步就会定住。 +const ANIM_LOOPED: Array[int] = [ + AnimState.IDLE, AnimState.WALK, AnimState.CROUCH, AnimState.RUN, AnimState.SNEAK +] @export_group("接线") -## 要驱动的 AnimationPlayer。留空则在本节点的兄弟子树里自动找一个。 -## 换模型后这里重新指一下即可 —— 这也是它能脱离具体模型的原因。 +## 要驱动的 AnimationPlayer。留空则自动找:脚本挂在 AnimationPlayer 上时就是它自己, +## 挂到独立节点上时是父节点下的第一个。换模型后重指一下即可 —— 这也是它能脱离具体模型的原因。 @export var animation_player: AnimationPlayer @export_group("播放") @@ -31,11 +44,16 @@ const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.RUN] @export var speed_scale_max: float = 1.6 @export_group("片段基准速度") -## walk 片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数: -## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,无需同步改两边。 +## 各片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数: +## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,两边不用对齐。 @export var walk_ref_speed: float = 5.0 -## sprint 片段的基准速度(米/秒) -@export var sprint_ref_speed: float = 8.5 +@export var run_ref_speed: float = 8.5 +@export var crouch_ref_speed: float = 2.5 +@export var sneak_ref_speed: float = 1.6 + +## 状态 → AnimationPlayer 里真正的片段键(形如 "walk/walk")。_ready() 里解析一次。 +## 不直接拿 ANIM_CLIPS 的值去播,是因为多库时短名查不到 —— 见 _ready() 里的说明。 +var _clips: Dictionary = {} ## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。 var anim_state: int = AnimState.IDLE: @@ -43,7 +61,7 @@ var anim_state: int = AnimState.IDLE: if anim_state == value: return anim_state = value - _play(ANIM_CLIPS.get(anim_state, ""), blend_time) + _play(str(_clips.get(anim_state, "")), blend_time) ## 当前播放倍率(1.0 = 片段原速)。 ## 同样要复制:远端只有 position,算不出真实速度,只能照抄权威端的倍率。 @@ -56,21 +74,48 @@ var anim_speed: float = 1.0: func _ready() -> void: + if animation_player == null and get_parent() != null: + # 脚本就挂在 AnimationPlayer 上,所以这里「在父节点的子节点里找」找到的其实是它自己; + # 表现层哪天拆成独立节点,这段也不用改 —— 找到的就是真正的播放器。 + # (不能写成 self as AnimationPlayer:加了 class_name 之后 self 的静态类型是本脚本,转不过去。) + for sibling in get_parent().get_children(): + if sibling is AnimationPlayer: + animation_player = sibling as AnimationPlayer + break if animation_player == null: push_warning("%s 没绑定 AnimationPlayer,角色不会有动画" % get_path()) return + + # 片段在 AnimationPlayer 里的键是「库名/片段名」—— 导入时每个 glb 各自成一个库, + # 短名 has_animation() / get_animation() 一律查不到(实测:has_animation("idle") 为假, + # has_animation("idle/idle") 才为真)。所以这里扫一遍库,把状态对应的真键找出来; + # 缺片段直接点名,免得又变成「名字看着对、播出来没反应」的静默失效。 + for state in ANIM_CLIPS: + var clip: String = ANIM_CLIPS[state] + for lib_name in animation_player.get_animation_library_list(): + var lib := animation_player.get_animation_library(lib_name) + if lib != null and lib.has_animation(clip): + _clips[state] = clip if String(lib_name).is_empty() else "%s/%s" % [lib_name, clip] + break + if not _clips.has(state): + push_warning("PlayerAnimator 找不到片段「%s」,对应状态不会有动画" % clip) + _patch_loop_modes() # 同步可能在 _ready() 之前就把 anim_state 写进来了 —— 那时 animation_player 还是 null, # setter 空跑了一趟。所以这里按「当前状态」补播一遍,不能写死 idle。 - _play(ANIM_CLIPS.get(anim_state, ""), 0.0) + _play(str(_clips.get(anim_state, "")), 0.0) # ---------------- 对外接口 ---------------- ## 控制器每物理帧调用一次(放在 move_and_slide() 之后)。 ## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。 -func set_motion(velocity: Vector3, on_floor: bool, sprinting: bool = false) -> void: - var state := _resolve_state(velocity, on_floor, sprinting) +## 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 负责切片段 @@ -85,25 +130,38 @@ func current_clip() -> String: # ---------------- 内部 ---------------- -func _resolve_state(velocity: Vector3, on_floor: bool, sprinting: bool) -> int: +## 优先级:空中 > 蹲 > 移动。 +## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。 +## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。 +## 蹲着不动是 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 5 / 重力 9.8 的上升时间。 return AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL - if Vector2(velocity.x, velocity.z).length() < idle_threshold: + 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.SPRINT if sprinting else AnimState.WALK + 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.SPRINT: - ref = sprint_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: @@ -114,15 +172,14 @@ func _speed_scale_for(state: int, velocity: Vector3) -> float: func _play(clip: String, blend: float) -> void: if animation_player == null or clip.is_empty(): return - if not animation_player.has_animation(clip): - return animation_player.play(clip, blend) - func _patch_loop_modes() -> void: + # 导入的 GLB 片段 loop_mode 默认是 NONE,播一遍就定在最后一帧; + # 这是导入产物的属性,编辑器里改不了,只能在运行时补。 for state in ANIM_LOOPED: - var clip: String = ANIM_CLIPS[state] - if not animation_player.has_animation(clip): + var clip: String = _clips.get(state, "") + if clip.is_empty(): continue animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR diff --git a/character/player/player_sync.tres b/character/player/player_sync.tres index 8942569..3fb97b9 100644 --- a/character/player/player_sync.tres +++ b/character/player/player_sync.tres @@ -10,9 +10,9 @@ properties/1/replication_mode = 1 properties/2/path = NodePath(".:health") properties/2/spawn = true properties/2/replication_mode = 1 -properties/3/path = NodePath("Body/PlayerAnimator:anim_state") +properties/3/path = NodePath("Body/AnimationPlayer:anim_state") properties/3/spawn = true properties/3/replication_mode = 1 -properties/4/path = NodePath("Body/PlayerAnimator:anim_speed") +properties/4/path = NodePath("Body/AnimationPlayer:anim_speed") properties/4/spawn = true properties/4/replication_mode = 1 diff --git a/project.godot b/project.godot index c32fc55..27df965 100644 --- a/project.godot +++ b/project.godot @@ -79,6 +79,11 @@ interact={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) ] } +crouch={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194326,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} [physics] diff --git a/tests/test_net_replication.gd b/tests/test_net_replication.gd index 5baec7f..bbe078d 100644 --- a/tests/test_net_replication.gd +++ b/tests/test_net_replication.gd @@ -5,28 +5,32 @@ extends McpTestSuite ## 三处典型的「静默失效」——都表现为「人站着不动」且不打任何错误: ## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER → 远端收不到字段; ## 2. track 路径带一层模型目录名前缀,换模型/改层级后整组失效; -## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/sprint 播一遍就定在最后一帧。 +## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/crouch/run/sneak 播一遍就定在最后一帧。 ## (loop 由 PlayerAnimator._ready() 在运行时补,编辑器测试覆盖不到,靠实机验证。) const CONFIG_PATH := "res://character/player/player_sync.tres" const PLAYER_SCENE_PATH := "res://character/player/player.tscn" ## 从 player.gd 抽出来的动画状态机。控制器只通过它间接驱动动画。 -const ANIMATOR_PATH := "Body/PlayerAnimator" +## 表现层脚本就直接挂在 AnimationPlayer 上,所以这两条路径指向同一个节点。 +const ANIMATOR_PATH := "Body/AnimationPlayer" const ANIMATOR_SCRIPT := "res://character/player/player_animator.gd" -## 真正干活的动画器。PlayerAnimator 没接线时按这个名字在自己兄弟子树里找它。 -const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer" +## 真正干活的动画器。表现层脚本挂在它身上。 +const ANIM_PLAYER_PATH := "Body/AnimationPlayer" ## player_animator.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。 -const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"] +const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "crouch", "run", "sneak", "jump", "fall"] + +## CROUCH / SNEAK 两个状态唯一的入口 —— 控制器读的就是这个动作。 +const CROUCH_ACTION := "crouch" ## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。 const REQUIRED_SYNCED: Array[String] = [ ".:position", "Body:rotation", ".:health", - "Body/PlayerAnimator:anim_state", - "Body/PlayerAnimator:anim_speed", + "Body/AnimationPlayer:anim_state", + "Body/AnimationPlayer:anim_speed", ] @@ -80,7 +84,7 @@ func test_player_scene_wires_synchronizer() -> void: ) -## 组件边界:动画状态机必须是挂在 Body 下的独立节点,而不是塞回 player.gd。 +## 组件边界:动画状态机必须是挂在 Body 下的节点,而不是塞回 player.gd。 ## 有人把 anim_state / anim_speed 挪回控制器时,这条会失败。 func test_player_scene_has_animator_component() -> void: var player := _instantiate_player() @@ -104,7 +108,7 @@ func test_player_scene_has_animator_component() -> void: assert_eq(script.resource_path, ANIMATOR_SCRIPT, "%s 挂的不是动画状态机脚本" % ANIMATOR_PATH) -## PlayerAnimator 没接线时按名字在自己兄弟子树里找 AnimationPlayer。 +## PlayerAnimator 没接线时在父节点的子节点里按类型找 AnimationPlayer。 ## 编辑器里跑不了它的 _ready()(非 @tool 脚本),所以这里退一步、用同样的规则找一遍: ## 改名或挪位置会让这条失败。 func test_animator_auto_find_target_exists() -> void: @@ -129,11 +133,25 @@ func test_required_animation_clips_exist() -> void: return for clip in REQUIRED_CLIPS: assert_true( - anim_player.has_animation(clip), + _find_clip(anim_player, clip) != null, "动作库里缺少片段 %s(PlayerAnimator 的 ANIM_CLIPS 会指向不存在的片段)" % clip ) +## crouch 动作是 CROUCH / SNEAK 唯一的入口;动作没了按 Ctrl 没反应,而且不报错。 +## 编辑器进程的 InputMap 只带内建动作,得先按引擎启动那套加载一次,否则这条必然假红。 +func test_crouch_action_exists() -> void: + InputMap.load_from_project_settings() + assert_true(InputMap.has_action(CROUCH_ACTION), "InputMap 里没有 %s 动作" % CROUCH_ACTION) + if not InputMap.has_action(CROUCH_ACTION): + return + var bound_to_ctrl := false + for event in InputMap.action_get_events(CROUCH_ACTION): + if event is InputEventKey and (event as InputEventKey).keycode == KEY_CTRL: + bound_to_ctrl = true + assert_true(bound_to_ctrl, "%s 没有绑定 Ctrl" % CROUCH_ACTION) + + ## 片段是从 glb 里拷出来的,track 路径带层级前缀。换模型/改层级后整组 track 会一起失效, ## 而 AnimationPlayer 不报任何错 —— 现象是「人站着不动」。用测试钉住路径真的能解析。 func test_animation_track_paths_resolve() -> void: @@ -146,7 +164,7 @@ func test_animation_track_paths_resolve() -> void: return for clip in REQUIRED_CLIPS: - var anim := anim_player.get_animation(clip) + var anim := _find_clip(anim_player, clip) if anim == null: continue assert_gt(anim.get_track_count(), 0, "%s 片段没有任何 track" % clip) @@ -169,6 +187,16 @@ func test_animation_track_paths_resolve() -> void: ) +## 片段在 AnimationPlayer 里按「库名/片段名」存(导入时每个 glb 各自成一个库), +## 短名 has_animation() / get_animation() 一律查不到 —— 拿短名断言会假红,必须按库扫一遍。 +func _find_clip(anim_player: AnimationPlayer, clip: String) -> Animation: + for lib_name in anim_player.get_animation_library_list(): + var lib := anim_player.get_animation_library(lib_name) + if lib != null and lib.has_animation(clip): + return lib.get_animation(clip) + return null + + func _find_animation_player() -> AnimationPlayer: var player := _instantiate_player() if player == null: diff --git a/tests/test_zipline.gd b/tests/test_zipline.gd index 80a3886..701ebff 100644 --- a/tests/test_zipline.gd +++ b/tests/test_zipline.gd @@ -92,7 +92,10 @@ func test_player_scene_has_interact_hint() -> void: ## 挂索/脱手都走 InputMap 的 interact 动作;动作没了按 E 没反应,而且不报错。 +## 编辑器进程的 InputMap 只带内建动作,项目动作要等游戏进程启动时才 load 进去 —— +## 不先补这一步,这条断言在编辑器测试环境里必然假红。 func test_interact_action_exists() -> void: + InputMap.load_from_project_settings() assert_true(InputMap.has_action(INTERACT_ACTION), "InputMap 里没有 %s 动作" % INTERACT_ACTION) if not InputMap.has_action(INTERACT_ACTION): return diff --git a/world/zipline/zipline.gd b/world/zipline/zipline.gd index 6049fa2..6727300 100644 --- a/world/zipline/zipline.gd +++ b/world/zipline/zipline.gd @@ -156,12 +156,12 @@ func _rebuild_cable() -> void: prev = p # 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量, # 这时换 RIGHT 兜底,否则 normalize() 会吐 NaN 把整根缆搅烂。 - var tan := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(maxf(t - 0.01, 0.0))).normalized() - var n := tan.cross(Vector3.UP) + var tan_dir := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(maxf(t - 0.01, 0.0))).normalized() + var n := tan_dir.cross(Vector3.UP) if n.length_squared() < 0.0001: - n = tan.cross(Vector3.RIGHT) + n = tan_dir.cross(Vector3.RIGHT) n = n.normalized() - var binormal := n.cross(tan).normalized() + var binormal := n.cross(tan_dir).normalized() for j in range(RING_SEGMENTS + 1): var ang := TAU * float(j) / float(RING_SEGMENTS) var off := n * cos(ang) + binormal * sin(ang)