角色动画
This commit is contained in:
@@ -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。要不要减速/压低碰撞盒等用户定。
|
||||
|
||||
|
||||
@@ -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`,比手改文件稳。
|
||||
|
||||
+17
-14
@@ -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))
|
||||
# 相机也跟着摆到行进方向背后 —— 否则人是横着飞的,看不见前面要去哪。
|
||||
# 速率刻意比转身慢:玩家用鼠标看的视角不会被立刻拽回去。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user