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"],
|
||||
"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
|
||||
|
||||
|
||||
@@ -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_ads")
|
||||
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_is_grapple_shooting")
|
||||
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_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)
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
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]
|
||||
|
||||
|
||||
@@ -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_ads")
|
||||
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_is_grapple_shooting")
|
||||
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_ads")
|
||||
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_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
|
||||
Reference in New Issue
Block a user