Feat/14 movement overhaul #20

Merged
Dotts merged 86 commits from feat/14-movement-overhaul into main 2026-07-17 10:44:23 -07:00
8 changed files with 136 additions and 10 deletions
Showing only changes of commit 5bcbcfdc3f - Show all commits
+38
View File
@@ -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);
}
+1
View File
@@ -0,0 +1 @@
uid://l1bq145iht8
+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
+1
View File
@@ -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")
+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)
+5
View File
@@ -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]
+1
View File
@@ -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")