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:
2026-03-07 00:21:44 +01:00
parent 401f91b0fe
commit 65a5ba99c0
10 changed files with 102 additions and 94 deletions

View File

@@ -6,3 +6,9 @@ edition = "2024"
[dependencies]
rand = "0.10.0"
raylib = { version = "5.5.1", features = ["wayland"] }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"

View File

@@ -31,7 +31,7 @@ mod tests {
/// 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.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Config {
pub screen_width: i32,
pub screen_height: i32,
@@ -68,11 +68,13 @@ pub struct Config {
pub enemy_height: f32,
pub enemy_spawn_chance: f32,
pub enemy_min_platform_width: f32,
pub enemy_platform_margin: f32,
// Pickups
pub pickup_size: 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>,
// Scoring
@@ -122,10 +124,12 @@ impl Default for Config {
enemy_height: 40.0,
enemy_spawn_chance: 0.35,
enemy_min_platform_width: 120.0,
enemy_platform_margin: 15.0,
pickup_size: 28.0,
pickup_spawn_chance: 0.28,
pickup_hover_gap: 12.0,
pickup_platform_margin: 20.0,
// ── Register new pickup types here ────────────────────────────────
pickup_defs: vec![

View File

@@ -27,14 +27,17 @@ pub trait ActiveEffect {
// ── Concrete effects ──────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct InvulnerabilityEffect {
timer: f32,
total: f32,
label: &'static str,
color: (u8, u8, u8),
}
impl InvulnerabilityEffect {
pub fn new(duration: f32) -> Self {
Self { timer: duration, total: duration }
pub fn new(duration: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
Self { timer: duration, total: duration, label, color }
}
}
@@ -44,10 +47,11 @@ impl ActiveEffect for InvulnerabilityEffect {
self.timer > 0.0
}
fn label(&self) -> &'static str {
"INVINCIBLE"
self.label
}
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 {
(self.timer / self.total).max(0.0)
@@ -59,15 +63,18 @@ impl ActiveEffect for InvulnerabilityEffect {
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct JumpBoostEffect {
timer: f32,
total: f32,
factor: f32,
label: &'static str,
color: (u8, u8, u8),
}
impl JumpBoostEffect {
pub fn new(duration: f32, factor: f32) -> Self {
Self { timer: duration, total: duration, factor }
pub fn new(duration: f32, factor: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
Self { timer: duration, total: duration, factor, label, color }
}
}
@@ -77,10 +84,11 @@ impl ActiveEffect for JumpBoostEffect {
self.timer > 0.0
}
fn label(&self) -> &'static str {
"JUMP BOOST"
self.label
}
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 {
(self.timer / self.total).max(0.0)
@@ -92,15 +100,18 @@ impl ActiveEffect for JumpBoostEffect {
// ─────────────────────────────────────────────────────────────────────────────
#[derive(Debug)]
pub struct ScoreMultiplierEffect {
timer: f32,
total: f32,
factor: f32,
label: &'static str,
color: (u8, u8, u8),
}
impl ScoreMultiplierEffect {
pub fn new(duration: f32, factor: f32) -> Self {
Self { timer: duration, total: duration, factor }
pub fn new(duration: f32, factor: f32, label: &'static str, color: (u8, u8, u8)) -> Self {
Self { timer: duration, total: duration, factor, label, color }
}
}
@@ -110,10 +121,11 @@ impl ActiveEffect for ScoreMultiplierEffect {
self.timer > 0.0
}
fn label(&self) -> &'static str {
"2X SCORE"
self.label
}
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 {
(self.timer / self.total).max(0.0)
@@ -129,63 +141,63 @@ mod tests {
#[test]
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));
}
#[test]
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));
}
#[test]
fn invulnerability_is_invulnerable() {
let e = InvulnerabilityEffect::new(5.0);
let e = InvulnerabilityEffect::new(5.0, "INVINCIBLE", (255, 200, 50));
assert!(e.is_invulnerable());
}
#[test]
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.score_multiplier(), 1.0);
}
#[test]
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);
}
#[test]
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());
}
#[test]
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);
}
#[test]
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);
}
#[test]
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);
assert!((e.progress() - 0.5).abs() < 1e-4);
}
#[test]
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);
assert_eq!(e.progress(), 0.0);
}

