Add unit tests across all game modules (42 tests)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,34 @@
|
|||||||
use crate::pickup::{PickupDef, PickupEffectType};
|
use crate::pickup::{PickupDef, PickupEffectType};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_speed_at_zero_equals_initial_speed() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert_eq!(cfg.scroll_speed(0.0), cfg.initial_speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_speed_increases_over_time() {
|
||||||
|
let cfg = Config::default();
|
||||||
|
assert!(cfg.scroll_speed(30.0) > cfg.scroll_speed(0.0));
|
||||||
|
assert!(cfg.scroll_speed(120.0) > cfg.scroll_speed(30.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_speed_growth_slows_over_time() {
|
||||||
|
// Logarithmic: each equal time interval adds less speed than the last.
|
||||||
|
let cfg = Config::default();
|
||||||
|
let d0 = cfg.scroll_speed(10.0) - cfg.scroll_speed(0.0);
|
||||||
|
let d1 = cfg.scroll_speed(20.0) - cfg.scroll_speed(10.0);
|
||||||
|
let d2 = cfg.scroll_speed(40.0) - cfg.scroll_speed(30.0);
|
||||||
|
assert!(d0 > d1);
|
||||||
|
assert!(d1 > d2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// All game parameters in one place — change here to tune the game.
|
/// All game parameters in one place — change here to tune the game.
|
||||||
/// Pickup types are also registered here; add a new `PickupDef` entry to extend.
|
/// Pickup types are also registered here; add a new `PickupDef` entry to extend.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -13,7 +42,6 @@ pub struct Config {
|
|||||||
pub player_height: f32,
|
pub player_height: f32,
|
||||||
pub gravity: f32,
|
pub gravity: f32,
|
||||||
pub jump_velocity: f32,
|
pub jump_velocity: f32,
|
||||||
pub stomp_bounce_velocity: f32,
|
|
||||||
/// Grace window (seconds) after leaving a platform where jumping is still allowed.
|
/// Grace window (seconds) after leaving a platform where jumping is still allowed.
|
||||||
pub coyote_time: f32,
|
pub coyote_time: f32,
|
||||||
|
|
||||||
@@ -73,7 +101,6 @@ impl Default for Config {
|
|||||||
player_height: 48.0,
|
player_height: 48.0,
|
||||||
gravity: 1900.0,
|
gravity: 1900.0,
|
||||||
jump_velocity: -800.0,
|
jump_velocity: -800.0,
|
||||||
stomp_bounce_velocity: -550.0,
|
|
||||||
coyote_time: 0.12,
|
coyote_time: 0.12,
|
||||||
|
|
||||||
initial_speed: 280.0,
|
initial_speed: 280.0,
|
||||||
|
|||||||
@@ -122,3 +122,71 @@ impl ActiveEffect for ScoreMultiplierEffect {
|
|||||||
self.factor
|
self.factor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invulnerability_active_while_timer_positive() {
|
||||||
|
let mut e = InvulnerabilityEffect::new(1.0);
|
||||||
|
assert!(e.update(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invulnerability_expires_when_timer_reaches_zero() {
|
||||||
|
let mut e = InvulnerabilityEffect::new(1.0);
|
||||||
|
assert!(!e.update(1.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invulnerability_is_invulnerable() {
|
||||||
|
let e = InvulnerabilityEffect::new(5.0);
|
||||||
|
assert!(e.is_invulnerable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invulnerability_default_multipliers_are_one() {
|
||||||
|
let e = InvulnerabilityEffect::new(5.0);
|
||||||
|
assert_eq!(e.jump_multiplier(), 1.0);
|
||||||
|
assert_eq!(e.score_multiplier(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_boost_returns_configured_factor() {
|
||||||
|
let e = JumpBoostEffect::new(5.0, 1.5);
|
||||||
|
assert_eq!(e.jump_multiplier(), 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_boost_is_not_invulnerable() {
|
||||||
|
let e = JumpBoostEffect::new(5.0, 1.5);
|
||||||
|
assert!(!e.is_invulnerable());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_multiplier_returns_configured_factor() {
|
||||||
|
let e = ScoreMultiplierEffect::new(5.0, 2.0);
|
||||||
|
assert_eq!(e.score_multiplier(), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_starts_at_one() {
|
||||||
|
let e = InvulnerabilityEffect::new(5.0);
|
||||||
|
assert!((e.progress() - 1.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_halves_at_half_duration() {
|
||||||
|
let mut e = InvulnerabilityEffect::new(4.0);
|
||||||
|
e.update(2.0);
|
||||||
|
assert!((e.progress() - 0.5).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_clamps_to_zero_when_expired() {
|
||||||
|
let mut e = InvulnerabilityEffect::new(1.0);
|
||||||
|
e.update(10.0);
|
||||||
|
assert_eq!(e.progress(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
24
src/enemy.rs
24
src/enemy.rs
@@ -32,3 +32,27 @@ impl Enemy {
|
|||||||
self.x + self.width
|
self.x + self.width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_moves_enemy_left() {
|
||||||
|
let mut e = Enemy::new(200.0, 400.0, 40.0, 40.0);
|
||||||
|
e.scroll(200.0, 0.5);
|
||||||
|
assert!((e.x - 100.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_screen_when_right_edge_is_negative() {
|
||||||
|
let e = Enemy::new(-50.0, 400.0, 40.0, 40.0);
|
||||||
|
assert!(e.is_off_screen());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_off_screen_when_partially_visible() {
|
||||||
|
let e = Enemy::new(-20.0, 400.0, 40.0, 40.0);
|
||||||
|
assert!(!e.is_off_screen());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,3 +27,34 @@ impl Platform {
|
|||||||
self.x + self.width
|
self.x + self.width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scroll_moves_platform_left() {
|
||||||
|
let mut p = Platform::new(100.0, 400.0, 200.0, 20.0);
|
||||||
|
p.scroll(100.0, 0.5);
|
||||||
|
assert!((p.x - 50.0).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_screen_when_right_edge_is_negative() {
|
||||||
|
let p = Platform::new(-210.0, 400.0, 200.0, 20.0);
|
||||||
|
assert!(p.is_off_screen());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_off_screen_when_partially_visible() {
|
||||||
|
let p = Platform::new(-100.0, 400.0, 200.0, 20.0);
|
||||||
|
assert!(!p.is_off_screen());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn top_and_right_helpers() {
|
||||||
|
let p = Platform::new(50.0, 300.0, 200.0, 20.0);
|
||||||
|
assert_eq!(p.top(), 300.0);
|
||||||
|
assert_eq!(p.right(), 250.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
src/player.rs
112
src/player.rs
@@ -1,5 +1,112 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn cfg() -> Config { Config::default() }
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_player_starts_on_ground_with_coyote_timer() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let p = Player::new(&cfg);
|
||||||
|
assert!(p.on_ground);
|
||||||
|
assert!(p.coyote_timer > 0.0);
|
||||||
|
assert!(p.alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gravity_moves_player_down() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.on_ground = false;
|
||||||
|
let y_before = p.y;
|
||||||
|
p.update(0.1, &cfg);
|
||||||
|
assert!(p.y > y_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_sets_negative_velocity() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.coyote_timer = cfg.coyote_time;
|
||||||
|
p.jump(&cfg, 1.0);
|
||||||
|
assert!(p.vy < 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_requires_positive_coyote_timer() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.coyote_timer = 0.0;
|
||||||
|
p.jump(&cfg, 1.0);
|
||||||
|
assert_eq!(p.vy, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_consumes_coyote_timer() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.coyote_timer = cfg.coyote_time;
|
||||||
|
p.jump(&cfg, 1.0);
|
||||||
|
assert_eq!(p.coyote_timer, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jump_multiplier_scales_velocity() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p1 = Player::new(&cfg);
|
||||||
|
let mut p2 = Player::new(&cfg);
|
||||||
|
p1.coyote_timer = cfg.coyote_time;
|
||||||
|
p2.coyote_timer = cfg.coyote_time;
|
||||||
|
p1.jump(&cfg, 1.0);
|
||||||
|
p2.jump(&cfg, 2.0);
|
||||||
|
let ratio = p2.vy / p1.vy;
|
||||||
|
assert!((ratio - 2.0).abs() < 1e-4, "ratio was {ratio}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coyote_timer_refreshes_when_on_ground() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.on_ground = true;
|
||||||
|
p.coyote_timer = 0.001; // nearly expired
|
||||||
|
p.update(0.016, &cfg);
|
||||||
|
// update() refreshes timer at its start when on_ground was true
|
||||||
|
assert!((p.coyote_timer - cfg.coyote_time).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coyote_timer_ticks_down_when_airborne() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.on_ground = false;
|
||||||
|
p.coyote_timer = cfg.coyote_time;
|
||||||
|
let dt = 0.05;
|
||||||
|
p.update(dt, &cfg);
|
||||||
|
assert!((p.coyote_timer - (cfg.coyote_time - dt)).abs() < 1e-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coyote_timer_does_not_go_below_zero() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.on_ground = false;
|
||||||
|
p.coyote_timer = 0.01;
|
||||||
|
p.update(1.0, &cfg); // much larger than timer
|
||||||
|
assert_eq!(p.coyote_timer, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_dies_when_below_screen() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut p = Player::new(&cfg);
|
||||||
|
p.y = cfg.screen_height as f32 + 100.0;
|
||||||
|
p.update(0.016, &cfg);
|
||||||
|
assert!(!p.alive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
@@ -61,11 +168,6 @@ impl Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stomp_bounce(&mut self, cfg: &Config) {
|
|
||||||
self.vy = cfg.stomp_bounce_velocity;
|
|
||||||
self.on_ground = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bottom(&self) -> f32 {
|
pub fn bottom(&self) -> f32 {
|
||||||
self.y + self.height
|
self.y + self.height
|
||||||
}
|
}
|
||||||
|
|||||||
254
src/world.rs
254
src/world.rs
@@ -10,6 +10,236 @@ use crate::{
|
|||||||
player::Player,
|
player::Player,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::effects::{InvulnerabilityEffect, ScoreMultiplierEffect};
|
||||||
|
|
||||||
|
fn cfg() -> Config { Config::default() }
|
||||||
|
|
||||||
|
fn world(cfg: &Config) -> World {
|
||||||
|
World::new(cfg).with_clear_scene(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform collision ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_lands_on_platform() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
let plat_y = 400.0;
|
||||||
|
w.platforms.push(Platform::new(
|
||||||
|
cfg.player_x - 20.0, plat_y, 100.0, cfg.platform_height,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Position player just above, falling toward the platform.
|
||||||
|
// Use vy high enough to cross the gap in a single 16ms frame.
|
||||||
|
w.player.y = plat_y - cfg.player_height - 2.0;
|
||||||
|
w.player.prev_y = w.player.y - 8.0;
|
||||||
|
w.player.vy = 500.0;
|
||||||
|
w.player.on_ground = false;
|
||||||
|
w.player.coyote_timer = 0.0;
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(w.player.on_ground, "should be grounded after landing");
|
||||||
|
assert_eq!(w.player.vy, 0.0, "velocity should be zeroed on landing");
|
||||||
|
assert!((w.player.y - (plat_y - cfg.player_height)).abs() < 2.0,
|
||||||
|
"player should be snapped to platform top");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_does_not_land_when_rising_through_platform() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
let plat_y = 400.0;
|
||||||
|
w.platforms.push(Platform::new(
|
||||||
|
cfg.player_x - 20.0, plat_y, 100.0, cfg.platform_height,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Player rising through the platform — should pass through.
|
||||||
|
w.player.y = plat_y - cfg.player_height + 5.0;
|
||||||
|
w.player.prev_y = w.player.y + 10.0;
|
||||||
|
w.player.vy = -400.0; // going up
|
||||||
|
w.player.on_ground = false;
|
||||||
|
w.player.coyote_timer = 0.0;
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(!w.player.on_ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_falls_without_platform() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = World::new(&cfg);
|
||||||
|
w.platforms.clear();
|
||||||
|
w.enemies.clear();
|
||||||
|
w.pickups.clear();
|
||||||
|
w.active_effects.clear();
|
||||||
|
|
||||||
|
w.player.y = 200.0;
|
||||||
|
w.player.vy = 0.0;
|
||||||
|
w.player.on_ground = false;
|
||||||
|
let y_before = w.player.y;
|
||||||
|
|
||||||
|
w.update(0.1, false, &cfg);
|
||||||
|
|
||||||
|
assert!(w.player.y > y_before);
|
||||||
|
assert!(!w.player.on_ground);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_dies_falling_off_screen() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
w.platforms.clear(); // remove ground so player falls
|
||||||
|
|
||||||
|
w.player.y = cfg.screen_height as f32 + 100.0;
|
||||||
|
w.player.vy = 10.0;
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(!w.player.alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enemy collision ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stomp_kills_enemy_and_lands_player() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
let enemy_y = 400.0;
|
||||||
|
w.enemies.push(Enemy::new(
|
||||||
|
cfg.player_x - 5.0, enemy_y, cfg.enemy_width, cfg.enemy_height,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Player falling onto enemy top.
|
||||||
|
w.player.y = enemy_y - cfg.player_height - 3.0;
|
||||||
|
w.player.prev_y = w.player.y - 10.0;
|
||||||
|
w.player.vy = 250.0;
|
||||||
|
w.player.on_ground = false;
|
||||||
|
|
||||||
|
let score_before = w.score_f;
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
// Dead enemies are removed by retain() after stomp.
|
||||||
|
assert!(w.enemies.is_empty(), "dead enemy should be removed from world");
|
||||||
|
assert!(w.player.alive, "player should survive stomp");
|
||||||
|
assert!(w.player.on_ground, "player should land on enemy");
|
||||||
|
assert!(w.score_f > score_before, "stomp should award score");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn side_collision_kills_player() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
// Enemy at same height as player, overlapping horizontally.
|
||||||
|
let enemy_y = w.player.y;
|
||||||
|
w.enemies.push(Enemy::new(
|
||||||
|
cfg.player_x, enemy_y, cfg.enemy_width, cfg.enemy_height,
|
||||||
|
));
|
||||||
|
w.player.vy = 0.0; // not falling → no stomp
|
||||||
|
w.player.prev_y = w.player.y; // was already at this height
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(!w.player.alive);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invulnerability_prevents_side_death() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
w.active_effects.push(Box::new(InvulnerabilityEffect::new(5.0)));
|
||||||
|
|
||||||
|
let enemy_y = w.player.y;
|
||||||
|
w.enemies.push(Enemy::new(
|
||||||
|
cfg.player_x, enemy_y, cfg.enemy_width, cfg.enemy_height,
|
||||||
|
));
|
||||||
|
w.player.vy = 0.0;
|
||||||
|
w.player.prev_y = w.player.y;
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(w.player.alive, "invulnerable player should survive side hit");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pickups ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collecting_pickup_adds_active_effect() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
// Place pickup directly on the player.
|
||||||
|
w.pickups.push(Pickup::new(
|
||||||
|
cfg.player_x, w.player.y, cfg.pickup_size, 0,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(w.active_effects.is_empty());
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
assert!(!w.active_effects.is_empty(), "effect should be active after collection");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collected_pickup_is_removed_from_world() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
|
||||||
|
w.pickups.push(Pickup::new(
|
||||||
|
cfg.player_x, w.player.y, cfg.pickup_size, 0,
|
||||||
|
));
|
||||||
|
|
||||||
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
|
assert!(w.pickups.is_empty(), "collected pickup should be removed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Score ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_increases_over_time() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
let score_before = w.score_f;
|
||||||
|
w.update(1.0, false, &cfg);
|
||||||
|
assert!(w.score_f > score_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn score_multiplier_effect_doubles_score_gain() {
|
||||||
|
let cfg = cfg();
|
||||||
|
|
||||||
|
let mut w1 = world(&cfg);
|
||||||
|
let mut w2 = world(&cfg);
|
||||||
|
w2.active_effects.push(Box::new(ScoreMultiplierEffect::new(10.0, 2.0)));
|
||||||
|
|
||||||
|
w1.update(1.0, false, &cfg);
|
||||||
|
w2.update(1.0, false, &cfg);
|
||||||
|
|
||||||
|
let ratio = w2.score_f / w1.score_f;
|
||||||
|
assert!((ratio - 2.0).abs() < 0.05, "ratio was {ratio:.3}, expected ~2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active effects lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expired_effects_are_removed() {
|
||||||
|
let cfg = cfg();
|
||||||
|
let mut w = world(&cfg);
|
||||||
|
w.active_effects.push(Box::new(InvulnerabilityEffect::new(0.01)));
|
||||||
|
|
||||||
|
w.update(1.0, false, &cfg); // well past expiry
|
||||||
|
|
||||||
|
assert!(w.active_effects.is_empty(), "expired effects should be removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct World {
|
pub struct World {
|
||||||
pub player: Player,
|
pub player: Player,
|
||||||
pub platforms: Vec<Platform>,
|
pub platforms: Vec<Platform>,
|
||||||
@@ -18,7 +248,7 @@ pub struct World {
|
|||||||
pub active_effects: Vec<Box<dyn ActiveEffect>>,
|
pub active_effects: Vec<Box<dyn ActiveEffect>>,
|
||||||
pub score: u64,
|
pub score: u64,
|
||||||
pub elapsed: f32,
|
pub elapsed: f32,
|
||||||
score_f: f32,
|
pub(crate) score_f: f32,
|
||||||
level_gen: LevelGenerator,
|
level_gen: LevelGenerator,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +412,9 @@ impl World {
|
|||||||
|
|
||||||
if stomp {
|
if stomp {
|
||||||
enemy.alive = false;
|
enemy.alive = false;
|
||||||
self.player.stomp_bounce(cfg);
|
self.player.y = enemy.top() - self.player.height;
|
||||||
|
self.player.vy = 0.0;
|
||||||
|
self.player.on_ground = true;
|
||||||
self.score_f += cfg.enemy_stomp_bonus * score_mult;
|
self.score_f += cfg.enemy_stomp_bonus * score_mult;
|
||||||
self.score = self.score_f as u64;
|
self.score = self.score_f as u64;
|
||||||
} else if !invulnerable {
|
} else if !invulnerable {
|
||||||
@@ -323,6 +555,24 @@ impl World {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test helper: clear generated content and add a permanent wide ground
|
||||||
|
/// platform so the player has something to stand on.
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn with_clear_scene(mut self, cfg: &Config) -> Self {
|
||||||
|
self.platforms.clear();
|
||||||
|
self.enemies.clear();
|
||||||
|
self.pickups.clear();
|
||||||
|
self.active_effects.clear();
|
||||||
|
// Wide enough that it never scrolls away during a short test.
|
||||||
|
self.platforms.push(Platform::new(
|
||||||
|
-5000.0,
|
||||||
|
cfg.start_platform_y,
|
||||||
|
15000.0,
|
||||||
|
cfg.platform_height,
|
||||||
|
));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_player(&self, d: &mut RaylibDrawHandle) {
|
fn draw_player(&self, d: &mut RaylibDrawHandle) {
|
||||||
let p = &self.player;
|
let p = &self.player;
|
||||||
if !p.alive {
|
if !p.alive {
|
||||||
|
|||||||
Reference in New Issue
Block a user