diff --git a/movement/states/state_air.gd b/movement/states/state_air.gd index 6127959..b4df1d5 100644 --- a/movement/states/state_air.gd +++ b/movement/states/state_air.gd @@ -73,25 +73,7 @@ func update(delta: float) -> void: machine.notify_landed(fall_speed) # Bunny hop: if jump was buffered or pressed on landing frame - if (machine.input_jump_pressed or machine.jump_buffer_time > 0.0) and machine.jump_cooldown_timer <= 0.0: - var land_speed := Vector3(player.velocity.x, 0.0, player.velocity.z).length() - var bhop_speed := maxf(land_speed, minf(land_speed + params.bunny_hop_speed_gain, params.bunny_hop_speed_cap)) - # Maintain horizontal direction, boost speed - var hdir := Vector3(player.velocity.x, 0.0, player.velocity.z) - if hdir.length_squared() > 0.01: - hdir = hdir.normalized() - else: - hdir = machine.wish_dir_world - player.velocity.x = hdir.x * bhop_speed - player.velocity.z = hdir.z * bhop_speed - player.velocity.y = params.jump_velocity * params.bunny_hop_impulse - machine.current_jump_count = 1 - machine.on_ground = false - machine.jump_buffer_time = 0.0 - machine.register_chain_mechanic("bunny_hop") - machine.jump_cooldown_timer = params.jump_cooldown - if player.jump_player: - player.jump_player.play() + if _try_bunny_hop(): # Stay in air state return @@ -109,6 +91,7 @@ func update(delta: float) -> void: player.double_jump_player.play() return + # ── Wall interaction (Run, Climb, Vault) ────────────────────────────── if machine.input_dir.length() > 0.1 and machine.input_dir.y <= 0.0 and machine.wall_cooldown_timer <= 0.0: var fwd_wall = machine.detect_wall_forward() @@ -143,3 +126,35 @@ func update(delta: float) -> void: if machine.input_dash and machine.can_dash(): machine.switch_to("dash") return + + +func _try_bunny_hop() -> bool: + # Held jump is intentionally accepted here so landing can immediately start + # the next hop without spending a frame in Ground. It is still a distinct + # takeoff and must emit the same event as every other grounded jump; the + # player model uses that event to alternate its complete airborne pose. + if not (machine.input_jump_pressed or machine.jump_buffer_time > 0.0) \ + or machine.jump_cooldown_timer > 0.0: + return false + var player := machine.player + var land_speed := Vector3(player.velocity.x, 0.0, player.velocity.z).length() + var bhop_speed := maxf(land_speed, minf( + land_speed + params.bunny_hop_speed_gain, params.bunny_hop_speed_cap)) + # Maintain horizontal direction, boost speed. + var hdir := Vector3(player.velocity.x, 0.0, player.velocity.z) + if hdir.length_squared() > 0.01: + hdir = hdir.normalized() + else: + hdir = machine.wish_dir_world + player.velocity.x = hdir.x * bhop_speed + player.velocity.z = hdir.z * bhop_speed + player.velocity.y = params.jump_velocity * params.bunny_hop_impulse + machine.current_jump_count = 1 + machine.on_ground = false + machine.jump_buffer_time = 0.0 + machine.register_chain_mechanic("bunny_hop") + machine.jump_cooldown_timer = params.jump_cooldown + machine.movement_event.emit("jump", {"kind": "bunny_hop"}) + if player.jump_player: + player.jump_player.play() + return true diff --git a/movement/states/state_grapple.gd b/movement/states/state_grapple.gd index fdda2d7..46f577c 100644 --- a/movement/states/state_grapple.gd +++ b/movement/states/state_grapple.gd @@ -63,6 +63,7 @@ func update(delta: float) -> void: player.velocity = vel machine.jump_cooldown_timer = params.jump_cooldown machine.register_chain_mechanic("grapple_jump") + machine.movement_event.emit("jump", {"kind": "grapple_jump"}) machine.switch_to("air") if player.jump_player: player.jump_player.play() diff --git a/movement/tests/test_fsm_runner.gd b/movement/tests/test_fsm_runner.gd index c0ebf21..0eda0f2 100644 --- a/movement/tests/test_fsm_runner.gd +++ b/movement/tests/test_fsm_runner.gd @@ -33,6 +33,7 @@ func run_all() -> void: var tests := [ "test_states_register_and_initialize", "test_do_jump_sets_velocity_and_counters", + "test_held_jump_bunny_hops_emit_each_takeoff", "test_double_jump_emits_jet_event", "test_wall_run_preserves_fast_entry_speed", "test_wall_run_entry_keeps_some_upward_momentum", @@ -129,6 +130,40 @@ func test_do_jump_sets_velocity_and_counters() -> void: _expect(not sm.on_ground, "no longer grounded") +func test_held_jump_bunny_hops_emit_each_takeoff() -> void: + var jump_events: Array[String] = [] + var controller := PlayerMovementController.new() + controller._machine = sm + sm.movement_event.connect(func(event_name: String, data: Dictionary) -> void: + if event_name == "jump": + jump_events.append(String(data.get("kind", "jump"))) + controller._on_movement_event(event_name, data) + ) + var air = sm.states["air"] + sm.input_jump_pressed = true + fake_player.velocity = Vector3(0.0, 0.0, -params.walk_speed) + var variants: Array[String] = [] + + # Start with an ordinary ground jump, then keep Space held through three + # direct Air -> Air bunny hops. No intervening motion state is involved. + sm.do_jump() + variants.append(controller._visual_animation_state("air", params.walk_speed)) + for hop in 3: + sm.jump_cooldown_timer = 0.0 + sm.notify_landed(0.0) + _expect(air._try_bunny_hop(), + "held jump starts bunny hop %d" % (hop + 1)) + variants.append(controller._visual_animation_state( + "air", params.walk_speed)) + + _eq(jump_events, ["jump", "bunny_hop", "bunny_hop", "bunny_hop"], + "every held-space bunny hop emits a jump takeoff") + _eq(variants, ["air_alt", "air", "air_alt", "air"], + "ground jump and consecutive bunny hops alternate leg variants") + controller._machine = null + controller.free() + + func test_double_jump_emits_jet_event() -> void: var events := [] sm.movement_event.connect(func(ev, data): events.append([ev, data]))