diff --git a/godot/pipeline/ldtk/levels/Room_0_0.scn b/godot/pipeline/ldtk/levels/Room_0_0.scn index fc3ea84..76beaae 100644 Binary files a/godot/pipeline/ldtk/levels/Room_0_0.scn and b/godot/pipeline/ldtk/levels/Room_0_0.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_0_1.scn b/godot/pipeline/ldtk/levels/Room_0_1.scn index ba0b087..96a3db7 100644 Binary files a/godot/pipeline/ldtk/levels/Room_0_1.scn and b/godot/pipeline/ldtk/levels/Room_0_1.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_1_0.scn b/godot/pipeline/ldtk/levels/Room_1_0.scn index 63cab19..f24d0a1 100644 Binary files a/godot/pipeline/ldtk/levels/Room_1_0.scn and b/godot/pipeline/ldtk/levels/Room_1_0.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_1_1.scn b/godot/pipeline/ldtk/levels/Room_1_1.scn index f5750ff..0b7217e 100644 Binary files a/godot/pipeline/ldtk/levels/Room_1_1.scn and b/godot/pipeline/ldtk/levels/Room_1_1.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_1_2.scn b/godot/pipeline/ldtk/levels/Room_1_2.scn index 1f8028f..6528083 100644 Binary files a/godot/pipeline/ldtk/levels/Room_1_2.scn and b/godot/pipeline/ldtk/levels/Room_1_2.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_2_1.scn b/godot/pipeline/ldtk/levels/Room_2_1.scn index 3ce6804..8a29c0e 100644 Binary files a/godot/pipeline/ldtk/levels/Room_2_1.scn and b/godot/pipeline/ldtk/levels/Room_2_1.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_2_2.scn b/godot/pipeline/ldtk/levels/Room_2_2.scn index 69e2ff4..19d12aa 100644 Binary files a/godot/pipeline/ldtk/levels/Room_2_2.scn and b/godot/pipeline/ldtk/levels/Room_2_2.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_3_1.scn b/godot/pipeline/ldtk/levels/Room_3_1.scn index 356a4ad..92eac20 100644 Binary files a/godot/pipeline/ldtk/levels/Room_3_1.scn and b/godot/pipeline/ldtk/levels/Room_3_1.scn differ diff --git a/godot/pipeline/ldtk/levels/Room_4_1.scn b/godot/pipeline/ldtk/levels/Room_4_1.scn index 98fff48..e59eb86 100644 Binary files a/godot/pipeline/ldtk/levels/Room_4_1.scn and b/godot/pipeline/ldtk/levels/Room_4_1.scn differ diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ccf7027..eceb6a0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -15,8 +15,8 @@ checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" [[package]] name = "godot" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "godot-core", "godot-macros", @@ -24,21 +24,21 @@ dependencies = [ [[package]] name = "godot-bindings" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "gdextension-api", ] [[package]] name = "godot-cell" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" [[package]] name = "godot-codegen" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "godot-bindings", "heck", @@ -49,8 +49,8 @@ dependencies = [ [[package]] name = "godot-core" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "glam", "godot-bindings", @@ -61,8 +61,8 @@ dependencies = [ [[package]] name = "godot-ffi" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "godot-bindings", "godot-codegen", @@ -71,8 +71,8 @@ dependencies = [ [[package]] name = "godot-macros" -version = "0.5.4" -source = "git+https://github.com/godot-rust/gdext?rev=770cf1adfc3d74eb7fb089b280f725ee691b40f3#770cf1adfc3d74eb7fb089b280f725ee691b40f3" +version = "0.5.5" +source = "git+https://github.com/godot-rust/gdext?rev=637cef73172bba23850131acd8583b0c72ebf0c7#637cef73172bba23850131acd8583b0c72ebf0c7" dependencies = [ "godot-bindings", "proc-macro2", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 27ad122..0b37842 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" crate-type = ["cdylib"] [dependencies] -godot = { git = "https://github.com/godot-rust/gdext", rev = "770cf1adfc3d74eb7fb089b280f725ee691b40f3" } +godot = { git = "https://github.com/godot-rust/gdext", rev = "637cef73172bba23850131acd8583b0c72ebf0c7" } [profile.dev] opt-level = 1 diff --git a/rust/src/core/player.rs b/rust/src/core/player.rs index 11fe4e4..7802d49 100644 --- a/rust/src/core/player.rs +++ b/rust/src/core/player.rs @@ -19,14 +19,20 @@ pub struct MovementInput { pub struct MovementConfig { pub gravity: f32, + pub apex_velocity_threshold: f32, + pub apex_gravity_multiplier: f32, + pub apex_turn_accel_multiplier: f32, + pub max_fall_speed: f32, pub walk_speed: f32, pub ground_accel_speed: f32, pub ground_decel_speed: f32, pub air_accel_speed: f32, pub air_decel_speed: f32, pub turn_accel_multiplier: f32, + pub landing_turn_grace_time: f32, pub jump_velocity: f32, pub jump_buffer_time: f32, + pub jump_buffer_fall_time: f32, pub coyote_time: f32, pub jump_release_velocity_factor: f32, pub min_walk_speed: f32, @@ -41,18 +47,27 @@ impl Default for MovementConfig { impl MovementConfig { pub fn platformer(gravity: f32) -> Self { + // The reference demo uses a 64 px player and frame units at 60 Hz. + // Timing and gravity ratios convert directly; speed values are scaled + // around this project's existing 320 px/s jump impulse and 16x22 body. Self { gravity, + apex_velocity_threshold: 96.0, + apex_gravity_multiplier: 0.67, + apex_turn_accel_multiplier: 8.0, + max_fall_speed: 340.0, walk_speed: 120.0, ground_accel_speed: 720.0, ground_decel_speed: 1080.0, air_accel_speed: 540.0, air_decel_speed: 720.0, turn_accel_multiplier: 1.4, + landing_turn_grace_time: 0.10, jump_velocity: -320.0, jump_buffer_time: 0.12, - coyote_time: 0.10, - jump_release_velocity_factor: 0.5, + jump_buffer_fall_time: 0.05, + coyote_time: 0.13, + jump_release_velocity_factor: 0.0, min_walk_speed: 0.1, climb_speed: 80.0, } @@ -63,10 +78,13 @@ pub struct PlayerMovement { pub state: MovementState, pub config: MovementConfig, jump_buffer_timer: f32, + jump_buffer_armed: bool, coyote_timer: f32, + falling_timer: f32, + landing_turn_timer: f32, was_on_floor: bool, jumped_this_frame: bool, - buffered_jump_cut_requested: bool, + jump_apex_assist_active: bool, } impl PlayerMovement { @@ -75,20 +93,26 @@ impl PlayerMovement { state: MovementState::Air, config, jump_buffer_timer: 0.0, + jump_buffer_armed: false, coyote_timer: 0.0, + falling_timer: 0.0, + landing_turn_timer: 0.0, was_on_floor: false, jumped_this_frame: false, - buffered_jump_cut_requested: false, + jump_apex_assist_active: false, } } pub fn reset_transient_state(&mut self) { self.state = MovementState::Air; self.jump_buffer_timer = 0.0; + self.jump_buffer_armed = false; self.coyote_timer = 0.0; + self.falling_timer = 0.0; + self.landing_turn_timer = 0.0; self.was_on_floor = false; self.jumped_this_frame = false; - self.buffered_jump_cut_requested = false; + self.jump_apex_assist_active = false; } pub fn physics_process( @@ -97,35 +121,54 @@ impl PlayerMovement { is_on_floor: bool, delta: f64, input: MovementInput, + ) -> Vector2 { + self.physics_process_with_jump_apex_assist(velocity, is_on_floor, delta, input, true) + } + + pub(crate) fn physics_process_without_jump_apex_assist( + &mut self, + velocity: Vector2, + is_on_floor: bool, + delta: f64, + input: MovementInput, + ) -> Vector2 { + self.physics_process_with_jump_apex_assist(velocity, is_on_floor, delta, input, false) + } + + fn physics_process_with_jump_apex_assist( + &mut self, + velocity: Vector2, + is_on_floor: bool, + delta: f64, + input: MovementInput, + jump_apex_assist_enabled: bool, ) -> Vector2 { let delta = delta as f32; let mut new_velocity = velocity; self.jumped_this_frame = false; + if jump_apex_assist_enabled { + self.update_jump_apex_assist(velocity.y, is_on_floor); + } else { + self.cancel_jump_apex_assist(); + } self.tick_timers(delta); if input.jump_just_pressed { self.jump_buffer_timer = self.config.jump_buffer_time; - self.buffered_jump_cut_requested = false; + self.jump_buffer_armed = self.has_jump_surface(is_on_floor); } + self.arm_jump_buffer_if_falling(); - if input.jump_just_released && self.jump_buffer_timer > 0.0 && !self.can_jump(is_on_floor) { - self.buffered_jump_cut_requested = true; - } - - new_velocity.y += self.config.gravity * delta; + self.apply_gravity(&mut new_velocity, delta, is_on_floor); self.apply_walk(&mut new_velocity, delta, input.direction, is_on_floor); if self.apply_jump(&mut new_velocity, is_on_floor) { self.jumped_this_frame = true; } - if input.jump_just_released || self.buffered_jump_cut_requested { + if input.jump_just_released { self.apply_jump_cut(&mut new_velocity); - - if self.jumped_this_frame || self.jump_buffer_timer <= 0.0 { - self.buffered_jump_cut_requested = false; - } } self.state = if self.jumped_this_frame || !is_on_floor { @@ -133,13 +176,23 @@ impl PlayerMovement { } else { MovementState::Floor }; + self.update_falling_timer(new_velocity.y, is_on_floor, delta); + + if !jump_apex_assist_enabled { + self.cancel_jump_apex_assist(); + } new_velocity } pub fn post_physics_update(&mut self, is_on_floor: bool) { if is_on_floor { + if !self.was_on_floor && !self.jumped_this_frame { + self.landing_turn_timer = self.config.landing_turn_grace_time; + } self.coyote_timer = 0.0; + self.falling_timer = 0.0; + self.jump_apex_assist_active = false; if !self.jumped_this_frame { self.state = MovementState::Floor; } @@ -154,12 +207,17 @@ impl PlayerMovement { self.jumped_this_frame = false; } + pub fn cancel_jump_apex_assist(&mut self) { + self.jump_apex_assist_active = false; + } + fn tick_timers(&mut self, delta: f32) { self.jump_buffer_timer = (self.jump_buffer_timer - delta).max(0.0); self.coyote_timer = (self.coyote_timer - delta).max(0.0); + self.landing_turn_timer = (self.landing_turn_timer - delta).max(0.0); if self.jump_buffer_timer <= 0.0 { - self.buffered_jump_cut_requested = false; + self.jump_buffer_armed = false; } } @@ -170,7 +228,12 @@ impl PlayerMovement { direction: f32, is_on_floor: bool, ) { - let accel = self.horizontal_acceleration(velocity.x, direction, is_on_floor); + if self.should_snap_landing_turn(velocity.x, direction, is_on_floor) { + velocity.x = direction.signum() * self.config.walk_speed; + return; + } + + let accel = self.horizontal_acceleration(*velocity, direction, is_on_floor); velocity.x = move_toward_scalar( velocity.x, direction * self.config.walk_speed, @@ -178,10 +241,8 @@ impl PlayerMovement { ); } - fn horizontal_acceleration(&self, velocity_x: f32, direction: f32, is_on_floor: bool) -> f32 { - let changing_direction = direction.abs() >= INPUT_DEADZONE - && velocity_x.abs() >= INPUT_DEADZONE - && direction.signum() != velocity_x.signum(); + fn horizontal_acceleration(&self, velocity: Vector2, direction: f32, is_on_floor: bool) -> f32 { + let changing_direction = is_changing_direction(velocity.x, direction); let base_accel = if is_on_floor { if direction.abs() < INPUT_DEADZONE { @@ -195,15 +256,73 @@ impl PlayerMovement { self.config.air_accel_speed }; - if is_on_floor && changing_direction { - base_accel * self.config.turn_accel_multiplier + if !changing_direction { + return base_accel; + } + + let multiplier = if is_on_floor { + self.config.turn_accel_multiplier + } else if self.is_at_assisted_jump_apex(velocity.y) { + self.config.apex_turn_accel_multiplier } else { - base_accel + 1.0 + }; + + base_accel * multiplier.max(0.0) + } + + fn should_snap_landing_turn(&self, velocity_x: f32, direction: f32, is_on_floor: bool) -> bool { + is_on_floor && self.landing_turn_timer > 0.0 && is_changing_direction(velocity_x, direction) + } + + fn apply_gravity(&self, velocity: &mut Vector2, delta: f32, is_on_floor: bool) { + let gravity_multiplier = if !is_on_floor && self.is_at_assisted_jump_apex(velocity.y) { + self.config.apex_gravity_multiplier.clamp(0.0, 1.0) + } else { + 1.0 + }; + + velocity.y += self.config.gravity * gravity_multiplier * delta; + velocity.y = velocity.y.min(self.config.max_fall_speed.max(0.0)); + } + + fn update_jump_apex_assist(&mut self, velocity_y: f32, is_on_floor: bool) { + if is_on_floor { + self.jump_apex_assist_active = false; + } else if velocity_y < -INPUT_DEADZONE { + self.jump_apex_assist_active = true; + } else if velocity_y > self.config.apex_velocity_threshold.max(0.0) { + self.jump_apex_assist_active = false; } } + fn is_at_assisted_jump_apex(&self, velocity_y: f32) -> bool { + self.jump_apex_assist_active + && velocity_y.abs() <= self.config.apex_velocity_threshold.max(0.0) + } + + fn arm_jump_buffer_if_falling(&mut self) { + if self.jump_buffer_timer > 0.0 + && self.falling_timer >= self.config.jump_buffer_fall_time.max(0.0) + { + self.jump_buffer_armed = true; + } + } + + fn update_falling_timer(&mut self, velocity_y: f32, is_on_floor: bool, delta: f32) { + if is_on_floor || velocity_y <= 0.0 { + self.falling_timer = 0.0; + } else { + self.falling_timer += delta; + } + } + + fn has_jump_surface(&self, is_on_floor: bool) -> bool { + is_on_floor || self.coyote_timer > 0.0 + } + fn can_jump(&self, is_on_floor: bool) -> bool { - self.jump_buffer_timer > 0.0 && (is_on_floor || self.coyote_timer > 0.0) + self.jump_buffer_timer > 0.0 && self.jump_buffer_armed && self.has_jump_surface(is_on_floor) } fn apply_jump(&mut self, velocity: &mut Vector2, is_on_floor: bool) -> bool { @@ -211,7 +330,11 @@ impl PlayerMovement { if can_jump { velocity.y = self.config.jump_velocity; self.jump_buffer_timer = 0.0; + self.jump_buffer_armed = false; self.coyote_timer = 0.0; + self.falling_timer = 0.0; + self.landing_turn_timer = 0.0; + self.jump_apex_assist_active = true; } can_jump } @@ -250,6 +373,12 @@ fn axis_or_zero(value: f32) -> f32 { } } +fn is_changing_direction(velocity_x: f32, direction: f32) -> bool { + direction.abs() >= INPUT_DEADZONE + && velocity_x.abs() >= INPUT_DEADZONE + && direction.signum() != velocity_x.signum() +} + fn move_toward_scalar(current: f32, target: f32, max_delta: f32) -> f32 { if (target - current).abs() <= max_delta { target @@ -265,28 +394,64 @@ mod tests { fn test_config() -> MovementConfig { MovementConfig { gravity: 0.0, + apex_velocity_threshold: 96.0, + apex_gravity_multiplier: 0.67, + apex_turn_accel_multiplier: 8.0, + max_fall_speed: 340.0, walk_speed: 120.0, ground_accel_speed: 720.0, ground_decel_speed: 1080.0, air_accel_speed: 540.0, air_decel_speed: 720.0, turn_accel_multiplier: 1.4, + landing_turn_grace_time: 0.10, jump_velocity: -300.0, jump_buffer_time: 0.12, - coyote_time: 0.10, - jump_release_velocity_factor: 0.5, + jump_buffer_fall_time: 0.05, + coyote_time: 0.13, + jump_release_velocity_factor: 0.0, min_walk_speed: 0.1, climb_speed: 80.0, } } + fn descend_past_buffer_guard( + movement: &mut PlayerMovement, + mut velocity: Vector2, + delta: f64, + ) -> Vector2 { + for _ in 0..4 { + velocity = movement.physics_process(velocity, false, delta, MovementInput::default()); + movement.post_physics_update(false); + } + velocity + } + + #[test] + fn platformer_defaults_preserve_reference_helper_proportions() { + let config = MovementConfig::platformer(980.0); + + assert_eq!(config.apex_velocity_threshold, 96.0); + assert_eq!(config.apex_gravity_multiplier, 0.67); + assert_eq!(config.apex_turn_accel_multiplier, 8.0); + assert_eq!(config.max_fall_speed, 340.0); + assert_eq!(config.landing_turn_grace_time, 0.10); + assert_eq!(config.jump_buffer_time, 0.12); + assert_eq!(config.jump_buffer_fall_time, 0.05); + assert_eq!(config.coyote_time, 0.13); + assert_eq!(config.jump_release_velocity_factor, 0.0); + } + #[test] fn buffers_jump_until_landing() { let mut movement = PlayerMovement::new(test_config()); let delta = 0.016; + let falling_velocity = + descend_past_buffer_guard(&mut movement, Vector2::new(0.0, 80.0), delta); + let airborne_velocity = movement.physics_process( - Vector2::new(0.0, 80.0), + falling_velocity, false, delta, MovementInput { @@ -304,6 +469,56 @@ mod tests { assert_eq!(movement.state, MovementState::Air); } + #[test] + fn jump_press_before_fall_guard_is_not_buffered_on_immediate_landing() { + let mut movement = PlayerMovement::new(test_config()); + let delta = 0.016; + + let airborne_velocity = movement.physics_process( + Vector2::new(0.0, 80.0), + false, + delta, + MovementInput { + jump_just_pressed: true, + ..Default::default() + }, + ); + movement.post_physics_update(false); + let grounded_velocity = + movement.physics_process(airborne_velocity, true, delta, MovementInput::default()); + + assert_eq!(grounded_velocity.y, 80.0); + assert_eq!(movement.state, MovementState::Floor); + } + + #[test] + fn recent_press_arms_once_the_player_has_fallen_long_enough() { + let mut movement = PlayerMovement::new(test_config()); + let delta = 0.016; + + let mut velocity = movement.physics_process( + Vector2::new(0.0, 80.0), + false, + delta, + MovementInput { + jump_just_pressed: true, + ..Default::default() + }, + ); + movement.post_physics_update(false); + + for _ in 0..4 { + velocity = movement.physics_process(velocity, false, delta, MovementInput::default()); + movement.post_physics_update(false); + } + + let landed_velocity = + movement.physics_process(velocity, true, delta, MovementInput::default()); + + assert_eq!(landed_velocity.y, movement.config.jump_velocity); + assert_eq!(movement.state, MovementState::Air); + } + #[test] fn allows_coyote_jump_after_walking_off_ledge() { let mut movement = PlayerMovement::new(test_config()); @@ -341,7 +556,7 @@ mod tests { }, ); - assert_eq!(velocity.y, -100.0); + assert_eq!(velocity.y, 0.0); } #[test] @@ -381,8 +596,11 @@ mod tests { let mut movement = PlayerMovement::new(test_config()); let delta = 0.016; + let falling_velocity = + descend_past_buffer_guard(&mut movement, Vector2::new(0.0, 80.0), delta); + let airborne_velocity = movement.physics_process( - Vector2::new(0.0, 80.0), + falling_velocity, false, delta, MovementInput { @@ -401,12 +619,15 @@ mod tests { } #[test] - fn buffered_jump_release_cuts_jump_on_landing() { + fn buffered_jump_survives_release_before_landing() { let mut movement = PlayerMovement::new(test_config()); let delta = 0.016; + let falling_velocity = + descend_past_buffer_guard(&mut movement, Vector2::new(0.0, 80.0), delta); + let airborne_velocity = movement.physics_process( - Vector2::new(0.0, 80.0), + falling_velocity, false, delta, MovementInput { @@ -431,7 +652,7 @@ mod tests { let jumped_velocity = movement.physics_process(released_velocity, true, delta, MovementInput::default()); - assert_eq!(jumped_velocity.y, movement.config.jump_velocity * 0.5); + assert_eq!(jumped_velocity.y, movement.config.jump_velocity); assert_eq!(movement.state, MovementState::Air); } @@ -465,6 +686,174 @@ mod tests { assert!(tuned_velocity.x < baseline_velocity.x); } + #[test] + fn jump_apex_reduces_gravity_without_slowing_a_plain_ledge_fall() { + let mut assisted_config = test_config(); + assisted_config.gravity = 100.0; + assisted_config.apex_velocity_threshold = 20.0; + assisted_config.apex_gravity_multiplier = 0.5; + assisted_config.max_fall_speed = 1_000.0; + let mut assisted = PlayerMovement::new(assisted_config); + + let apex_velocity = assisted.physics_process( + Vector2::new(0.0, -10.0), + false, + 0.1, + MovementInput::default(), + ); + + let mut ledge_config = test_config(); + ledge_config.gravity = 100.0; + ledge_config.apex_velocity_threshold = 20.0; + ledge_config.apex_gravity_multiplier = 0.5; + ledge_config.max_fall_speed = 1_000.0; + let mut ledge_fall = PlayerMovement::new(ledge_config); + let ledge_velocity = + ledge_fall.physics_process(Vector2::ZERO, false, 0.1, MovementInput::default()); + + assert!((apex_velocity.y - -5.0).abs() < 0.001); + assert!((ledge_velocity.y - 10.0).abs() < 0.001); + } + + #[test] + fn ceiling_hit_can_cancel_jump_apex_assist() { + let mut config = test_config(); + config.gravity = 100.0; + config.apex_velocity_threshold = 20.0; + config.apex_gravity_multiplier = 0.5; + config.max_fall_speed = 1_000.0; + let mut movement = PlayerMovement::new(config); + + movement.physics_process( + Vector2::new(0.0, -10.0), + false, + 0.0, + MovementInput::default(), + ); + movement.cancel_jump_apex_assist(); + let velocity = + movement.physics_process(Vector2::ZERO, false, 0.1, MovementInput::default()); + + assert!((velocity.y - 10.0).abs() < 0.001); + } + + #[test] + fn disabled_jump_apex_assist_uses_regular_air_physics() { + let mut config = test_config(); + config.gravity = 100.0; + config.air_accel_speed = 100.0; + config.apex_velocity_threshold = 20.0; + config.apex_gravity_multiplier = 0.5; + config.apex_turn_accel_multiplier = 3.0; + config.max_fall_speed = 1_000.0; + let mut movement = PlayerMovement::new(config); + + let velocity = movement.physics_process_without_jump_apex_assist( + Vector2::new(100.0, -10.0), + false, + 0.1, + MovementInput { + direction: -1.0, + ..Default::default() + }, + ); + + assert!((velocity.y - 0.0).abs() < 0.001); + assert_eq!(velocity.x, 90.0); + } + + #[test] + fn falling_speed_is_clamped() { + let mut config = test_config(); + config.gravity = 1_000.0; + config.max_fall_speed = 120.0; + let mut movement = PlayerMovement::new(config); + + let velocity = movement.physics_process( + Vector2::new(0.0, 100.0), + false, + 0.1, + MovementInput::default(), + ); + + assert_eq!(velocity.y, 120.0); + } + + #[test] + fn reversing_near_jump_apex_gets_stronger_air_control() { + let mut apex_config = test_config(); + apex_config.air_accel_speed = 100.0; + apex_config.apex_velocity_threshold = 20.0; + apex_config.apex_turn_accel_multiplier = 3.0; + let mut at_apex = PlayerMovement::new(apex_config); + + let apex_velocity = at_apex.physics_process( + Vector2::new(100.0, -10.0), + false, + 0.1, + MovementInput { + direction: -1.0, + ..Default::default() + }, + ); + + let mut regular_config = test_config(); + regular_config.air_accel_speed = 100.0; + regular_config.apex_velocity_threshold = 20.0; + regular_config.apex_turn_accel_multiplier = 3.0; + let mut outside_apex = PlayerMovement::new(regular_config); + let regular_velocity = outside_apex.physics_process( + Vector2::new(100.0, -40.0), + false, + 0.1, + MovementInput { + direction: -1.0, + ..Default::default() + }, + ); + + assert_eq!(apex_velocity.x, 70.0); + assert_eq!(regular_velocity.x, 90.0); + } + + #[test] + fn reversing_just_after_landing_snaps_to_full_opposite_speed() { + let mut assisted_config = test_config(); + assisted_config.ground_accel_speed = 100.0; + assisted_config.turn_accel_multiplier = 1.0; + let mut assisted = PlayerMovement::new(assisted_config); + assisted.post_physics_update(true); + + let assisted_velocity = assisted.physics_process( + Vector2::new(100.0, 0.0), + true, + 0.02, + MovementInput { + direction: -1.0, + ..Default::default() + }, + ); + + let mut regular_config = test_config(); + regular_config.ground_accel_speed = 100.0; + regular_config.turn_accel_multiplier = 1.0; + regular_config.landing_turn_grace_time = 0.0; + let mut regular = PlayerMovement::new(regular_config); + regular.post_physics_update(true); + let regular_velocity = regular.physics_process( + Vector2::new(100.0, 0.0), + true, + 0.02, + MovementInput { + direction: -1.0, + ..Default::default() + }, + ); + + assert_eq!(assisted_velocity.x, -120.0); + assert_eq!(regular_velocity.x, 98.0); + } + #[test] fn walking_or_pressing_treats_wall_push_as_walk_intent() { let movement = PlayerMovement::new(test_config()); diff --git a/rust/src/player/corner_correction.rs b/rust/src/player/corner_correction.rs index 667d7cd..d183814 100644 --- a/rust/src/player/corner_correction.rs +++ b/rust/src/player/corner_correction.rs @@ -2,30 +2,54 @@ use godot::{classes::CharacterBody2D, prelude::*}; use crate::core::world::{PLAYER_WIDTH, ROOM_WIDTH}; -const MAX_CORRECTION_PX: i32 = 3; +const MAX_HEAD_CORRECTION_PX: i32 = 7; +const MAX_LEDGE_CATCH_PX: i32 = 7; +const LEDGE_HORIZONTAL_STEP_PX: f32 = 1.0; +const LEDGE_FLOOR_PROBE_MARGIN_PX: i32 = 2; +const MIN_LEDGE_CATCH_SPEED: f32 = 12.0; const SIDE_NORMAL_THRESHOLD: f32 = 0.7; const INTENT_EPSILON: f32 = 0.01; const UPWARD_CLEARANCE_PX: f32 = -1.0; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CorrectionOutcome { + pub head_corrected: bool, + pub ledge_caught: bool, +} + pub fn apply_after_slide( body: &mut Gd, attempted_velocity: Vector2, horizontal_intent: f32, -) { - if attempted_velocity.y >= 0.0 || body.get_collision_mask() == 0 { - return; +) -> CorrectionOutcome { + if body.get_collision_mask() == 0 { + return CorrectionOutcome::default(); } let collision_context = collision_context(body); - if !body.is_on_ceiling() && !collision_context.has_side_collision { - return; + let mut outcome = CorrectionOutcome::default(); + + if attempted_velocity.y < 0.0 && (body.is_on_ceiling() || collision_context.has_side_collision) + { + outcome.head_corrected = try_head_offsets( + body, + attempted_velocity, + correction_directions(horizontal_intent, collision_context.side_normal_x), + MAX_HEAD_CORRECTION_PX, + ); + } else if attempted_velocity.y > 0.0 + && !body.is_on_floor() + && collision_context.has_side_collision + { + outcome.ledge_caught = try_ledge_catch( + body, + attempted_velocity, + collision_context.side_normal_x, + MAX_LEDGE_CATCH_PX, + ); } - try_offsets( - body, - correction_directions(horizontal_intent, collision_context.side_normal_x), - MAX_CORRECTION_PX, - ); + outcome } #[derive(Default)] @@ -55,7 +79,12 @@ fn collision_context(body: &mut Gd) -> CollisionContext { context } -fn try_offsets(body: &mut Gd, directions: [f32; 2], max_px: i32) { +fn try_head_offsets( + body: &mut Gd, + attempted_velocity: Vector2, + directions: [f32; 2], + max_px: i32, +) -> bool { let transform = body.get_global_transform(); for px in 1..=max_px { for direction in directions { @@ -64,10 +93,109 @@ fn try_offsets(body: &mut Gd, directions: [f32; 2], max_px: i32 let position = body.get_global_position(); godot_print!("[Player] corner correction applied offset={:?}", offset); body.set_global_position(position + offset); - return; + let restored_velocity = + restore_upward_velocity(body.get_velocity(), attempted_velocity); + body.set_velocity(restored_velocity); + return true; } } } + + false +} + +fn restore_upward_velocity(mut resolved: Vector2, attempted: Vector2) -> Vector2 { + resolved.y = attempted.y; + resolved +} + +fn try_ledge_catch( + body: &mut Gd, + attempted_velocity: Vector2, + side_normal_x: Option, + max_px: i32, +) -> bool { + let Some(direction) = ledge_catch_direction(attempted_velocity, side_normal_x) else { + return false; + }; + + let transform = body.get_global_transform(); + for px in 1..=max_px { + let lift = Vector2::new(0.0, -(px as f32)); + let lifted_transform = transform.translated(lift); + let horizontal_step = Vector2::new(direction * LEDGE_HORIZONTAL_STEP_PX, 0.0); + let destination_x = lifted_transform.origin.x + horizontal_step.x; + if !within_room_horizontal_bounds(destination_x) { + continue; + } + + let lift_blocked = motion_collides(body, transform, lift); + let side_still_blocked = motion_collides(body, lifted_transform, horizontal_step); + let over_ledge_transform = lifted_transform.translated(horizontal_step); + let floor_probe_distance = px + LEDGE_FLOOR_PROBE_MARGIN_PX; + let floor_below = motion_collides( + body, + over_ledge_transform, + Vector2::new(0.0, floor_probe_distance as f32), + ); + + if !ledge_catch_candidate(lift_blocked, side_still_blocked, floor_below) { + continue; + } + + let safe_drop = safe_downward_offset(body, over_ledge_transform, floor_probe_distance); + let correction = lift + horizontal_step + Vector2::new(0.0, safe_drop); + let corrected_position = body.get_global_position() + correction; + let corrected_velocity = restore_ledge_velocity(body.get_velocity(), attempted_velocity); + body.set_global_position(corrected_position); + body.set_velocity(corrected_velocity); + godot_print!("[Player] ledge catch applied offset={:?}", correction); + return true; + } + + false +} + +fn ledge_catch_direction(attempted_velocity: Vector2, side_normal_x: Option) -> Option { + if attempted_velocity.y <= 0.0 || attempted_velocity.x.abs() < MIN_LEDGE_CATCH_SPEED { + return None; + } + + let direction = attempted_velocity.x.signum(); + if let Some(normal_x) = side_normal_x + && normal_x.abs() >= SIDE_NORMAL_THRESHOLD + && normal_x.signum() == direction + { + return None; + } + + Some(direction) +} + +fn ledge_catch_candidate(lift_blocked: bool, side_still_blocked: bool, floor_below: bool) -> bool { + !lift_blocked && !side_still_blocked && floor_below +} + +fn safe_downward_offset( + body: &mut Gd, + transform: Transform2D, + max_px: i32, +) -> f32 { + let mut safe_offset = 0.0; + for px in 1..=max_px { + let motion = Vector2::new(0.0, px as f32); + if motion_collides(body, transform, motion) { + break; + } + safe_offset = px as f32; + } + safe_offset +} + +fn restore_ledge_velocity(mut resolved: Vector2, attempted: Vector2) -> Vector2 { + resolved.x = attempted.x; + resolved.y = 0.0; + resolved } fn can_apply_offset( @@ -153,6 +281,64 @@ mod tests { assert_eq!(correction_directions(1.0, Some(-1.0)), [1.0, -1.0]); } + #[test] + fn correction_restores_upward_speed_without_overriding_horizontal_resolution() { + assert_eq!( + restore_upward_velocity(Vector2::new(25.0, 0.0), Vector2::new(80.0, -140.0)), + Vector2::new(25.0, -140.0) + ); + } + + #[test] + fn descending_into_opposing_side_can_attempt_ledge_catch() { + assert_eq!( + ledge_catch_direction(Vector2::new(80.0, 120.0), Some(-1.0)), + Some(1.0) + ); + assert_eq!( + ledge_catch_direction(Vector2::new(-80.0, 120.0), Some(1.0)), + Some(-1.0) + ); + } + + #[test] + fn ledge_catch_rejects_rising_slow_or_unrelated_side_motion() { + assert_eq!( + ledge_catch_direction(Vector2::new(80.0, -1.0), Some(-1.0)), + None + ); + assert_eq!( + ledge_catch_direction(Vector2::new(11.0, 120.0), Some(-1.0)), + None + ); + assert_eq!( + ledge_catch_direction(Vector2::new(80.0, 120.0), Some(1.0)), + None + ); + } + + #[test] + fn ledge_catch_requires_lift_side_clearance_and_floor_below() { + assert!(ledge_catch_candidate(false, false, true)); + assert!(!ledge_catch_candidate(true, false, true)); + assert!(!ledge_catch_candidate(false, true, true)); + assert!(!ledge_catch_candidate(false, false, false)); + } + + #[test] + fn ledge_catch_restores_horizontal_momentum_and_stops_fall() { + assert_eq!( + restore_ledge_velocity(Vector2::new(0.0, 75.0), Vector2::new(90.0, 130.0)), + Vector2::new(90.0, 0.0) + ); + } + + #[test] + fn reference_scaled_corrections_allow_seven_pixels() { + assert_eq!(MAX_HEAD_CORRECTION_PX, 7); + assert_eq!(MAX_LEDGE_CATCH_PX, 7); + } + #[test] fn direction_falls_back_to_opposite_side_normal() { assert_eq!(correction_directions(0.0, Some(1.0)), [-1.0, 1.0]); diff --git a/rust/src/player/mod.rs b/rust/src/player/mod.rs index db76e81..02170c0 100644 --- a/rust/src/player/mod.rs +++ b/rust/src/player/mod.rs @@ -390,7 +390,7 @@ impl Player { /// Normal movement frame. Call order is preserved: input -> ladder guard /// (handled by the dispatcher) -> platform drop -> water -> PlayerMovement - /// -> move_and_slide -> corner correction -> hazard -> push -> presentation. + /// -> move_and_slide -> corner/ledge correction -> hazard -> push -> presentation. fn physics_process_playing( &mut self, delta: f64, @@ -430,12 +430,22 @@ impl Player { return; }; let jump_velocity = movement.config.jump_velocity; - let mut new_velocity = movement.physics_process( - movement_velocity, - is_on_floor || jumped_from_ladder, - delta, - movement_input_for_physics, - ); + let movement_is_grounded = is_on_floor || jumped_from_ladder; + let mut new_velocity = if water_contact == water::WaterContact::None { + movement.physics_process( + movement_velocity, + movement_is_grounded, + delta, + movement_input_for_physics, + ) + } else { + movement.physics_process_without_jump_apex_assist( + movement_velocity, + movement_is_grounded, + delta, + movement_input_for_physics, + ) + }; new_velocity = self.water_controller.adjust_velocity( new_velocity, movement_input, @@ -446,19 +456,26 @@ impl Player { self.base_mut().set_velocity(new_velocity); self.base_mut().move_and_slide(); - if water_contact == water::WaterContact::None { - corner_correction::apply_after_slide(&mut body, new_velocity, movement_input.direction); - } + let correction_outcome = if water_contact == water::WaterContact::None { + corner_correction::apply_after_slide(&mut body, new_velocity, movement_input.direction) + } else { + corner_correction::CorrectionOutcome::default() + }; self.water_controller .apply_post_move(water_contact, body.get_global_position()); let resolved_velocity = self.base().get_velocity(); - let is_on_floor_after_move = self.base().is_on_floor(); + let is_on_floor_after_move = self.base().is_on_floor() || correction_outcome.ledge_caught; + let hit_uncorrected_ceiling = + self.base().is_on_ceiling() && !correction_outcome.head_corrected; let (state, is_walking) = { let Some(movement) = self.movement.as_mut() else { return; }; movement.post_physics_update(is_on_floor_after_move); + if hit_uncorrected_ceiling { + movement.cancel_jump_apex_assist(); + } ( movement.state, movement.is_walking_or_pressing(resolved_velocity, movement_input.direction),