View File

@@ -1,4 +1,4 @@
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Enemy {
pub x: f32,
pub y: f32,
@@ -20,10 +20,6 @@ impl Enemy {
self.x + self.width < 0.0
}
pub fn top(&self) -> f32 {
self.y
}
pub fn bottom(&self) -> f32 {
self.y + self.height
}

View File

@@ -2,6 +2,7 @@ use raylib::prelude::*;
use crate::{config::Config, world::World};
#[derive(Debug)]
pub enum GameState {
Playing,
GameOver,
@@ -84,7 +85,7 @@ impl Game {
d.draw_rectangle(bar_x, bar_y, fill, bar_h, Color::new(80, 180, 255, 255));
// 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) {
@@ -133,8 +134,10 @@ impl Game {
let prompt = "SPACE / R / ENTER to restart";
let ps = 22;
let pw = d.measure_text(prompt, ps);
// Blinking effect based on elapsed time (we don't have time here, so just static)
d.draw_text(prompt, (sw - pw) / 2, panel_y + 240, ps, Color::new(160, 200, 255, 255));
// Blink at ~2 Hz using the world's accumulated elapsed time.
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 cw = d.measure_text(ctrl, 18);

View File

@@ -65,36 +65,34 @@ impl LevelGenerator {
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
&& self.rng.random::<f32>() < cfg.enemy_spawn_chance
{
let margin = 15.0;
let max_offset = width - cfg.enemy_width - margin * 2.0;
let max_offset = width - cfg.enemy_width - cfg.enemy_platform_margin * 2.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;
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 {
let def_index = self.rng.random_range(0..cfg.pickup_defs.len());
let margin = 20.0;
let px_min = x + margin;
let px_max = x + width - cfg.pickup_size - margin;
let px_min = x + cfg.pickup_platform_margin;
let px_max = x + width - cfg.pickup_size - cfg.pickup_platform_margin;
if px_max > px_min {
let mut px: f32 = self.rng.random_range(px_min..px_max);
if has_enemy {
let enemy_cx = x + width * 0.5;
if (px - enemy_cx).abs() < cfg.enemy_width + cfg.pickup_size {
if let Some(ecx) = enemy_cx {
if (px - ecx).abs() < 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);
}
}

View File

@@ -2,7 +2,7 @@ use crate::effects::{ActiveEffect, InvulnerabilityEffect, JumpBoostEffect, Score
/// Which gameplay effect this pickup grants.
/// Add a new variant here (and a matching `ActiveEffect` impl) to extend.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub enum PickupEffectType {
Invulnerability,
JumpBoost { factor: f32 },
@@ -10,7 +10,7 @@ pub enum PickupEffectType {
}
/// Data-driven definition of a pickup type — lives entirely in `Config`.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct PickupDef {
pub label: &'static str,
/// 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> {
match self.effect {
PickupEffectType::Invulnerability => {
Box::new(InvulnerabilityEffect::new(self.duration))
Box::new(InvulnerabilityEffect::new(self.duration, self.label, self.color))
}
PickupEffectType::JumpBoost { factor } => {
Box::new(JumpBoostEffect::new(self.duration, factor))
Box::new(JumpBoostEffect::new(self.duration, factor, self.label, self.color))
}
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 ──────────────────────────────────────────────────
#[derive(Debug)]
pub struct Pickup {
pub x: f32,
pub y: f32,
@@ -45,12 +46,11 @@ pub struct Pickup {
pub height: f32,
/// Index into `Config::pickup_defs`.
pub def_index: usize,
pub collected: bool,
}
impl Pickup {
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) {

View File

@@ -1,4 +1,4 @@
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Platform {
pub x: f32,
pub y: f32,
@@ -19,10 +19,6 @@ impl Platform {
self.x + self.width < 0.0
}
pub fn top(&self) -> f32 {
self.y
}
pub fn right(&self) -> f32 {
self.x + self.width
}
@@ -52,9 +48,8 @@ mod tests {
}
#[test]
fn top_and_right_helpers() {
fn right_helper() {
let p = Platform::new(50.0, 300.0, 200.0, 20.0);
assert_eq!(p.top(), 300.0);
assert_eq!(p.right(), 250.0);
}
}

View File

@@ -107,6 +107,7 @@ mod tests {
}
}
#[derive(Debug)]
pub struct Player {
pub x: f32,
pub y: f32,

View File

@@ -123,14 +123,14 @@ mod tests {
w.player.vy = 250.0;
w.player.on_ground = false;
let score_before = w.score_f;
let score_before = w.score;
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");
assert!(w.score > score_before, "stomp should award score");
}
#[test]
@@ -155,7 +155,7 @@ mod tests {
fn invulnerability_prevents_side_death() {
let cfg = 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;
w.enemies.push(Enemy::new(
@@ -206,9 +206,9 @@ mod tests {
fn score_increases_over_time() {
let cfg = cfg();
let mut w = world(&cfg);
let score_before = w.score_f;
let score_before = w.score;
w.update(1.0, false, &cfg);
assert!(w.score_f > score_before);
assert!(w.score > score_before);
}
#[test]
@@ -217,12 +217,12 @@ mod tests {
let mut w1 = 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);
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");
}
@@ -232,7 +232,7 @@ mod tests {
fn expired_effects_are_removed() {
let cfg = 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
@@ -248,7 +248,7 @@ pub struct World {
pub active_effects: Vec<Box<dyn ActiveEffect>>,
pub score: u64,
pub elapsed: f32,
pub(crate) score_f: f32,
score_f: f32,
level_gen: LevelGenerator,
}
@@ -316,7 +316,7 @@ impl World {
// 6. Recycle off-screen objects
self.platforms.retain(|p| !p.is_off_screen());
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
self.level_gen.generate_if_needed(
@@ -367,10 +367,10 @@ impl World {
// Top-landing: bottom crossed platform surface this frame while falling.
if self.player.vy >= 0.0
&& prev_bottom <= platform.top() + 1.0
&& curr_bottom >= platform.top()
&& prev_bottom <= platform.y + 1.0
&& curr_bottom >= platform.y
{
self.player.y = platform.top() - ph;
self.player.y = platform.y - ph;
self.player.vy = 0.0;
self.player.on_ground = true;
break;
@@ -400,19 +400,19 @@ impl World {
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 {
continue;
}
// Stomp: player was above enemy top and crosses it while falling.
let stomp = self.player.vy > 0.0
&& prev_bottom <= enemy.top() + 8.0
&& curr_bottom >= enemy.top();
&& prev_bottom <= enemy.y + 8.0
&& curr_bottom >= enemy.y;
if stomp {
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.on_ground = true;
self.score_f += cfg.enemy_stomp_bonus * score_mult;
@@ -432,21 +432,20 @@ impl World {
let px = self.player.x;
let curr_bottom = self.player.bottom();
let player_top = self.player.y;
let player_right = self.player.right();
for pickup in &mut self.pickups {
if pickup.collected {
continue;
}
let overlaps_x = self.player.right() > pickup.x && px < pickup.right();
let mut new_effects: Vec<Box<dyn ActiveEffect>> = Vec::new();
self.pickups.retain(|pickup| {
let overlaps_x = player_right > pickup.x && px < pickup.right();
let overlaps_y = curr_bottom > pickup.y && player_top < pickup.bottom();
if overlaps_x && overlaps_y {
pickup.collected = true;
let effect = cfg.pickup_defs[pickup.def_index].create_effect();
self.active_effects.push(effect);
new_effects.push(cfg.pickup_defs[pickup.def_index].create_effect());
false // remove collected pickup immediately
} else {
true
}
}
});
self.active_effects.extend(new_effects);
}
// ── Rendering ─────────────────────────────────────────────────────────────
@@ -465,7 +464,7 @@ impl 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_h = 14;
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));
// 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);
y += bar_h + 4;
@@ -509,10 +506,6 @@ impl World {
let bob = (self.elapsed * 3.0).sin() * 3.0;
for pickup in &self.pickups {
if pickup.collected {
continue;
}
let def = &cfg.pickup_defs[pickup.def_index];
let (r, g, b) = def.color;
let x = pickup.x as i32;
@@ -586,7 +579,7 @@ impl World {
// Invulnerability pulsing outline
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;
d.draw_rectangle(x - 3, y - 3, w + 6, h + 6, Color::new(255, 200, 50, alpha));
}