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:
357
src/world.rs
Normal file
357
src/world.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user