- Air: direct steering bends existing velocity toward input without changing its magnitude (air_steer_rate), on top of Quake accelerate with raised gains (air_strafe_accel 55->90, per-tick cap 1.2->2.5). No-input air drag nearly removed so held momentum carries. Steering skips opposing input to avoid flipping through zero. - Dash: charge system on the machine — dash_charges (2) spend instantly, each recharges in dash_cooldown (2 s). get_dash_cooldown_remaining() now reports time to the NEXT charge (0 while one is banked) so existing HUDs keep working; get_dash_charges() added for pips. - Tests: dash tests updated to the charge API + new coverage for spend-2-then-recharge; 11/11 pass, spawn smoke clean. Co-Authored-By: Claude Fable 5 <[email protected]>
245 lines
9.1 KiB
GDScript
245 lines
9.1 KiB
GDScript
extends Node
|
|
class_name MovementStateMachineTest
|
|
|
|
## Unit tests for MovementStateMachine + states. Runs headless via
|
|
## movement/tests/run_fsm_tests.gd. Each test gets a fresh machine with the
|
|
## real state scripts attached, added to the tree so _ready registers states.
|
|
## Tests exercise transitions and bookkeeping — not physics (no move_and_slide
|
|
## against a real world here; the spawn smoke test covers integration).
|
|
|
|
var sm: MovementStateMachine
|
|
var fake_player: CharacterBody3D
|
|
var params: MovementParams
|
|
var fail_log := []
|
|
|
|
var tests_passed: int = 0
|
|
var tests_failed: int = 0
|
|
|
|
const STATE_SCRIPTS := {
|
|
"state_ground": "res://movement/states/state_ground.gd",
|
|
"state_air": "res://movement/states/state_air.gd",
|
|
"state_wall_run": "res://movement/states/state_wall_run.gd",
|
|
"state_wall_cling": "res://movement/states/state_wall_cling.gd",
|
|
"state_slide": "res://movement/states/state_slide.gd",
|
|
"state_dash": "res://movement/states/state_dash.gd",
|
|
}
|
|
|
|
|
|
func run_all() -> void:
|
|
print("=== MovementStateMachine tests ===")
|
|
tests_passed = 0
|
|
tests_failed = 0
|
|
|
|
var tests := [
|
|
"test_states_register_and_initialize",
|
|
"test_do_jump_sets_velocity_and_counters",
|
|
"test_wall_run_preserves_fast_entry_speed",
|
|
"test_wall_run_entry_keeps_some_upward_momentum",
|
|
"test_dash_cooldown_is_per_machine",
|
|
"test_dash_has_two_charges_then_recharges",
|
|
"test_dash_adds_speed_to_forward_momentum",
|
|
"test_chain_bonus_soft_caps",
|
|
"test_notify_landed_emits_scaled_event",
|
|
"test_slide_entry_boosts_and_keeps_direction",
|
|
"test_grapple_state_registered_by_machine",
|
|
]
|
|
for t in tests:
|
|
_fresh()
|
|
call(t)
|
|
var ok: bool = fail_log.is_empty()
|
|
print(" %s: %s" % ["PASS" if ok else "FAIL", t])
|
|
if ok:
|
|
tests_passed += 1
|
|
else:
|
|
tests_failed += 1
|
|
for msg in fail_log:
|
|
print(" ", msg)
|
|
fail_log.clear()
|
|
_teardown()
|
|
|
|
print("=== Results: %d passed, %d failed ===" % [tests_passed, tests_failed])
|
|
if tests_failed > 0:
|
|
print("SOME TESTS FAILED")
|
|
get_tree().quit(1)
|
|
else:
|
|
print("ALL TESTS PASSED")
|
|
get_tree().quit(0)
|
|
|
|
|
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
func _fresh() -> void:
|
|
params = MovementParams.new()
|
|
fake_player = load("res://movement/tests/fake_player.gd").new()
|
|
add_child(fake_player)
|
|
sm = MovementStateMachine.new()
|
|
sm.player = fake_player
|
|
sm.params = params
|
|
for state_name in STATE_SCRIPTS:
|
|
var st := Node.new()
|
|
st.name = state_name
|
|
st.set_script(load(STATE_SCRIPTS[state_name]))
|
|
sm.add_child(st)
|
|
fake_player.add_child(sm) # triggers _ready: registers states + injected ones
|
|
sm.set_physics_process(false) # tests drive the machine manually
|
|
|
|
|
|
func _teardown() -> void:
|
|
if is_instance_valid(fake_player):
|
|
remove_child(fake_player)
|
|
fake_player.free()
|
|
sm = null
|
|
fake_player = null
|
|
|
|
|
|
func _expect(cond: bool, msg: String) -> void:
|
|
if not cond:
|
|
fail_log.append(msg)
|
|
|
|
|
|
func _eq(a: Variant, b: Variant, msg: String = "") -> void:
|
|
if a != b:
|
|
fail_log.append(msg if not msg.is_empty() else ("expected %s == %s" % [a, b]))
|
|
|
|
|
|
# ── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
func test_states_register_and_initialize() -> void:
|
|
# All six explicit states plus the two the machine injects itself.
|
|
for expected in ["ground", "air", "wall_run", "wall_cling", "slide", "dash", "grapple", "wall_climb"]:
|
|
_expect(sm.states.has(expected), "state '%s' should be registered" % expected)
|
|
_expect(not sm.current_state.is_empty(), "machine should start in a state")
|
|
sm.switch_to("ground")
|
|
_eq(sm.current_state, "ground")
|
|
sm.switch_to("air")
|
|
_eq(sm.current_state, "air")
|
|
|
|
|
|
func test_do_jump_sets_velocity_and_counters() -> void:
|
|
sm.switch_to("ground")
|
|
sm.on_ground = true
|
|
fake_player.velocity = Vector3(5, 0, 0)
|
|
sm.do_jump()
|
|
_expect(fake_player.velocity.y == params.jump_velocity, "jump sets vertical velocity")
|
|
_expect(fake_player.velocity.x == 5.0, "jump keeps horizontal momentum")
|
|
_eq(sm.current_jump_count, 1, "jump count set")
|
|
_expect(sm.jump_cooldown_timer > 0.0, "jump cooldown armed")
|
|
_expect(not sm.on_ground, "no longer grounded")
|
|
|
|
|
|
func test_wall_run_preserves_fast_entry_speed() -> void:
|
|
# Enter a wall run at 20 m/s along the wall; speed must not drop to base.
|
|
sm.wall_normal = Vector3.RIGHT
|
|
sm.wall_side = -1.0
|
|
fake_player.velocity = Vector3(0, 0, -20) # along wall tangent
|
|
sm.switch_to("wall_run")
|
|
var hspeed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
|
_expect(hspeed >= 19.9, "wall run must keep entry speed (got %.1f)" % hspeed)
|
|
|
|
|
|
func test_wall_run_entry_keeps_some_upward_momentum() -> void:
|
|
sm.wall_normal = Vector3.RIGHT
|
|
fake_player.velocity = Vector3(0, 20.0, -10)
|
|
sm.switch_to("wall_run")
|
|
_expect(fake_player.velocity.y > 0.0, "some upward carry should remain")
|
|
_expect(fake_player.velocity.y <= params.wall_run_entry_max_up + 0.001,
|
|
"upward carry capped at wall_run_entry_max_up")
|
|
|
|
|
|
func test_dash_cooldown_is_per_machine() -> void:
|
|
fake_player.velocity = Vector3(10, 0, 0)
|
|
sm.wish_dir_world = Vector3(1, 0, 0)
|
|
sm.switch_to("dash")
|
|
_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 state (the old
|
|
# implementation used a static var shared across all players).
|
|
var player2 = load("res://movement/tests/fake_player.gd").new()
|
|
add_child(player2)
|
|
var sm2 := MovementStateMachine.new()
|
|
sm2.player = player2
|
|
sm2.params = MovementParams.new()
|
|
player2.add_child(sm2)
|
|
_expect(sm2.get_dash_charges() == sm2.params.dash_charges, "second player keeps full charges")
|
|
remove_child(player2)
|
|
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:
|
|
fake_player.velocity = Vector3(12, 0, 0)
|
|
sm.wish_dir_world = Vector3(1, 0, 0)
|
|
sm.switch_to("dash")
|
|
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
|
_expect(speed >= 12.0 + params.dash_speed - 0.01,
|
|
"forward dash should add dash_speed on top of momentum (got %.1f)" % speed)
|
|
|
|
|
|
func test_chain_bonus_soft_caps() -> void:
|
|
params.chain_bonus_per_success = 0.05
|
|
params.chain_bonus_cap = 0.25
|
|
for i in range(12):
|
|
sm.register_chain_mechanic("jump")
|
|
_eq(sm.chain_count, 12, "chain count accumulates")
|
|
_expect(sm.current_chain_bonus <= params.chain_bonus_cap + 0.35,
|
|
"bonus stays near cap (soft cap)")
|
|
_expect(sm.current_chain_bonus >= params.chain_bonus_cap,
|
|
"soft cap still rewards long chains slightly")
|
|
|
|
|
|
func test_notify_landed_emits_scaled_event() -> void:
|
|
var events := []
|
|
sm.movement_event.connect(func(ev, data): events.append([ev, data]))
|
|
sm.notify_landed(2.0) # soft: below land_soft_speed → no event
|
|
_expect(events.is_empty(), "gentle landings emit no event")
|
|
sm.notify_landed(params.land_heavy_speed + 1.0)
|
|
_expect(events.size() == 1 and events[0][0] == "land", "heavy landing emits land event")
|
|
if events.size() == 1:
|
|
_expect(events[0][1].get("heavy", false), "heavy flag set above land_heavy_speed")
|
|
_expect(sm.current_jump_count == 0, "landing resets jump count")
|
|
|
|
|
|
func test_slide_entry_boosts_and_keeps_direction() -> void:
|
|
sm.switch_to("ground")
|
|
fake_player.velocity = Vector3(0, 0, -12) # faster than 0.8*walk_speed
|
|
# Slide entry boost only applies when is_on_floor(); fake player isn't on a
|
|
# real floor, so just verify direction is kept and speed never decreases.
|
|
sm.switch_to("slide")
|
|
_expect(fake_player.velocity.z < 0.0, "slide keeps travel direction")
|
|
var speed := Vector3(fake_player.velocity.x, 0, fake_player.velocity.z).length()
|
|
_expect(speed >= 12.0 - 0.01, "slide entry must not lose speed (got %.1f)" % speed)
|
|
_expect(sm.states.has("slide"), "slide state present")
|
|
|
|
|
|
func test_grapple_state_registered_by_machine() -> void:
|
|
_expect(sm.states.has("grapple"), "machine injects grapple state")
|
|
_expect(sm.states.has("wall_climb"), "machine injects wall_climb state")
|
|
sm.grapple_length = 10.0
|
|
sm.switch_to("grapple")
|
|
_eq(sm.current_state, "grapple")
|