Feat/14 movement overhaul #20
@@ -22,8 +22,11 @@ class_name MovementParams
|
|||||||
# ── Air Strafe (Quake-style) ──────────────────────────────────────────────────
|
# ── Air Strafe (Quake-style) ──────────────────────────────────────────────────
|
||||||
# Classic accelerate: only the velocity component along wish_dir is capped, so
|
# Classic accelerate: only the velocity component along wish_dir is capped, so
|
||||||
# turning while strafing genuinely gains speed (up to max_air_speed overall).
|
# turning while strafing genuinely gains speed (up to max_air_speed overall).
|
||||||
@export var air_strafe_accel: float = 55.0
|
@export var air_strafe_accel: float = 90.0
|
||||||
@export var air_wish_speed_cap: float = 1.2 # per-tick projection cap (m/s), quake-style
|
@export var air_wish_speed_cap: float = 2.5 # per-tick projection cap (m/s), quake-style
|
||||||
|
# Direct steering: bends existing horizontal velocity toward the input without
|
||||||
|
# changing its magnitude, so air direction changes feel light and responsive.
|
||||||
|
@export var air_steer_rate: float = 5.0
|
||||||
|
|
||||||
# ── Bunny Hop ─────────────────────────────────────────────────────────────────
|
# ── Bunny Hop ─────────────────────────────────────────────────────────────────
|
||||||
@export var bunny_hop_impulse: float = 1.1
|
@export var bunny_hop_impulse: float = 1.1
|
||||||
@@ -82,7 +85,8 @@ class_name MovementParams
|
|||||||
# ── Dash ──────────────────────────────────────────────────────────────────────
|
# ── Dash ──────────────────────────────────────────────────────────────────────
|
||||||
@export var dash_speed: float = 13.0
|
@export var dash_speed: float = 13.0
|
||||||
@export var dash_duration: float = 0.18
|
@export var dash_duration: float = 0.18
|
||||||
@export var dash_cooldown: float = 2.0
|
@export var dash_charges: int = 2 # dashes available before recharging
|
||||||
|
@export var dash_cooldown: float = 2.0 # seconds to regain ONE charge
|
||||||
@export var dash_invulnerability_time: float = 0.1
|
@export var dash_invulnerability_time: float = 0.1
|
||||||
|
|
||||||
# ── Landing feel ──────────────────────────────────────────────────────────────
|
# ── Landing feel ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ var last_ground_time: float = 0.0
|
|||||||
var jump_buffer_time: float = 0.0
|
var jump_buffer_time: float = 0.0
|
||||||
var coyote_timer: float = 0.0
|
var coyote_timer: float = 0.0
|
||||||
var jump_cooldown_timer: float = 0.0
|
var jump_cooldown_timer: float = 0.0
|
||||||
var dash_cooldown_timer: float = 0.0
|
var dash_charges: int = -1 # -1 = initialize from params on first tick
|
||||||
|
var dash_recharge_timer: float = 0.0
|
||||||
var current_jump_count: int = 0
|
var current_jump_count: int = 0
|
||||||
var chain_timer: float = 0.0
|
var chain_timer: float = 0.0
|
||||||
var chain_count: int = 0
|
var chain_count: int = 0
|
||||||
@@ -103,10 +104,18 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
# Update timers
|
# Update timers
|
||||||
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
jump_cooldown_timer = maxf(jump_cooldown_timer - delta, 0.0)
|
||||||
dash_cooldown_timer = maxf(dash_cooldown_timer - delta, 0.0)
|
|
||||||
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
slide_cooldown_timer = maxf(slide_cooldown_timer - delta, 0.0)
|
||||||
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
wall_cooldown_timer = maxf(wall_cooldown_timer - delta, 0.0)
|
||||||
|
|
||||||
|
# Dash charges: regain one per dash_cooldown while below max
|
||||||
|
if dash_charges < 0:
|
||||||
|
dash_charges = params.dash_charges
|
||||||
|
if dash_charges < params.dash_charges:
|
||||||
|
dash_recharge_timer -= delta
|
||||||
|
if dash_recharge_timer <= 0.0:
|
||||||
|
dash_charges += 1
|
||||||
|
dash_recharge_timer = params.dash_cooldown if dash_charges < params.dash_charges else 0.0
|
||||||
|
|
||||||
# Update chain timer
|
# Update chain timer
|
||||||
if chain_timer > 0.0 and on_ground:
|
if chain_timer > 0.0 and on_ground:
|
||||||
chain_timer -= delta
|
chain_timer -= delta
|
||||||
@@ -401,5 +410,26 @@ func get_crouch_factor() -> float:
|
|||||||
/ (original_capsule_height * 0.5), 0.0, 1.0)
|
/ (original_capsule_height * 0.5), 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
func can_dash() -> bool:
|
||||||
|
return dash_charges != 0 # -1 (uninitialized) counts as ready
|
||||||
|
|
||||||
|
|
||||||
|
## Spend one dash charge and start the recharge clock if it isn't running.
|
||||||
|
func consume_dash_charge() -> void:
|
||||||
|
if dash_charges < 0:
|
||||||
|
dash_charges = params.dash_charges
|
||||||
|
dash_charges = maxi(dash_charges - 1, 0)
|
||||||
|
if dash_recharge_timer <= 0.0:
|
||||||
|
dash_recharge_timer = params.dash_cooldown
|
||||||
|
|
||||||
|
|
||||||
|
## Seconds until the NEXT dash is available (0 when a charge is banked).
|
||||||
|
## HUDs also read get_dash_charges() to draw pips.
|
||||||
func get_dash_cooldown_remaining() -> float:
|
func get_dash_cooldown_remaining() -> float:
|
||||||
return dash_cooldown_timer
|
if dash_charges != 0:
|
||||||
|
return 0.0
|
||||||
|
return maxf(dash_recharge_timer, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
func get_dash_charges() -> int:
|
||||||
|
return dash_charges if dash_charges >= 0 else params.dash_charges
|
||||||
|
|||||||
@@ -45,9 +45,21 @@ func update(delta: float) -> void:
|
|||||||
# Quake's trick: cap per-tick projection so sharp turns give the gain
|
# Quake's trick: cap per-tick projection so sharp turns give the gain
|
||||||
accel_speed = minf(accel_speed, params.air_wish_speed_cap)
|
accel_speed = minf(accel_speed, params.air_wish_speed_cap)
|
||||||
hvel += wish_dir * accel_speed
|
hvel += wish_dir * accel_speed
|
||||||
|
|
||||||
|
# Direct steering: bend existing velocity toward the input WITHOUT
|
||||||
|
# changing its magnitude, so airborne direction changes feel light.
|
||||||
|
# Skipped when input opposes travel (braking is the accel path's job,
|
||||||
|
# and lerping through zero would flip the heading unstably).
|
||||||
|
var speed_now := hvel.length()
|
||||||
|
if speed_now > 1.0:
|
||||||
|
var travel_dir := hvel / speed_now
|
||||||
|
if travel_dir.dot(wish_dir) > -0.7:
|
||||||
|
var steered := hvel.lerp(wish_dir * speed_now, params.air_steer_rate * delta)
|
||||||
|
if steered.length_squared() > 0.01:
|
||||||
|
hvel = steered.normalized() * speed_now
|
||||||
else:
|
else:
|
||||||
# No input: slight air drag (very subtle)
|
# No input: barely any drag — held momentum should carry
|
||||||
hvel *= (1.0 - 0.5 * delta)
|
hvel *= (1.0 - 0.15 * delta)
|
||||||
|
|
||||||
vel.x = hvel.x
|
vel.x = hvel.x
|
||||||
vel.z = hvel.z
|
vel.z = hvel.z
|
||||||
@@ -127,6 +139,6 @@ func update(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── Dash ──────────────────────────────────────────────────────────────
|
# ── Dash ──────────────────────────────────────────────────────────────
|
||||||
if machine.input_dash and machine.dash_cooldown_timer <= 0.0:
|
if machine.input_dash and machine.can_dash():
|
||||||
machine.switch_to("dash")
|
machine.switch_to("dash")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ var _dash_speed: float = 0.0
|
|||||||
|
|
||||||
func enter(_data: Dictionary = {}) -> void:
|
func enter(_data: Dictionary = {}) -> void:
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
machine.dash_cooldown_timer = params.dash_cooldown
|
machine.consume_dash_charge()
|
||||||
|
|
||||||
var player := machine.player
|
var player := machine.player
|
||||||
if player.dash_player:
|
if player.dash_player:
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ func update(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── Dash ──────────────────────────────────────────────────────────────
|
# ── Dash ──────────────────────────────────────────────────────────────
|
||||||
if machine.input_dash and machine.dash_cooldown_timer <= 0.0:
|
if machine.input_dash and machine.can_dash():
|
||||||
machine.switch_to("dash")
|
machine.switch_to("dash")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ func update(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Dash out of slide
|
# Dash out of slide
|
||||||
if machine.input_dash and machine.dash_cooldown_timer <= 0.0:
|
if machine.input_dash and machine.can_dash():
|
||||||
machine.switch_to("dash")
|
machine.switch_to("dash")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d07u4xreb41rf
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dhu2es4slyh2i
|
||||||
@@ -36,6 +36,7 @@ func run_all() -> void:
|
|||||||
"test_wall_run_preserves_fast_entry_speed",
|
"test_wall_run_preserves_fast_entry_speed",
|
||||||
"test_wall_run_entry_keeps_some_upward_momentum",
|
"test_wall_run_entry_keeps_some_upward_momentum",
|
||||||
"test_dash_cooldown_is_per_machine",
|
"test_dash_cooldown_is_per_machine",
|
||||||
|
"test_dash_has_two_charges_then_recharges",
|
||||||
"test_dash_adds_speed_to_forward_momentum",
|
"test_dash_adds_speed_to_forward_momentum",
|
||||||
"test_chain_bonus_soft_caps",
|
"test_chain_bonus_soft_caps",
|
||||||
"test_notify_landed_emits_scaled_event",
|
"test_notify_landed_emits_scaled_event",
|
||||||
@@ -149,9 +150,10 @@ func test_dash_cooldown_is_per_machine() -> void:
|
|||||||
fake_player.velocity = Vector3(10, 0, 0)
|
fake_player.velocity = Vector3(10, 0, 0)
|
||||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||||
sm.switch_to("dash")
|
sm.switch_to("dash")
|
||||||
_expect(sm.dash_cooldown_timer > 0.0, "dash arms this machine's cooldown")
|
_expect(sm.get_dash_charges() == params.dash_charges - 1, "dash spends one charge")
|
||||||
|
_expect(sm.dash_recharge_timer > 0.0, "dash arms this machine's recharge clock")
|
||||||
|
|
||||||
# A second, independent machine must NOT share that cooldown (the old
|
# A second, independent machine must NOT share that state (the old
|
||||||
# implementation used a static var shared across all players).
|
# implementation used a static var shared across all players).
|
||||||
var player2 = load("res://movement/tests/fake_player.gd").new()
|
var player2 = load("res://movement/tests/fake_player.gd").new()
|
||||||
add_child(player2)
|
add_child(player2)
|
||||||
@@ -159,11 +161,36 @@ func test_dash_cooldown_is_per_machine() -> void:
|
|||||||
sm2.player = player2
|
sm2.player = player2
|
||||||
sm2.params = MovementParams.new()
|
sm2.params = MovementParams.new()
|
||||||
player2.add_child(sm2)
|
player2.add_child(sm2)
|
||||||
_expect(sm2.dash_cooldown_timer == 0.0, "second player's dash must be ready")
|
_expect(sm2.get_dash_charges() == sm2.params.dash_charges, "second player keeps full charges")
|
||||||
remove_child(player2)
|
remove_child(player2)
|
||||||
player2.free()
|
player2.free()
|
||||||
|
|
||||||
|
|
||||||
|
func test_dash_has_two_charges_then_recharges() -> void:
|
||||||
|
params.dash_charges = 2
|
||||||
|
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||||
|
fake_player.velocity = Vector3(10, 0, 0)
|
||||||
|
|
||||||
|
_expect(sm.can_dash(), "first dash available")
|
||||||
|
sm.switch_to("dash")
|
||||||
|
sm.switch_to("air")
|
||||||
|
_expect(sm.can_dash(), "second charge still banked — no cooldown wait")
|
||||||
|
_eq(sm.get_dash_cooldown_remaining(), 0.0, "cooldown reads 0 while a charge is banked")
|
||||||
|
sm.switch_to("dash")
|
||||||
|
sm.switch_to("air")
|
||||||
|
_expect(not sm.can_dash(), "both charges spent → dash blocked")
|
||||||
|
_expect(sm.get_dash_cooldown_remaining() > 0.0, "cooldown now counts to next charge")
|
||||||
|
|
||||||
|
# Simulate the recharge tick the machine runs each physics frame.
|
||||||
|
# Blank the state so only machine bookkeeping runs (no state update /
|
||||||
|
# move_and_slide outside a real physics frame).
|
||||||
|
sm.current_state = ""
|
||||||
|
sm.dash_recharge_timer = 0.0001
|
||||||
|
sm._physics_process(0.016)
|
||||||
|
_expect(sm.can_dash(), "one charge regained after recharge timer elapses")
|
||||||
|
_expect(sm.dash_recharge_timer > 0.0, "recharge continues toward the second charge")
|
||||||
|
|
||||||
|
|
||||||
func test_dash_adds_speed_to_forward_momentum() -> void:
|
func test_dash_adds_speed_to_forward_momentum() -> void:
|
||||||
fake_player.velocity = Vector3(12, 0, 0)
|
fake_player.velocity = Vector3(12, 0, 0)
|
||||||
sm.wish_dir_world = Vector3(1, 0, 0)
|
sm.wish_dir_world = Vector3(1, 0, 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user