Files
agame/character/player/player_animator.gd
T
2026-10-15 16:56:45 +08:00

151 lines
6.9 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.
class_name PlayerAnimator
extends Node
## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。
##
## 「现在该播哪个状态、按什么速度为基准」由 hsm/ 的 LimboHSM 状态机决定:
## 状态在 _enter 时调 play_state(状态, 基准速度) 切片段,
## 控制器每物理帧调 set_gait() 按实际水平速度刷播放倍率。
## 片段名集中在 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.JUMP: "jump",
AnimState.FALL: "fall",
}
## 需要循环的状态。其余(jump / fall)播完停在最后一帧。
## crouch / sneak 也是循环的位移动作 —— 漏掉它们,蹲着走一步就会定住。
const ANIM_LOOPED: Array[int] = [
AnimState.IDLE, AnimState.WALK, AnimState.CROUCH, AnimState.RUN, AnimState.SNEAK
]
@export_group("接线")
## 要驱动的 AnimationPlayer。留空则自动找:脚本挂在 AnimationPlayer 上时就是它自己,
## 挂到独立节点上时是父节点下的第一个。换模型后重指一下即可 —— 这也是它能脱离具体模型的原因。
@export var animation_player: AnimationPlayer
@export_group("播放")
## 动作切换的交叉淡入时间(秒)。0 为硬切
@export var blend_time: float = 0.15
## 播放倍率下限:被墙抵住、起步那一瞬,脚步不该慢到像在滑冰
@export var speed_scale_min: float = 0.65
## 播放倍率上限:防止速度被外力拉高时脚步快到抽搐
@export var speed_scale_max: float = 1.6
## 状态 → 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):
if anim_state == value:
return
anim_state = value
_play(str(_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 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(str(_clips.get(anim_state, "")), 0.0)
# ---------------- 对外接口 ----------------
## 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() 之后)。
## 只按当前水平速度刷播放倍率 —— 不切片段、不回写任何物理量,单向耦合的边界不变。
## 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
## 当前正在播的片段名。调试与测试用。
func current_clip() -> String:
if animation_player == null:
return ""
return str(animation_player.current_animation)
# ---------------- 内部 ----------------
func _play(clip: String, blend: float) -> void:
if animation_player == null or clip.is_empty():
return
animation_player.play(clip, blend)
func _patch_loop_modes() -> void:
# 导入的 GLB 片段 loop_mode 默认是 NONE,播一遍就定在最后一帧;
# 这是导入产物的属性,编辑器里改不了,只能在运行时补。
for state in ANIM_LOOPED:
var clip: String = _clips.get(state, "")
if clip.is_empty():
continue
animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR