limbo hsm

This commit is contained in:
2026-10-15 16:56:45 +08:00
parent 18e7b2daa8
commit a76c4aebef
30 changed files with 665 additions and 121 deletions
+17
View File
@@ -0,0 +1,17 @@
extends LimboHSM
## 空中子机(挂在 HSM/Airborne 上)。
##
## Jump(上升段)→ Fall(越过顶点开始下落)。进入时按当前竖直速度挑初始子状态:
## 起跳进来 vy > 0 → Jump;踏空掉进来 vy ≈ 0 → Fall(vy ≤ 0 一律算 Fall)。
@onready var jump: LimboState = $Jump
@onready var fall: LimboState = $Fall
func _setup() -> void:
add_transition(jump, fall, &"falling")
func _enter() -> void:
var player := get_agent() as Player
set_initial_state(fall if player == null or player.velocity.y <= 0.0 else jump)
+1
View File
@@ -0,0 +1 @@
uid://43psuo6pb4wq
+18
View File
@@ -0,0 +1,18 @@
extends PlayerGroundState
## 原地蹲:目标速度 0,播 crouch(蹲着不动)。
## 蹲着走是 Sneak —— 两者只差「有没有方向输入」,转换事件同为 move_start/stop。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = 0.0
player.motor.sprinting = false
player.motor.crouching = true
player.animation_player.play_state(PlayerAnimator.AnimState.CROUCH)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if not Input.is_action_pressed("crouch"):
dispatch(&"crouch_end")
elif player.motor.has_move_input():
dispatch(&"move_start")
+1
View File
@@ -0,0 +1 @@
uid://bb8dvx7nwuiwo
+14
View File
@@ -0,0 +1,14 @@
extends LimboState
## 下落段:播 fall。落地(is_on_floor 且不再上升)转回地面,由 Grounded._enter
## 按当下输入挑子状态(走/跑/蹲着落地不会闪站立)。
func _enter() -> void:
(get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.FALL)
func _update(_delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
if player.is_on_floor() and player.velocity.y <= 0.0:
dispatch(&"landed")
+1
View File
@@ -0,0 +1 @@
uid://yadts2ydgfs6
+32
View File
@@ -0,0 +1,32 @@
class_name PlayerGroundState
extends LimboState
## 地面叶子状态的公共基类(Idle / Walk / Sprint / Crouch / Sneak 共用)。
##
## 每帧先统一判定「环境事实」,之后才轮到 _think() 里各状态自己的输入转换:
## 1. 跳跃缓冲成立(try_jump 当场起跳)→ 转空中(vy > 0 → Jump);
## 2. 脚下没地板(踏空)→ 转空中(Fall)。
## 这两层判定放在**叶子**而不是父机:父机 _update 先于子状态执行,
## 若在父机里 dispatch,转换后父机本轮还会继续调(已被退出的)旧子状态,
## 存在「旧状态在已退出的机器里再次 dispatch」的顺序陷阱;叶子自己判、
## dispatch 后立即 return,则没有这个窗口。
##
## is_on_floor() 反映的是上一帧 move_and_slide() 的结果 ——
## 与推进的更新节奏一致,不引入额外的检测延迟。
func _update(delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
if player.motor.try_jump():
dispatch(&"airborne")
return
if not player.is_on_floor():
dispatch(&"airborne")
return
_think(delta)
## 各状态自己的输入转换。要不要 dispatch 事件、往哪转,由具体状态决定
## (转换表注册在 grounded.gd 的 _setup 里)。
func _think(_delta: float) -> void:
pass
+1
View File
@@ -0,0 +1 @@
uid://dss42q7c4kqui
+45
View File
@@ -0,0 +1,45 @@
extends LimboHSM
## 地面子机(挂在 HSM/Grounded 上)。
##
## 五个叶子状态:
## Idle 站立不动 Walk 步行 Sprint 疾跑 Crouch 原地蹲 Sneak 蹲走
## 叶子各自的输入转换见 ground_state.gd 及各状态脚本;本机只注册转换表,
## 并在 _enter(首次激活 / 每次落地)时按「当下输入」挑初始子状态 ——
## 不然按着方向键落地会先闪一帧站立。
@onready var idle: LimboState = $Idle
@onready var walk: LimboState = $Walk
@onready var sprint: LimboState = $Sprint
@onready var crouch: LimboState = $Crouch
@onready var sneak: LimboState = $Sneak
func _setup() -> void:
# 事件口径:move_start/stop 有无方向输入,sprint_start/stop 疾跑键,
# crouch_start/end 蹲键。蹲伏优先于疾跑(蹲下时按 Shift 不算疾跑)。
add_transition(idle, walk, &"move_start")
add_transition(idle, crouch, &"crouch_start")
add_transition(walk, idle, &"move_stop")
add_transition(walk, sprint, &"sprint_start")
add_transition(walk, sneak, &"crouch_start")
add_transition(sprint, walk, &"sprint_stop")
add_transition(sprint, idle, &"move_stop")
add_transition(sprint, sneak, &"crouch_start")
add_transition(crouch, sneak, &"move_start")
add_transition(crouch, idle, &"crouch_end")
add_transition(sneak, crouch, &"move_stop")
add_transition(sneak, walk, &"crouch_end")
func _enter() -> void:
var player := get_agent() as Player
if player == null:
set_initial_state(idle)
return
var moving := player.motor.has_move_input()
if Input.is_action_pressed("crouch"):
set_initial_state(sneak if moving else crouch)
elif moving:
set_initial_state(sprint if Input.is_action_pressed("sprint") else walk)
else:
set_initial_state(idle)
+1
View File
@@ -0,0 +1 @@
uid://2bnkxlycmqqu
+17
View File
@@ -0,0 +1,17 @@
extends PlayerGroundState
## 站立:目标速度 0,播 idle。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = 0.0
player.motor.sprinting = false
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.IDLE)
func _think(_delta: float) -> void:
# 蹲键优先 —— 同帧又蹲又走时先蹲下,下一帧 Crouch 再转 Sneak,不会漏蹲。
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif (get_agent() as Player).motor.has_move_input():
dispatch(&"move_start")
+1
View File
@@ -0,0 +1 @@
uid://2isemrs6ov3
+20
View File
@@ -0,0 +1,20 @@
extends LimboState
## 上升段:播 jump。越过顶点(vy ≤ 0)转 Fall。
## 水平目标速度保持离地前的值 —— 空中加速度本来就小(air_acceleration),
## 不由本状态改写。
func _enter() -> void:
(get_agent() as Player).animation_player.play_state(PlayerAnimator.AnimState.JUMP)
func _update(_delta: float) -> void:
var player := get_agent() as Player
if player == null:
return
# 落地判定带 vy ≤ 0 门槛:起跳那一帧 is_on_floor() 还是 true
# (要等 move_and_slide() 之后才翻假),没有门槛会当帧弹回地面状态。
if player.is_on_floor() and player.velocity.y <= 0.0:
dispatch(&"landed")
return
if player.velocity.y <= 0.0:
dispatch(&"falling")
+1
View File
@@ -0,0 +1 @@
uid://cvfcvo7k1i5jr
+16
View File
@@ -0,0 +1,16 @@
extends LimboHSM
## 玩家状态机根(挂在 Player/HSM 节点上,update_mode = MANUAL)。
##
## 只管最粗的一层:「身体在地上还是空中」。细分状态在两个子机里:
## Grounded 地面 grounded.gd —— Idle / Walk / Sprint / Crouch / Sneak
## Airborne 空中 airborne.gd —— Jump / Fall
## 推进由 Player._physics_process 显式调 update(delta)(管线第一位)。
@onready var grounded: LimboHSM = $Grounded
@onready var airborne: LimboHSM = $Airborne
func _setup() -> void:
# 起跳或踏空 → 空中;落地 → 回地面。事件的发起在各叶子状态里。
add_transition(grounded, airborne, &"airborne")
add_transition(airborne, grounded, &"landed")
+1
View File
@@ -0,0 +1 @@
uid://cgjaw5xbg7gdj
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 蹲走(潜行):目标速度 = crouch_speed,播 sneak。
## 蹲伏优先于疾跑:蹲着按 Shift 也只是潜行。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.crouch_speed
player.motor.sprinting = false
player.motor.crouching = true
player.animation_player.play_state(PlayerAnimator.AnimState.SNEAK, player.motor.crouch_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if not Input.is_action_pressed("crouch"):
dispatch(&"crouch_end")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
+1
View File
@@ -0,0 +1 @@
uid://b4okw70vrah42
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 疾跑:目标速度 = sprint_speed,播 run。只在按着方向键时成立(Sprint 由 Walk 进入)。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.sprint_speed
player.motor.sprinting = true
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.RUN, player.motor.sprint_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
elif not Input.is_action_pressed("sprint"):
dispatch(&"sprint_stop")
+1
View File
@@ -0,0 +1 @@
uid://cklw8uq6xahe
+19
View File
@@ -0,0 +1,19 @@
extends PlayerGroundState
## 步行:目标速度 = walk_speed,播 walk。
func _enter() -> void:
var player := get_agent() as Player
player.motor.target_speed = player.motor.walk_speed
player.motor.sprinting = false
player.motor.crouching = false
player.animation_player.play_state(PlayerAnimator.AnimState.WALK, player.motor.walk_speed)
func _think(_delta: float) -> void:
var player := get_agent() as Player
if Input.is_action_pressed("crouch"):
dispatch(&"crouch_start")
elif not player.motor.has_move_input():
dispatch(&"move_stop")
elif Input.is_action_pressed("sprint"):
dispatch(&"sprint_start")
+1
View File
@@ -0,0 +1 @@
uid://6pa3038100o4
+22 -5
View File
@@ -1,3 +1,4 @@
class_name Player
extends CharacterBody3D
## 玩家协调器:只做三件事 —— 多人权限、依赖注入、按固定顺序编排各组件。
##
@@ -6,8 +7,12 @@ extends CharacterBody3D
## Motor 移动跳跃 character/player/player_motor.gd
## Pickup 拾取 character/player/player_pickup.gd
## Animator 动画表现 character/player/player_animator.gd(挂在 AnimationPlayer 上)
## HSM 状态机 character/player/hsm/(LimboAI LimboHSM,MANUAL 模式)
##
## 本脚本不实现任何操作,只负责「谁先谁后」和「谁有权跑」。
## 「玩家处于什么状态」(走 / 跑 / 蹲 / 跳 / 落)的判定完全在 HSM 里:
## 状态在 _enter 时写 motor.target_speed、motor.crouching/sprinting 和动画片段,
## 这里只按固定顺序推进各组件。
@onready var body: Node3D = $Body
## 本地玩家自己的模型(鼠)。第一人称要藏掉它,见 _ready()。
@@ -22,6 +27,9 @@ extends CharacterBody3D
@onready var look: PlayerLook = $Look
@onready var motor: PlayerMotor = $Motor
@onready var pickup: PlayerPickup = $Pickup
## LimboAI 层级状态机。update_mode = MANUAL,由 _physics_process 按管线顺序显式推进,
## 保证它永远排在 motor.step 之前、且只在权威端跑。
@onready var hsm: LimboHSM = $HSM
func _ready() -> void:
@@ -60,6 +68,12 @@ func _ready() -> void:
pickup.interact_state_changed.connect(_on_pickup_interact_state_changed)
# 调试面板接线:把要读的组件注入进去,面板自己不 get_node。
debug_panel.setup(self, motor, pickup, camera)
# 状态机接线:initialize 递归调所有状态的 _setup(注册各自的转换表),
# set_active 进入 initial_state(Grounded 会按当下输入挑子状态)。
# 只在权威端跑 —— 远端玩家由 Sync 复制 position / anim_state / anim_speed,
# 不需要也不允许本地再跑一套状态机;HSM 是 MANUAL 模式,不初始化就完全惰性。
hsm.initialize(self)
hsm.set_active(true)
set_mouse_captured(true)
@@ -91,13 +105,16 @@ func _on_pickup_interact_state_changed(hovering: bool, holding: bool) -> void:
func _physics_process(delta: float) -> void:
# 顺序固定:算速度 → 同步蹲伏高度 → 推进 → 播动画 → 搬物体。
# 由根节点统一编排,各组件自己不许写 _physics_process ——
# 顺序固定:状态机(判定状态,写目标速度与动画)→ 算速度 → 同步蹲伏高度 →
# 推进 → 刷步频 → 搬物体。由根节点统一编排,各组件自己不许写 _physics_process ——
# 否则顺序取决于节点树顺序,改个节点位置就会出现「先滑再算速度」这类隐形 bug。
# HSM 排最前:状态写入的 target_speed 当帧就被 motor 消费;
# 起跳在 Grounded 状态里就地执行(motor.try_jump),同一帧转进 Jump。
hsm.update(delta)
motor.step(delta)
# 蹲伏状态由 Motor 单点判定,再转交给 Look 去升降摄像机 ——
# 不让 Look 自己去读蹲键,免得两个组件各判一次、将来不同步。
# 蹲伏事实由 HSM 的 Crouch/Sneak 状态写进 motor.crouching ——
# 不让 Look 自己去读蹲键,免得两边各判一次、将来不同步。
look.set_crouching(motor.crouching)
move_and_slide()
animation_player.set_motion(velocity, is_on_floor(), motor.sprinting, motor.crouching)
animation_player.set_gait(velocity)
pickup.physics_step()
+41 -2
View File
@@ -19,6 +19,16 @@
[ext_resource type="Script" uid="uid://bo1hob2whrlu0" path="res://character/player/player_look.gd" id="13_look"]
[ext_resource type="Script" uid="uid://xta8h2snalsd" path="res://character/player/player_motor.gd" id="14_motor"]
[ext_resource type="Script" uid="uid://b3ko4cfjf0vgu" path="res://character/player/player_pickup.gd" id="15_pickup"]
[ext_resource type="Script" uid="uid://cgjaw5xbg7gdj" path="res://character/player/hsm/player_hsm.gd" id="20_7gte1"]
[ext_resource type="Script" uid="uid://2bnkxlycmqqu" path="res://character/player/hsm/grounded.gd" id="21_k6mlu"]
[ext_resource type="Script" uid="uid://2isemrs6ov3" path="res://character/player/hsm/idle.gd" id="22_r212m"]
[ext_resource type="Script" uid="uid://6pa3038100o4" path="res://character/player/hsm/walk.gd" id="23_8bmvw"]
[ext_resource type="Script" uid="uid://bb8dvx7nwuiwo" path="res://character/player/hsm/crouch.gd" id="24_2pong"]
[ext_resource type="Script" uid="uid://b4okw70vrah42" path="res://character/player/hsm/sneak.gd" id="25_tkl2s"]
[ext_resource type="Script" uid="uid://cklw8uq6xahe" path="res://character/player/hsm/sprint.gd" id="26_vhygx"]
[ext_resource type="Script" uid="uid://43psuo6pb4wq" path="res://character/player/hsm/airborne.gd" id="27_s2r15"]
[ext_resource type="Script" uid="uid://cvfcvo7k1i5jr" path="res://character/player/hsm/jump.gd" id="28_ed2qs"]
[ext_resource type="Script" uid="uid://yadts2ydgfs6" path="res://character/player/hsm/fall.gd" id="29_u55fa"]
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
radius = 0.54541016
@@ -98,8 +108,37 @@ script = ExtResource("13_look")
[node name="Motor" type="Node" parent="." unique_id=1043927815]
script = ExtResource("14_motor")
walk_speed = 2.419
sprint_speed = 3.624
[node name="Pickup" type="Node" parent="." unique_id=1587230140]
script = ExtResource("15_pickup")
[node name="HSM" type="LimboHSM" parent="." unique_id=100369043]
update_mode = 2
script = ExtResource("20_7gte1")
[node name="Grounded" type="LimboHSM" parent="HSM" unique_id=159625644]
script = ExtResource("21_k6mlu")
[node name="Idle" type="LimboState" parent="HSM/Grounded" unique_id=1876145612]
script = ExtResource("22_r212m")
[node name="Walk" type="LimboState" parent="HSM/Grounded" unique_id=689938768]
script = ExtResource("23_8bmvw")
[node name="Crouch" type="LimboState" parent="HSM/Grounded" unique_id=1470963204]
script = ExtResource("24_2pong")
[node name="Sneak" type="LimboState" parent="HSM/Grounded" unique_id=988596449]
script = ExtResource("25_tkl2s")
[node name="Sprint" type="LimboState" parent="HSM/Grounded" unique_id=674736640]
script = ExtResource("26_vhygx")
[node name="Airborne" type="LimboHSM" parent="HSM" unique_id=1349311867]
script = ExtResource("27_s2r15")
[node name="Jump" type="LimboState" parent="HSM/Airborne" unique_id=2079971118]
script = ExtResource("28_ed2qs")
[node name="Fall" type="LimboState" parent="HSM/Airborne" unique_id=233377038]
script = ExtResource("29_u55fa")
+26 -69
View File
@@ -2,8 +2,10 @@ class_name PlayerAnimator
extends Node
## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。
##
## 控制器每物理帧调一次 set_motion(),本节点据此决定 AnimState 并切片段。
## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,控制器不认识任何片段名。
## 「现在该播哪个状态、按什么速度为基准」由 hsm/ 的 LimboHSM 状态机决定:
## 状态在 _enter 时调 play_state(状态, 基准速度) 切片段,
## 控制器每物理帧调 set_gait() 按实际水平速度刷播放倍率。
## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,状态机不认识任何片段名。
##
## 节点接法:本脚本直接挂在 Body/AnimationPlayer 上 —— 这时 animation_player 留空,
## _ready() 会在父节点的子节点里找 AnimationPlayer,找到的就是它自己。
@@ -36,32 +38,21 @@ const ANIM_LOOPED: Array[int] = [
@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 = {}
## 当前片段的基准速度(米/秒):play_state() 时由 HSM 状态传入。
## 倍率 = 实际水平速度 / 它。状态自己清楚它把 motor 的哪个速度当目标,
## 由它传入就不会出现「两处各存一份基准速度、改了一边忘了另一边」的打滑。
## 0(或负)= 该片段不参与速度缩放(站立 / 原地蹲 / 跳 / 落一律原速)。
var _ref_speed: float = 0.0
## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。
var anim_state: int = AnimState.IDLE:
set(value):
@@ -115,17 +106,23 @@ func _ready() -> void:
# ---------------- 对外接口 ----------------
## 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() 之后)。
## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。
## 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 负责切片段
## 只按当前水平速度刷播放倍率 —— 不切片段、不回写任何物理量,单向耦合的边界不变。
## 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
## 当前正在播的片段名。调试与测试用。
@@ -137,46 +134,6 @@ func current_clip() -> String:
# ---------------- 内部 ----------------
## 优先级:空中 > 蹲 > 移动。
## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。
## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。
## 蹲着不动是 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
+50 -43
View File
@@ -4,43 +4,41 @@ extends Node
##
## 只算速度写回 body.velocity,**不调 move_and_slide()** —— 推进由 Player 统一做。
## 这样「算速度 → 推进 → 播动画」的顺序永远固定,不受节点树顺序影响。
##
## 「走多快、蹲不蹲、跑不跑」由 hsm/ 的 LimboHSM(LimboAI)决定:
## 状态在 _enter 时写 target_speed / crouching / sprinting,
## 本组件只负责把这份「意图」用物理方式变成速度。
##
## 设计基准:**小人国玩法,角色身高约 0.2 m**。速度不按「人类等比缩放」推导,
## 直接按鼠类疾走的手感定标:走 ≈ 12 身长/秒、疾跑 ≈ 18 身长/秒、
## 跳高 ≈ 1.15 身高。速度/加速度按手感单独调即可,不必成套换算;
## 唯独 jump_velocity 与 gravity_scale 是**配对**的(反解公式见重力一节)。
## 跳跃输入缓冲(秒):按下跳跃后这段时间内若落地,自动补上一次起跳
const JUMP_BUFFER_TIME: float = 0.1
@export_group("移动")
## 【设计基准】以人类为参照做等比缩放:人 1.75 m,游戏惯例 walk 5.0 / sprint 8.5 m/s。
## 本角色身高 0.15 m,系数 k = 0.15 / 1.75 ≈ 0.0857,下面所有线性量都是原值 × k。
## 这样「每秒走过几个自身体长」和人类一致,动作比例看起来才对。
##
## 换尺寸时的公式:线性量(速度/加速度)× k';跳跃那两个数(gravity_scale 与 jump_velocity)用下面的反解公式重算。
##
## 常规移动速度(米/秒)—— 约 2.9 身长/秒,和人类走路同一比例
@export var walk_speed: float = 0.4286
## 疾跑速度(米/秒)—— 约 4.9 身长/秒
@export var sprint_speed: float = 0.7286
## 蹲行速度(米/秒)—— 默认是走速的一半。蹲下时按住 Shift 也不会疾跑。
## 注意:改这个值要同步改 player_animator.gd 的 sneak_ref_speed,否则蹲走时脚步会打滑。
@export var crouch_speed: float = 0.2143
## 地面加速度(米/秒²)—— 约 0.11 秒加到全速,起步干脆
@export var acceleration: float = 3.8571
## 空中加速度(米/秒²)
@export var air_acceleration: float = 1.0286
## 起跳初速度(米/秒)—— 配下面的 gravity_scale 0.5(g = 4.9)时,
## 跳跃高度 ≈ 0.230 m(约 1.53 身高)、滞空 ≈ 0.61 s。
## 这两个数是**配对**的:改重力必须按「反解公式」重算它,否则跳跃高度按 1/g 暴涨。
## 常规移动速度(米/秒)—— ≈ 12 身长/秒(0.2 m 身高),小鼠疾走的步频感
@export var walk_speed: float = 2.419
## 疾跑速度(米/秒)—— ≈ 18 身长/秒
@export var sprint_speed: float = 3.624
## 蹲行速度(米/秒)—— 走速的一半。蹲下时按住 Shift 也不疾跑(蹲伏优先)。
@export var crouch_speed: float = 1.21
## 地面加速度(米/秒²)—— 约 0.11 秒加到走速全速,起步干脆(鼠类的一窜一动)
@export var acceleration: float = 22.0
## 空中加速度(米/秒²)—— 约地面的 1/4:空中能修正方向,但不至于完全失控
@export var air_acceleration: float = 5.9
## 起跳初速度(米/秒)—— 配 gravity_scale 0.5(有效 g = 4.9)时,
## 跳高 ≈ 0.23 m(约 1.15 身高)、滞空 ≈ 0.61 s。**与 gravity_scale 配对**,见下。
@export var jump_velocity: float = 1.5
@export_group("重力")
## 重力缩放系数。0.5 = 重力减半(有效 g = 4.9 m/s²)。
## 重力缩放系数。0.5 = 有效重力 4.9 m/s²。
##
## 为什么不用 1.0(物理真实的 9.8):重力加速度本身和物体大小无关,但我们的速度是按身材
## 等比缩过的,于是落体相对过快。更关键的是跳跃 —— 由 h = v²/2g 和 T = 2v/g 可得
## h = g·T²/8:**固定重力下滞空翻倍、高度要涨 4 倍**。所以「想跳得久又不想跳太高」
## 只能降重力。取 0.5 时:跳高 0.230 m(1.53 身高)、滞空 0.61 s;
## 若维持 1.0,同样 0.23 m 的高度只有 0.43 s 滞空,落地明显仓促。
## 小个子角色若用真实 9.8,同样的起跳速度滞空短一半、跳起来像被拍回去 ——
## 降重力换滞空,跳跃才有「腾空感」。0.5 时:跳高 0.23 m、滞空 0.61 s。
##
## ⚠ 改这个值必须同步改 jump_velocity(跳跃高度 = v²/2g)。
## ⚠ 与 jump_velocity **配对**:改一个必须按反解公式重算另一个(跳跃高度 = v²/2g)。
## 反解公式 —— 定目标高度 h(米)与滞空 T(秒):
## g = 8h / T² → gravity_scale = g / 9.8
## jump_velocity = 4h / T
@@ -49,8 +47,12 @@ const JUMP_BUFFER_TIME: float = 0.1
## 那是全局的,会连带把拖拽/投掷的刚体一起变飘。
@export_range(0.0, 2.0, 0.01) var gravity_scale: float = 0.5
## 本帧是否在疾跑 / 是否按住蹲键。
## Player 读这两个值转交给 PlayerAnimator 切片段,crouching 还用来压低摄像机。
## 当前目标水平速度(米/秒)—— 由 HSM 状态在 _enter 时写入,step() 朝它加减速。
## Idle / Crouch 写 0(自然减速到停),Walk / Sprint / Sneak 写各自速度。
var target_speed: float = 0.0
## 是否蹲着 / 是否疾跑 —— 由 HSM 状态写入的**事实镜像**,本组件不再自己读输入推断:
## crouching 给 Look 压低摄像机,两者都给 DebugPanel 显示。
var sprinting: bool = false
var crouching: bool = false
@@ -73,6 +75,23 @@ func _unhandled_input(event: InputEvent) -> void:
_jump_buffer = JUMP_BUFFER_TIME
## 本帧是否按着方向键。HSM 状态(Idle/Walk 等的转换判定)与 step() 共用这个口径。
func has_move_input() -> bool:
return Input.get_vector("move_left", "move_right", "move_back", "move_forward") \
.length_squared() > 0.000001
## 由 HSM 地面状态每帧调用:跳跃缓冲还有效且脚下有地板,就当场起跳并返回 true。
## 起跳「动作」放在状态判定处执行(而不是 step() 里),
## 状态机同一帧就能拿到 vy > 0 这个事实、转进 Jump,动画不迟到一帧。
func try_jump() -> bool:
if _jump_buffer > 0.0 and _body != null and _body.is_on_floor():
_body.velocity.y = jump_velocity
_jump_buffer = 0.0
return true
return false
## 每物理帧由 Player 调用一次,必须排在 move_and_slide() 之前。
## 只写 _body.velocity,不做位移推进。
func step(delta: float) -> void:
@@ -87,12 +106,9 @@ func step(delta: float) -> void:
# gravity_scale 每帧现读,方便运行中拖着试手感(1.0 = 物理真实的 9.8)
_body.velocity.y -= _gravity * gravity_scale * delta
# --- 跳跃(读取输入缓冲)---
# --- 跳跃缓冲倒计时(执行挪到 try_jump(),由 HSM 地面状态调用)---
if _jump_buffer > 0.0:
_jump_buffer -= delta
if _body.is_on_floor():
_body.velocity.y = jump_velocity
_jump_buffer = 0.0
# --- 相对视线方向的水平输入 ---
# 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。
@@ -103,16 +119,7 @@ func step(delta: float) -> void:
if wish.length_squared() > 0.000001:
wish = wish.normalized()
# 只有真正按了方向键才允许疾跑(避免原地"疾跑"触发跑动动画)
var wants_sprint := Input.is_action_pressed("sprint") and input_dir.length_squared() > 0.000001
crouching = Input.is_action_pressed("crouch")
# 蹲伏优先于疾跑:蹲着时按住 Shift 不算疾跑,速度取蹲行速度。
sprinting = wants_sprint and not crouching
var target_speed := walk_speed
if crouching:
target_speed = crouch_speed
elif sprinting:
target_speed = sprint_speed
# 目标速度来自 HSM 状态(target_speed),不在本组件里按蹲键/Shift 分档。
var accel := acceleration if _body.is_on_floor() else air_acceleration
var horizontal := Vector2(_body.velocity.x, _body.velocity.z)