Apply code review improvements across all modules
Bugs fixed: - Remove duplicate/corrupt draw_text in draw_active_effects_hud (screen_width as u8 = 0) - Fix pickup-enemy avoidance using actual enemy center-x instead of platform midpoint - Remove redundant `as f32` cast on an already-f32 expression DRY / data coupling: - Effect label and color now stored in each effect struct and passed from PickupDef::create_effect, eliminating the three-way duplication between Config, PickupEffectType, and ActiveEffect impls Config-driven level gen: - Add enemy_platform_margin and pickup_platform_margin fields to Config - Replace magic number literals in level_gen with cfg fields Encapsulation and code clarity: - score_f made fully private; tests assert on the public score: u64 - Pickup::collected flag removed; collect_pickups uses retain for immediate removal - Platform::top() and Enemy::top() removed (transparent aliases for .y); call sites use .y directly - Game-over restart prompt now blinks at ~2 Hz using world.elapsed Rust idioms: - #[derive(Debug)] added to Player, Platform, Enemy, Pickup, PickupDef, PickupEffectType, Config, GameState, and all three effect structs - [profile.release] added to Cargo.toml (lto=thin, codegen-units=1, panic=abort) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,9 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
raylib = { version = "5.5.1", features = ["wayland"] }
|
raylib = { version = "5.5.1", features = ["wayland"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ mod tests {
|
|||||||
|
|
||||||
/// 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, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub screen_width: i32,
|
pub screen_width: i32,
|
||||||
pub screen_height: i32,
|
pub screen_height: i32,
|
||||||
@@ -68,11 +68,13 @@ pub struct Config {
|
|||||||
pub enemy_height: f32,
|
pub enemy_height: f32,
|
||||||
pub enemy_spawn_chance: f32,
|
pub enemy_spawn_chance: f32,
|
||||||
pub enemy_min_platform_width: f32,
|
pub enemy_min_platform_width: f32,
|
||||||
|
pub enemy_platform_margin: f32,
|
||||||
|
|
||||||
// Pickups
|
// Pickups
|
||||||
pub pickup_size: f32,
|
pub pickup_size: f32,
|
||||||
pub pickup_spawn_chance: f32,
|
pub pickup_spawn_chance: f32,
|
||||||
pub pickup_hover_gap: f32, // pixels above platform surface
|
pub pickup_hover_gap: f32, // pixels above platform surface
|
||||||
|
pub pickup_platform_margin: f32, // horizontal inset from platform edge
|
||||||
pub pickup_defs: Vec<PickupDef>,
|
pub pickup_defs: Vec<PickupDef>,
|
||||||
|
|
||||||
// Scoring
|
// Scoring
|
||||||
@@ -122,10 +124,12 @@ impl Default for Config {
|
|||||||
enemy_height: 40.0,
|
enemy_height: 40.0,
|
||||||
enemy_spawn_chance: 0.35,
|
enemy_spawn_chance: 0.35,
|
||||||
enemy_min_platform_width: 120.0,
|
enemy_min_platform_width: 120.0,
|
||||||
|
enemy_platform_margin: 15.0,
|
||||||
|
|
||||||
pickup_size: 28.0,
|
pickup_size: 28.0,
|
||||||
pickup_spawn_chance: 0.28,
|
pickup_spawn_chance: 0.28,
|
||||||
pickup_hover_gap: 12.0,
|
pickup_hover_gap: 12.0,
|
||||||
|
pickup_platform_margin: 20.0,
|
||||||
|
|
||||||
// ── Register new pickup types here ────────────────────────────────
|
// ── Register new pickup types here ────────────────────────────────
|
||||||
pickup_defs: vec![
|
pickup_defs: vec![
|
||||||
|
|||||||
@@ -27,14 +27,17 @@ pub trait ActiveEffect {
|
|||||||
|
|
||||||
// ── Concrete effects ──────────────────────────────────────────────────────────
|
// ── Concrete effects ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct InvulnerabilityEffect {
|
pub struct InvulnerabilityEffect {
|
||||||
timer: f32,
|
timer: f32,
|
||||||
total: f32,
|
total: f32,
|
||||||
|
label: &'static str,
|
||||||
|
color: (u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InvulnerabilityEffect {
|
impl InvulnerabilityEffect {
|
||||||
pub fn new(duration: f32) -> Self {
|
pub fn new(duration: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
|
||||||
Self { timer: duration, total: duration }
|
Self { timer: duration, total: duration, label, color }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +47,11 @@ impl ActiveEffect for InvulnerabilityEffect {
|
|||||||
self.timer > 0.0
|
self.timer > 0.0
|
||||||
}
|
}
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"INVINCIBLE"
|
self.label
|
||||||
}
|
}
|
||||||
fn tint(&self) -> Color {
|
fn tint(&self) -> Color {
|
||||||
Color::new(255, 200, 50, 255)
|
let (r, g, b) = self.color;
|
||||||
|
Color::new(r, g, b, 255)
|
||||||
}
|
}
|
||||||
fn progress(&self) -> f32 {
|
fn progress(&self) -> f32 {
|
||||||
(self.timer / self.total).max(0.0)
|
(self.timer / self.total).max(0.0)
|
||||||
@@ -59,15 +63,18 @@ impl ActiveEffect for InvulnerabilityEffect {
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct JumpBoostEffect {
|
pub struct JumpBoostEffect {
|
||||||
timer: f32,
|
timer: f32,
|
||||||
total: f32,
|
total: f32,
|
||||||
factor: f32,
|
factor: f32,
|
||||||
|
label: &'static str,
|
||||||
|
color: (u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JumpBoostEffect {
|
impl JumpBoostEffect {
|
||||||
pub fn new(duration: f32, factor: f32) -> Self {
|
pub fn new(duration: f32, factor: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
|
||||||
Self { timer: duration, total: duration, factor }
|
Self { timer: duration, total: duration, factor, label, color }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,10 +84,11 @@ impl ActiveEffect for JumpBoostEffect {
|
|||||||
self.timer > 0.0
|
self.timer > 0.0
|
||||||
}
|
}
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"JUMP BOOST"
|
self.label
|
||||||
}
|
}
|
||||||
fn tint(&self) -> Color {
|
fn tint(&self) -> Color {
|
||||||
Color::new(80, 180, 255, 255)
|
let (r, g, b) = self.color;
|
||||||
|
Color::new(r, g, b, 255)
|
||||||
}
|
}
|
||||||
fn progress(&self) -> f32 {
|
fn progress(&self) -> f32 {
|
||||||
(self.timer / self.total).max(0.0)
|
(self.timer / self.total).max(0.0)
|
||||||
@@ -92,15 +100,18 @@ impl ActiveEffect for JumpBoostEffect {
|
|||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct ScoreMultiplierEffect {
|
pub struct ScoreMultiplierEffect {
|
||||||
timer: f32,
|
timer: f32,
|
||||||
total: f32,
|
total: f32,
|
||||||
factor: f32,
|
factor: f32,
|
||||||
|
label: &'static str,
|
||||||
|
color: (u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ScoreMultiplierEffect {
|
impl ScoreMultiplierEffect {
|
||||||
pub fn new(duration: f32, factor: f32) -> Self {
|
pub fn new(duration: f32, factor: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
|
||||||
Self { timer: duration, total: duration, factor }
|
Self { timer: duration, total: duration, factor, label, color }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,10 +121,11 @@ impl ActiveEffect for ScoreMultiplierEffect {
|
|||||||
self.timer > 0.0
|
self.timer > 0.0
|
||||||
}
|
}
|
||||||
fn label(&self) -> &'static str {
|
fn label(&self) -> &'static str {
|
||||||
"2X SCORE"
|
self.label
|
||||||
}
|
}
|
||||||
fn tint(&self) -> Color {
|
fn tint(&self) -> Color {
|
||||||
Color::new(180, 80, 255, 255)
|
let (r, g, b) = self.color;
|
||||||
|
Color::new(r, g, b, 255)
|
||||||
}
|
}
|
||||||
fn progress(&self) -> f32 {
|
fn progress(&self) -> f32 {
|
||||||
(self.timer / self.total).max(0.0)
|
(self.timer / self.total).max(0.0)
|
||||||
@@ -129,63 +141,63 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invulnerability_active_while_timer_positive() {
|
fn invulnerability_active_while_timer_positive() {
|
||||||
let mut e = InvulnerabilityEffect::new(1.0);
|
let mut e = InvulnerabilityEffect::new(1.0, "INVINCIBLE", (255, 200, 50));
|
||||||
assert!(e.update(0.5));
|
assert!(e.update(0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invulnerability_expires_when_timer_reaches_zero() {
|
fn invulnerability_expires_when_timer_reaches_zero() {
|
||||||
let mut e = InvulnerabilityEffect::new(1.0);
|
let mut e = InvulnerabilityEffect::new(1.0, "INVINCIBLE", (255, 200, 50));
|
||||||
assert!(!e.update(1.1));
|
assert!(!e.update(1.1));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invulnerability_is_invulnerable() {
|
fn invulnerability_is_invulnerable() {
|
||||||
let e = InvulnerabilityEffect::new(5.0);
|
let e = InvulnerabilityEffect::new(5.0, "INVINCIBLE", (255, 200, 50));
|
||||||
assert!(e.is_invulnerable());
|
assert!(e.is_invulnerable());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invulnerability_default_multipliers_are_one() {
|
fn invulnerability_default_multipliers_are_one() {
|
||||||
let e = InvulnerabilityEffect::new(5.0);
|
let e = InvulnerabilityEffect::new(5.0, "INVINCIBLE", (255, 200, 50));
|
||||||
assert_eq!(e.jump_multiplier(), 1.0);
|
assert_eq!(e.jump_multiplier(), 1.0);
|
||||||
assert_eq!(e.score_multiplier(), 1.0);
|
assert_eq!(e.score_multiplier(), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_boost_returns_configured_factor() {
|
fn jump_boost_returns_configured_factor() {
|
||||||
let e = JumpBoostEffect::new(5.0, 1.5);
|
let e = JumpBoostEffect::new(5.0, 1.5, "JUMP BOOST", (80, 180, 255));
|
||||||
assert_eq!(e.jump_multiplier(), 1.5);
|
assert_eq!(e.jump_multiplier(), 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jump_boost_is_not_invulnerable() {
|
fn jump_boost_is_not_invulnerable() {
|
||||||
let e = JumpBoostEffect::new(5.0, 1.5);
|
let e = JumpBoostEffect::new(5.0, 1.5, "JUMP BOOST", (80, 180, 255));
|
||||||
assert!(!e.is_invulnerable());
|
assert!(!e.is_invulnerable());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_multiplier_returns_configured_factor() {
|
fn score_multiplier_returns_configured_factor() {
|
||||||
let e = ScoreMultiplierEffect::new(5.0, 2.0);
|
let e = ScoreMultiplierEffect::new(5.0, 2.0, "2X SCORE", (180, 80, 255));
|
||||||
assert_eq!(e.score_multiplier(), 2.0);
|
assert_eq!(e.score_multiplier(), 2.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn progress_starts_at_one() {
|
fn progress_starts_at_one() {
|
||||||
let e = InvulnerabilityEffect::new(5.0);
|
let e = InvulnerabilityEffect::new(5.0, "INVINCIBLE", (255, 200, 50));
|
||||||
assert!((e.progress() - 1.0).abs() < 1e-4);
|
assert!((e.progress() - 1.0).abs() < 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn progress_halves_at_half_duration() {
|
fn progress_halves_at_half_duration() {
|
||||||
let mut e = InvulnerabilityEffect::new(4.0);
|
let mut e = InvulnerabilityEffect::new(4.0, "INVINCIBLE", (255, 200, 50));
|
||||||
e.update(2.0);
|
e.update(2.0);
|
||||||
assert!((e.progress() - 0.5).abs() < 1e-4);
|
assert!((e.progress() - 0.5).abs() < 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn progress_clamps_to_zero_when_expired() {
|
fn progress_clamps_to_zero_when_expired() {
|
||||||
let mut e = InvulnerabilityEffect::new(1.0);
|
let mut e = InvulnerabilityEffect::new(1.0, "INVINCIBLE", (255, 200, 50));
|
||||||
e.update(10.0);
|
e.update(10.0);
|
||||||
assert_eq!(e.progress(), 0.0);
|
assert_eq!(e.progress(), 0.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Enemy {
|
pub struct Enemy {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
@@ -20,10 +20,6 @@ impl Enemy {
|
|||||||
self.x + self.width < 0.0
|
self.x + self.width < 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top(&self) -> f32 {
|
|
||||||
self.y
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bottom(&self) -> f32 {
|
pub fn bottom(&self) -> f32 {
|
||||||
self.y + self.height
|
self.y + self.height
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use raylib::prelude::*;
|
|||||||
|
|
||||||
use crate::{config::Config, world::World};
|
use crate::{config::Config, world::World};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum GameState {
|
pub enum GameState {
|
||||||
Playing,
|
Playing,
|
||||||
GameOver,
|
GameOver,
|
||||||
@@ -84,7 +85,7 @@ impl Game {
|
|||||||
d.draw_rectangle(bar_x, bar_y, fill, bar_h, Color::new(80, 180, 255, 255));
|
d.draw_rectangle(bar_x, bar_y, fill, bar_h, Color::new(80, 180, 255, 255));
|
||||||
|
|
||||||
// Active effect timer bars
|
// Active effect timer bars
|
||||||
self.world.draw_active_effects_hud(d, &self.cfg);
|
self.world.draw_active_effects_hud(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_game_over(&self, d: &mut RaylibDrawHandle) {
|
fn draw_game_over(&self, d: &mut RaylibDrawHandle) {
|
||||||
@@ -133,8 +134,10 @@ impl Game {
|
|||||||
let prompt = "SPACE / R / ENTER to restart";
|
let prompt = "SPACE / R / ENTER to restart";
|
||||||
let ps = 22;
|
let ps = 22;
|
||||||
let pw = d.measure_text(prompt, ps);
|
let pw = d.measure_text(prompt, ps);
|
||||||
// Blinking effect based on elapsed time (we don't have time here, so just static)
|
// Blink at ~2 Hz using the world's accumulated elapsed time.
|
||||||
d.draw_text(prompt, (sw - pw) / 2, panel_y + 240, ps, Color::new(160, 200, 255, 255));
|
if (self.world.elapsed * 2.0).sin() > 0.0 {
|
||||||
|
d.draw_text(prompt, (sw - pw) / 2, panel_y + 240, ps, Color::new(160, 200, 255, 255));
|
||||||
|
}
|
||||||
|
|
||||||
let ctrl = "SPACE / W / UP to jump";
|
let ctrl = "SPACE / W / UP to jump";
|
||||||
let cw = d.measure_text(ctrl, 18);
|
let cw = d.measure_text(ctrl, 18);
|
||||||
|
|||||||
@@ -65,36 +65,34 @@ impl LevelGenerator {
|
|||||||
|
|
||||||
platforms.push(Platform::new(x, y, width, cfg.platform_height));
|
platforms.push(Platform::new(x, y, width, cfg.platform_height));
|
||||||
|
|
||||||
let mut has_enemy = false;
|
// enemy_cx holds the center-x of a spawned enemy, used for pickup avoidance.
|
||||||
|
let mut enemy_cx: Option<f32> = None;
|
||||||
|
|
||||||
if width >= cfg.enemy_min_platform_width
|
if width >= cfg.enemy_min_platform_width
|
||||||
&& self.rng.random::<f32>() < cfg.enemy_spawn_chance
|
&& self.rng.random::<f32>() < cfg.enemy_spawn_chance
|
||||||
{
|
{
|
||||||
let margin = 15.0;
|
let max_offset = width - cfg.enemy_width - cfg.enemy_platform_margin * 2.0;
|
||||||
let max_offset = width - cfg.enemy_width - margin * 2.0;
|
|
||||||
if max_offset > 0.0 {
|
if max_offset > 0.0 {
|
||||||
let ex = x + margin + self.rng.random_range(0.0..max_offset);
|
let ex = x + cfg.enemy_platform_margin + self.rng.random_range(0.0..max_offset);
|
||||||
let ey = y - cfg.enemy_height;
|
let ey = y - cfg.enemy_height;
|
||||||
enemies.push(Enemy::new(ex, ey, cfg.enemy_width, cfg.enemy_height));
|
enemies.push(Enemy::new(ex, ey, cfg.enemy_width, cfg.enemy_height));
|
||||||
has_enemy = true;
|
enemy_cx = Some(ex + cfg.enemy_width * 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.pickup_defs.is_empty() && self.rng.random::<f32>() < cfg.pickup_spawn_chance {
|
if !cfg.pickup_defs.is_empty() && self.rng.random::<f32>() < cfg.pickup_spawn_chance {
|
||||||
let def_index = self.rng.random_range(0..cfg.pickup_defs.len());
|
let def_index = self.rng.random_range(0..cfg.pickup_defs.len());
|
||||||
|
|
||||||
let margin = 20.0;
|
let px_min = x + cfg.pickup_platform_margin;
|
||||||
let px_min = x + margin;
|
let px_max = x + width - cfg.pickup_size - cfg.pickup_platform_margin;
|
||||||
let px_max = x + width - cfg.pickup_size - margin;
|
|
||||||
|
|
||||||
if px_max > px_min {
|
if px_max > px_min {
|
||||||
let mut px: f32 = self.rng.random_range(px_min..px_max);
|
let mut px: f32 = self.rng.random_range(px_min..px_max);
|
||||||
|
|
||||||
if has_enemy {
|
if let Some(ecx) = enemy_cx {
|
||||||
let enemy_cx = x + width * 0.5;
|
if (px - ecx).abs() < cfg.enemy_width + cfg.pickup_size {
|
||||||
if (px - enemy_cx).abs() < cfg.enemy_width + cfg.pickup_size {
|
|
||||||
px = (px + cfg.enemy_width + cfg.pickup_size)
|
px = (px + cfg.enemy_width + cfg.pickup_size)
|
||||||
.min(x + width - cfg.pickup_size - margin);
|
.min(x + width - cfg.pickup_size - cfg.pickup_platform_margin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::effects::{ActiveEffect, InvulnerabilityEffect, JumpBoostEffect, Score
|
|||||||
|
|
||||||
/// Which gameplay effect this pickup grants.
|
/// Which gameplay effect this pickup grants.
|
||||||
/// Add a new variant here (and a matching `ActiveEffect` impl) to extend.
|
/// Add a new variant here (and a matching `ActiveEffect` impl) to extend.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum PickupEffectType {
|
pub enum PickupEffectType {
|
||||||
Invulnerability,
|
Invulnerability,
|
||||||
JumpBoost { factor: f32 },
|
JumpBoost { factor: f32 },
|
||||||
@@ -10,7 +10,7 @@ pub enum PickupEffectType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Data-driven definition of a pickup type — lives entirely in `Config`.
|
/// Data-driven definition of a pickup type — lives entirely in `Config`.
|
||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PickupDef {
|
pub struct PickupDef {
|
||||||
pub label: &'static str,
|
pub label: &'static str,
|
||||||
/// RGB colour used for the pickup icon and effect HUD bar.
|
/// RGB colour used for the pickup icon and effect HUD bar.
|
||||||
@@ -24,13 +24,13 @@ impl PickupDef {
|
|||||||
pub fn create_effect(&self) -> Box<dyn ActiveEffect> {
|
pub fn create_effect(&self) -> Box<dyn ActiveEffect> {
|
||||||
match self.effect {
|
match self.effect {
|
||||||
PickupEffectType::Invulnerability => {
|
PickupEffectType::Invulnerability => {
|
||||||
Box::new(InvulnerabilityEffect::new(self.duration))
|
Box::new(InvulnerabilityEffect::new(self.duration, self.label, self.color))
|
||||||
}
|
}
|
||||||
PickupEffectType::JumpBoost { factor } => {
|
PickupEffectType::JumpBoost { factor } => {
|
||||||
Box::new(JumpBoostEffect::new(self.duration, factor))
|
Box::new(JumpBoostEffect::new(self.duration, factor, self.label, self.color))
|
||||||
}
|
}
|
||||||
PickupEffectType::ScoreMultiplier { factor } => {
|
PickupEffectType::ScoreMultiplier { factor } => {
|
||||||
Box::new(ScoreMultiplierEffect::new(self.duration, factor))
|
Box::new(ScoreMultiplierEffect::new(self.duration, factor, self.label, self.color))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +38,7 @@ impl PickupDef {
|
|||||||
|
|
||||||
// ── In-world pickup instance ──────────────────────────────────────────────────
|
// ── In-world pickup instance ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Pickup {
|
pub struct Pickup {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
@@ -45,12 +46,11 @@ pub struct Pickup {
|
|||||||
pub height: f32,
|
pub height: f32,
|
||||||
/// Index into `Config::pickup_defs`.
|
/// Index into `Config::pickup_defs`.
|
||||||
pub def_index: usize,
|
pub def_index: usize,
|
||||||
pub collected: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pickup {
|
impl Pickup {
|
||||||
pub fn new(x: f32, y: f32, size: f32, def_index: usize) -> Self {
|
pub fn new(x: f32, y: f32, size: f32, def_index: usize) -> Self {
|
||||||
Self { x, y, width: size, height: size, def_index, collected: false }
|
Self { x, y, width: size, height: size, def_index }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn scroll(&mut self, speed: f32, dt: f32) {
|
pub fn scroll(&mut self, speed: f32, dt: f32) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
#[derive(Clone)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Platform {
|
pub struct Platform {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
@@ -19,10 +19,6 @@ impl Platform {
|
|||||||
self.x + self.width < 0.0
|
self.x + self.width < 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn top(&self) -> f32 {
|
|
||||||
self.y
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn right(&self) -> f32 {
|
pub fn right(&self) -> f32 {
|
||||||
self.x + self.width
|
self.x + self.width
|
||||||
}
|
}
|
||||||
@@ -52,9 +48,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn top_and_right_helpers() {
|
fn right_helper() {
|
||||||
let p = Platform::new(50.0, 300.0, 200.0, 20.0);
|
let p = Platform::new(50.0, 300.0, 200.0, 20.0);
|
||||||
assert_eq!(p.top(), 300.0);
|
|
||||||
assert_eq!(p.right(), 250.0);
|
assert_eq!(p.right(), 250.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
|
|||||||
65
src/world.rs
65
src/world.rs
@@ -123,14 +123,14 @@ mod tests {
|
|||||||
w.player.vy = 250.0;
|
w.player.vy = 250.0;
|
||||||
w.player.on_ground = false;
|
w.player.on_ground = false;
|
||||||
|
|
||||||
let score_before = w.score_f;
|
let score_before = w.score;
|
||||||
w.update(0.016, false, &cfg);
|
w.update(0.016, false, &cfg);
|
||||||
|
|
||||||
// Dead enemies are removed by retain() after stomp.
|
// Dead enemies are removed by retain() after stomp.
|
||||||
assert!(w.enemies.is_empty(), "dead enemy should be removed from world");
|
assert!(w.enemies.is_empty(), "dead enemy should be removed from world");
|
||||||
assert!(w.player.alive, "player should survive stomp");
|
assert!(w.player.alive, "player should survive stomp");
|
||||||
assert!(w.player.on_ground, "player should land on enemy");
|
assert!(w.player.on_ground, "player should land on enemy");
|
||||||
assert!(w.score_f > score_before, "stomp should award score");
|
assert!(w.score > score_before, "stomp should award score");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -155,7 +155,7 @@ mod tests {
|
|||||||
fn invulnerability_prevents_side_death() {
|
fn invulnerability_prevents_side_death() {
|
||||||
let cfg = cfg();
|
let cfg = cfg();
|
||||||
let mut w = world(&cfg);
|
let mut w = world(&cfg);
|
||||||
w.active_effects.push(Box::new(InvulnerabilityEffect::new(5.0)));
|
w.active_effects.push(Box::new(InvulnerabilityEffect::new(5.0, "INVINCIBLE", (255, 200, 50))));
|
||||||
|
|
||||||
let enemy_y = w.player.y;
|
let enemy_y = w.player.y;
|
||||||
w.enemies.push(Enemy::new(
|
w.enemies.push(Enemy::new(
|
||||||
@@ -206,9 +206,9 @@ mod tests {
|
|||||||
fn score_increases_over_time() {
|
fn score_increases_over_time() {
|
||||||
let cfg = cfg();
|
let cfg = cfg();
|
||||||
let mut w = world(&cfg);
|
let mut w = world(&cfg);
|
||||||
let score_before = w.score_f;
|
let score_before = w.score;
|
||||||
w.update(1.0, false, &cfg);
|
w.update(1.0, false, &cfg);
|
||||||
assert!(w.score_f > score_before);
|
assert!(w.score > score_before);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -217,12 +217,12 @@ mod tests {
|
|||||||
|
|
||||||
let mut w1 = world(&cfg);
|
let mut w1 = world(&cfg);
|
||||||
let mut w2 = world(&cfg);
|
let mut w2 = world(&cfg);
|
||||||
w2.active_effects.push(Box::new(ScoreMultiplierEffect::new(10.0, 2.0)));
|
w2.active_effects.push(Box::new(ScoreMultiplierEffect::new(10.0, 2.0, "2X SCORE", (180, 80, 255))));
|
||||||
|
|
||||||
w1.update(1.0, false, &cfg);
|
w1.update(1.0, false, &cfg);
|
||||||
w2.update(1.0, false, &cfg);
|
w2.update(1.0, false, &cfg);
|
||||||
|
|
||||||
let ratio = w2.score_f / w1.score_f;
|
let ratio = w2.score as f64 / w1.score as f64;
|
||||||
assert!((ratio - 2.0).abs() < 0.05, "ratio was {ratio:.3}, expected ~2.0");
|
assert!((ratio - 2.0).abs() < 0.05, "ratio was {ratio:.3}, expected ~2.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ mod tests {
|
|||||||
fn expired_effects_are_removed() {
|
fn expired_effects_are_removed() {
|
||||||
let cfg = cfg();
|
let cfg = cfg();
|
||||||
let mut w = world(&cfg);
|
let mut w = world(&cfg);
|
||||||
w.active_effects.push(Box::new(InvulnerabilityEffect::new(0.01)));
|
w.active_effects.push(Box::new(InvulnerabilityEffect::new(0.01, "INVINCIBLE", (255, 200, 50))));
|
||||||
|
|
||||||
w.update(1.0, false, &cfg); // well past expiry
|
w.update(1.0, false, &cfg); // well past expiry
|
||||||
|
|
||||||
@@ -248,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,
|
||||||
pub(crate) score_f: f32,
|
score_f: f32,
|
||||||
level_gen: LevelGenerator,
|
level_gen: LevelGenerator,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +316,7 @@ impl World {
|
|||||||
// 6. Recycle off-screen objects
|
// 6. Recycle off-screen objects
|
||||||
self.platforms.retain(|p| !p.is_off_screen());
|
self.platforms.retain(|p| !p.is_off_screen());
|
||||||
self.enemies.retain(|e| !e.is_off_screen() && e.alive);
|
self.enemies.retain(|e| !e.is_off_screen() && e.alive);
|
||||||
self.pickups.retain(|pk| !pk.is_off_screen() && !pk.collected);
|
self.pickups.retain(|pk| !pk.is_off_screen());
|
||||||
|
|
||||||
// 7. Generate new content ahead
|
// 7. Generate new content ahead
|
||||||
self.level_gen.generate_if_needed(
|
self.level_gen.generate_if_needed(
|
||||||
@@ -367,10 +367,10 @@ impl World {
|
|||||||
|
|
||||||
// Top-landing: bottom crossed platform surface this frame while falling.
|
// Top-landing: bottom crossed platform surface this frame while falling.
|
||||||
if self.player.vy >= 0.0
|
if self.player.vy >= 0.0
|
||||||
&& prev_bottom <= platform.top() + 1.0
|
&& prev_bottom <= platform.y + 1.0
|
||||||
&& curr_bottom >= platform.top()
|
&& curr_bottom >= platform.y
|
||||||
{
|
{
|
||||||
self.player.y = platform.top() - ph;
|
self.player.y = platform.y - ph;
|
||||||
self.player.vy = 0.0;
|
self.player.vy = 0.0;
|
||||||
self.player.on_ground = true;
|
self.player.on_ground = true;
|
||||||
break;
|
break;
|
||||||
@@ -400,19 +400,19 @@ impl World {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let overlaps_y = curr_bottom > enemy.top() && player_top < enemy.bottom();
|
let overlaps_y = curr_bottom > enemy.y && player_top < enemy.bottom();
|
||||||
if !overlaps_y {
|
if !overlaps_y {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stomp: player was above enemy top and crosses it while falling.
|
// Stomp: player was above enemy top and crosses it while falling.
|
||||||
let stomp = self.player.vy > 0.0
|
let stomp = self.player.vy > 0.0
|
||||||
&& prev_bottom <= enemy.top() + 8.0
|
&& prev_bottom <= enemy.y + 8.0
|
||||||
&& curr_bottom >= enemy.top();
|
&& curr_bottom >= enemy.y;
|
||||||
|
|
||||||
if stomp {
|
if stomp {
|
||||||
enemy.alive = false;
|
enemy.alive = false;
|
||||||
self.player.y = enemy.top() - self.player.height;
|
self.player.y = enemy.y - self.player.height;
|
||||||
self.player.vy = 0.0;
|
self.player.vy = 0.0;
|
||||||
self.player.on_ground = true;
|
self.player.on_ground = true;
|
||||||
self.score_f += cfg.enemy_stomp_bonus * score_mult;
|
self.score_f += cfg.enemy_stomp_bonus * score_mult;
|
||||||
@@ -432,21 +432,20 @@ impl World {
|
|||||||
let px = self.player.x;
|
let px = self.player.x;
|
||||||
let curr_bottom = self.player.bottom();
|
let curr_bottom = self.player.bottom();
|
||||||
let player_top = self.player.y;
|
let player_top = self.player.y;
|
||||||
|
let player_right = self.player.right();
|
||||||
|
|
||||||
for pickup in &mut self.pickups {
|
let mut new_effects: Vec<Box<dyn ActiveEffect>> = Vec::new();
|
||||||
if pickup.collected {
|
self.pickups.retain(|pickup| {
|
||||||
continue;
|
let overlaps_x = player_right > pickup.x && px < pickup.right();
|
||||||
}
|
|
||||||
|
|
||||||
let overlaps_x = self.player.right() > pickup.x && px < pickup.right();
|
|
||||||
let overlaps_y = curr_bottom > pickup.y && player_top < pickup.bottom();
|
let overlaps_y = curr_bottom > pickup.y && player_top < pickup.bottom();
|
||||||
|
|
||||||
if overlaps_x && overlaps_y {
|
if overlaps_x && overlaps_y {
|
||||||
pickup.collected = true;
|
new_effects.push(cfg.pickup_defs[pickup.def_index].create_effect());
|
||||||
let effect = cfg.pickup_defs[pickup.def_index].create_effect();
|
false // remove collected pickup immediately
|
||||||
self.active_effects.push(effect);
|
} else {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
self.active_effects.extend(new_effects);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||||
@@ -465,7 +464,7 @@ impl World {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw effect timer bars; called from game HUD so it layers above the world.
|
/// Draw effect timer bars; called from game HUD so it layers above the world.
|
||||||
pub fn draw_active_effects_hud(&self, d: &mut RaylibDrawHandle, cfg: &Config) {
|
pub fn draw_active_effects_hud(&self, d: &mut RaylibDrawHandle) {
|
||||||
let bar_w = 180;
|
let bar_w = 180;
|
||||||
let bar_h = 14;
|
let bar_h = 14;
|
||||||
let x0 = 16;
|
let x0 = 16;
|
||||||
@@ -484,8 +483,6 @@ impl World {
|
|||||||
d.draw_rectangle_lines(x0, y, bar_w, bar_h, Color::new(r, g, b, 160));
|
d.draw_rectangle_lines(x0, y, bar_w, bar_h, Color::new(r, g, b, 160));
|
||||||
// Label
|
// Label
|
||||||
let lbl = effect.label();
|
let lbl = effect.label();
|
||||||
d.draw_text(lbl, x0 + 4, y + 1, 11,
|
|
||||||
Color::new(cfg.screen_width as u8, 255, 255, 255)); // reuse white
|
|
||||||
d.draw_text(lbl, x0 + 4, y + 1, 11, Color::WHITE);
|
d.draw_text(lbl, x0 + 4, y + 1, 11, Color::WHITE);
|
||||||
|
|
||||||
y += bar_h + 4;
|
y += bar_h + 4;
|
||||||
@@ -509,10 +506,6 @@ impl World {
|
|||||||
let bob = (self.elapsed * 3.0).sin() * 3.0;
|
let bob = (self.elapsed * 3.0).sin() * 3.0;
|
||||||
|
|
||||||
for pickup in &self.pickups {
|
for pickup in &self.pickups {
|
||||||
if pickup.collected {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let def = &cfg.pickup_defs[pickup.def_index];
|
let def = &cfg.pickup_defs[pickup.def_index];
|
||||||
let (r, g, b) = def.color;
|
let (r, g, b) = def.color;
|
||||||
let x = pickup.x as i32;
|
let x = pickup.x as i32;
|
||||||
@@ -586,7 +579,7 @@ impl World {
|
|||||||
|
|
||||||
// Invulnerability pulsing outline
|
// Invulnerability pulsing outline
|
||||||
if self.is_invulnerable() {
|
if self.is_invulnerable() {
|
||||||
let pulse = ((self.elapsed * 8.0).sin() * 0.5 + 0.5) as f32;
|
let pulse = (self.elapsed * 8.0).sin() * 0.5 + 0.5;
|
||||||
let alpha = (150.0 + pulse * 105.0) as u8;
|
let alpha = (150.0 + pulse * 105.0) as u8;
|
||||||
d.draw_rectangle(x - 3, y - 3, w + 6, h + 6, Color::new(255, 200, 50, alpha));
|
d.draw_rectangle(x - 3, y - 3, w + 6, h + 6, Color::new(255, 200, 50, alpha));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user