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
+56
View File
@@ -75,6 +75,11 @@ var synced_position: Vector3 = Vector3.ZERO
var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
var synced_is_dancing: bool = false # dance emote (B), shown on the model
# Anime speed-lines overlay (local player only)
var _speedlines: ColorRect = null
var _speedline_burst: float = 0.0
@export var synced_skin_id: String = ""
@export var synced_weapon_path: String = ""
@@ -130,6 +135,7 @@ func _ready() -> void:
add_child(_damage_layer)
_setup_hit_marker()
_setup_hud()
_setup_speedlines()
else:
call_deferred("_hide_remote_weapons")
@@ -315,6 +321,18 @@ func _setup_audio() -> void:
add_child(grapple_swing_player)
grapple_swing_player.play()
func _setup_speedlines() -> void:
_speedlines = ColorRect.new()
_speedlines.name = "SpeedLines"
_speedlines.set_anchors_preset(Control.PRESET_FULL_RECT)
_speedlines.mouse_filter = Control.MOUSE_FILTER_IGNORE
var mat := ShaderMaterial.new()
mat.shader = load("res://assets/shaders/speed_lines.gdshader")
mat.set_shader_parameter("intensity", 0.0)
_speedlines.material = mat
_damage_layer.add_child(_speedlines)
func _setup_hit_marker() -> void:
hit_marker = Control.new()
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
@@ -665,6 +683,12 @@ func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_n
if attacker_id != 0:
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
# Flinch: the victim's model plays a hit reaction on every screen.
if amount > 0.0:
var vm = get_visual_model()
if vm and vm.has_method("play_oneshot"):
vm.play_oneshot("Hit", 0.3)
# Knockback lands on the simulating peer (movement is client-authoritative)
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
apply_impulse(impulse)
@@ -817,6 +841,22 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
# Dance emote (B): toggles while grounded and idle-ish; any
# movement/jump/crouch input breaks it.
if Input.is_action_just_pressed("emote"):
var m := _ensure_machine()
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
if not synced_is_dancing and m and m.current_state == "ground" and slow:
synced_is_dancing = true
else:
synced_is_dancing = false
if synced_is_dancing:
var m2 := _ensure_machine()
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
var airborne: bool = m2 and m2.current_state != "ground"
if moving or airborne:
synced_is_dancing = false
var machine := _ensure_machine()
if machine:
machine.input_dir = raw_input
@@ -834,6 +874,16 @@ func _physics_process(_delta: float) -> void:
if not wind_player.playing and wind_factor > 0.0:
wind_player.play()
# Anime speed lines: fade in past ~1.2x walk speed, spike on dash.
if is_instance_valid(_speedlines):
_speedline_burst = maxf(_speedline_burst - _delta * 2.5, 0.0)
var hs := Vector2(velocity.x, velocity.z).length()
var speed_intensity := clampf((hs - params.walk_speed * 1.2) / 12.0, 0.0, 0.85)
var target := maxf(speed_intensity, _speedline_burst)
var mat := _speedlines.material as ShaderMaterial
var cur: float = mat.get_shader_parameter("intensity")
mat.set_shader_parameter("intensity", lerpf(cur, target, 1.0 - exp(-10.0 * _delta)))
var sm := _ensure_machine()
if not sm:
return
@@ -879,6 +929,8 @@ func _physics_process(_delta: float) -> void:
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
# Publish state for remote peers
synced_movement_state = sm.current_state
@@ -918,6 +970,8 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
grapple_shoot_player.play()
elif ev == "grapple_latch":
grapple_latch_player.play()
elif ev == "dash":
_speedline_burst = 1.0
elif ev == "land":
# Landing thud: reuse the footstep sample, pitched down and louder
# with impact. Ground state resets pitch/volume before each step.
@@ -960,6 +1014,8 @@ func _process(delta: float) -> void:
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
# Check for weapon changes
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
visual.set_weapon(synced_weapon_path)