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

194 lines
8.8 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
## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。
##
## 控制器每物理帧调一次 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.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 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 = {}
## 当前动作状态(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)
# ---------------- 对外接口 ----------------
## 控制器每物理帧调用一次(放在 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 负责切片段
## 当前正在播的片段名。调试与测试用。
func current_clip() -> String:
if animation_player == null:
return ""
return str(animation_player.current_animation)
# ---------------- 内部 ----------------
## 优先级:空中 > 蹲 > 移动。
## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。
## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。
## 蹲着不动是 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
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