Files
agame/character/player/player_animator.gd
T
2026-09-23 16:39:12 +08:00

155 lines
6.1 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node
class_name PlayerAnimator
## 角色动作状态机 —— 只负责「把运动状态翻译成播放哪段动画」,完全不碰移动。
##
## 挂在 Player/Body 下,与具体模型解耦:换角色模型只要在检视面板重新指定
## animation_player(或让它自动找),代码里没有任何硬编码的节点路径。
##
## 对外接口只有一个:set_motion()。方向、加速度、重力、输入全归控制器管;
## 本组件只读入一个「运动快照」,输出到 AnimationPlayer。反向不回写任何东西。
##
## 为什么状态与倍率必须是可复制属性:
## 远端玩家物理与输入都关着,手里只有 position 和 Body.rotation,
## 算不出水平速度,也分不清是上升还是下落。所以权威端把结论复制过去,
## 远端只在 setter 里回放。
## 对应的同步条目(Sync.root_path = "..",即 Player 节点):
## Body/PlayerAnimator:anim_state
## Body/PlayerAnimator:anim_speed
## 角色动作状态。权威端每物理帧算一次,再靠 Sync 复制给远端回放。
enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }
## 状态 → 片段名。取自 character-male-e.glb 的动作库,换动作库改这里。
const ANIM_CLIPS: Dictionary = {
AnimState.IDLE: "idle",
AnimState.WALK: "walk",
AnimState.SPRINT: "sprint",
AnimState.JUMP: "jump",
AnimState.FALL: "fall",
}
## 需要循环的状态。glb 里每个片段的 loop_mode 都是 NONE,
## 不补循环的话 idle/walk/sprint 播一遍就定在最后一帧。
const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.SPRINT]
@export_group("接线")
## 要驱动的 AnimationPlayer。留空则在本节点的兄弟子树里自动找一个。
## 换模型后这里重新指一下即可 —— 这也是它能脱离具体模型的原因。
@export var animation_player: AnimationPlayer
@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("片段基准速度")
## walk 片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数:
## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,无需同步改两边。
@export var walk_ref_speed: float = 5.0
## sprint 片段的基准速度(米/秒)
@export var sprint_ref_speed: float = 8.5
## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。
var anim_state: int = AnimState.IDLE:
set(value):
if anim_state == value:
return
anim_state = value
_play(ANIM_CLIPS.get(anim_state, ""), blend_time)
## 当前播放倍率(1.0 = 片段原速)。
## 同样要复制:远端只有 position,算不出真实速度,只能照抄权威端的倍率。
var anim_speed: float = 1.0:
set(value):
value = clampf(value, speed_scale_min, speed_scale_max)
anim_speed = value
if animation_player != null:
animation_player.speed_scale = value
func _ready() -> void:
if animation_player == null:
animation_player = _auto_find_animation_player()
if animation_player == null:
push_warning("%s 没绑定 AnimationPlayer,角色不会有动画" % get_path())
return
_patch_loop_modes()
# 同步可能在 _ready() 之前就把 anim_state 写进来了 —— 那时 animation_player 还是 null,
# setter 空跑了一趟。所以这里按「当前状态」补播一遍,不能写死 idle。
_play(ANIM_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)
# 先写倍率再切片段 —— 顺序反了会让新片段用一帧旧倍率。
anim_speed = _speed_scale_for(state, velocity)
anim_state = state # setter 负责切片段
## 当前正在播的片段名。调试与测试用。
func current_clip() -> String:
if animation_player == null:
return ""
return str(animation_player.current_animation)
# ---------------- 内部 ----------------
func _resolve_state(velocity: Vector3, on_floor: bool, sprinting: 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:
return AnimState.IDLE
return AnimState.SPRINT 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
_:
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
if not animation_player.has_animation(clip):
return
animation_player.play(clip, blend)
## 兜底:在同一层视觉节点里找一个 AnimationPlayer。只在没接线时生效。
func _auto_find_animation_player() -> AnimationPlayer:
var parent := get_parent()
if parent == null:
return null
return parent.find_child("AnimationPlayer", true, false) as AnimationPlayer
func _patch_loop_modes() -> void:
for state in ANIM_LOOPED:
var clip: String = ANIM_CLIPS[state]
if not animation_player.has_animation(clip):
continue
animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR