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:
co-authored by
Claude Fable 5
parent
37c15bed1a
commit
5bcbcfdc3f
@@ -0,0 +1,38 @@
|
|||||||
|
shader_type canvas_item;
|
||||||
|
|
||||||
|
// Anime-style radial speed lines. Fullscreen ColorRect overlay; `intensity`
|
||||||
|
// (0..1) is driven by the player controller from horizontal speed + dash.
|
||||||
|
// Lines live at the screen edges and jitter inward, center stays clear.
|
||||||
|
|
||||||
|
uniform float intensity : hint_range(0.0, 1.0) = 0.0;
|
||||||
|
uniform vec4 line_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
float hash(float n) {
|
||||||
|
return fract(sin(n * 4310.17) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec2 uv = UV - vec2(0.5);
|
||||||
|
uv.x *= 1.6; // widescreen: keep the clear zone round-ish
|
||||||
|
float dist = length(uv);
|
||||||
|
float ang = atan(uv.y, uv.x);
|
||||||
|
|
||||||
|
// Quantize the circle into spokes; each spoke gets a stable random phase.
|
||||||
|
float spokes = 90.0;
|
||||||
|
float id = floor((ang / 6.2831853 + 0.5) * spokes);
|
||||||
|
float rnd = hash(id);
|
||||||
|
|
||||||
|
// Only some spokes draw, and they flicker over time.
|
||||||
|
float alive = step(0.55, fract(rnd + floor(TIME * 9.0) * 0.13));
|
||||||
|
|
||||||
|
// Thin line across the spoke's angular width.
|
||||||
|
float local = fract((ang / 6.2831853 + 0.5) * spokes);
|
||||||
|
float line = smoothstep(0.5, 0.05, abs(local - 0.5)) ;
|
||||||
|
|
||||||
|
// Radial extent: start further out for weak intensity, reach inward as it grows.
|
||||||
|
float start = mix(0.62, 0.34, intensity) + rnd * 0.12;
|
||||||
|
float mask = smoothstep(start, start + 0.25, dist);
|
||||||
|
|
||||||
|
float a = intensity * alive * line * mask;
|
||||||
|
COLOR = vec4(line_color.rgb, a * line_color.a * 0.55);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://l1bq145iht8
|
||||||
@@ -44,10 +44,12 @@ const CLIP_FALLBACKS := {
|
|||||||
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
|
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
|
||||||
"Dash": ["Dash", "Sprint", "Run", "Idle"],
|
"Dash": ["Dash", "Sprint", "Run", "Idle"],
|
||||||
"Death": ["Death", "Fall"],
|
"Death": ["Death", "Fall"],
|
||||||
|
"Hit": ["Hit", "Idle"],
|
||||||
|
"Dance": ["Dance", "Idle"],
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
|
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
|
const BLEND_TIME := 0.15
|
||||||
|
|
||||||
@@ -191,7 +193,23 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
|||||||
# ── Animation state ───────────────────────────────────────────────────────────
|
# ── Animation state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
var _prev_state: String = ""
|
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
|
## 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:
|
if not loaded or not animation_player:
|
||||||
return
|
return
|
||||||
|
|
||||||
# A heavy landing plays the Land one-shot before locomotion resumes.
|
# One-shots (Land, Hit) own playback briefly.
|
||||||
if _land_lock > 0.0:
|
if _oneshot_lock > 0.0:
|
||||||
_land_lock -= get_process_delta_time()
|
_oneshot_lock -= get_process_delta_time()
|
||||||
if _land_lock > 0.0 and state in ["ground", "idle"]:
|
if _oneshot_lock > 0.0:
|
||||||
_prev_state = state
|
_prev_state = state
|
||||||
return
|
return
|
||||||
|
# A heavy landing plays the Land one-shot before locomotion resumes.
|
||||||
if state in ["ground", "idle"] and _prev_state == "air" \
|
if state in ["ground", "idle"] and _prev_state == "air" \
|
||||||
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
|
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
|
||||||
_land_lock = 0.25
|
_oneshot_lock = 0.25
|
||||||
_play_clip("Land")
|
_play_clip("Land")
|
||||||
_prev_state = state
|
_prev_state = state
|
||||||
return
|
return
|
||||||
@@ -217,7 +236,9 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
|
|||||||
var clip := "Idle"
|
var clip := "Idle"
|
||||||
match state:
|
match state:
|
||||||
"ground", "idle":
|
"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"
|
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
|
||||||
elif speed > run_anim_reference_speed * 1.35:
|
elif speed > run_anim_reference_speed * 1.35:
|
||||||
clip = "Sprint"
|
clip = "Sprint"
|
||||||
@@ -291,12 +312,14 @@ func _process(delta: float) -> void:
|
|||||||
_pose_mod.wall = _cur_wall
|
_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):
|
if not animation_player or not _resolved_clips.has(canonical):
|
||||||
return
|
return
|
||||||
var clip_name: String = _resolved_clips[canonical]
|
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
|
return
|
||||||
|
if restart:
|
||||||
|
animation_player.stop()
|
||||||
animation_player.play(clip_name, BLEND_TIME)
|
animation_player.play(clip_name, BLEND_TIME)
|
||||||
_current_clip = clip_name
|
_current_clip = clip_name
|
||||||
|
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
client_rep_config.add_property(":synced_is_crouching")
|
client_rep_config.add_property(":synced_is_crouching")
|
||||||
client_rep_config.add_property(":synced_is_ads")
|
client_rep_config.add_property(":synced_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
|
client_rep_config.add_property(":synced_is_dancing")
|
||||||
client_rep_config.add_property(":synced_grapple_point")
|
client_rep_config.add_property(":synced_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ var synced_position: Vector3 = Vector3.ZERO
|
|||||||
var synced_velocity: Vector3 = Vector3.ZERO
|
var synced_velocity: Vector3 = Vector3.ZERO
|
||||||
var synced_is_ads: bool = false
|
var synced_is_ads: bool = false
|
||||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
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_skin_id: String = ""
|
||||||
@export var synced_weapon_path: String = ""
|
@export var synced_weapon_path: String = ""
|
||||||
|
|
||||||
@@ -130,6 +135,7 @@ func _ready() -> void:
|
|||||||
add_child(_damage_layer)
|
add_child(_damage_layer)
|
||||||
_setup_hit_marker()
|
_setup_hit_marker()
|
||||||
_setup_hud()
|
_setup_hud()
|
||||||
|
_setup_speedlines()
|
||||||
else:
|
else:
|
||||||
call_deferred("_hide_remote_weapons")
|
call_deferred("_hide_remote_weapons")
|
||||||
|
|
||||||
@@ -315,6 +321,18 @@ func _setup_audio() -> void:
|
|||||||
add_child(grapple_swing_player)
|
add_child(grapple_swing_player)
|
||||||
grapple_swing_player.play()
|
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:
|
func _setup_hit_marker() -> void:
|
||||||
hit_marker = Control.new()
|
hit_marker = Control.new()
|
||||||
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
|
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:
|
if attacker_id != 0:
|
||||||
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.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)
|
# Knockback lands on the simulating peer (movement is client-authoritative)
|
||||||
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
|
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
|
||||||
apply_impulse(impulse)
|
apply_impulse(impulse)
|
||||||
@@ -817,6 +841,22 @@ func _physics_process(_delta: float) -> void:
|
|||||||
if Input.is_action_just_pressed("toggle_camera_view"):
|
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||||
set_third_person(not third_person)
|
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()
|
var machine := _ensure_machine()
|
||||||
if machine:
|
if machine:
|
||||||
machine.input_dir = raw_input
|
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:
|
if not wind_player.playing and wind_factor > 0.0:
|
||||||
wind_player.play()
|
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()
|
var sm := _ensure_machine()
|
||||||
if not sm:
|
if not sm:
|
||||||
return
|
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)
|
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(sm.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
|
# Publish state for remote peers
|
||||||
synced_movement_state = sm.current_state
|
synced_movement_state = sm.current_state
|
||||||
@@ -918,6 +970,8 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
|
|||||||
grapple_shoot_player.play()
|
grapple_shoot_player.play()
|
||||||
elif ev == "grapple_latch":
|
elif ev == "grapple_latch":
|
||||||
grapple_latch_player.play()
|
grapple_latch_player.play()
|
||||||
|
elif ev == "dash":
|
||||||
|
_speedline_burst = 1.0
|
||||||
elif ev == "land":
|
elif ev == "land":
|
||||||
# Landing thud: reuse the footstep sample, pitched down and louder
|
# Landing thud: reuse the footstep sample, pitched down and louder
|
||||||
# with impact. Ground state resets pitch/volume before each step.
|
# 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)
|
visual.set_locomotion(d.x, d.y, 1.0 if synced_is_ads else 0.0)
|
||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(synced_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
|
# Check for weapon changes
|
||||||
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
||||||
visual.set_weapon(synced_weapon_path)
|
visual.set_weapon(synced_weapon_path)
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ toggle_camera_view={
|
|||||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
emote={
|
||||||
|
"deadzone": 0.0,
|
||||||
|
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":66,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[physics]
|
[physics]
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
client_rep_config.add_property(":synced_is_crouching")
|
client_rep_config.add_property(":synced_is_crouching")
|
||||||
client_rep_config.add_property(":synced_is_ads")
|
client_rep_config.add_property(":synced_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
|
client_rep_config.add_property(":synced_is_dancing")
|
||||||
client_rep_config.add_property(":synced_grapple_point")
|
client_rep_config.add_property(":synced_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
client_rep_config.add_property(":synced_is_crouching")
|
client_rep_config.add_property(":synced_is_crouching")
|
||||||
client_rep_config.add_property(":synced_is_ads")
|
client_rep_config.add_property(":synced_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
|
client_rep_config.add_property(":synced_is_dancing")
|
||||||
client_rep_config.add_property(":synced_grapple_point")
|
client_rep_config.add_property(":synced_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
|
|||||||
Reference in New Issue
Block a user