Add 2D endless runner game with pickups and coyote time

- Platformer endless runner: fixed player x, world scrolls left
- Logarithmic speed curve: initial + factor * ln(1 + t / time_scale)
- Enemies stomped from above; side/bottom contact kills player
- Procedural level generation with StdRng seeded from SystemTime
- Object pooling via Vec::retain + frontier-based generator
- Coyote time: grace window after leaving platform edge
- Data-driven pickup system with trait-based effects (ActiveEffect)
  - Invulnerability, JumpBoost, ScoreMultiplier — extend via config
- Score accumulates with per-effect multiplier; stomp bonuses scaled
- Game over screen with score, best score, restart prompt
- HUD: score, speed bar, active effect timer bars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 23:03:09 +01:00
commit 090f5d4a6d
13 changed files with 1751 additions and 0 deletions

357
src/world.rs Normal file
View File

@@ -0,0 +1,357 @@
use raylib::prelude::*;
use crate::{
config::Config,
effects::ActiveEffect,
enemy::Enemy,
level_gen::LevelGenerator,
pickup::Pickup,
platform::Platform,
player::Player,
};
pub struct World {
pub player: Player,
pub platforms: Vec<Platform>,
pub enemies: Vec<Enemy>,
pub pickups: Vec<Pickup>,
pub active_effects: Vec<Box<dyn ActiveEffect>>,
pub score: u64,
pub elapsed: f32,
score_f: f32,
level_gen: LevelGenerator,
}
impl World {
pub fn new(cfg: &Config) -> Self {
let start_platform = Platform::new(
0.0,
cfg.start_platform_y,
cfg.screen_width as f32 * 0.65,
cfg.platform_height,
);
let mut platforms = vec![start_platform];
let mut enemies = Vec::new();
let mut pickups = Vec::new();
let mut level_gen = LevelGenerator::new(cfg);
level_gen.generate_if_needed(cfg, &mut platforms, &mut enemies, &mut pickups);
Self {
player: Player::new(cfg),
platforms,
enemies,
pickups,
active_effects: Vec::new(),
score: 0,
score_f: 0.0,
elapsed: 0.0,
level_gen,
}
}
pub fn update(&mut self, dt: f32, jump_pressed: bool, cfg: &Config) {
self.elapsed += dt;
let speed = cfg.scroll_speed(self.elapsed);
// 1. Physics
self.player.update(dt, cfg);
// 2. Scroll world
for p in &mut self.platforms {
p.scroll(speed, dt);
}
for e in &mut self.enemies {
e.scroll(speed, dt);
}
for pk in &mut self.pickups {
pk.scroll(speed, dt);
}
self.level_gen.scroll(speed, dt);
// 3. Resolve collisions (sets on_ground / alive)
self.resolve_platform_collisions();
self.resolve_enemy_collisions(cfg);
self.collect_pickups(cfg);
// 4. Jump input — uses coyote_timer set during update, and effect multiplier
if jump_pressed {
let mult = self.jump_multiplier();
self.player.jump(cfg, mult);
}
// 5. Tick active effects; remove expired ones
self.active_effects.retain_mut(|e| e.update(dt));
// 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);
// 7. Generate new content ahead
self.level_gen.generate_if_needed(
cfg,
&mut self.platforms,
&mut self.enemies,
&mut self.pickups,
);
// 8. Accumulate score with current multiplier
let mult = self.score_multiplier();
self.score_f += cfg.score_per_second * mult * dt;
self.score = self.score_f as u64;
}
// ── Effect queries ────────────────────────────────────────────────────────
pub fn is_invulnerable(&self) -> bool {
self.active_effects.iter().any(|e| e.is_invulnerable())
}
fn jump_multiplier(&self) -> f32 {
self.active_effects.iter().fold(1.0_f32, |acc, e| acc * e.jump_multiplier())
}
fn score_multiplier(&self) -> f32 {
self.active_effects.iter().fold(1.0_f32, |acc, e| acc * e.score_multiplier())
}
// ── Collision resolution ──────────────────────────────────────────────────
fn resolve_platform_collisions(&mut self) {
if !self.player.alive {
return;
}
let ph = self.player.height;
let px = self.player.x;
let prev_bottom = self.player.prev_bottom();
let curr_bottom = self.player.bottom();
for platform in &self.platforms {
let overlaps_x = self.player.right() > platform.x + 4.0
&& px < platform.right() - 4.0;
if !overlaps_x {
continue;
}
// 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()
{
self.player.y = platform.top() - ph;
self.player.vy = 0.0;
self.player.on_ground = true;
break;
}
}
}
fn resolve_enemy_collisions(&mut self, cfg: &Config) {
if !self.player.alive {
return;
}
let px = self.player.x;
let prev_bottom = self.player.prev_bottom();
let curr_bottom = self.player.bottom();
let player_top = self.player.y;
// Precompute effect queries before the mutable borrow of self.enemies.
let invulnerable = self.is_invulnerable();
let score_mult = self.score_multiplier();
for enemy in &mut self.enemies {
if !enemy.alive {
continue;
}
let overlaps_x = self.player.right() > enemy.x && px < enemy.right();
if !overlaps_x {
continue;
}
let overlaps_y = curr_bottom > enemy.top() && 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();
if stomp {
enemy.alive = false;
self.player.stomp_bounce(cfg);
self.score_f += cfg.enemy_stomp_bonus * score_mult;
self.score = self.score_f as u64;
} else if !invulnerable {
self.player.alive = false;
return;
}
// invulnerable + non-stomp → pass through
}
}
fn collect_pickups(&mut self, cfg: &Config) {
if !self.player.alive {
return;
}
let px = self.player.x;
let curr_bottom = self.player.bottom();
let player_top = self.player.y;
for pickup in &mut self.pickups {
if pickup.collected {
continue;
}
let overlaps_x = self.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);
}
}
}
// ── Rendering ─────────────────────────────────────────────────────────────
pub fn render(&self, d: &mut RaylibDrawHandle, cfg: &Config) {
// Sky
d.draw_rectangle(0, 0, cfg.screen_width, cfg.screen_height,
Color::new(22, 24, 46, 255));
d.draw_rectangle(0, cfg.screen_height / 2, cfg.screen_width, cfg.screen_height / 2,
Color::new(18, 20, 38, 255));
self.draw_platforms(d);
self.draw_pickups(d, cfg);
self.draw_enemies(d);
self.draw_player(d);
}
/// 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) {
let bar_w = 180;
let bar_h = 14;
let x0 = 16;
let mut y = 58;
for effect in &self.active_effects {
let tint = effect.tint();
let (r, g, b, _) = (tint.r, tint.g, tint.b, tint.a);
// Background
d.draw_rectangle(x0, y, bar_w, bar_h, Color::new(30, 30, 55, 200));
// Fill
let fill = (effect.progress() * bar_w as f32) as i32;
d.draw_rectangle(x0, y, fill, bar_h, Color::new(r, g, b, 200));
// Border
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;
}
}
fn draw_platforms(&self, d: &mut RaylibDrawHandle) {
for p in &self.platforms {
d.draw_rectangle(p.x as i32, p.y as i32, p.width as i32, p.height as i32,
Color::new(58, 160, 72, 255));
d.draw_rectangle(p.x as i32, p.y as i32, p.width as i32, 4,
Color::new(100, 220, 110, 255));
d.draw_rectangle(p.x as i32, (p.y + p.height - 4.0) as i32, p.width as i32, 4,
Color::new(38, 110, 50, 255));
}
}
fn draw_pickups(&self, d: &mut RaylibDrawHandle, cfg: &Config) {
let s = cfg.pickup_size as i32;
// Animate a gentle bob using elapsed time
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;
let y = (pickup.y + bob) as i32;
// Glow shadow
d.draw_rectangle(x - 2, y - 2, s + 4, s + 4, Color::new(r, g, b, 60));
// Body
d.draw_rectangle(x, y, s, s, Color::new(r, g, b, 220));
// Shine
d.draw_rectangle(x + 3, y + 3, s / 3, s / 3, Color::new(255, 255, 255, 100));
// Border
d.draw_rectangle_lines(x, y, s, s, Color::WHITE);
// First letter of label
let letter = &def.label[..1];
let lw = d.measure_text(letter, 16);
d.draw_text(letter, x + (s - lw) / 2, y + (s - 16) / 2, 16, Color::WHITE);
}
}
fn draw_enemies(&self, d: &mut RaylibDrawHandle) {
for e in &self.enemies {
if !e.alive {
continue;
}
d.draw_rectangle(e.x as i32, e.y as i32, e.width as i32, e.height as i32,
Color::new(210, 50, 50, 255));
let eye_y = (e.y + e.height * 0.28) as i32;
d.draw_circle((e.x + e.width * 0.28) as i32, eye_y, 5.0, Color::WHITE);
d.draw_circle((e.x + e.width * 0.72) as i32, eye_y, 5.0, Color::WHITE);
d.draw_circle((e.x + e.width * 0.28) as i32 + 1, eye_y + 1, 2.5, Color::BLACK);
d.draw_circle((e.x + e.width * 0.72) as i32 + 1, eye_y + 1, 2.5, Color::BLACK);
let bx = e.x as i32;
let bw = e.width as i32;
let by = eye_y - 10;
d.draw_line(bx + 4, by - 2, bx + bw / 2 - 2, by + 4, Color::new(140, 20, 20, 255));
d.draw_line(bx + bw / 2 + 2, by + 4, bx + bw - 4, by - 2, Color::new(140, 20, 20, 255));
}
}
fn draw_player(&self, d: &mut RaylibDrawHandle) {
let p = &self.player;
if !p.alive {
return;
}
let x = p.x as i32;
let y = p.y as i32;
let w = p.width as i32;
let h = p.height as i32;
// Invulnerability pulsing outline
if self.is_invulnerable() {
let pulse = ((self.elapsed * 8.0).sin() * 0.5 + 0.5) as f32;
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));
}
let body_color = if p.on_ground {
Color::new(60, 120, 220, 255)
} else {
Color::new(80, 160, 255, 255)
};
d.draw_rectangle(x, y + h / 4, w, h * 3 / 4, body_color);
d.draw_rectangle(x + 4, y, w - 8, h / 2, Color::new(255, 210, 140, 255));
d.draw_rectangle(x + 8, y + 8, 6, 6, Color::WHITE);
d.draw_rectangle(x + w - 14, y + 8, 6, 6, Color::WHITE);
d.draw_rectangle(x + 10, y + 10, 3, 3, Color::BLACK);
d.draw_rectangle(x + w - 12, y + 10, 3, 3, Color::BLACK);
d.draw_rectangle(x, y + h / 4, w, 5, Color::new(220, 60, 60, 255));
}
}