feat: emotive animations + anime speed lines — hit flinch, dance emote (B)

- SkinnedPlayerModel: generic play_oneshot() (generalizes the Land lock),
  Hit and Dance in the clip table (Dance loops), restartable clips
- Hit flinch: victims visibly react on every peer's screen via the existing
  damage broadcast — no new RPC
- Dance emote on B: toggles while grounded and idle, breaks on any movement;
  synced to remotes via synced_is_dancing (all three spawner configs)
- Anime radial speed-lines overlay (canvas shader): fades in past ~1.2x walk
  speed, spikes on dash, widescreen-corrected clear center
- New input action 'emote' (B) in project.godot

11/11 FSM tests, spawn smoke 0 failures.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-17 13:40:49 -04:00
co-authored by Claude Fable 5
parent 37c15bed1a
commit 5bcbcfdc3f
8 changed files with 136 additions and 10 deletions
+33 -10
View File
@@ -44,10 +44,12 @@ const CLIP_FALLBACKS := {
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
"Dash": ["Dash", "Sprint", "Run", "Idle"],
"Death": ["Death", "Fall"],
"Hit": ["Hit", "Idle"],
"Dance": ["Dance", "Idle"],
}
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple"]
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple", "Dance"]
const BLEND_TIME := 0.15
@@ -191,7 +193,23 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
# ── Animation state ───────────────────────────────────────────────────────────
var _prev_state: String = ""
var _land_lock: float = 0.0 # seconds left where the Land one-shot owns playback
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _dancing: bool = false
## Play a one-shot clip (Hit reaction, Land, ...) over locomotion for
## `lock_time` seconds; locomotion resumes afterwards.
func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical):
return
_oneshot_lock = lock_time
_play_clip(canonical, true)
## Emote toggle (Dance). Shown while grounded and near-idle; any real
## movement breaks it (the controller clears the flag too).
func set_dancing(on: bool) -> void:
_dancing = on
## Same contract as HumanoidModel.update_state(). Called by the movement
@@ -200,15 +218,16 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
if not loaded or not animation_player:
return
# A heavy landing plays the Land one-shot before locomotion resumes.
if _land_lock > 0.0:
_land_lock -= get_process_delta_time()
if _land_lock > 0.0 and state in ["ground", "idle"]:
# One-shots (Land, Hit) own playback briefly.
if _oneshot_lock > 0.0:
_oneshot_lock -= get_process_delta_time()
if _oneshot_lock > 0.0:
_prev_state = state
return
# A heavy landing plays the Land one-shot before locomotion resumes.
if state in ["ground", "idle"] and _prev_state == "air" \
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
_land_lock = 0.25
_oneshot_lock = 0.25
_play_clip("Land")
_prev_state = state
return
@@ -217,7 +236,9 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
var clip := "Idle"
match state:
"ground", "idle":
if is_crouching:
if _dancing and speed < 0.5 and not is_crouching:
clip = "Dance"
elif is_crouching:
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
elif speed > run_anim_reference_speed * 1.35:
clip = "Sprint"
@@ -291,12 +312,14 @@ func _process(delta: float) -> void:
_pose_mod.wall = _cur_wall
func _play_clip(canonical: String) -> void:
func _play_clip(canonical: String, restart: bool = false) -> void:
if not animation_player or not _resolved_clips.has(canonical):
return
var clip_name: String = _resolved_clips[canonical]
if _current_clip == clip_name and animation_player.is_playing():
if not restart and _current_clip == clip_name and animation_player.is_playing():
return
if restart:
animation_player.stop()
animation_player.play(clip_name, BLEND_TIME)
_current_clip = clip_name