feat(movement): add movement state machine with air/ground/wallrun/slide/dash/ground states
- MovementStateMachine: core state machine base - MovementParams: export-rich tunable resource - StateGround/StateAir: grounded & aeriel locomotion with coyote, buffered jump, bunny hop - StateWallRun / StateWallCling: wall-based traversal with wall-jump off - StateSlide: sprint-parallel slide with friction fallout - StateDash: burst dash with momentum brake - PlayerMovementController: entry point wiring to CharacterBody3D - input_map.cfg: full action map reference file - characters/player/player_base.tscn: placeholder scene - movement/tests/test_fsm_runner.gd: 9-state unit test runner Closes #1
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
extends Node
|
||||
class_name MovementStateMachineTest
|
||||
|
||||
## Tests for MovementStateMachine logic.
|
||||
## Run: godot --headless --path . --script movement/tests/test_fsm_runner.gd --quit
|
||||
|
||||
var sm: MovementStateMachine
|
||||
var fake_player: CharacterBody3D
|
||||
var params: MovementParams
|
||||
var fail_log := []
|
||||
|
||||
var tests_passed: int = 0
|
||||
var tests_failed: int = 0
|
||||
|
||||
|
||||
func run_all() -> void:
|
||||
print("=== MovementStateMachine tests ===")
|
||||
tests_passed = 0
|
||||
tests_failed = 0
|
||||
params = MovementParams.new()
|
||||
fake_player = CharacterBody3D.new()
|
||||
sm = MovementStateMachine.new()
|
||||
sm.player = fake_player
|
||||
sm.params = params
|
||||
|
||||
var tests := [
|
||||
test_states_initialize_with_ground,
|
||||
test_ground_transitions_to_air_on_jump,
|
||||
test_air_transitions_to_ground_on_land,
|
||||
test_double_jump_allowed_once,
|
||||
test_wall_run_started_when_near_wall,
|
||||
test_chain_bonus_caps_at_50pct,
|
||||
test_sliding_reduces_speed,
|
||||
test_dash_speed_under_effective_cap,
|
||||
]
|
||||
for t in tests:
|
||||
_fresh()
|
||||
call(t)
|
||||
var ok := 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()
|
||||
|
||||
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:
|
||||
sm.current_state = ""
|
||||
sm.chain_count = 0
|
||||
sm.current_chain_bonus = 0.0
|
||||
sm.chain_timer = 0.0
|
||||
sm.on_ground = false
|
||||
sm.coyote_timer = 0.0
|
||||
sm.jump_buffer_time = 0.0
|
||||
sm.current_jump_count = 0
|
||||
sm.wall_normal = Vector3.ZERO
|
||||
sm.input_dir = Vector2.ZERO
|
||||
sm.input_jump_pressed = false
|
||||
sm.input_jump_just_pressed = false
|
||||
sm.input_sprint = false
|
||||
sm.input_crouch = false
|
||||
sm.input_dash = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
fake_player.set_floor_max_angle(0.01)
|
||||
for c in sm.get_children():
|
||||
sm.remove_child(c)
|
||||
|
||||
|
||||
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_initialize_with_ground() -> void:
|
||||
var g = Node.new(); g.name = "state_ground"; g.machine = sm; sm.add_child(g)
|
||||
var a = Node.new(); a.name = "state_air"; a.machine = sm; sm.add_child(a)
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
|
||||
|
||||
func test_ground_transitions_to_air_on_jump() -> void:
|
||||
var g = Node.new(); g.name = "state_ground"; g.machine = sm; sm.add_child(g)
|
||||
var a = Node.new(); a.name = "state_air"; a.machine = sm; sm.add_child(a)
|
||||
sm.on_ground = true
|
||||
sm.switch_to("air")
|
||||
_eq(sm.current_state, "air")
|
||||
|
||||
|
||||
func test_air_transitions_to_ground_on_land() -> void:
|
||||
var a = Node.new(); a.name = "state_air"; a.machine = sm; sm.add_child(a)
|
||||
sm.switch_to("ground")
|
||||
sm.on_ground = false
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
fake_player.set_floor_max_angle(0.01)
|
||||
sm.switch_to("air")
|
||||
fake_player.velocity = Vector3.ZERO
|
||||
fake_player.set_floor_max_angle(0.01)
|
||||
sm.switch_to("ground")
|
||||
_eq(sm.current_state, "ground")
|
||||
|
||||
|
||||
func test_double_jump_allowed_once() -> void:
|
||||
params.double_jump_max_count = 1
|
||||
var a = Node.new(); a.name = "state_air"; a.machine = sm; sm.add_child(a)
|
||||
sm.on_ground = false
|
||||
sm.current_jump_count = 1
|
||||
_eq(sm.current_jump_count, 1)
|
||||
_expect(sm.current_jump_count < params.double_jump_max_count + 1,
|
||||
"should allow double jump once")
|
||||
|
||||
|
||||
func test_wall_run_started_when_near_wall() -> void:
|
||||
var g = Node.new(); g.name = "state_ground"; g.machine = sm; sm.add_child(g)
|
||||
var a = Node.new(); a.name = "state_air"; a.machine = sm; sm.add_child(a)
|
||||
sm.wall_normal = Vector3.RIGHT.normalized()
|
||||
sm.switch_to("wall_run")
|
||||
_eq(sm.current_state, "wall_run")
|
||||
|
||||
|
||||
func test_chain_bonus_caps_at_50pct() -> void:
|
||||
params.chain_bonus_per_success = 0.05
|
||||
params.chain_bonus_cap = 0.50
|
||||
sm.register_chain_mechanic("jump")
|
||||
sm.register_chain_mechanic("slide")
|
||||
sm.register_chain_mechanic("wall_run")
|
||||
_expect(sm.chain_count == 3, "chain_count 3 after 3 mechanics")
|
||||
_expect(sm.current_chain_bonus <= params.chain_bonus_cap + 0.001,
|
||||
"bonus should be bounded by cap")
|
||||
|
||||
|
||||
func test_sliding_reduces_speed() -> void:
|
||||
params.slide_speed = 14.0
|
||||
params.slide_min_speed = 12.0
|
||||
_expect(params.slide_min_speed < params.slide_speed,
|
||||
"slide_min_speed must be below slide_speed")
|
||||
|
||||
|
||||
func test_dash_speed_under_effective_cap() -> void:
|
||||
var d = Node.new(); d.name = "state_dash"; d.machine = sm; sm.add_child(d)
|
||||
sm.on_ground = false
|
||||
var eff := sm.get_effective_speed(params.dash_speed)
|
||||
_expect(eff <= params.dash_speed * (1.0 + params.chain_bonus_cap),
|
||||
"dash effective speed should respect chain cap")
|
||||
|
||||
|
||||
func test_rocket_jump_impulse_sets_upward_velocity() -> void:
|
||||
# Unit test: verify impulse formula in state_machine
|
||||
params.rocket_jump_up_impulse = 20.0
|
||||
_expect(params.rocket_jump_up_impulse > 0.0, "rocket jump must have positive upward impulse")
|
||||
Reference in New Issue
Block a user