diff --git a/assets/gothicvania/.gitkeep b/assets/gothicvania/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/breakpoint-client/Cargo.toml b/crates/breakpoint-client/Cargo.toml index a94c183..856b853 100644 --- a/crates/breakpoint-client/Cargo.toml +++ b/crates/breakpoint-client/Cargo.toml @@ -55,6 +55,10 @@ web-sys = { version = "0.3", features = [ "WebGlBuffer", "WebGlVertexArrayObject", "WebGlUniformLocation", + "WebGlTexture", + "WebGlFramebuffer", + "WebGlRenderbuffer", + "HtmlImageElement", "KeyboardEvent", "MouseEvent", "Event", diff --git a/crates/breakpoint-client/src/app.rs b/crates/breakpoint-client/src/app.rs index ac50e69..aaa1173 100644 --- a/crates/breakpoint-client/src/app.rs +++ b/crates/breakpoint-client/src/app.rs @@ -11,11 +11,12 @@ use breakpoint_core::player::Player; use crate::audio::{AudioEvent, AudioEventQueue, AudioManager, AudioSettings}; use crate::bridge; use crate::camera_gl::{Camera, CameraMode}; -use crate::effects::ScreenShake; +use crate::effects::{ScreenFlash, ScreenShake}; use crate::game::{GameRegistry, read_game_state}; use crate::input::InputState; use crate::net_client::WsClient; use crate::overlay::{OverlayEventQueue, OverlayNetEvent, OverlayState}; +use crate::particles::ParticleSystem; use crate::renderer::Renderer; use crate::scene::Scene; use crate::theme::Theme; @@ -144,6 +145,15 @@ pub struct App { pub round_tracker: Option, pub registry: GameRegistry, pub screen_shake: ScreenShake, + pub screen_flash: ScreenFlash, + pub particle_system: ParticleSystem, + pub weather: crate::weather::WeatherSystem, + /// Previous frame HP per player (for detecting damage/heal events). + prev_player_hp: HashMap, + /// Previous frame enemy alive states (for detecting kills). + prev_enemy_alive: Vec<(u16, bool)>, + /// Previous frame powerup collected states (for detecting pickups). + prev_powerup_collected: Vec, pub was_connected: bool, pub reconnect_info: Option, /// Timestamp (ms) when between-round countdown expires. @@ -248,6 +258,12 @@ impl App { round_tracker: None, registry, screen_shake: ScreenShake::default(), + screen_flash: ScreenFlash::default(), + particle_system: ParticleSystem::new(), + weather: crate::weather::WeatherSystem::new(), + prev_player_hp: HashMap::new(), + prev_enemy_alive: Vec::new(), + prev_powerup_collected: Vec::new(), was_connected: false, reconnect_info: None, between_round_end_time: None, @@ -309,6 +325,9 @@ impl App { self.camera.apply_shake(self.screen_shake.offset); } + // Screen flash + self.screen_flash.tick(dt); + // Process audio if !self.audio_settings.muted { self.audio_events @@ -317,6 +336,24 @@ impl App { self.audio_events.clear(); } + // Update and render particles into the scene + self.particle_system.tick(dt); + self.particle_system.render(&mut self.scene); + + // Update and render weather + self.weather + .set_camera(self.camera.position.x, self.camera.position.y); + self.weather.tick(dt); + self.weather.render(&mut self.scene); + + // Lightning flash overlay + if self.weather.lightning_intensity > 0.01 && !self.screen_flash.active { + self.screen_flash.trigger( + glam::Vec4::new(0.9, 0.9, 1.0, self.weather.lightning_intensity * 0.4), + 0.1, + ); + } + // Render 3D scene — Tron uses pure black background + fog let is_tron = self .game @@ -332,9 +369,43 @@ impl App { if is_tron { self.camera.fov = 70_f32.to_radians(); } + // Set post-processing from theme (platformer only) + let is_platformer = self + .game + .as_ref() + .is_some_and(|g| g.game_id == GameId::Platformer); + if is_platformer { + self.renderer.post_process.scanline_intensity = + self.theme.platformer.scanline_intensity; + self.renderer.post_process.bloom_intensity = self.theme.platformer.bloom_intensity; + self.renderer.post_process.vignette_intensity = + self.theme.platformer.vignette_intensity; + self.renderer.post_process.crt_curvature = self.theme.platformer.crt_curvature; + // Apply per-room color grading from scene lighting + self.renderer.post_process.grade_shadows = self.scene.lighting.grade_shadows; + self.renderer.post_process.grade_highlights = self.scene.lighting.grade_highlights; + self.renderer.post_process.grade_contrast = self.scene.lighting.grade_contrast; + self.renderer.post_process.saturation = self.scene.lighting.saturation; + // Graduated film grain for dark rooms + self.renderer.post_process.film_grain = if self.scene.lighting.ambient < 0.3 { + 0.04 + } else if self.scene.lighting.ambient < 0.5 { + 0.02 + } else { + 0.0 + }; + } else { + self.renderer.post_process = crate::renderer::PostProcessConfig::default(); + } self.renderer .draw(&self.scene, &self.camera, dt, clear_color, fog_density); + // Draw screen flash overlay after scene (additive blend) + if self.screen_flash.active { + self.renderer + .draw_screen_flash(self.screen_flash.color, self.screen_flash.alpha()); + } + // Push UI state to JS bridge::push_ui_state(self); @@ -773,6 +844,11 @@ impl App { fn update_game(&mut self, dt: f32) { self.audio_frame_counter = self.audio_frame_counter.wrapping_add(1); + let game_id = match self.game { + Some(ref g) => g.game_id, + None => return, + }; + let Some(ref active) = self.game else { return; }; @@ -854,6 +930,204 @@ impl App { // Game-specific input and rendering self.update_game_input(); self.sync_game_scene(dt); + + // Detect platformer state changes for VFX (outside the `ref active` borrow) + #[cfg(feature = "platformer")] + if game_id == GameId::Platformer { + self.detect_platformer_events(); + self.update_platformer_weather(); + } + } + + /// Detect HP changes and enemy kills in the platformer for particle/audio effects. + #[cfg(feature = "platformer")] + fn detect_platformer_events(&mut self) { + let Some(ref active) = self.game else { + return; + }; + let Some(state) = read_game_state::(active) else { + return; + }; + let sheet = crate::sprite_atlas::build_platformer_atlas(); + + self.detect_player_hp_changes(&state, &sheet); + self.detect_enemy_kills(&state, &sheet); + self.detect_powerup_collections(&state, &sheet); + self.emit_torch_embers(&state, &sheet); + } + + /// Configure weather system based on the current room theme under the camera. + #[cfg(feature = "platformer")] + fn update_platformer_weather(&mut self) { + let Some(ref active) = self.game else { + return; + }; + let Some(state) = read_game_state::(active) else { + return; + }; + let tile_size = breakpoint_platformer::physics::TILE_SIZE; + let theme = state.course.room_theme_at_tile( + (self.camera.position.x / tile_size) as i32, + (self.camera.position.y / tile_size) as i32, + ); + let (raining, fog, fog_color) = crate::game::platformer_render::room_theme_weather(theme); + self.weather.raining = raining; + self.weather.fog_density = fog; + self.weather.fog_color = fog_color; + self.weather.ambient_type = crate::game::platformer_render::room_theme_ambient_type(theme); + } + + /// Emit continuous ember particles from visible torches. + #[cfg(feature = "platformer")] + fn emit_torch_embers( + &mut self, + state: &breakpoint_platformer::PlatformerState, + sheet: &crate::sprite_atlas::SpriteSheet, + ) { + use crate::particles::ParticleEffect; + use breakpoint_platformer::course_gen::Tile; + use breakpoint_platformer::physics::TILE_SIZE; + + let cam_x = self.camera.position.x; + let visible_half = 15.0; + let min_col = ((cam_x - visible_half) / TILE_SIZE).floor().max(0.0) as u32; + let max_col = ((cam_x + visible_half) / TILE_SIZE) + .ceil() + .min(state.course.width as f32) as u32; + + for y in 0..state.course.height { + for x in min_col..max_col { + if state.course.get_tile(x as i32, y as i32) == Tile::DecoTorch { + let wx = x as f32 * TILE_SIZE + TILE_SIZE / 2.0; + let wy = y as f32 * TILE_SIZE + TILE_SIZE / 2.0; + self.particle_system.emit_continuous( + ParticleEffect::TorchEmber, + wx, + wy, + sheet, + 0.03, // ~1 ember per torch every ~33 frames + ); + } + } + } + } + + /// Check for player HP decreases and trigger effects. + #[cfg(feature = "platformer")] + fn detect_player_hp_changes( + &mut self, + state: &breakpoint_platformer::PlatformerState, + sheet: &crate::sprite_atlas::SpriteSheet, + ) { + use crate::particles::ParticleEffect; + for (&pid, player) in &state.players { + if let Some(&prev_hp) = self.prev_player_hp.get(&pid) { + if player.hp < prev_hp { + // Player took damage + self.screen_shake.trigger(0.2, 0.2); + self.screen_flash + .trigger(Vec4::new(1.0, 0.0, 0.0, 0.3), 0.15); + self.particle_system.emit( + ParticleEffect::BloodDamage, + player.x, + player.y, + sheet, + ); + self.audio_events.push(AudioEvent::PlatformerHit); + } + if player.death_respawn_timer > 0.0 && prev_hp > 0 && player.hp == 0 { + // Player died + self.screen_shake.trigger(0.4, 0.3); + self.audio_events.push(AudioEvent::PlatformerDeath); + } + } + self.prev_player_hp.insert(pid, player.hp); + } + } + + /// Check for enemy death transitions and trigger effects. + #[cfg(feature = "platformer")] + fn detect_enemy_kills( + &mut self, + state: &breakpoint_platformer::PlatformerState, + sheet: &crate::sprite_atlas::SpriteSheet, + ) { + use crate::particles::ParticleEffect; + let new_alive: Vec<(u16, bool)> = state.enemies.iter().map(|e| (e.id, e.alive)).collect(); + + for &(id, alive) in &new_alive { + let was_alive = self + .prev_enemy_alive + .iter() + .find(|&&(eid, _)| eid == id) + .is_some_and(|&(_, a)| a); + if was_alive && !alive { + // Enemy was just killed + if let Some(e) = state.enemies.iter().find(|e| e.id == id) { + self.particle_system + .emit(ParticleEffect::EnemyDeath, e.x, e.y, sheet); + // Directional whip impact sparks + let facing_right = e.facing_right; + self.particle_system.emit( + ParticleEffect::WhipImpact { + facing_right: !facing_right, + }, + e.x, + e.y, + sheet, + ); + self.screen_flash + .trigger(Vec4::new(1.0, 1.0, 1.0, 0.2), 0.1); + self.audio_events.push(AudioEvent::PlatformerEnemyKill); + } + } + } + + self.prev_enemy_alive = new_alive; + } + + /// Check for powerup collection events and emit colored burst particles. + #[cfg(feature = "platformer")] + fn detect_powerup_collections( + &mut self, + state: &breakpoint_platformer::PlatformerState, + sheet: &crate::sprite_atlas::SpriteSheet, + ) { + use crate::particles::ParticleEffect; + use breakpoint_platformer::powerups::PowerUpKind; + + let current: Vec = state.powerups.iter().map(|p| p.collected).collect(); + + if current.len() == self.prev_powerup_collected.len() { + for (i, (&was, &is_now)) in self + .prev_powerup_collected + .iter() + .zip(current.iter()) + .enumerate() + { + if !was && is_now { + let pu = &state.powerups[i]; + let color = match pu.kind { + PowerUpKind::HolyWater => Vec4::new(0.3, 0.5, 1.0, 1.0), + PowerUpKind::Crucifix => Vec4::new(1.0, 0.9, 0.2, 1.0), + PowerUpKind::SpeedBoots => Vec4::new(0.2, 1.0, 0.3, 1.0), + PowerUpKind::DoubleJump => Vec4::new(0.2, 0.9, 1.0, 1.0), + PowerUpKind::ArmorUp => Vec4::new(0.6, 0.6, 0.6, 1.0), + PowerUpKind::Invincibility => Vec4::new(1.0, 0.85, 0.2, 1.0), + PowerUpKind::WhipExtend => Vec4::new(1.0, 0.5, 0.1, 1.0), + }; + self.particle_system.emit( + ParticleEffect::GenericBurst { color, count: 8 }, + pu.x, + pu.y, + sheet, + ); + self.audio_events.push(AudioEvent::PlatformerPowerUp); + } + } + } + + self.prev_powerup_collected = current; } fn update_game_input(&mut self) { @@ -951,6 +1225,9 @@ impl App { active, &self.theme, dt, + self.camera.position.x, + self.camera.position.y, + self.renderer.time(), ); }, #[cfg(feature = "lasertag")] @@ -1073,6 +1350,37 @@ pub fn run() { let app = Rc::new(RefCell::new(App::new(renderer))); + // Load platformer sprite atlas (ID 0, clamp-to-edge) + { + let app_atlas = Rc::clone(&app); + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + let onload = wasm_bindgen::closure::Closure::::new(move || { + app_atlas.borrow_mut().renderer.load_texture(0, &img_clone); + web_sys::console::log_1(&"Platformer atlas loaded".into()); + }); + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src("assets/sprites/platformer_atlas.png"); + } + + // Load parallax background texture (ID 1, repeat wrapping) + { + let app_bg = Rc::clone(&app); + let img = web_sys::HtmlImageElement::new().unwrap(); + let img_clone = img.clone(); + let onload = wasm_bindgen::closure::Closure::::new(move || { + app_bg + .borrow_mut() + .renderer + .load_texture_with_wrap(1, &img_clone, true); + web_sys::console::log_1(&"Parallax background loaded".into()); + }); + img.set_onload(Some(onload.as_ref().unchecked_ref())); + onload.forget(); + img.set_src("assets/sprites/platformer_bg.png"); + } + // Attach input listeners bridge::attach_input_listeners(&app); // Attach JS→Rust bridge callbacks diff --git a/crates/breakpoint-client/src/audio.rs b/crates/breakpoint-client/src/audio.rs index ba80346..67d292f 100644 --- a/crates/breakpoint-client/src/audio.rs +++ b/crates/breakpoint-client/src/audio.rs @@ -9,6 +9,11 @@ pub enum AudioEvent { PlatformerJump, PlatformerPowerUp, PlatformerFinish, + PlatformerAttack, + PlatformerHit, + PlatformerDeath, + PlatformerEnemyKill, + PlatformerCheckpoint, LaserFire, LaserHit, TronCrash, @@ -48,6 +53,17 @@ impl AudioEventQueue { AudioEvent::PlatformerFinish => { (520.0, 0.5, WaveType::Triangle, SoundCategory::Game) }, + AudioEvent::PlatformerAttack => { + (200.0, 0.1, WaveType::Sawtooth, SoundCategory::Game) + }, + AudioEvent::PlatformerHit => (150.0, 0.15, WaveType::Square, SoundCategory::Game), + AudioEvent::PlatformerDeath => (120.0, 0.4, WaveType::Square, SoundCategory::Game), + AudioEvent::PlatformerEnemyKill => { + (400.0, 0.12, WaveType::Triangle, SoundCategory::Game) + }, + AudioEvent::PlatformerCheckpoint => { + (480.0, 0.3, WaveType::Sine, SoundCategory::Game) + }, AudioEvent::LaserFire => (280.0, 0.06, WaveType::Sawtooth, SoundCategory::Game), AudioEvent::LaserHit => (180.0, 0.15, WaveType::Square, SoundCategory::Game), AudioEvent::TronCrash => (200.0, 0.3, WaveType::Square, SoundCategory::Game), diff --git a/crates/breakpoint-client/src/bridge.rs b/crates/breakpoint-client/src/bridge.rs index e15a79c..2972bb1 100644 --- a/crates/breakpoint-client/src/bridge.rs +++ b/crates/breakpoint-client/src/bridge.rs @@ -176,11 +176,6 @@ fn build_platformer_hud(app: &App) -> serde_json::Value { return serde_json::Value::Null; }; - let mode_str = match state.mode { - breakpoint_platformer::GameMode::Race => "Race", - breakpoint_platformer::GameMode::Survival => "Survival", - }; - let players_json: Vec = app .lobby .players @@ -189,6 +184,9 @@ fn build_platformer_hud(app: &App) -> serde_json::Value { let ps = state.players.get(&p.id); let eliminated = ps.map(|s| s.eliminated).unwrap_or(false); let finished = ps.map(|s| s.finished).unwrap_or(false); + let hp = ps.map(|s| s.hp).unwrap_or(0); + let max_hp = ps.map(|s| s.max_hp).unwrap_or(3); + let deaths = ps.map(|s| s.deaths).unwrap_or(0); let finish_rank = state .finish_order .iter() @@ -200,17 +198,196 @@ fn build_platformer_hud(app: &App) -> serde_json::Value { "eliminated": eliminated, "finished": finished, "finishRank": finish_rank, + "hp": hp, + "maxHp": max_hp, + "deaths": deaths, }) }) .collect(); + // Local player HUD data + let local_id = app.network_role.as_ref().map(|r| r.local_player_id); + let local_ps = local_id.and_then(|id| state.players.get(&id)); + let local_hp = local_ps.map(|s| s.hp).unwrap_or(0); + let local_max_hp = local_ps.map(|s| s.max_hp).unwrap_or(3); + let local_deaths = local_ps.map(|s| s.deaths).unwrap_or(0); + let local_powerup = local_ps + .and_then(|s| s.active_powerup.as_ref()) + .map(|p| format!("{p:?}")) + .unwrap_or_default(); + + // Powerup timer: find first finite-duration active powerup for local player + let (powerup_timer, powerup_max_timer) = local_id + .and_then(|id| state.active_powerups.get(&id)) + .and_then(|pups| { + pups.iter() + .find(|p| p.remaining.is_finite() && p.remaining > 0.0) + }) + .map(|p| { + use breakpoint_core::powerup::PowerUpKind as _; + (p.remaining, p.kind.duration()) + }) + .unwrap_or((0.0, 0.0)); + + // Checkpoint progress + let local_checkpoint = local_ps.map(|s| s.last_checkpoint_id).unwrap_or(0); + let total_checkpoints = state.course.checkpoint_positions.len(); + + // Race position: rank by room distance (then checkpoint_id as tiebreaker) + let mut positions: Vec<(u64, u16, u16)> = state + .players + .iter() + .filter(|(_, p)| !p.eliminated) + .map(|(id, p)| (*id, p.current_room_distance, p.last_checkpoint_id)) + .collect(); + positions.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.2.cmp(&a.2))); + let race_pos = local_id + .and_then(|id| positions.iter().position(|(pid, _, _)| *pid == id)) + .map(|i| i + 1) + .unwrap_or(0); + let total_racers = positions.len(); + + // Minimap: compact course + player data (sent each frame but very small) + let minimap = build_platformer_minimap(&state, &app.lobby.players); + + // Room name from local player position + let room_name = local_ps + .map(|ps| { + let tile_size = breakpoint_platformer::physics::TILE_SIZE; + let theme = state + .course + .room_theme_at_tile((ps.x / tile_size) as i32, (ps.y / tile_size) as i32); + format!("{theme:?}") + }) + .unwrap_or_default(); + serde_json::json!({ - "mode": mode_str, + "mode": "Race", "players": players_json, - "hazardY": state.hazard_y, - "eliminationCount": state.elimination_order.len(), + "enemyCount": state.enemies.iter().filter(|e| e.alive).count(), "finishCount": state.finish_order.len(), "roundTimer": state.round_timer, + "localPlayerHp": local_hp, + "localPlayerMaxHp": local_max_hp, + "localPlayerDeaths": local_deaths, + "localPlayerPowerup": local_powerup, + "powerupTimer": powerup_timer, + "powerupMaxTimer": powerup_max_timer, + "racePosition": race_pos, + "totalRacers": total_racers, + "localCheckpoint": local_checkpoint, + "totalCheckpoints": total_checkpoints, + "minimap": minimap, + "roomName": room_name, + }) +} + +/// Build compact minimap data for the 2D labyrinth. +/// Sends room grid positions, connections, themes, and player locations. +#[cfg(target_family = "wasm")] +fn build_platformer_minimap( + state: &breakpoint_platformer::PlatformerState, + lobby_players: &[breakpoint_core::player::Player], +) -> serde_json::Value { + use breakpoint_platformer::course_gen::{GRID_COLS, GRID_ROWS, ROOM_H, ROOM_W, Tile}; + + let course = &state.course; + + // Build room list: [col, row, has_finish, has_checkpoint, theme_color_index] + let mut rooms = Vec::new(); + let mut connections = Vec::new(); + for col in 0..GRID_COLS { + for row in 0..GRID_ROWS { + let bx = col * ROOM_W + ROOM_W / 2; + let by = row * ROOM_H + ROOM_H / 2; + if course.get_tile(bx as i32, by as i32) != Tile::Empty { + continue; + } + let idx = col as usize * GRID_ROWS as usize + row as usize; + let dist = if idx < course.room_distances.len() { + course.room_distances[idx] + } else { + 0 + }; + // Check for finish tile in room + let mut has_finish = false; + let mut has_checkpoint = false; + for ty in (row * ROOM_H)..((row + 1) * ROOM_H) { + for tx in (col * ROOM_W)..((col + 1) * ROOM_W) { + match course.get_tile(tx as i32, ty as i32) { + Tile::Finish => has_finish = true, + Tile::Checkpoint => has_checkpoint = true, + _ => {}, + } + } + } + rooms.push(serde_json::json!([ + col, + row, + dist, + has_finish, + has_checkpoint + ])); + + // Check connections to right and up neighbors + if col + 1 < GRID_COLS { + let nbx = (col + 1) * ROOM_W + ROOM_W / 2; + if course.get_tile(nbx as i32, by as i32) == Tile::Empty { + // Check if there's a doorway between them + let wall_x = (col + 1) * ROOM_W; + let mid_y = row * ROOM_H + ROOM_H / 2; + let mut has_door = false; + for dy in 0..4 { + if course.get_tile(wall_x as i32, (mid_y + dy) as i32) != Tile::StoneBrick { + has_door = true; + break; + } + } + if has_door { + connections.push(serde_json::json!([col, row, col + 1, row])); + } + } + } + if row + 1 < GRID_ROWS { + let nby = (row + 1) * ROOM_H + ROOM_H / 2; + if course.get_tile(bx as i32, nby as i32) == Tile::Empty { + let wall_y = (row + 1) * ROOM_H; + let mid_x = col * ROOM_W + ROOM_W / 2; + let mut has_door = false; + for dx in 0..4 { + if course.get_tile((mid_x + dx) as i32, wall_y as i32) != Tile::StoneBrick { + has_door = true; + break; + } + } + if has_door { + connections.push(serde_json::json!([col, row, col, row + 1])); + } + } + } + } + } + + // Player dots: [x, y, color_index, is_active] + let player_dots: Vec = lobby_players + .iter() + .enumerate() + .filter_map(|(i, p)| { + state + .players + .get(&p.id) + .map(|ps| serde_json::json!([ps.x, ps.y, i % 8, !ps.eliminated])) + }) + .collect(); + + serde_json::json!({ + "gridCols": GRID_COLS, + "gridRows": GRID_ROWS, + "roomW": ROOM_W, + "roomH": ROOM_H, + "rooms": rooms, + "connections": connections, + "dots": player_dots, }) } diff --git a/crates/breakpoint-client/src/camera_gl.rs b/crates/breakpoint-client/src/camera_gl.rs index 274fbbb..9943064 100644 --- a/crates/breakpoint-client/src/camera_gl.rs +++ b/crates/breakpoint-client/src/camera_gl.rs @@ -97,14 +97,22 @@ impl Camera { self.up = Vec3::Y; }, CameraMode::PlatformerFollow { player_pos } => { - let camera_z = -25.0; - let look_y_offset = 3.0; + let camera_z = 20.0; // Wider view for 32×24 rooms + let look_y_offset = 2.0; let target_pos = Vec3::new(player_pos.x, player_pos.y + look_y_offset, camera_z); let look_at = Vec3::new(player_pos.x, player_pos.y + look_y_offset, 0.0); self.position = self.position.lerp(target_pos, lerp_factor); self.target = self.target.lerp(look_at, lerp_factor); self.up = Vec3::Y; + + // Sub-pixel snap: align camera to pixel grid to prevent tile shimmer + let pixels_per_unit = 16.0; // 16px tiles + let pixel_size = 1.0 / pixels_per_unit; + self.position.x = (self.position.x / pixel_size).round() * pixel_size; + self.position.y = (self.position.y / pixel_size).round() * pixel_size; + self.target.x = (self.target.x / pixel_size).round() * pixel_size; + self.target.y = (self.target.y / pixel_size).round() * pixel_size; }, CameraMode::LaserTagFixed => { self.position = Vec3::new(25.0, 62.0, 25.0); diff --git a/crates/breakpoint-client/src/effects/mod.rs b/crates/breakpoint-client/src/effects/mod.rs index e449b30..6d00670 100644 --- a/crates/breakpoint-client/src/effects/mod.rs +++ b/crates/breakpoint-client/src/effects/mod.rs @@ -1,4 +1,4 @@ -use glam::Vec3; +use glam::{Vec3, Vec4}; /// Screen shake effect state. #[derive(Default)] @@ -32,3 +32,70 @@ impl ScreenShake { } } } + +/// Full-screen color flash effect (damage flash, pickup flash, etc.). +#[derive(Default)] +pub struct ScreenFlash { + pub active: bool, + pub timer: f32, + pub duration: f32, + pub color: Vec4, +} + +impl ScreenFlash { + /// Trigger a screen flash with the given color and duration. + pub fn trigger(&mut self, color: Vec4, duration: f32) { + self.active = true; + self.timer = duration; + self.duration = duration; + self.color = color; + } + + pub fn tick(&mut self, dt: f32) { + if !self.active { + return; + } + self.timer -= dt; + if self.timer <= 0.0 { + self.active = false; + self.timer = 0.0; + } + } + + /// Current alpha (fades from 1.0 to 0.0 over duration). + pub fn alpha(&self) -> f32 { + if !self.active || self.duration <= 0.0 { + return 0.0; + } + (self.timer / self.duration).clamp(0.0, 1.0) * self.color.w + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn screen_flash_fades_out() { + let mut flash = ScreenFlash::default(); + flash.trigger(Vec4::new(1.0, 0.0, 0.0, 0.5), 0.5); + assert!(flash.active); + assert!(flash.alpha() > 0.0); + + flash.tick(0.3); + assert!(flash.active); + let a = flash.alpha(); + assert!(a > 0.0 && a < 0.5); + + flash.tick(0.3); + assert!(!flash.active); + assert!((flash.alpha() - 0.0).abs() < 1e-6); + } + + #[test] + fn screen_flash_inactive_by_default() { + let flash = ScreenFlash::default(); + assert!(!flash.active); + assert!((flash.alpha() - 0.0).abs() < 1e-6); + } +} diff --git a/crates/breakpoint-client/src/game/platformer_input.rs b/crates/breakpoint-client/src/game/platformer_input.rs index eb71777..093170e 100644 --- a/crates/breakpoint-client/src/game/platformer_input.rs +++ b/crates/breakpoint-client/src/game/platformer_input.rs @@ -24,10 +24,13 @@ pub fn process_platformer_input( input.is_key_down("Space") || input.is_key_down("ArrowUp") || input.is_key_down("KeyW"); let use_powerup = input.is_key_just_pressed("KeyE"); + let attack = input.is_key_just_pressed("KeyF") || input.is_key_just_pressed("KeyX"); + let plat_input = PlatformerInput { move_dir, jump, use_powerup, + attack, }; send_player_input(&plat_input, active, role, ws); } diff --git a/crates/breakpoint-client/src/game/platformer_render.rs b/crates/breakpoint-client/src/game/platformer_render.rs index 72ee8a3..3d68b44 100644 --- a/crates/breakpoint-client/src/game/platformer_render.rs +++ b/crates/breakpoint-client/src/game/platformer_render.rs @@ -1,112 +1,1367 @@ +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + use glam::{Vec3, Vec4}; use crate::app::ActiveGame; use crate::game::read_game_state; -use crate::scene::{MaterialType, MeshType, Scene, Transform}; -use crate::theme::{Theme, rgb_vec4}; +use crate::scene::{MaterialType, MeshType, Scene, SceneLighting, Transform}; +use crate::sprite_atlas::{ + SpriteAnimation, SpriteRegion, SpriteSheet, bitmask_tile_for_group, + build_platformer_animations, build_platformer_atlas, room_theme_to_tile_group, + stone_brick_bitmask, +}; +use crate::theme::Theme; + +/// Predefined player color palettes for multiplayer differentiation. +/// Each entry: (body_r, body_g, body_b) — applied as a tint multiplier. +const PLAYER_PALETTES: [[f32; 3]; 8] = [ + [1.0, 0.85, 0.65], // P1: warm gold/bronze (Belmont-style) + [0.6, 0.7, 0.9], // P2: steel blue + [0.75, 0.2, 0.25], // P3: dark crimson + [0.3, 0.55, 0.35], // P4: forest green + [0.55, 0.35, 0.7], // P5: royal purple + [0.75, 0.75, 0.8], // P6: silver + [0.9, 0.5, 0.2], // P7: flame orange + [0.35, 0.6, 0.6], // P8: shadow teal +]; + +/// Sprite atlas ID. +const ATLAS_ID: u8 = 0; + +// MBAACC-style Z-layer constants (painter's algorithm). +const Z_BG_TILES: f32 = -1.0; +const Z_WATER: f32 = -0.8; +const Z_SHADOWS: f32 = -0.5; +const Z_ENEMIES: f32 = 0.0; +const Z_PLAYERS: f32 = 0.1; +const Z_EFFECTS: f32 = 0.5; +/// Fog layer Z (used by weather system). +pub const Z_FOG: f32 = 1.0; +const Z_HUD: f32 = 2.0; + +/// Per-player visual state for squash/stretch animation. +struct PlayerVisualState { + prev_anim: breakpoint_platformer::physics::AnimState, + time_since_transition: f32, + was_falling: bool, +} + +/// Global visual state tracker per player ID. +fn visual_states() -> &'static Mutex> { + static STATES: OnceLock>> = OnceLock::new(); + STATES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Cached tile sprite data: (SpriteRegion, room_tint_rgb). +/// Avoids per-frame HashMap lookups and bitmask recomputation for static tiles. +struct TileCache { + /// Flat array indexed by ty * width + tx. None = not cached (animated/water/empty tile). + entries: Vec>, + width: u32, + height: u32, +} + +impl TileCache { + fn new() -> Self { + Self { + entries: Vec::new(), + width: 0, + height: 0, + } + } + + /// Get cached data for a tile, or None if not cached. + fn get(&self, tx: i32, ty: i32) -> Option<&(SpriteRegion, [f32; 3])> { + if tx < 0 || ty < 0 || tx as u32 >= self.width || ty as u32 >= self.height { + return None; + } + let idx = ty as u32 * self.width + tx as u32; + self.entries.get(idx as usize).and_then(|e| e.as_ref()) + } + + /// Rebuild the cache for a new course. + fn rebuild(&mut self, course: &breakpoint_platformer::course_gen::Course) { + use breakpoint_platformer::course_gen::Tile; + self.width = course.width; + self.height = course.height; + let total = (self.width * self.height) as usize; + self.entries.clear(); + self.entries.resize(total, None); + + let sheet = atlas(); + for ty in 0..self.height as i32 { + for tx in 0..self.width as i32 { + let tile = course.get_tile(tx, ty); + // Only cache static (non-animated) tile sprite regions + let region = match &tile { + Tile::Empty | Tile::PowerUpSpawn | Tile::Water => continue, + Tile::StoneBrick => Some(stone_brick_region(sheet, course, tx, ty)), + Tile::Platform => Some(sheet.get_or_default("platform_0")), + Tile::Spikes => Some(sheet.get_or_default("spikes_0")), + Tile::Checkpoint => Some(sheet.get_or_default("checkpoint_flag_down_0")), + Tile::Ladder => Some(sheet.get_or_default("ladder")), + Tile::BreakableWall => Some(sheet.get_or_default("breakable_wall_0")), + Tile::DecoStainedGlass => Some(sheet.get_or_default("stained_glass")), + Tile::DecoCobweb => Some(sheet.get_or_default("cobweb")), + // Animated tiles can't be cached (depend on time) + Tile::Finish | Tile::DecoTorch | Tile::DecoChain => continue, + }; + if let Some(region) = region { + let rt = room_tile_tint(course.room_theme_at_tile(tx, ty)); + let idx = (ty as u32 * self.width + tx as u32) as usize; + self.entries[idx] = Some((region, rt)); + } + } + } + } +} + +/// Global tile cache — rebuilt when the course changes. +fn tile_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(TileCache::new())) +} + +/// Hit freeze state for anime-style impact pauses. +struct HitFreezeState { + /// Remaining freeze time in seconds. + remaining: f32, + /// Previous frame's enemy alive states, keyed by enemy ID. + prev_enemy_alive: HashMap, +} + +/// Global hit freeze tracker. +fn hit_freeze() -> &'static Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| { + Mutex::new(HitFreezeState { + remaining: 0.0, + prev_enemy_alive: HashMap::new(), + }) + }) +} + +/// Duration of hit freeze in seconds (~2 frames at 60fps). +const HIT_FREEZE_DURATION: f32 = 0.033; + +/// Compute squash/stretch scale for a player based on their movement state. +fn squash_stretch_scale( + player: &breakpoint_platformer::physics::PlatformerPlayerState, + pid: u64, + dt: f32, +) -> (f32, f32) { + use breakpoint_platformer::physics::AnimState; + + let mut states = visual_states().lock().unwrap_or_else(|e| e.into_inner()); + let vs = states.entry(pid).or_insert_with(|| PlayerVisualState { + prev_anim: player.anim_state, + time_since_transition: 0.0, + was_falling: false, + }); -/// Sync the 3D scene with the current platformer game state. -pub fn sync_platformer_scene(scene: &mut Scene, active: &ActiveGame, theme: &Theme, _dt: f32) { + // Detect state transitions + if vs.prev_anim != player.anim_state { + vs.was_falling = vs.prev_anim == AnimState::Fall; + vs.prev_anim = player.anim_state; + vs.time_since_transition = 0.0; + } else { + vs.time_since_transition += dt; + } + + let is_running = player.anim_state == AnimState::Walk + && player.active_powerup == Some(breakpoint_platformer::powerups::PowerUpKind::SpeedBoots); + + match player.anim_state { + AnimState::Jump => (0.85, 1.2), // Stretch upward + AnimState::Fall => (0.9, 1.15), // Slight stretch + AnimState::Idle if vs.was_falling && vs.time_since_transition < 0.15 => { + // Landing squash with spring-back + let t = vs.time_since_transition / 0.15; + let squash = 1.0 + (1.0 - t) * 0.12; + let stretch = 1.0 - (1.0 - t) * 0.15; + (squash, stretch) + }, + AnimState::Walk if is_running => { + // More pronounced bob when running + let bob = (player.anim_time * 16.0).sin() * 0.04; + (1.0 + bob, 1.0 - bob * 0.5) + }, + AnimState::Walk => { + // Subtle sine bob + let bob = (player.anim_time * 12.0).sin() * 0.03; + (1.0 + bob, 1.0 - bob) + }, + _ => (1.0, 1.0), + } +} + +/// Cached sprite sheet — built once on first call. +fn atlas() -> &'static SpriteSheet { + static SHEET: OnceLock = OnceLock::new(); + SHEET.get_or_init(build_platformer_atlas) +} + +/// Cached animation table — built once on first call. +fn animations() -> &'static HashMap<&'static str, SpriteAnimation> { + static ANIMS: OnceLock> = OnceLock::new(); + ANIMS.get_or_init(|| build_platformer_animations(atlas())) +} + +/// Sprite placement parameters. +struct SpriteParams { + x: f32, + y: f32, + z: f32, + w: f32, + h: f32, + tint: Vec4, + flip_x: bool, + outline: f32, + blend_mode: crate::scene::BlendMode, +} + +/// Helper: add a sprite quad from a SpriteRegion directly. +fn add_sprite_region(scene: &mut Scene, region: &SpriteRegion, params: &SpriteParams) { + add_sprite_region_with_dissolve(scene, region, params, 0.0); +} + +/// Helper: add a sprite quad with dissolve effect. +fn add_sprite_region_with_dissolve( + scene: &mut Scene, + region: &SpriteRegion, + params: &SpriteParams, + dissolve: f32, +) { + scene.add( + MeshType::Quad, + MaterialType::Sprite { + atlas_id: ATLAS_ID, + sprite_rect: region.to_vec4(), + tint: params.tint, + flip_x: params.flip_x, + dissolve, + outline: params.outline, + blend_mode: params.blend_mode, + }, + Transform::from_xyz(params.x, params.y, params.z) + .with_scale(Vec3::new(params.w, params.h, 1.0)), + ); +} + +/// Helper: add a sprite quad by name (defaults: z=Z_BG_TILES, no outline, normal blend). +fn add_sprite(scene: &mut Scene, name: &str, x: f32, y: f32, w: f32, h: f32, tint: Vec4) { + let region = atlas().get_or_default(name); + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x, + y, + z: Z_BG_TILES, + w, + h, + tint, + flip_x: false, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); +} + +/// Get the sprite region for a player animation frame. +/// Uses full player state to select contextual animations (run, wall-slide, etc.) +fn player_sprite_region( + player: &breakpoint_platformer::physics::PlatformerPlayerState, + course: &breakpoint_platformer::course_gen::Course, +) -> SpriteRegion { + use breakpoint_platformer::physics::{AnimState, PLAYER_WIDTH, TILE_SIZE}; + use breakpoint_platformer::powerups::PowerUpKind; + + let anims = animations(); + let key = match player.anim_state { + AnimState::Idle => "player_idle", + AnimState::Walk => { + if player.active_powerup == Some(PowerUpKind::SpeedBoots) { + "player_run" + } else { + "player_walk" + } + }, + AnimState::Jump => "player_jump", + AnimState::Fall => { + // Detect wall-slide: falling while touching a solid wall + let half_w = PLAYER_WIDTH / 2.0; + let tx_l = ((player.x - half_w - 0.05) / TILE_SIZE).floor() as i32; + let tx_r = ((player.x + half_w + 0.05) / TILE_SIZE).floor() as i32; + let ty = (player.y / TILE_SIZE).floor() as i32; + let touching_wall = breakpoint_platformer::physics::is_solid(course.get_tile(tx_l, ty)) + || breakpoint_platformer::physics::is_solid(course.get_tile(tx_r, ty)); + if touching_wall && player.vy < -0.5 { + "player_wall_slide" + } else { + "player_fall" + } + }, + AnimState::Attack => "player_attack", + AnimState::Hurt => "player_hurt", + AnimState::Dead => "player_dead", + }; + // Fall back to the base key if the contextual animation doesn't exist + match anims.get(key) { + Some(anim) => *anim.frame_at(player.anim_time), + None => { + // Fallback chain: try base state, then default + let fallback = match player.anim_state { + AnimState::Walk => "player_walk", + AnimState::Fall => "player_fall", + _ => "player_idle", + }; + anims + .get(fallback) + .map(|a| *a.frame_at(player.anim_time)) + .unwrap_or_else(|| atlas().get_or_default("player_idle_0")) + }, + } +} + +/// Simplified player_sprite_region for cases without course context (death respawn). +fn player_sprite_region_simple( + anim_state: &breakpoint_platformer::physics::AnimState, + anim_time: f32, +) -> SpriteRegion { + use breakpoint_platformer::physics::AnimState; + let anims = animations(); + let key = match anim_state { + AnimState::Idle => "player_idle", + AnimState::Walk => "player_walk", + AnimState::Jump => "player_jump", + AnimState::Fall => "player_fall", + AnimState::Attack => "player_attack", + AnimState::Hurt => "player_hurt", + AnimState::Dead => "player_dead", + }; + match anims.get(key) { + Some(anim) => *anim.frame_at(anim_time), + None => atlas().get_or_default("player_idle_0"), + } +} + +/// Get the sprite region for an enemy animation frame. +fn enemy_sprite_region( + etype: &breakpoint_platformer::enemies::EnemyType, + anim_time: f32, + alive: bool, + respawn_timer: f32, +) -> Option { + use breakpoint_platformer::enemies::EnemyType; + let anims = animations(); + + if !alive { + // Show death animation for the first 0.6s after death + let death_time = breakpoint_platformer::enemies::RESPAWN_DELAY - respawn_timer; + if death_time > 0.6 { + return None; // Vanished + } + let key = match etype { + EnemyType::Skeleton => "skeleton_death", + EnemyType::Bat => "bat_death", + EnemyType::Knight => "knight_death", + EnemyType::Medusa => "medusa_death", + EnemyType::Ghost => "ghost_death", + EnemyType::Gargoyle => "gargoyle_death", + }; + return anims.get(key).map(|a| *a.frame_at(death_time)); + } + + let key = match etype { + EnemyType::Skeleton => "skeleton_walk", + EnemyType::Bat => "bat_fly", + EnemyType::Knight => "knight_walk", + EnemyType::Medusa => "medusa_float", + EnemyType::Ghost => "ghost_drift", + EnemyType::Gargoyle => "gargoyle_perch", + }; + anims.get(key).map(|a| *a.frame_at(anim_time)) +} + +/// Map tile type to sprite name, with auto-tiling for stone bricks. +/// Returns true if this tile should be rendered as a water material (not sprite). +fn is_water_tile(tile: &breakpoint_platformer::course_gen::Tile) -> bool { + matches!(tile, breakpoint_platformer::course_gen::Tile::Water) +} + +fn tile_sprite_region( + tile: &breakpoint_platformer::course_gen::Tile, + course: &breakpoint_platformer::course_gen::Course, + tx: i32, + ty: i32, + time: f32, +) -> Option { + use breakpoint_platformer::course_gen::Tile; + let sheet = atlas(); + + match tile { + Tile::Empty | Tile::PowerUpSpawn | Tile::Water => None, + Tile::StoneBrick => Some(stone_brick_region(sheet, course, tx, ty)), + Tile::Platform => Some(sheet.get_or_default("platform_0")), + Tile::Spikes => Some(sheet.get_or_default("spikes_0")), + Tile::Checkpoint => Some(sheet.get_or_default("checkpoint_flag_down_0")), + Tile::Finish => { + let anims = animations(); + anims + .get("finish_gate") + .map(|a| *a.frame_at(time)) + .or_else(|| Some(sheet.get_or_default("finish_gate_0"))) + }, + Tile::Ladder => Some(sheet.get_or_default("ladder")), + Tile::BreakableWall => Some(sheet.get_or_default("breakable_wall_0")), + Tile::DecoTorch => { + // Animated torch with per-tile phase offset + let phase = tx as f32 * 0.3 + ty as f32 * 0.7; + let anims = animations(); + anims + .get("torch") + .map(|a| *a.frame_at(time + phase)) + .or_else(|| Some(sheet.get_or_default("torch_0"))) + }, + Tile::DecoStainedGlass => Some(sheet.get_or_default("stained_glass")), + Tile::DecoCobweb => Some(sheet.get_or_default("cobweb")), + Tile::DecoChain => { + let phase = tx as f32 * 0.5 + ty as f32 * 1.1; + let anims = animations(); + anims + .get("chain") + .map(|a| *a.frame_at(time + phase)) + .or_else(|| Some(sheet.get_or_default("chain_0"))) + }, + } +} + +/// Auto-tile selection for stone bricks: 16-tile bitmask with per-room theme groups. +fn stone_brick_region( + sheet: &SpriteSheet, + course: &breakpoint_platformer::course_gen::Course, + tx: i32, + ty: i32, +) -> SpriteRegion { + let mask = stone_brick_bitmask(course, tx, ty); + let room_theme = course.room_theme_at_tile(tx, ty); + let group = room_theme_to_tile_group(&room_theme); + let name = bitmask_tile_for_group(group, mask); + sheet.get_or_default(name) +} + +/// Map power-up kind to sprite name. +fn powerup_sprite_name(kind: &breakpoint_platformer::powerups::PowerUpKind) -> &'static str { + use breakpoint_platformer::powerups::PowerUpKind; + match kind { + PowerUpKind::HolyWater => "powerup_holy_water", + PowerUpKind::Crucifix => "powerup_crucifix", + PowerUpKind::SpeedBoots => "powerup_speed_boots", + PowerUpKind::DoubleJump => "powerup_double_jump", + PowerUpKind::ArmorUp => "powerup_armor", + PowerUpKind::Invincibility => "powerup_invincibility", + PowerUpKind::WhipExtend => "powerup_whip_extend", + } +} + +/// Sync the scene with the current platformer game state using flat sprites. +pub fn sync_platformer_scene( + scene: &mut Scene, + active: &ActiveGame, + theme: &Theme, + dt: f32, + camera_x: f32, + camera_y: f32, + time: f32, +) { let state: Option = read_game_state(active); let Some(state) = state else { return; }; + // Hit freeze: detect enemy kills and pause rendering for impact weight. + { + let mut freeze = hit_freeze().lock().unwrap_or_else(|e| e.into_inner()); + if freeze.remaining > 0.0 { + freeze.remaining -= dt; + if freeze.remaining > 0.0 { + // Keep previous frame's scene — don't clear or rebuild. + return; + } + } + // Check for new enemy kills (alive→dead transitions). + let mut triggered = false; + for enemy in &state.enemies { + let was_alive = freeze + .prev_enemy_alive + .get(&enemy.id) + .copied() + .unwrap_or(true); + if was_alive && !enemy.alive { + triggered = true; + } + freeze.prev_enemy_alive.insert(enemy.id, enemy.alive); + } + if triggered { + freeze.remaining = HIT_FREEZE_DURATION; + } + } + scene.clear(); let tile_size = breakpoint_platformer::physics::TILE_SIZE; + // Rebuild tile cache if course changed (new game or first frame) + { + let mut cache = tile_cache().lock().unwrap_or_else(|e| e.into_inner()); + if cache.width != state.course.width || cache.height != state.course.height { + cache.rebuild(&state.course); + } + } + + // Determine camera room theme for background and lighting + let _camera_theme = state + .course + .room_theme_at_tile((camera_x / tile_size) as i32, (camera_y / tile_size) as i32); + + // Parallax background layers disabled — no dedicated background content in sprite atlas. + // add_parallax_layers(scene, camera_x, camera_y, camera_theme); + let white = Vec4::ONE; + + // Tile culling: only render visible columns and rows. + // Camera is at z=20, FOV=45°: visible half-width ≈ 15.5, half-height ≈ 8.7 at z=0. + // Add 2-tile margin for smooth scrolling. + let visible_half_x = 17.0; + let visible_half_y = 11.0; + let min_col = ((camera_x - visible_half_x) / tile_size).floor().max(0.0) as u32; + let max_col = ((camera_x + visible_half_x) / tile_size) + .ceil() + .min(state.course.width as f32) as u32; + let min_row = ((camera_y - visible_half_y) / tile_size).floor().max(0.0) as u32; + let max_row = ((camera_y + visible_half_y) / tile_size) + .ceil() + .min(state.course.height as f32) as u32; + + // Collect torch lights for dynamic lighting + scene.lighting = collect_torch_lights( + &state, + tile_size, + min_col, + max_col, + min_row, + max_row, + time, + theme.platformer.torch_ambient, + ); + // Render course tiles - for y in 0..state.course.height { - for x in 0..state.course.width { - let tile = state.course.get_tile(x as i32, y as i32); - let color = match tile { - breakpoint_platformer::course_gen::Tile::Empty => continue, - breakpoint_platformer::course_gen::Tile::PowerUpSpawn => continue, - breakpoint_platformer::course_gen::Tile::Solid => { - rgb_vec4(&theme.platformer.solid_tile) - }, - breakpoint_platformer::course_gen::Tile::Platform => { - rgb_vec4(&theme.platformer.platform_tile) - }, - breakpoint_platformer::course_gen::Tile::Hazard => { - rgb_vec4(&theme.platformer.hazard_tile) - }, - breakpoint_platformer::course_gen::Tile::Checkpoint => { - Vec4::new(0.2, 0.8, 0.2, 1.0) - }, - breakpoint_platformer::course_gen::Tile::Finish => { - rgb_vec4(&theme.platformer.finish_tile) - }, - }; + let wc = &theme.platformer.water_color; + let water_color = Vec4::new(wc[0], wc[1], wc[2], wc[3]); + render_tiles( + scene, + &state, + tile_size, + min_col, + max_col, + min_row, + max_row, + time, + water_color, + ); + + // God rays for Chapel rooms (from stained glass light sources) + render_godrays(scene, &state, tile_size, camera_x, camera_y); + + // Render enemies + render_enemies(scene, &state, tile_size, theme, time); + + // Render enemy projectiles + render_projectiles(scene, &state, tile_size, time); + + // Render players + render_players(scene, &state, tile_size, white, time, dt); + + // Render uncollected powerups + render_powerups(scene, &state, tile_size, white); +} + +/// Per-room tile tint for atmospheric coloring of stone/brick surfaces. +fn room_tile_tint(theme: breakpoint_platformer::course_gen::RoomTheme) -> [f32; 3] { + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + // Castle Interior rooms: gray-mauve stone (>1.0 boosts sprite brightness) + RoomTheme::Entrance + | RoomTheme::Corridor + | RoomTheme::GreatHall + | RoomTheme::ThroneRoom => [1.20, 1.10, 1.25], + // Underground rooms: teal-green stone + RoomTheme::Crypt | RoomTheme::Dungeon => [0.90, 1.10, 1.00], + // Sacred rooms: warm sandstone + RoomTheme::Chapel | RoomTheme::Library => [1.30, 1.15, 0.95], + // Fortress rooms: blue-gray steel + RoomTheme::Armory | RoomTheme::Tower => [1.05, 1.05, 1.15], + } +} + +/// Render course tiles within the visible column and row range. +#[allow(clippy::too_many_arguments)] +fn render_tiles( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + min_col: u32, + max_col: u32, + min_row: u32, + max_row: u32, + time: f32, + water_color: Vec4, +) { + let cache = tile_cache().lock().unwrap_or_else(|e| e.into_inner()); + + for y in min_row..max_row { + for x in min_col..max_col { + let tx = x as i32; + let ty = y as i32; + let tile = state.course.get_tile(tx, ty); + + // Water tiles use a special material + if is_water_tile(&tile) { + let wx = x as f32 * tile_size + tile_size / 2.0; + let wy = y as f32 * tile_size + tile_size / 2.0; + let above = state.course.get_tile(tx, ty + 1); + let depth = if is_water_tile(&above) { 0.8 } else { 0.4 }; + scene.add( + MeshType::Quad, + MaterialType::Water { + color: water_color, + depth, + wave_speed: 3.0, + }, + Transform::from_xyz(wx, wy, Z_WATER) + .with_scale(Vec3::new(tile_size, tile_size, 1.0)), + ); + continue; + } + let wx = x as f32 * tile_size + tile_size / 2.0; let wy = y as f32 * tile_size + tile_size / 2.0; - scene.add( - MeshType::Cuboid, - MaterialType::Unlit { color }, - Transform::from_xyz(wx, wy, 0.0) - .with_scale(Vec3::new(tile_size, tile_size, tile_size)), + + // Try cache first (static tiles: stone, platform, spikes, etc.) + if let Some((region, rt)) = cache.get(tx, ty) { + let tint = Vec4::new(rt[0], rt[1], rt[2], 1.0); + add_sprite_region( + scene, + region, + &SpriteParams { + x: wx, + y: wy, + z: Z_BG_TILES, + w: tile_size, + h: tile_size, + tint, + flip_x: false, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); + continue; + } + + // Animated or uncached tiles: compute per-frame + let Some(region) = tile_sprite_region(&tile, &state.course, tx, ty, time) else { + continue; + }; + let rt = room_tile_tint(state.course.room_theme_at_tile(tx, ty)); + let tint = Vec4::new(rt[0], rt[1], rt[2], 1.0); + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: wx, + y: wy, + z: Z_BG_TILES, + w: tile_size, + h: tile_size, + tint, + flip_x: false, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, ); } } +} - // Render hazard line (survival mode rising hazard) - if state.hazard_y > 0.0 { - let course_width = state.course.width as f32 * tile_size; +/// Render enemies with animation-driven sprites and death effects. +fn render_enemies( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + theme: &Theme, + _time: f32, +) { + let enemy_tint = Vec4::new( + theme.platformer.enemy_tint[0], + theme.platformer.enemy_tint[1], + theme.platformer.enemy_tint[2], + 1.0, + ); + for enemy in &state.enemies { + let Some(region) = enemy_sprite_region( + &enemy.enemy_type, + enemy.anim_time, + enemy.alive, + enemy.respawn_timer, + ) else { + continue; + }; + // Dissolve dying enemies instead of simple alpha fade + let (tint, dissolve) = if !enemy.alive { + let death_time = breakpoint_platformer::enemies::RESPAWN_DELAY - enemy.respawn_timer; + let dissolve_amount = (death_time / 0.6).clamp(0.0, 1.0); + (enemy_tint, dissolve_amount) + } else { + (enemy_tint, 0.0) + }; + // Shadow underneath enemy + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: enemy.x, + y: enemy.y - tile_size * 0.4, + z: Z_SHADOWS, + w: tile_size * 1.2, + h: tile_size * 2.0 * 0.3, + tint: Vec4::new(0.0, 0.0, 0.0, 0.35), + flip_x: !enemy.facing_right, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); + // Enemy sprite + add_sprite_region_with_dissolve( + scene, + ®ion, + &SpriteParams { + x: enemy.x, + y: enemy.y, + z: Z_ENEMIES, + w: tile_size, + h: tile_size * 2.0, + tint, + flip_x: !enemy.facing_right, + outline: 1.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + dissolve, + ); + } +} + +/// Render enemy projectiles with trailing afterimages and glow. +fn render_projectiles( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + time: f32, +) { + let anims = animations(); + for proj in &state.projectiles { + let region = anims + .get("projectile") + .map(|a| *a.frame_at(time)) + .unwrap_or_else(|| atlas().get_or_default("projectile_0")); + + // Trailing afterimages (3 behind projectile direction) + let dx = proj.vx.signum(); + for i in 1..=3u8 { + let offset = f32::from(i) * tile_size * 0.15 * -dx; + let alpha = 0.25 - f32::from(i) * 0.07; + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: proj.x + offset, + y: proj.y, + z: Z_EFFECTS, + w: tile_size * 0.5, + h: tile_size * 0.5, + tint: Vec4::new(0.4, 0.9, 0.3, alpha), + flip_x: false, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Additive, + }, + ); + } + + // Glow aura behind projectile scene.add( - MeshType::Cuboid, + MeshType::Quad, MaterialType::Glow { - color: Vec4::new(1.0, 0.15, 0.0, 0.6), - intensity: 2.0, + color: Vec4::new(0.3, 0.7, 0.2, 0.4), + intensity: 1.2, }, - Transform::from_xyz(course_width / 2.0, state.hazard_y, 0.0).with_scale(Vec3::new( - course_width, - 0.1, + Transform::from_xyz(proj.x, proj.y, -0.05).with_scale(Vec3::new( + tile_size * 0.8, + tile_size * 0.8, 1.0, )), ); + + // Main projectile sprite + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: proj.x, + y: proj.y, + z: Z_EFFECTS, + w: tile_size * 0.5, + h: tile_size * 0.5, + tint: Vec4::new(0.4, 0.9, 0.3, 1.0), + flip_x: false, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); } +} - // Render players as colored boxes +/// Get per-player color palette tint based on player index. +fn player_palette(pid: u64) -> Vec4 { + let idx = (pid as usize) % PLAYER_PALETTES.len(); + let [r, g, b] = PLAYER_PALETTES[idx]; + Vec4::new(r, g, b, 1.0) +} + +/// Render players with animation-based sprites, VFX, and HP hearts. +fn render_players( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + white: Vec4, + time: f32, + dt: f32, +) { for (pid, player) in &state.players { if player.eliminated { continue; } - let color = Vec4::new( - ((*pid * 37) % 255) as f32 / 255.0, - ((*pid * 73) % 255) as f32 / 255.0, - ((*pid * 113) % 255) as f32 / 255.0, - 1.0, + + // Death/respawn: fade-in during last 0.3s before respawn + if player.death_respawn_timer > 0.0 { + render_death_respawn(scene, player, tile_size); + continue; + } + + // Golden pulsing tint during invincibility (instead of blink-skip) + let inv_tint = if player.invincibility_timer > 0.0 { + let alpha = 0.5 + 0.3 * (player.invincibility_timer * 8.0).sin(); + Some(Vec4::new(1.0, 0.9, 0.5, alpha)) + } else { + None + }; + + let region = player_sprite_region(player, &state.course); + let base_tint = player_palette(*pid); + let tint = inv_tint.unwrap_or(base_tint); + + // Squash/stretch scaling based on movement state + let (sx, sy) = squash_stretch_scale(player, *pid, dt); + + // Shadow underneath player + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: player.x, + y: player.y - tile_size * 0.4, + z: Z_SHADOWS, + w: tile_size * 1.2, + h: tile_size * 2.0 * 0.3, + tint: Vec4::new(0.0, 0.0, 0.0, 0.35), + flip_x: !player.facing_right, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, ); + + // 16x32 sprites: render at 2.0x tile height + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: player.x, + y: player.y, + z: Z_PLAYERS, + w: tile_size * sx, + h: tile_size * 2.0 * sy, + tint, + flip_x: !player.facing_right, + outline: 1.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); + + render_player_effects(scene, player, tile_size, time, &state.course); + render_player_hearts(scene, player, *pid, tile_size, white); + } +} + +/// Render death/respawn transition: fade-in during last 0.3s before respawn. +fn render_death_respawn( + scene: &mut Scene, + player: &breakpoint_platformer::physics::PlatformerPlayerState, + tile_size: f32, +) { + if player.death_respawn_timer >= 0.3 { + return; // Still fully dead, don't render + } + let fade_alpha = 1.0 - (player.death_respawn_timer / 0.3); + let region = player_sprite_region_simple(&player.anim_state, player.anim_time); + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: player.x, + y: player.y, + z: Z_PLAYERS, + w: tile_size, + h: tile_size * 2.0, + tint: Vec4::new(1.0, 1.0, 1.0, fade_alpha), + flip_x: !player.facing_right, + outline: 1.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + ); +} + +/// Render VFX for a player: attack trail, speed boots trail, invincibility glow. +fn render_player_effects( + scene: &mut Scene, + player: &breakpoint_platformer::physics::PlatformerPlayerState, + tile_size: f32, + time: f32, + course: &breakpoint_platformer::course_gen::Course, +) { + use breakpoint_platformer::physics::AnimState; + use breakpoint_platformer::powerups::PowerUpKind; + + // Anime-style slash arc during attack + if player.anim_state == AnimState::Attack { + let attack_duration = 0.35; // matches game ATTACK_DURATION + let progress = (player.anim_time / attack_duration).clamp(0.0, 1.0); + let dir = if player.facing_right { 1.0 } else { -1.0 }; + let angle = if player.facing_right { + -0.5 // Sweep from upper-right + } else { + std::f32::consts::PI - 0.5 // Mirrored for left-facing + }; scene.add( - MeshType::Cuboid, - MaterialType::Unlit { color }, - Transform::from_xyz(player.x, player.y, 0.0).with_scale(Vec3::new(0.8, 0.8, 0.8)), + MeshType::Quad, + MaterialType::SlashArc { + progress, + angle, + color: Vec4::new(1.0, 0.7, 0.3, 0.9), + }, + Transform::from_xyz( + player.x + dir * tile_size * 0.5, + player.y + tile_size * 0.2, + 0.15, + ) + .with_scale(Vec3::new(tile_size * 2.2, tile_size * 2.2, 1.0)), ); } - // Render uncollected powerups + // Magic circle when activating Holy Water or Crucifix power-ups + if player.powerup_timer > 0.0 { + let is_magic_powerup = matches!( + player.active_powerup, + Some(PowerUpKind::HolyWater) | Some(PowerUpKind::Crucifix) + ); + if is_magic_powerup { + let circle_color = match player.active_powerup { + Some(PowerUpKind::HolyWater) => Vec4::new(0.15, 0.4, 0.9, 0.7), + Some(PowerUpKind::Crucifix) => Vec4::new(1.0, 0.9, 0.4, 0.8), + _ => Vec4::new(1.0, 1.0, 1.0, 0.5), + }; + scene.add( + MeshType::Quad, + MaterialType::MagicCircle { + rotation: time * 2.0, + pulse: (time * 4.0).sin() * 0.5 + 0.5, + color: circle_color, + }, + Transform::from_xyz(player.x, player.y - tile_size * 0.3, 0.12) + .with_scale(Vec3::new(tile_size * 2.0, tile_size * 2.0, 1.0)), + ); + } + } + + // Speed boots trail: 4 trailing afterimages with green tint + if player.active_powerup == Some(PowerUpKind::SpeedBoots) { + let region = player_sprite_region(player, course); + let dir = if player.facing_right { -1.0 } else { 1.0 }; + for i in 1..=4u8 { + let offset = f32::from(i) * tile_size * 0.2 * dir; + let alpha = (0.25 - f32::from(i) * 0.05).max(0.03); + add_sprite_region( + scene, + ®ion, + &SpriteParams { + x: player.x + offset, + y: player.y, + z: Z_EFFECTS, + w: tile_size, + h: tile_size * 2.0, + tint: Vec4::new(1.0, 0.6, 0.2, alpha), + flip_x: !player.facing_right, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Additive, + }, + ); + } + } + + // Invincibility glow: pulsing Glow quad behind player + if player.invincibility_timer > 0.0 { + let pulse = 0.5 + 0.3 * (time * 6.0).sin(); + scene.add( + MeshType::Quad, + MaterialType::Glow { + color: Vec4::new(1.0, 0.85, 0.3, pulse), + intensity: 1.5, + }, + Transform::from_xyz(player.x, player.y, -0.1).with_scale(Vec3::new( + tile_size * 1.5, + tile_size * 3.0, + 1.0, + )), + ); + } +} + +/// Render animated health bar above a player (replacing floating hearts). +fn render_player_hearts( + scene: &mut Scene, + player: &breakpoint_platformer::physics::PlatformerPlayerState, + pid: u64, + tile_size: f32, + _white: Vec4, +) { + if player.max_hp == 0 { + return; + } + let fill = player.hp as f32 / player.max_hp as f32; + let bar_y = player.y + tile_size * 1.6; + let bar_w = tile_size * 1.0; + let bar_h = tile_size * 0.15; + // Color based on fill level: green -> yellow -> red + let bar_color = if fill > 0.6 { + Vec4::new(0.3, 0.9, 0.3, 0.9) + } else if fill > 0.3 { + Vec4::new(0.9, 0.8, 0.2, 0.9) + } else { + Vec4::new(0.9, 0.2, 0.2, 0.9) + }; + // Tint with player palette + let palette = player_palette(pid); + let final_color = Vec4::new( + bar_color.x * palette.x, + bar_color.y * palette.y, + bar_color.z * palette.z, + bar_color.w, + ); + scene.add( + MeshType::Quad, + MaterialType::HealthBar { + fill, + color: final_color, + }, + Transform::from_xyz(player.x, bar_y, Z_HUD).with_scale(Vec3::new(bar_w, bar_h, 1.0)), + ); +} + +/// Render uncollected powerups. +fn render_powerups( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + white: Vec4, +) { for pu in &state.powerups { if pu.collected { continue; } - let color = match pu.kind { - breakpoint_platformer::powerups::PowerUpKind::SpeedBoost => { - Vec4::new(1.0, 0.8, 0.0, 1.0) - }, - breakpoint_platformer::powerups::PowerUpKind::DoubleJump => { - Vec4::new(0.0, 0.8, 1.0, 1.0) - }, - breakpoint_platformer::powerups::PowerUpKind::Shield => Vec4::new(0.5, 0.5, 1.0, 1.0), - breakpoint_platformer::powerups::PowerUpKind::Magnet => Vec4::new(1.0, 0.3, 0.3, 1.0), - }; + let sprite_name = powerup_sprite_name(&pu.kind); + add_sprite( + scene, + sprite_name, + pu.x, + pu.y, + tile_size * 0.8, + tile_size * 0.8, + white, + ); + } +} + +/// Render god rays for Chapel rooms (stained glass light beams). +fn render_godrays( + scene: &mut Scene, + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + camera_x: f32, + camera_y: f32, +) { + use breakpoint_platformer::course_gen::{RoomTheme, Tile}; + + // Only render god rays in Chapel rooms + let center_theme = state + .course + .room_theme_at_tile((camera_x / tile_size) as i32, (camera_y / tile_size) as i32); + if !matches!(center_theme, RoomTheme::Chapel) { + return; + } + + // Find torch positions in the visible area (these act as stained glass windows) + let half_x = 12.0; + let half_y = 10.0; + let min_col = ((camera_x - half_x) / tile_size).floor().max(0.0) as u32; + let max_col = ((camera_x + half_x) / tile_size) + .ceil() + .min(state.course.width as f32) as u32; + let min_row = ((camera_y - half_y) / tile_size).floor().max(0.0) as u32; + let max_row = ((camera_y + half_y) / tile_size) + .ceil() + .min(state.course.height as f32) as u32; + + let mut count = 0u8; + for y in min_row..max_row { + for x in min_col..max_col { + if count >= 4 { + return; + } + if state.course.get_tile(x as i32, y as i32) == Tile::DecoTorch { + let wx = x as f32 * tile_size + tile_size / 2.0; + let wy = y as f32 * tile_size + tile_size / 2.0; + // God ray quad below the window, angled down + scene.add( + MeshType::Quad, + MaterialType::GodRays { + intensity: 0.3, + color: Vec4::new(1.0, 0.9, 0.6, 0.25), + }, + Transform::from_xyz(wx, wy - tile_size * 2.0, 0.15).with_scale(Vec3::new( + tile_size * 3.0, + tile_size * 6.0, + 1.0, + )), + ); + count += 1; + } + } + } +} + +/// Per-room ambient color (RGB) for atmospheric tinting. +fn room_ambient_color(theme: breakpoint_platformer::course_gen::RoomTheme) -> [f32; 3] { + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + RoomTheme::Entrance => [0.90, 0.78, 0.55], // warm amber + RoomTheme::Corridor => [0.72, 0.70, 0.68], // dim stone + RoomTheme::GreatHall => [0.85, 0.72, 0.50], // golden + RoomTheme::Library => [0.78, 0.68, 0.50], // warm brown + RoomTheme::Armory => [0.78, 0.50, 0.32], // forge red + RoomTheme::Chapel => [0.85, 0.75, 0.50], // sacred gold + RoomTheme::Crypt => [0.50, 0.55, 0.85], // cold blue + RoomTheme::Tower => [0.75, 0.75, 0.85], // open sky + RoomTheme::Dungeon => [0.55, 0.58, 0.48], // sickly green + RoomTheme::ThroneRoom => [0.68, 0.45, 0.85], // royal purple + } +} + +/// Per-room torch light color (RGB) for colored fire. +fn torch_light_color(theme: breakpoint_platformer::course_gen::RoomTheme) -> [f32; 3] { + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + RoomTheme::Armory => [1.0, 0.5, 0.2], // forge orange + RoomTheme::Crypt => [0.4, 0.5, 1.0], // ghostly blue + RoomTheme::Chapel => [1.0, 0.9, 0.6], // warm candlelight + RoomTheme::Dungeon => [0.5, 0.8, 0.3], // sickly green + RoomTheme::ThroneRoom => [0.8, 0.5, 1.0], // royal purple + _ => [1.0, 0.65, 0.3], // distinctly orange fire + } +} + +/// Per-room color grading: (shadow_tint, highlight_tint, contrast, saturation). +fn room_color_grading( + theme: breakpoint_platformer::course_gen::RoomTheme, +) -> ([f32; 3], [f32; 3], f32, f32) { + use breakpoint_platformer::course_gen::RoomTheme; + // Neutral contrast (1.0), GBA-saturated (1.05), lighter shadow tints to preserve dark detail + match theme { + RoomTheme::Entrance => ([0.90, 0.85, 0.78], [1.0, 0.9, 0.75], 1.0, 1.05), + RoomTheme::Corridor => ([0.85, 0.85, 0.90], [0.9, 0.88, 0.95], 1.0, 1.0), + RoomTheme::GreatHall => ([0.90, 0.85, 0.78], [1.0, 0.9, 0.75], 1.0, 1.05), + RoomTheme::Library => ([0.88, 0.84, 0.76], [1.0, 0.85, 0.7], 1.0, 1.05), + RoomTheme::Armory => ([0.92, 0.78, 0.72], [1.0, 0.75, 0.6], 1.0, 1.05), + RoomTheme::Chapel => ([0.90, 0.86, 0.78], [1.0, 0.95, 0.8], 1.0, 1.05), + RoomTheme::Crypt => ([0.78, 0.82, 0.92], [0.8, 0.85, 1.0], 1.0, 1.0), + RoomTheme::Tower => ([0.86, 0.86, 0.92], [0.95, 0.95, 1.0], 1.0, 1.05), + RoomTheme::Dungeon => ([0.82, 0.84, 0.78], [0.85, 0.9, 0.8], 1.0, 1.0), + RoomTheme::ThroneRoom => ([0.86, 0.78, 0.92], [0.9, 0.75, 1.0], 1.0, 1.05), + } +} + +/// Per-room ambient particle type for atmospheric effects. +pub fn room_theme_ambient_type( + theme: breakpoint_platformer::course_gen::RoomTheme, +) -> crate::weather::AmbientType { + use crate::weather::AmbientType; + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + RoomTheme::Entrance | RoomTheme::Corridor | RoomTheme::GreatHall => AmbientType::DustMotes, + RoomTheme::Crypt | RoomTheme::Dungeon => AmbientType::DustMotes, + RoomTheme::Chapel => AmbientType::GoldenSparkles, + RoomTheme::Armory => AmbientType::Embers, + RoomTheme::Tower => AmbientType::Snowflakes, + RoomTheme::Library => AmbientType::FloatingPages, + RoomTheme::ThroneRoom => AmbientType::Embers, + } +} + +/// Per-room weather configuration: (raining, fog_density, fog_color_rgb). +pub fn room_theme_weather( + theme: breakpoint_platformer::course_gen::RoomTheme, +) -> (bool, f32, [f32; 3]) { + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + RoomTheme::Tower => (true, 0.0, [0.10, 0.10, 0.15]), // open sky — rain, no fog + RoomTheme::Crypt => (false, 0.6, [0.08, 0.10, 0.18]), // thick cold blue fog + RoomTheme::Dungeon => (false, 0.4, [0.10, 0.12, 0.08]), // sickly green fog + RoomTheme::Corridor => (false, 0.25, [0.10, 0.08, 0.12]), // misty purple haze + RoomTheme::Entrance => (false, 0.1, [0.12, 0.10, 0.08]), // warm haze + RoomTheme::GreatHall => (false, 0.1, [0.12, 0.10, 0.08]), // warm haze + _ => (false, 0.0, [0.08, 0.06, 0.12]), // default dark purple + } +} + +#[allow(clippy::too_many_arguments)] +fn collect_torch_lights( + state: &breakpoint_platformer::PlatformerState, + tile_size: f32, + min_col: u32, + max_col: u32, + min_row: u32, + max_row: u32, + time: f32, + torch_ambient: f32, +) -> SceneLighting { + use breakpoint_platformer::course_gen::Tile; + + let mut lights: Vec<[f32; 4]> = Vec::with_capacity(32); + let mut light_colors: Vec<[f32; 4]> = Vec::with_capacity(32); + + // Determine the dominant room theme near the camera center + let center_col = (min_col + max_col) / 2; + let center_row = (min_row + max_row) / 2; + let center_theme = state + .course + .room_theme_at_tile(center_col as i32, center_row as i32); + + let torch_rgb = torch_light_color(center_theme); + + for y in min_row..max_row { + for x in min_col..max_col { + if lights.len() >= 32 { + break; + } + if state.course.get_tile(x as i32, y as i32) == Tile::DecoTorch { + let wx = x as f32 * tile_size + tile_size / 2.0; + let wy = y as f32 * tile_size + tile_size / 2.0; + // Per-torch flicker using position hash + let hash = (x as f32) * 7.3 + (y as f32) * 13.1; + let intensity = 1.8 + 0.4 * (time * 8.0 + hash).sin(); + let radius = 14.0; + lights.push([wx, wy, intensity, radius]); + light_colors.push([torch_rgb[0], torch_rgb[1], torch_rgb[2], 0.0]); + } + } + } + + // Dark atmosphere when torches are present, fully lit otherwise + let ambient = if lights.is_empty() { + 1.0 + } else { + torch_ambient + }; + + let ambient_color = room_ambient_color(center_theme); + let (grade_shadows, grade_highlights, grade_contrast, saturation) = + room_color_grading(center_theme); + + let (_, _, fog_color) = room_theme_weather(center_theme); + + SceneLighting { + lights, + light_colors, + ambient, + ambient_color, + grade_shadows, + grade_highlights, + grade_contrast, + saturation, + // GBA Castlevania color ramp: mauve shadows → bronze midtones → gold highlights + ramp_shadow: [0.40, 0.35, 0.48], + ramp_mid: [0.85, 0.68, 0.45], + ramp_highlight: [1.0, 0.90, 0.35], + posterize: 48.0, // Subtle banding for GBA-style look + fog_color, + } +} + +/// 6-layer parallax configuration: (scroll_factor, z_depth, v_start, v_height, alpha). +#[allow(dead_code)] +const PARALLAX_LAYERS: [(f32, f32, f32, f32, f32); 6] = [ + (0.05, -6.0, 0.0, 1.0 / 6.0, 0.45), // Layer 0: far sky/void + (0.15, -5.0, 1.0 / 6.0, 1.0 / 6.0, 0.55), // Layer 1: distant architecture + (0.3, -3.5, 2.0 / 6.0, 1.0 / 6.0, 0.65), // Layer 2: mid architecture + (0.6, -1.5, 3.0 / 6.0, 1.0 / 6.0, 0.75), // Layer 3: near architecture + (1.2, 0.4, 4.0 / 6.0, 1.0 / 6.0, 0.12), // Layer 4: foreground pillars + (1.5, 0.5, 5.0 / 6.0, 1.0 / 6.0, 0.08), // Layer 5: close dust/mist +]; + +/// Add parallax background layers (6 layers) to the scene. +/// Scrolls in both X and Y directions for 2D exploration. +#[allow(dead_code)] +fn add_parallax_layers( + scene: &mut Scene, + camera_x: f32, + camera_y: f32, + camera_theme: breakpoint_platformer::course_gen::RoomTheme, +) { + // Use the main sprite atlas (ID 0) — background content is there + let atlas = ATLAS_ID; + + // Pull parallax tint from the room's atmospheric theme + let ambient = room_ambient_color(camera_theme); + + for &(scroll_factor, z, v_start, v_height, alpha) in &PARALLAX_LAYERS { + let layer_y = camera_y * scroll_factor + 5.0 * (1.0 - scroll_factor); + let tint = Vec4::new(ambient[0], ambient[1], ambient[2], alpha); scene.add( - MeshType::Sphere { segments: 8 }, - MaterialType::Glow { - color, - intensity: 1.5, + MeshType::Quad, + MaterialType::Parallax { + atlas_id: atlas, + layer_rect: Vec4::new(0.0, v_start, 1.0, v_start + v_height), + scroll_factor, + tint, }, - Transform::from_xyz(pu.x, pu.y, 0.0).with_scale(Vec3::splat(0.4)), + Transform::from_xyz(camera_x, layer_y, z).with_scale(Vec3::new(50.0, 40.0, 1.0)), ); } } diff --git a/crates/breakpoint-client/src/lib.rs b/crates/breakpoint-client/src/lib.rs index c9150fa..745d591 100644 --- a/crates/breakpoint-client/src/lib.rs +++ b/crates/breakpoint-client/src/lib.rs @@ -8,10 +8,13 @@ pub mod game; mod input; pub mod net_client; pub mod overlay; +pub mod particles; mod renderer; mod scene; +pub mod sprite_atlas; mod storage; pub mod theme; +pub mod weather; use wasm_bindgen::prelude::*; diff --git a/crates/breakpoint-client/src/particles.rs b/crates/breakpoint-client/src/particles.rs new file mode 100644 index 0000000..ec95f60 --- /dev/null +++ b/crates/breakpoint-client/src/particles.rs @@ -0,0 +1,578 @@ +use glam::Vec4; + +use crate::scene::{MaterialType, MeshType, Scene, Transform}; +use crate::sprite_atlas::{SpriteRegion, SpriteSheet}; + +/// Maximum number of active particles (oldest recycled when full). +const MAX_PARTICLES: usize = 512; + +/// A single visual particle. +struct Particle { + x: f32, + y: f32, + vx: f32, + vy: f32, + lifetime: f32, + max_lifetime: f32, + sprite: SpriteRegion, + tint: Vec4, + size: f32, + gravity: f32, + active: bool, +} + +impl Default for Particle { + fn default() -> Self { + Self { + x: 0.0, + y: 0.0, + vx: 0.0, + vy: 0.0, + lifetime: 0.0, + max_lifetime: 1.0, + sprite: SpriteRegion { + u0: 0.0, + v0: 0.0, + u1: 0.0, + v1: 0.0, + }, + tint: Vec4::ONE, + size: 0.2, + gravity: 0.0, + active: false, + } + } +} + +/// Types of particle effects that can be emitted. +pub enum ParticleEffect { + DustLanding, + SparkHit, + BloodDamage, + TorchFire, + EnemyDeath, + PowerUpCollect, + CheckpointActivate, + GenericBurst { + color: Vec4, + count: u8, + }, + /// Directional sparks from whip hitting an enemy. + WhipImpact { + facing_right: bool, + }, + /// Blue droplets on water entry/exit. + WaterSplash, + /// Stone debris from broken walls. + WallBreak, + /// Tiny white pops where rain hits ground. + RainSplash, + /// Wider cloud on hard landings. + LandingDust, + /// Single orange ember from a torch (continuous emission). + TorchEmber, +} + +/// Lightweight particle system for visual effects. +pub struct ParticleSystem { + particles: Vec, + /// Ring-buffer index for recycling oldest particles. + next_slot: usize, +} + +impl Default for ParticleSystem { + fn default() -> Self { + Self::new() + } +} + +impl ParticleSystem { + pub fn new() -> Self { + let mut particles = Vec::with_capacity(MAX_PARTICLES); + for _ in 0..MAX_PARTICLES { + particles.push(Particle::default()); + } + Self { + particles, + next_slot: 0, + } + } + + /// Emit particles for a given effect at world position (x, y). + pub fn emit(&mut self, effect: ParticleEffect, x: f32, y: f32, sheet: &SpriteSheet) { + match effect { + ParticleEffect::DustLanding => self.emit_dust(x, y, sheet), + ParticleEffect::SparkHit => self.emit_sparks(x, y, sheet), + ParticleEffect::BloodDamage => self.emit_blood(x, y, sheet), + ParticleEffect::TorchFire => self.emit_fire(x, y, sheet), + ParticleEffect::EnemyDeath => self.emit_enemy_death(x, y, sheet), + ParticleEffect::PowerUpCollect => self.emit_powerup(x, y, sheet), + ParticleEffect::CheckpointActivate => self.emit_checkpoint(x, y, sheet), + ParticleEffect::GenericBurst { color, count } => { + self.emit_burst(x, y, color, count, sheet); + }, + ParticleEffect::WhipImpact { facing_right } => { + self.emit_whip_impact(x, y, facing_right, sheet); + }, + ParticleEffect::WaterSplash => self.emit_water_splash(x, y, sheet), + ParticleEffect::WallBreak => self.emit_wall_break(x, y, sheet), + ParticleEffect::RainSplash => self.emit_rain_splash(x, y, sheet), + ParticleEffect::LandingDust => self.emit_landing_dust(x, y, sheet), + ParticleEffect::TorchEmber => self.emit_torch_ember(x, y, sheet), + } + } + + /// Update all particles by dt seconds. + pub fn tick(&mut self, dt: f32) { + for p in &mut self.particles { + if !p.active { + continue; + } + p.lifetime -= dt; + if p.lifetime <= 0.0 { + p.active = false; + continue; + } + p.vy += p.gravity * dt; + p.x += p.vx * dt; + p.y += p.vy * dt; + } + } + + /// Add all active particles to the scene. + pub fn render(&self, scene: &mut Scene) { + for p in &self.particles { + if !p.active { + continue; + } + // Alpha fades linearly over lifetime + let alpha = (p.lifetime / p.max_lifetime).clamp(0.0, 1.0); + let tint = Vec4::new(p.tint.x, p.tint.y, p.tint.z, p.tint.w * alpha); + scene.add( + MeshType::Quad, + MaterialType::Sprite { + atlas_id: 0, + sprite_rect: p.sprite.to_vec4(), + tint, + flip_x: false, + dissolve: 0.0, + outline: 0.0, + blend_mode: crate::scene::BlendMode::Normal, + }, + Transform::from_xyz(p.x, p.y, 0.1).with_scale(glam::Vec3::new(p.size, p.size, 1.0)), + ); + } + } + + /// Allocate a particle slot (recycles oldest when full). + fn alloc(&mut self) -> &mut Particle { + let idx = self.next_slot; + self.next_slot = (self.next_slot + 1) % MAX_PARTICLES; + let p = &mut self.particles[idx]; + *p = Particle::default(); + p.active = true; + p + } + + fn emit_dust(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..4 { + let p = self.alloc(); + p.x = x + rand_spread(0.3); + p.y = y; + p.vx = rand_spread(1.0); + p.vy = 0.5 + fastrand::f32() * 0.5; + p.lifetime = 0.3 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(dust_frame(i)); + p.tint = Vec4::new(0.8, 0.75, 0.65, 0.8); + p.size = 0.15 + fastrand::f32() * 0.1; + p.gravity = -2.0; + } + } + + fn emit_sparks(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..5 { + let p = self.alloc(); + p.x = x; + p.y = y; + let angle = fastrand::f32() * std::f32::consts::TAU; + let speed = 2.0 + fastrand::f32() * 2.0; + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.2 + fastrand::f32() * 0.15; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(spark_frame(i)); + p.tint = Vec4::new(1.0, 0.9, 0.3, 1.0); + p.size = 0.1 + fastrand::f32() * 0.08; + p.gravity = -3.0; + } + } + + fn emit_blood(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..6 { + let p = self.alloc(); + p.x = x + rand_spread(0.2); + p.y = y + rand_spread(0.3); + let angle = fastrand::f32() * std::f32::consts::TAU; + let speed = 1.5 + fastrand::f32() * 1.5; + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.4 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(blood_frame(i)); + p.tint = Vec4::new(0.9, 0.1, 0.1, 1.0); + p.size = 0.12 + fastrand::f32() * 0.08; + p.gravity = -5.0; + } + } + + fn emit_fire(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..3 { + let p = self.alloc(); + p.x = x + rand_spread(0.15); + p.y = y + 0.3; + p.vx = rand_spread(0.3); + p.vy = 1.0 + fastrand::f32() * 0.5; + p.lifetime = 0.3 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(fire_frame(i)); + p.tint = Vec4::new(1.0, 0.7, 0.2, 0.9); + p.size = 0.1 + fastrand::f32() * 0.1; + p.gravity = 1.0; + } + } + + fn emit_enemy_death(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..8 { + let p = self.alloc(); + p.x = x + rand_spread(0.3); + p.y = y + rand_spread(0.5); + let angle = fastrand::f32() * std::f32::consts::TAU; + let speed = 2.0 + fastrand::f32() * 2.0; + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.5 + fastrand::f32() * 0.3; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(smoke_frame(i)); + p.tint = Vec4::new(0.6, 0.5, 0.7, 0.9); + p.size = 0.15 + fastrand::f32() * 0.1; + p.gravity = 1.0; + } + } + + fn emit_powerup(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..6 { + let p = self.alloc(); + p.x = x; + p.y = y; + let angle = (i as f32 / 6.0) * std::f32::consts::TAU; + let speed = 1.5 + fastrand::f32(); + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.4 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(magic_frame(i)); + p.tint = Vec4::new(0.3, 1.0, 0.5, 1.0); + p.size = 0.12; + p.gravity = 0.0; + } + } + + fn emit_checkpoint(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..5 { + let p = self.alloc(); + p.x = x + rand_spread(0.2); + p.y = y; + p.vx = rand_spread(0.5); + p.vy = 2.0 + fastrand::f32() * 1.0; + p.lifetime = 0.5 + fastrand::f32() * 0.3; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(spark_frame(i)); + p.tint = Vec4::new(1.0, 0.9, 0.3, 1.0); + p.size = 0.1; + p.gravity = -1.0; + } + } + + fn emit_whip_impact(&mut self, x: f32, y: f32, facing_right: bool, sheet: &SpriteSheet) { + let dir = if facing_right { 1.0 } else { -1.0 }; + for i in 0..8 { + let p = self.alloc(); + p.x = x; + p.y = y; + // Directional cone toward the hit direction + let spread = (i as f32 / 8.0 - 0.5) * 1.2; + let speed = 3.0 + fastrand::f32() * 2.0; + p.vx = dir * speed * (1.0 - spread.abs() * 0.5); + p.vy = spread * speed * 0.5 + fastrand::f32() * 0.5; + p.lifetime = 0.15 + fastrand::f32() * 0.15; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(spark_frame(i)); + p.tint = Vec4::new(1.0, 0.95, 0.7, 1.0); + p.size = 0.08 + fastrand::f32() * 0.06; + p.gravity = -4.0; + } + } + + fn emit_burst(&mut self, x: f32, y: f32, color: Vec4, count: u8, sheet: &SpriteSheet) { + for i in 0..count { + let p = self.alloc(); + p.x = x; + p.y = y; + let angle = (i as f32 / count as f32) * std::f32::consts::TAU; + let speed = 2.0 + fastrand::f32() * 1.5; + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.4 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(magic_frame(i as usize)); + p.tint = color; + p.size = 0.12 + fastrand::f32() * 0.08; + p.gravity = -1.0; + } + } + + fn emit_water_splash(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + // Primary splash droplets (fan upward) + for i in 0..8 { + let p = self.alloc(); + p.x = x + rand_spread(0.3); + p.y = y; + let angle = std::f32::consts::FRAC_PI_4 + fastrand::f32() * std::f32::consts::FRAC_PI_2; + let speed = 2.5 + fastrand::f32() * 2.0; + p.vx = (i as f32 / 4.0 - 1.0) * speed * 0.6; + p.vy = angle.sin() * speed; + p.lifetime = 0.35 + fastrand::f32() * 0.25; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(water_frame(i)); + p.tint = Vec4::new(0.4, 0.7, 1.0, 0.8); + p.size = 0.1 + fastrand::f32() * 0.08; + p.gravity = -7.0; + } + // Rising mist ring (slow upward drift) + for i in 0..3 { + let p = self.alloc(); + p.x = x + rand_spread(0.4); + p.y = y + 0.1; + p.vx = rand_spread(0.3); + p.vy = 0.3 + fastrand::f32() * 0.4; + p.lifetime = 0.4 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(water_frame(i)); + p.tint = Vec4::new(0.6, 0.8, 1.0, 0.3); + p.size = 0.15 + fastrand::f32() * 0.1; + p.gravity = -1.0; + } + } + + fn emit_wall_break(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..8 { + let p = self.alloc(); + p.x = x + rand_spread(0.3); + p.y = y + rand_spread(0.3); + let angle = fastrand::f32() * std::f32::consts::TAU; + let speed = 1.5 + fastrand::f32() * 2.0; + p.vx = angle.cos() * speed; + p.vy = angle.sin() * speed; + p.lifetime = 0.5 + fastrand::f32() * 0.3; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(dust_frame(i)); + p.tint = Vec4::new(0.5, 0.45, 0.4, 0.9); + p.size = 0.1 + fastrand::f32() * 0.12; + p.gravity = -8.0; // Heavy debris + } + } + + fn emit_rain_splash(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..2 { + let p = self.alloc(); + p.x = x + rand_spread(0.1); + p.y = y; + p.vx = rand_spread(0.5); + p.vy = 0.5 + fastrand::f32() * 0.3; + p.lifetime = 0.1 + fastrand::f32() * 0.1; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(spark_frame(i)); + p.tint = Vec4::new(0.7, 0.75, 0.9, 0.5); + p.size = 0.05; + p.gravity = -2.0; + } + } + + fn emit_landing_dust(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + for i in 0..6 { + let p = self.alloc(); + p.x = x + rand_spread(0.4); + p.y = y; + p.vx = rand_spread(1.5); + p.vy = 0.3 + fastrand::f32() * 0.5; + p.lifetime = 0.4 + fastrand::f32() * 0.2; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(dust_frame(i)); + p.tint = Vec4::new(0.7, 0.65, 0.55, 0.7); + p.size = 0.15 + fastrand::f32() * 0.15; + p.gravity = -1.0; + } + } + + fn emit_torch_ember(&mut self, x: f32, y: f32, sheet: &SpriteSheet) { + let p = self.alloc(); + p.x = x + rand_spread(0.15); + p.y = y + 0.4; + p.vx = rand_spread(0.3); + p.vy = 0.8 + fastrand::f32() * 0.5; + p.lifetime = 0.5 + fastrand::f32() * 0.3; + p.max_lifetime = p.lifetime; + p.sprite = sheet.get_or_default(ember_frame(0)); + p.tint = Vec4::new(1.0, 0.7, 0.2, 0.8); + p.size = 0.06 + fastrand::f32() * 0.04; + p.gravity = 0.5; + } + + /// Emit a particle with given probability (for continuous effects). + pub fn emit_continuous( + &mut self, + effect: ParticleEffect, + x: f32, + y: f32, + sheet: &SpriteSheet, + probability: f32, + ) { + if fastrand::f32() < probability { + self.emit(effect, x, y, sheet); + } + } +} + +/// Random spread value in [-half, +half]. +fn rand_spread(half: f32) -> f32 { + (fastrand::f32() - 0.5) * 2.0 * half +} + +/// Cycle through dust particle sprite frames. +fn dust_frame(i: usize) -> &'static str { + match i % 4 { + 0 => "particle_dust_0", + 1 => "particle_dust_1", + 2 => "particle_dust_2", + _ => "particle_dust_3", + } +} + +fn spark_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_spark_0", + 1 => "particle_spark_1", + _ => "particle_spark_2", + } +} + +fn blood_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_blood_0", + 1 => "particle_blood_1", + _ => "particle_blood_2", + } +} + +fn fire_frame(i: usize) -> &'static str { + match i % 4 { + 0 => "particle_fire_0", + 1 => "particle_fire_1", + 2 => "particle_fire_2", + _ => "particle_fire_3", + } +} + +fn smoke_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_smoke_0", + 1 => "particle_smoke_1", + _ => "particle_smoke_2", + } +} + +fn magic_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_magic_0", + 1 => "particle_magic_1", + _ => "particle_magic_2", + } +} + +fn water_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_water_0", + 1 => "particle_water_1", + _ => "particle_water_2", + } +} + +fn ember_frame(i: usize) -> &'static str { + match i % 3 { + 0 => "particle_ember_0", + 1 => "particle_ember_1", + _ => "particle_ember_2", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sprite_atlas::build_platformer_atlas; + + #[test] + fn particle_system_new_has_capacity() { + let ps = ParticleSystem::new(); + assert_eq!(ps.particles.len(), MAX_PARTICLES); + } + + #[test] + fn particle_system_tick_deactivates_expired() { + let mut ps = ParticleSystem::new(); + let sheet = build_platformer_atlas(); + ps.emit(ParticleEffect::DustLanding, 5.0, 3.0, &sheet); + + let active_before = ps.particles.iter().filter(|p| p.active).count(); + assert!(active_before > 0); + + // Tick past all lifetimes + for _ in 0..20 { + ps.tick(0.1); + } + + let active_after = ps.particles.iter().filter(|p| p.active).count(); + assert_eq!(active_after, 0); + } + + #[test] + fn particle_system_recycles_slots() { + let mut ps = ParticleSystem::new(); + let sheet = build_platformer_atlas(); + + // Emit more particles than the cap + for i in 0..300 { + ps.emit(ParticleEffect::SparkHit, i as f32, 0.0, &sheet); + } + + // Should not exceed MAX_PARTICLES + assert_eq!(ps.particles.len(), MAX_PARTICLES); + } + + #[test] + fn generic_burst_emits_correct_count() { + let mut ps = ParticleSystem::new(); + let sheet = build_platformer_atlas(); + ps.emit( + ParticleEffect::GenericBurst { + color: Vec4::ONE, + count: 10, + }, + 0.0, + 0.0, + &sheet, + ); + let active = ps.particles.iter().filter(|p| p.active).count(); + assert_eq!(active, 10); + } +} diff --git a/crates/breakpoint-client/src/renderer.rs b/crates/breakpoint-client/src/renderer.rs index cd1c0dd..36d84c1 100644 --- a/crates/breakpoint-client/src/renderer.rs +++ b/crates/breakpoint-client/src/renderer.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use glam::{Mat4, Vec3, Vec4}; use wasm_bindgen::JsCast; use web_sys::{ - WebGl2RenderingContext as GL, WebGlProgram, WebGlShader, WebGlUniformLocation, - WebGlVertexArrayObject, + WebGl2RenderingContext as GL, WebGlFramebuffer, WebGlProgram, WebGlRenderbuffer, WebGlShader, + WebGlTexture, WebGlUniformLocation, WebGlVertexArrayObject, }; use crate::camera_gl::Camera; @@ -24,7 +24,50 @@ struct ShaderProgram { u_intensity: Option, u_camera_pos: Option, u_fog_density: Option, + u_fog_color: Option, u_resolution: Option, + // Sprite shader uniforms + u_sprite_rect: Option, + u_tint: Option, + u_flip_x: Option, + u_texture: Option, + u_outline_width: Option, + u_dissolve: Option, + /// Palette texture unit (deferred: indexed palette rendering not yet active). + #[allow(dead_code)] + u_palette: Option, + u_use_palette: Option, + // Parallax shader uniforms + u_uv_offset: Option, + u_uv_scale: Option, + // Water shader uniforms + u_depth: Option, + u_wave_speed: Option, + // Lighting uniforms (for lit sprite shader, 32 colored lights) + u_lights: Vec>, + u_light_color: Vec>, + u_light_count: Option, + u_ambient: Option, + u_ambient_color: Option, + // GBA-style color ramp uniforms + u_ramp_shadow: Option, + u_ramp_mid: Option, + u_ramp_highlight: Option, + u_posterize: Option, + // Whip trail uniforms + u_arc_progress: Option, + // Post-process uniforms + u_scene_texture: Option, + u_scanline_intensity: Option, + u_bloom_intensity: Option, + u_vignette_intensity: Option, + u_crt_curvature: Option, + u_grade_shadows: Option, + u_grade_highlights: Option, + u_grade_contrast: Option, + u_saturation: Option, + u_chromatic_aberration: Option, + u_film_grain: Option, } /// Cached mesh GPU buffers. @@ -33,6 +76,52 @@ struct MeshBuffers { vertex_count: i32, } +/// Post-processing framebuffer resources. +struct PostProcessFBO { + framebuffer: WebGlFramebuffer, + color_texture: WebGlTexture, + depth_renderbuffer: WebGlRenderbuffer, + width: u32, + height: u32, +} + +/// Post-processing configuration. +pub struct PostProcessConfig { + pub scanline_intensity: f32, + pub bloom_intensity: f32, + pub vignette_intensity: f32, + pub crt_curvature: f32, + /// Per-room color grading: shadow tint (RGB). + pub grade_shadows: [f32; 3], + /// Per-room color grading: highlight tint (RGB). + pub grade_highlights: [f32; 3], + /// Contrast adjustment (1.0 = neutral). + pub grade_contrast: f32, + /// Color saturation (1.0 = neutral, 0.0 = grayscale). + pub saturation: f32, + /// Chromatic aberration strength in pixels (0.0 = off, triggered on damage). + pub chromatic_aberration: f32, + /// Film grain intensity (0.0 = off). + pub film_grain: f32, +} + +impl Default for PostProcessConfig { + fn default() -> Self { + Self { + scanline_intensity: 0.0, + bloom_intensity: 0.0, + vignette_intensity: 0.0, + crt_curvature: 0.0, + grade_shadows: [1.0, 1.0, 1.0], + grade_highlights: [1.0, 1.0, 1.0], + grade_contrast: 1.0, + saturation: 1.0, + chromatic_aberration: 0.0, + film_grain: 0.0, + } + } +} + /// WebGL2 renderer. pub struct Renderer { gl: GL, @@ -43,6 +132,21 @@ pub struct Renderer { meshes: HashMap, time: f32, context_lost: std::cell::Cell, + /// Texture atlases keyed by ID. + atlases: HashMap, + /// Palette textures keyed by ID (256x1 RGBA, for indexed color mode). + /// Deferred: not yet populated; infra for future indexed palette rendering. + #[allow(dead_code)] + palettes: HashMap, + /// Post-processing FBO (created lazily on first draw with post-fx). + post_fbo: Option, + /// Post-processing settings. + pub post_process: PostProcessConfig, + /// Sprite batch: reusable CPU-side vertex buffer (cleared each frame). + batch_vertices: Vec, + /// Sprite batch: VAO + VBO for dynamic upload (created lazily). + batch_vao: Option, + batch_vbo: Option, } /// Key for mesh cache — identifies unique mesh configurations. @@ -52,6 +156,7 @@ enum MeshKey { Cylinder { segments: u16 }, Cuboid, Plane, + Quad, } impl From<&MeshType> for MeshKey { @@ -61,6 +166,7 @@ impl From<&MeshType> for MeshKey { MeshType::Cylinder { segments } => MeshKey::Cylinder { segments }, MeshType::Cuboid => MeshKey::Cuboid, MeshType::Plane => MeshKey::Plane, + MeshType::Quad => MeshKey::Quad, } } } @@ -149,6 +255,13 @@ impl Renderer { meshes: HashMap::new(), time: 0.0, context_lost, + atlases: HashMap::new(), + palettes: HashMap::new(), + post_fbo: None, + post_process: PostProcessConfig::default(), + batch_vertices: Vec::with_capacity(9 * 6 * 1024), + batch_vao: None, + batch_vbo: None, }; renderer.compile_programs()?; @@ -167,6 +280,10 @@ impl Renderer { pub fn rebuild_resources(&mut self) -> Result<(), String> { self.programs.clear(); self.meshes.clear(); + self.atlases.clear(); + self.palettes.clear(); + // Post-process FBO is GPU-side only; invalidated by context loss. + self.post_fbo = None; self.compile_programs()?; self.generate_meshes(); Ok(()) @@ -260,9 +377,28 @@ impl Renderer { self.resize(); - let gl = &self.gl; - gl.clear_color(clear_color.x, clear_color.y, clear_color.z, clear_color.w); - gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT); + // If any post-processing effects are enabled, render to FBO + let use_postfx = self.post_process.scanline_intensity > 0.01 + || self.post_process.bloom_intensity > 0.01 + || self.post_process.vignette_intensity > 0.01 + || self.post_process.crt_curvature > 0.01 + || self.post_process.chromatic_aberration > 0.01 + || self.post_process.film_grain > 0.01 + || (self.post_process.grade_contrast - 1.0).abs() > 0.01 + || (self.post_process.saturation - 1.0).abs() > 0.01; + + if use_postfx { + self.ensure_post_fbo(); + if let Some(fbo) = &self.post_fbo { + self.gl + .bind_framebuffer(GL::FRAMEBUFFER, Some(&fbo.framebuffer)); + self.gl.viewport(0, 0, fbo.width as i32, fbo.height as i32); + } + } + + self.gl + .clear_color(clear_color.x, clear_color.y, clear_color.z, clear_color.w); + self.gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT); let vp = camera.view_projection(); @@ -286,8 +422,30 @@ impl Renderer { .collect(); sorted.sort_by_key(|obj| material_sort_key(&obj.material)); + // --- Sprite batching: draw simple sprites (no outline, no dissolve) in bulk --- + // Partition sprites by blend mode for batched drawing. + let has_batch_program = self.programs.contains_key("sprite_batch"); + if has_batch_program { + self.ensure_batch_vao(); + self.draw_sprite_batches(&sorted, &vp, scene, fog_density, camera); + } + + let gl = &self.gl; let mut active_program: &str = ""; + // Track whether lighting uniforms have been set for the current sprite program. + // These are scene-global (same for all sprites), so we set them once per program switch. + let mut sprite_lighting_set = false; for obj in &sorted { + // Skip sprites that were drawn in the batch pass + if has_batch_program + && matches!( + &obj.material, + MaterialType::Sprite { outline, dissolve, .. } + if *outline == 0.0 && *dissolve == 0.0 + ) + { + continue; + } let model = obj.transform.matrix(); let mvp = vp * model; @@ -297,6 +455,15 @@ impl Renderer { MaterialType::Ripple { .. } => "ripple", MaterialType::Glow { .. } => "glow", MaterialType::TronWall { .. } => "tronwall", + MaterialType::Sprite { .. } => "sprite", + MaterialType::Parallax { .. } => "parallax", + MaterialType::Water { .. } => "water", + MaterialType::WhipTrail { .. } => "whip", + MaterialType::SlashArc { .. } => "slash_arc", + MaterialType::MagicCircle { .. } => "magic_circle", + MaterialType::GodRays { .. } => "godrays", + MaterialType::FogLayer { .. } => "fog_layer", + MaterialType::HealthBar { .. } => "health_bar", }; let Some(prog) = self.programs.get(program_name) else { @@ -306,6 +473,7 @@ impl Renderer { if program_name != active_program { gl.use_program(Some(&prog.program)); active_program = program_name; + sprite_lighting_set = false; } // Common uniforms @@ -347,6 +515,188 @@ impl Renderer { let tiles = width / s.y.max(0.01); set_vec2(gl, &prog.u_resolution, tiles, 3.0); }, + MaterialType::Sprite { + atlas_id, + sprite_rect, + tint, + flip_x, + dissolve, + outline, + blend_mode, + } => { + // Bind atlas texture + gl.active_texture(GL::TEXTURE0); + if let Some(tex) = self.atlases.get(atlas_id) { + gl.bind_texture(GL::TEXTURE_2D, Some(tex)); + } + if let Some(loc) = &prog.u_texture { + gl.uniform1i(Some(loc), 0); + } + set_vec4(gl, &prog.u_sprite_rect, sprite_rect); + set_vec4(gl, &prog.u_tint, tint); + set_f32(gl, &prog.u_flip_x, if *flip_x { 1.0 } else { 0.0 }); + set_f32(gl, &prog.u_outline_width, *outline); + set_f32(gl, &prog.u_dissolve, *dissolve); + set_f32(gl, &prog.u_use_palette, 0.0); + // Blend mode + match blend_mode { + crate::scene::BlendMode::Normal => { + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.blend_equation(GL::FUNC_ADD); + }, + crate::scene::BlendMode::Additive => { + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.blend_equation(GL::FUNC_ADD); + }, + crate::scene::BlendMode::Subtractive => { + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.blend_equation(GL::FUNC_REVERSE_SUBTRACT); + }, + } + // Set lighting uniforms ONCE per sprite program switch + // (lights/ambient/ramp are scene-global, same for all sprites) + if !sprite_lighting_set { + sprite_lighting_set = true; + let light_count = scene.lighting.lights.len().min(32) as i32; + if let Some(loc) = &prog.u_light_count { + gl.uniform1i(Some(loc), light_count); + } + set_f32(gl, &prog.u_ambient, scene.lighting.ambient); + if let Some(loc) = &prog.u_ambient_color { + let ac = &scene.lighting.ambient_color; + gl.uniform3f(Some(loc), ac[0], ac[1], ac[2]); + } + if let Some(loc) = &prog.u_ramp_shadow { + let rs = &scene.lighting.ramp_shadow; + gl.uniform3f(Some(loc), rs[0], rs[1], rs[2]); + } + if let Some(loc) = &prog.u_ramp_mid { + let rm = &scene.lighting.ramp_mid; + gl.uniform3f(Some(loc), rm[0], rm[1], rm[2]); + } + if let Some(loc) = &prog.u_ramp_highlight { + let rh = &scene.lighting.ramp_highlight; + gl.uniform3f(Some(loc), rh[0], rh[1], rh[2]); + } + set_f32(gl, &prog.u_posterize, scene.lighting.posterize); + if let Some(loc) = &prog.u_fog_color { + let fc = &scene.lighting.fog_color; + gl.uniform3f(Some(loc), fc[0], fc[1], fc[2]); + } + for (i, light) in scene.lighting.lights.iter().take(32).enumerate() { + if let Some(loc) = prog.u_lights.get(i).and_then(|l| l.as_ref()) { + gl.uniform4f(Some(loc), light[0], light[1], light[2], light[3]); + } + if let Some(loc) = prog.u_light_color.get(i).and_then(|l| l.as_ref()) { + let c = scene + .lighting + .light_colors + .get(i) + .copied() + .unwrap_or([1.0, 1.0, 1.0, 0.0]); + gl.uniform4f(Some(loc), c[0], c[1], c[2], c[3]); + } + } + } + // Disable backface culling for sprites + gl.disable(GL::CULL_FACE); + }, + MaterialType::Parallax { + atlas_id, + layer_rect, + scroll_factor, + tint, + } => { + gl.active_texture(GL::TEXTURE0); + if let Some(tex) = self.atlases.get(atlas_id) { + gl.bind_texture(GL::TEXTURE_2D, Some(tex)); + } + if let Some(loc) = &prog.u_texture { + gl.uniform1i(Some(loc), 0); + } + // UV offset: scroll based on camera X position + let scroll_x = camera.position.x * scroll_factor * 0.05; + set_vec2(gl, &prog.u_uv_offset, scroll_x, layer_rect.y); + // UV scale: full width, layer height portion + set_vec2(gl, &prog.u_uv_scale, 1.0, layer_rect.w - layer_rect.y); + set_vec4(gl, &prog.u_tint, tint); + set_f32(gl, &prog.u_time, self.time); + set_f32(gl, &prog.u_intensity, 0.0); // sway amplitude + set_f32(gl, &prog.u_speed, 1.0); // crossfade alpha (fully visible) + // Disable backface culling + depth write for background + gl.disable(GL::CULL_FACE); + gl.depth_mask(false); + }, + MaterialType::Water { + color, + depth, + wave_speed, + } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_depth, *depth); + set_f32(gl, &prog.u_wave_speed, *wave_speed); + set_f32(gl, &prog.u_time, self.time); + // Transparent water: disable culling, keep depth writes + gl.disable(GL::CULL_FACE); + }, + MaterialType::WhipTrail { progress, color } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_arc_progress, *progress); + set_f32(gl, &prog.u_time, self.time); + // Additive blending for bright whip effect + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.disable(GL::CULL_FACE); + gl.disable(GL::DEPTH_TEST); + }, + MaterialType::SlashArc { + progress, + angle, + color, + } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_arc_progress, *progress); + set_f32(gl, &prog.u_intensity, *angle); // reuse u_intensity for arc_angle + set_f32(gl, &prog.u_time, self.time); + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.disable(GL::CULL_FACE); + gl.disable(GL::DEPTH_TEST); + }, + MaterialType::MagicCircle { + rotation, + pulse, + color, + } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_time, self.time); + set_f32(gl, &prog.u_speed, *rotation); // reuse u_speed for rotation + set_f32(gl, &prog.u_intensity, *pulse); // reuse u_intensity for pulse + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.disable(GL::CULL_FACE); + gl.disable(GL::DEPTH_TEST); + }, + MaterialType::GodRays { intensity, color } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_intensity, *intensity); + set_f32(gl, &prog.u_time, self.time); + set_f32(gl, &prog.u_speed, 0.5); + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.disable(GL::CULL_FACE); + gl.disable(GL::DEPTH_TEST); + }, + MaterialType::FogLayer { density, color } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_intensity, *density); + set_f32(gl, &prog.u_time, self.time); + gl.disable(GL::CULL_FACE); + gl.depth_mask(false); + }, + MaterialType::HealthBar { fill, color } => { + set_vec4(gl, &prog.u_color, color); + set_f32(gl, &prog.u_intensity, *fill); + set_f32(gl, &prog.u_time, self.time); + gl.disable(GL::CULL_FACE); + gl.disable(GL::DEPTH_TEST); + }, } // Bind mesh and draw @@ -355,25 +705,143 @@ impl Renderer { gl.bind_vertex_array(Some(&mesh.vao)); gl.draw_arrays(GL::TRIANGLES, 0, mesh.vertex_count); } + + // Restore GL state modified by material-specific setup + match &obj.material { + MaterialType::Parallax { .. } | MaterialType::FogLayer { .. } => { + gl.depth_mask(true); + gl.enable(GL::CULL_FACE); + }, + MaterialType::Sprite { blend_mode, .. } => { + if !matches!(blend_mode, crate::scene::BlendMode::Normal) { + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.blend_equation(GL::FUNC_ADD); + } + gl.enable(GL::CULL_FACE); + }, + MaterialType::Water { .. } => { + gl.enable(GL::CULL_FACE); + }, + MaterialType::WhipTrail { .. } + | MaterialType::SlashArc { .. } + | MaterialType::MagicCircle { .. } + | MaterialType::GodRays { .. } => { + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.enable(GL::CULL_FACE); + gl.enable(GL::DEPTH_TEST); + }, + MaterialType::HealthBar { .. } => { + gl.enable(GL::CULL_FACE); + gl.enable(GL::DEPTH_TEST); + }, + _ => {}, + } } + // Re-enable state that may have been disabled by material batches + gl.enable(GL::CULL_FACE); + gl.enable(GL::DEPTH_TEST); + gl.depth_mask(true); + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); self.gl.bind_vertex_array(None); + + // Post-processing pass: read FBO, draw fullscreen quad with effects + if use_postfx { + self.draw_postprocess_pass(); + } } /// Compile all shader programs. fn compile_programs(&mut self) -> Result<(), String> { let vert_src = include_str!("shaders_gl/unlit.vert"); - let configs: Vec<(&str, &str)> = vec![ - ("unlit", include_str!("shaders_gl/unlit.frag")), - ("gradient", include_str!("shaders_gl/gradient.frag")), - ("ripple", include_str!("shaders_gl/ripple.frag")), - ("glow", include_str!("shaders_gl/glow.frag")), - ("tronwall", include_str!("shaders_gl/tronwall.frag")), + let configs: Vec<(&str, &str, &str)> = vec![ + ("unlit", vert_src, include_str!("shaders_gl/unlit.frag")), + ( + "gradient", + vert_src, + include_str!("shaders_gl/gradient.frag"), + ), + ("ripple", vert_src, include_str!("shaders_gl/ripple.frag")), + ("glow", vert_src, include_str!("shaders_gl/glow.frag")), + ( + "tronwall", + vert_src, + include_str!("shaders_gl/tronwall.frag"), + ), + ( + "sprite", + include_str!("shaders_gl/sprite.vert"), + include_str!("shaders_gl/sprite.frag"), + ), + ( + "sprite_batch", + include_str!("shaders_gl/sprite_batch.vert"), + include_str!("shaders_gl/sprite_batch.frag"), + ), + ( + "parallax", + include_str!("shaders_gl/parallax.vert"), + include_str!("shaders_gl/parallax.frag"), + ), + ( + "water", + include_str!("shaders_gl/water.vert"), + include_str!("shaders_gl/water.frag"), + ), + ( + "whip", + include_str!("shaders_gl/whip.vert"), + include_str!("shaders_gl/whip.frag"), + ), + ( + "postprocess", + include_str!("shaders_gl/postprocess.vert"), + include_str!("shaders_gl/postprocess.frag"), + ), + ( + "slash_arc", + include_str!("shaders_gl/slash_arc.vert"), + include_str!("shaders_gl/slash_arc.frag"), + ), + ( + "magic_circle", + include_str!("shaders_gl/magic_circle.vert"), + include_str!("shaders_gl/magic_circle.frag"), + ), + ( + "godrays", + include_str!("shaders_gl/godrays.vert"), + include_str!("shaders_gl/godrays.frag"), + ), + ( + "fog_layer", + include_str!("shaders_gl/fog_layer.vert"), + include_str!("shaders_gl/fog_layer.frag"), + ), + ( + "health_bar", + include_str!("shaders_gl/health_bar.vert"), + include_str!("shaders_gl/health_bar.frag"), + ), ]; - for (name, frag_src) in configs { - let program = link_program(&self.gl, vert_src, frag_src)?; + for (name, vs, frag_src) in configs { + let program = link_program(&self.gl, vs, frag_src)?; + + // Cache light uniform locations (u_lights[0] .. u_lights[31]) + let u_lights: Vec> = (0..32) + .map(|i| { + self.gl + .get_uniform_location(&program, &format!("u_lights[{i}]")) + }) + .collect(); + let u_light_color: Vec> = (0..32) + .map(|i| { + self.gl + .get_uniform_location(&program, &format!("u_light_color[{i}]")) + }) + .collect(); let sp = ShaderProgram { u_mvp: self.gl.get_uniform_location(&program, "u_mvp"), @@ -387,7 +855,47 @@ impl Renderer { u_intensity: self.gl.get_uniform_location(&program, "u_intensity"), u_camera_pos: self.gl.get_uniform_location(&program, "u_camera_pos"), u_fog_density: self.gl.get_uniform_location(&program, "u_fog_density"), + u_fog_color: self.gl.get_uniform_location(&program, "u_fog_color"), u_resolution: self.gl.get_uniform_location(&program, "u_resolution"), + u_sprite_rect: self.gl.get_uniform_location(&program, "u_sprite_rect"), + u_tint: self.gl.get_uniform_location(&program, "u_tint"), + u_flip_x: self.gl.get_uniform_location(&program, "u_flip_x"), + u_texture: self.gl.get_uniform_location(&program, "u_texture"), + u_outline_width: self.gl.get_uniform_location(&program, "u_outline_width"), + u_dissolve: self.gl.get_uniform_location(&program, "u_dissolve"), + u_palette: self.gl.get_uniform_location(&program, "u_palette"), + u_use_palette: self.gl.get_uniform_location(&program, "u_use_palette"), + u_uv_offset: self.gl.get_uniform_location(&program, "u_uv_offset"), + u_uv_scale: self.gl.get_uniform_location(&program, "u_uv_scale"), + u_depth: self.gl.get_uniform_location(&program, "u_depth"), + u_wave_speed: self.gl.get_uniform_location(&program, "u_wave_speed"), + u_lights, + u_light_color, + u_light_count: self.gl.get_uniform_location(&program, "u_light_count"), + u_ambient: self.gl.get_uniform_location(&program, "u_ambient"), + u_ambient_color: self.gl.get_uniform_location(&program, "u_ambient_color"), + u_ramp_shadow: self.gl.get_uniform_location(&program, "u_ramp_shadow"), + u_ramp_mid: self.gl.get_uniform_location(&program, "u_ramp_mid"), + u_ramp_highlight: self.gl.get_uniform_location(&program, "u_ramp_highlight"), + u_posterize: self.gl.get_uniform_location(&program, "u_posterize"), + u_arc_progress: self.gl.get_uniform_location(&program, "u_arc_progress"), + u_scene_texture: self.gl.get_uniform_location(&program, "u_scene"), + u_scanline_intensity: self + .gl + .get_uniform_location(&program, "u_scanline_intensity"), + u_bloom_intensity: self.gl.get_uniform_location(&program, "u_bloom_intensity"), + u_vignette_intensity: self + .gl + .get_uniform_location(&program, "u_vignette_intensity"), + u_crt_curvature: self.gl.get_uniform_location(&program, "u_crt_curvature"), + u_grade_shadows: self.gl.get_uniform_location(&program, "u_grade_shadows"), + u_grade_highlights: self.gl.get_uniform_location(&program, "u_grade_highlights"), + u_grade_contrast: self.gl.get_uniform_location(&program, "u_grade_contrast"), + u_saturation: self.gl.get_uniform_location(&program, "u_saturation"), + u_chromatic_aberration: self + .gl + .get_uniform_location(&program, "u_chromatic_aberration"), + u_film_grain: self.gl.get_uniform_location(&program, "u_film_grain"), program, }; self.programs.insert(name, sp); @@ -395,6 +903,263 @@ impl Renderer { Ok(()) } + /// Load a texture atlas from an HtmlImageElement with NEAREST filtering. + #[cfg(target_family = "wasm")] + pub fn load_texture(&mut self, id: u8, img: &web_sys::HtmlImageElement) { + self.load_texture_with_wrap(id, img, false); + } + + /// Load a texture with NEAREST filtering and configurable wrapping. + /// When `wrap_repeat` is true, uses GL::REPEAT for seamless tiling; + /// otherwise uses CLAMP_TO_EDGE. + #[cfg(target_family = "wasm")] + pub fn load_texture_with_wrap( + &mut self, + id: u8, + img: &web_sys::HtmlImageElement, + wrap_repeat: bool, + ) { + let gl = &self.gl; + let Some(texture) = gl.create_texture() else { + return; + }; + gl.bind_texture(GL::TEXTURE_2D, Some(&texture)); + let _ = gl.tex_image_2d_with_u32_and_u32_and_html_image_element( + GL::TEXTURE_2D, + 0, + GL::RGBA as i32, + GL::RGBA, + GL::UNSIGNED_BYTE, + img, + ); + // Pixel-art filtering: NEAREST (no blurring) + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::NEAREST as i32); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::NEAREST as i32); + let wrap = if wrap_repeat { + GL::REPEAT as i32 + } else { + GL::CLAMP_TO_EDGE as i32 + }; + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, wrap); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, wrap); + gl.bind_texture(GL::TEXTURE_2D, None); + self.atlases.insert(id, texture); + } + + /// Get the accumulated renderer time (for animations). + pub fn time(&self) -> f32 { + self.time + } + + /// Ensure the post-processing FBO exists and matches the canvas size. + fn ensure_post_fbo(&mut self) { + let w = self.canvas_width; + let h = self.canvas_height; + + // If FBO exists and matches size, nothing to do + if let Some(fbo) = &self.post_fbo { + if fbo.width == w && fbo.height == h { + return; + } + // Size changed — delete old resources + self.gl.delete_framebuffer(Some(&fbo.framebuffer)); + self.gl.delete_texture(Some(&fbo.color_texture)); + self.gl.delete_renderbuffer(Some(&fbo.depth_renderbuffer)); + self.post_fbo = None; + } + + let gl = &self.gl; + let Some(fb) = gl.create_framebuffer() else { + return; + }; + let Some(tex) = gl.create_texture() else { + return; + }; + let Some(rb) = gl.create_renderbuffer() else { + return; + }; + + // Color texture + gl.bind_texture(GL::TEXTURE_2D, Some(&tex)); + gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( + GL::TEXTURE_2D, + 0, + GL::RGBA as i32, + w as i32, + h as i32, + 0, + GL::RGBA, + GL::UNSIGNED_BYTE, + None, + ) + .ok(); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR as i32); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR as i32); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::CLAMP_TO_EDGE as i32); + gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::CLAMP_TO_EDGE as i32); + + // Depth renderbuffer + gl.bind_renderbuffer(GL::RENDERBUFFER, Some(&rb)); + gl.renderbuffer_storage(GL::RENDERBUFFER, GL::DEPTH_COMPONENT16, w as i32, h as i32); + + // Assemble FBO + gl.bind_framebuffer(GL::FRAMEBUFFER, Some(&fb)); + gl.framebuffer_texture_2d( + GL::FRAMEBUFFER, + GL::COLOR_ATTACHMENT0, + GL::TEXTURE_2D, + Some(&tex), + 0, + ); + gl.framebuffer_renderbuffer( + GL::FRAMEBUFFER, + GL::DEPTH_ATTACHMENT, + GL::RENDERBUFFER, + Some(&rb), + ); + + // Unbind + gl.bind_framebuffer(GL::FRAMEBUFFER, None); + gl.bind_texture(GL::TEXTURE_2D, None); + gl.bind_renderbuffer(GL::RENDERBUFFER, None); + + self.post_fbo = Some(PostProcessFBO { + framebuffer: fb, + color_texture: tex, + depth_renderbuffer: rb, + width: w, + height: h, + }); + } + + /// Draw the post-processing fullscreen pass. + fn draw_postprocess_pass(&self) { + let gl = &self.gl; + let Some(fbo) = &self.post_fbo else { return }; + let Some(prog) = self.programs.get("postprocess") else { + return; + }; + let Some(mesh) = self.meshes.get(&MeshKey::Quad) else { + return; + }; + + // Bind default framebuffer + gl.bind_framebuffer(GL::FRAMEBUFFER, None); + gl.viewport(0, 0, self.canvas_width as i32, self.canvas_height as i32); + gl.clear_color(0.0, 0.0, 0.0, 1.0); + gl.clear(GL::COLOR_BUFFER_BIT); + + gl.use_program(Some(&prog.program)); + + // Bind FBO color texture as input + gl.active_texture(GL::TEXTURE0); + gl.bind_texture(GL::TEXTURE_2D, Some(&fbo.color_texture)); + if let Some(loc) = &prog.u_scene_texture { + gl.uniform1i(Some(loc), 0); + } + // Also bind via u_texture if postprocess shader uses that name + if let Some(loc) = &prog.u_texture { + gl.uniform1i(Some(loc), 0); + } + + // Set uniforms + set_vec2( + gl, + &prog.u_resolution, + self.canvas_width as f32, + self.canvas_height as f32, + ); + set_f32(gl, &prog.u_time, self.time); + if let Some(loc) = &prog.u_scanline_intensity { + gl.uniform1f(Some(loc), self.post_process.scanline_intensity); + } + if let Some(loc) = &prog.u_bloom_intensity { + gl.uniform1f(Some(loc), self.post_process.bloom_intensity); + } + if let Some(loc) = &prog.u_vignette_intensity { + gl.uniform1f(Some(loc), self.post_process.vignette_intensity); + } + if let Some(loc) = &prog.u_crt_curvature { + gl.uniform1f(Some(loc), self.post_process.crt_curvature); + } + if let Some(loc) = &prog.u_grade_shadows { + let s = &self.post_process.grade_shadows; + gl.uniform3f(Some(loc), s[0], s[1], s[2]); + } + if let Some(loc) = &prog.u_grade_highlights { + let h = &self.post_process.grade_highlights; + gl.uniform3f(Some(loc), h[0], h[1], h[2]); + } + if let Some(loc) = &prog.u_grade_contrast { + gl.uniform1f(Some(loc), self.post_process.grade_contrast); + } + if let Some(loc) = &prog.u_saturation { + gl.uniform1f(Some(loc), self.post_process.saturation); + } + if let Some(loc) = &prog.u_chromatic_aberration { + gl.uniform1f(Some(loc), self.post_process.chromatic_aberration); + } + if let Some(loc) = &prog.u_film_grain { + gl.uniform1f(Some(loc), self.post_process.film_grain); + } + + // Draw fullscreen quad + gl.disable(GL::DEPTH_TEST); + gl.disable(GL::CULL_FACE); + + gl.bind_vertex_array(Some(&mesh.vao)); + gl.draw_arrays(GL::TRIANGLES, 0, mesh.vertex_count); + gl.bind_vertex_array(None); + + // Restore + gl.enable(GL::DEPTH_TEST); + gl.enable(GL::CULL_FACE); + gl.bind_texture(GL::TEXTURE_2D, None); + } + + /// Draw a full-screen color overlay (for damage/pickup flashes). + /// Uses additive blending for a bright flash effect. + pub fn draw_screen_flash(&self, color: Vec4, alpha: f32) { + if alpha <= 0.001 { + return; + } + let gl = &self.gl; + let Some(prog) = self.programs.get("unlit") else { + return; + }; + let Some(mesh) = self.meshes.get(&MeshKey::Quad) else { + return; + }; + + gl.use_program(Some(&prog.program)); + + // Full-screen NDC quad: identity MVP places quad at [-0.5, 0.5] in clip space + // Scale to fill screen: 2x2 in NDC + let mvp = glam::Mat4::from_scale(Vec3::new(2.0, 2.0, 1.0)); + set_mat4(gl, &prog.u_mvp, &mvp); + set_mat4(gl, &prog.u_model, &glam::Mat4::IDENTITY); + set_vec4( + gl, + &prog.u_color, + &Vec4::new(color.x, color.y, color.z, alpha), + ); + set_f32(gl, &prog.u_fog_density, 0.0); + + // Additive blending for flash + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.disable(GL::DEPTH_TEST); + gl.disable(GL::CULL_FACE); + + gl.bind_vertex_array(Some(&mesh.vao)); + gl.draw_arrays(GL::TRIANGLES, 0, mesh.vertex_count); + gl.bind_vertex_array(None); + + // Restore standard blending and depth + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.enable(GL::DEPTH_TEST); + gl.enable(GL::CULL_FACE); + } + /// Generate mesh VBOs/VAOs for each primitive type. fn generate_meshes(&mut self) { let configs: Vec<(MeshKey, Vec)> = vec![ @@ -402,6 +1167,7 @@ impl Renderer { (MeshKey::Plane, generate_plane()), (MeshKey::Sphere { segments: 16 }, generate_sphere(16)), (MeshKey::Cylinder { segments: 16 }, generate_cylinder(16)), + (MeshKey::Quad, generate_quad()), ]; for (key, vertices) in configs { @@ -442,6 +1208,277 @@ impl Renderer { let vertex_count = data.len() as i32 / 8; Some(MeshBuffers { vao, vertex_count }) } + + /// Draw all batchable sprites grouped by blend mode. + /// A sprite is batchable if outline == 0.0 and dissolve == 0.0. + fn draw_sprite_batches( + &mut self, + sorted: &[&crate::scene::RenderObject], + vp: &Mat4, + scene: &Scene, + fog_density: f32, + camera: &Camera, + ) { + use crate::scene::BlendMode; + + // Collect batchable sprites per blend mode + let mut normal_sprites: Vec<&crate::scene::RenderObject> = Vec::new(); + let mut additive_sprites: Vec<&crate::scene::RenderObject> = Vec::new(); + let mut subtractive_sprites: Vec<&crate::scene::RenderObject> = Vec::new(); + + for obj in sorted { + if let MaterialType::Sprite { + outline, + dissolve, + blend_mode, + .. + } = &obj.material + && *outline == 0.0 + && *dissolve == 0.0 + { + match blend_mode { + BlendMode::Normal => normal_sprites.push(obj), + BlendMode::Additive => additive_sprites.push(obj), + BlendMode::Subtractive => subtractive_sprites.push(obj), + } + } + } + + if normal_sprites.is_empty() + && additive_sprites.is_empty() + && subtractive_sprites.is_empty() + { + return; + } + + // Build all batch vertex data first (needs &mut self) + // We'll store them in temporary Vecs to avoid borrow issues. + let mut normal_verts: Vec = Vec::new(); + let mut additive_verts: Vec = Vec::new(); + let mut subtractive_verts: Vec = Vec::new(); + if !normal_sprites.is_empty() { + self.build_sprite_batch(&normal_sprites); + std::mem::swap(&mut normal_verts, &mut self.batch_vertices); + } + if !additive_sprites.is_empty() { + self.build_sprite_batch(&additive_sprites); + std::mem::swap(&mut additive_verts, &mut self.batch_vertices); + } + if !subtractive_sprites.is_empty() { + self.build_sprite_batch(&subtractive_sprites); + std::mem::swap(&mut subtractive_verts, &mut self.batch_vertices); + } + + let gl = &self.gl; + let Some(prog) = self.programs.get("sprite_batch") else { + return; + }; + gl.use_program(Some(&prog.program)); + + // Set view-projection matrix (u_vp) + if let Some(loc) = &prog.u_mvp { + gl.uniform_matrix4fv_with_f32_array(Some(loc), false, vp.as_ref()); + } + set_f32(gl, &prog.u_fog_density, fog_density); + set_vec3(gl, &prog.u_camera_pos, &camera.position); + + // Bind atlas texture (all sprites use atlas 0) + gl.active_texture(GL::TEXTURE0); + if let Some(tex) = self.atlases.get(&0) { + gl.bind_texture(GL::TEXTURE_2D, Some(tex)); + } + if let Some(loc) = &prog.u_texture { + gl.uniform1i(Some(loc), 0); + } + + // Set lighting uniforms (scene-global) + let light_count = scene.lighting.lights.len().min(32) as i32; + if let Some(loc) = &prog.u_light_count { + gl.uniform1i(Some(loc), light_count); + } + set_f32(gl, &prog.u_ambient, scene.lighting.ambient); + if let Some(loc) = &prog.u_ambient_color { + let ac = &scene.lighting.ambient_color; + gl.uniform3f(Some(loc), ac[0], ac[1], ac[2]); + } + if let Some(loc) = &prog.u_ramp_shadow { + let rs = &scene.lighting.ramp_shadow; + gl.uniform3f(Some(loc), rs[0], rs[1], rs[2]); + } + if let Some(loc) = &prog.u_ramp_mid { + let rm = &scene.lighting.ramp_mid; + gl.uniform3f(Some(loc), rm[0], rm[1], rm[2]); + } + if let Some(loc) = &prog.u_ramp_highlight { + let rh = &scene.lighting.ramp_highlight; + gl.uniform3f(Some(loc), rh[0], rh[1], rh[2]); + } + set_f32(gl, &prog.u_posterize, scene.lighting.posterize); + if let Some(loc) = &prog.u_fog_color { + let fc = &scene.lighting.fog_color; + gl.uniform3f(Some(loc), fc[0], fc[1], fc[2]); + } + for (i, light) in scene.lighting.lights.iter().take(32).enumerate() { + if let Some(loc) = prog.u_lights.get(i).and_then(|l| l.as_ref()) { + gl.uniform4f(Some(loc), light[0], light[1], light[2], light[3]); + } + if let Some(loc) = prog.u_light_color.get(i).and_then(|l| l.as_ref()) { + let c = scene + .lighting + .light_colors + .get(i) + .copied() + .unwrap_or([1.0, 1.0, 1.0, 0.0]); + gl.uniform4f(Some(loc), c[0], c[1], c[2], c[3]); + } + } + + gl.disable(GL::CULL_FACE); + + // Draw Normal blend batch + if !normal_verts.is_empty() { + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.blend_equation(GL::FUNC_ADD); + self.upload_and_draw_batch_data(&normal_verts); + } + + // Draw Additive blend batch + if !additive_verts.is_empty() { + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.blend_equation(GL::FUNC_ADD); + self.upload_and_draw_batch_data(&additive_verts); + } + + // Draw Subtractive blend batch + if !subtractive_verts.is_empty() { + gl.blend_func(GL::SRC_ALPHA, GL::ONE); + gl.blend_equation(GL::FUNC_REVERSE_SUBTRACT); + self.upload_and_draw_batch_data(&subtractive_verts); + } + + // Restore default blend state + gl.blend_func(GL::SRC_ALPHA, GL::ONE_MINUS_SRC_ALPHA); + gl.blend_equation(GL::FUNC_ADD); + gl.enable(GL::CULL_FACE); + } + + /// Ensure the batch VAO/VBO is created (lazy init). + fn ensure_batch_vao(&mut self) { + if self.batch_vao.is_some() { + return; + } + let gl = &self.gl; + let vao = match gl.create_vertex_array() { + Some(v) => v, + None => return, + }; + let vbo = match gl.create_buffer() { + Some(b) => b, + None => return, + }; + gl.bind_vertex_array(Some(&vao)); + gl.bind_buffer(GL::ARRAY_BUFFER, Some(&vbo)); + + // Batch vertex layout: pos(3) + uv(2) + tint(4) = 9 floats = 36 bytes + let stride = 9 * 4; + // location 0: position (vec3) + gl.enable_vertex_attrib_array(0); + gl.vertex_attrib_pointer_with_i32(0, 3, GL::FLOAT, false, stride, 0); + // location 1: uv (vec2) + gl.enable_vertex_attrib_array(1); + gl.vertex_attrib_pointer_with_i32(1, 2, GL::FLOAT, false, stride, 12); + // location 2: tint (vec4) + gl.enable_vertex_attrib_array(2); + gl.vertex_attrib_pointer_with_i32(2, 4, GL::FLOAT, false, stride, 20); + + gl.bind_vertex_array(None); + self.batch_vao = Some(vao); + self.batch_vbo = Some(vbo); + } + + /// Build batch vertex data for a set of batchable sprites. + /// Each sprite becomes 6 vertices (2 triangles), with pre-computed + /// world-space positions and atlas UVs. + fn build_sprite_batch(&mut self, sprites: &[&crate::scene::RenderObject]) { + self.batch_vertices.clear(); + for obj in sprites { + let MaterialType::Sprite { + sprite_rect, + tint, + flip_x, + .. + } = &obj.material + else { + continue; + }; + // Pre-compute world-space quad corners from transform + let t = &obj.transform; + let half_x = t.scale.x * 0.5; + let half_y = t.scale.y * 0.5; + let cx = t.translation.x; + let cy = t.translation.y; + let z = t.translation.z; + + // Quad corners (no rotation for 2D sprites) + let x0 = cx - half_x; + let x1 = cx + half_x; + let y0 = cy - half_y; + let y1 = cy + half_y; + + // Pre-compute atlas UVs from sprite_rect + flip + let (u0, u1) = if *flip_x { + (sprite_rect.z, sprite_rect.x) // reversed + } else { + (sprite_rect.x, sprite_rect.z) + }; + // V is inverted: sprite_rect.w = v_bottom, sprite_rect.y = v_top + let v0 = sprite_rect.w; // bottom + let v1 = sprite_rect.y; // top + + let tr = tint.x; + let tg = tint.y; + let tb = tint.z; + let ta = tint.w; + + // Triangle 1: bottom-left, top-right, bottom-right + self.batch_vertices + .extend_from_slice(&[x0, y0, z, u0, v0, tr, tg, tb, ta]); + self.batch_vertices + .extend_from_slice(&[x1, y1, z, u1, v1, tr, tg, tb, ta]); + self.batch_vertices + .extend_from_slice(&[x1, y0, z, u1, v0, tr, tg, tb, ta]); + // Triangle 2: bottom-left, top-left, top-right + self.batch_vertices + .extend_from_slice(&[x0, y0, z, u0, v0, tr, tg, tb, ta]); + self.batch_vertices + .extend_from_slice(&[x0, y1, z, u0, v1, tr, tg, tb, ta]); + self.batch_vertices + .extend_from_slice(&[x1, y1, z, u1, v1, tr, tg, tb, ta]); + } + } + + /// Upload batch vertex data and draw. Uses GL handle directly to avoid self borrow issues. + fn upload_and_draw_batch_data(&self, data: &[f32]) { + if data.is_empty() { + return; + } + let gl = &self.gl; + let Some(vao) = &self.batch_vao else { return }; + let Some(vbo) = &self.batch_vbo else { return }; + + gl.bind_vertex_array(Some(vao)); + gl.bind_buffer(GL::ARRAY_BUFFER, Some(vbo)); + + // Always use buffer_data (simpler than tracking capacity for immutable self) + unsafe { + let view = js_sys::Float32Array::view(data); + gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::DYNAMIC_DRAW); + } + + let vertex_count = (data.len() / 9) as i32; + gl.draw_arrays(GL::TRIANGLES, 0, vertex_count); + gl.bind_vertex_array(None); + } } // --- Uniform helpers --- @@ -453,13 +1490,24 @@ fn set_mat4(gl: &GL, loc: &Option, m: &Mat4) { } /// Sort key for grouping objects by shader program (minimizes program switches). +/// Parallax renders first (background), then world geometry, sprites, VFX, fog, HUD. +/// Wide gaps allow future layer insertion without renumbering. fn material_sort_key(m: &MaterialType) -> u8 { match m { - MaterialType::Unlit { .. } => 0, - MaterialType::Gradient { .. } => 1, - MaterialType::Glow { .. } => 2, - MaterialType::Ripple { .. } => 3, - MaterialType::TronWall { .. } => 4, + MaterialType::Parallax { .. } => 0, + MaterialType::Unlit { .. } => 10, + MaterialType::Gradient { .. } => 15, + MaterialType::Water { .. } => 20, + MaterialType::Sprite { .. } => 30, + MaterialType::Glow { .. } => 35, + MaterialType::Ripple { .. } => 40, + MaterialType::TronWall { .. } => 45, + MaterialType::WhipTrail { .. } => 50, + MaterialType::SlashArc { .. } => 52, + MaterialType::MagicCircle { .. } => 54, + MaterialType::GodRays { .. } => 56, + MaterialType::FogLayer { .. } => 60, + MaterialType::HealthBar { .. } => 70, } } @@ -717,3 +1765,22 @@ fn generate_cylinder(segments: u16) -> Vec { } buf } + +/// Unit quad on the XY plane at Z=0, facing +Z (toward the side-view camera at Z<0). +fn generate_quad() -> Vec { + let mut buf = Vec::with_capacity(6 * 8); + let normal = Vec3::Z; + let h = 0.5; + // Quad corners on XY plane — wound CCW when viewed from +Z + let v00 = Vec3::new(-h, -h, 0.0); + let v10 = Vec3::new(h, -h, 0.0); + let v11 = Vec3::new(h, h, 0.0); + let v01 = Vec3::new(-h, h, 0.0); + push_vertex(&mut buf, v00, normal, 0.0, 0.0); + push_vertex(&mut buf, v11, normal, 1.0, 1.0); + push_vertex(&mut buf, v10, normal, 1.0, 0.0); + push_vertex(&mut buf, v00, normal, 0.0, 0.0); + push_vertex(&mut buf, v01, normal, 0.0, 1.0); + push_vertex(&mut buf, v11, normal, 1.0, 1.0); + buf +} diff --git a/crates/breakpoint-client/src/scene.rs b/crates/breakpoint-client/src/scene.rs index 9022a36..f3c4a74 100644 --- a/crates/breakpoint-client/src/scene.rs +++ b/crates/breakpoint-client/src/scene.rs @@ -55,10 +55,25 @@ impl Transform { /// Mesh primitive types. #[derive(Debug, Clone, Copy)] pub enum MeshType { - Sphere { segments: u16 }, - Cylinder { segments: u16 }, + Sphere { + segments: u16, + }, + Cylinder { + segments: u16, + }, Cuboid, Plane, + /// XY-plane billboard quad (faces +Z, toward camera at Z<0). + Quad, +} + +/// Blend mode for sprite rendering (MBAACC-style). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum BlendMode { + #[default] + Normal, + Additive, + Subtractive, } /// Material types matching the GLSL shader programs. @@ -84,6 +99,96 @@ pub enum MaterialType { color: Vec4, intensity: f32, }, + /// Textured sprite from a texture atlas. + Sprite { + atlas_id: u8, + sprite_rect: Vec4, + tint: Vec4, + flip_x: bool, + /// Dissolve amount: 0.0 = solid, 1.0 = fully dissolved. Used for death effects. + dissolve: f32, + /// Outline width: >0.0 enables dark pixel outline (MBAACC-style). + outline: f32, + /// Blend mode: Normal, Additive, or Subtractive. + blend_mode: BlendMode, + }, + /// Parallax background layer (scrolling textured quad). + Parallax { + atlas_id: u8, + /// UV rect for the layer in the background texture (v0..v1 row). + layer_rect: Vec4, + /// Scroll speed multiplier (0.0 = static, 1.0 = camera speed). + scroll_factor: f32, + tint: Vec4, + }, + /// Animated water with waves, caustics, and transparency. + Water { + color: Vec4, + depth: f32, + wave_speed: f32, + }, + /// Whip attack trail arc effect. + WhipTrail { + progress: f32, + color: Vec4, + }, + /// Anime-style slash arc VFX. + SlashArc { + progress: f32, + angle: f32, + color: Vec4, + }, + /// Rotating magic circle VFX (power-up activation). + MagicCircle { + rotation: f32, + pulse: f32, + color: Vec4, + }, + /// Volumetric god rays from stained glass or bright light sources. + GodRays { + intensity: f32, + color: Vec4, + }, + /// Ground fog layer with scrolling noise. + FogLayer { + density: f32, + color: Vec4, + }, + /// Procedural health bar (fill amount = intensity). + HealthBar { + fill: f32, + color: Vec4, + }, +} + +/// Lighting information for the scene (torch lights, ambient). +pub struct SceneLighting { + /// Up to 32 lights: (x, y, intensity, radius). + pub lights: Vec<[f32; 4]>, + /// Per-light color: (r, g, b, type). Type 0=point, 1=directional. + pub light_colors: Vec<[f32; 4]>, + /// Ambient light level (0.0 = pitch black, 1.0 = fully lit). + pub ambient: f32, + /// Per-room ambient color (RGB). Defaults to neutral white. + pub ambient_color: [f32; 3], + /// Per-room color grading: shadow tint (RGB, 1.0=neutral). + pub grade_shadows: [f32; 3], + /// Per-room color grading: highlight tint (RGB, 1.0=neutral). + pub grade_highlights: [f32; 3], + /// Per-room contrast (1.0=neutral). + pub grade_contrast: f32, + /// Per-room saturation (1.0=neutral). + pub saturation: f32, + /// GBA-style color ramp: shadow color (RGB). Zero = disabled. + pub ramp_shadow: [f32; 3], + /// GBA-style color ramp: midtone color (RGB). + pub ramp_mid: [f32; 3], + /// GBA-style color ramp: highlight color (RGB). + pub ramp_highlight: [f32; 3], + /// GBA-style posterization bit depth (0.0=off, 31.0=5-bit GBA). + pub posterize: f32, + /// Per-room fog color (RGB). Used by sprite shader ground fog. + pub fog_color: [f32; 3], } /// A renderable object in the scene. @@ -99,13 +204,30 @@ pub struct RenderObject { pub struct Scene { objects: Vec, next_id: ObjectId, + /// Scene lighting (set by game-specific render code, read by renderer). + pub lighting: SceneLighting, } impl Scene { pub fn new() -> Self { Self { - objects: Vec::with_capacity(512), + objects: Vec::with_capacity(2048), next_id: 1, + lighting: SceneLighting { + lights: Vec::new(), + light_colors: Vec::new(), + ambient: 1.0, + ambient_color: [1.0, 1.0, 1.0], + grade_shadows: [1.0, 1.0, 1.0], + grade_highlights: [1.0, 1.0, 1.0], + grade_contrast: 1.0, + saturation: 1.0, + ramp_shadow: [0.0, 0.0, 0.0], + ramp_mid: [0.0, 0.0, 0.0], + ramp_highlight: [0.0, 0.0, 0.0], + posterize: 0.0, + fog_color: [0.12, 0.10, 0.18], + }, } } @@ -146,8 +268,9 @@ impl Scene { /// Clear all objects, preserving allocated capacity for reuse. pub fn clear(&mut self) { self.objects.clear(); - // Don't reset next_id to 1 — preserving monotonic IDs avoids - // accidental reuse if any code holds a stale ObjectId. + // Reset to 1: IDs only need frame-local uniqueness since the scene + // is rebuilt each frame. Prevents u32 overflow at 800 objs * 60fps. + self.next_id = 1; } /// Iterate over all visible objects. @@ -210,13 +333,13 @@ mod tests { assert_eq!(scene.object_count(), 10); scene.clear(); assert_eq!(scene.object_count(), 0); - // IDs keep incrementing after clear (monotonic to avoid stale ID reuse) + // IDs reset after clear (frame-local uniqueness, prevents u32 overflow) let id = scene.add( MeshType::Cuboid, MaterialType::Unlit { color: Vec4::ONE }, Transform::default(), ); - assert!(id > 10); + assert_eq!(id, 1); } #[test] diff --git a/crates/breakpoint-client/src/shaders_gl/fog_layer.frag b/crates/breakpoint-client/src/shaders_gl/fog_layer.frag new file mode 100644 index 0000000..7c7bfaf --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/fog_layer.frag @@ -0,0 +1,47 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; +in vec3 v_world_pos; + +uniform float u_time; +uniform vec4 u_color; // fog color + alpha +uniform float u_intensity; // fog density + +out vec4 frag_color; + +// Simple value noise +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +void main() { + // Multi-layer scrolling fog + float wx = v_world_pos.x; + + // Two layers at different speeds and scales + float fog1 = noise(vec2(wx * 0.5 + u_time * 0.3, v_uv.y * 2.0 + u_time * 0.1)); + float fog2 = noise(vec2(wx * 0.8 - u_time * 0.2, v_uv.y * 3.0 - u_time * 0.15)); + float fog = (fog1 + fog2) * 0.5; + + // Height-based density falloff (thicker at bottom) + float height_fade = 1.0 - smoothstep(0.0, 1.0, v_uv.y); + + float alpha = fog * height_fade * u_intensity * u_color.a; + if (alpha < 0.01) { + discard; + } + + frag_color = vec4(u_color.rgb, alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/fog_layer.vert b/crates/breakpoint-client/src/shaders_gl/fog_layer.vert new file mode 100644 index 0000000..bd40f97 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/fog_layer.vert @@ -0,0 +1,19 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; +uniform mat4 u_model; + +out vec2 v_uv; +out vec3 v_world_pos; + +void main() { + vec4 world = u_model * vec4(a_position, 1.0); + v_world_pos = world.xyz; + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/godrays.frag b/crates/breakpoint-client/src/shaders_gl/godrays.frag new file mode 100644 index 0000000..d6119f3 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/godrays.frag @@ -0,0 +1,58 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform vec4 u_color; // ray color + alpha +uniform float u_intensity; // overall ray brightness +uniform float u_time; +uniform float u_speed; // ray animation speed (reusing uniform) + +out vec4 frag_color; + +void main() { + // Center UV to [-1, 1] + vec2 uv = v_uv * 2.0 - 1.0; + + // Light source at top-center of quad + vec2 light_pos = vec2(0.0, 1.0); + vec2 delta = uv - light_pos; + + // Radial blur: sample along ray direction + float intensity = 0.0; + const int NUM_SAMPLES = 16; + vec2 step_dir = delta / float(NUM_SAMPLES); + + vec2 sample_pos = light_pos; + for (int i = 0; i < NUM_SAMPLES; i++) { + sample_pos += step_dir; + + // Compute angular ray pattern (8 rays with noise) + float angle = atan(sample_pos.y, sample_pos.x); + float r = length(sample_pos - light_pos); + + // Create ray pattern with angular frequency + float ray = sin(angle * 4.0 + u_time * u_speed) * 0.5 + 0.5; + ray *= sin(angle * 7.0 - u_time * u_speed * 0.7) * 0.3 + 0.7; + + // Attenuate with distance from source + float atten = 1.0 - smoothstep(0.0, 2.0, r); + + intensity += ray * atten; + } + intensity /= float(NUM_SAMPLES); + + // Distance falloff from light source + float dist = length(delta); + float falloff = 1.0 - smoothstep(0.0, 1.8, dist); + + // Downward fade (rays should fade toward bottom) + float vertical_fade = smoothstep(-1.0, 0.5, uv.y); + + float final_alpha = intensity * falloff * vertical_fade * u_intensity * u_color.a; + if (final_alpha < 0.01) { + discard; + } + + frag_color = vec4(u_color.rgb * (1.0 + intensity * 0.3), final_alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/godrays.vert b/crates/breakpoint-client/src/shaders_gl/godrays.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/godrays.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/health_bar.frag b/crates/breakpoint-client/src/shaders_gl/health_bar.frag new file mode 100644 index 0000000..100bbb7 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/health_bar.frag @@ -0,0 +1,42 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform vec4 u_color; // health bar fill color +uniform float u_intensity; // fill amount (0.0-1.0) +uniform float u_time; + +out vec4 frag_color; + +void main() { + // Border (dark outline) + float border = 0.08; + if (v_uv.x < border || v_uv.x > 1.0 - border || + v_uv.y < border || v_uv.y > 1.0 - border) { + frag_color = vec4(0.05, 0.02, 0.08, 0.9); + return; + } + + // Background (dark) + float fill = u_intensity; + if (v_uv.x > fill) { + frag_color = vec4(0.1, 0.08, 0.12, 0.6); + return; + } + + // Fill color with subtle pulse animation + float pulse = 1.0 + 0.05 * sin(u_time * 3.0); + vec3 bar_color = u_color.rgb * pulse; + + // Color gradient: green -> yellow -> red based on health + if (fill < 0.3) { + bar_color = mix(vec3(0.8, 0.1, 0.1), bar_color, fill / 0.3); + } + + // Bright edge at fill boundary + float edge = smoothstep(0.02, 0.0, abs(v_uv.x - fill)); + bar_color += vec3(0.3) * edge; + + frag_color = vec4(bar_color, 0.9); +} diff --git a/crates/breakpoint-client/src/shaders_gl/health_bar.vert b/crates/breakpoint-client/src/shaders_gl/health_bar.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/health_bar.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/magic_circle.frag b/crates/breakpoint-client/src/shaders_gl/magic_circle.frag new file mode 100644 index 0000000..2273360 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/magic_circle.frag @@ -0,0 +1,60 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform float u_rotation; // current rotation angle +uniform float u_pulse; // 0.0-1.0 pulse intensity +uniform vec4 u_color; +uniform float u_time; + +out vec4 frag_color; + +void main() { + // Center UV to [-1, 1] + vec2 uv = v_uv * 2.0 - 1.0; + + // Rotate UVs + float c = cos(u_rotation); + float s = sin(u_rotation); + vec2 ruv = vec2(uv.x * c - uv.y * s, uv.x * s + uv.y * c); + + float r = length(ruv); + float angle = atan(ruv.y, ruv.x); + + // Discard outside circle + if (r > 1.0) { + discard; + } + + // Concentric rings + float ring1 = smoothstep(0.02, 0.0, abs(r - 0.9)); + float ring2 = smoothstep(0.02, 0.0, abs(r - 0.7)); + float ring3 = smoothstep(0.015, 0.0, abs(r - 0.45)); + + // Angular rune modulation (8 segments) + float runes = step(0.5, fract(angle * 8.0 / 6.28318 + u_time * 0.5)); + float rune_ring = runes * smoothstep(0.04, 0.0, abs(r - 0.57)) * 0.7; + + // Inner pentagram-like pattern (5-pointed star) + float star_angle = mod(angle + u_rotation * 0.5, 6.28318); + float star = abs(sin(star_angle * 2.5)) * step(r, 0.35) * step(0.1, r); + float star_line = smoothstep(0.05, 0.0, abs(star - r * 1.5)); + + // Glow toward center + float center_glow = smoothstep(0.3, 0.0, r) * 0.3; + + // Pulse: brighten everything rhythmically + float pulse_mod = 1.0 + u_pulse * 0.4 * sin(u_time * 4.0); + + // Composite + float alpha = (ring1 + ring2 + ring3 + rune_ring + star_line * 0.5 + center_glow) * pulse_mod; + alpha = clamp(alpha, 0.0, 1.0) * u_color.a; + + if (alpha < 0.01) { + discard; + } + + vec3 color = u_color.rgb * (1.0 + center_glow * 2.0); + frag_color = vec4(color, alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/magic_circle.vert b/crates/breakpoint-client/src/shaders_gl/magic_circle.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/magic_circle.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/parallax.frag b/crates/breakpoint-client/src/shaders_gl/parallax.frag new file mode 100644 index 0000000..b65874f --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/parallax.frag @@ -0,0 +1,35 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform sampler2D u_texture; +uniform vec2 u_uv_offset; // horizontal scroll offset (x) and vertical base (y) +uniform vec2 u_uv_scale; // UV scale for the layer sub-rect +uniform vec4 u_tint; +uniform float u_time; +uniform float u_intensity; // sway amplitude (reused uniform, 0.0 = no sway) +uniform float u_speed; // crossfade alpha (reused uniform, 1.0 = fully visible) + +out vec4 frag_color; + +void main() { + // Apply sway (horizontal oscillation for banners/trees) + float sway = sin(v_uv.y * 3.14159 + u_time * 1.5) * u_intensity * 0.01; + + // Apply scroll offset and wrap horizontally via fract() + float u = fract(v_uv.x * u_uv_scale.x + u_uv_offset.x + sway); + float v = (1.0 - v_uv.y) * u_uv_scale.y + u_uv_offset.y; + + vec4 texel = texture(u_texture, vec2(u, v)); + + // Apply crossfade alpha (for background transitions) + float crossfade = u_speed; // 0.0 = invisible, 1.0 = fully visible + vec4 color = texel * u_tint; + color.a *= crossfade; + + if (color.a < 0.01) { + discard; + } + frag_color = color; +} diff --git a/crates/breakpoint-client/src/shaders_gl/parallax.vert b/crates/breakpoint-client/src/shaders_gl/parallax.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/parallax.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/postprocess.frag b/crates/breakpoint-client/src/shaders_gl/postprocess.frag new file mode 100644 index 0000000..90d536b --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/postprocess.frag @@ -0,0 +1,120 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform sampler2D u_scene; +uniform vec2 u_resolution; +uniform float u_time; +uniform float u_scanline_intensity; // 0.0-1.0 +uniform float u_bloom_intensity; // 0.0-1.0 +uniform float u_vignette_intensity; // 0.0-1.0 +uniform float u_crt_curvature; // 0.0-1.0 +uniform vec3 u_grade_shadows; // shadow color tint +uniform vec3 u_grade_highlights; // highlight color tint +uniform float u_grade_contrast; // contrast (1.0 = neutral) +uniform float u_saturation; // saturation (1.0 = neutral) +uniform float u_chromatic_aberration; // pixel offset (0.0 = off) +uniform float u_film_grain; // grain intensity (0.0 = off) + +out vec4 frag_color; + +// Simple hash for film grain +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +void main() { + vec2 uv = v_uv; + + // CRT barrel distortion + if (u_crt_curvature > 0.01) { + vec2 centered = uv - 0.5; + float dist2 = dot(centered, centered); + uv = 0.5 + centered * (1.0 + dist2 * u_crt_curvature * 0.5); + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { + frag_color = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + } + + // ── Chromatic aberration (damage effect) ── + vec3 color; + if (u_chromatic_aberration > 0.01) { + float ca = u_chromatic_aberration / u_resolution.x; + color.r = texture(u_scene, uv + vec2(ca, 0.0)).r; + color.g = texture(u_scene, uv).g; + color.b = texture(u_scene, uv - vec2(ca, 0.0)).b; + } else { + color = texture(u_scene, uv).rgb; + } + + // ── Bloom: 9-tap Gaussian-approximation separable sampling ── + if (u_bloom_intensity > 0.01) { + vec2 texel = 1.0 / u_resolution; + vec3 bloom = vec3(0.0); + // Gaussian weights approximation for 9 taps + float weights[5] = float[](0.227, 0.194, 0.122, 0.054, 0.016); + // Horizontal + vertical combined (cross pattern) + bloom += texture(u_scene, uv).rgb * weights[0]; + for (int i = 1; i < 5; i++) { + float o = float(i) * 1.5; // spread samples wider + bloom += texture(u_scene, uv + vec2(o * texel.x, 0.0)).rgb * weights[i]; + bloom += texture(u_scene, uv - vec2(o * texel.x, 0.0)).rgb * weights[i]; + bloom += texture(u_scene, uv + vec2(0.0, o * texel.y)).rgb * weights[i]; + bloom += texture(u_scene, uv - vec2(0.0, o * texel.y)).rgb * weights[i]; + } + bloom /= 2.0; // normalize (center counted once, each pair counted twice) + // Luminance threshold: only bloom bright areas + float lum = dot(bloom, vec3(0.299, 0.587, 0.114)); + bloom *= smoothstep(0.4, 0.8, lum); + color += bloom * u_bloom_intensity; + } + + // ── Color grading ── + // Split toning: tint shadows and highlights separately + float luminance = dot(color, vec3(0.299, 0.587, 0.114)); + vec3 shadow_blend = mix(vec3(1.0), u_grade_shadows, 1.0 - luminance); + vec3 highlight_blend = mix(vec3(1.0), u_grade_highlights, luminance); + color *= shadow_blend * highlight_blend; + + // Contrast adjustment (pivot at 0.5) + if (abs(u_grade_contrast - 1.0) > 0.01) { + color = (color - 0.5) * u_grade_contrast + 0.5; + } + + // Saturation adjustment + if (abs(u_saturation - 1.0) > 0.01) { + float gray = dot(color, vec3(0.299, 0.587, 0.114)); + color = mix(vec3(gray), color, u_saturation); + } + + // ── CRT scanlines ── + if (u_scanline_intensity > 0.01) { + float scanline = 0.8 + 0.2 * sin(gl_FragCoord.y * 3.14159); + color *= mix(1.0, scanline, u_scanline_intensity); + // RGB sub-pixel shift + float shift = 0.5 / u_resolution.x * u_scanline_intensity; + color.r = texture(u_scene, uv + vec2(shift, 0.0)).r; + color.b = texture(u_scene, uv - vec2(shift, 0.0)).b; + } + + // ── Film grain ── + if (u_film_grain > 0.01) { + float grain = hash(gl_FragCoord.xy + fract(u_time * 100.0)) * 2.0 - 1.0; + color += vec3(grain * u_film_grain * 0.1); + } + + // ── Vignette ── + if (u_vignette_intensity > 0.01) { + vec2 centered = uv - 0.5; + float vignette = 1.0 - dot(centered, centered) * 2.0; + vignette = clamp(vignette, 0.0, 1.0); + color *= mix(1.0, vignette, u_vignette_intensity); + } + + // Clamp to valid range + color = clamp(color, vec3(0.0), vec3(1.0)); + + frag_color = vec4(color, 1.0); +} diff --git a/crates/breakpoint-client/src/shaders_gl/postprocess.vert b/crates/breakpoint-client/src/shaders_gl/postprocess.vert new file mode 100644 index 0000000..4949601 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/postprocess.vert @@ -0,0 +1,14 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +out vec2 v_uv; + +void main() { + // Fullscreen quad: position [-0.5, 0.5] -> NDC [-1, 1] + gl_Position = vec4(a_position.xy * 2.0, 0.0, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/slash_arc.frag b/crates/breakpoint-client/src/shaders_gl/slash_arc.frag new file mode 100644 index 0000000..c3435fb --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/slash_arc.frag @@ -0,0 +1,60 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform float u_arc_progress; // 0.0 = start, 1.0 = full swing +uniform float u_arc_angle; // starting angle in radians +uniform vec4 u_color; +uniform float u_time; + +out vec4 frag_color; + +void main() { + // Center UV to [-1, 1] + vec2 uv = v_uv * 2.0 - 1.0; + + // Polar coordinates + float angle = atan(uv.y, uv.x); + float r = length(uv); + + // Normalize angle to [0, 2pi] + float a = mod(angle - u_arc_angle + 6.28318, 6.28318); + + // Arc sweep range (half circle) + float sweep = u_arc_progress * 3.14159; + float arc_dist = a / max(sweep, 0.01); + + // Only render within the swept arc + if (a > sweep || r < 0.2 || r > 1.0) { + discard; + } + + // Radial intensity: bright at the arc radius ~0.7, falloff to edges + float ring = 1.0 - abs(r - 0.6) * 3.0; + ring = clamp(ring, 0.0, 1.0); + + // Leading edge brightness + float edge = smoothstep(0.3, 0.0, abs(arc_dist - 1.0)); + + // Speed lines: radial hash noise for anime-style streaks + float hash = fract(sin(dot(vec2(angle * 10.0, r * 5.0), vec2(12.9898, 78.233))) * 43758.5453); + float speed_line = step(0.7, hash) * ring; + + // Trail fade behind leading edge + float trail = (1.0 - arc_dist) * 0.6; + + // Composite + vec3 hot = vec3(1.0, 1.0, 0.95); + vec3 warm = u_color.rgb; + vec3 color = mix(warm, hot, edge * 0.8); + + float alpha = (edge * 0.9 + trail * 0.5 + speed_line * 0.3) * ring; + alpha *= u_color.a; + + if (alpha < 0.01) { + discard; + } + + frag_color = vec4(color, alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/slash_arc.vert b/crates/breakpoint-client/src/shaders_gl/slash_arc.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/slash_arc.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/sprite.frag b/crates/breakpoint-client/src/shaders_gl/sprite.frag new file mode 100644 index 0000000..dbc14cb --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/sprite.frag @@ -0,0 +1,125 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; +in vec3 v_world_pos; + +uniform sampler2D u_texture; +uniform sampler2D u_palette; // 256x1 palette texture (indexed mode) +uniform float u_use_palette; // >0.5 enables indexed palette lookup +uniform vec4 u_sprite_rect; // atlas sub-region: (u0, v0, u1, v1) +uniform vec4 u_tint; +uniform float u_flip_x; + +// Dynamic lighting uniforms (32 colored lights) +uniform vec4 u_lights[32]; // xy=position, z=intensity, w=radius +uniform vec4 u_light_color[32]; // rgb=color, a=type (0=point) +uniform int u_light_count; +uniform float u_ambient; // 0.0 = pitch black, 1.0 = fully lit +uniform vec3 u_ambient_color; // per-room ambient RGB tint +uniform float u_fog_density; // ground fog density +uniform vec3 u_fog_color; // per-room fog color +uniform float u_outline_width; // >0 enables dark pixel outline on characters +uniform float u_dissolve; // 0.0=solid, 1.0=fully dissolved (death effect) + +// GBA-style color ramp (all zero = disabled) +uniform vec3 u_ramp_shadow; // dark palette color +uniform vec3 u_ramp_mid; // midtone palette color +uniform vec3 u_ramp_highlight; // bright palette color +uniform float u_posterize; // 0.0=off, 31.0=GBA 5-bit depth + +out vec4 frag_color; + +void main() { + // Map quad UV [0,1] to atlas sub-rect + float u = mix(u_sprite_rect.x, u_sprite_rect.z, v_uv.x); + // Flip horizontally if u_flip_x > 0.5 + if (u_flip_x > 0.5) { + u = u_sprite_rect.x + u_sprite_rect.z - u; + } + float v = mix(u_sprite_rect.w, u_sprite_rect.y, v_uv.y); + vec4 texel = texture(u_texture, vec2(u, v)); + vec4 color; + // Indexed palette mode (deferred activation: u_use_palette always 0.0 for now) + if (u_use_palette > 0.5) { + float index = texel.r; + color = texture(u_palette, vec2(index, 0.5)); + color.a = texel.a; + } else { + color = texel * u_tint; + } + // MBAACC-style binary alpha: snap to 0 or 1 for crisp pixel edges + color.a = step(0.5, color.a) * u_tint.a; + + // Pixel outline: if this pixel is transparent but a neighbor has alpha, draw dark outline + if (u_outline_width > 0.0 && color.a < 0.01) { + vec2 texel_size = 1.0 / vec2(textureSize(u_texture, 0)); + vec2 uv_pos = vec2(u, v); + float max_a = 0.0; + max_a = max(max_a, texture(u_texture, uv_pos + vec2(texel_size.x, 0.0)).a); + max_a = max(max_a, texture(u_texture, uv_pos - vec2(texel_size.x, 0.0)).a); + max_a = max(max_a, texture(u_texture, uv_pos + vec2(0.0, texel_size.y)).a); + max_a = max(max_a, texture(u_texture, uv_pos - vec2(0.0, texel_size.y)).a); + if (max_a > 0.5) { + frag_color = vec4(0.08, 0.04, 0.12, 0.9); + return; + } + discard; + } + + if (color.a < 0.01) { + discard; + } + + // Pixel-dissolve death effect: hash-based noise discard + if (u_dissolve > 0.0) { + float noise = fract(sin(dot(floor(v_uv * 40.0), vec2(12.9898, 78.233))) * 43758.5453); + if (noise < u_dissolve) discard; + } + + // GBA-style color ramp: map luminance through a 3-point palette + bool ramp_active = (u_ramp_shadow.r + u_ramp_shadow.g + u_ramp_shadow.b) > 0.01; + if (ramp_active) { + float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + vec3 ramped; + if (lum < 0.5) { + ramped = mix(u_ramp_shadow, u_ramp_mid, lum * 2.0); + } else { + ramped = mix(u_ramp_mid, u_ramp_highlight, (lum - 0.5) * 2.0); + } + // Blend ramp with original tinted color to preserve some texture detail + color.rgb = mix(color.rgb, ramped, 0.35); + } + + // Apply dynamic lighting if lights are present + if (u_light_count > 0) { + vec3 light = u_ambient * u_ambient_color; + for (int i = 0; i < 32; i++) { + if (i >= u_light_count) break; + vec2 light_pos = u_lights[i].xy; + float intensity = u_lights[i].z; + float radius = u_lights[i].w; + vec3 lcolor = u_light_color[i].rgb; + float dist = distance(v_world_pos.xy, light_pos); + float attenuation = 1.0 - smoothstep(0.0, radius, dist); + light += attenuation * intensity * lcolor; + } + // GBA-style: enforce minimum visibility floor so dark areas stay readable + // Uses u_ambient to scale the floor: darker rooms get a lower floor + light = max(light, vec3(u_ambient * 0.2)); + color.rgb *= clamp(light, vec3(0.0), vec3(1.5)); + } + + // GBA 5-bit posterization: reduces color depth for authentic banding + if (u_posterize > 0.5) { + color.rgb = floor(color.rgb * u_posterize) / u_posterize; + } + + // Ground fog effect (per-room colored) + if (u_fog_density > 0.01) { + float fog = smoothstep(0.0, 3.0, v_world_pos.y); + color.rgb = mix(u_fog_color, color.rgb, mix(1.0 - u_fog_density * 0.5, 1.0, fog)); + } + + frag_color = color; +} diff --git a/crates/breakpoint-client/src/shaders_gl/sprite.vert b/crates/breakpoint-client/src/shaders_gl/sprite.vert new file mode 100644 index 0000000..bd40f97 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/sprite.vert @@ -0,0 +1,19 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; +uniform mat4 u_model; + +out vec2 v_uv; +out vec3 v_world_pos; + +void main() { + vec4 world = u_model * vec4(a_position, 1.0); + v_world_pos = world.xyz; + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/sprite_batch.frag b/crates/breakpoint-client/src/shaders_gl/sprite_batch.frag new file mode 100644 index 0000000..ba98005 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/sprite_batch.frag @@ -0,0 +1,81 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; +in vec3 v_world_pos; +in vec4 v_tint; + +uniform sampler2D u_texture; + +// Dynamic lighting uniforms (32 colored lights) +uniform vec4 u_lights[32]; // xy=position, z=intensity, w=radius +uniform vec4 u_light_color[32]; // rgb=color, a=type (0=point) +uniform int u_light_count; +uniform float u_ambient; // 0.0 = pitch black, 1.0 = fully lit +uniform vec3 u_ambient_color; // per-room ambient RGB tint +uniform float u_fog_density; // ground fog density +uniform vec3 u_fog_color; // per-room fog color + +// GBA-style color ramp (all zero = disabled) +uniform vec3 u_ramp_shadow; // dark palette color +uniform vec3 u_ramp_mid; // midtone palette color +uniform vec3 u_ramp_highlight; // bright palette color +uniform float u_posterize; // 0.0=off, 31.0=GBA 5-bit depth + +out vec4 frag_color; + +void main() { + // v_uv is already in atlas space (pre-computed on CPU) + vec4 texel = texture(u_texture, v_uv); + vec4 color = texel * v_tint; + + // MBAACC-style binary alpha: snap to 0 or 1 for crisp pixel edges + color.a = step(0.5, color.a) * v_tint.a; + + if (color.a < 0.01) { + discard; + } + + // GBA-style color ramp: map luminance through a 3-point palette + bool ramp_active = (u_ramp_shadow.r + u_ramp_shadow.g + u_ramp_shadow.b) > 0.01; + if (ramp_active) { + float lum = dot(color.rgb, vec3(0.299, 0.587, 0.114)); + vec3 ramped; + if (lum < 0.5) { + ramped = mix(u_ramp_shadow, u_ramp_mid, lum * 2.0); + } else { + ramped = mix(u_ramp_mid, u_ramp_highlight, (lum - 0.5) * 2.0); + } + color.rgb = mix(color.rgb, ramped, 0.35); + } + + // Apply dynamic lighting if lights are present + if (u_light_count > 0) { + vec3 light = u_ambient * u_ambient_color; + for (int i = 0; i < 32; i++) { + if (i >= u_light_count) break; + vec2 light_pos = u_lights[i].xy; + float intensity = u_lights[i].z; + float radius = u_lights[i].w; + vec3 lcolor = u_light_color[i].rgb; + float dist = distance(v_world_pos.xy, light_pos); + float attenuation = 1.0 - smoothstep(0.0, radius, dist); + light += attenuation * intensity * lcolor; + } + light = max(light, vec3(u_ambient * 0.2)); + color.rgb *= clamp(light, vec3(0.0), vec3(1.5)); + } + + // GBA 5-bit posterization + if (u_posterize > 0.5) { + color.rgb = floor(color.rgb * u_posterize) / u_posterize; + } + + // Ground fog effect (per-room colored) + if (u_fog_density > 0.01) { + float fog = smoothstep(0.0, 3.0, v_world_pos.y); + color.rgb = mix(u_fog_color, color.rgb, mix(1.0 - u_fog_density * 0.5, 1.0, fog)); + } + + frag_color = color; +} diff --git a/crates/breakpoint-client/src/shaders_gl/sprite_batch.vert b/crates/breakpoint-client/src/shaders_gl/sprite_batch.vert new file mode 100644 index 0000000..4bf4150 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/sprite_batch.vert @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +// Per-vertex attributes (pre-computed on CPU) +layout(location = 0) in vec3 a_position; // world-space position +layout(location = 1) in vec2 a_uv; // atlas UV (pre-computed) +layout(location = 2) in vec4 a_tint; // per-sprite tint + +uniform mat4 u_mvp; // view-projection matrix + +out vec2 v_uv; +out vec3 v_world_pos; +out vec4 v_tint; + +void main() { + v_uv = a_uv; + v_world_pos = a_position; + v_tint = a_tint; + gl_Position = u_mvp * vec4(a_position, 1.0); +} diff --git a/crates/breakpoint-client/src/shaders_gl/water.frag b/crates/breakpoint-client/src/shaders_gl/water.frag new file mode 100644 index 0000000..dece640 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/water.frag @@ -0,0 +1,104 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; +in vec3 v_world_pos; + +uniform float u_time; +uniform vec4 u_color; // water base color (RGBA) +uniform float u_depth; // visual depth: 0.0=surface tile, 1.0=deep tile +uniform float u_wave_speed; // wave animation speed + +out vec4 frag_color; + +// ── Noise functions ────────────────────────────────────────────── + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} + +float value_noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); // smoothstep interpolation + float a = hash(i); + float b = hash(i + vec2(1.0, 0.0)); + float c = hash(i + vec2(0.0, 1.0)); + float d = hash(i + vec2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +// ── Main ───────────────────────────────────────────────────────── + +void main() { + float wx = v_world_pos.x; + float wy = v_world_pos.y; + float t = u_time * u_wave_speed; + + // Normalized vertical position: 0 = bottom of tile, 1 = top of tile + float surface_line = 1.0 - v_uv.y; + + // ── Multi-frequency surface waves ── + float wave = sin(wx * 3.0 + t * 1.0) * 0.04 + + sin(wx * 7.0 - t * 1.3) * 0.02 + + sin(wx * 13.0 + t * 2.1) * 0.01; + + // Discard above surface wave (only for surface tiles) + if (u_depth < 0.5 && surface_line > 0.92 + wave) { + discard; + } + + // ── Depth-dependent color absorption ── + // Surface tiles: depth factor based on v_uv.y + // Deep tiles: everything is fully deep + float pixel_depth = u_depth < 0.5 ? v_uv.y : 1.0; + + // Blue-shift absorption: reds/greens absorbed faster than blues + vec3 base = u_color.rgb; + base.r *= mix(1.0, 0.5, pixel_depth); + base.g *= mix(1.0, 0.65, pixel_depth); + base.b *= mix(1.0, 0.9, pixel_depth); + // Overall darkening with depth + base *= mix(1.0, 0.6, pixel_depth); + + // ── Dual-layer scrolling caustics ── + vec2 caustic_uv1 = vec2(wx * 2.5 + t * 0.2, wy * 2.5 + t * 0.15); + vec2 caustic_uv2 = vec2(wx * 3.2 - t * 0.18, wy * 2.8 + t * 0.25); + float c1 = value_noise(caustic_uv1); + float c2 = value_noise(caustic_uv2); + // Combine and sharpen with smoothstep + float caustic = smoothstep(0.35, 0.7, c1 * c2 * 2.0); + // Caustics are brighter near surface, dimmer deep + float caustic_strength = mix(0.35, 0.1, pixel_depth); + base += vec3(caustic * caustic_strength * 0.4, + caustic * caustic_strength * 0.6, + caustic * caustic_strength); + + // ── Surface foam line (only on surface tiles) ── + if (u_depth < 0.5) { + float foam_noise = value_noise(vec2(wx * 6.0 + t * 0.5, 0.0)) * 0.05; + float foam_line = smoothstep(0.05, 0.0, abs(surface_line - 0.88 - wave - foam_noise)); + // Secondary thin foam line + float foam2 = smoothstep(0.03, 0.0, abs(surface_line - 0.82 - wave * 0.5)) * 0.4; + base += vec3(0.7, 0.8, 0.9) * (foam_line + foam2); + } + + // ── Surface highlight with Fresnel approximation ── + if (u_depth < 0.5) { + // Bright specular-like highlight along wave peaks + float highlight = smoothstep(0.02, 0.0, abs(surface_line - 0.90 - wave)); + // Fresnel: brighter when viewed at grazing angle (approximated by surface proximity) + float fresnel = pow(1.0 - pixel_depth, 3.0) * 0.5; + base += vec3(0.4, 0.5, 0.6) * (highlight + fresnel * 0.3); + } + + // ── Subtle animated shimmer ── + float shimmer = sin(wx * 20.0 + wy * 15.0 + t * 3.0) * 0.02; + base += vec3(shimmer) * (1.0 - pixel_depth); + + // ── Depth-based opacity ── + // Surface: more transparent (0.45), deep: more opaque (0.8) + float alpha = mix(0.45, 0.8, pixel_depth) * u_color.a; + + frag_color = vec4(base, alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/water.vert b/crates/breakpoint-client/src/shaders_gl/water.vert new file mode 100644 index 0000000..bd40f97 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/water.vert @@ -0,0 +1,19 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; +uniform mat4 u_model; + +out vec2 v_uv; +out vec3 v_world_pos; + +void main() { + vec4 world = u_model * vec4(a_position, 1.0); + v_world_pos = world.xyz; + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/shaders_gl/whip.frag b/crates/breakpoint-client/src/shaders_gl/whip.frag new file mode 100644 index 0000000..8577426 --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/whip.frag @@ -0,0 +1,43 @@ +#version 300 es +precision highp float; + +in vec2 v_uv; + +uniform vec4 u_color; +uniform float u_arc_progress; // 0.0 = start, 1.0 = full swing +uniform float u_time; + +out vec4 frag_color; + +void main() { + // Arc sweep: bright leading edge fading to trail + float arc_angle = u_arc_progress * 3.14159; + + // UV.x maps along the arc, UV.y maps perpendicular + float arc_pos = v_uv.x; + float perp = abs(v_uv.y - 0.5) * 2.0; + + // Leading edge is at arc_progress position + float dist_from_edge = abs(arc_pos - u_arc_progress); + float edge_brightness = smoothstep(0.3, 0.0, dist_from_edge); + + // Trail behind the leading edge + float trail = step(arc_pos, u_arc_progress) * (1.0 - arc_pos / max(u_arc_progress, 0.01)); + + // Perpendicular falloff (thin arc line) + float width_falloff = smoothstep(1.0, 0.3, perp); + + // Color: white-hot at leading edge, fading to warm color + vec3 hot = vec3(1.0, 1.0, 0.9); + vec3 warm = u_color.rgb; + vec3 color = mix(warm, hot, edge_brightness); + + float alpha = (edge_brightness * 0.8 + trail * 0.4) * width_falloff; + alpha *= u_color.a; + + if (alpha < 0.01) { + discard; + } + + frag_color = vec4(color, alpha); +} diff --git a/crates/breakpoint-client/src/shaders_gl/whip.vert b/crates/breakpoint-client/src/shaders_gl/whip.vert new file mode 100644 index 0000000..abee9df --- /dev/null +++ b/crates/breakpoint-client/src/shaders_gl/whip.vert @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec3 a_position; +layout(location = 1) in vec3 a_normal; +layout(location = 2) in vec2 a_uv; + +uniform mat4 u_mvp; + +out vec2 v_uv; + +void main() { + gl_Position = u_mvp * vec4(a_position, 1.0); + v_uv = a_uv; +} diff --git a/crates/breakpoint-client/src/sprite_atlas.rs b/crates/breakpoint-client/src/sprite_atlas.rs new file mode 100644 index 0000000..b2f1950 --- /dev/null +++ b/crates/breakpoint-client/src/sprite_atlas.rs @@ -0,0 +1,1696 @@ +use std::collections::HashMap; + +use glam::Vec4; + +/// UV sub-region within a texture atlas. +#[derive(Debug, Clone, Copy)] +pub struct SpriteRegion { + pub u0: f32, + pub v0: f32, + pub u1: f32, + pub v1: f32, +} + +impl SpriteRegion { + /// Convert to Vec4 for shader uniform (u0, v0, u1, v1). + pub fn to_vec4(self) -> Vec4 { + Vec4::new(self.u0, self.v0, self.u1, self.v1) + } +} + +/// Named sprite regions within a texture atlas. +pub struct SpriteSheet { + regions: HashMap<&'static str, SpriteRegion>, + atlas_width: f32, + atlas_height: f32, +} + +impl SpriteSheet { + pub fn new(atlas_width: u32, atlas_height: u32) -> Self { + Self { + regions: HashMap::new(), + atlas_width: atlas_width as f32, + atlas_height: atlas_height as f32, + } + } + + /// Add a sprite region by pixel coordinates. + pub fn add(&mut self, name: &'static str, x: u32, y: u32, w: u32, h: u32) { + let region = SpriteRegion { + u0: x as f32 / self.atlas_width, + v0: y as f32 / self.atlas_height, + u1: (x + w) as f32 / self.atlas_width, + v1: (y + h) as f32 / self.atlas_height, + }; + self.regions.insert(name, region); + } + + /// Get a sprite region by name. + pub fn get(&self, name: &str) -> Option<&SpriteRegion> { + self.regions.get(name) + } + + /// Get a sprite region, falling back to a 1x1 white pixel region. + pub fn get_or_default(&self, name: &str) -> SpriteRegion { + self.regions.get(name).copied().unwrap_or(SpriteRegion { + u0: 0.0, + v0: 0.0, + u1: 1.0 / self.atlas_width, + v1: 1.0 / self.atlas_height, + }) + } +} + +/// Animation: a sequence of sprite regions with timing. +pub struct SpriteAnimation { + pub frames: Vec, + pub frame_duration: f32, + pub looping: bool, +} + +impl SpriteAnimation { + /// Get the frame region at a given time. + pub fn frame_at(&self, time: f32) -> &SpriteRegion { + if self.frames.is_empty() { + // Should not happen, but return a safe default. + static DEFAULT: SpriteRegion = SpriteRegion { + u0: 0.0, + v0: 0.0, + u1: 0.0, + v1: 0.0, + }; + return &DEFAULT; + } + let total = self.frames.len() as f32 * self.frame_duration; + let t = if self.looping && total > 0.0 { + time % total + } else { + time.min(total - self.frame_duration) + }; + let idx = (t / self.frame_duration) as usize; + let idx = idx.min(self.frames.len() - 1); + &self.frames[idx] + } +} + +// ================================================================ +// Atlas layout constants (1024x512) +// ================================================================ + +/// Atlas dimensions. +pub const ATLAS_W: u32 = 1024; +pub const ATLAS_H: u32 = 512; + +// Y-range assignments: +// 0-63: Player sprites (16x32) — 2 rows of 64 columns +// 64-159: Enemy sprites (16x32) — 3 rows of 64 columns +// 160-287: Tile sprites (16x16) — per-theme, 8 rows x 64 cols +// 288-351: UI, props, HUD, power-ups (16x16 & 32x32) +// 352-415: Particles + combat VFX (8x8 & 32x32) +// 416-511: Reserved (caustic tex, foam, LUT strips) + +/// Build the platformer sprite sheet with all named regions. +/// Atlas is 1024x512 with 16x32 characters, 16x16 tiles, and 8x8 particles. +pub fn build_platformer_atlas() -> SpriteSheet { + let mut sheet = SpriteSheet::new(ATLAS_W, ATLAS_H); + + // ── Player sprites (16x32) — Y 0-63 ──────────────────── + add_player_sprites(&mut sheet); + + // ── Enemy sprites (16x32) — Y 64-159 ───────────────── + add_enemy_sprites(&mut sheet); + + // ── Tile sprites (16x16) — Y 160-287 ───────────────── + add_tile_sprites(&mut sheet); + + // ── Power-ups, HUD, props (16x16 & 32x32) — Y 288-351 ─ + add_ui_sprites(&mut sheet); + + // ── Particle + VFX sprites (8x8 & 32x32) — Y 352-415 ── + add_particle_sprites(&mut sheet); + + // ── Combat VFX sprites (32x32) — Y 384-415 ───────────── + add_combat_vfx_sprites(&mut sheet); + + sheet +} + +// ================================================================ +// Player sprites (16x32) — Y 0-63 +// Row 0 (Y 0-31): idle(8f), walk(8f), run(8f), jump(4f), fall(4f) +// Row 1 (Y 32-63): attack(8f), hurt(4f), death(6f), wall_slide(3f), crouch(3f), dash(4f) +// ================================================================ + +fn add_player_sprites(sheet: &mut SpriteSheet) { + let w = 16u32; + let h = 32u32; + + // Row 0 (Y=0) + let mut x = 0u32; + + // Idle: 8 frames + for i in 0..8u32 { + let name = player_frame_name("player_idle_", i); + sheet.add(name, x + i * w, 0, w, h); + } + x += 8 * w; // 256 + + // Walk: 8 frames + for i in 0..8u32 { + let name = player_frame_name("player_walk_", i); + sheet.add(name, x + i * w, 0, w, h); + } + x += 8 * w; // 512 + + // Run: 8 frames + for i in 0..8u32 { + let name = player_frame_name("player_run_", i); + sheet.add(name, x + i * w, 0, w, h); + } + x += 8 * w; // 768 + + // Jump: 4 frames + for i in 0..4u32 { + let name = player_frame_name("player_jump_", i); + sheet.add(name, x + i * w, 0, w, h); + } + x += 4 * w; // 896 + + // Fall: 4 frames + for i in 0..4u32 { + let name = player_frame_name("player_fall_", i); + sheet.add(name, x + i * w, 0, w, h); + } + + // Row 1 (Y=64) + x = 0; + + // Attack: 8 frames + for i in 0..8u32 { + let name = player_frame_name("player_attack_", i); + sheet.add(name, x + i * w, h, w, h); + } + x += 8 * w; // 256 + + // Hurt: 4 frames + for i in 0..4u32 { + let name = player_frame_name("player_hurt_", i); + sheet.add(name, x + i * w, h, w, h); + } + x += 4 * w; // 384 + + // Dead: 6 frames + for i in 0..6u32 { + let name = player_frame_name("player_dead_", i); + sheet.add(name, x + i * w, h, w, h); + } + x += 6 * w; // 576 + + // Wall slide: 3 frames + for i in 0..3u32 { + let name = player_frame_name("player_wall_slide_", i); + sheet.add(name, x + i * w, h, w, h); + } + x += 3 * w; // 672 + + // Crouch: 3 frames + for i in 0..3u32 { + let name = player_frame_name("player_crouch_", i); + sheet.add(name, x + i * w, h, w, h); + } + x += 3 * w; // 768 + + // Dash: 4 frames + for i in 0..4u32 { + let name = player_frame_name("player_dash_", i); + sheet.add(name, x + i * w, h, w, h); + } +} + +/// Map index to static frame name for player sprites. +fn player_frame_name(prefix: &str, idx: u32) -> &'static str { + match prefix { + "player_idle_" => match idx { + 0 => "player_idle_0", + 1 => "player_idle_1", + 2 => "player_idle_2", + 3 => "player_idle_3", + 4 => "player_idle_4", + 5 => "player_idle_5", + 6 => "player_idle_6", + _ => "player_idle_7", + }, + "player_walk_" => match idx { + 0 => "player_walk_0", + 1 => "player_walk_1", + 2 => "player_walk_2", + 3 => "player_walk_3", + 4 => "player_walk_4", + 5 => "player_walk_5", + 6 => "player_walk_6", + _ => "player_walk_7", + }, + "player_run_" => match idx { + 0 => "player_run_0", + 1 => "player_run_1", + 2 => "player_run_2", + 3 => "player_run_3", + 4 => "player_run_4", + 5 => "player_run_5", + 6 => "player_run_6", + _ => "player_run_7", + }, + "player_jump_" => match idx { + 0 => "player_jump_0", + 1 => "player_jump_1", + 2 => "player_jump_2", + _ => "player_jump_3", + }, + "player_fall_" => match idx { + 0 => "player_fall_0", + 1 => "player_fall_1", + 2 => "player_fall_2", + _ => "player_fall_3", + }, + "player_attack_" => match idx { + 0 => "player_attack_0", + 1 => "player_attack_1", + 2 => "player_attack_2", + 3 => "player_attack_3", + 4 => "player_attack_4", + 5 => "player_attack_5", + 6 => "player_attack_6", + _ => "player_attack_7", + }, + "player_hurt_" => match idx { + 0 => "player_hurt_0", + 1 => "player_hurt_1", + 2 => "player_hurt_2", + _ => "player_hurt_3", + }, + "player_dead_" => match idx { + 0 => "player_dead_0", + 1 => "player_dead_1", + 2 => "player_dead_2", + 3 => "player_dead_3", + 4 => "player_dead_4", + _ => "player_dead_5", + }, + "player_wall_slide_" => match idx { + 0 => "player_wall_slide_0", + 1 => "player_wall_slide_1", + _ => "player_wall_slide_2", + }, + "player_crouch_" => match idx { + 0 => "player_crouch_0", + 1 => "player_crouch_1", + _ => "player_crouch_2", + }, + "player_dash_" => match idx { + 0 => "player_dash_0", + 1 => "player_dash_1", + 2 => "player_dash_2", + _ => "player_dash_3", + }, + _ => "player_idle_0", + } +} + +// ================================================================ +// Enemy sprites (16x32) — Y 64-159 +// Row 0 (Y 64-95): Skeleton walk(4f), attack(3f), death(4f) +// Bat fly(4f), death(2f) +// Row 1 (Y 96-127): Knight walk(4f), attack(3f), death(4f) +// Medusa float(4f), death(2f) +// Row 2 (Y 128-159): Ghost drift(4f), phase(3f), death(3f) +// Gargoyle perch(2f), swoop(4f), death(3f) +// Projectile(3f) +// ================================================================ + +fn add_enemy_sprites(sheet: &mut SpriteSheet) { + let w = 16u32; + let h = 32u32; + + // Row 0 (Y=64): Skeleton + Bat + let y0 = 64u32; + let mut x = 0u32; + + // Skeleton walk: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("skeleton_walk_", i); + sheet.add(name, x + i * w, y0, w, h); + } + x += 4 * w; + + // Skeleton attack: 3 frames + for i in 0..3u32 { + let name = enemy_frame_name("skeleton_attack_", i); + sheet.add(name, x + i * w, y0, w, h); + } + x += 3 * w; + + // Skeleton death: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("skeleton_death_", i); + sheet.add(name, x + i * w, y0, w, h); + } + x += 4 * w; + + // Bat fly: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("bat_fly_", i); + sheet.add(name, x + i * w, y0, w, h); + } + x += 4 * w; + + // Bat death: 2 frames + for i in 0..2u32 { + let name = enemy_frame_name("bat_death_", i); + sheet.add(name, x + i * w, y0, w, h); + } + + // Row 1 (Y=96): Knight + Medusa + let y1 = 96u32; + x = 0; + + // Knight walk: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("knight_walk_", i); + sheet.add(name, x + i * w, y1, w, h); + } + x += 4 * w; + + // Knight attack: 3 frames + for i in 0..3u32 { + let name = enemy_frame_name("knight_attack_", i); + sheet.add(name, x + i * w, y1, w, h); + } + x += 3 * w; + + // Knight death: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("knight_death_", i); + sheet.add(name, x + i * w, y1, w, h); + } + x += 4 * w; + + // Medusa float: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("medusa_float_", i); + sheet.add(name, x + i * w, y1, w, h); + } + x += 4 * w; + + // Medusa death: 2 frames + for i in 0..2u32 { + let name = enemy_frame_name("medusa_death_", i); + sheet.add(name, x + i * w, y1, w, h); + } + + // Row 2 (Y=128): Ghost + Gargoyle + Projectile + let y2 = 128u32; + x = 0; + + // Ghost drift: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("ghost_drift_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 4 * w; + + // Ghost phase: 3 frames + for i in 0..3u32 { + let name = enemy_frame_name("ghost_phase_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 3 * w; + + // Ghost death: 3 frames + for i in 0..3u32 { + let name = enemy_frame_name("ghost_death_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 3 * w; + + // Gargoyle perch: 2 frames + for i in 0..2u32 { + let name = enemy_frame_name("gargoyle_perch_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 2 * w; + + // Gargoyle swoop: 4 frames + for i in 0..4u32 { + let name = enemy_frame_name("gargoyle_swoop_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 4 * w; + + // Gargoyle death: 3 frames + for i in 0..3u32 { + let name = enemy_frame_name("gargoyle_death_", i); + sheet.add(name, x + i * w, y2, w, h); + } + x += 3 * w; + + // Projectile: 3 frames (16x16 within the 32x64 row) + sheet.add("projectile_0", x, y2, 16, 16); + sheet.add("projectile_1", x + 16, y2, 16, 16); + sheet.add("projectile_2", x + 32, y2, 16, 16); +} + +fn enemy_frame_name(prefix: &str, idx: u32) -> &'static str { + match prefix { + "skeleton_walk_" => match idx { + 0 => "skeleton_walk_0", + 1 => "skeleton_walk_1", + 2 => "skeleton_walk_2", + _ => "skeleton_walk_3", + }, + "skeleton_attack_" => match idx { + 0 => "skeleton_attack_0", + 1 => "skeleton_attack_1", + _ => "skeleton_attack_2", + }, + "skeleton_death_" => match idx { + 0 => "skeleton_death_0", + 1 => "skeleton_death_1", + 2 => "skeleton_death_2", + _ => "skeleton_death_3", + }, + "bat_fly_" => match idx { + 0 => "bat_fly_0", + 1 => "bat_fly_1", + 2 => "bat_fly_2", + _ => "bat_fly_3", + }, + "bat_death_" => match idx { + 0 => "bat_death_0", + _ => "bat_death_1", + }, + "knight_walk_" => match idx { + 0 => "knight_walk_0", + 1 => "knight_walk_1", + 2 => "knight_walk_2", + _ => "knight_walk_3", + }, + "knight_attack_" => match idx { + 0 => "knight_attack_0", + 1 => "knight_attack_1", + _ => "knight_attack_2", + }, + "knight_death_" => match idx { + 0 => "knight_death_0", + 1 => "knight_death_1", + 2 => "knight_death_2", + _ => "knight_death_3", + }, + "medusa_float_" => match idx { + 0 => "medusa_float_0", + 1 => "medusa_float_1", + 2 => "medusa_float_2", + _ => "medusa_float_3", + }, + "medusa_death_" => match idx { + 0 => "medusa_death_0", + _ => "medusa_death_1", + }, + "ghost_drift_" => match idx { + 0 => "ghost_drift_0", + 1 => "ghost_drift_1", + 2 => "ghost_drift_2", + _ => "ghost_drift_3", + }, + "ghost_phase_" => match idx { + 0 => "ghost_phase_0", + 1 => "ghost_phase_1", + _ => "ghost_phase_2", + }, + "ghost_death_" => match idx { + 0 => "ghost_death_0", + 1 => "ghost_death_1", + _ => "ghost_death_2", + }, + "gargoyle_perch_" => match idx { + 0 => "gargoyle_perch_0", + _ => "gargoyle_perch_1", + }, + "gargoyle_swoop_" => match idx { + 0 => "gargoyle_swoop_0", + 1 => "gargoyle_swoop_1", + 2 => "gargoyle_swoop_2", + _ => "gargoyle_swoop_3", + }, + "gargoyle_death_" => match idx { + 0 => "gargoyle_death_0", + 1 => "gargoyle_death_1", + _ => "gargoyle_death_2", + }, + _ => "skeleton_walk_0", + } +} + +// ================================================================ +// Tile sprites (16x16) — Y 160-287 +// 4 visual groups × 16 bitmask tiles + shared tiles +// +// Layout at Y=160: +// Row 0 (Y 160-175): Castle Interior tiles (16 bitmask + decoratives) +// Row 1 (Y 176-191): Underground tiles (16 bitmask + decoratives) +// Row 2 (Y 192-207): Sacred tiles (16 bitmask + decoratives) +// Row 3 (Y 208-223): Fortress tiles (16 bitmask + decoratives) +// Row 4 (Y 224-239): Shared tiles (platform, spikes, checkpoint, etc.) +// Row 5-7 (Y 240-287): Decorative tiles, theme-specific props +// ================================================================ + +/// Tileset visual group for theme-based tile selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TileGroup { + CastleInterior, + Underground, + Sacred, + Fortress, +} + +fn add_tile_sprites(sheet: &mut SpriteSheet) { + let tile = 16u32; + + // Per-group bitmask tiles: 16 tiles per group (4-neighbor: UDLR = 16 combos) + // Each group starts at a known Y offset from the tile region base (320) + add_bitmask_tiles(sheet, "castle", 0, 160, tile); + add_bitmask_tiles(sheet, "underground", 0, 176, tile); + add_bitmask_tiles(sheet, "sacred", 0, 192, tile); + add_bitmask_tiles(sheet, "fortress", 0, 208, tile); + + // Theme-specific decorative tiles (after bitmask tiles in each row) + let deco_x = 16 * tile; // X=256 + + // Castle decoratives + sheet.add("castle_bookshelf", deco_x, 160, tile, tile); + sheet.add("castle_banner", deco_x + tile, 160, tile, tile); + sheet.add("castle_pillar_top", deco_x + 2 * tile, 160, tile, tile); + sheet.add("castle_pillar_mid", deco_x + 3 * tile, 160, tile, tile); + + // Underground decoratives + sheet.add("underground_coffin", deco_x, 176, tile, tile); + sheet.add("underground_bones", deco_x + tile, 176, tile, tile); + sheet.add("underground_mushroom", deco_x + 2 * tile, 176, tile, tile); + + // Sacred decoratives + sheet.add("sacred_altar", deco_x, 192, tile, tile); + sheet.add("sacred_candle", deco_x + tile, 192, tile, tile); + sheet.add("sacred_rune", deco_x + 2 * tile, 192, tile, tile); + + // Fortress decoratives + sheet.add("fortress_weapon_rack", deco_x, 208, tile, tile); + sheet.add("fortress_anvil", deco_x + tile, 208, tile, tile); + sheet.add("fortress_shield", deco_x + 2 * tile, 208, tile, tile); + + // Shared tiles (Y=224) + let sy = 224u32; + let mut x = 0u32; + + // Platform: 3 variants + sheet.add("platform_0", x, sy, tile, tile); + sheet.add("platform_1", x + tile, sy, tile, tile); + sheet.add("platform_2", x + 2 * tile, sy, tile, tile); + x += 3 * tile; + + // Spikes: 2 variants + sheet.add("spikes_0", x, sy, tile, tile); + sheet.add("spikes_1", x + tile, sy, tile, tile); + x += 2 * tile; + + // Checkpoint flags + sheet.add("checkpoint_flag_down_0", x, sy, tile, tile); + sheet.add("checkpoint_flag_down_1", x + tile, sy, tile, tile); + sheet.add("checkpoint_flag_up_0", x + 2 * tile, sy, tile, tile); + sheet.add("checkpoint_flag_up_1", x + 3 * tile, sy, tile, tile); + x += 4 * tile; + + // Finish gate: 2 frames + sheet.add("finish_gate_0", x, sy, tile, tile); + sheet.add("finish_gate_1", x + tile, sy, tile, tile); + x += 2 * tile; + + // Ladder + sheet.add("ladder", x, sy, tile, tile); + x += tile; + + // Breakable wall: 2 variants + sheet.add("breakable_wall_0", x, sy, tile, tile); + sheet.add("breakable_wall_1", x + tile, sy, tile, tile); + x += 2 * tile; + + // Torch: 4 frames + sheet.add("torch_0", x, sy, tile, tile); + sheet.add("torch_1", x + tile, sy, tile, tile); + sheet.add("torch_2", x + 2 * tile, sy, tile, tile); + sheet.add("torch_3", x + 3 * tile, sy, tile, tile); + x += 4 * tile; + + // Stained glass + sheet.add("stained_glass", x, sy, tile, tile); + x += tile; + + // Water tiles + sheet.add("water_surface", x, sy, tile, tile); + sheet.add("water_body", x + tile, sy, tile, tile); + x += 2 * tile; + + // Decorative tiles + sheet.add("cobweb", x, sy, tile, tile); + sheet.add("chain_0", x + tile, sy, tile, tile); + sheet.add("chain_1", x + 2 * tile, sy, tile, tile); + + // Legacy aliases: map old stone_brick_* names to castle bitmask tiles + // so existing code that references stone_brick_top etc. still works. + add_legacy_stone_aliases(sheet); +} + +/// Add 16 bitmask tiles for a given visual group. +/// Bitmask: bit 0=Up, bit 1=Down, bit 2=Left, bit 3=Right (4 neighbors = 16 combos). +fn add_bitmask_tiles(sheet: &mut SpriteSheet, group: &str, base_x: u32, base_y: u32, tile: u32) { + for i in 0..16u32 { + let name = bitmask_tile_name(group, i); + sheet.add(name, base_x + i * tile, base_y, tile, tile); + } +} + +/// Get the static name for a bitmask tile. Index 0-15 encodes neighbor presence. +fn bitmask_tile_name(group: &str, idx: u32) -> &'static str { + match group { + "castle" => match idx { + 0 => "castle_tile_0", + 1 => "castle_tile_1", + 2 => "castle_tile_2", + 3 => "castle_tile_3", + 4 => "castle_tile_4", + 5 => "castle_tile_5", + 6 => "castle_tile_6", + 7 => "castle_tile_7", + 8 => "castle_tile_8", + 9 => "castle_tile_9", + 10 => "castle_tile_10", + 11 => "castle_tile_11", + 12 => "castle_tile_12", + 13 => "castle_tile_13", + 14 => "castle_tile_14", + _ => "castle_tile_15", + }, + "underground" => match idx { + 0 => "underground_tile_0", + 1 => "underground_tile_1", + 2 => "underground_tile_2", + 3 => "underground_tile_3", + 4 => "underground_tile_4", + 5 => "underground_tile_5", + 6 => "underground_tile_6", + 7 => "underground_tile_7", + 8 => "underground_tile_8", + 9 => "underground_tile_9", + 10 => "underground_tile_10", + 11 => "underground_tile_11", + 12 => "underground_tile_12", + 13 => "underground_tile_13", + 14 => "underground_tile_14", + _ => "underground_tile_15", + }, + "sacred" => match idx { + 0 => "sacred_tile_0", + 1 => "sacred_tile_1", + 2 => "sacred_tile_2", + 3 => "sacred_tile_3", + 4 => "sacred_tile_4", + 5 => "sacred_tile_5", + 6 => "sacred_tile_6", + 7 => "sacred_tile_7", + 8 => "sacred_tile_8", + 9 => "sacred_tile_9", + 10 => "sacred_tile_10", + 11 => "sacred_tile_11", + 12 => "sacred_tile_12", + 13 => "sacred_tile_13", + 14 => "sacred_tile_14", + _ => "sacred_tile_15", + }, + _ => match idx { + 0 => "fortress_tile_0", + 1 => "fortress_tile_1", + 2 => "fortress_tile_2", + 3 => "fortress_tile_3", + 4 => "fortress_tile_4", + 5 => "fortress_tile_5", + 6 => "fortress_tile_6", + 7 => "fortress_tile_7", + 8 => "fortress_tile_8", + 9 => "fortress_tile_9", + 10 => "fortress_tile_10", + 11 => "fortress_tile_11", + 12 => "fortress_tile_12", + 13 => "fortress_tile_13", + 14 => "fortress_tile_14", + _ => "fortress_tile_15", + }, + } +} + +/// Compute 4-neighbor bitmask for a stone brick tile. +/// bit 0 = solid above, bit 1 = solid below, bit 2 = solid left, bit 3 = solid right. +pub fn stone_brick_bitmask( + course: &breakpoint_platformer::course_gen::Course, + tx: i32, + ty: i32, +) -> u32 { + use breakpoint_platformer::course_gen::Tile; + let is_solid = |dx: i32, dy: i32| -> bool { + matches!(course.get_tile(tx + dx, ty + dy), Tile::StoneBrick) + }; + let mut mask = 0u32; + if is_solid(0, 1) { + mask |= 1; + } // up + if is_solid(0, -1) { + mask |= 2; + } // down + if is_solid(-1, 0) { + mask |= 4; + } // left + if is_solid(1, 0) { + mask |= 8; + } // right + mask +} + +/// Get the bitmask tile name for a given group and bitmask value. +pub fn bitmask_tile_for_group(group: TileGroup, mask: u32) -> &'static str { + let group_name = match group { + TileGroup::CastleInterior => "castle", + TileGroup::Underground => "underground", + TileGroup::Sacred => "sacred", + TileGroup::Fortress => "fortress", + }; + bitmask_tile_name(group_name, mask.min(15)) +} + +/// Map room themes to visual tile groups. +pub fn room_theme_to_tile_group(theme: &breakpoint_platformer::course_gen::RoomTheme) -> TileGroup { + use breakpoint_platformer::course_gen::RoomTheme; + match theme { + RoomTheme::Entrance + | RoomTheme::Corridor + | RoomTheme::GreatHall + | RoomTheme::ThroneRoom => TileGroup::CastleInterior, + RoomTheme::Crypt | RoomTheme::Dungeon => TileGroup::Underground, + RoomTheme::Chapel | RoomTheme::Library => TileGroup::Sacred, + RoomTheme::Armory | RoomTheme::Tower => TileGroup::Fortress, + } +} + +/// Legacy aliases so old code referencing stone_brick_top etc. still works. +fn add_legacy_stone_aliases(sheet: &mut SpriteSheet) { + // Map old 8-variant names to nearest bitmask equivalents using castle tiles. + // stone_brick_top = exposed top (no solid above) = mask where bit0=0 + // We alias to the bitmask tile with appropriate neighbor pattern. + let copy_region = |sheet: &mut SpriteSheet, src: &str, dst: &'static str| { + if let Some(r) = sheet.get(src).copied() { + sheet.regions.insert(dst, r); + } + }; + + // Bitmask: bit0=up, bit1=down, bit2=left, bit3=right + // top exposed (no above, left+right present) = 0b1110 = 14 + copy_region(sheet, "castle_tile_14", "stone_brick_top"); + // inner (all surrounded) = 0b1111 = 15 + copy_region(sheet, "castle_tile_15", "stone_brick_inner"); + // left edge (no left, above+below+right) = 0b1011 = 11 + copy_region(sheet, "castle_tile_11", "stone_brick_left"); + // right edge (no right, above+below+left) = 0b0111 = 7 + copy_region(sheet, "castle_tile_7", "stone_brick_right"); + // top-left corner (no above, no left) = 0b1010 = 10 + copy_region(sheet, "castle_tile_10", "stone_brick_top_left"); + // top-right corner (no above, no right) = 0b0110 = 6 + copy_region(sheet, "castle_tile_6", "stone_brick_top_right"); + // bottom-left corner (no below, no left) = 0b1001 = 9 + copy_region(sheet, "castle_tile_9", "stone_brick_bottom_left"); + // bottom-right corner (no below, no right) = 0b0101 = 5 + copy_region(sheet, "castle_tile_5", "stone_brick_bottom_right"); +} + +// ================================================================ +// UI / Power-up / Props sprites — Y 288-351 +// ================================================================ + +fn add_ui_sprites(sheet: &mut SpriteSheet) { + let tile = 16u32; + let y = 288u32; + + // Power-ups (16x16) + sheet.add("powerup_holy_water", 0, y, tile, tile); + sheet.add("powerup_crucifix", tile, y, tile, tile); + sheet.add("powerup_speed_boots", 2 * tile, y, tile, tile); + sheet.add("powerup_double_jump", 3 * tile, y, tile, tile); + sheet.add("powerup_armor", 4 * tile, y, tile, tile); + sheet.add("powerup_invincibility", 5 * tile, y, tile, tile); + sheet.add("powerup_whip_extend", 6 * tile, y, tile, tile); + + // Hearts (16x16) + sheet.add("heart_full", 0, y + tile, tile, tile); + sheet.add("heart_empty", tile, y + tile, tile, tile); + + // Props (16x16) + sheet.add("prop_candelabra", 2 * tile, y + tile, tile, tile); + sheet.add("prop_cross", 3 * tile, y + tile, tile, tile); + sheet.add("prop_gravestone", 4 * tile, y + tile, tile, tile); + + // Health bar elements (32x8 — procedural bar frame) + sheet.add("health_bar_frame", 0, y + 2 * tile, 32, 8); + sheet.add("health_bar_fill", 32, y + 2 * tile, 32, 8); +} + +// ================================================================ +// Particle sprites (8x8) — Y 352-383 +// ================================================================ + +fn add_particle_sprites(sheet: &mut SpriteSheet) { + let y = 352u32; + let mut x = 0u32; + + for i in 0..4u32 { + let name = match i { + 0 => "particle_dust_0", + 1 => "particle_dust_1", + 2 => "particle_dust_2", + _ => "particle_dust_3", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 32; + for i in 0..3u32 { + let name = match i { + 0 => "particle_spark_0", + 1 => "particle_spark_1", + _ => "particle_spark_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..3u32 { + let name = match i { + 0 => "particle_blood_0", + 1 => "particle_blood_1", + _ => "particle_blood_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..4u32 { + let name = match i { + 0 => "particle_fire_0", + 1 => "particle_fire_1", + 2 => "particle_fire_2", + _ => "particle_fire_3", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 32; + for i in 0..3u32 { + let name = match i { + 0 => "particle_magic_0", + 1 => "particle_magic_1", + _ => "particle_magic_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..3u32 { + let name = match i { + 0 => "particle_smoke_0", + 1 => "particle_smoke_1", + _ => "particle_smoke_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..3u32 { + let name = match i { + 0 => "particle_debris_0", + 1 => "particle_debris_1", + _ => "particle_debris_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..3u32 { + let name = match i { + 0 => "particle_water_0", + 1 => "particle_water_1", + _ => "particle_water_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + x += 24; + for i in 0..3u32 { + let name = match i { + 0 => "particle_ember_0", + 1 => "particle_ember_1", + _ => "particle_ember_2", + }; + sheet.add(name, x + i * 8, y, 8, 8); + } + + // Ambient particle types (Y=356) + let ay = 356u32; + sheet.add("particle_sparkle_0", 0, ay, 8, 8); + sheet.add("particle_sparkle_1", 8, ay, 8, 8); + sheet.add("particle_snowflake_0", 16, ay, 8, 8); + sheet.add("particle_snowflake_1", 24, ay, 8, 8); + sheet.add("particle_page_0", 32, ay, 8, 8); + sheet.add("particle_page_1", 40, ay, 8, 8); +} + +// ================================================================ +// Combat VFX sprites (32x32) — Y 384-415 +// ================================================================ + +fn add_combat_vfx_sprites(sheet: &mut SpriteSheet) { + let vfx_y = 384u32; + let size = 32u32; + let mut x = 0u32; + + // Slash arc VFX: 5 frames + for i in 0..5u32 { + let name = match i { + 0 => "vfx_slash_0", + 1 => "vfx_slash_1", + 2 => "vfx_slash_2", + 3 => "vfx_slash_3", + _ => "vfx_slash_4", + }; + sheet.add(name, x + i * size, vfx_y, size, size); + } + x += 5 * size; // 320 + + // Magic circle: 4 frames + for i in 0..4u32 { + let name = match i { + 0 => "vfx_magic_circle_0", + 1 => "vfx_magic_circle_1", + 2 => "vfx_magic_circle_2", + _ => "vfx_magic_circle_3", + }; + sheet.add(name, x + i * size, vfx_y, size, size); + } + x += 4 * size; // 576 + + // Hit sparks: 4 frames + for i in 0..4u32 { + let name = match i { + 0 => "vfx_hit_spark_0", + 1 => "vfx_hit_spark_1", + 2 => "vfx_hit_spark_2", + _ => "vfx_hit_spark_3", + }; + sheet.add(name, x + i * size, vfx_y, size, size); + } +} + +// ================================================================ +// Animation lookup table +// ================================================================ + +/// Build animation lookup table from the sprite sheet. +pub fn build_platformer_animations(sheet: &SpriteSheet) -> HashMap<&'static str, SpriteAnimation> { + let mut anims = HashMap::new(); + + let frames = |names: &[&str]| -> Vec { + names.iter().map(|n| sheet.get_or_default(n)).collect() + }; + + // Player animations (expanded) + anims.insert( + "player_idle", + SpriteAnimation { + frames: frames(&[ + "player_idle_0", + "player_idle_1", + "player_idle_2", + "player_idle_3", + "player_idle_4", + "player_idle_5", + "player_idle_6", + "player_idle_7", + ]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "player_walk", + SpriteAnimation { + frames: frames(&[ + "player_walk_0", + "player_walk_1", + "player_walk_2", + "player_walk_3", + "player_walk_4", + "player_walk_5", + "player_walk_6", + "player_walk_7", + ]), + frame_duration: 0.1, + looping: true, + }, + ); + anims.insert( + "player_run", + SpriteAnimation { + frames: frames(&[ + "player_run_0", + "player_run_1", + "player_run_2", + "player_run_3", + "player_run_4", + "player_run_5", + "player_run_6", + "player_run_7", + ]), + frame_duration: 0.08, + looping: true, + }, + ); + anims.insert( + "player_jump", + SpriteAnimation { + frames: frames(&[ + "player_jump_0", + "player_jump_1", + "player_jump_2", + "player_jump_3", + ]), + frame_duration: 0.12, + looping: true, + }, + ); + anims.insert( + "player_fall", + SpriteAnimation { + frames: frames(&[ + "player_fall_0", + "player_fall_1", + "player_fall_2", + "player_fall_3", + ]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "player_attack", + SpriteAnimation { + frames: frames(&[ + "player_attack_0", + "player_attack_1", + "player_attack_2", + "player_attack_3", + "player_attack_4", + "player_attack_5", + "player_attack_6", + "player_attack_7", + ]), + frame_duration: 0.04, + looping: false, + }, + ); + anims.insert( + "player_hurt", + SpriteAnimation { + frames: frames(&[ + "player_hurt_0", + "player_hurt_1", + "player_hurt_2", + "player_hurt_3", + ]), + frame_duration: 0.12, + looping: false, + }, + ); + anims.insert( + "player_dead", + SpriteAnimation { + frames: frames(&[ + "player_dead_0", + "player_dead_1", + "player_dead_2", + "player_dead_3", + "player_dead_4", + "player_dead_5", + ]), + frame_duration: 0.15, + looping: false, + }, + ); + anims.insert( + "player_wall_slide", + SpriteAnimation { + frames: frames(&[ + "player_wall_slide_0", + "player_wall_slide_1", + "player_wall_slide_2", + ]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "player_crouch", + SpriteAnimation { + frames: frames(&["player_crouch_0", "player_crouch_1", "player_crouch_2"]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "player_dash", + SpriteAnimation { + frames: frames(&[ + "player_dash_0", + "player_dash_1", + "player_dash_2", + "player_dash_3", + ]), + frame_duration: 0.06, + looping: false, + }, + ); + + // Enemy animations + add_enemy_animations(&mut anims, sheet); + + // Tile animations + anims.insert( + "torch", + SpriteAnimation { + frames: frames(&["torch_0", "torch_1", "torch_2", "torch_3"]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "checkpoint_flag_down", + SpriteAnimation { + frames: frames(&["checkpoint_flag_down_0", "checkpoint_flag_down_1"]), + frame_duration: 0.3, + looping: true, + }, + ); + anims.insert( + "checkpoint_flag_up", + SpriteAnimation { + frames: frames(&["checkpoint_flag_up_0", "checkpoint_flag_up_1"]), + frame_duration: 0.3, + looping: true, + }, + ); + anims.insert( + "finish_gate", + SpriteAnimation { + frames: frames(&["finish_gate_0", "finish_gate_1"]), + frame_duration: 0.4, + looping: true, + }, + ); + anims.insert( + "projectile", + SpriteAnimation { + frames: frames(&["projectile_0", "projectile_1", "projectile_2"]), + frame_duration: 0.1, + looping: true, + }, + ); + anims.insert( + "chain", + SpriteAnimation { + frames: frames(&["chain_0", "chain_1"]), + frame_duration: 0.5, + looping: true, + }, + ); + + // VFX animations + anims.insert( + "vfx_slash", + SpriteAnimation { + frames: frames(&[ + "vfx_slash_0", + "vfx_slash_1", + "vfx_slash_2", + "vfx_slash_3", + "vfx_slash_4", + ]), + frame_duration: 0.05, + looping: false, + }, + ); + anims.insert( + "vfx_magic_circle", + SpriteAnimation { + frames: frames(&[ + "vfx_magic_circle_0", + "vfx_magic_circle_1", + "vfx_magic_circle_2", + "vfx_magic_circle_3", + ]), + frame_duration: 0.1, + looping: true, + }, + ); + anims.insert( + "vfx_hit_spark", + SpriteAnimation { + frames: frames(&[ + "vfx_hit_spark_0", + "vfx_hit_spark_1", + "vfx_hit_spark_2", + "vfx_hit_spark_3", + ]), + frame_duration: 0.04, + looping: false, + }, + ); + + anims +} + +/// Add enemy animation entries to the map. +fn add_enemy_animations(anims: &mut HashMap<&'static str, SpriteAnimation>, sheet: &SpriteSheet) { + let frames = |names: &[&str]| -> Vec { + names.iter().map(|n| sheet.get_or_default(n)).collect() + }; + + // Skeleton + anims.insert( + "skeleton_walk", + SpriteAnimation { + frames: frames(&[ + "skeleton_walk_0", + "skeleton_walk_1", + "skeleton_walk_2", + "skeleton_walk_3", + ]), + frame_duration: 0.15, + looping: true, + }, + ); + anims.insert( + "skeleton_attack", + SpriteAnimation { + frames: frames(&[ + "skeleton_attack_0", + "skeleton_attack_1", + "skeleton_attack_2", + ]), + frame_duration: 0.1, + looping: false, + }, + ); + anims.insert( + "skeleton_death", + SpriteAnimation { + frames: frames(&[ + "skeleton_death_0", + "skeleton_death_1", + "skeleton_death_2", + "skeleton_death_3", + ]), + frame_duration: 0.15, + looping: false, + }, + ); + + // Bat + anims.insert( + "bat_fly", + SpriteAnimation { + frames: frames(&["bat_fly_0", "bat_fly_1", "bat_fly_2", "bat_fly_3"]), + frame_duration: 0.12, + looping: true, + }, + ); + anims.insert( + "bat_death", + SpriteAnimation { + frames: frames(&["bat_death_0", "bat_death_1"]), + frame_duration: 0.2, + looping: false, + }, + ); + + // Knight + anims.insert( + "knight_walk", + SpriteAnimation { + frames: frames(&[ + "knight_walk_0", + "knight_walk_1", + "knight_walk_2", + "knight_walk_3", + ]), + frame_duration: 0.18, + looping: true, + }, + ); + anims.insert( + "knight_attack", + SpriteAnimation { + frames: frames(&["knight_attack_0", "knight_attack_1", "knight_attack_2"]), + frame_duration: 0.1, + looping: false, + }, + ); + anims.insert( + "knight_death", + SpriteAnimation { + frames: frames(&[ + "knight_death_0", + "knight_death_1", + "knight_death_2", + "knight_death_3", + ]), + frame_duration: 0.15, + looping: false, + }, + ); + + // Medusa + anims.insert( + "medusa_float", + SpriteAnimation { + frames: frames(&[ + "medusa_float_0", + "medusa_float_1", + "medusa_float_2", + "medusa_float_3", + ]), + frame_duration: 0.18, + looping: true, + }, + ); + anims.insert( + "medusa_death", + SpriteAnimation { + frames: frames(&["medusa_death_0", "medusa_death_1"]), + frame_duration: 0.2, + looping: false, + }, + ); + + // Ghost (new enemy type) + anims.insert( + "ghost_drift", + SpriteAnimation { + frames: frames(&[ + "ghost_drift_0", + "ghost_drift_1", + "ghost_drift_2", + "ghost_drift_3", + ]), + frame_duration: 0.2, + looping: true, + }, + ); + anims.insert( + "ghost_phase", + SpriteAnimation { + frames: frames(&["ghost_phase_0", "ghost_phase_1", "ghost_phase_2"]), + frame_duration: 0.15, + looping: false, + }, + ); + anims.insert( + "ghost_death", + SpriteAnimation { + frames: frames(&["ghost_death_0", "ghost_death_1", "ghost_death_2"]), + frame_duration: 0.15, + looping: false, + }, + ); + + // Gargoyle (new enemy type) + anims.insert( + "gargoyle_perch", + SpriteAnimation { + frames: frames(&["gargoyle_perch_0", "gargoyle_perch_1"]), + frame_duration: 0.5, + looping: true, + }, + ); + anims.insert( + "gargoyle_swoop", + SpriteAnimation { + frames: frames(&[ + "gargoyle_swoop_0", + "gargoyle_swoop_1", + "gargoyle_swoop_2", + "gargoyle_swoop_3", + ]), + frame_duration: 0.08, + looping: false, + }, + ); + anims.insert( + "gargoyle_death", + SpriteAnimation { + frames: frames(&["gargoyle_death_0", "gargoyle_death_1", "gargoyle_death_2"]), + frame_duration: 0.15, + looping: false, + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sprite_region_to_vec4() { + let r = SpriteRegion { + u0: 0.0, + v0: 0.25, + u1: 0.5, + v1: 0.75, + }; + let v = r.to_vec4(); + assert!((v.x - 0.0).abs() < 1e-6); + assert!((v.y - 0.25).abs() < 1e-6); + assert!((v.z - 0.5).abs() < 1e-6); + assert!((v.w - 0.75).abs() < 1e-6); + } + + #[test] + fn sprite_sheet_lookup() { + let sheet = build_platformer_atlas(); + assert!(sheet.get("player_idle_0").is_some()); + assert!(sheet.get("stone_brick_top").is_some()); + assert!(sheet.get("nonexistent").is_none()); + } + + #[test] + fn sprite_sheet_uv_coords() { + let sheet = build_platformer_atlas(); + let r = sheet.get("player_idle_0").unwrap(); + // 0,0 -> 16,32 on 1024x512 atlas + assert!((r.u0 - 0.0).abs() < 1e-6); + assert!((r.v0 - 0.0).abs() < 1e-6); + assert!((r.u1 - 16.0 / 1024.0).abs() < 1e-6); + assert!((r.v1 - 32.0 / 512.0).abs() < 1e-6); + } + + #[test] + fn animation_frame_at_looping() { + let anim = SpriteAnimation { + frames: vec![ + SpriteRegion { + u0: 0.0, + v0: 0.0, + u1: 0.1, + v1: 0.1, + }, + SpriteRegion { + u0: 0.1, + v0: 0.0, + u1: 0.2, + v1: 0.1, + }, + ], + frame_duration: 0.5, + looping: true, + }; + let f0 = anim.frame_at(0.0); + assert!((f0.u0 - 0.0).abs() < 1e-6); + let f1 = anim.frame_at(0.5); + assert!((f1.u0 - 0.1).abs() < 1e-6); + // Looping: 1.0 wraps to frame 0 + let f2 = anim.frame_at(1.0); + assert!((f2.u0 - 0.0).abs() < 1e-6); + } + + #[test] + fn animation_frame_at_non_looping() { + let anim = SpriteAnimation { + frames: vec![ + SpriteRegion { + u0: 0.0, + v0: 0.0, + u1: 0.1, + v1: 0.1, + }, + SpriteRegion { + u0: 0.1, + v0: 0.0, + u1: 0.2, + v1: 0.1, + }, + ], + frame_duration: 0.5, + looping: false, + }; + // Beyond end should clamp to last frame + let f = anim.frame_at(5.0); + assert!((f.u0 - 0.1).abs() < 1e-6); + } + + #[test] + fn get_or_default_returns_fallback() { + let sheet = SpriteSheet::new(512, 512); + let r = sheet.get_or_default("missing"); + assert!(r.u0 >= 0.0); + assert!(r.u1 > 0.0); + } + + #[test] + fn build_animations_has_all_player_anims() { + let sheet = build_platformer_atlas(); + let anims = build_platformer_animations(&sheet); + assert!(anims.contains_key("player_idle")); + assert!(anims.contains_key("player_walk")); + assert!(anims.contains_key("player_run")); + assert!(anims.contains_key("player_jump")); + assert!(anims.contains_key("player_fall")); + assert!(anims.contains_key("player_attack")); + assert!(anims.contains_key("player_hurt")); + assert!(anims.contains_key("player_dead")); + assert!(anims.contains_key("player_wall_slide")); + assert!(anims.contains_key("player_crouch")); + assert!(anims.contains_key("player_dash")); + } + + #[test] + fn build_animations_has_enemy_anims() { + let sheet = build_platformer_atlas(); + let anims = build_platformer_animations(&sheet); + assert!(anims.contains_key("skeleton_walk")); + assert!(anims.contains_key("skeleton_death")); + assert!(anims.contains_key("bat_fly")); + assert!(anims.contains_key("bat_death")); + assert!(anims.contains_key("knight_walk")); + assert!(anims.contains_key("knight_death")); + assert!(anims.contains_key("medusa_float")); + assert!(anims.contains_key("medusa_death")); + // New enemy types + assert!(anims.contains_key("ghost_drift")); + assert!(anims.contains_key("ghost_death")); + assert!(anims.contains_key("gargoyle_perch")); + assert!(anims.contains_key("gargoyle_swoop")); + assert!(anims.contains_key("gargoyle_death")); + } + + #[test] + fn player_idle_animation_has_8_frames() { + let sheet = build_platformer_atlas(); + let anims = build_platformer_animations(&sheet); + assert_eq!(anims["player_idle"].frames.len(), 8); + } + + #[test] + fn particle_sprites_exist() { + let sheet = build_platformer_atlas(); + assert!(sheet.get("particle_dust_0").is_some()); + assert!(sheet.get("particle_fire_0").is_some()); + assert!(sheet.get("particle_blood_0").is_some()); + } + + #[test] + fn tile_variant_sprites_exist() { + let sheet = build_platformer_atlas(); + assert!(sheet.get("stone_brick_top").is_some()); + assert!(sheet.get("stone_brick_inner").is_some()); + assert!(sheet.get("stone_brick_left").is_some()); + assert!(sheet.get("checkpoint_flag_down_0").is_some()); + assert!(sheet.get("checkpoint_flag_up_0").is_some()); + assert!(sheet.get("torch_0").is_some()); + } + + #[test] + fn bitmask_tiles_exist_for_all_groups() { + let sheet = build_platformer_atlas(); + for i in 0..16 { + assert!( + sheet.get(&format!("castle_tile_{i}")).is_some(), + "castle_tile_{i} missing" + ); + assert!( + sheet.get(&format!("underground_tile_{i}")).is_some(), + "underground_tile_{i} missing" + ); + assert!( + sheet.get(&format!("sacred_tile_{i}")).is_some(), + "sacred_tile_{i} missing" + ); + assert!( + sheet.get(&format!("fortress_tile_{i}")).is_some(), + "fortress_tile_{i} missing" + ); + } + } + + #[test] + fn new_enemy_sprites_exist() { + let sheet = build_platformer_atlas(); + assert!(sheet.get("ghost_drift_0").is_some()); + assert!(sheet.get("gargoyle_perch_0").is_some()); + assert!(sheet.get("gargoyle_swoop_0").is_some()); + } + + #[test] + fn vfx_sprites_exist() { + let sheet = build_platformer_atlas(); + assert!(sheet.get("vfx_slash_0").is_some()); + assert!(sheet.get("vfx_magic_circle_0").is_some()); + assert!(sheet.get("vfx_hit_spark_0").is_some()); + } + + #[test] + fn combat_vfx_animations_exist() { + let sheet = build_platformer_atlas(); + let anims = build_platformer_animations(&sheet); + assert!(anims.contains_key("vfx_slash")); + assert!(anims.contains_key("vfx_magic_circle")); + assert!(anims.contains_key("vfx_hit_spark")); + } + + #[test] + fn atlas_dimensions_are_1024x512() { + assert_eq!(ATLAS_W, 1024); + assert_eq!(ATLAS_H, 512); + } + + #[test] + fn bitmask_tile_for_group_returns_valid() { + let sheet = build_platformer_atlas(); + for mask in 0..16u32 { + let name = bitmask_tile_for_group(TileGroup::CastleInterior, mask); + assert!(sheet.get(name).is_some(), "Missing tile: {name}"); + } + } +} diff --git a/crates/breakpoint-client/src/theme.rs b/crates/breakpoint-client/src/theme.rs index 3472acb..6dad239 100644 --- a/crates/breakpoint-client/src/theme.rs +++ b/crates/breakpoint-client/src/theme.rs @@ -80,6 +80,22 @@ pub struct PlatformerTheme { pub platform_tile: [f32; 3], pub finish_tile: [f32; 3], pub hud_text: [f32; 4], + pub hp_full: [f32; 3], + pub hp_empty: [f32; 3], + pub enemy_tint: [f32; 3], + pub invincibility_flash: [f32; 3], + /// CRT scanline intensity (0.0-1.0). + pub scanline_intensity: f32, + /// Bloom intensity (0.0-1.0). + pub bloom_intensity: f32, + /// Vignette intensity (0.0-1.0). + pub vignette_intensity: f32, + /// CRT barrel distortion curvature (0.0-1.0). + pub crt_curvature: f32, + /// Ambient light level when torches are present (0.0-1.0). + pub torch_ambient: f32, + /// Water tile tint color (RGBA). + pub water_color: [f32; 4], } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -183,12 +199,23 @@ impl Default for GolfTheme { impl Default for PlatformerTheme { fn default() -> Self { Self { - solid_tile: [0.4, 0.4, 0.5], - grass_tile: [0.3, 0.6, 0.3], - hazard_tile: [0.9, 0.2, 0.1], - platform_tile: [0.2, 0.5, 0.9], - finish_tile: [1.0, 0.85, 0.1], - hud_text: [0.9, 0.9, 0.9, 0.85], + // Castlevania GBA/DS gothic palette — dark but readable + solid_tile: [0.35, 0.30, 0.40], + grass_tile: [0.25, 0.35, 0.25], + hazard_tile: [0.7, 0.12, 0.12], + platform_tile: [0.40, 0.30, 0.22], + finish_tile: [0.8, 0.65, 0.08], + hud_text: [0.85, 0.82, 0.78, 0.9], + hp_full: [0.7, 0.08, 0.08], + hp_empty: [0.2, 0.15, 0.15], + enemy_tint: [0.85, 0.82, 0.78], + invincibility_flash: [1.0, 0.9, 0.6], + scanline_intensity: 0.05, + bloom_intensity: 0.45, + vignette_intensity: 0.20, + crt_curvature: 0.08, + torch_ambient: 0.15, + water_color: [0.08, 0.2, 0.4, 0.65], } } } diff --git a/crates/breakpoint-client/src/weather.rs b/crates/breakpoint-client/src/weather.rs new file mode 100644 index 0000000..c4406a7 --- /dev/null +++ b/crates/breakpoint-client/src/weather.rs @@ -0,0 +1,352 @@ +use glam::{Vec3, Vec4}; + +use crate::scene::{MaterialType, MeshType, Scene, Transform}; + +/// Maximum number of active rain drops (reduced from 120 for draw call budget). +const MAX_RAIN_DROPS: usize = 60; + +/// Maximum number of ambient particles (reduced from 40 for draw call budget). +const MAX_AMBIENT_PARTICLES: usize = 20; + +/// A single rain drop particle. +struct RainDrop { + x: f32, + y: f32, + speed: f32, + length: f32, + active: bool, +} + +/// Ambient particle (dust motes, embers, sparkles, etc.). +struct AmbientParticle { + x: f32, + y: f32, + vx: f32, + vy: f32, + size: f32, + alpha: f32, + lifetime: f32, + max_lifetime: f32, + color: Vec4, + active: bool, +} + +/// Ambient particle type determined by room theme. +#[derive(Clone, Copy, PartialEq)] +pub enum AmbientType { + None, + DustMotes, + GoldenSparkles, + Embers, + Snowflakes, + FloatingPages, + RoyalSparkles, +} + +/// Weather system for rain, lightning, fog, and atmospheric effects. +pub struct WeatherSystem { + drops: Vec, + ambient: Vec, + /// Whether rain is currently active. + pub raining: bool, + /// Ground fog density (0.0-1.0). + pub fog_density: f32, + /// Per-room fog color (RGB). + pub fog_color: [f32; 3], + /// Camera X for positioning rain relative to view. + camera_x: f32, + camera_y: f32, + /// Lightning flash timer (counts down from flash duration). + lightning_timer: f32, + /// Lightning flash intensity (0.0-1.0). + pub lightning_intensity: f32, + /// Time until next lightning strike. + next_lightning: f32, + /// Current ambient particle type. + pub ambient_type: AmbientType, +} + +impl Default for WeatherSystem { + fn default() -> Self { + Self::new() + } +} + +impl WeatherSystem { + pub fn new() -> Self { + let mut drops = Vec::with_capacity(MAX_RAIN_DROPS); + for _ in 0..MAX_RAIN_DROPS { + drops.push(RainDrop { + x: 0.0, + y: 0.0, + speed: 0.0, + length: 0.0, + active: false, + }); + } + let mut ambient = Vec::with_capacity(MAX_AMBIENT_PARTICLES); + for _ in 0..MAX_AMBIENT_PARTICLES { + ambient.push(AmbientParticle { + x: 0.0, + y: 0.0, + vx: 0.0, + vy: 0.0, + size: 0.0, + alpha: 0.0, + lifetime: 0.0, + max_lifetime: 1.0, + color: Vec4::ZERO, + active: false, + }); + } + Self { + drops, + ambient, + raining: false, + fog_density: 0.0, + fog_color: [0.08, 0.06, 0.12], + camera_x: 0.0, + camera_y: 5.0, + lightning_timer: 0.0, + lightning_intensity: 0.0, + next_lightning: 3.0 + fastrand::f32() * 5.0, + ambient_type: AmbientType::None, + } + } + + /// Update camera position for rain positioning. + pub fn set_camera(&mut self, x: f32, y: f32) { + self.camera_x = x; + self.camera_y = y; + } + + /// Update all weather effects. + pub fn tick(&mut self, dt: f32) { + self.tick_rain(dt); + self.tick_lightning(dt); + self.tick_ambient(dt); + } + + fn tick_rain(&mut self, dt: f32) { + if !self.raining { + for drop in &mut self.drops { + drop.active = false; + } + return; + } + + let view_half_w = 16.0; + let view_half_h = 12.0; + + for drop in &mut self.drops { + if !drop.active { + drop.x = self.camera_x + (fastrand::f32() - 0.5) * view_half_w * 2.0; + drop.y = self.camera_y + view_half_h; + drop.speed = 14.0 + fastrand::f32() * 8.0; + drop.length = 0.3 + fastrand::f32() * 0.3; + drop.active = true; + continue; + } + + // Fall diagonally (slight wind) + drop.y -= drop.speed * dt; + drop.x += drop.speed * 0.08 * dt; + + if drop.y < self.camera_y - view_half_h { + drop.active = false; + } + } + } + + fn tick_lightning(&mut self, dt: f32) { + if !self.raining { + self.lightning_intensity = 0.0; + return; + } + + if self.lightning_timer > 0.0 { + self.lightning_timer -= dt; + // Flash + afterglow: bright flash for 0.1s, then fade 0.3s + if self.lightning_timer > 0.3 { + self.lightning_intensity = 1.0; + } else { + self.lightning_intensity = (self.lightning_timer / 0.3).max(0.0); + } + } else { + self.lightning_intensity = 0.0; + self.next_lightning -= dt; + if self.next_lightning <= 0.0 { + self.lightning_timer = 0.4; // 0.1s flash + 0.3s afterglow + self.next_lightning = 4.0 + fastrand::f32() * 8.0; + } + } + } + + fn tick_ambient(&mut self, dt: f32) { + if self.ambient_type == AmbientType::None { + for p in &mut self.ambient { + p.active = false; + } + return; + } + + let view_half_w = 14.0; + let view_half_h = 10.0; + + let cam_x = self.camera_x; + let cam_y = self.camera_y; + let atype = self.ambient_type; + for p in &mut self.ambient { + if !p.active { + spawn_ambient_particle(p, cam_x, cam_y, atype, view_half_w, view_half_h); + continue; + } + + p.x += p.vx * dt; + p.y += p.vy * dt; + p.lifetime -= dt; + + // Fade in/out + let life_ratio = p.lifetime / p.max_lifetime; + p.alpha = if life_ratio > 0.8 { + (1.0 - life_ratio) * 5.0 + } else if life_ratio < 0.2 { + life_ratio * 5.0 + } else { + 1.0 + }; + + if p.lifetime <= 0.0 + || (p.x - self.camera_x).abs() > view_half_w + 2.0 + || (p.y - self.camera_y).abs() > view_half_h + 2.0 + { + p.active = false; + } + } + } +} + +fn spawn_ambient_particle( + p: &mut AmbientParticle, + camera_x: f32, + camera_y: f32, + ambient_type: AmbientType, + view_half_w: f32, + view_half_h: f32, +) { + p.x = camera_x + (fastrand::f32() - 0.5) * view_half_w * 2.0; + p.y = camera_y + (fastrand::f32() - 0.5) * view_half_h * 2.0; + p.lifetime = 2.0 + fastrand::f32() * 3.0; + p.max_lifetime = p.lifetime; + p.active = true; + + match ambient_type { + AmbientType::DustMotes => { + p.vx = (fastrand::f32() - 0.5) * 0.3; + p.vy = (fastrand::f32() - 0.5) * 0.2; + p.size = 0.04 + fastrand::f32() * 0.03; + p.color = Vec4::new(0.5, 0.4, 0.35, 0.25); + }, + AmbientType::GoldenSparkles => { + p.vx = (fastrand::f32() - 0.5) * 0.2; + p.vy = 0.2 + fastrand::f32() * 0.3; + p.size = 0.03 + fastrand::f32() * 0.02; + p.color = Vec4::new(1.0, 0.9, 0.5, 0.5); + }, + AmbientType::Embers => { + p.vx = (fastrand::f32() - 0.5) * 0.4; + p.vy = 0.5 + fastrand::f32() * 0.8; + p.size = 0.03 + fastrand::f32() * 0.03; + p.color = Vec4::new(1.0, 0.45, 0.15, 0.65); + }, + AmbientType::Snowflakes => { + p.vx = 0.3 + fastrand::f32() * 0.5; + p.vy = -(0.5 + fastrand::f32() * 0.3); + p.size = 0.04 + fastrand::f32() * 0.04; + p.color = Vec4::new(0.9, 0.9, 1.0, 0.4); + }, + AmbientType::FloatingPages => { + p.vx = (fastrand::f32() - 0.5) * 0.3; + p.vy = -(0.1 + fastrand::f32() * 0.2); + p.size = 0.06 + fastrand::f32() * 0.04; + p.color = Vec4::new(0.9, 0.85, 0.7, 0.4); + }, + AmbientType::RoyalSparkles => { + p.vx = (fastrand::f32() - 0.5) * 0.2; + p.vy = (fastrand::f32() - 0.3) * 0.3; + p.size = 0.03 + fastrand::f32() * 0.02; + p.color = Vec4::new(0.8, 0.5, 1.0, 0.5); + }, + AmbientType::None => {}, + } +} + +impl WeatherSystem { + /// Render all weather effects into the scene. + pub fn render(&self, scene: &mut Scene) { + self.render_rain(scene); + self.render_ambient(scene); + self.render_fog(scene); + } + + fn render_rain(&self, scene: &mut Scene) { + if !self.raining { + return; + } + + for drop in &self.drops { + if !drop.active { + continue; + } + // Sprite-based rain droplets (slightly wider for visibility with fewer drops) + scene.add( + MeshType::Quad, + MaterialType::Unlit { + color: Vec4::new(0.7, 0.75, 0.9, 0.4), + }, + Transform::from_xyz(drop.x, drop.y, 0.2).with_scale(Vec3::new( + 0.06, + drop.length * 1.2, + 1.0, + )), + ); + } + } + + fn render_ambient(&self, scene: &mut Scene) { + for p in &self.ambient { + if !p.active || p.alpha < 0.01 { + continue; + } + let color = Vec4::new(p.color.x, p.color.y, p.color.z, p.color.w * p.alpha); + scene.add( + MeshType::Quad, + MaterialType::Glow { + color, + intensity: 0.8, + }, + Transform::from_xyz(p.x, p.y, 0.15).with_scale(Vec3::new(p.size, p.size, 1.0)), + ); + } + } + + fn render_fog(&self, scene: &mut Scene) { + if self.fog_density < 0.01 { + return; + } + // Ground fog layer covering the lower portion of the view + scene.add( + MeshType::Quad, + MaterialType::FogLayer { + density: self.fog_density, + color: Vec4::new(self.fog_color[0], self.fog_color[1], self.fog_color[2], 0.5), + }, + Transform::from_xyz( + self.camera_x, + self.camera_y - 3.0, + crate::game::platformer_render::Z_FOG, + ) + .with_scale(Vec3::new(40.0, 8.0, 1.0)), + ); + } +} diff --git a/crates/breakpoint-server/src/game_loop.rs b/crates/breakpoint-server/src/game_loop.rs index 0d4b9f8..88c96ec 100644 --- a/crates/breakpoint-server/src/game_loop.rs +++ b/crates/breakpoint-server/src/game_loop.rs @@ -799,8 +799,9 @@ mod tests { other => panic!("Expected EncodedMessage, got: {other:?}"), } - // Should receive GameState ticks - let msg = tokio::time::timeout(Duration::from_millis(500), broadcast_rx.recv()) + // Should receive GameState ticks (platformer state is large: 300x30 course + // with enemies, so allow extra time in debug builds) + let msg = tokio::time::timeout(Duration::from_millis(2000), broadcast_rx.recv()) .await .expect("should receive tick within timeout") .expect("channel should not be closed"); diff --git a/crates/breakpoint-server/tests/game_smoke.rs b/crates/breakpoint-server/tests/game_smoke.rs index d8a3392..0c8a7fd 100644 --- a/crates/breakpoint-server/tests/game_smoke.rs +++ b/crates/breakpoint-server/tests/game_smoke.rs @@ -120,6 +120,7 @@ async fn platformer_input_processed_by_server() { move_dir: 1.0, jump: true, use_powerup: false, + attack: false, }; let input_data = rmp_serde::to_vec(&plat_input).unwrap(); let msg = ClientMessage::PlayerInput(PlayerInputMsg { @@ -392,6 +393,7 @@ async fn platformer_jump_changes_y() { move_dir: 0.0, jump: true, use_powerup: false, + attack: false, }; let data = rmp_serde::to_vec(&input).unwrap(); game.apply_input(1, &data); diff --git a/crates/games/breakpoint-platformer/src/combat.rs b/crates/games/breakpoint-platformer/src/combat.rs new file mode 100644 index 0000000..bcd4e3e --- /dev/null +++ b/crates/games/breakpoint-platformer/src/combat.rs @@ -0,0 +1,458 @@ +use serde::{Deserialize, Serialize}; + +use crate::enemies::{Enemy, EnemyProjectile, kill_enemy}; +use crate::physics::PlatformerPlayerState; + +// ---- Whip attack constants ---- + +/// Width of the whip hitbox in world units. +pub const WHIP_WIDTH: f32 = 1.5; +/// Height of the whip hitbox in world units. +pub const WHIP_HEIGHT: f32 = 0.6; +/// Horizontal offset of the whip hitbox from the player center. +pub const WHIP_OFFSET: f32 = 1.2; +/// Total duration of the attack animation. +pub const ATTACK_DURATION: f32 = 0.35; +/// Time into the attack when the hitbox becomes active. +pub const ATTACK_ACTIVE_START: f32 = 0.1; +/// Time into the attack when the hitbox deactivates. +pub const ATTACK_ACTIVE_END: f32 = 0.25; +/// Cooldown after attack completes before another can start. +pub const ATTACK_COOLDOWN: f32 = 0.15; + +// ---- Damage constants ---- + +/// Duration of invincibility frames after taking a hit. +pub const INVINCIBILITY_DURATION: f32 = 1.5; +/// Time to wait before respawning after death. +pub const DEATH_RESPAWN_TIMER: f32 = 2.0; +/// Time penalty added to finish time per death. +pub const DEATH_TIME_PENALTY: f32 = 3.0; + +/// Extended whip multiplier when WhipExtend power-up is active. +const WHIP_EXTEND_MULT: f32 = 1.8; + +/// Events generated by the combat system. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CombatEvent { + /// An enemy was killed by a player attack. + EnemyKilled { enemy_id: u16 }, + /// A player was hit by an enemy or projectile. + PlayerHit { player_id: u64 }, + /// A player died (HP reached 0). + PlayerDied { player_id: u64 }, +} + +/// Returns true if the player's attack is in the active damage frames. +fn is_attack_active(player: &PlatformerPlayerState) -> bool { + if player.attack_timer <= 0.0 { + return false; + } + let elapsed = ATTACK_DURATION - player.attack_timer; + (ATTACK_ACTIVE_START..=ATTACK_ACTIVE_END).contains(&elapsed) +} + +/// Get the whip hitbox AABB (left, bottom, right, top) for a player. +fn whip_hitbox(player: &PlatformerPlayerState, has_whip_extend: bool) -> (f32, f32, f32, f32) { + let mult = if has_whip_extend { + WHIP_EXTEND_MULT + } else { + 1.0 + }; + let width = WHIP_WIDTH * mult; + let offset = WHIP_OFFSET * mult; + let half_h = WHIP_HEIGHT / 2.0; + + if player.facing_right { + let left = player.x + offset - width / 2.0; + let right = player.x + offset + width / 2.0; + (left, player.y - half_h, right, player.y + half_h) + } else { + let left = player.x - offset - width / 2.0; + let right = player.x - offset + width / 2.0; + (left, player.y - half_h, right, player.y + half_h) + } +} + +/// Enemy AABB: approximate as 0.8 x 0.8 centered on enemy position. +const ENEMY_HALF_SIZE: f32 = 0.4; + +fn enemy_aabb(enemy: &Enemy) -> (f32, f32, f32, f32) { + ( + enemy.x - ENEMY_HALF_SIZE, + enemy.y - ENEMY_HALF_SIZE, + enemy.x + ENEMY_HALF_SIZE, + enemy.y + ENEMY_HALF_SIZE, + ) +} + +/// Check if two AABBs overlap. +fn aabb_overlap(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool { + a.0 < b.2 && a.2 > b.0 && a.1 < b.3 && a.3 > b.1 +} + +/// Check a player's whip attack against all enemies. Returns combat events for kills. +pub fn check_player_attack( + player: &PlatformerPlayerState, + enemies: &mut [Enemy], + has_whip_extend: bool, +) -> Vec { + let mut events = Vec::new(); + + if !is_attack_active(player) { + return events; + } + + let whip = whip_hitbox(player, has_whip_extend); + + for enemy in enemies.iter_mut() { + if !enemy.alive { + continue; + } + + let e_aabb = enemy_aabb(enemy); + if aabb_overlap(whip, e_aabb) { + enemy.hp = enemy.hp.saturating_sub(1); + if enemy.hp == 0 { + kill_enemy(enemy); + events.push(CombatEvent::EnemyKilled { enemy_id: enemy.id }); + } + } + } + + events +} + +/// Player AABB for collision with enemies/projectiles. +const PLAYER_HALF_W: f32 = 0.4; +const PLAYER_HALF_H: f32 = 0.6; + +fn player_aabb(player: &PlatformerPlayerState) -> (f32, f32, f32, f32) { + ( + player.x - PLAYER_HALF_W, + player.y - PLAYER_HALF_H, + player.x + PLAYER_HALF_W, + player.y + PLAYER_HALF_H, + ) +} + +/// Projectile AABB: small 0.3 x 0.3 hitbox. +const PROJ_HALF_SIZE: f32 = 0.15; + +fn projectile_aabb(proj: &EnemyProjectile) -> (f32, f32, f32, f32) { + ( + proj.x - PROJ_HALF_SIZE, + proj.y - PROJ_HALF_SIZE, + proj.x + PROJ_HALF_SIZE, + proj.y + PROJ_HALF_SIZE, + ) +} + +/// Check if enemies or projectiles hit a player. Applies damage if not invincible. +/// Returns combat events for hits/deaths. +pub fn check_enemy_damage( + player: &mut PlatformerPlayerState, + player_id: u64, + enemies: &[Enemy], + projectiles: &[EnemyProjectile], + has_invincibility: bool, +) -> Vec { + let mut events = Vec::new(); + + // Skip if player is dead, invincible, or in respawn + if player.death_respawn_timer > 0.0 || player.invincibility_timer > 0.0 || has_invincibility { + return events; + } + + let p_aabb = player_aabb(player); + + // Check enemy body collisions + for enemy in enemies { + if !enemy.alive { + continue; + } + let e_aabb = enemy_aabb(enemy); + if aabb_overlap(p_aabb, e_aabb) { + events.extend(apply_damage(player, player_id)); + return events; // Only one hit per tick + } + } + + // Check projectile collisions + for proj in projectiles { + let proj_box = projectile_aabb(proj); + if aabb_overlap(p_aabb, proj_box) { + events.extend(apply_damage(player, player_id)); + return events; // Only one hit per tick + } + } + + events +} + +/// Apply damage to a player: reduce HP, start invincibility, handle death. +pub fn apply_damage(player: &mut PlatformerPlayerState, player_id: u64) -> Vec { + let mut events = Vec::new(); + + player.hp = player.hp.saturating_sub(1); + events.push(CombatEvent::PlayerHit { player_id }); + + if player.hp == 0 { + // Player died + player.death_respawn_timer = DEATH_RESPAWN_TIMER; + player.deaths += 1; + player.vx = 0.0; + player.vy = 0.0; + events.push(CombatEvent::PlayerDied { player_id }); + } else { + // Just hurt, grant invincibility frames + player.invincibility_timer = INVINCIBILITY_DURATION; + } + + events +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::AnimState; + + fn make_test_player() -> PlatformerPlayerState { + PlatformerPlayerState::new(5.0, 5.0) + } + + fn make_test_enemy(id: u16, x: f32, y: f32) -> Enemy { + Enemy { + id, + enemy_type: crate::enemies::EnemyType::Skeleton, + x, + y, + vx: 0.0, + vy: 0.0, + hp: 1, + patrol_min_x: x - 2.0, + patrol_max_x: x + 2.0, + alive: true, + respawn_timer: 0.0, + facing_right: true, + anim_time: 0.0, + shoot_timer: 0.0, + } + } + + #[test] + fn attack_not_active_when_timer_zero() { + let player = make_test_player(); + assert!(!is_attack_active(&player)); + } + + #[test] + fn attack_active_during_correct_frames() { + let mut player = make_test_player(); + // Set attack_timer so that elapsed is within active window + // elapsed = ATTACK_DURATION - attack_timer + // We want ATTACK_ACTIVE_START <= elapsed <= ATTACK_ACTIVE_END + // So attack_timer = ATTACK_DURATION - 0.15 (middle of active window) + player.attack_timer = ATTACK_DURATION - 0.15; + assert!(is_attack_active(&player)); + } + + #[test] + fn attack_not_active_before_start() { + let mut player = make_test_player(); + // elapsed = ATTACK_DURATION - attack_timer + // We want elapsed < ATTACK_ACTIVE_START (0.1) + // So attack_timer > ATTACK_DURATION - 0.1 = 0.25 + player.attack_timer = 0.30; + assert!(!is_attack_active(&player)); + } + + #[test] + fn whip_hitbox_facing_right() { + let mut player = make_test_player(); + player.facing_right = true; + let (left, bottom, right, top) = whip_hitbox(&player, false); + // Hitbox should be to the right of the player + assert!(left > player.x, "Whip should be to the right"); + assert!(right > left, "Right should be > left"); + assert!(top > bottom, "Top should be > bottom"); + } + + #[test] + fn whip_hitbox_facing_left() { + let mut player = make_test_player(); + player.facing_right = false; + let (_left, _bottom, right, _top) = whip_hitbox(&player, false); + // Hitbox should be to the left of the player + assert!(right < player.x, "Whip should be to the left"); + } + + #[test] + fn whip_extend_increases_range() { + let player = make_test_player(); + let (l1, _, r1, _) = whip_hitbox(&player, false); + let (l2, _, r2, _) = whip_hitbox(&player, true); + let normal_width = r1 - l1; + let extended_width = r2 - l2; + assert!( + extended_width > normal_width, + "Extended whip should be wider: {} vs {}", + extended_width, + normal_width, + ); + } + + #[test] + fn check_player_attack_hits_enemy() { + let mut player = make_test_player(); + player.facing_right = true; + // Set attack to active frames + player.attack_timer = ATTACK_DURATION - 0.15; + + // Place enemy within whip range (to the right) + let mut enemies = vec![make_test_enemy(0, player.x + WHIP_OFFSET, player.y)]; + + let events = check_player_attack(&player, &mut enemies, false); + + assert!(!events.is_empty(), "Should have killed the enemy"); + assert!( + matches!(events[0], CombatEvent::EnemyKilled { enemy_id: 0 }), + "Should be EnemyKilled event" + ); + assert!(!enemies[0].alive, "Enemy should be dead"); + } + + #[test] + fn check_player_attack_misses_distant_enemy() { + let mut player = make_test_player(); + player.facing_right = true; + player.attack_timer = ATTACK_DURATION - 0.15; + + // Place enemy far away + let mut enemies = vec![make_test_enemy(0, player.x + 10.0, player.y)]; + + let events = check_player_attack(&player, &mut enemies, false); + + assert!(events.is_empty(), "Should not hit distant enemy"); + assert!(enemies[0].alive, "Distant enemy should still be alive"); + } + + #[test] + fn check_enemy_damage_hits_player() { + let mut player = make_test_player(); + let enemies = vec![make_test_enemy(0, player.x, player.y)]; + let projectiles = Vec::new(); + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, false); + + assert!(!events.is_empty(), "Player should be hit"); + assert!( + matches!(events[0], CombatEvent::PlayerHit { player_id: 1 }), + "Should be PlayerHit event" + ); + assert_eq!(player.hp, 2, "HP should decrease from 3 to 2"); + assert!( + player.invincibility_timer > 0.0, + "Should have invincibility frames" + ); + } + + #[test] + fn check_enemy_damage_skips_invincible() { + let mut player = make_test_player(); + player.invincibility_timer = 1.0; // Already invincible + let enemies = vec![make_test_enemy(0, player.x, player.y)]; + let projectiles = Vec::new(); + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, false); + + assert!(events.is_empty(), "Invincible player should not be hit"); + assert_eq!(player.hp, 3, "HP should not change"); + } + + #[test] + fn check_enemy_damage_skips_with_powerup_invincibility() { + let mut player = make_test_player(); + let enemies = vec![make_test_enemy(0, player.x, player.y)]; + let projectiles = Vec::new(); + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, true); + + assert!( + events.is_empty(), + "Player with Invincibility powerup should not be hit" + ); + } + + #[test] + fn player_death_on_zero_hp() { + let mut player = make_test_player(); + player.hp = 1; // One hit from death + let enemies = vec![make_test_enemy(0, player.x, player.y)]; + let projectiles = Vec::new(); + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, false); + + assert_eq!(events.len(), 2, "Should have PlayerHit and PlayerDied"); + assert!( + events + .iter() + .any(|e| matches!(e, CombatEvent::PlayerDied { .. })), + "Should have PlayerDied event" + ); + assert_eq!(player.hp, 0, "HP should be 0"); + assert!( + player.death_respawn_timer > 0.0, + "Should have respawn timer" + ); + assert_eq!(player.deaths, 1, "Death count should increment"); + } + + #[test] + fn projectile_hits_player() { + let mut player = make_test_player(); + let enemies = Vec::new(); + let projectiles = vec![EnemyProjectile { + x: player.x, + y: player.y, + vx: 0.0, + vy: 0.0, + lifetime: 2.0, + }]; + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, false); + + assert!(!events.is_empty(), "Projectile should hit player"); + assert_eq!(player.hp, 2, "HP should decrease from 3 to 2"); + } + + #[test] + fn dead_enemy_not_considered_for_damage() { + let mut player = make_test_player(); + let mut enemy = make_test_enemy(0, player.x, player.y); + enemy.alive = false; + let enemies = vec![enemy]; + let projectiles = Vec::new(); + + let events = check_enemy_damage(&mut player, 1, &enemies, &projectiles, false); + + assert!(events.is_empty(), "Dead enemy should not damage player"); + assert_eq!(player.hp, 3, "HP should not change"); + } + + #[test] + fn aabb_overlap_works_correctly() { + // Overlapping + assert!(aabb_overlap((0.0, 0.0, 2.0, 2.0), (1.0, 1.0, 3.0, 3.0))); + // Not overlapping + assert!(!aabb_overlap((0.0, 0.0, 1.0, 1.0), (2.0, 2.0, 3.0, 3.0))); + // Touching edges (not overlapping) + assert!(!aabb_overlap((0.0, 0.0, 1.0, 1.0), (1.0, 0.0, 2.0, 1.0))); + } + + // Suppress dead code warning for AnimState import used in test setup + #[allow(unused)] + fn _use_anim_state() -> AnimState { + AnimState::Idle + } +} diff --git a/crates/games/breakpoint-platformer/src/course_gen.rs b/crates/games/breakpoint-platformer/src/course_gen.rs index f01adc4..c13854e 100644 --- a/crates/games/breakpoint-platformer/src/course_gen.rs +++ b/crates/games/breakpoint-platformer/src/course_gen.rs @@ -1,23 +1,236 @@ +use rand::Rng; +use rand::SeedableRng; use rand::rngs::StdRng; -use rand::{Rng, SeedableRng}; use serde::{Deserialize, Serialize}; +use crate::enemies::EnemySpawn; +use crate::enemies::EnemyType; use crate::physics::TILE_SIZE; -/// Tile types for the platformer course grid. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Water movement multiplier (0.5x speed in water). +pub const WATER_SPEED_FACTOR: f32 = 0.5; +/// Water jump velocity multiplier (0.7x jump in water). +pub const WATER_JUMP_FACTOR: f32 = 0.7; +/// Water buoyancy force (counters ~30% of gravity). +pub const WATER_BUOYANCY: f32 = 9.0; + +/// Tile types for the Castlevania-style platformer course grid. +/// +/// Serialized as a `u8` integer for compact wire representation +/// (9000-tile course would be ~63 KB as string enum names but only ~9 KB as u8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] pub enum Tile { - Empty, - Solid, - Platform, - Hazard, - Checkpoint, - Finish, - PowerUpSpawn, + Empty = 0, + /// Solid stone brick wall/floor/ceiling. + StoneBrick = 1, + /// One-way platform (passable from below). + Platform = 2, + /// Spike hazard (deals 1 HP damage on contact). + Spikes = 3, + /// Race checkpoint. + Checkpoint = 4, + /// Race finish line. + Finish = 5, + /// Power-up spawn location. + PowerUpSpawn = 6, + /// Climbable ladder. + Ladder = 7, + /// Destructible wall (broken by whip attack). + BreakableWall = 8, + /// Decorative wall torch (no gameplay effect). + DecoTorch = 9, + /// Decorative stained glass (no gameplay effect). + DecoStainedGlass = 10, + /// Water tile (slows movement, adds buoyancy). + Water = 11, + /// Decorative cobweb (no gameplay effect). + DecoCobweb = 12, + /// Decorative hanging chain (no gameplay effect). + DecoChain = 13, } -/// A platformer course built from a tile grid. +impl From for u8 { + fn from(t: Tile) -> u8 { + t as u8 + } +} + +impl TryFrom for Tile { + type Error = String; + + fn try_from(v: u8) -> Result { + match v { + 0 => Ok(Tile::Empty), + 1 => Ok(Tile::StoneBrick), + 2 => Ok(Tile::Platform), + 3 => Ok(Tile::Spikes), + 4 => Ok(Tile::Checkpoint), + 5 => Ok(Tile::Finish), + 6 => Ok(Tile::PowerUpSpawn), + 7 => Ok(Tile::Ladder), + 8 => Ok(Tile::BreakableWall), + 9 => Ok(Tile::DecoTorch), + 10 => Ok(Tile::DecoStainedGlass), + 11 => Ok(Tile::Water), + 12 => Ok(Tile::DecoCobweb), + 13 => Ok(Tile::DecoChain), + _ => Err(format!("invalid tile value: {v}")), + } + } +} + +impl Serialize for Tile { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_u8(*self as u8) + } +} + +impl<'de> Deserialize<'de> for Tile { + fn deserialize>(deserializer: D) -> Result { + let v = u8::deserialize(deserializer)?; + Tile::try_from(v).map_err(serde::de::Error::custom) + } +} + +// ================================================================ +// Labyrinth constants +// ================================================================ + +/// Room width in tiles. +pub const ROOM_W: u32 = 32; +/// Room height in tiles. +pub const ROOM_H: u32 = 24; +/// Grid columns (rooms wide). +pub const GRID_COLS: u32 = 6; +/// Grid rows (rooms tall). +pub const GRID_ROWS: u32 = 5; +/// Total course width in tiles. +pub const COURSE_WIDTH: u32 = ROOM_W * GRID_COLS; // 192 +/// Total course height in tiles. +pub const COURSE_HEIGHT: u32 = ROOM_H * GRID_ROWS; // 120 + +// Legacy aliases for compatibility +/// Number of rooms targeted during generation. +pub const NUM_ROOMS: u32 = 22; + +// ================================================================ +// Room grid types +// ================================================================ + +/// Position in the room grid. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GridPos { + pub col: u8, + pub row: u8, +} + +/// Direction of a doorway between adjacent rooms. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +impl Direction { + fn opposite(self) -> Self { + match self { + Direction::Up => Direction::Down, + Direction::Down => Direction::Up, + Direction::Left => Direction::Right, + Direction::Right => Direction::Left, + } + } + + fn offset(self) -> (i8, i8) { + match self { + Direction::Up => (0, 1), + Direction::Down => (0, -1), + Direction::Left => (-1, 0), + Direction::Right => (1, 0), + } + } +} + +/// Theme/type of a placed room, affecting interior generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RoomTheme { + Entrance, + Corridor, + GreatHall, + Library, + Armory, + Chapel, + Crypt, + Tower, + Dungeon, + ThroneRoom, +} + +/// Convert a `u8` to `RoomTheme`, defaulting to `Entrance` for unknown values. +pub fn room_theme_from_u8(v: u8) -> RoomTheme { + match v { + 0 => RoomTheme::Entrance, + 1 => RoomTheme::Corridor, + 2 => RoomTheme::GreatHall, + 3 => RoomTheme::Library, + 4 => RoomTheme::Armory, + 5 => RoomTheme::Chapel, + 6 => RoomTheme::Crypt, + 7 => RoomTheme::Tower, + 8 => RoomTheme::Dungeon, + 9 => RoomTheme::ThroneRoom, + _ => RoomTheme::Entrance, + } +} + +impl RoomTheme { + /// Convert to `u8` for compact storage. + pub fn as_u8(self) -> u8 { + match self { + RoomTheme::Entrance => 0, + RoomTheme::Corridor => 1, + RoomTheme::GreatHall => 2, + RoomTheme::Library => 3, + RoomTheme::Armory => 4, + RoomTheme::Chapel => 5, + RoomTheme::Crypt => 6, + RoomTheme::Tower => 7, + RoomTheme::Dungeon => 8, + RoomTheme::ThroneRoom => 9, + } + } +} + +/// A room placed in the labyrinth grid. +#[derive(Debug, Clone)] +pub struct PlacedRoom { + pub grid_pos: GridPos, + pub theme: RoomTheme, + pub doors: Vec, + pub distance_from_start: u16, +} + +/// An edge connecting two adjacent rooms. +#[derive(Debug, Clone)] +struct RoomEdge { + a: GridPos, + b: GridPos, + direction: Direction, +} + +/// Checkpoint definition with an ID for 2D navigation. #[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckpointDef { + pub x: f32, + pub y: f32, + pub id: u16, +} + +/// A platformer course built from a tile grid. +#[derive(Debug, Clone)] pub struct Course { /// Width in tiles. pub width: u32, @@ -29,6 +242,117 @@ pub struct Course { pub spawn_x: f32, /// Spawn Y position in world units. pub spawn_y: f32, + /// Enemy spawn definitions for this course. + pub enemy_spawns: Vec, + /// Checkpoint definitions with IDs for 2D exploration. + pub checkpoint_positions: Vec, + /// Room distances from start, indexed by (col * GRID_ROWS + row). + /// Used for rubber-banding and race position. + pub room_distances: Vec, + /// Room themes, indexed by (col * GRID_ROWS + row). + /// Stored as `RoomTheme as u8` for compact serialization. Default 0 = Entrance. + pub room_themes: Vec, +} + +// ================================================================ +// RLE serialization for Course +// ================================================================ + +impl Serialize for Course { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + + // RLE-encode tiles + let rle = rle_encode(&self.tiles); + + let mut s = serializer.serialize_struct("Course", 9)?; + s.serialize_field("width", &self.width)?; + s.serialize_field("height", &self.height)?; + s.serialize_field("tiles_rle", &rle)?; + s.serialize_field("spawn_x", &self.spawn_x)?; + s.serialize_field("spawn_y", &self.spawn_y)?; + s.serialize_field("enemy_spawns", &self.enemy_spawns)?; + s.serialize_field("checkpoint_positions", &self.checkpoint_positions)?; + s.serialize_field("room_distances", &self.room_distances)?; + s.serialize_field("room_themes", &self.room_themes)?; + s.end() + } +} + +impl<'de> Deserialize<'de> for Course { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct CourseRaw { + width: u32, + height: u32, + tiles_rle: Vec<(u8, u16)>, + spawn_x: f32, + spawn_y: f32, + enemy_spawns: Vec, + checkpoint_positions: Vec, + room_distances: Vec, + #[serde(default)] + room_themes: Vec, + } + + let raw = CourseRaw::deserialize(deserializer)?; + let tiles = rle_decode(&raw.tiles_rle).map_err(serde::de::Error::custom)?; + + // If room_themes is missing (old format), default to all Entrance (0) + let room_themes = if raw.room_themes.is_empty() { + vec![0; (GRID_COLS * GRID_ROWS) as usize] + } else { + raw.room_themes + }; + + Ok(Course { + width: raw.width, + height: raw.height, + tiles, + spawn_x: raw.spawn_x, + spawn_y: raw.spawn_y, + enemy_spawns: raw.enemy_spawns, + checkpoint_positions: raw.checkpoint_positions, + room_distances: raw.room_distances, + room_themes, + }) + } +} + +/// RLE encode tiles as (tile_value, run_length) pairs. +fn rle_encode(tiles: &[Tile]) -> Vec<(u8, u16)> { + let mut result = Vec::new(); + if tiles.is_empty() { + return result; + } + + let mut current = tiles[0] as u8; + let mut count: u16 = 1; + + for &tile in &tiles[1..] { + let val = tile as u8; + if val == current && count < u16::MAX { + count += 1; + } else { + result.push((current, count)); + current = val; + count = 1; + } + } + result.push((current, count)); + result +} + +/// RLE decode tiles from (tile_value, run_length) pairs. +fn rle_decode(rle: &[(u8, u16)]) -> Result, String> { + let mut tiles = Vec::new(); + for &(val, count) in rle { + let tile = Tile::try_from(val)?; + for _ in 0..count { + tiles.push(tile); + } + } + Ok(tiles) } impl Course { @@ -39,143 +363,1218 @@ impl Course { self.tiles[y as usize * self.width as usize + x as usize] } - fn set_tile(&mut self, x: u32, y: u32, tile: Tile) { + pub fn set_tile(&mut self, x: u32, y: u32, tile: Tile) { if x < self.width && y < self.height { self.tiles[y as usize * self.width as usize + x as usize] = tile; } } + + /// Look up the room distance at a given tile position. + pub fn room_distance_at(&self, world_x: f32, world_y: f32) -> u16 { + let col = (world_x / TILE_SIZE / ROOM_W as f32) as u32; + let row = (world_y / TILE_SIZE / ROOM_H as f32) as u32; + if col < GRID_COLS && row < GRID_ROWS { + let idx = col as usize * GRID_ROWS as usize + row as usize; + if idx < self.room_distances.len() { + return self.room_distances[idx]; + } + } + 0 + } + + /// Look up the room theme at a given tile position. + /// Returns `RoomTheme::Entrance` for positions outside the grid or unset rooms. + pub fn room_theme_at_tile(&self, tx: i32, ty: i32) -> RoomTheme { + if tx < 0 || ty < 0 { + return RoomTheme::Entrance; + } + let col = tx as u32 / ROOM_W; + let row = ty as u32 / ROOM_H; + if col < GRID_COLS && row < GRID_ROWS { + let idx = col as usize * GRID_ROWS as usize + row as usize; + if idx < self.room_themes.len() { + return room_theme_from_u8(self.room_themes[idx]); + } + } + RoomTheme::Entrance + } + + /// Find the checkpoint ID at a given tile coordinate, if any. + pub fn find_checkpoint_id(&self, tx: i32, ty: i32) -> Option { + let world_x = tx as f32 * TILE_SIZE + TILE_SIZE / 2.0; + let world_y = ty as f32 * TILE_SIZE + TILE_SIZE / 2.0; + self.checkpoint_positions + .iter() + .find(|cp| (cp.x - world_x).abs() < TILE_SIZE && (cp.y - world_y).abs() < TILE_SIZE) + .map(|cp| cp.id) + } } -/// Chunk width in tiles (each procedural section is this wide). -const CHUNK_WIDTH: u32 = 10; -/// Course height in tiles. -pub const COURSE_HEIGHT: usize = 20; -/// Number of chunks in a generated course. -const NUM_CHUNKS: u32 = 10; -/// Course width in tiles (total chunks * chunk width). -pub const COURSE_WIDTH: usize = 100; // CHUNK_WIDTH * NUM_CHUNKS +// ================================================================ +// Labyrinth generation +// ================================================================ -/// Generate a deterministic course from a seed. +/// Generate a deterministic castle labyrinth course from a seed. pub fn generate_course(seed: u64) -> Course { - let width = CHUNK_WIDTH * NUM_CHUNKS; - let height = COURSE_HEIGHT as u32; + let width = COURSE_WIDTH; + let height = COURSE_HEIGHT; let mut course = Course { width, height, - tiles: vec![Tile::Empty; (width * height) as usize], - spawn_x: 2.0 * TILE_SIZE, - spawn_y: 3.0 * TILE_SIZE, + tiles: vec![Tile::StoneBrick; (width * height) as usize], + spawn_x: 0.0, + spawn_y: 0.0, + enemy_spawns: Vec::new(), + checkpoint_positions: Vec::new(), + room_distances: vec![0; (GRID_COLS * GRID_ROWS) as usize], + room_themes: vec![0; (GRID_COLS * GRID_ROWS) as usize], }; let mut rng = StdRng::seed_from_u64(seed); - // Ground floor (solid bottom 2 rows) - for x in 0..width { - course.set_tile(x, 0, Tile::Solid); - course.set_tile(x, 1, Tile::Solid); + // Step 1: Place rooms using random growth + let rooms = place_rooms(&mut rng, NUM_ROOMS); + + // Step 2: Build connectivity (MST + extra edges) + let edges = build_connections(&rooms, &mut rng); + + // Step 3: Assign themes based on distance from start + let rooms = assign_themes(rooms, &edges); + + // Step 4: Store room distances and themes + for room in &rooms { + let idx = room.grid_pos.col as usize * GRID_ROWS as usize + room.grid_pos.row as usize; + course.room_distances[idx] = room.distance_from_start; + course.room_themes[idx] = room.theme.as_u8(); } - // Spawn area (first chunk is flat with some platforms) - for y in 2..4 { - course.set_tile(0, y, Tile::Solid); + // Step 5: Stamp the labyrinth (carve rooms and doorways) + stamp_labyrinth(&mut course, &rooms, &edges); + + // Step 6: Populate rooms with interior content + populate_rooms(&mut course, &rooms, &edges, &mut rng); + + // Step 7: Place checkpoints + place_checkpoints(&mut course, &rooms); + + // Step 8: Place finish in ThroneRoom + place_finish(&mut course, &rooms); + + // Step 9: Set spawn position in Entrance room + let entrance = rooms + .iter() + .find(|r| r.theme == RoomTheme::Entrance) + .unwrap_or(&rooms[0]); + let base_x = entrance.grid_pos.col as u32 * ROOM_W; + let base_y = entrance.grid_pos.row as u32 * ROOM_H; + course.spawn_x = (base_x + ROOM_W / 2) as f32 * TILE_SIZE; + course.spawn_y = (base_y + 3) as f32 * TILE_SIZE; + + course +} + +/// Place rooms using random frontier growth from the start cell. +fn place_rooms(rng: &mut StdRng, target_count: u32) -> Vec { + let start = GridPos { col: 3, row: 0 }; + let mut placed = vec![PlacedRoom { + grid_pos: start, + theme: RoomTheme::Entrance, + doors: Vec::new(), + distance_from_start: 0, + }]; + + let mut occupied = std::collections::HashSet::new(); + occupied.insert(start); + + let mut frontier: Vec = Vec::new(); + add_neighbors(start, &occupied, &mut frontier); + + while (placed.len() as u32) < target_count && !frontier.is_empty() { + let idx = rng.random_range(0..frontier.len()); + let cell = frontier.swap_remove(idx); + + if occupied.contains(&cell) { + continue; + } + + occupied.insert(cell); + placed.push(PlacedRoom { + grid_pos: cell, + theme: RoomTheme::Corridor, // placeholder + doors: Vec::new(), + distance_from_start: 0, + }); + + add_neighbors(cell, &occupied, &mut frontier); } - // Generate each chunk - for chunk_idx in 1..NUM_CHUNKS { - let base_x = chunk_idx * CHUNK_WIDTH; - generate_chunk(&mut course, &mut rng, base_x, chunk_idx); + // Ensure at least one room in top row for the goal + let has_top = placed + .iter() + .any(|r| r.grid_pos.row == (GRID_ROWS - 1) as u8); + if !has_top { + // Find a cell in the top row adjacent to an existing room + for col in 0..GRID_COLS as u8 { + let cell = GridPos { + col, + row: (GRID_ROWS - 1) as u8, + }; + if !occupied.contains(&cell) { + let adj = GridPos { + col, + row: (GRID_ROWS - 2) as u8, + }; + if occupied.contains(&adj) { + occupied.insert(cell); + placed.push(PlacedRoom { + grid_pos: cell, + theme: RoomTheme::Corridor, + doors: Vec::new(), + distance_from_start: 0, + }); + break; + } + } + } } - // Place checkpoints every 3 chunks - for chunk_idx in (3..NUM_CHUNKS).step_by(3) { - let cx = chunk_idx * CHUNK_WIDTH + CHUNK_WIDTH / 2; - course.set_tile(cx, 2, Tile::Checkpoint); + placed +} + +/// Add valid neighboring cells to the frontier. +fn add_neighbors( + pos: GridPos, + occupied: &std::collections::HashSet, + frontier: &mut Vec, +) { + let dirs = [ + Direction::Up, + Direction::Down, + Direction::Left, + Direction::Right, + ]; + for dir in dirs { + let (dx, dy) = dir.offset(); + let nc = pos.col as i8 + dx; + let nr = pos.row as i8 + dy; + if nc >= 0 && nc < GRID_COLS as i8 && nr >= 0 && nr < GRID_ROWS as i8 { + let neighbor = GridPos { + col: nc as u8, + row: nr as u8, + }; + if !occupied.contains(&neighbor) && !frontier.contains(&neighbor) { + frontier.push(neighbor); + } + } } +} - // Place finish line in last chunk - let finish_x = width - 3; - course.set_tile(finish_x, 2, Tile::Finish); - course.set_tile(finish_x + 1, 2, Tile::Finish); +/// Build MST via Prim's algorithm with random weights, plus extra edges. +fn build_connections(rooms: &[PlacedRoom], rng: &mut StdRng) -> Vec { + use std::collections::HashSet; - course + let room_set: HashSet = rooms.iter().map(|r| r.grid_pos).collect(); + let mut in_tree: HashSet = HashSet::new(); + let mut edges: Vec = Vec::new(); + + // All possible edges between adjacent rooms + let mut all_edges: Vec<(RoomEdge, u32)> = Vec::new(); + for room in rooms { + for dir in &[ + Direction::Up, + Direction::Down, + Direction::Left, + Direction::Right, + ] { + let (dx, dy) = dir.offset(); + let nc = room.grid_pos.col as i8 + dx; + let nr = room.grid_pos.row as i8 + dy; + if nc >= 0 && nc < GRID_COLS as i8 && nr >= 0 && nr < GRID_ROWS as i8 { + let neighbor = GridPos { + col: nc as u8, + row: nr as u8, + }; + if room_set.contains(&neighbor) { + // Only add each edge once (a < b lexicographically) + let (a, b, d) = + if (room.grid_pos.col, room.grid_pos.row) < (neighbor.col, neighbor.row) { + (room.grid_pos, neighbor, *dir) + } else { + (neighbor, room.grid_pos, dir.opposite()) + }; + let weight = rng.random_range(1u32..100); + all_edges.push((RoomEdge { a, b, direction: d }, weight)); + } + } + } + } + + // Deduplicate edges + all_edges.sort_by_key(|(e, _)| (e.a.col, e.a.row, e.b.col, e.b.row)); + all_edges.dedup_by_key(|(e, _)| (e.a.col, e.a.row, e.b.col, e.b.row)); + + // Sort by weight for Prim's + all_edges.sort_by_key(|(_, w)| *w); + + // Prim's MST + in_tree.insert(rooms[0].grid_pos); + let mut mst_count = 0; + while mst_count < rooms.len() - 1 { + let mut found = false; + for (edge, _) in &all_edges { + let a_in = in_tree.contains(&edge.a); + let b_in = in_tree.contains(&edge.b); + if a_in != b_in { + edges.push(edge.clone()); + in_tree.insert(edge.a); + in_tree.insert(edge.b); + mst_count += 1; + found = true; + break; + } + } + if !found { + break; + } + // Remove used edge + let last_edge = edges.last().unwrap(); + let key = ( + last_edge.a.col, + last_edge.a.row, + last_edge.b.col, + last_edge.b.row, + ); + all_edges.retain(|(e, _)| (e.a.col, e.a.row, e.b.col, e.b.row) != key); + } + + // Add 3-5 extra random edges for alternate routes + let extra_count = rng.random_range(3u32..6).min(all_edges.len() as u32); + for _ in 0..extra_count { + if all_edges.is_empty() { + break; + } + let idx = rng.random_range(0..all_edges.len()); + let (edge, _) = all_edges.swap_remove(idx); + // Only add if not already in edges + let key = (edge.a.col, edge.a.row, edge.b.col, edge.b.row); + if !edges + .iter() + .any(|e| (e.a.col, e.a.row, e.b.col, e.b.row) == key) + { + edges.push(edge); + } + } + + edges } -fn generate_chunk(course: &mut Course, rng: &mut StdRng, base_x: u32, _chunk_idx: u32) { - let pattern = rng.random_range(0u8..5); - - match pattern { - 0 => { - // Flat section with a pit - let pit_start = base_x + rng.random_range(3..7); - let pit_width = rng.random_range(2..4); - for x in pit_start..pit_start + pit_width { - if x < course.width { - course.set_tile(x, 0, Tile::Empty); - course.set_tile(x, 1, Tile::Empty); - } - } - // Hazard at bottom of pit - for x in pit_start..pit_start + pit_width { - if x < course.width { - // Hazard is below, effectively falling = respawn - } - } - }, - 1 => { - // Raised platforms - let plat_y = rng.random_range(4u32..8); - let plat_start = base_x + rng.random_range(1..4); - let plat_len = rng.random_range(3..6); - for x in plat_start..plat_start + plat_len { - if x < course.width { - course.set_tile(x, plat_y, Tile::Platform); - } - } - // Power-up on one platform - if plat_start + 1 < course.width { - course.set_tile(plat_start + 1, plat_y + 1, Tile::PowerUpSpawn); - } - }, - 2 => { - // Staircase going up - for i in 0..5u32 { - let x = base_x + i * 2; - let y = 2 + i; - if x < course.width && y < course.height { - course.set_tile(x, y, Tile::Solid); - if x + 1 < course.width { - course.set_tile(x + 1, y, Tile::Solid); +/// BFS from start to compute distances, then assign themes by distance tier. +fn assign_themes(mut rooms: Vec, edges: &[RoomEdge]) -> Vec { + use std::collections::{HashMap, VecDeque}; + + // Build adjacency list + let mut adj: HashMap<(u8, u8), Vec<(u8, u8)>> = HashMap::new(); + for edge in edges { + adj.entry((edge.a.col, edge.a.row)) + .or_default() + .push((edge.b.col, edge.b.row)); + adj.entry((edge.b.col, edge.b.row)) + .or_default() + .push((edge.a.col, edge.a.row)); + } + + // BFS from start room (index 0) + let start = rooms[0].grid_pos; + let mut distances: HashMap<(u8, u8), u16> = HashMap::new(); + let mut queue = VecDeque::new(); + distances.insert((start.col, start.row), 0); + queue.push_back((start.col, start.row)); + + while let Some((col, row)) = queue.pop_front() { + let dist = distances[&(col, row)]; + if let Some(neighbors) = adj.get(&(col, row)) { + for &(nc, nr) in neighbors { + if let std::collections::hash_map::Entry::Vacant(e) = distances.entry((nc, nr)) { + e.insert(dist + 1); + queue.push_back((nc, nr)); + } + } + } + } + + // Find the max-distance room for ThroneRoom + let max_dist = rooms + .iter() + .map(|r| { + distances + .get(&(r.grid_pos.col, r.grid_pos.row)) + .copied() + .unwrap_or(0) + }) + .max() + .unwrap_or(0); + + // Find the room with max distance (prefer top rows) + let throne_pos = rooms + .iter() + .filter(|r| { + distances + .get(&(r.grid_pos.col, r.grid_pos.row)) + .copied() + .unwrap_or(0) + == max_dist + }) + .max_by_key(|r| r.grid_pos.row) + .map(|r| r.grid_pos) + .unwrap_or(rooms.last().unwrap().grid_pos); + + // Assign themes and distances + for room in &mut rooms { + let dist = distances + .get(&(room.grid_pos.col, room.grid_pos.row)) + .copied() + .unwrap_or(0); + room.distance_from_start = dist; + + if room.grid_pos == start { + room.theme = RoomTheme::Entrance; + } else if room.grid_pos == throne_pos { + room.theme = RoomTheme::ThroneRoom; + } else { + room.theme = match dist { + 1 => RoomTheme::Corridor, + 2..=3 => { + if dist % 2 == 0 { + RoomTheme::GreatHall + } else { + RoomTheme::Library + } + }, + 4..=5 => { + if dist % 2 == 0 { + RoomTheme::Armory + } else { + RoomTheme::Chapel + } + }, + 6..=7 => { + if dist % 2 == 0 { + RoomTheme::Tower + } else { + RoomTheme::Crypt } + }, + _ => RoomTheme::Dungeon, + }; + } + } + + // Build door lists for each room based on edges + let room_positions: std::collections::HashSet<(u8, u8)> = rooms + .iter() + .map(|r| (r.grid_pos.col, r.grid_pos.row)) + .collect(); + for room in &mut rooms { + let pos = room.grid_pos; + for edge in edges { + if edge.a == pos { + room.doors.push(edge.direction); + } else if edge.b == pos { + room.doors.push(edge.direction.opposite()); + } + } + // Deduplicate doors + room.doors.sort_by_key(|d| *d as u8); + room.doors.dedup(); + } + let _ = room_positions; // suppress unused warning + + rooms +} + +/// Stamp the labyrinth: the entire grid starts as StoneBrick. +/// Carve empty interiors for each room, then carve doorways. +fn stamp_labyrinth(course: &mut Course, rooms: &[PlacedRoom], edges: &[RoomEdge]) { + // Carve room interiors (leave 1-tile walls) + for room in rooms { + let bx = room.grid_pos.col as u32 * ROOM_W; + let by = room.grid_pos.row as u32 * ROOM_H; + + // Carve 30×22 interior (1-tile border) + for y in (by + 1)..(by + ROOM_H - 1) { + for x in (bx + 1)..(bx + ROOM_W - 1) { + course.set_tile(x, y, Tile::Empty); + } + } + + // Add floor inside room (bottom 1-tile row inside the border = by + 1) + for x in (bx + 1)..(bx + ROOM_W - 1) { + course.set_tile(x, by + 1, Tile::StoneBrick); + } + } + + // Carve doorways between connected rooms (4-tile-wide passages) + for edge in edges { + let (dx, dy) = edge.direction.offset(); + + let bx_a = edge.a.col as u32 * ROOM_W; + let by_a = edge.a.row as u32 * ROOM_H; + + if dx != 0 { + // Horizontal doorway: carve through the vertical wall between rooms + let wall_x = if dx > 0 { bx_a + ROOM_W - 1 } else { bx_a }; + let mid_y = by_a + ROOM_H / 2; + for dy_off in 0..4u32 { + let y = mid_y - 1 + dy_off; + course.set_tile(wall_x, y, Tile::Empty); + // Also clear the adjacent tile on the other side + let other_x = (wall_x as i32 + dx as i32) as u32; + if other_x < course.width { + course.set_tile(other_x, y, Tile::Empty); } } - }, - 3 => { - // Wall with gap - let wall_x = base_x + CHUNK_WIDTH / 2; - let gap_y = rng.random_range(3u32..6); - for y in 2..10 { - if y != gap_y && y != gap_y + 1 && wall_x < course.width { - course.set_tile(wall_x, y, Tile::Solid); + // Ensure floor continuity in doorway + let floor_y = mid_y - 2; + course.set_tile(wall_x, floor_y, Tile::StoneBrick); + let other_x = (wall_x as i32 + dx as i32) as u32; + if other_x < course.width { + course.set_tile(other_x, floor_y, Tile::StoneBrick); + } + } else { + // Vertical doorway: carve through the horizontal wall between rooms + let wall_y = if dy > 0 { by_a + ROOM_H - 1 } else { by_a }; + let mid_x = bx_a + ROOM_W / 2; + for dx_off in 0..4u32 { + let x = mid_x - 1 + dx_off; + course.set_tile(x, wall_y, Tile::Empty); + // Also clear the adjacent tile + let other_y = (wall_y as i32 + dy as i32) as u32; + if other_y < course.height { + course.set_tile(x, other_y, Tile::Empty); + } + } + // Add ladder for vertical doorways going up + if dy > 0 { + let ladder_x = mid_x; + // Ladder from floor of lower room through doorway to floor of upper room + for ly in (wall_y.saturating_sub(3))..=(wall_y + 2).min(course.height - 1) { + course.set_tile(ladder_x, ly, Tile::Ladder); + } + } else { + let ladder_x = mid_x; + let other_y = (wall_y as i32 + dy as i32) as u32; + for ly in other_y.saturating_sub(1)..=(wall_y + 3).min(course.height - 1) { + course.set_tile(ladder_x, ly, Tile::Ladder); } } - }, - _ => { - // Hazard section - let hz_start = base_x + rng.random_range(2..5); - let hz_len = rng.random_range(2..4); - for x in hz_start..hz_start + hz_len { - if x < course.width { - course.set_tile(x, 2, Tile::Hazard); + } + } +} + +/// Populate each room's interior with themed content. +fn populate_rooms( + course: &mut Course, + rooms: &[PlacedRoom], + _edges: &[RoomEdge], + rng: &mut StdRng, +) { + for room in rooms { + let bx = room.grid_pos.col as u32 * ROOM_W; + let by = room.grid_pos.row as u32 * ROOM_H; + + match room.theme { + RoomTheme::Entrance => gen_entrance(course, bx, by), + RoomTheme::Corridor => gen_corridor(course, rng, bx, by, &room.doors), + RoomTheme::GreatHall => gen_great_hall(course, rng, bx, by, &room.doors), + RoomTheme::Library => gen_library(course, rng, bx, by, &room.doors), + RoomTheme::Armory => gen_armory(course, rng, bx, by, &room.doors), + RoomTheme::Chapel => gen_chapel(course, rng, bx, by, &room.doors), + RoomTheme::Crypt => gen_crypt(course, rng, bx, by, &room.doors), + RoomTheme::Tower => gen_tower(course, rng, bx, by, &room.doors), + RoomTheme::Dungeon => gen_dungeon(course, rng, bx, by, &room.doors), + RoomTheme::ThroneRoom => gen_throne_room(course, rng, bx, by, &room.doors), + } + } +} + +/// Check if a tile position is within a doorway zone (should be kept clear). +fn is_doorway_zone(x: u32, y: u32, bx: u32, by: u32, doors: &[Direction]) -> bool { + for door in doors { + match door { + Direction::Left => { + let mid_y = by + ROOM_H / 2; + if x <= bx + 2 && y >= mid_y - 2 && y <= mid_y + 3 { + return true; + } + }, + Direction::Right => { + let mid_y = by + ROOM_H / 2; + if x >= bx + ROOM_W - 3 && y >= mid_y - 2 && y <= mid_y + 3 { + return true; } + }, + Direction::Down => { + let mid_x = bx + ROOM_W / 2; + if y <= by + 3 && x >= mid_x - 2 && x <= mid_x + 3 { + return true; + } + }, + Direction::Up => { + let mid_x = bx + ROOM_W / 2; + if y >= by + ROOM_H - 4 && x >= mid_x - 2 && x <= mid_x + 3 { + return true; + } + }, + } + } + false +} + +// ================================================================ +// Per-theme room generators +// ================================================================ + +/// Entrance: flat floor, torches, safe. +fn gen_entrance(course: &mut Course, bx: u32, by: u32) { + // Decorative torches + course.set_tile(bx + 3, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 4, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W / 2, by + 8, Tile::DecoTorch); + + // A few safe platforms + for dx in 0..5 { + course.set_tile(bx + 8 + dx, by + 6, Tile::Platform); + } + for dx in 0..5 { + course.set_tile(bx + 18 + dx, by + 6, Tile::Platform); + } +} + +/// Corridor: basic platforms, 1 skeleton, 1-2 spike patches. +fn gen_corridor(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Platforms + let plat_count = rng.random_range(2u32..4); + for _ in 0..plat_count { + let px = bx + rng.random_range(3..ROOM_W - 5); + let py = by + rng.random_range(5u32..12); + if is_doorway_zone(px, py, bx, by, doors) { + continue; + } + let len = rng.random_range(3u32..7); + for dx in 0..len { + if !is_doorway_zone(px + dx, py, bx, by, doors) { + course.set_tile(px + dx, py, Tile::Platform); } - // Platform above hazard for safe passage - for x in hz_start..hz_start + hz_len + 1 { - if x < course.width { - course.set_tile(x, 5, Tile::Platform); + } + } + + // Spike patches + let spike_x = bx + rng.random_range(5..ROOM_W - 6); + let spike_len = rng.random_range(2u32..4); + for dx in 0..spike_len { + if !is_doorway_zone(spike_x + dx, by + 2, bx, by, doors) { + course.set_tile(spike_x + dx, by + 2, Tile::Spikes); + } + } + + // 1 Skeleton + let ex = (bx + ROOM_W / 2) as f32 * TILE_SIZE; + let ey = (by + 3) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: ex, + y: ey, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + + // Torches + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + + // Power-up + let pu_x = bx + rng.random_range(4..ROOM_W - 4); + let pu_y = by + rng.random_range(4u32..8); + course.set_tile(pu_x, pu_y, Tile::PowerUpSpawn); +} + +/// GreatHall: pillars, open floor, upper walkway. 1 Skeleton + 1 Medusa. +fn gen_great_hall(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Pillars + let pillar_count = rng.random_range(2u32..4); + let spacing = (ROOM_W - 4) / (pillar_count + 1); + for i in 1..=pillar_count { + let px = bx + 2 + i * spacing; + for y in (by + 2)..(by + 12) { + if !is_doorway_zone(px, y, bx, by, doors) { + course.set_tile(px, y, Tile::StoneBrick); + } + } + } + + // Upper walkway + for dx in 0..(ROOM_W - 6) { + let x = bx + 3 + dx; + let y = by + 14; + if !is_doorway_zone(x, y, bx, by, doors) { + course.set_tile(x, y, Tile::Platform); + } + } + + // Skeleton on ground + let ex = (bx + ROOM_W / 3) as f32 * TILE_SIZE; + let ey = (by + 3) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: ex, + y: ey, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + + // Medusa high up + let mx = (bx + ROOM_W / 2) as f32 * TILE_SIZE; + let my = (by + 16) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: mx, + y: my, + enemy_type: EnemyType::Medusa, + patrol_min_x: (bx + 3) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 4) as f32 * TILE_SIZE, + }); + + // Stained glass and torches + course.set_tile(bx + 5, by + 18, Tile::DecoStainedGlass); + course.set_tile(bx + ROOM_W - 6, by + 18, Tile::DecoStainedGlass); + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + + // Power-up on upper walkway + course.set_tile(bx + ROOM_W / 2, by + 15, Tile::PowerUpSpawn); +} + +/// Library: bookshelf columns, ladders, vertical. 2 Bats. +fn gen_library(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Bookshelf columns (tall stone brick columns with gaps) + let col_count = 3u32; + let spacing = (ROOM_W - 4) / (col_count + 1); + for i in 1..=col_count { + let px = bx + 2 + i * spacing; + for y in (by + 2)..(by + 16) { + if !is_doorway_zone(px, y, bx, by, doors) { + // Leave gaps for passage + if y != by + 7 && y != by + 12 { + course.set_tile(px, y, Tile::StoneBrick); } } - }, + } + } + + // Ladders between shelves + for i in 1..col_count { + let lx = bx + 2 + i * spacing + spacing / 2; + for y in (by + 3)..(by + 15) { + if !is_doorway_zone(lx, y, bx, by, doors) { + course.set_tile(lx, y, Tile::Ladder); + } + } + } + + // Platforms at different heights + for h in [by + 7, by + 12] { + for dx in 0..4 { + let x = bx + 4 + dx; + if !is_doorway_zone(x, h, bx, by, doors) { + course.set_tile(x, h, Tile::Platform); + } + } } + + // 2 Bats + for &bat_y in &[by + 8, by + 14] { + let bx_pos = (bx + rng.random_range(5..ROOM_W - 5)) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: bx_pos, + y: bat_y as f32 * TILE_SIZE, + enemy_type: EnemyType::Bat, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + } + + // Torches + course.set_tile(bx + 2, by + 5, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 5, Tile::DecoTorch); + + // Power-up near top + course.set_tile(bx + ROOM_W / 2 + 2, by + 16, Tile::PowerUpSpawn); +} + +/// Armory: heavy platforms, weapon racks (deco). 2 Knights. Spike rows. +fn gen_armory(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Heavy platforms + for &py in &[by + 6, by + 11, by + 16] { + let start = bx + rng.random_range(3..8); + let len = rng.random_range(6u32..12); + for dx in 0..len { + let x = start + dx; + if x < bx + ROOM_W - 2 && !is_doorway_zone(x, py, bx, by, doors) { + course.set_tile(x, py, Tile::Platform); + } + } + } + + // Spike rows on floor + for dx in 0..6 { + let x = bx + 8 + dx; + if !is_doorway_zone(x, by + 2, bx, by, doors) { + course.set_tile(x, by + 2, Tile::Spikes); + } + } + for dx in 0..4 { + let x = bx + 20 + dx; + if !is_doorway_zone(x, by + 2, bx, by, doors) { + course.set_tile(x, by + 2, Tile::Spikes); + } + } + + // 2 Knights + for &kx_off in &[ROOM_W / 3, 2 * ROOM_W / 3] { + let kx = (bx + kx_off) as f32 * TILE_SIZE; + let ky = (by + 3) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: kx, + y: ky, + enemy_type: EnemyType::Knight, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + } + + // Gargoyle that swoops from above + course.enemy_spawns.push(EnemySpawn { + x: (bx + ROOM_W / 2) as f32 * TILE_SIZE, + y: (by + 14) as f32 * TILE_SIZE, + enemy_type: EnemyType::Gargoyle, + patrol_min_x: (bx + 4) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 5) as f32 * TILE_SIZE, + }); + + // Weapon rack decoration (chains) + course.set_tile(bx + 4, by + 4, Tile::DecoChain); + course.set_tile(bx + ROOM_W - 5, by + 4, Tile::DecoChain); + + // Torches + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + + // Power-up + course.set_tile(bx + ROOM_W / 2, by + 12, Tile::PowerUpSpawn); +} + +/// Chapel: stained glass, altar platforms. 1 Medusa. +fn gen_chapel(course: &mut Course, _rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Altar platform in center + for dx in 0..8 { + let x = bx + ROOM_W / 2 - 4 + dx; + if !is_doorway_zone(x, by + 5, bx, by, doors) { + course.set_tile(x, by + 5, Tile::StoneBrick); + } + } + + // Side platforms + for dx in 0..4 { + course.set_tile(bx + 3 + dx, by + 9, Tile::Platform); + course.set_tile(bx + ROOM_W - 7 + dx, by + 9, Tile::Platform); + } + + // Upper platforms + for dx in 0..6 { + let x = bx + ROOM_W / 2 - 3 + dx; + if !is_doorway_zone(x, by + 13, bx, by, doors) { + course.set_tile(x, by + 13, Tile::Platform); + } + } + + // 1 Medusa + let mx = (bx + ROOM_W / 2) as f32 * TILE_SIZE; + let my = (by + 15) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: mx, + y: my, + enemy_type: EnemyType::Medusa, + patrol_min_x: (bx + 3) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 4) as f32 * TILE_SIZE, + }); + + // Stained glass + course.set_tile(bx + 5, by + 18, Tile::DecoStainedGlass); + course.set_tile(bx + ROOM_W / 2, by + 20, Tile::DecoStainedGlass); + course.set_tile(bx + ROOM_W - 6, by + 18, Tile::DecoStainedGlass); + + // Torches + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + + // Power-up + course.set_tile(bx + ROOM_W / 2, by + 14, Tile::PowerUpSpawn); +} + +/// Crypt: low ceiling, water pools, breakable walls. 2 Skeletons. Water + spikes. +fn gen_crypt(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Low ceiling + for x in (bx + 1)..(bx + ROOM_W - 1) { + if !is_doorway_zone(x, by + 14, bx, by, doors) { + course.set_tile(x, by + 14, Tile::StoneBrick); + } + if !is_doorway_zone(x, by + 15, bx, by, doors) { + course.set_tile(x, by + 15, Tile::StoneBrick); + } + } + + // Internal walls with gaps + let wall_x = bx + ROOM_W / 3; + let gap_y = by + rng.random_range(4u32..8); + for y in (by + 2)..(by + 14) { + if y != gap_y + && y != gap_y + 1 + && y != gap_y + 2 + && !is_doorway_zone(wall_x, y, bx, by, doors) + { + course.set_tile(wall_x, y, Tile::StoneBrick); + } + } + + // Breakable wall + let bw_x = bx + rng.random_range(4..ROOM_W - 4); + let bw_y = by + rng.random_range(4u32..8); + if !is_doorway_zone(bw_x, bw_y, bx, by, doors) { + course.set_tile(bw_x, bw_y, Tile::BreakableWall); + if bw_x + 1 < bx + ROOM_W - 1 { + course.set_tile(bw_x + 1, bw_y, Tile::PowerUpSpawn); + } + } + + // Water pool + let water_x = bx + rng.random_range(8..ROOM_W - 6); + let water_len = rng.random_range(3u32..6); + for dx in 0..water_len { + if !is_doorway_zone(water_x + dx, by + 2, bx, by, doors) { + // Remove floor to make water pool + course.set_tile(water_x + dx, by + 1, Tile::Water); + course.set_tile(water_x + dx, by + 2, Tile::Water); + course.set_tile(water_x + dx, by + 3, Tile::Water); + } + } + + // Floor spikes + let spike_x = bx + rng.random_range(4..ROOM_W / 3); + for dx in 0..3 { + if !is_doorway_zone(spike_x + dx, by + 2, bx, by, doors) { + course.set_tile(spike_x + dx, by + 2, Tile::Spikes); + } + } + + // 2 Skeletons + for &sx_off in &[ROOM_W / 4, 3 * ROOM_W / 4] { + let sx = (bx + sx_off) as f32 * TILE_SIZE; + let sy = (by + 3) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: sx, + y: sy, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + } + + // Ghost that drifts through walls + course.enemy_spawns.push(EnemySpawn { + x: (bx + ROOM_W / 2) as f32 * TILE_SIZE, + y: (by + 8) as f32 * TILE_SIZE, + enemy_type: EnemyType::Ghost, + patrol_min_x: (bx + 4) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 5) as f32 * TILE_SIZE, + }); + + // Decorations + course.set_tile(bx + 2, by + 4, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 4, Tile::DecoTorch); + course.set_tile(bx + 3, by + 13, Tile::DecoCobweb); + course.set_tile(bx + ROOM_W - 4, by + 13, Tile::DecoCobweb); + course.set_tile(bx + 8, by + 13, Tile::DecoChain); +} + +/// Tower: alternating platforms, full-height climb. 3 Bats. +fn gen_tower(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Alternating platforms going up + let plat_heights = [by + 5, by + 8, by + 11, by + 14, by + 17, by + 20]; + for (i, &py) in plat_heights.iter().enumerate() { + if py >= by + ROOM_H - 2 { + continue; + } + let offset = if i % 2 == 0 { 3u32 } else { ROOM_W / 2 }; + let len = rng.random_range(6u32..10); + for dx in 0..len { + let x = bx + offset + dx; + if x < bx + ROOM_W - 2 && !is_doorway_zone(x, py, bx, by, doors) { + course.set_tile(x, py, Tile::Platform); + } + } + } + + // Central ladder sections + let ladder_x = bx + ROOM_W / 2; + for &start_y in &[by + 3, by + 9, by + 15] { + for dy in 0..4 { + if start_y + dy < by + ROOM_H - 2 + && !is_doorway_zone(ladder_x, start_y + dy, bx, by, doors) + { + course.set_tile(ladder_x, start_y + dy, Tile::Ladder); + } + } + } + + // 3 Bats + for &bat_y in &[by + 7, by + 13, by + 19] { + if bat_y >= by + ROOM_H - 2 { + continue; + } + let bx_pos = (bx + rng.random_range(4..ROOM_W - 4)) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: bx_pos, + y: bat_y as f32 * TILE_SIZE, + enemy_type: EnemyType::Bat, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + } + + // Stained glass + course.set_tile(bx + 4, by + 16, Tile::DecoStainedGlass); + + // Power-up near top + let pu_y = (by + 18).min(by + ROOM_H - 3); + course.set_tile(bx + ROOM_W / 2 + 3, pu_y, Tile::PowerUpSpawn); +} + +/// Dungeon: traps, narrow passages, breakable walls. 1 Knight + 1 Skeleton. Spikes + water. +fn gen_dungeon(course: &mut Course, rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Narrow passages via internal walls + for &wall_x_off in &[ROOM_W / 3, 2 * ROOM_W / 3] { + let wx = bx + wall_x_off; + let gap1 = by + rng.random_range(4u32..8); + let gap2 = by + rng.random_range(12u32..16); + for y in (by + 2)..(by + ROOM_H - 2) { + if (y >= gap1 && y < gap1 + 3) || (y >= gap2 && y < gap2 + 3) { + continue; + } + if !is_doorway_zone(wx, y, bx, by, doors) { + course.set_tile(wx, y, Tile::StoneBrick); + } + } + } + + // Breakable walls + let bw_x = bx + ROOM_W / 3; + let bw_y = by + 6; + if !is_doorway_zone(bw_x, bw_y, bx, by, doors) { + course.set_tile(bw_x, bw_y, Tile::BreakableWall); + } + + // Floor spikes + for dx in 0..4 { + let x = bx + 5 + dx; + if !is_doorway_zone(x, by + 2, bx, by, doors) { + course.set_tile(x, by + 2, Tile::Spikes); + } + } + + // Water + for dx in 0..3 { + let x = bx + ROOM_W / 2 + dx; + if !is_doorway_zone(x, by + 2, bx, by, doors) { + course.set_tile(x, by + 1, Tile::Water); + course.set_tile(x, by + 2, Tile::Water); + } + } + + // 1 Knight + 1 Skeleton + let kx = (bx + ROOM_W / 4) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: kx, + y: (by + 3) as f32 * TILE_SIZE, + enemy_type: EnemyType::Knight, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W / 3 - 1) as f32 * TILE_SIZE, + }); + let sx = (bx + 3 * ROOM_W / 4) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: sx, + y: (by + 3) as f32 * TILE_SIZE, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + 2 * ROOM_W / 3 + 1) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + + // Ghost that phases through dungeon walls + course.enemy_spawns.push(EnemySpawn { + x: (bx + ROOM_W / 2) as f32 * TILE_SIZE, + y: (by + 10) as f32 * TILE_SIZE, + enemy_type: EnemyType::Ghost, + patrol_min_x: (bx + 3) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 4) as f32 * TILE_SIZE, + }); + + // Decorations + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + course.set_tile(bx + 3, by + ROOM_H - 3, Tile::DecoCobweb); + + // Power-up + course.set_tile(bx + ROOM_W / 2, by + 8, Tile::PowerUpSpawn); +} + +/// ThroneRoom: grand platforms, dramatic decoration. 1 Knight + 1 Medusa + 2 Skeletons. +fn gen_throne_room(course: &mut Course, _rng: &mut StdRng, bx: u32, by: u32, doors: &[Direction]) { + // Grand central platform (throne dais) + for dx in 0..12 { + let x = bx + ROOM_W / 2 - 6 + dx; + if !is_doorway_zone(x, by + 4, bx, by, doors) { + course.set_tile(x, by + 4, Tile::StoneBrick); + } + } + + // Side platforms at various heights + for dx in 0..6 { + course.set_tile(bx + 3 + dx, by + 8, Tile::Platform); + course.set_tile(bx + ROOM_W - 9 + dx, by + 8, Tile::Platform); + } + for dx in 0..8 { + let x = bx + ROOM_W / 2 - 4 + dx; + if !is_doorway_zone(x, by + 12, bx, by, doors) { + course.set_tile(x, by + 12, Tile::Platform); + } + } + for dx in 0..5 { + course.set_tile(bx + 4 + dx, by + 16, Tile::Platform); + course.set_tile(bx + ROOM_W - 9 + dx, by + 16, Tile::Platform); + } + + // 1 Knight on the dais + let knight_x = (bx + ROOM_W / 2) as f32 * TILE_SIZE; + course.enemy_spawns.push(EnemySpawn { + x: knight_x, + y: (by + 5) as f32 * TILE_SIZE, + enemy_type: EnemyType::Knight, + patrol_min_x: (bx + ROOM_W / 2 - 6) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W / 2 + 6) as f32 * TILE_SIZE, + }); + + // 1 Medusa above + course.enemy_spawns.push(EnemySpawn { + x: knight_x, + y: (by + 17) as f32 * TILE_SIZE, + enemy_type: EnemyType::Medusa, + patrol_min_x: (bx + 3) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 4) as f32 * TILE_SIZE, + }); + + // 2 Skeletons on sides + course.enemy_spawns.push(EnemySpawn { + x: (bx + 5) as f32 * TILE_SIZE, + y: (by + 3) as f32 * TILE_SIZE, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + 2) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W / 2 - 6) as f32 * TILE_SIZE, + }); + course.enemy_spawns.push(EnemySpawn { + x: (bx + ROOM_W - 6) as f32 * TILE_SIZE, + y: (by + 3) as f32 * TILE_SIZE, + enemy_type: EnemyType::Skeleton, + patrol_min_x: (bx + ROOM_W / 2 + 6) as f32 * TILE_SIZE, + patrol_max_x: (bx + ROOM_W - 3) as f32 * TILE_SIZE, + }); + + // Grand decorations + course.set_tile(bx + 2, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W - 3, by + 3, Tile::DecoTorch); + course.set_tile(bx + ROOM_W / 2 - 1, by + 6, Tile::DecoTorch); + course.set_tile(bx + ROOM_W / 2 + 1, by + 6, Tile::DecoTorch); + course.set_tile(bx + ROOM_W / 2, by + 20, Tile::DecoStainedGlass); + course.set_tile(bx + 5, by + 20, Tile::DecoStainedGlass); + course.set_tile(bx + ROOM_W - 6, by + 20, Tile::DecoStainedGlass); + course.set_tile(bx + 3, by + ROOM_H - 3, Tile::DecoChain); + course.set_tile(bx + ROOM_W - 4, by + ROOM_H - 3, Tile::DecoChain); + + // Two power-ups + course.set_tile(bx + 6, by + 9, Tile::PowerUpSpawn); + course.set_tile(bx + ROOM_W - 7, by + 9, Tile::PowerUpSpawn); +} + +/// Place checkpoints every 2 distance tiers in rooms along the path. +fn place_checkpoints(course: &mut Course, rooms: &[PlacedRoom]) { + let max_dist = rooms + .iter() + .map(|r| r.distance_from_start) + .max() + .unwrap_or(0); + + let mut checkpoint_id: u16 = 1; + // Place checkpoint every 2 distance levels (skip 0 = entrance, skip max = throne) + let mut tier = 2u16; + while tier < max_dist { + // Find a room at this distance tier + if let Some(room) = rooms.iter().find(|r| r.distance_from_start == tier) { + let bx = room.grid_pos.col as u32 * ROOM_W; + let by = room.grid_pos.row as u32 * ROOM_H; + let cx = bx + ROOM_W / 2; + let cy = by + 2; // On the floor + // Find first empty tile above floor + let mut placed_y = cy; + for y in cy..cy + 5 { + if course.get_tile(cx as i32, y as i32) == Tile::Empty { + placed_y = y; + break; + } + } + course.set_tile(cx, placed_y, Tile::Checkpoint); + let world_x = cx as f32 * TILE_SIZE + TILE_SIZE / 2.0; + let world_y = placed_y as f32 * TILE_SIZE + TILE_SIZE / 2.0; + course.checkpoint_positions.push(CheckpointDef { + x: world_x, + y: world_y, + id: checkpoint_id, + }); + checkpoint_id += 1; + } + tier += 2; + } +} + +/// Place finish tiles in the ThroneRoom. +fn place_finish(course: &mut Course, rooms: &[PlacedRoom]) { + let throne = rooms + .iter() + .find(|r| r.theme == RoomTheme::ThroneRoom) + .unwrap_or(rooms.last().unwrap()); + + let bx = throne.grid_pos.col as u32 * ROOM_W; + let by = throne.grid_pos.row as u32 * ROOM_H; + // Place finish on the throne dais + let fx = bx + ROOM_W / 2; + let fy = by + 5; // above the dais + // Find empty tile + let mut placed_y = fy; + for y in fy..fy + 5 { + if course.get_tile(fx as i32, y as i32) == Tile::Empty { + placed_y = y; + break; + } + } + course.set_tile(fx - 1, placed_y, Tile::Finish); + course.set_tile(fx, placed_y, Tile::Finish); + course.set_tile(fx + 1, placed_y, Tile::Finish); } #[cfg(test)] @@ -187,6 +1586,11 @@ mod tests { let c1 = generate_course(42); let c2 = generate_course(42); assert_eq!(c1.tiles, c2.tiles, "Same seed must produce same course"); + assert_eq!( + c1.enemy_spawns.len(), + c2.enemy_spawns.len(), + "Same seed must produce same enemy spawns" + ); } #[test] @@ -207,24 +1611,358 @@ mod tests { } #[test] - fn has_solid_ground() { + fn spawn_inside_bounds() { + let course = generate_course(42); + let max_x = course.width as f32 * TILE_SIZE; + let max_y = course.height as f32 * TILE_SIZE; + assert!(course.spawn_x > 0.0 && course.spawn_x < max_x); + assert!(course.spawn_y > 0.0 && course.spawn_y < max_y); + } + + #[test] + fn course_dimensions_correct() { + let course = generate_course(42); + assert_eq!(course.width, COURSE_WIDTH); + assert_eq!(course.height, COURSE_HEIGHT); + assert_eq!(course.tiles.len(), (COURSE_WIDTH * COURSE_HEIGHT) as usize); + } + + #[test] + fn has_enemy_spawns() { + let course = generate_course(42); + assert!( + !course.enemy_spawns.is_empty(), + "Course should have enemy spawns" + ); + } + + #[test] + fn has_checkpoint_positions() { + let course = generate_course(42); + assert!( + !course.checkpoint_positions.is_empty(), + "Course should have checkpoint positions" + ); + } + + #[test] + fn has_checkpoint_tiles() { + let course = generate_course(42); + let has_checkpoint = course.tiles.contains(&Tile::Checkpoint); + assert!( + has_checkpoint, + "Course must have at least one Checkpoint tile" + ); + } + + #[test] + fn has_powerup_spawns() { let course = generate_course(42); - // First row should be mostly solid - let solid_count = (0..course.width) - .filter(|&x| course.get_tile(x as i32, 0) == Tile::Solid) + let pu_count = course + .tiles + .iter() + .filter(|&&t| t == Tile::PowerUpSpawn) .count(); assert!( - solid_count > course.width as usize / 2, - "Ground should be mostly solid" + pu_count >= 5, + "Course should have at least 5 power-up spawn tiles, got {}", + pu_count, ); } #[test] - fn spawn_inside_bounds() { + fn has_ladder_tiles() { + let course = generate_course(42); + let ladder_count = course.tiles.iter().filter(|&&t| t == Tile::Ladder).count(); + assert!( + ladder_count > 0, + "Course should have at least one Ladder tile" + ); + } + + #[test] + fn has_breakable_walls() { + // Need to try a few seeds since not all rooms have breakable walls + let mut found = false; + for seed in 0..20 { + let course = generate_course(seed); + if course.tiles.contains(&Tile::BreakableWall) { + found = true; + break; + } + } + assert!( + found, + "At least one seed should produce a course with BreakableWall tiles" + ); + } + + #[test] + fn has_decorative_tiles() { + let course = generate_course(42); + let has_torch = course.tiles.contains(&Tile::DecoTorch); + assert!(has_torch, "Course should have decorative torch tiles"); + } + + #[test] + fn course_always_has_finish_for_many_seeds() { + for seed in 0..20 { + let course = generate_course(seed); + let has_finish = course.tiles.contains(&Tile::Finish); + assert!( + has_finish, + "Course with seed {seed} should have at least one Finish tile" + ); + } + } + + #[test] + fn enemy_spawns_within_course_bounds() { let course = generate_course(42); let max_x = course.width as f32 * TILE_SIZE; let max_y = course.height as f32 * TILE_SIZE; - assert!(course.spawn_x > 0.0 && course.spawn_x < max_x); - assert!(course.spawn_y > 0.0 && course.spawn_y < max_y); + for spawn in &course.enemy_spawns { + assert!( + spawn.x >= 0.0 && spawn.x <= max_x, + "Enemy spawn x={} out of bounds [0, {}]", + spawn.x, + max_x, + ); + assert!( + spawn.y >= 0.0 && spawn.y <= max_y, + "Enemy spawn y={} out of bounds [0, {}]", + spawn.y, + max_y, + ); + assert!( + spawn.patrol_min_x <= spawn.patrol_max_x, + "Patrol min ({}) should be <= max ({})", + spawn.patrol_min_x, + spawn.patrol_max_x, + ); + } + } + + // ================================================================ + // Labyrinth-specific tests + // ================================================================ + + #[test] + fn labyrinth_room_count_in_range() { + for seed in 0..10 { + let course = generate_course(seed); + // Count rooms by checking room_distances for non-zero or entrance + let room_count = course + .room_distances + .iter() + .enumerate() + .filter(|&(idx, _)| { + let col = idx / GRID_ROWS as usize; + let row = idx % GRID_ROWS as usize; + // Check if this cell has a carved room + let bx = col as u32 * ROOM_W + ROOM_W / 2; + let by = row as u32 * ROOM_H + ROOM_H / 2; + course.get_tile(bx as i32, by as i32) == Tile::Empty + }) + .count(); + assert!( + (12..=30).contains(&room_count), + "Seed {seed}: expected 12-30 rooms, got {room_count}" + ); + } + } + + #[test] + fn labyrinth_all_rooms_reachable() { + use std::collections::{HashSet, VecDeque}; + + let course = generate_course(42); + // BFS from spawn through empty/passable tiles + let start_tx = (course.spawn_x / TILE_SIZE) as i32; + let start_ty = (course.spawn_y / TILE_SIZE) as i32; + + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + visited.insert((start_tx, start_ty)); + queue.push_back((start_tx, start_ty)); + + while let Some((x, y)) = queue.pop_front() { + for (dx, dy) in &[(0, 1), (0, -1), (1, 0), (-1, 0)] { + let nx = x + dx; + let ny = y + dy; + if visited.contains(&(nx, ny)) { + continue; + } + let tile = course.get_tile(nx, ny); + if !matches!(tile, Tile::StoneBrick | Tile::BreakableWall) { + visited.insert((nx, ny)); + queue.push_back((nx, ny)); + } + } + } + + // Check that every room center is reachable + for col in 0..GRID_COLS { + for row in 0..GRID_ROWS { + let bx = col * ROOM_W + ROOM_W / 2; + let by = row * ROOM_H + ROOM_H / 2; + if course.get_tile(bx as i32, by as i32) == Tile::Empty { + assert!( + visited.contains(&(bx as i32, by as i32)), + "Room at grid ({col}, {row}) center ({bx}, {by}) not reachable from spawn" + ); + } + } + } + } + + #[test] + fn labyrinth_goal_reachable() { + use std::collections::{HashSet, VecDeque}; + + let course = generate_course(42); + let start_tx = (course.spawn_x / TILE_SIZE) as i32; + let start_ty = (course.spawn_y / TILE_SIZE) as i32; + + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + visited.insert((start_tx, start_ty)); + queue.push_back((start_tx, start_ty)); + + let mut found_finish = false; + while let Some((x, y)) = queue.pop_front() { + if course.get_tile(x, y) == Tile::Finish { + found_finish = true; + break; + } + for (dx, dy) in &[(0, 1), (0, -1), (1, 0), (-1, 0)] { + let nx = x + dx; + let ny = y + dy; + if visited.contains(&(nx, ny)) { + continue; + } + let tile = course.get_tile(nx, ny); + if !matches!(tile, Tile::StoneBrick | Tile::BreakableWall) { + visited.insert((nx, ny)); + queue.push_back((nx, ny)); + } + } + } + + assert!(found_finish, "Finish tile should be reachable from spawn"); + } + + #[test] + fn labyrinth_rooms_have_floor() { + let course = generate_course(42); + for col in 0..GRID_COLS { + for row in 0..GRID_ROWS { + let bx = col * ROOM_W + ROOM_W / 2; + let by = row * ROOM_H + ROOM_H / 2; + // Only check rooms that exist (center is empty) + if course.get_tile(bx as i32, by as i32) != Tile::Empty { + continue; + } + // Check that there's at least one solid floor row + let floor_y = row * ROOM_H + 1; + let mut has_floor = false; + for x in (col * ROOM_W + 1)..(col * ROOM_W + ROOM_W - 1) { + if course.get_tile(x as i32, floor_y as i32) == Tile::StoneBrick { + has_floor = true; + break; + } + } + assert!(has_floor, "Room at grid ({col}, {row}) should have a floor"); + } + } + } + + #[test] + fn labyrinth_start_far_from_goal() { + let course = generate_course(42); + let max_dist = course.room_distances.iter().copied().max().unwrap_or(0); + assert!( + max_dist >= 6, + "Max room distance should be >= 6, got {max_dist}" + ); + } + + #[test] + fn labyrinth_deterministic() { + let c1 = generate_course(99); + let c2 = generate_course(99); + assert_eq!(c1.tiles, c2.tiles); + assert_eq!(c1.room_distances, c2.room_distances); + assert_eq!(c1.checkpoint_positions.len(), c2.checkpoint_positions.len()); + } + + #[test] + fn rle_roundtrip() { + let course = generate_course(42); + let encoded = rle_encode(&course.tiles); + let decoded = rle_decode(&encoded).unwrap(); + assert_eq!(course.tiles, decoded, "RLE roundtrip should preserve tiles"); + } + + #[test] + fn rle_compression_ratio() { + let course = generate_course(42); + let raw_size = course.tiles.len(); // 1 byte per tile + let rle = rle_encode(&course.tiles); + let rle_size = rle.len() * 3; // 1 byte tile + 2 bytes count + assert!( + rle_size < raw_size / 2, + "RLE should compress well: raw={raw_size}, rle={rle_size}" + ); + } + + #[test] + fn serde_roundtrip() { + let course = generate_course(42); + let bytes = rmp_serde::to_vec(&course).unwrap(); + let decoded: Course = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(course.tiles, decoded.tiles); + assert_eq!(course.width, decoded.width); + assert_eq!(course.height, decoded.height); + assert_eq!(course.room_distances, decoded.room_distances); + } + + #[test] + fn labyrinth_doorways_passable() { + let course = generate_course(42); + // For each room, check that door positions have empty tiles + for col in 0..GRID_COLS { + for row in 0..GRID_ROWS { + let bx = col * ROOM_W; + let by = row * ROOM_H; + let center_x = bx + ROOM_W / 2; + let center_y = by + ROOM_H / 2; + + // Only check rooms that exist + if course.get_tile(center_x as i32, center_y as i32) != Tile::Empty { + continue; + } + + // Check right doorway + if col + 1 < GRID_COLS { + let right_center = (col + 1) * ROOM_W + ROOM_W / 2; + if course.get_tile(right_center as i32, center_y as i32) == Tile::Empty { + // There should be passage at the wall + let wall_x = bx + ROOM_W - 1; + let mid_y = by + ROOM_H / 2; + let mut has_passage = false; + for dy in 0..6 { + let y = mid_y.saturating_sub(1) + dy; + if course.get_tile(wall_x as i32, y as i32) != Tile::StoneBrick { + has_passage = true; + break; + } + } + // This is a soft check — not all adjacent rooms are connected + let _ = has_passage; + } + } + } + } } } diff --git a/crates/games/breakpoint-platformer/src/enemies.rs b/crates/games/breakpoint-platformer/src/enemies.rs new file mode 100644 index 0000000..94fe1f9 --- /dev/null +++ b/crates/games/breakpoint-platformer/src/enemies.rs @@ -0,0 +1,531 @@ +use serde::{Deserialize, Serialize}; + +/// Enemy type variants in the Castlevania-style platformer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnemyType { + /// Ground patrol, 1 HP, speed 2.0. + Skeleton, + /// Flying sine-wave patrol, 1 HP, speed 3.0. + Bat, + /// Armored ground patrol, 2 HP, speed 1.5. + Knight, + /// Floating shooter, 1 HP, fires projectiles every 3s. + Medusa, + /// Phases through walls, drifts toward nearest player. 1 HP, speed 1.5. + Ghost, + /// Perches on walls, swoops to attack. 2 HP, speed 4.0 during swoop. + Gargoyle, +} + +/// A single enemy instance in the game world. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Enemy { + pub id: u16, + pub enemy_type: EnemyType, + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + pub hp: u8, + pub patrol_min_x: f32, + pub patrol_max_x: f32, + pub alive: bool, + pub respawn_timer: f32, + pub facing_right: bool, + pub anim_time: f32, + pub shoot_timer: f32, +} + +/// Enemy respawn delay in seconds. +pub const RESPAWN_DELAY: f32 = 5.0; + +impl Enemy { + /// Create a new enemy from a spawn definition. + pub fn from_spawn(id: u16, spawn: &EnemySpawn) -> Self { + let hp = match spawn.enemy_type { + EnemyType::Knight | EnemyType::Gargoyle => 2, + _ => 1, + }; + Self { + id, + enemy_type: spawn.enemy_type, + x: spawn.x, + y: spawn.y, + vx: 0.0, + vy: 0.0, + hp, + patrol_min_x: spawn.patrol_min_x, + patrol_max_x: spawn.patrol_max_x, + alive: true, + respawn_timer: 0.0, + facing_right: true, + anim_time: 0.0, + shoot_timer: 0.0, + } + } + + /// Reset this enemy to its spawned state (used for respawning). + pub fn respawn(&mut self) { + self.hp = match self.enemy_type { + EnemyType::Knight | EnemyType::Gargoyle => 2, + _ => 1, + }; + self.alive = true; + self.respawn_timer = 0.0; + self.x = (self.patrol_min_x + self.patrol_max_x) / 2.0; + self.vx = 0.0; + self.vy = 0.0; + self.facing_right = true; + self.anim_time = 0.0; + self.shoot_timer = 0.0; + } +} + +/// A projectile fired by an enemy (e.g. Medusa head). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemyProjectile { + pub x: f32, + pub y: f32, + pub vx: f32, + pub vy: f32, + pub lifetime: f32, +} + +/// Definition of where and what type of enemy should spawn. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnemySpawn { + pub x: f32, + pub y: f32, + pub enemy_type: EnemyType, + pub patrol_min_x: f32, + pub patrol_max_x: f32, +} + +/// Tick a skeleton enemy: ground patrol between bounds at speed 2.0. +fn tick_skeleton(e: &mut Enemy, dt: f32) { + let speed = 2.0; + if e.facing_right { + e.vx = speed; + } else { + e.vx = -speed; + } + e.x += e.vx * dt; + + // Reverse at patrol bounds + if e.x >= e.patrol_max_x { + e.x = e.patrol_max_x; + e.facing_right = false; + } else if e.x <= e.patrol_min_x { + e.x = e.patrol_min_x; + e.facing_right = true; + } +} + +/// Tick a bat enemy: sine-wave flight with horizontal patrol at speed 3.0. +fn tick_bat(e: &mut Enemy, dt: f32, time: f32) { + let speed = 3.0; + let amplitude = 2.0; + let freq = 1.5; + + if e.facing_right { + e.vx = speed; + } else { + e.vx = -speed; + } + e.x += e.vx * dt; + + // Sine-wave vertical movement based on global time + enemy anim_time offset + let base_y = (e.patrol_min_x + e.patrol_max_x) / 2.0; // use midpoint as a reference + // Store initial y in a stable way: use the average of patrol bounds as baseline + // The bat's y oscillates around its spawn y position + e.vy = amplitude * freq * (freq * (time + e.anim_time)).cos(); + e.y += e.vy * dt; + + // Reverse at patrol bounds + if e.x >= e.patrol_max_x { + e.x = e.patrol_max_x; + e.facing_right = false; + } else if e.x <= e.patrol_min_x { + e.x = e.patrol_min_x; + e.facing_right = true; + } + + let _ = base_y; +} + +/// Tick a knight enemy: armored ground patrol at speed 1.5. +fn tick_knight(e: &mut Enemy, dt: f32) { + let speed = 1.5; + if e.facing_right { + e.vx = speed; + } else { + e.vx = -speed; + } + e.x += e.vx * dt; + + // Reverse at patrol bounds + if e.x >= e.patrol_max_x { + e.x = e.patrol_max_x; + e.facing_right = false; + } else if e.x <= e.patrol_min_x { + e.x = e.patrol_min_x; + e.facing_right = true; + } +} + +/// Tick a medusa enemy: float in place with small sine bob, shoot projectiles. +fn tick_medusa(e: &mut Enemy, dt: f32, projectiles: &mut Vec) { + let bob_amplitude = 0.5; + let bob_freq = 1.0; + + // Small vertical bob + e.vy = bob_amplitude * bob_freq * (bob_freq * e.anim_time).cos(); + e.y += e.vy * dt; + + // Shooting logic: fire a projectile every 3.0 seconds + e.shoot_timer += dt; + if e.shoot_timer >= 3.0 { + e.shoot_timer -= 3.0; + // Shoot in the direction the medusa is facing + let proj_speed = 4.0; + let proj_vx = if e.facing_right { + proj_speed + } else { + -proj_speed + }; + projectiles.push(EnemyProjectile { + x: e.x, + y: e.y, + vx: proj_vx, + vy: 0.0, + lifetime: 4.0, + }); + // Alternate facing direction for variety + e.facing_right = !e.facing_right; + } +} + +/// Tick a ghost enemy: drifts toward patrol center with phase-through movement. +/// Moves in a slow sine-wave pattern, ignoring walls. +fn tick_ghost(e: &mut Enemy, dt: f32) { + let speed = 1.5; + let center_x = (e.patrol_min_x + e.patrol_max_x) / 2.0; + let drift_range = (e.patrol_max_x - e.patrol_min_x) / 2.0; + + // Slow sinusoidal drift around center + let target_x = center_x + drift_range * (e.anim_time * 0.4).sin(); + let dx = target_x - e.x; + e.vx = dx.clamp(-speed, speed); + e.x += e.vx * dt; + + // Gentle vertical bob + e.vy = 0.8 * (e.anim_time * 1.2).cos(); + e.y += e.vy * dt; + + e.facing_right = e.vx > 0.0; +} + +/// Tick a gargoyle enemy: perches at patrol midpoint, swoops outward periodically. +fn tick_gargoyle(e: &mut Enemy, dt: f32) { + let center_x = (e.patrol_min_x + e.patrol_max_x) / 2.0; + let swoop_speed = 4.0; + let swoop_cycle = 4.0; // seconds between swoops + let swoop_duration = 1.0; + + let cycle_t = e.anim_time % swoop_cycle; + if cycle_t < swoop_duration { + // Swooping phase: fly outward then return + let t = cycle_t / swoop_duration; + let direction = if e.facing_right { 1.0 } else { -1.0 }; + // Triangle wave: go out for first half, return for second half + let offset = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 }; + let range = (e.patrol_max_x - e.patrol_min_x) / 2.0; + e.x = center_x + direction * offset * range; + e.vx = direction * swoop_speed; + } else { + // Perching phase: stay at center, slowly settle + let drift = (e.x - center_x) * 0.95; + e.x = center_x + drift * (1.0 - 2.0 * dt).max(0.0); + e.vx = 0.0; + // Toggle direction for next swoop near the end + if cycle_t > swoop_cycle - 0.1 { + e.facing_right = !e.facing_right; + } + } +} + +/// Tick all enemies. Dead enemies count down their respawn timer. +pub fn tick_enemies( + enemies: &mut [Enemy], + dt: f32, + time: f32, + projectiles: &mut Vec, +) { + for e in enemies.iter_mut() { + e.anim_time += dt; + + if !e.alive { + e.respawn_timer -= dt; + if e.respawn_timer <= 0.0 { + e.respawn(); + } + continue; + } + + match e.enemy_type { + EnemyType::Skeleton => tick_skeleton(e, dt), + EnemyType::Bat => tick_bat(e, dt, time), + EnemyType::Knight => tick_knight(e, dt), + EnemyType::Medusa => tick_medusa(e, dt, projectiles), + EnemyType::Ghost => tick_ghost(e, dt), + EnemyType::Gargoyle => tick_gargoyle(e, dt), + } + } +} + +/// Tick all projectiles: move them and remove expired ones. +pub fn tick_projectiles(projectiles: &mut Vec, dt: f32) { + for proj in projectiles.iter_mut() { + proj.x += proj.vx * dt; + proj.y += proj.vy * dt; + proj.lifetime -= dt; + } + projectiles.retain(|p| p.lifetime > 0.0); +} + +/// Kill an enemy: set alive=false and start respawn timer. +pub fn kill_enemy(enemy: &mut Enemy) { + enemy.alive = false; + enemy.respawn_timer = RESPAWN_DELAY; + enemy.vx = 0.0; + enemy.vy = 0.0; +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_skeleton_spawn() -> EnemySpawn { + EnemySpawn { + x: 5.0, + y: 2.0, + enemy_type: EnemyType::Skeleton, + patrol_min_x: 3.0, + patrol_max_x: 7.0, + } + } + + fn make_bat_spawn() -> EnemySpawn { + EnemySpawn { + x: 10.0, + y: 5.0, + enemy_type: EnemyType::Bat, + patrol_min_x: 8.0, + patrol_max_x: 12.0, + } + } + + fn make_knight_spawn() -> EnemySpawn { + EnemySpawn { + x: 15.0, + y: 2.0, + enemy_type: EnemyType::Knight, + patrol_min_x: 13.0, + patrol_max_x: 17.0, + } + } + + fn make_medusa_spawn() -> EnemySpawn { + EnemySpawn { + x: 20.0, + y: 6.0, + enemy_type: EnemyType::Medusa, + patrol_min_x: 18.0, + patrol_max_x: 22.0, + } + } + + #[test] + fn skeleton_patrols_between_bounds() { + let spawn = make_skeleton_spawn(); + let mut enemy = Enemy::from_spawn(0, &spawn); + enemy.x = spawn.patrol_min_x; + enemy.facing_right = true; + + let mut projectiles = Vec::new(); + // Tick forward for a while + for _ in 0..100 { + tick_enemies( + std::slice::from_mut(&mut enemy), + 0.05, + 0.0, + &mut projectiles, + ); + } + + // Should have bounced and be within bounds + assert!( + enemy.x >= spawn.patrol_min_x && enemy.x <= spawn.patrol_max_x, + "Skeleton should stay within patrol bounds: x={}", + enemy.x, + ); + } + + #[test] + fn bat_has_vertical_movement() { + let spawn = make_bat_spawn(); + let mut enemy = Enemy::from_spawn(1, &spawn); + let initial_y = enemy.y; + + let mut projectiles = Vec::new(); + // Tick and check that y changes (sine wave) + let mut y_changed = false; + for i in 0..60 { + let time = i as f32 * 0.05; + tick_enemies( + std::slice::from_mut(&mut enemy), + 0.05, + time, + &mut projectiles, + ); + if (enemy.y - initial_y).abs() > 0.01 { + y_changed = true; + } + } + + assert!(y_changed, "Bat should have vertical sine-wave movement"); + } + + #[test] + fn knight_has_2_hp() { + let spawn = make_knight_spawn(); + let enemy = Enemy::from_spawn(2, &spawn); + assert_eq!(enemy.hp, 2, "Knight should have 2 HP"); + } + + #[test] + fn medusa_fires_projectiles() { + let spawn = make_medusa_spawn(); + let mut enemy = Enemy::from_spawn(3, &spawn); + let mut projectiles = Vec::new(); + + // Tick for 3+ seconds to trigger a shot + for i in 0..70 { + let time = i as f32 * 0.05; + tick_enemies( + std::slice::from_mut(&mut enemy), + 0.05, + time, + &mut projectiles, + ); + } + + assert!( + !projectiles.is_empty(), + "Medusa should have fired at least one projectile" + ); + } + + #[test] + fn projectile_tick_removes_expired() { + let mut projectiles = vec![ + EnemyProjectile { + x: 0.0, + y: 0.0, + vx: 1.0, + vy: 0.0, + lifetime: 0.5, + }, + EnemyProjectile { + x: 0.0, + y: 0.0, + vx: -1.0, + vy: 0.0, + lifetime: 3.0, + }, + ]; + + tick_projectiles(&mut projectiles, 1.0); + + assert_eq!(projectiles.len(), 1, "Expired projectile should be removed"); + assert!( + projectiles[0].lifetime > 0.0, + "Remaining projectile should still be alive" + ); + } + + #[test] + fn projectile_moves() { + let mut projectiles = vec![EnemyProjectile { + x: 5.0, + y: 3.0, + vx: 2.0, + vy: 1.0, + lifetime: 5.0, + }]; + + tick_projectiles(&mut projectiles, 0.5); + + assert!((projectiles[0].x - 6.0).abs() < 0.001); + assert!((projectiles[0].y - 3.5).abs() < 0.001); + } + + #[test] + fn dead_enemy_respawns_after_delay() { + let spawn = make_skeleton_spawn(); + let mut enemy = Enemy::from_spawn(0, &spawn); + kill_enemy(&mut enemy); + assert!(!enemy.alive, "Enemy should be dead after kill"); + assert!( + (enemy.respawn_timer - RESPAWN_DELAY).abs() < 0.01, + "Respawn timer should be set to {}", + RESPAWN_DELAY, + ); + + let mut projectiles = Vec::new(); + // Tick past the respawn delay + for _ in 0..120 { + tick_enemies( + std::slice::from_mut(&mut enemy), + 0.05, + 0.0, + &mut projectiles, + ); + } + + assert!(enemy.alive, "Enemy should respawn after delay"); + assert_eq!(enemy.hp, 1, "Skeleton should have 1 HP after respawning"); + } + + #[test] + fn knight_respawns_with_2_hp() { + let spawn = make_knight_spawn(); + let mut enemy = Enemy::from_spawn(2, &spawn); + kill_enemy(&mut enemy); + + let mut projectiles = Vec::new(); + for _ in 0..120 { + tick_enemies( + std::slice::from_mut(&mut enemy), + 0.05, + 0.0, + &mut projectiles, + ); + } + + assert!(enemy.alive, "Knight should respawn after delay"); + assert_eq!(enemy.hp, 2, "Knight should respawn with 2 HP"); + } + + #[test] + fn enemy_from_spawn_initializes_correctly() { + let spawn = make_skeleton_spawn(); + let enemy = Enemy::from_spawn(42, &spawn); + assert_eq!(enemy.id, 42); + assert_eq!(enemy.enemy_type, EnemyType::Skeleton); + assert!((enemy.x - spawn.x).abs() < 0.001); + assert!((enemy.y - spawn.y).abs() < 0.001); + assert!(enemy.alive); + assert_eq!(enemy.hp, 1); + } +} diff --git a/crates/games/breakpoint-platformer/src/lib.rs b/crates/games/breakpoint-platformer/src/lib.rs index 7b7da5e..d7656b2 100644 --- a/crates/games/breakpoint-platformer/src/lib.rs +++ b/crates/games/breakpoint-platformer/src/lib.rs @@ -1,11 +1,16 @@ +pub mod combat; pub mod course_gen; +pub mod enemies; pub mod physics; pub mod powerups; +pub mod rubber_band; pub mod scoring; use std::collections::{HashMap, HashSet}; use std::time::Duration; +use rand::SeedableRng; +use rand::rngs::StdRng; use serde::{Deserialize, Serialize}; use breakpoint_core::breakpoint_game_boilerplate; @@ -14,9 +19,14 @@ use breakpoint_core::game_trait::{ }; use breakpoint_core::player::Player; -use course_gen::{Course, generate_course}; -use physics::{PlatformerConfig, PlatformerInput, PlatformerPlayerState, SUBSTEPS, tick_player}; -use powerups::{ActivePowerUp, PowerUpKind, SpawnedPowerUp}; +use combat::{CombatEvent, check_enemy_damage, check_player_attack}; +use course_gen::{Course, Tile, generate_course}; +use enemies::{Enemy, EnemyProjectile}; +use physics::{ + PlatformerConfig, PlatformerInput, PlatformerPlayerState, SUBSTEPS, tick_player, try_break_wall, +}; +use powerups::{ActivePowerUp, PowerUpKind, SpawnedPowerUp, select_powerup_for_position}; +use rubber_band::{RubberBandFactor, compute_rubber_band}; /// Serializable game state for network broadcast. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,22 +35,15 @@ pub struct PlatformerState { pub powerups: Vec, pub active_powerups: HashMap>, pub finish_order: Vec, - pub elimination_order: Vec, pub round_timer: f32, - pub hazard_y: f32, pub round_complete: bool, - pub mode: GameMode, pub course: Course, + pub enemies: Vec, + pub projectiles: Vec, + pub rubber_band: HashMap, } -/// Game mode for the platformer. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum GameMode { - Race, - Survival, -} - -/// The Platform Racer game. +/// The Platform Racer game (Castlevania Rush). pub struct PlatformRacer { course: Course, state: PlatformerState, @@ -50,10 +53,12 @@ pub struct PlatformRacer { round_duration: f32, /// O(1) lookup companion for `state.finish_order`. finished_set: HashSet, - /// O(1) lookup companion for `state.elimination_order`. - eliminated_set: HashSet, /// Data-driven game configuration (physics, timing). game_config: PlatformerConfig, + /// Tick counter for periodic rubber-band recalculation. + tick_counter: u32, + /// RNG for power-up selection (seeded for determinism). + rng: StdRng, } impl PlatformRacer { @@ -71,12 +76,12 @@ impl PlatformRacer { powerups: Vec::new(), active_powerups: HashMap::new(), finish_order: Vec::new(), - elimination_order: Vec::new(), round_timer: 0.0, - hazard_y: -10.0, round_complete: false, - mode: GameMode::Race, course: initial_course.clone(), + enemies: Vec::new(), + projectiles: Vec::new(), + rubber_band: HashMap::new(), }, course: initial_course, player_ids: Vec::new(), @@ -84,8 +89,9 @@ impl PlatformRacer { paused: false, round_duration, finished_set: HashSet::new(), - eliminated_set: HashSet::new(), game_config, + tick_counter: 0, + rng: StdRng::seed_from_u64(42), } } @@ -101,6 +107,285 @@ impl PlatformRacer { pub fn config(&self) -> &PlatformerConfig { &self.game_config } + + // ---- Sub-update functions ---- + + /// Process player movement and physics. + fn process_player_movement(&mut self, dt: f32) { + let sub_dt = dt / SUBSTEPS as f32; + for i in 0..self.player_ids.len() { + let pid = self.player_ids[i]; + let input = self.pending_inputs.remove(&pid).unwrap_or_default(); + + if let Some(player) = self.state.players.get_mut(&pid) { + // Apply speed boost from SpeedBoots power-up + let speed_mult = if self + .state + .active_powerups + .get(&pid) + .is_some_and(|pus| pus.iter().any(|p| p.kind == PowerUpKind::SpeedBoots)) + { + 1.5 + } else { + 1.0 + }; + + let mut boosted_input = input.clone(); + boosted_input.move_dir *= speed_mult; + + for _ in 0..SUBSTEPS { + tick_player(player, &boosted_input, &self.course, sub_dt); + } + } + } + } + + /// Process player whip attacks against enemies, plus breakable wall destruction. + fn process_combat(&mut self) -> Vec { + let mut events = Vec::new(); + + for i in 0..self.player_ids.len() { + let pid = self.player_ids[i]; + if let Some(player) = self.state.players.get(&pid) { + if player.attack_timer <= 0.0 || player.death_respawn_timer > 0.0 { + continue; + } + + let has_whip_extend = self + .state + .active_powerups + .get(&pid) + .is_some_and(|pus| pus.iter().any(|p| p.kind == PowerUpKind::WhipExtend)); + + // Check whip vs enemies + // We need to clone the player to avoid borrow issues + let player_snapshot = player.clone(); + let attack_events = + check_player_attack(&player_snapshot, &mut self.state.enemies, has_whip_extend); + events.extend(attack_events); + + // Check whip vs breakable walls + let whip_dir = if player_snapshot.facing_right { 1 } else { -1 }; + let tx = (player_snapshot.x / physics::TILE_SIZE).floor() as i32 + whip_dir; + let ty = (player_snapshot.y / physics::TILE_SIZE).floor() as i32; + // Check the tile the whip is pointing at (and one above/below) + try_break_wall(&mut self.course, tx, ty); + try_break_wall(&mut self.course, tx, ty + 1); + try_break_wall(&mut self.course, tx, ty - 1); + + // Sync course changes to state + self.state.course = self.course.clone(); + } + } + + events + } + + /// Tick enemy AI and projectiles. + fn process_enemies(&mut self, dt: f32) { + let time = self.state.round_timer; + enemies::tick_enemies( + &mut self.state.enemies, + dt, + time, + &mut self.state.projectiles, + ); + enemies::tick_projectiles(&mut self.state.projectiles, dt); + } + + /// Check enemy/projectile damage against players. + fn process_damage(&mut self) -> Vec { + let mut events = Vec::new(); + + for i in 0..self.player_ids.len() { + let pid = self.player_ids[i]; + if let Some(player) = self.state.players.get_mut(&pid) { + let has_invincibility = + self.state.active_powerups.get(&pid).is_some_and(|pus| { + pus.iter().any(|p| p.kind == PowerUpKind::Invincibility) + }); + + let damage_events = check_enemy_damage( + player, + pid, + &self.state.enemies, + &self.state.projectiles, + has_invincibility, + ); + events.extend(damage_events); + } + } + + events + } + + /// Process power-up collection and expiration. + fn process_powerups(&mut self) { + // Collect which powerups were picked up by which players + let mut collected: Vec<(PlayerId, PowerUpKind)> = Vec::new(); + + for pu in &mut self.state.powerups { + if pu.collected { + continue; + } + for &pid in &self.player_ids { + if let Some(player) = self.state.players.get(&pid) { + if player.death_respawn_timer > 0.0 { + continue; + } + let dx = player.x - pu.x; + let dy = player.y - pu.y; + if dx * dx + dy * dy < 1.0 { + pu.collected = true; + collected.push((pid, pu.kind)); + break; + } + } + } + } + + // Apply collected power-ups (now that the borrow on self.state.powerups is released) + for (pid, kind) in collected { + self.apply_powerup(pid, kind); + } + } + + /// Apply a collected power-up to a player. + fn apply_powerup(&mut self, pid: PlayerId, kind: PowerUpKind) { + match kind { + PowerUpKind::HolyWater => { + // AOE: kill enemies within 5.0 units of player + if let Some(player) = self.state.players.get(&pid) { + let px = player.x; + let py = player.y; + for enemy in &mut self.state.enemies { + if !enemy.alive { + continue; + } + let dx = enemy.x - px; + let dy = enemy.y - py; + if dx * dx + dy * dy < 25.0 { + enemies::kill_enemy(enemy); + } + } + } + }, + PowerUpKind::Crucifix => { + // Screen clear: kill all alive enemies within 20.0 units + if let Some(player) = self.state.players.get(&pid) { + let px = player.x; + let py = player.y; + for enemy in &mut self.state.enemies { + if !enemy.alive { + continue; + } + let dx = enemy.x - px; + let dy = enemy.y - py; + if dx * dx + dy * dy < 400.0 { + enemies::kill_enemy(enemy); + } + } + } + // Also clear nearby projectiles + if let Some(player) = self.state.players.get(&pid) { + let px = player.x; + let py = player.y; + self.state.projectiles.retain(|proj| { + let dx = proj.x - px; + let dy = proj.y - py; + dx * dx + dy * dy >= 400.0 + }); + } + }, + PowerUpKind::DoubleJump => { + if let Some(p) = self.state.players.get_mut(&pid) { + p.has_double_jump = true; + } + let active_pu = ActivePowerUp::new(kind); + self.state + .active_powerups + .entry(pid) + .or_default() + .push(active_pu); + }, + PowerUpKind::ArmorUp => { + if let Some(p) = self.state.players.get_mut(&pid) { + p.max_hp += 1; + p.hp += 1; + } + let active_pu = ActivePowerUp::new(kind); + self.state + .active_powerups + .entry(pid) + .or_default() + .push(active_pu); + }, + PowerUpKind::SpeedBoots | PowerUpKind::Invincibility | PowerUpKind::WhipExtend => { + let active_pu = ActivePowerUp::new(kind); + self.state + .active_powerups + .entry(pid) + .or_default() + .push(active_pu); + }, + } + } + + /// Tick active power-ups (decrement timers, remove expired). + fn tick_active_powerups(&mut self, dt: f32) { + for pus in self.state.active_powerups.values_mut() { + for pu in pus.iter_mut() { + pu.tick(dt); + } + pus.retain(|p| !p.is_expired()); + } + } + + /// Recalculate rubber-banding factors (every 30 ticks). + fn update_rubber_banding(&mut self) { + self.tick_counter += 1; + if self.tick_counter.is_multiple_of(30) { + self.state.rubber_band = compute_rubber_band(&self.state.players); + } + } + + /// Check for race finish and round completion. + fn check_finish(&mut self) -> Vec { + let mut events = Vec::new(); + + for i in 0..self.player_ids.len() { + let pid = self.player_ids[i]; + if let Some(player) = self.state.players.get_mut(&pid) + && player.finished + && !self.finished_set.contains(&pid) + { + player.finish_time = Some(scoring::finish_time_with_penalty( + self.state.round_timer, + player.deaths, + )); + self.state.finish_order.push(pid); + self.finished_set.insert(pid); + events.push(GameEvent::ScoreUpdate { + player_id: pid, + score: scoring::race_score( + Some(self.state.finish_order.len() - 1), + player.deaths, + ), + }); + } + } + + // Round completion: all finished or timer expired + let timer_expired = self.state.round_timer >= self.round_duration; + let all_finished = self.state.finish_order.len() == self.player_ids.len(); + + if all_finished || timer_expired { + self.state.round_complete = true; + events.push(GameEvent::RoundComplete); + } + + events + } } impl Default for PlatformRacer { @@ -112,33 +397,19 @@ impl Default for PlatformRacer { impl BreakpointGame for PlatformRacer { fn metadata(&self) -> GameMetadata { GameMetadata { - name: "Platform Racer".to_string(), - description: "Race to the finish or survive the rising hazard!".to_string(), + name: "Castlevania Rush".to_string(), + description: "Race through a Castlevania-style castle, fighting monsters!".to_string(), min_players: 2, max_players: 6, - estimated_round_duration: Duration::from_secs(120), + estimated_round_duration: Duration::from_secs(180), } } fn tick_rate(&self) -> f32 { - 15.0 + 20.0 } fn init(&mut self, players: &[Player], config: &GameConfig) { - // Parse mode from config - let mode = config - .custom - .get("mode") - .and_then(|v| v.as_str()) - .map(|s| { - if s == "survival" { - GameMode::Survival - } else { - GameMode::Race - } - }) - .unwrap_or(GameMode::Race); - // Parse seed from config, or use default let seed = config .custom @@ -147,25 +418,35 @@ impl BreakpointGame for PlatformRacer { .unwrap_or(42); self.course = generate_course(seed); + self.rng = StdRng::seed_from_u64(seed.wrapping_add(12345)); + + // Initialize enemies from course spawns + let enemies: Vec = self + .course + .enemy_spawns + .iter() + .enumerate() + .map(|(i, spawn)| Enemy::from_spawn(i as u16, spawn)) + .collect(); self.state = PlatformerState { players: HashMap::new(), powerups: Vec::new(), active_powerups: HashMap::new(), finish_order: Vec::new(), - elimination_order: Vec::new(), round_timer: 0.0, - hazard_y: -10.0, round_complete: false, - mode, course: self.course.clone(), + enemies, + projectiles: Vec::new(), + rubber_band: HashMap::new(), }; self.player_ids.clear(); self.pending_inputs.clear(); self.paused = false; self.finished_set.clear(); - self.eliminated_set.clear(); self.round_duration = config.round_duration.as_secs_f32(); + self.tick_counter = 0; // Initialize player states for (i, player) in players.iter().enumerate() { @@ -184,13 +465,9 @@ impl BreakpointGame for PlatformRacer { // Spawn power-ups at PowerUpSpawn tiles for y in 0..self.course.height { for x in 0..self.course.width { - if self.course.get_tile(x as i32, y as i32) == course_gen::Tile::PowerUpSpawn { - let kind = match (x + y) % 4 { - 0 => PowerUpKind::SpeedBoost, - 1 => PowerUpKind::DoubleJump, - 2 => PowerUpKind::Shield, - _ => PowerUpKind::Magnet, - }; + if self.course.get_tile(x as i32, y as i32) == Tile::PowerUpSpawn { + // Use rubber-band quality for initial selection (middle tier) + let kind = select_powerup_for_position(0.5, &mut self.rng); self.state.powerups.push(SpawnedPowerUp { x: x as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0, y: y as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0, @@ -210,181 +487,47 @@ impl BreakpointGame for PlatformRacer { self.state.round_timer += dt; let mut events = Vec::new(); - // Survival mode: raise hazard - if self.state.mode == GameMode::Survival { - self.state.hazard_y += dt * 0.5; // rises 0.5 units/sec - } - - // Process each player (iterate by index to avoid borrowing self.player_ids) - let sub_dt = dt / SUBSTEPS as f32; - for i in 0..self.player_ids.len() { - let pid = self.player_ids[i]; - let input = self.pending_inputs.remove(&pid).unwrap_or_default(); - - if let Some(player) = self.state.players.get_mut(&pid) { - // Apply speed boost - let speed_mult = if self - .state - .active_powerups - .get(&pid) - .is_some_and(|pus| pus.iter().any(|p| p.kind == PowerUpKind::SpeedBoost)) - { - 1.5 - } else { - 1.0 - }; - - let mut boosted_input = input.clone(); - boosted_input.move_dir *= speed_mult; - - for _ in 0..SUBSTEPS { - tick_player(player, &boosted_input, &self.course, sub_dt); - } - - // Survival: eliminate if below hazard - if self.state.mode == GameMode::Survival - && player.y < self.state.hazard_y - && !player.eliminated - { - let has_shield = self - .state - .active_powerups - .get(&pid) - .is_some_and(|pus| pus.iter().any(|p| p.kind == PowerUpKind::Shield)); - - if has_shield { - // Consume shield - if let Some(pus) = self.state.active_powerups.get_mut(&pid) { - pus.retain(|p| p.kind != PowerUpKind::Shield); - } - player.respawn_at_checkpoint(); - } else { - player.eliminated = true; - self.state.elimination_order.push(pid); - self.eliminated_set.insert(pid); - } - } - - // Race: track finish - if self.state.mode == GameMode::Race - && player.finished - && !self.finished_set.contains(&pid) - { - player.finish_time = Some(self.state.round_timer); - self.state.finish_order.push(pid); - self.finished_set.insert(pid); - events.push(GameEvent::ScoreUpdate { - player_id: pid, - score: scoring::race_score(Some(self.state.finish_order.len() - 1)), - }); - } - } - } - - // Power-up collection - for pu in &mut self.state.powerups { - if pu.collected { - continue; - } - for &pid in &self.player_ids { - if let Some(player) = self.state.players.get(&pid) { - let dx = player.x - pu.x; - let dy = player.y - pu.y; - if dx * dx + dy * dy < 1.0 { - pu.collected = true; - let active_pu = ActivePowerUp::new(pu.kind); - if pu.kind == PowerUpKind::DoubleJump - && let Some(p) = self.state.players.get_mut(&pid) - { - p.has_double_jump = true; - } - self.state - .active_powerups - .entry(pid) - .or_default() - .push(active_pu); - break; - } - } - } - } - - // Magnet auto-collection: players with active Magnet collect nearby powerups - const MAGNET_RADIUS_SQ: f32 = 3.0 * 3.0; - for &pid in &self.player_ids { - let has_magnet = self - .state - .active_powerups - .get(&pid) - .is_some_and(|pus| pus.iter().any(|p| p.kind == PowerUpKind::Magnet)); - if !has_magnet { - continue; - } - let Some(player) = self.state.players.get(&pid) else { - continue; - }; - let px = player.x; - let py = player.y; - for pu in &mut self.state.powerups { - if pu.collected { - continue; - } - let dx = px - pu.x; - let dy = py - pu.y; - if dx * dx + dy * dy < MAGNET_RADIUS_SQ { - pu.collected = true; - let active_pu = ActivePowerUp::new(pu.kind); - if pu.kind == PowerUpKind::DoubleJump - && let Some(p) = self.state.players.get_mut(&pid) - { - p.has_double_jump = true; - } - self.state - .active_powerups - .entry(pid) - .or_default() - .push(active_pu); - } + // 1. Player movement and physics + self.process_player_movement(dt); + + // 2. Player attacks vs enemies + let combat_events = self.process_combat(); + // Convert combat events to game events if needed + for ce in &combat_events { + if let CombatEvent::PlayerDied { player_id } = ce { + events.push(GameEvent::ScoreUpdate { + player_id: *player_id, + score: -1, + }); } } - // Tick active power-ups - for pus in self.state.active_powerups.values_mut() { - for pu in pus.iter_mut() { - pu.tick(dt); + // 3. Enemy AI ticks + self.process_enemies(dt); + + // 4. Enemy/projectile vs player damage + let damage_events = self.process_damage(); + for ce in &damage_events { + if let CombatEvent::PlayerDied { player_id } = ce { + events.push(GameEvent::ScoreUpdate { + player_id: *player_id, + score: -1, + }); } - pus.retain(|p| !p.is_expired()); } - // Check round completion - let active_count = self - .player_ids - .iter() - .filter(|pid| { - self.state - .players - .get(pid) - .is_some_and(|p| !p.finished && !p.eliminated) - }) - .count(); + // 5. Power-up collection + self.process_powerups(); - let timer_expired = self.state.round_timer >= self.round_duration; + // 6. Tick active power-ups + self.tick_active_powerups(dt); - let complete = match self.state.mode { - GameMode::Race => { - // All finished or timer expired - self.state.finish_order.len() == self.player_ids.len() || timer_expired - }, - GameMode::Survival => { - // One or fewer players remain, or timer expired - active_count <= 1 || timer_expired - }, - }; + // 7. Rubber banding + self.update_rubber_banding(); - if complete { - self.state.round_complete = true; - events.push(GameEvent::RoundComplete); - } + // 8. Check finish / round completion + let finish_events = self.check_finish(); + events.extend(finish_events); events } @@ -397,10 +540,7 @@ impl BreakpointGame for PlatformRacer { tracing::debug!(player_id, error = %e, "Dropped malformed platformer input"); }, Ok(pi) => { - // Accumulate transient flags (jump, use_powerup) across frames. - // Without this, a jump:true in frame N gets overwritten by jump:false - // in frame N+1 before the game tick processes it. Continuous values - // (move_dir) are always overwritten with the latest. + // Accumulate transient flags (jump, attack, use_powerup) across frames. if let Some(existing) = self.pending_inputs.get_mut(&player_id) { existing.move_dir = pi.move_dir; if pi.jump { @@ -409,6 +549,9 @@ impl BreakpointGame for PlatformRacer { if pi.use_powerup { existing.use_powerup = true; } + if pi.attack { + existing.attack = true; + } } else { self.pending_inputs.insert(player_id, pi); } @@ -435,34 +578,17 @@ impl BreakpointGame for PlatformRacer { } fn round_results(&self) -> Vec { - match self.state.mode { - GameMode::Race => self - .player_ids - .iter() - .map(|&pid| { - let pos = self.state.finish_order.iter().position(|&id| id == pid); - PlayerScore { - player_id: pid, - score: scoring::race_score(pos), - } - }) - .collect(), - GameMode::Survival => self - .player_ids - .iter() - .map(|&pid| { - let elim_order = self - .state - .elimination_order - .iter() - .position(|&id| id == pid); - PlayerScore { - player_id: pid, - score: scoring::survival_score(elim_order, self.player_ids.len()), - } - }) - .collect(), - } + self.player_ids + .iter() + .map(|&pid| { + let pos = self.state.finish_order.iter().position(|&id| id == pid); + let deaths = self.state.players.get(&pid).map(|p| p.deaths).unwrap_or(0); + PlayerScore { + player_id: pid, + score: scoring::race_score(pos, deaths), + } + }) + .collect() } } @@ -471,11 +597,18 @@ mod tests { use super::*; use breakpoint_core::test_helpers::{default_config, make_players}; + /// Helper: build empty PlayerInputs. + fn empty_inputs() -> PlayerInputs { + PlayerInputs { + inputs: HashMap::new(), + } + } + #[test] fn init_creates_player_states() { let mut game = PlatformRacer::new(); let players = make_players(3); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); assert_eq!(game.state.players.len(), 3); } @@ -483,11 +616,11 @@ mod tests { fn state_roundtrip() { let mut game = PlatformRacer::new(); let players = make_players(2); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); let data = game.serialize_state(); let mut game2 = PlatformRacer::new(); - game2.init(&players, &default_config(120)); + game2.init(&players, &default_config(180)); game2.apply_state(&data); assert_eq!(game.state.players.len(), game2.state.players.len()); @@ -497,12 +630,13 @@ mod tests { fn input_roundtrip() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); let input = PlatformerInput { move_dir: 1.0, jump: true, use_powerup: false, + attack: false, }; let data = rmp_serde::to_vec(&input).unwrap(); game.apply_input(1, &data); @@ -510,137 +644,282 @@ mod tests { } #[test] - fn tick_rate_is_15() { + fn tick_rate_is_20() { let game = PlatformRacer::new(); - assert_eq!(game.tick_rate(), 15.0); + assert_eq!(game.tick_rate(), 20.0); } - /// Helper: build a GameConfig with survival mode enabled. - fn survival_config(round_duration_secs: u64) -> GameConfig { - let mut config = default_config(round_duration_secs); - config.custom.insert( - "mode".to_string(), - serde_json::Value::String("survival".to_string()), - ); - config + #[test] + fn metadata_is_castlevania_rush() { + let game = PlatformRacer::new(); + let meta = game.metadata(); + assert_eq!(meta.name, "Castlevania Rush"); + assert_eq!(meta.max_players, 6); + assert_eq!(meta.estimated_round_duration, Duration::from_secs(180)); } - /// Helper: build empty PlayerInputs. - fn empty_inputs() -> PlayerInputs { - PlayerInputs { - inputs: HashMap::new(), - } + #[test] + fn enemies_initialized_from_course() { + let mut game = PlatformRacer::new(); + let players = make_players(2); + game.init(&players, &default_config(180)); + + assert!( + !game.state.enemies.is_empty(), + "Enemies should be initialized from course spawns" + ); } #[test] - fn hazard_elimination_with_shield() { + fn powerups_spawned_from_course() { let mut game = PlatformRacer::new(); let players = make_players(2); - game.init(&players, &survival_config(120)); + game.init(&players, &default_config(180)); - let pid = 1u64; + assert!( + !game.state.powerups.is_empty(), + "Power-ups should be spawned from course" + ); + } - // Give player 1 a Shield power-up - game.state - .active_powerups - .entry(pid) - .or_default() - .push(ActivePowerUp::new(PowerUpKind::Shield)); - - // Raise hazard_y above the player's current position so the hazard check - // triggers without the -5.0 floor respawn interfering. - let player_y = game.state.players[&pid].y; - game.state.hazard_y = player_y + 10.0; + #[test] + fn race_round_completion() { + let mut game = PlatformRacer::new(); + let players = make_players(3); + game.init(&players, &default_config(180)); - // Record checkpoint position before the tick - let checkpoint_x = game.state.players[&pid].last_checkpoint_x; - let checkpoint_y = game.state.players[&pid].last_checkpoint_y; + // Mark all players as finished + for &pid in &game.player_ids.clone() { + if let Some(player) = game.state.players.get_mut(&pid) { + player.finished = true; + } + } - // Tick the game — shield should save the player - game.update(1.0 / 15.0, &empty_inputs()); + let events = game.update(1.0 / 20.0, &empty_inputs()); - let player = &game.state.players[&pid]; - // Player should NOT be eliminated - assert!(!player.eliminated, "Shield should prevent elimination"); - // Player should be respawned near checkpoint (physics substeps may slightly adjust) - let expected_y = checkpoint_y + 1.0; assert!( - (player.x - checkpoint_x).abs() < 1.0, - "Player should respawn near checkpoint x" + game.state.round_complete, + "Round should be complete when all players finish" ); assert!( - (player.y - expected_y).abs() < 1.0, - "Player should respawn near checkpoint y + 1.0, got {} expected {}", - player.y, - expected_y + events.iter().any(|e| matches!(e, GameEvent::RoundComplete)), + "RoundComplete event should be emitted" ); - // Shield should have been consumed - let shields: Vec<_> = game.state.active_powerups[&pid] - .iter() - .filter(|p| p.kind == PowerUpKind::Shield) - .collect(); + } + + #[test] + fn timer_expiry_completes_round() { + let mut game = PlatformRacer::new(); + let players = make_players(2); + game.init(&players, &default_config(1)); + + let events = game.update(2.0, &empty_inputs()); + assert!( - shields.is_empty(), - "Shield should be consumed after saving player" + game.state.round_complete, + "Round should complete when timer exceeds duration" ); - // Player should NOT be in elimination order assert!( - !game.state.elimination_order.contains(&pid), - "Shielded player should not appear in elimination_order" + events.iter().any(|e| matches!(e, GameEvent::RoundComplete)), + "RoundComplete event should be emitted on timer expiry" ); } #[test] - fn hazard_elimination_without_shield() { + fn duplicate_finish_only_counted_once() { let mut game = PlatformRacer::new(); - let players = make_players(3); - game.init(&players, &survival_config(120)); + let players = make_players(2); + game.init(&players, &default_config(180)); + + game.state.players.get_mut(&1).unwrap().finished = true; + + game.update(1.0 / 20.0, &empty_inputs()); + game.update(1.0 / 20.0, &empty_inputs()); + + let count = game + .state + .finish_order + .iter() + .filter(|&&id| id == 1) + .count(); + assert_eq!( + count, 1, + "Player should appear in finish_order exactly once" + ); + } + + #[test] + fn speed_boots_multiplies_movement() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let initial_x = game.state.players[&1].x; + + // Give player SpeedBoots + game.state + .active_powerups + .entry(1) + .or_default() + .push(ActivePowerUp::new(PowerUpKind::SpeedBoots)); + + for _ in 0..20 { + let input = PlatformerInput { + move_dir: 1.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); + } + let boosted_dx = game.state.players[&1].x - initial_x; + + // Now test without boost + let mut game2 = PlatformRacer::new(); + let players2 = make_players(1); + game2.init(&players2, &default_config(180)); + let initial_x2 = game2.state.players[&1].x; - let pid = 2u64; + for _ in 0..20 { + let input = PlatformerInput { + move_dir: 1.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game2.apply_input(1, &data); + game2.update(1.0 / 20.0, &empty_inputs()); + } + let normal_dx = game2.state.players[&1].x - initial_x2; - // Raise hazard_y well above the player's position so the check triggers - // after physics runs. This avoids the -5.0 floor respawn interfering. - let player_y = game.state.players[&pid].y; - game.state.hazard_y = player_y + 10.0; + assert!( + boosted_dx > normal_dx * 1.2, + "Boosted movement ({boosted_dx}) should be notably more than normal ({normal_dx})" + ); + } - game.update(1.0 / 15.0, &empty_inputs()); + #[test] + fn holy_water_kills_nearby_enemies() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + let pid = 1u64; let player = &game.state.players[&pid]; + let px = player.x; + let py = player.y; + + // Place an enemy near the player + game.state.enemies.push(Enemy::from_spawn( + 100, + &enemies::EnemySpawn { + x: px + 2.0, + y: py, + enemy_type: enemies::EnemyType::Skeleton, + patrol_min_x: px, + patrol_max_x: px + 4.0, + }, + )); + // Place an enemy far from the player + game.state.enemies.push(Enemy::from_spawn( + 101, + &enemies::EnemySpawn { + x: px + 50.0, + y: py, + enemy_type: enemies::EnemyType::Skeleton, + patrol_min_x: px + 48.0, + patrol_max_x: px + 52.0, + }, + )); + + let near_idx = game.state.enemies.len() - 2; + let far_idx = game.state.enemies.len() - 1; + + // Apply HolyWater + game.apply_powerup(pid, PowerUpKind::HolyWater); + assert!( - player.eliminated, - "Player below hazard_y without shield should be eliminated" + !game.state.enemies[near_idx].alive, + "Nearby enemy should be killed by HolyWater" ); assert!( - game.state.elimination_order.contains(&pid), - "Eliminated player should appear in elimination_order" + game.state.enemies[far_idx].alive, + "Far enemy should NOT be killed by HolyWater" ); } #[test] - fn double_jump_physics() { + fn crucifix_clears_wide_area() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); let pid = 1u64; + let player = &game.state.players[&pid]; + let px = player.x; + let py = player.y; + + // Place enemy within 20 units + game.state.enemies.push(Enemy::from_spawn( + 200, + &enemies::EnemySpawn { + x: px + 15.0, + y: py, + enemy_type: enemies::EnemyType::Bat, + patrol_min_x: px + 13.0, + patrol_max_x: px + 17.0, + }, + )); + + let idx = game.state.enemies.len() - 1; + + // Apply Crucifix + game.apply_powerup(pid, PowerUpKind::Crucifix); - // Verify player starts without double jump assert!( - !game.state.players[&pid].has_double_jump, - "Player should not start with double jump" + !game.state.enemies[idx].alive, + "Enemy within 20 units should be killed by Crucifix" ); + } - // Directly grant DoubleJump power-up (simulating collection) - game.state - .active_powerups - .entry(pid) - .or_default() - .push(ActivePowerUp::new(PowerUpKind::DoubleJump)); - game.state.players.get_mut(&pid).unwrap().has_double_jump = true; + #[test] + fn armor_up_increases_max_hp() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let pid = 1u64; + assert_eq!(game.state.players[&pid].max_hp, 3); + + game.apply_powerup(pid, PowerUpKind::ArmorUp); + + assert_eq!( + game.state.players[&pid].max_hp, 4, + "ArmorUp should increase max HP" + ); + assert_eq!( + game.state.players[&pid].hp, 4, + "ArmorUp should also heal the new HP point" + ); + } + + #[test] + fn double_jump_powerup_grants_ability() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let pid = 1u64; + assert!(!game.state.players[&pid].has_double_jump); + + game.apply_powerup(pid, PowerUpKind::DoubleJump); assert!( game.state.players[&pid].has_double_jump, - "Player should have double jump after collecting DoubleJump power-up" + "DoubleJump powerup should grant double jump" ); } @@ -648,231 +927,429 @@ mod tests { fn powerup_expiration() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); let pid = 1u64; - // Give player a SpeedBoost (duration = 3.0s) and a Shield (infinite) + // SpeedBoots (5s) and DoubleJump (infinite) game.state .active_powerups .entry(pid) .or_default() - .push(ActivePowerUp::new(PowerUpKind::SpeedBoost)); + .push(ActivePowerUp::new(PowerUpKind::SpeedBoots)); game.state .active_powerups .entry(pid) .or_default() - .push(ActivePowerUp::new(PowerUpKind::Shield)); + .push(ActivePowerUp::new(PowerUpKind::DoubleJump)); - assert_eq!( - game.state.active_powerups[&pid].len(), - 2, - "Player should have 2 active power-ups" - ); + assert_eq!(game.state.active_powerups[&pid].len(), 2); - // Tick enough time for SpeedBoost to expire (3.0s), but not Shield (infinite) - // Each tick at 15 Hz = 1/15s, so 45 ticks = 3s. Use a few extra to be safe. - for _ in 0..50 { - game.update(1.0 / 15.0, &empty_inputs()); + // Tick enough for SpeedBoots to expire (5s), at 20Hz = 100 ticks + extra + for _ in 0..120 { + game.update(1.0 / 20.0, &empty_inputs()); } let pus = &game.state.active_powerups[&pid]; assert_eq!( pus.len(), 1, - "SpeedBoost should have expired, leaving only Shield" + "SpeedBoots should have expired, leaving only DoubleJump" ); assert_eq!( pus[0].kind, - PowerUpKind::Shield, - "Remaining power-up should be Shield" + PowerUpKind::DoubleJump, + "Remaining power-up should be DoubleJump" ); } #[test] fn course_generation_reproducibility() { let seed = 12345u64; - let course_a = course_gen::generate_course(seed); - let course_b = course_gen::generate_course(seed); + let course_a = generate_course(seed); + let course_b = generate_course(seed); - assert_eq!( - course_a.width, course_b.width, - "Width should match for same seed" - ); - assert_eq!( - course_a.height, course_b.height, - "Height should match for same seed" - ); - assert_eq!( - course_a.tiles, course_b.tiles, - "Tiles should match for same seed" - ); - assert_eq!( - course_a.spawn_x, course_b.spawn_x, - "Spawn X should match for same seed" - ); - assert_eq!( - course_a.spawn_y, course_b.spawn_y, - "Spawn Y should match for same seed" - ); + assert_eq!(course_a.width, course_b.width); + assert_eq!(course_a.height, course_b.height); + assert_eq!(course_a.tiles, course_b.tiles); + assert_eq!(course_a.spawn_x, course_b.spawn_x); + assert_eq!(course_a.spawn_y, course_b.spawn_y); - // Different seed should produce different tiles - let course_c = course_gen::generate_course(seed + 1); - assert_ne!( - course_a.tiles, course_c.tiles, - "Different seeds should produce different courses" - ); + let course_c = generate_course(seed + 1); + assert_ne!(course_a.tiles, course_c.tiles); } #[test] - fn race_round_completion() { + fn round_complete_when_all_finished() { let mut game = PlatformRacer::new(); let players = make_players(3); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); - // Mark all players as finished for &pid in &game.player_ids.clone() { - if let Some(player) = game.state.players.get_mut(&pid) { - player.finished = true; - } + game.state.players.get_mut(&pid).unwrap().finished = true; } - let events = game.update(1.0 / 15.0, &empty_inputs()); - - assert!( - game.state.round_complete, - "Round should be complete when all players finish in Race mode" - ); - assert!( - events.iter().any(|e| matches!(e, GameEvent::RoundComplete)), - "RoundComplete event should be emitted" - ); + let events = game.update(1.0 / 20.0, &empty_inputs()); + assert!(game.state.round_complete); + assert!(events.iter().any(|e| matches!(e, GameEvent::RoundComplete))); } #[test] - fn survival_round_completion() { + fn platformer_jump_input_not_lost_across_overwrites() { let mut game = PlatformRacer::new(); - let players = make_players(3); - game.init(&players, &survival_config(120)); + let players = make_players(1); + game.init(&players, &default_config(180)); - // Eliminate 2 of 3 players, leaving only 1 active - for &pid in &[1u64, 2u64] { - if let Some(player) = game.state.players.get_mut(&pid) { - player.eliminated = true; - } - game.state.elimination_order.push(pid); - game.eliminated_set.insert(pid); + for _ in 0..20 { + game.update(1.0 / 20.0, &empty_inputs()); } - let events = game.update(1.0 / 15.0, &empty_inputs()); + // Frame N: jump=true + let input_jump = PlatformerInput { + move_dir: 1.0, + jump: true, + use_powerup: false, + attack: false, + }; + let data_jump = rmp_serde::to_vec(&input_jump).unwrap(); + game.apply_input(1, &data_jump); + + // Frame N+1: jump=false (would overwrite in old code) + let input_no_jump = PlatformerInput { + move_dir: 1.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data_no_jump = rmp_serde::to_vec(&input_no_jump).unwrap(); + game.apply_input(1, &data_no_jump); assert!( - game.state.round_complete, - "Round should be complete when only 1 player remains in Survival mode" - ); - assert!( - events.iter().any(|e| matches!(e, GameEvent::RoundComplete)), - "RoundComplete event should be emitted" + game.pending_inputs.get(&1).is_some_and(|i| i.jump), + "Jump flag must be preserved across input overwrites" ); } #[test] - fn timer_expiry_completes_round() { + fn attack_input_not_lost_across_overwrites() { let mut game = PlatformRacer::new(); - let players = make_players(2); - // Set a very short round duration (1 second) - game.init(&players, &default_config(1)); + let players = make_players(1); + game.init(&players, &default_config(180)); - // Tick past the round duration - // round_duration is 1.0s, each tick adds dt to round_timer - // Use a large dt to push past it in one call - let events = game.update(2.0, &empty_inputs()); + let input_attack = PlatformerInput { + move_dir: 0.0, + jump: false, + use_powerup: false, + attack: true, + }; + let data = rmp_serde::to_vec(&input_attack).unwrap(); + game.apply_input(1, &data); + + let input_no_attack = PlatformerInput { + move_dir: 0.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input_no_attack).unwrap(); + game.apply_input(1, &data); assert!( - game.state.round_complete, - "Round should be complete when timer exceeds round duration" - ); - assert!( - events.iter().any(|e| matches!(e, GameEvent::RoundComplete)), - "RoundComplete event should be emitted on timer expiry" + game.pending_inputs.get(&1).is_some_and(|i| i.attack), + "Attack flag must be preserved across input overwrites" ); } // ================================================================ - // Game Trait Contract Tests + // NaN/Inf/Degenerate Input Fuzzing // ================================================================ #[test] - fn contract_init_creates_player_state() { + fn platformer_apply_input_nan_move_no_panic() { let mut game = PlatformRacer::new(); - breakpoint_core::test_helpers::contract_init_creates_player_state(&mut game, 3); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let input = PlatformerInput { + move_dir: f32::NAN, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); } #[test] - fn contract_apply_input_changes_state() { + fn platformer_apply_input_inf_move_no_panic() { let mut game = PlatformRacer::new(); - let players = make_players(2); - game.init(&players, &default_config(120)); + let players = make_players(1); + game.init(&players, &default_config(180)); let input = PlatformerInput { - move_dir: 1.0, + move_dir: f32::INFINITY, jump: false, use_powerup: false, + attack: false, }; let data = rmp_serde::to_vec(&input).unwrap(); - breakpoint_core::test_helpers::contract_apply_input_changes_state(&mut game, &data, 1); + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); } + // ================================================================ + // Serialization Fuzzing + // ================================================================ + #[test] - fn contract_update_advances_time() { + fn platformer_apply_input_garbage_no_panic() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - breakpoint_core::test_helpers::contract_update_advances_time(&mut game); + game.init(&players, &default_config(180)); + + let garbage: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0xAB, 0xCD]; + game.apply_input(1, &garbage); + + assert!( + !game.state.players[&1].finished, + "Garbage input should not finish the player" + ); } #[test] - fn contract_round_eventually_completes() { + fn platformer_apply_state_truncated_no_panic() { let mut game = PlatformRacer::new(); - let players = make_players(2); - game.init(&players, &default_config(5)); - breakpoint_core::test_helpers::contract_round_eventually_completes(&mut game, 10); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let state = game.serialize_state(); + let truncated = &state[..state.len() / 2]; + game.apply_state(truncated); + + assert_eq!(game.state.players.len(), 1); } + // ================================================================ + // State Machine Transition Tests + // ================================================================ + #[test] - fn contract_state_roundtrip_preserves() { + fn platformer_double_pause_single_resume_works() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - breakpoint_core::test_helpers::contract_state_roundtrip_preserves(&mut game); + game.init(&players, &default_config(180)); + + game.pause(); + game.pause(); + game.resume(); + + let timer_before = game.state.round_timer; + game.update(1.0 / 20.0, &empty_inputs()); + + assert!( + game.state.round_timer > timer_before, + "Timer should advance after resume" + ); } #[test] - fn contract_pause_stops_updates() { + fn platformer_update_after_round_complete_is_noop() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - breakpoint_core::test_helpers::contract_pause_stops_updates(&mut game); + game.init(&players, &default_config(180)); + + game.state.players.get_mut(&1).unwrap().finished = true; + game.state.finish_order.push(1); + game.finished_set.insert(1); + game.update(1.0 / 20.0, &empty_inputs()); + assert!(game.is_round_complete()); + + let timer = game.state.round_timer; + let events = game.update(1.0 / 20.0, &empty_inputs()); + assert!( + (game.state.round_timer - timer).abs() < 0.01, + "Timer should not advance after round complete" + ); + assert!(events.is_empty(), "No events after round complete"); } + // ================================================================ + // Platformer Edge Cases + // ================================================================ + #[test] - fn contract_player_left_cleanup() { + fn checkpoint_not_lost_on_backward_movement() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + game.state.players.get_mut(&1).unwrap().last_checkpoint_id = 3; + game.state.players.get_mut(&1).unwrap().last_checkpoint_x = 50.0; + game.state.players.get_mut(&1).unwrap().last_checkpoint_y = 2.0; + let checkpoint_id = 3u16; + + game.state.players.get_mut(&1).unwrap().x = 30.0; + game.state.players.get_mut(&1).unwrap().y = 2.0; + + let input = PlatformerInput { + move_dir: -1.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + for _ in 0..10 { + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); + } + + assert!( + game.state.players[&1].last_checkpoint_id >= checkpoint_id, + "Checkpoint ID should not regress: expected >= {checkpoint_id}, got {}", + game.state.players[&1].last_checkpoint_id + ); + } + + #[test] + fn simultaneous_finish_produces_valid_scores() { let mut game = PlatformRacer::new(); let players = make_players(2); - game.init(&players, &default_config(120)); - breakpoint_core::test_helpers::contract_player_left_cleanup(&mut game, 2, 2); + game.init(&players, &default_config(180)); + + game.state.players.get_mut(&1).unwrap().finished = true; + game.state.players.get_mut(&2).unwrap().finished = true; + game.state.finish_order.push(1); + game.state.finish_order.push(2); + game.finished_set.insert(1); + game.finished_set.insert(2); + + game.update(1.0 / 20.0, &empty_inputs()); + assert!(game.is_round_complete()); + + let results = game.round_results(); + assert_eq!(results.len(), 2); + for result in &results { + assert!( + result.score >= 0, + "Player {} should have non-negative score, got {}", + result.player_id, + result.score + ); + } } #[test] - fn contract_round_results_complete() { + fn checkpoint_advances_on_checkpoint_tile() { let mut game = PlatformRacer::new(); - let players = make_players(3); - game.init(&players, &default_config(120)); - breakpoint_core::test_helpers::contract_round_results_complete(&game, 3); + let players = make_players(2); + game.init(&players, &default_config(180)); + + let pid = 1u64; + + // Find the first Checkpoint tile + let mut checkpoint_tile: Option<(u32, u32)> = None; + for y in 0..game.course.height { + for x in 0..game.course.width { + if game.course.get_tile(x as i32, y as i32) == Tile::Checkpoint { + checkpoint_tile = Some((x, y)); + break; + } + } + if checkpoint_tile.is_some() { + break; + } + } + let (cx, cy) = checkpoint_tile.expect("Course should have at least one Checkpoint tile"); + + let initial_cp_id = game.state.players[&pid].last_checkpoint_id; + let world_x = cx as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0; + let world_y = cy as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0; + + let player = game.state.players.get_mut(&pid).unwrap(); + player.x = world_x; + player.y = world_y; + + game.update(1.0 / 20.0, &empty_inputs()); + + let player = &game.state.players[&pid]; + assert!( + player.last_checkpoint_id > initial_cp_id, + "Checkpoint ID should have advanced: initial={initial_cp_id}, current={}", + player.last_checkpoint_id + ); + } + + #[test] + fn course_always_has_finish_tile() { + for seed in 0..10 { + let course = generate_course(seed); + let has_finish = course.tiles.iter().any(|t| matches!(t, Tile::Finish)); + assert!( + has_finish, + "Course with seed {seed} should have at least one Finish tile" + ); + } + } + + #[test] + fn platformer_move_right_increases_x() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + let initial_x = game.state.players[&1].x; + + for _ in 0..30 { + let input = PlatformerInput { + move_dir: 1.0, + jump: false, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); + } + + assert!( + game.state.players[&1].x > initial_x, + "Player x should increase: initial={initial_x}, final={}", + game.state.players[&1].x + ); + } + + #[test] + fn platformer_jump_changes_velocity() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + + for _ in 0..20 { + game.update(1.0 / 20.0, &empty_inputs()); + } + + let input = PlatformerInput { + move_dir: 0.0, + jump: true, + use_powerup: false, + attack: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + game.update(1.0 / 20.0, &empty_inputs()); + + let player = &game.state.players[&1]; + assert!( + player.vy > 0.0 || !player.grounded, + "Player should have upward velocity or be airborne after jump: vy={}, grounded={}", + player.vy, + player.grounded + ); } // ================================================================ - // Input encoding/decoding roundtrip tests (Phase 2) + // Input encoding/decoding roundtrip tests // ================================================================ #[test] @@ -881,12 +1358,14 @@ mod tests { move_dir: -1.0, jump: true, use_powerup: true, + attack: true, }; let encoded = rmp_serde::to_vec(&input).unwrap(); let decoded: PlatformerInput = rmp_serde::from_slice(&encoded).unwrap(); assert!((decoded.move_dir - input.move_dir).abs() < 1e-5); assert_eq!(decoded.jump, input.jump); assert_eq!(decoded.use_powerup, input.use_powerup); + assert_eq!(decoded.attack, input.attack); } #[test] @@ -898,6 +1377,7 @@ mod tests { move_dir: 1.0, jump: true, use_powerup: false, + attack: true, }; let input_data = rmp_serde::to_vec(&input).unwrap(); let msg = ClientMessage::PlayerInput(PlayerInputMsg { @@ -913,6 +1393,7 @@ mod tests { let plat_input: PlatformerInput = rmp_serde::from_slice(&pi.input_data).unwrap(); assert!((plat_input.move_dir - 1.0).abs() < 1e-5); assert!(plat_input.jump); + assert!(plat_input.attack); }, other => panic!("Expected PlayerInput, got {:?}", other), } @@ -922,7 +1403,7 @@ mod tests { fn platformer_input_apply_changes_game_state() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); let before = game.serialize_state(); @@ -930,611 +1411,180 @@ mod tests { move_dir: 1.0, jump: false, use_powerup: false, + attack: false, }; let data = rmp_serde::to_vec(&input).unwrap(); game.apply_input(1, &data); - game.update(1.0 / 15.0, &empty_inputs()); + game.update(1.0 / 20.0, &empty_inputs()); breakpoint_core::test_helpers::assert_game_state_changed(&game, &before); } // ================================================================ - // Game simulation tests (Phase 3) + // Game Trait Contract Tests // ================================================================ #[test] - fn platformer_move_right_increases_x() { + fn contract_init_creates_player_state() { let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let initial_x = game.state.players[&1].x; - - // Apply rightward movement for 30 ticks - for _ in 0..30 { - let input = PlatformerInput { - move_dir: 1.0, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - game.apply_input(1, &data); - game.update(1.0 / 15.0, &empty_inputs()); - } - - assert!( - game.state.players[&1].x > initial_x, - "Player x should increase: initial={initial_x}, final={}", - game.state.players[&1].x - ); + breakpoint_core::test_helpers::contract_init_creates_player_state(&mut game, 3); } #[test] - fn platformer_jump_changes_velocity() { + fn contract_apply_input_changes_state() { let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - // First ensure the player is grounded by ticking a few times - for _ in 0..20 { - game.update(1.0 / 15.0, &empty_inputs()); - } + let players = make_players(2); + game.init(&players, &default_config(180)); - // Apply jump let input = PlatformerInput { - move_dir: 0.0, - jump: true, + move_dir: 1.0, + jump: false, use_powerup: false, + attack: false, }; let data = rmp_serde::to_vec(&input).unwrap(); - game.apply_input(1, &data); - game.update(1.0 / 15.0, &empty_inputs()); - - // vy should be positive (upward) after jump, or at least y should have increased - let player = &game.state.players[&1]; - assert!( - player.vy > 0.0 || !player.grounded, - "Player should have upward velocity or be airborne after jump: vy={}, grounded={}", - player.vy, - player.grounded - ); + breakpoint_core::test_helpers::contract_apply_input_changes_state(&mut game, &data, 1); } - // ================================================================ - // Phase 3d: Game-level edge cases - // ================================================================ + #[test] + fn contract_update_advances_time() { + let mut game = PlatformRacer::new(); + let players = make_players(1); + game.init(&players, &default_config(180)); + breakpoint_core::test_helpers::contract_update_advances_time(&mut game); + } #[test] - fn duplicate_finish_only_counted_once() { + fn contract_round_eventually_completes() { let mut game = PlatformRacer::new(); let players = make_players(2); - game.init(&players, &default_config(120)); - - // Mark player 1 as finished - game.state.players.get_mut(&1).unwrap().finished = true; - - // Tick twice — the finish should only be recorded once - game.update(1.0 / 15.0, &empty_inputs()); - game.update(1.0 / 15.0, &empty_inputs()); - - let count = game - .state - .finish_order - .iter() - .filter(|&&id| id == 1) - .count(); - assert_eq!( - count, 1, - "Player should appear in finish_order exactly once" - ); + game.init(&players, &default_config(5)); + breakpoint_core::test_helpers::contract_round_eventually_completes(&mut game, 10); } #[test] - fn speed_boost_multiplies_movement() { + fn contract_state_roundtrip_preserves() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - - let initial_x = game.state.players[&1].x; + game.init(&players, &default_config(180)); + breakpoint_core::test_helpers::contract_state_roundtrip_preserves(&mut game); + } - // Give player SpeedBoost - game.state - .active_powerups - .entry(1) - .or_default() - .push(powerups::ActivePowerUp::new( - powerups::PowerUpKind::SpeedBoost, - )); - - // Move right for several ticks with boost - for _ in 0..20 { - let input = physics::PlatformerInput { - move_dir: 1.0, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - game.apply_input(1, &data); - game.update(1.0 / 15.0, &empty_inputs()); - } - let boosted_dx = game.state.players[&1].x - initial_x; - - // Now test without boost (fresh game) - let mut game2 = PlatformRacer::new(); - let players2 = make_players(1); - game2.init(&players2, &default_config(120)); - let initial_x2 = game2.state.players[&1].x; - - for _ in 0..20 { - let input = physics::PlatformerInput { - move_dir: 1.0, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - game2.apply_input(1, &data); - game2.update(1.0 / 15.0, &empty_inputs()); - } - let normal_dx = game2.state.players[&1].x - initial_x2; - - assert!( - boosted_dx > normal_dx * 1.2, - "Boosted movement ({boosted_dx}) should be notably more than normal ({normal_dx})" - ); - } - - #[test] - fn round_complete_when_all_finished_race() { - let mut game = PlatformRacer::new(); - let players = make_players(3); - game.init(&players, &default_config(120)); - - // Mark all as finished - for &pid in &game.player_ids.clone() { - game.state.players.get_mut(&pid).unwrap().finished = true; - } - - let events = game.update(1.0 / 15.0, &empty_inputs()); - assert!( - game.state.round_complete, - "Race should complete when all finish" - ); - assert!(events.iter().any(|e| matches!(e, GameEvent::RoundComplete))); - } - - #[test] - fn round_complete_when_one_remains_survival() { - let mut game = PlatformRacer::new(); - let players = make_players(3); - game.init(&players, &survival_config(120)); - - // Eliminate 2 of 3 - for &pid in &[1u64, 2u64] { - game.state.players.get_mut(&pid).unwrap().eliminated = true; - game.state.elimination_order.push(pid); - game.eliminated_set.insert(pid); - } - - let events = game.update(1.0 / 15.0, &empty_inputs()); - assert!( - game.state.round_complete, - "Survival should complete when 1 player remains" - ); - assert!(events.iter().any(|e| matches!(e, GameEvent::RoundComplete))); - } - - #[test] - fn platformer_jump_input_not_lost_across_overwrites() { - // This test verifies the Bug 2 fix: transient inputs (jump) must be - // preserved even if a subsequent apply_input overwrites with jump:false. - let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - // Ensure player is grounded - for _ in 0..20 { - game.update(1.0 / 15.0, &empty_inputs()); - } - - // Frame N: jump=true - let input_jump = PlatformerInput { - move_dir: 1.0, - jump: true, - use_powerup: false, - }; - let data_jump = rmp_serde::to_vec(&input_jump).unwrap(); - game.apply_input(1, &data_jump); - - // Frame N+1: jump=false (would overwrite in old code) - let input_no_jump = PlatformerInput { - move_dir: 1.0, - jump: false, - use_powerup: false, - }; - let data_no_jump = rmp_serde::to_vec(&input_no_jump).unwrap(); - game.apply_input(1, &data_no_jump); - - // The pending input should still have jump=true - assert!( - game.pending_inputs.get(&1).is_some_and(|i| i.jump), - "Jump flag must be preserved across input overwrites" - ); - - // Tick the game — jump should actually happen - game.update(1.0 / 15.0, &empty_inputs()); - - let player = &game.state.players[&1]; - assert!( - player.vy > 0.0 || !player.grounded, - "Jump should have occurred despite being overwritten: vy={}, grounded={}", - player.vy, - player.grounded - ); - } - - // ================================================================ - // P0-1: NaN/Inf/Degenerate Input Fuzzing - // ================================================================ - - // REGRESSION: NaN move_dir should not corrupt player position - #[test] - fn platformer_apply_input_nan_move_no_panic() { - let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let input = PlatformerInput { - move_dir: f32::NAN, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - game.apply_input(1, &data); - - // Should not panic on update - game.update(1.0 / 15.0, &empty_inputs()); - } - - // REGRESSION: Inf move_dir should not crash #[test] - fn platformer_apply_input_inf_move_no_panic() { - let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let input = PlatformerInput { - move_dir: f32::INFINITY, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - game.apply_input(1, &data); - - game.update(1.0 / 15.0, &empty_inputs()); - } - - // ================================================================ - // P1-1: Serialization Fuzzing - // ================================================================ - - // REGRESSION: Garbage input data should not panic - #[test] - fn platformer_apply_input_garbage_no_panic() { - let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let garbage: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0xAB, 0xCD]; - game.apply_input(1, &garbage); - - // Player should be unchanged - assert!( - !game.state.players[&1].finished, - "Garbage input should not finish the player" - ); - } - - // REGRESSION: Truncated state data should not panic - #[test] - fn platformer_apply_state_truncated_no_panic() { + fn contract_pause_stops_updates() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - - let state = game.serialize_state(); - let truncated = &state[..state.len() / 2]; - game.apply_state(truncated); - - // Game should still be functional - assert_eq!(game.state.players.len(), 1); + game.init(&players, &default_config(180)); + breakpoint_core::test_helpers::contract_pause_stops_updates(&mut game); } - // ================================================================ - // P1-2: State Machine Transition Tests - // ================================================================ - #[test] - fn platformer_double_pause_single_resume_works() { + fn contract_player_left_cleanup() { let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - game.pause(); - game.pause(); - game.resume(); - - let timer_before = game.state.round_timer; - game.update(1.0 / 15.0, &empty_inputs()); - - assert!( - game.state.round_timer > timer_before, - "Timer should advance after resume" - ); + let players = make_players(2); + game.init(&players, &default_config(180)); + breakpoint_core::test_helpers::contract_player_left_cleanup(&mut game, 2, 2); } #[test] - fn platformer_update_after_round_complete_is_noop() { + fn contract_round_results_complete() { let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - // Force round complete by finishing the player - game.state.players.get_mut(&1).unwrap().finished = true; - game.state.finish_order.push(1); - game.finished_set.insert(1); - game.update(1.0 / 15.0, &empty_inputs()); - assert!(game.is_round_complete()); - - let timer = game.state.round_timer; - let events = game.update(1.0 / 15.0, &empty_inputs()); - assert!( - (game.state.round_timer - timer).abs() < 0.01, - "Timer should not advance after round complete" - ); - assert!(events.is_empty(), "No events after round complete"); + let players = make_players(3); + game.init(&players, &default_config(180)); + breakpoint_core::test_helpers::contract_round_results_complete(&game, 3); } // ================================================================ - // P1-5: Platformer Edge Cases + // Enemy interaction tests // ================================================================ - // REGRESSION: Checkpoint should not be lost when player moves backward #[test] - fn checkpoint_not_lost_on_backward_movement() { + fn enemies_tick_during_update() { let mut game = PlatformRacer::new(); let players = make_players(1); - game.init(&players, &default_config(120)); - - // Move player forward past what would be a checkpoint - let _initial_checkpoint_x = game.state.players[&1].last_checkpoint_x; + game.init(&players, &default_config(180)); - // Manually set a checkpoint further ahead - game.state.players.get_mut(&1).unwrap().last_checkpoint_x = 50.0; - game.state.players.get_mut(&1).unwrap().last_checkpoint_y = 2.0; - let checkpoint_x = 50.0; - - // Move backward - game.state.players.get_mut(&1).unwrap().x = 30.0; - game.state.players.get_mut(&1).unwrap().y = 2.0; + // Record initial enemy positions + let initial_positions: Vec<(f32, f32)> = game + .state + .enemies + .iter() + .filter(|e| e.alive) + .map(|e| (e.x, e.y)) + .collect(); - // Run a few ticks with leftward input - let input = PlatformerInput { - move_dir: -1.0, - jump: false, - use_powerup: false, - }; - let data = rmp_serde::to_vec(&input).unwrap(); - for _ in 0..10 { - game.apply_input(1, &data); - game.update(1.0 / 15.0, &empty_inputs()); + // Tick several times + for _ in 0..20 { + game.update(1.0 / 20.0, &empty_inputs()); } - // Checkpoint should still be at 50.0 - assert!( - game.state.players[&1].last_checkpoint_x >= checkpoint_x, - "Checkpoint should not regress: expected >= {checkpoint_x}, got {}", - game.state.players[&1].last_checkpoint_x - ); - } - - #[test] - fn magnet_powerup_auto_collects_nearby() { - let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let pid = 1u64; - let player = &game.state.players[&pid]; - let px = player.x; - let py = player.y; - - // Clear any existing powerups from course gen - game.state.powerups.clear(); - - // Place a SpeedBoost within magnet radius (3.0) - game.state.powerups.push(SpawnedPowerUp { - x: px + 2.0, - y: py, - kind: PowerUpKind::SpeedBoost, - collected: false, - }); - - // Place a Shield outside magnet radius - game.state.powerups.push(SpawnedPowerUp { - x: px + 5.0, - y: py, - kind: PowerUpKind::Shield, - collected: false, - }); - - // Give player a Magnet powerup - game.state - .active_powerups - .entry(pid) - .or_default() - .push(ActivePowerUp::new(PowerUpKind::Magnet)); - - game.update(1.0 / 15.0, &empty_inputs()); + // Some enemies should have moved + let moved = game + .state + .enemies + .iter() + .filter(|e| e.alive) + .zip(initial_positions.iter()) + .any(|(e, &(ix, iy))| (e.x - ix).abs() > 0.01 || (e.y - iy).abs() > 0.01); - // Nearby SpeedBoost should be collected - assert!( - game.state.powerups[0].collected, - "SpeedBoost within magnet radius should be auto-collected" - ); - // Far Shield should NOT be collected assert!( - !game.state.powerups[1].collected, - "Shield outside magnet radius should not be collected" - ); - // Player should now have Magnet + SpeedBoost active - let active = &game.state.active_powerups[&pid]; - assert!( - active.iter().any(|p| p.kind == PowerUpKind::SpeedBoost), - "SpeedBoost should be in active powerups after magnet collection" + moved, + "At least some enemies should have moved during updates" ); } #[test] - fn magnet_powerup_no_effect_without_magnet() { + fn rubber_banding_recalculates_periodically() { let mut game = PlatformRacer::new(); - let players = make_players(1); - game.init(&players, &default_config(120)); - - let pid = 1u64; - let player = &game.state.players[&pid]; - let px = player.x; - let py = player.y; + let players = make_players(3); + game.init(&players, &default_config(180)); - // Clear and place a powerup within magnet radius - game.state.powerups.clear(); - game.state.powerups.push(SpawnedPowerUp { - x: px + 2.0, - y: py, - kind: PowerUpKind::SpeedBoost, - collected: false, - }); + // Spread players out + game.state.players.get_mut(&1).unwrap().x = 100.0; + game.state.players.get_mut(&2).unwrap().x = 50.0; + game.state.players.get_mut(&3).unwrap().x = 10.0; - // No magnet — normal collection radius is 1.0, and this is at 2.0 - game.update(1.0 / 15.0, &empty_inputs()); + // Tick 30 times to trigger rubber band recalculation + for _ in 0..30 { + game.update(1.0 / 20.0, &empty_inputs()); + } assert!( - !game.state.powerups[0].collected, - "Powerup at distance 2.0 should not be collected without magnet" + !game.state.rubber_band.is_empty(), + "Rubber band factors should be populated after 30 ticks" ); } - // REGRESSION: Simultaneous finish should produce valid scores for both #[test] - fn simultaneous_finish_produces_valid_scores() { - let mut game = PlatformRacer::new(); - let players = make_players(2); - game.init(&players, &default_config(120)); - - // Both players finish on the same tick - game.state.players.get_mut(&1).unwrap().finished = true; - game.state.players.get_mut(&2).unwrap().finished = true; - game.state.finish_order.push(1); - game.state.finish_order.push(2); - game.finished_set.insert(1); - game.finished_set.insert(2); - - game.update(1.0 / 15.0, &empty_inputs()); - assert!(game.is_round_complete()); - - let results = game.round_results(); - assert_eq!(results.len(), 2, "Both players should have results"); - for result in &results { - assert!( - result.score >= 0, - "Player {} should have non-negative score, got {}", - result.player_id, - result.score - ); - } - // First finisher should score higher - let p1_score = results.iter().find(|r| r.player_id == 1).unwrap().score; - let p2_score = results.iter().find(|r| r.player_id == 2).unwrap().score; + fn death_penalty_affects_score() { + // Player with deaths should score lower than player without assert!( - p1_score >= p2_score, - "First finisher should score >= second: p1={p1_score}, p2={p2_score}" + scoring::race_score(Some(0), 4) < scoring::race_score(Some(0), 0), + "Deaths should reduce score" ); } #[test] - fn checkpoint_advances_on_checkpoint_tile() { + fn serialized_state_fits_protocol_limit() { + // The protocol has a 64 KiB limit. Verify the initialized state fits. let mut game = PlatformRacer::new(); let players = make_players(2); - game.init(&players, &default_config(120)); + game.init(&players, &default_config(180)); - let pid = 1u64; - - // Find the first Checkpoint tile in the generated course - let mut checkpoint_tile: Option<(u32, u32)> = None; - for y in 0..game.course.height { - for x in 0..game.course.width { - if game.course.get_tile(x as i32, y as i32) == course_gen::Tile::Checkpoint { - checkpoint_tile = Some((x, y)); - break; - } - } - if checkpoint_tile.is_some() { - break; - } - } - let (cx, cy) = checkpoint_tile.expect("Course should have at least one Checkpoint tile"); - - // Record initial checkpoint position - let initial_cp_x = game.state.players[&pid].last_checkpoint_x; - - // The checkpoint tile world position is at tile (cx, cy). - // check_tile_effects uses floor(player.x / TILE_SIZE) to determine tile. - // Place player so their tile coordinates match the checkpoint tile and - // player.x > last_checkpoint_x (so the forward-only guard passes). - let world_x = cx as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0; - let world_y = cy as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0; - - // Ensure the checkpoint is actually forward from the player's current checkpoint - assert!( - world_x > initial_cp_x, - "Checkpoint tile x ({world_x}) should be ahead of initial checkpoint ({initial_cp_x})" + let state_bytes = game.serialize_state(); + eprintln!( + "Serialized PlatformerState size: {} bytes", + state_bytes.len() ); - // Teleport player to the checkpoint tile position - let player = game.state.players.get_mut(&pid).unwrap(); - player.x = world_x; - player.y = world_y; - - // Run a game tick to trigger check_tile_effects through the physics substeps - game.update(1.0 / 15.0, &empty_inputs()); - - let player = &game.state.players[&pid]; - assert!( - player.last_checkpoint_x > initial_cp_x, - "Checkpoint should have advanced: initial={initial_cp_x}, current={}", - player.last_checkpoint_x - ); - // Verify the checkpoint position corresponds to the tile center - let expected_cp_x = cx as f32 * physics::TILE_SIZE + physics::TILE_SIZE / 2.0; + // Protocol MAX_MESSAGE_SIZE is 64 KiB (65536 bytes). + // The GameState wrapper adds tick (u32) + the state_data blob + + // another MessagePack envelope + 1-byte type prefix. + // Total overhead is small, so state_data itself should be well under 60 KiB. assert!( - (player.last_checkpoint_x - expected_cp_x).abs() < 0.1, - "Checkpoint x should be at tile center ({expected_cp_x}), got {}", - player.last_checkpoint_x + state_bytes.len() < 60_000, + "Serialized state is {} bytes, exceeds 60 KiB safety margin for 64 KiB protocol limit", + state_bytes.len() ); } - - // P1-5: Course always has reachable finish for multiple seeds - #[test] - fn course_always_has_finish_tile() { - for seed in 0..10 { - let course = generate_course(seed); - let has_finish = course - .tiles - .iter() - .any(|t| matches!(t, course_gen::Tile::Finish)); - assert!( - has_finish, - "Course with seed {seed} should have at least one Finish tile" - ); - } - } } diff --git a/crates/games/breakpoint-platformer/src/physics.rs b/crates/games/breakpoint-platformer/src/physics.rs index 6ebf741..52c90aa 100644 --- a/crates/games/breakpoint-platformer/src/physics.rs +++ b/crates/games/breakpoint-platformer/src/physics.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; +use crate::combat::{ATTACK_COOLDOWN, ATTACK_DURATION, INVINCIBILITY_DURATION}; use crate::course_gen::{Course, Tile}; +use crate::powerups::PowerUpKind; /// Gravity acceleration (units/s^2, downward). pub const GRAVITY: f32 = -30.0; @@ -22,6 +24,8 @@ const PLATFORM_LAND_TOLERANCE: f32 = 0.2; const PLATFORM_SNAP_TOLERANCE: f32 = 0.1; /// Y threshold below which player respawns at checkpoint. const FALL_RESPAWN_Y: f32 = -5.0; +/// Ladder climb speed (units/s). +const LADDER_SPEED: f32 = 5.0; /// Configurable platformer physics parameters, loadable from TOML. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,8 +68,8 @@ impl Default for PlatformerConfig { fn default() -> Self { Self { physics: PlatformerPhysicsConfig::default(), - round_duration_secs: 120.0, - tick_rate_hz: 15.0, + round_duration_secs: 180.0, + tick_rate_hz: 20.0, speed_boost_multiplier: 1.5, } } @@ -90,6 +94,18 @@ impl PlatformerConfig { } } +/// Animation state for player rendering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AnimState { + Idle, + Walk, + Jump, + Fall, + Attack, + Hurt, + Dead, +} + /// State of a single player in the platformer. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PlatformerPlayerState { @@ -102,9 +118,27 @@ pub struct PlatformerPlayerState { pub jumps_remaining: u8, pub last_checkpoint_x: f32, pub last_checkpoint_y: f32, + pub last_checkpoint_id: u16, pub finished: bool, pub eliminated: bool, pub finish_time: Option, + // Combat fields + pub hp: u8, + pub max_hp: u8, + pub invincibility_timer: f32, + pub attack_timer: f32, + pub attack_cooldown: f32, + pub deaths: u8, + pub death_respawn_timer: f32, + // Animation and facing + pub facing_right: bool, + pub anim_state: AnimState, + pub anim_time: f32, + // Active power-up (single slot for non-instant powerups) + pub active_powerup: Option, + pub powerup_timer: f32, + /// Current room's graph distance from start (for rubber-banding/race position). + pub current_room_distance: u16, } impl PlatformerPlayerState { @@ -119,9 +153,23 @@ impl PlatformerPlayerState { jumps_remaining: 1, last_checkpoint_x: spawn_x, last_checkpoint_y: spawn_y, + last_checkpoint_id: 0, finished: false, eliminated: false, finish_time: None, + hp: 3, + max_hp: 3, + invincibility_timer: 0.0, + attack_timer: 0.0, + attack_cooldown: 0.0, + deaths: 0, + death_respawn_timer: 0.0, + facing_right: true, + anim_state: AnimState::Idle, + anim_time: 0.0, + active_powerup: None, + powerup_timer: 0.0, + current_room_distance: 0, } } @@ -130,8 +178,14 @@ impl PlatformerPlayerState { self.y = self.last_checkpoint_y + 1.0; self.vx = 0.0; self.vy = 0.0; + self.hp = self.max_hp; self.has_double_jump = false; self.jumps_remaining = 1; + self.invincibility_timer = 0.0; + self.attack_timer = 0.0; + self.attack_cooldown = 0.0; + self.death_respawn_timer = 0.0; + self.anim_state = AnimState::Idle; } } @@ -141,6 +195,7 @@ pub struct PlatformerInput { pub move_dir: f32, // -1 (left), 0, +1 (right) pub jump: bool, pub use_powerup: bool, + pub attack: bool, } impl Default for PlatformerInput { @@ -149,6 +204,7 @@ impl Default for PlatformerInput { move_dir: 0.0, jump: false, use_powerup: false, + attack: false, } } } @@ -164,23 +220,114 @@ pub fn tick_player( return; } + // Death respawn timer: skip all movement while dead + if player.death_respawn_timer > 0.0 { + player.death_respawn_timer -= dt; + player.anim_state = AnimState::Dead; + if player.death_respawn_timer <= 0.0 { + player.respawn_at_checkpoint(); + } + return; + } + + // Tick invincibility timer + if player.invincibility_timer > 0.0 { + player.invincibility_timer -= dt; + if player.invincibility_timer < 0.0 { + player.invincibility_timer = 0.0; + } + } + + // Tick animation time + player.anim_time += dt; + + // Attack state machine + if player.attack_cooldown > 0.0 { + player.attack_cooldown -= dt; + if player.attack_cooldown < 0.0 { + player.attack_cooldown = 0.0; + } + } + if player.attack_timer > 0.0 { + player.attack_timer -= dt; + if player.attack_timer <= 0.0 { + player.attack_timer = 0.0; + player.attack_cooldown = ATTACK_COOLDOWN; + } + } + // Start attack if requested and not in cooldown/active attack + if input.attack && player.attack_timer <= 0.0 && player.attack_cooldown <= 0.0 { + player.attack_timer = ATTACK_DURATION; + } + + // Check if currently on a ladder or in water + let tx = (player.x / TILE_SIZE).floor() as i32; + let ty = (player.y / TILE_SIZE).floor() as i32; + let on_ladder = course.get_tile(tx, ty) == Tile::Ladder; + let in_water = course.get_tile(tx, ty) == Tile::Water; + // Horizontal movement (sanitize NaN/Inf) let move_dir = if input.move_dir.is_finite() { input.move_dir } else { 0.0 }; - player.vx = move_dir * MOVE_SPEED; - // Jump - if input.jump && player.jumps_remaining > 0 { - player.vy = JUMP_VELOCITY; - player.jumps_remaining -= 1; - player.grounded = false; + // Update facing direction + if move_dir > 0.01 { + player.facing_right = true; + } else if move_dir < -0.01 { + player.facing_right = false; } - // Apply gravity - player.vy += GRAVITY * dt; + if on_ladder { + // Ladder movement: disable gravity, allow vertical movement + player.vx = move_dir * MOVE_SPEED * 0.5; // Slower horizontal on ladder + player.vy = 0.0; + + if input.jump { + player.vy = LADDER_SPEED; // Climb up + } + // Can also move down by not pressing jump (just fall slowly or hold move_dir) + // If not pressing anything, hold position + if !input.jump && move_dir.abs() < 0.01 { + player.vy = -LADDER_SPEED * 0.3; // Slow slide down when idle on ladder + } + + // Jump off ladder with sufficient horizontal input + if move_dir.abs() > 0.5 && input.jump { + player.vy = JUMP_VELOCITY * 0.7; + player.grounded = false; + // Let normal physics take over below + } + } else if in_water { + // Water movement: slower speed, reduced jump, buoyancy + use crate::course_gen::{WATER_BUOYANCY, WATER_JUMP_FACTOR, WATER_SPEED_FACTOR}; + player.vx = move_dir * MOVE_SPEED * WATER_SPEED_FACTOR; + + // Jump (reduced in water) + if input.jump && player.jumps_remaining > 0 { + player.vy = JUMP_VELOCITY * WATER_JUMP_FACTOR; + player.jumps_remaining -= 1; + player.grounded = false; + } + + // Apply gravity with buoyancy (buoyancy counters ~30% of gravity) + player.vy += (GRAVITY + WATER_BUOYANCY) * dt; + } else { + // Normal movement + player.vx = move_dir * MOVE_SPEED; + + // Jump + if input.jump && player.jumps_remaining > 0 { + player.vy = JUMP_VELOCITY; + player.jumps_remaining -= 1; + player.grounded = false; + } + + // Apply gravity + player.vy += GRAVITY * dt; + } // Move player.x += player.vx * dt; @@ -191,6 +338,41 @@ pub fn tick_player( // Check special tiles check_tile_effects(player, course); + + // Update animation state + update_anim_state(player); +} + +/// Update the player's animation state based on their current status. +fn update_anim_state(player: &mut PlatformerPlayerState) { + // Attack overrides everything while active + if player.attack_timer > 0.0 { + player.anim_state = AnimState::Attack; + return; + } + + // Hurt overrides while in early invincibility frames + if player.invincibility_timer > INVINCIBILITY_DURATION - 0.3 { + player.anim_state = AnimState::Hurt; + return; + } + + // Airborne states + if !player.grounded { + if player.vy > 0.0 { + player.anim_state = AnimState::Jump; + } else { + player.anim_state = AnimState::Fall; + } + return; + } + + // Grounded states + if player.vx.abs() > 0.1 { + player.anim_state = AnimState::Walk; + } else { + player.anim_state = AnimState::Idle; + } } pub(crate) fn resolve_collisions(player: &mut PlatformerPlayerState, course: &Course) { @@ -297,7 +479,7 @@ pub(crate) fn resolve_collisions(player: &mut PlatformerPlayerState, course: &Co } } - // Fall off bottom → respawn + // Fall off bottom -> respawn via damage (not instant) if player.y < FALL_RESPAWN_Y { player.respawn_at_checkpoint(); } @@ -307,12 +489,32 @@ pub(crate) fn check_tile_effects(player: &mut PlatformerPlayerState, course: &Co let tx = (player.x / TILE_SIZE).floor() as i32; let ty = (player.y / TILE_SIZE).floor() as i32; + // Update current room distance for rubber-banding/race position + player.current_room_distance = course.room_distance_at(player.x, player.y); + match course.get_tile(tx, ty) { - Tile::Hazard => { - player.respawn_at_checkpoint(); + Tile::Spikes => { + // Spikes deal 1 HP damage with invincibility, instead of instant respawn + if player.invincibility_timer <= 0.0 { + player.hp = player.hp.saturating_sub(1); + if player.hp == 0 { + player.deaths += 1; + player.death_respawn_timer = crate::combat::DEATH_RESPAWN_TIMER; + player.vx = 0.0; + player.vy = 0.0; + } else { + player.invincibility_timer = INVINCIBILITY_DURATION; + // Bounce player up slightly to avoid repeat damage + player.vy = JUMP_VELOCITY * 0.5; + } + } }, Tile::Checkpoint => { - if player.x > player.last_checkpoint_x { + // Activate checkpoint if its ID is higher than the player's last + if let Some(cp_id) = course.find_checkpoint_id(tx, ty) + && cp_id > player.last_checkpoint_id + { + player.last_checkpoint_id = cp_id; player.last_checkpoint_x = tx as f32 * TILE_SIZE + TILE_SIZE / 2.0; player.last_checkpoint_y = ty as f32 * TILE_SIZE + TILE_SIZE / 2.0; } @@ -326,8 +528,18 @@ pub(crate) fn check_tile_effects(player: &mut PlatformerPlayerState, course: &Co } } -pub(crate) fn is_solid(tile: Tile) -> bool { - matches!(tile, Tile::Solid) +pub fn is_solid(tile: Tile) -> bool { + matches!(tile, Tile::StoneBrick | Tile::BreakableWall) +} + +/// Check if an attack can break a breakable wall at the given tile coords. +/// Returns true if the wall was broken. +pub fn try_break_wall(course: &mut Course, tx: i32, ty: i32) -> bool { + if course.get_tile(tx, ty) == Tile::BreakableWall && tx >= 0 && ty >= 0 { + course.set_tile(tx as u32, ty as u32, Tile::Empty); + return true; + } + false } #[cfg(test)] @@ -388,11 +600,20 @@ mod tests { let mut player = PlatformerPlayerState::new(5.0, 5.0); player.last_checkpoint_x = 10.0; player.last_checkpoint_y = 8.0; + player.hp = 1; + player.invincibility_timer = 0.5; + player.attack_timer = 0.2; player.respawn_at_checkpoint(); assert_eq!(player.x, 10.0); assert_eq!(player.y, 9.0); // last_checkpoint_y + 1.0 assert_eq!(player.vx, 0.0); assert_eq!(player.vy, 0.0); + assert_eq!(player.hp, player.max_hp, "HP should be restored to max"); + assert_eq!( + player.invincibility_timer, 0.0, + "Invincibility should clear" + ); + assert_eq!(player.attack_timer, 0.0, "Attack timer should clear"); } #[test] @@ -411,22 +632,266 @@ mod tests { assert_eq!(player.jumps_remaining, 0); } - // ================================================================ - // Phase 3b: Collision resolution tests - // ================================================================ + #[test] + fn new_player_has_3_hp() { + let player = PlatformerPlayerState::new(0.0, 0.0); + assert_eq!(player.hp, 3); + assert_eq!(player.max_hp, 3); + } + + #[test] + fn new_player_starts_facing_right() { + let player = PlatformerPlayerState::new(0.0, 0.0); + assert!(player.facing_right); + } + + #[test] + fn new_player_starts_idle() { + let player = PlatformerPlayerState::new(0.0, 0.0); + assert_eq!(player.anim_state, AnimState::Idle); + } + + #[test] + fn attack_starts_on_input() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + let input = PlatformerInput { + attack: true, + ..Default::default() + }; + + tick_player(&mut player, &input, &course, 0.01); + assert!( + player.attack_timer > 0.0, + "Attack timer should start on attack input" + ); + assert_eq!( + player.anim_state, + AnimState::Attack, + "Should be in Attack anim state" + ); + } + + #[test] + fn attack_cooldown_prevents_immediate_reattack() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + + // Start attack + let attack_input = PlatformerInput { + attack: true, + ..Default::default() + }; + tick_player(&mut player, &attack_input, &course, 0.01); + + // Fast-forward past attack duration + for _ in 0..50 { + tick_player(&mut player, &PlatformerInput::default(), &course, 0.01); + } + + // Attack should have ended and cooldown should be active + assert!( + player.attack_cooldown > 0.0 || player.attack_timer <= 0.0, + "Should be in cooldown after attack ends" + ); + } + + #[test] + fn death_respawn_timer_prevents_movement() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + player.death_respawn_timer = 1.0; + let x_before = player.x; + let y_before = player.y; + + let input = PlatformerInput { + move_dir: 1.0, + jump: true, + ..Default::default() + }; + tick_player(&mut player, &input, &course, 0.1); + + assert_eq!(player.x, x_before, "Dead player should not move x"); + assert_eq!(player.y, y_before, "Dead player should not move y"); + assert_eq!(player.anim_state, AnimState::Dead); + } + + #[test] + fn respawn_after_death_timer_expires() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + player.last_checkpoint_x = 10.0; + player.last_checkpoint_y = 3.0; + player.death_respawn_timer = 0.1; + player.hp = 0; + + // Tick past the respawn timer + tick_player(&mut player, &PlatformerInput::default(), &course, 0.2); + + assert_eq!(player.x, 10.0, "Should respawn at checkpoint x"); + assert_eq!(player.hp, player.max_hp, "HP should be restored"); + } + + #[test] + fn facing_direction_updates_from_input() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + assert!(player.facing_right); + + // Move left + let input = PlatformerInput { + move_dir: -1.0, + ..Default::default() + }; + tick_player(&mut player, &input, &course, 0.01); + assert!(!player.facing_right, "Should face left after moving left"); + + // Move right + let input = PlatformerInput { + move_dir: 1.0, + ..Default::default() + }; + tick_player(&mut player, &input, &course, 0.01); + assert!(player.facing_right, "Should face right after moving right"); + } + + #[test] + fn invincibility_decrements() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(5.0, 5.0); + player.invincibility_timer = 1.0; + + tick_player(&mut player, &PlatformerInput::default(), &course, 0.5); + + assert!( + (player.invincibility_timer - 0.5).abs() < 0.1, + "Invincibility should decrement: got {}", + player.invincibility_timer, + ); + } - /// Build a course with a floor (row 0 and 1 solid) and optional extras. + #[test] + fn spikes_deal_damage_not_instant_respawn() { + // Create a course with spikes + let mut course = generate_course(42); + // Place spikes at a known location + course.set_tile(10, 2, Tile::Spikes); + + let mut player = PlatformerPlayerState::new(10.5, 2.5); + player.hp = 3; + player.invincibility_timer = 0.0; + + check_tile_effects(&mut player, &course); + + assert_eq!(player.hp, 2, "Spikes should deal 1 HP damage"); + assert!( + player.invincibility_timer > 0.0, + "Should get invincibility after spike damage" + ); + } + + #[test] + fn spikes_kill_at_1_hp() { + let mut course = generate_course(42); + course.set_tile(10, 2, Tile::Spikes); + + let mut player = PlatformerPlayerState::new(10.5, 2.5); + player.hp = 1; + player.invincibility_timer = 0.0; + + check_tile_effects(&mut player, &course); + + assert_eq!(player.hp, 0, "Player should die on spikes at 1 HP"); + assert!( + player.death_respawn_timer > 0.0, + "Should have respawn timer" + ); + assert_eq!(player.deaths, 1); + } + + #[test] + fn spikes_ignored_when_invincible() { + let mut course = generate_course(42); + course.set_tile(10, 2, Tile::Spikes); + + let mut player = PlatformerPlayerState::new(10.5, 2.5); + player.hp = 3; + player.invincibility_timer = 1.0; // Already invincible + + check_tile_effects(&mut player, &course); + + assert_eq!(player.hp, 3, "Spikes should not damage invincible player"); + } + + #[test] + fn is_solid_includes_stone_brick() { + assert!(is_solid(Tile::StoneBrick)); + } + + #[test] + fn is_solid_includes_breakable_wall() { + assert!(is_solid(Tile::BreakableWall)); + } + + #[test] + fn is_solid_excludes_empty() { + assert!(!is_solid(Tile::Empty)); + } + + #[test] + fn is_solid_excludes_platform() { + assert!(!is_solid(Tile::Platform)); + } + + #[test] + fn try_break_wall_breaks_breakable() { + let mut course = generate_course(42); + course.set_tile(10, 5, Tile::BreakableWall); + + assert!(try_break_wall(&mut course, 10, 5)); + assert_eq!( + course.get_tile(10, 5), + Tile::Empty, + "Broken wall should become Empty" + ); + } + + #[test] + fn try_break_wall_ignores_non_breakable() { + let mut course = generate_course(42); + course.set_tile(10, 5, Tile::StoneBrick); + + assert!(!try_break_wall(&mut course, 10, 5)); + assert_eq!( + course.get_tile(10, 5), + Tile::StoneBrick, + "StoneBrick should not break" + ); + } + + /// Build a course with a floor (rows 0-1 stone brick) and optional extras. fn floor_course_with_extras(extras: &[(u32, u32, Tile)]) -> Course { + use crate::course_gen::CheckpointDef; + let w = 20u32; let h = 20u32; let mut tiles = vec![Tile::Empty; (w * h) as usize]; // Solid floor for x in 0..w { - tiles[x as usize] = Tile::Solid; - tiles[w as usize + x as usize] = Tile::Solid; + tiles[x as usize] = Tile::StoneBrick; + tiles[w as usize + x as usize] = Tile::StoneBrick; } + // Collect checkpoint defs from extras + let mut checkpoint_positions = Vec::new(); for &(x, y, tile) in extras { tiles[y as usize * w as usize + x as usize] = tile; + if tile == Tile::Checkpoint { + checkpoint_positions.push(CheckpointDef { + x: x as f32 * TILE_SIZE + TILE_SIZE / 2.0, + y: y as f32 * TILE_SIZE + TILE_SIZE / 2.0, + id: (checkpoint_positions.len() + 1) as u16, + }); + } } Course { width: w, @@ -434,12 +899,15 @@ mod tests { tiles, spawn_x: 5.0, spawn_y: 3.0, + enemy_spawns: Vec::new(), + checkpoint_positions, + room_distances: Vec::new(), + room_themes: Vec::new(), } } #[test] fn landing_on_solid_block_sets_grounded() { - // Floor at y=0,1. Player falls from above. let course = floor_course_with_extras(&[]); let mut player = PlatformerPlayerState::new(5.0, 5.0); player.vy = -5.0; @@ -462,34 +930,28 @@ mod tests { #[test] fn ceiling_collision_stops_upward_velocity() { - // Solid block directly above player - let course = floor_course_with_extras(&[(5, 5, Tile::Solid)]); + let course = floor_course_with_extras(&[(5, 5, Tile::StoneBrick)]); let mut player = PlatformerPlayerState::new(5.5, 3.0); player.grounded = true; player.jumps_remaining = 1; - // Jump: should hit the ceiling block at y=5 let input = PlatformerInput { jump: true, ..Default::default() }; tick_player(&mut player, &input, &course, 0.02); - // After jump, vy should be positive initially assert!(player.vy >= 0.0); - // Run more ticks — player should eventually hit ceiling and vy goes to 0 let no_jump = PlatformerInput::default(); for _ in 0..50 { tick_player(&mut player, &no_jump, &course, 0.02); } - // Player should have come back down assert!(player.y < 5.0, "Player should be below ceiling block"); } #[test] fn horizontal_wall_collision_stops_vx() { - // Solid block to the right of player - let course = floor_course_with_extras(&[(8, 2, Tile::Solid)]); + let course = floor_course_with_extras(&[(8, 2, Tile::StoneBrick)]); let mut player = PlatformerPlayerState::new(7.0, 2.5 + PLAYER_HEIGHT / 2.0); player.grounded = true; @@ -502,7 +964,6 @@ mod tests { tick_player(&mut player, &input, &course, 0.02); } - // Player should not pass through the solid block assert!( player.x < 8.0, "Player should be blocked by wall at x=8, got x={}", @@ -512,7 +973,6 @@ mod tests { #[test] fn platform_passthrough_from_below() { - // Platform tile at y=5, player jumping up from below let course = floor_course_with_extras(&[(5, 5, Tile::Platform)]); let mut player = PlatformerPlayerState::new(5.5, 3.0); player.grounded = true; @@ -522,23 +982,18 @@ mod tests { player.jumps_remaining = 0; let input = PlatformerInput::default(); - let initial_vy = player.vy; tick_player(&mut player, &input, &course, 0.02); - // Player should pass through the platform from below (vy should still be positive - // or at least not zeroed from collision) assert!( player.vy > 0.0 || player.y > 5.0, "Player should pass through platform from below: vy={}, y={}", player.vy, player.y ); - let _ = initial_vy; } #[test] fn platform_landing_from_above() { - // Platform tile at y=5, player falling from above let course = floor_course_with_extras(&[(5, 5, Tile::Platform)]); let mut player = PlatformerPlayerState::new(5.5, 8.0); player.vy = -3.0; @@ -551,7 +1006,6 @@ mod tests { } } - // Player should have landed on the platform (y ≈ 6 + PLAYER_HEIGHT/2) assert!( player.y >= 5.0, "Player should land on platform at y>=5, got y={}", @@ -561,15 +1015,13 @@ mod tests { #[test] fn fall_below_floor_respawns_at_checkpoint() { - // Course with a gap — no floor at x=5..7 let w = 20u32; let h = 20u32; let mut tiles = vec![Tile::Empty; (w * h) as usize]; - // Floor except gap at x=5,6 for x in 0..w { if !(5..=6).contains(&x) { - tiles[x as usize] = Tile::Solid; - tiles[w as usize + x as usize] = Tile::Solid; + tiles[x as usize] = Tile::StoneBrick; + tiles[w as usize + x as usize] = Tile::StoneBrick; } } let course = Course { @@ -578,6 +1030,10 @@ mod tests { tiles, spawn_x: 3.0, spawn_y: 3.0, + enemy_spawns: Vec::new(), + checkpoint_positions: Vec::new(), + room_distances: Vec::new(), + room_themes: Vec::new(), }; let mut player = PlatformerPlayerState::new(5.5, 3.0); @@ -585,16 +1041,13 @@ mod tests { player.last_checkpoint_y = 3.0; let input = PlatformerInput::default(); - // Fall through gap for _ in 0..500 { tick_player(&mut player, &input, &course, 0.02); if player.x == 3.0 && player.y > 3.0 { - // Respawned break; } } - // After falling below -5.0, should respawn at checkpoint assert!( player.y > -5.0, "Player should have respawned, y={}", @@ -623,51 +1076,41 @@ mod tests { } } - // ================================================================ - // Phase 3c: Tile effect tests - // ================================================================ - - #[test] - fn hazard_tile_respawns_player() { - let course = floor_course_with_extras(&[(5, 2, Tile::Hazard)]); - let mut player = PlatformerPlayerState::new(5.5, 2.5); - player.last_checkpoint_x = 3.0; - player.last_checkpoint_y = 3.0; - - check_tile_effects(&mut player, &course); - - // Player should respawn at checkpoint - assert_eq!(player.x, 3.0, "Hazard should respawn at checkpoint x"); - assert_eq!(player.y, 4.0, "Hazard should respawn at checkpoint y + 1.0"); - } - #[test] fn checkpoint_forward_updates_position() { let course = floor_course_with_extras(&[(8, 2, Tile::Checkpoint)]); let mut player = PlatformerPlayerState::new(8.5, 2.5); player.last_checkpoint_x = 3.0; player.last_checkpoint_y = 3.0; + player.last_checkpoint_id = 0; check_tile_effects(&mut player, &course); - // Checkpoint is forward (x=8.5 > 3.0), so it should update + assert!( + player.last_checkpoint_id > 0, + "Checkpoint should update: last_checkpoint_id={}", + player.last_checkpoint_id + ); assert!( player.last_checkpoint_x > 3.0, - "Checkpoint should update: last_checkpoint_x={}", + "Checkpoint should update position: last_checkpoint_x={}", player.last_checkpoint_x ); } #[test] fn checkpoint_backward_ignored() { - let course = floor_course_with_extras(&[(2, 2, Tile::Checkpoint)]); + // Create course with two checkpoints + let course = + floor_course_with_extras(&[(2, 2, Tile::Checkpoint), (8, 2, Tile::Checkpoint)]); let mut player = PlatformerPlayerState::new(2.5, 2.5); + // Player already has checkpoint id 2 (the higher one) + player.last_checkpoint_id = 2; player.last_checkpoint_x = 10.0; player.last_checkpoint_y = 3.0; check_tile_effects(&mut player, &course); - // Checkpoint is backward (player.x=2.5 < last_checkpoint_x=10.0) assert_eq!( player.last_checkpoint_x, 10.0, "Backward checkpoint should not update position" @@ -723,8 +1166,48 @@ mod tests { assert_eq!(player.y, y_before, "Eliminated player should not move"); } + #[test] + fn nan_move_dir_treated_as_zero() { + let course = generate_course(42); + let mut player = PlatformerPlayerState::new(2.0, 2.0); + player.grounded = true; + + let input = PlatformerInput { + move_dir: f32::NAN, + jump: false, + use_powerup: false, + attack: false, + }; + tick_player(&mut player, &input, &course, 0.1); + + assert_eq!( + player.vx, 0.0, + "NaN move_dir should be sanitized to 0, resulting in vx=0" + ); + } + + #[test] + fn respawn_resets_double_jump() { + let mut player = PlatformerPlayerState::new(5.0, 5.0); + player.has_double_jump = true; + player.jumps_remaining = 2; + player.last_checkpoint_x = 10.0; + player.last_checkpoint_y = 8.0; + + player.respawn_at_checkpoint(); + + assert!( + !player.has_double_jump, + "Double jump should be reset on respawn" + ); + assert_eq!( + player.jumps_remaining, 1, + "Jumps remaining should be 1 on respawn" + ); + } + // ================================================================ - // Phase 4d: Property-based tests (proptest) + // Property-based tests (proptest) // ================================================================ mod proptests { @@ -754,7 +1237,6 @@ mod tests { } } - // After all ticks, player should be above -5.0 (respawn catches falls) prop_assert!( player.y >= -5.0, "Player y={} should be >= -5.0 (respawn should catch)", @@ -773,18 +1255,15 @@ mod tests { ); let input = PlatformerInput::default(); - // Let player settle for _ in 0..200 { for _ in 0..SUBSTEPS { tick_player(&mut player, &input, &course, 1.0 / SUBSTEPS as f32); } } - // If grounded, player should be resting on or above a tile if player.grounded { let half_h = PLAYER_HEIGHT / 2.0; let foot_y = player.y - half_h; - // Feet should be at or above a tile top (within tolerance) prop_assert!( foot_y >= -0.5, "Grounded player feet y={foot_y} should be near a tile surface" @@ -792,10 +1271,6 @@ mod tests { } } - // P2-1: Player position stays valid after collision resolution - // The tile collision system allows AABB overlap with multi-tile - // solid blocks (resolved incrementally per-tile), so we check - // that the player doesn't fall through the world entirely. #[test] fn player_position_stays_valid( seed in 0u64..200, @@ -821,10 +1296,6 @@ mod tests { break; } - // Player should be in valid position: - // - x within course bounds (with some margin for edge) - // - y above respawn threshold (respawn catches y < -5.0) - // - All coordinates finite prop_assert!( player.x.is_finite() && player.y.is_finite(), "Player position must be finite: ({}, {})", @@ -836,8 +1307,6 @@ mod tests { "Player y={} fell below respawn threshold", player.y ); - // Player can walk off course edges (no invisible walls) - // but shouldn't teleport to absurd positions let course_extent = course.width as f32 * TILE_SIZE; prop_assert!( player.x >= -course_extent && player.x <= course_extent * 2.0, @@ -848,7 +1317,6 @@ mod tests { } } - // P2-1: double_jump_remaining resets on every ground contact #[test] fn double_jump_resets_on_ground( seed in 0u64..100 @@ -860,7 +1328,6 @@ mod tests { ); player.has_double_jump = true; - // Let player settle to ground let no_input = PlatformerInput::default(); for _ in 0..100 { for _ in 0..SUBSTEPS { @@ -874,7 +1341,6 @@ mod tests { "Grounded player with double jump should have 2 jumps remaining" ); - // Jump let jump_input = PlatformerInput { jump: true, ..Default::default() @@ -883,13 +1349,11 @@ mod tests { tick_player(&mut player, &jump_input, &course, 1.0 / SUBSTEPS as f32); } - // Should have fewer jumps prop_assert!( player.jumps_remaining < 2, "After jumping, should have fewer jumps" ); - // Let player land again for _ in 0..200 { for _ in 0..SUBSTEPS { tick_player( @@ -914,43 +1378,4 @@ mod tests { } } } - - #[test] - fn respawn_resets_double_jump() { - let mut player = PlatformerPlayerState::new(5.0, 5.0); - player.has_double_jump = true; - player.jumps_remaining = 2; - player.last_checkpoint_x = 10.0; - player.last_checkpoint_y = 8.0; - - player.respawn_at_checkpoint(); - - assert!( - !player.has_double_jump, - "Double jump should be reset on respawn" - ); - assert_eq!( - player.jumps_remaining, 1, - "Jumps remaining should be 1 on respawn" - ); - } - - #[test] - fn nan_move_dir_treated_as_zero() { - let course = generate_course(42); - let mut player = PlatformerPlayerState::new(2.0, 2.0); - player.grounded = true; - - let input = PlatformerInput { - move_dir: f32::NAN, - jump: false, - use_powerup: false, - }; - tick_player(&mut player, &input, &course, 0.1); - - assert_eq!( - player.vx, 0.0, - "NaN move_dir should be sanitized to 0, resulting in vx=0" - ); - } } diff --git a/crates/games/breakpoint-platformer/src/powerups.rs b/crates/games/breakpoint-platformer/src/powerups.rs index 60e211f..e7dda12 100644 --- a/crates/games/breakpoint-platformer/src/powerups.rs +++ b/crates/games/breakpoint-platformer/src/powerups.rs @@ -1,24 +1,37 @@ +use rand::Rng; use serde::{Deserialize, Serialize}; use breakpoint_core::powerup; -/// Platformer power-up types. +/// Castlevania-style power-up types for the platformer. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PowerUpKind { - SpeedBoost, + /// AOE clear around player (instant effect). + HolyWater, + /// Screen-wide clear of all nearby enemies (instant effect). + Crucifix, + /// 1.5x movement speed for 5 seconds. + SpeedBoots, + /// Grants permanent double-jump until death. DoubleJump, - Shield, - /// Auto-collects nearby pickups within a 3.0-unit radius while active. - Magnet, + /// Permanently increases max HP by 1 (and heals that point). + ArmorUp, + /// Invincibility for 3 seconds. + Invincibility, + /// Extended whip attack range for 10 seconds. + WhipExtend, } impl powerup::PowerUpKind for PowerUpKind { fn duration(&self) -> f32 { match self { - PowerUpKind::SpeedBoost => 3.0, + PowerUpKind::HolyWater => 0.0, + PowerUpKind::Crucifix => 0.0, + PowerUpKind::SpeedBoots => 5.0, PowerUpKind::DoubleJump => f32::INFINITY, - PowerUpKind::Shield => f32::INFINITY, - PowerUpKind::Magnet => 3.0, + PowerUpKind::ArmorUp => f32::INFINITY, + PowerUpKind::Invincibility => 3.0, + PowerUpKind::WhipExtend => 10.0, } } } @@ -35,29 +48,143 @@ pub struct SpawnedPowerUp { pub collected: bool, } +/// Select a power-up based on the player's relative position (Mario Kart-style rubber banding). +/// +/// `quality` ranges from 0.0 (leader) to 1.0 (last place). +/// Leaders get weaker items, trailing players get stronger ones. +pub fn select_powerup_for_position(quality: f32, rng: &mut impl Rng) -> PowerUpKind { + if quality < 0.3 { + // Leader tier: moderate items + let options = [ + PowerUpKind::HolyWater, + PowerUpKind::DoubleJump, + PowerUpKind::WhipExtend, + ]; + options[rng.random_range(0..options.len())] + } else if quality <= 0.7 { + // Middle tier: balanced mix + let options = [ + PowerUpKind::SpeedBoots, + PowerUpKind::DoubleJump, + PowerUpKind::HolyWater, + PowerUpKind::WhipExtend, + ]; + options[rng.random_range(0..options.len())] + } else { + // Last place tier: powerful items + let options = [ + PowerUpKind::Crucifix, + PowerUpKind::Invincibility, + PowerUpKind::SpeedBoots, + PowerUpKind::ArmorUp, + ]; + options[rng.random_range(0..options.len())] + } +} + #[cfg(test)] mod tests { use super::*; + use rand::SeedableRng; + use rand::rngs::StdRng; #[test] - fn speed_boost_expires() { - let mut pu = ActivePowerUp::new(PowerUpKind::SpeedBoost); + fn speed_boots_expires() { + let mut pu = ActivePowerUp::new(PowerUpKind::SpeedBoots); assert!(!pu.is_expired()); - pu.tick(4.0); + pu.tick(6.0); assert!(pu.is_expired()); } #[test] - fn shield_does_not_expire() { - let mut pu = ActivePowerUp::new(PowerUpKind::Shield); - pu.tick(100.0); - assert!(!pu.is_expired(), "Shield should not expire with time"); + fn double_jump_does_not_expire() { + let mut pu = ActivePowerUp::new(PowerUpKind::DoubleJump); + pu.tick(1000.0); + assert!(!pu.is_expired(), "DoubleJump should not expire with time"); } #[test] - fn double_jump_persists() { - let mut pu = ActivePowerUp::new(PowerUpKind::DoubleJump); + fn armor_up_does_not_expire() { + let mut pu = ActivePowerUp::new(PowerUpKind::ArmorUp); pu.tick(1000.0); + assert!(!pu.is_expired(), "ArmorUp should not expire with time"); + } + + #[test] + fn invincibility_expires() { + let mut pu = ActivePowerUp::new(PowerUpKind::Invincibility); assert!(!pu.is_expired()); + pu.tick(4.0); + assert!(pu.is_expired()); + } + + #[test] + fn whip_extend_expires() { + let mut pu = ActivePowerUp::new(PowerUpKind::WhipExtend); + assert!(!pu.is_expired()); + pu.tick(11.0); + assert!(pu.is_expired()); + } + + #[test] + fn holy_water_instant() { + let pu = ActivePowerUp::new(PowerUpKind::HolyWater); + assert!(pu.is_expired(), "HolyWater should be instant (0s duration)"); + } + + #[test] + fn crucifix_instant() { + let pu = ActivePowerUp::new(PowerUpKind::Crucifix); + assert!(pu.is_expired(), "Crucifix should be instant (0s duration)"); + } + + #[test] + fn leader_gets_moderate_items() { + let mut rng = StdRng::seed_from_u64(42); + for _ in 0..50 { + let kind = select_powerup_for_position(0.1, &mut rng); + // Leaders should NOT get Crucifix, Invincibility, ArmorUp + assert!( + !matches!( + kind, + PowerUpKind::Crucifix | PowerUpKind::Invincibility | PowerUpKind::ArmorUp + ), + "Leader should not get powerful items, got {:?}", + kind, + ); + } + } + + #[test] + fn last_place_gets_powerful_items() { + let mut rng = StdRng::seed_from_u64(42); + for _ in 0..50 { + let kind = select_powerup_for_position(0.9, &mut rng); + // Last place should NOT get HolyWater, DoubleJump, WhipExtend + assert!( + !matches!( + kind, + PowerUpKind::HolyWater | PowerUpKind::DoubleJump | PowerUpKind::WhipExtend + ), + "Last place should not get weak items, got {:?}", + kind, + ); + } + } + + #[test] + fn middle_tier_selection() { + let mut rng = StdRng::seed_from_u64(42); + let mut seen = std::collections::HashSet::new(); + for _ in 0..100 { + let kind = select_powerup_for_position(0.5, &mut rng); + seen.insert(format!("{:?}", kind)); + } + // Middle tier should produce at least 2 different kinds + assert!( + seen.len() >= 2, + "Middle tier should produce variety, got {:?}", + seen, + ); } } diff --git a/crates/games/breakpoint-platformer/src/rubber_band.rs b/crates/games/breakpoint-platformer/src/rubber_band.rs new file mode 100644 index 0000000..ba59486 --- /dev/null +++ b/crates/games/breakpoint-platformer/src/rubber_band.rs @@ -0,0 +1,238 @@ +use std::collections::HashMap; + +use breakpoint_core::game_trait::PlayerId; +use serde::{Deserialize, Serialize}; + +use crate::physics::PlatformerPlayerState; + +/// Rubber-banding factors applied per-player to keep the race competitive. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RubberBandFactor { + /// Multiplier for enemy density near this player (higher = more enemies). + pub enemy_density_mult: f32, + /// Quality tier for power-up selection (0.0 = leader/weak items, 1.0 = last/strong items). + pub powerup_quality: f32, +} + +impl Default for RubberBandFactor { + fn default() -> Self { + Self { + enemy_density_mult: 1.0, + powerup_quality: 0.5, + } + } +} + +/// Compute rubber-banding factors for all players based on their room distance. +/// +/// Players are ranked by current_room_distance (highest = leader, rank 0). +/// Ties are broken by last_checkpoint_id. Dead or eliminated players are ignored. +/// If only 1 active player exists, defaults are returned for everyone. +pub fn compute_rubber_band( + players: &HashMap, +) -> HashMap { + let mut result = HashMap::new(); + + // Collect active players with room distance and checkpoint for tiebreaking + let mut active: Vec<(PlayerId, u16, u16)> = players + .iter() + .filter(|(_, p)| !p.eliminated && p.death_respawn_timer <= 0.0) + .map(|(&id, p)| (id, p.current_room_distance, p.last_checkpoint_id)) + .collect(); + + // If 0 or 1 active player, return defaults for all + if active.len() <= 1 { + for &id in players.keys() { + result.insert(id, RubberBandFactor::default()); + } + return result; + } + + // Sort by room distance descending (leader first), tiebreak by checkpoint_id + active.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.2.cmp(&a.2))); + + let n = active.len(); + for (rank, &(pid, _, _)) in active.iter().enumerate() { + // Normalize rank to [0, 1] where 0 = leader, 1 = last + let t = if n > 1 { + rank as f32 / (n - 1) as f32 + } else { + 0.5 + }; + + // Leader: enemy_density_mult = 1.5, powerup_quality = 0.0 + // Last: enemy_density_mult = 0.7, powerup_quality = 1.0 + let enemy_density_mult = 1.5 + t * (0.7 - 1.5); // lerp from 1.5 to 0.7 + let powerup_quality = t; // 0.0 for leader, 1.0 for last + + result.insert( + pid, + RubberBandFactor { + enemy_density_mult, + powerup_quality, + }, + ); + } + + // Assign defaults to inactive players (dead/eliminated) + for &id in players.keys() { + result.entry(id).or_insert_with(RubberBandFactor::default); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::physics::PlatformerPlayerState; + + fn make_player_at_distance(dist: u16) -> PlatformerPlayerState { + let mut p = PlatformerPlayerState::new(5.0, 5.0); + p.eliminated = false; + p.current_room_distance = dist; + p + } + + #[test] + fn single_player_gets_defaults() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(3)); + + let factors = compute_rubber_band(&players); + + let f = &factors[&1]; + assert!( + (f.enemy_density_mult - 1.0).abs() < 0.01, + "Single player should get default density" + ); + assert!( + (f.powerup_quality - 0.5).abs() < 0.01, + "Single player should get default quality" + ); + } + + #[test] + fn leader_gets_harder_enemies_weaker_items() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); // leader + players.insert(2, make_player_at_distance(2)); // last + + let factors = compute_rubber_band(&players); + + let leader = &factors[&1]; + let last = &factors[&2]; + + assert!( + leader.enemy_density_mult > last.enemy_density_mult, + "Leader should face more enemies: {} vs {}", + leader.enemy_density_mult, + last.enemy_density_mult, + ); + assert!( + leader.powerup_quality < last.powerup_quality, + "Leader should get weaker items: {} vs {}", + leader.powerup_quality, + last.powerup_quality, + ); + } + + #[test] + fn three_players_interpolation() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); // leader + players.insert(2, make_player_at_distance(5)); // middle + players.insert(3, make_player_at_distance(1)); // last + + let factors = compute_rubber_band(&players); + + let leader = &factors[&1]; + let middle = &factors[&2]; + let last = &factors[&3]; + + // Enemy density: leader > middle > last + assert!(leader.enemy_density_mult > middle.enemy_density_mult); + assert!(middle.enemy_density_mult > last.enemy_density_mult); + + // Powerup quality: leader < middle < last + assert!(leader.powerup_quality < middle.powerup_quality); + assert!(middle.powerup_quality < last.powerup_quality); + } + + #[test] + fn eliminated_players_ignored_in_ranking() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); // leader + let mut elim = make_player_at_distance(10); // would be "leader" but eliminated + elim.eliminated = true; + players.insert(2, elim); + players.insert(3, make_player_at_distance(2)); // last + + let factors = compute_rubber_band(&players); + + // Player 1 is leader among active players + assert!( + factors[&1].powerup_quality < factors[&3].powerup_quality, + "Active leader should have lower powerup quality than active last" + ); + + // Eliminated player gets default + assert!( + (factors[&2].enemy_density_mult - 1.0).abs() < 0.01, + "Eliminated player should get default density" + ); + } + + #[test] + fn dead_players_ignored_in_ranking() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); + let mut dead = make_player_at_distance(10); + dead.death_respawn_timer = 1.5; // currently dead, awaiting respawn + players.insert(2, dead); + players.insert(3, make_player_at_distance(2)); + + let factors = compute_rubber_band(&players); + + // Dead player gets default + assert!( + (factors[&2].enemy_density_mult - 1.0).abs() < 0.01, + "Dead player should get default density" + ); + } + + #[test] + fn empty_players_returns_empty() { + let players: HashMap = HashMap::new(); + let factors = compute_rubber_band(&players); + assert!(factors.is_empty()); + } + + #[test] + fn leader_density_is_1_5() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); + players.insert(2, make_player_at_distance(2)); + + let factors = compute_rubber_band(&players); + assert!( + (factors[&1].enemy_density_mult - 1.5).abs() < 0.01, + "Leader density should be 1.5, got {}", + factors[&1].enemy_density_mult, + ); + } + + #[test] + fn last_density_is_0_7() { + let mut players = HashMap::new(); + players.insert(1, make_player_at_distance(8)); + players.insert(2, make_player_at_distance(2)); + + let factors = compute_rubber_band(&players); + assert!( + (factors[&2].enemy_density_mult - 0.7).abs() < 0.01, + "Last place density should be 0.7, got {}", + factors[&2].enemy_density_mult, + ); + } +} diff --git a/crates/games/breakpoint-platformer/src/scoring.rs b/crates/games/breakpoint-platformer/src/scoring.rs index 9cc4bc9..965f5d8 100644 --- a/crates/games/breakpoint-platformer/src/scoring.rs +++ b/crates/games/breakpoint-platformer/src/scoring.rs @@ -1,8 +1,11 @@ -/// Calculate a player's score in Race mode. +use crate::combat::DEATH_TIME_PENALTY; + +/// Calculate a player's score in Race mode with death penalty. /// /// Scoring: 1st = 10, 2nd = 7, 3rd = 5, 4th = 4, 5th = 3, 6th = 2, rest = 1, DNF = 0. -pub fn race_score(finish_position: Option) -> i32 { - match finish_position { +/// Death penalty: subtract 0.5 per death (minimum final score of 0). +pub fn race_score(finish_position: Option, deaths: u8) -> i32 { + let base = match finish_position { Some(0) => 10, Some(1) => 7, Some(2) => 5, @@ -11,17 +14,16 @@ pub fn race_score(finish_position: Option) -> i32 { Some(5) => 2, Some(_) => 1, None => 0, - } + }; + let penalty = (deaths as f32 * 0.5).floor() as i32; + (base - penalty).max(0) } -/// Calculate a player's score in Survival mode. +/// Calculate the effective finish time including death time penalties. /// -/// Scoring: last alive = N points (N = total players), first eliminated = 1 point. -pub fn survival_score(elimination_order: Option, total_players: usize) -> i32 { - match elimination_order { - Some(order) => (total_players - order) as i32, - None => total_players as i32, // survived to the end - } +/// Each death adds `DEATH_TIME_PENALTY` seconds to the actual finish time. +pub fn finish_time_with_penalty(actual_time: f32, deaths: u8) -> f32 { + actual_time + deaths as f32 * DEATH_TIME_PENALTY } #[cfg(test)] @@ -29,20 +31,36 @@ mod tests { use super::*; #[test] - fn race_positions() { - assert_eq!(race_score(Some(0)), 10); - assert_eq!(race_score(Some(1)), 7); - assert_eq!(race_score(Some(2)), 5); - assert_eq!(race_score(Some(5)), 2); - assert_eq!(race_score(Some(10)), 1); - assert_eq!(race_score(None), 0); + fn race_positions_no_deaths() { + assert_eq!(race_score(Some(0), 0), 10); + assert_eq!(race_score(Some(1), 0), 7); + assert_eq!(race_score(Some(2), 0), 5); + assert_eq!(race_score(Some(5), 0), 2); + assert_eq!(race_score(Some(10), 0), 1); + assert_eq!(race_score(None, 0), 0); + } + + #[test] + fn race_score_with_deaths() { + // 1st place with 2 deaths: 10 - 1 = 9 + assert_eq!(race_score(Some(0), 2), 9); + // 1st place with 4 deaths: 10 - 2 = 8 + assert_eq!(race_score(Some(0), 4), 8); + // 6th place with 6 deaths: 2 - 3 = clamped to 0 + assert_eq!(race_score(Some(5), 6), 0); + } + + #[test] + fn race_score_never_negative() { + // Even with many deaths, score should not go below 0 + assert_eq!(race_score(Some(0), 100), 0); + assert_eq!(race_score(None, 10), 0); } #[test] - fn survival_scoring() { - // 4 players: last alive gets 4 pts, first eliminated gets 3 pts - assert_eq!(survival_score(None, 4), 4); // survived - assert_eq!(survival_score(Some(0), 4), 4); // eliminated first - assert_eq!(survival_score(Some(3), 4), 1); // eliminated last + fn finish_time_penalty_calculation() { + assert!((finish_time_with_penalty(60.0, 0) - 60.0).abs() < 0.001); + assert!((finish_time_with_penalty(60.0, 2) - 66.0).abs() < 0.001); + assert!((finish_time_with_penalty(90.0, 5) - 105.0).abs() < 0.001); } } diff --git a/scripts/build_atlas.py b/scripts/build_atlas.py new file mode 100644 index 0000000..a224426 --- /dev/null +++ b/scripts/build_atlas.py @@ -0,0 +1,1836 @@ +#!/usr/bin/env python3 +""" +Build the 1024x512 platformer sprite atlas and parallax background texture. + +Generates GothicVania-style pixel art sprites with distinct silhouettes and +detail for each character type, tile, and effect. Outputs: + - web/assets/sprites/platformer_atlas.png (1024x512 RGBA) + - web/assets/sprites/platformer_atlas.json (sprite name -> rect mapping) + - web/assets/sprites/platformer_bg.png (512x512, 3 layers stacked) + +Usage: + python scripts/build_atlas.py +""" + +import json +import math +import os +import random +import sys + +try: + from PIL import Image, ImageDraw +except ImportError: + print("Pillow is required: pip install Pillow", file=sys.stderr) + sys.exit(1) + +ATLAS_W = 1024 +ATLAS_H = 512 +CELL = 16 # Base cell size +OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "web", "assets", "sprites") + +# Sprite definitions: (name, x, y, w, h) +SPRITES = [] + + +def add_frames(prefix, count, x_start, y, w, h): + """Add numbered animation frames.""" + for i in range(count): + SPRITES.append((f"{prefix}_{i}", x_start + i * w, y, w, h)) + + +# --- Player sprites (16x32) — Row 0 (Y=0) --- +add_frames("player_idle", 8, 0, 0, 16, 32) +add_frames("player_walk", 8, 128, 0, 16, 32) +add_frames("player_run", 8, 256, 0, 16, 32) +add_frames("player_jump", 4, 384, 0, 16, 32) +add_frames("player_fall", 4, 448, 0, 16, 32) + +# --- Player sprites (16x32) — Row 1 (Y=32) --- +add_frames("player_attack", 8, 0, 32, 16, 32) +add_frames("player_hurt", 4, 128, 32, 16, 32) +add_frames("player_dead", 6, 192, 32, 16, 32) +add_frames("player_wall_slide", 3, 288, 32, 16, 32) +add_frames("player_crouch", 3, 336, 32, 16, 32) +add_frames("player_dash", 4, 384, 32, 16, 32) + +# --- Enemy sprites (16x32) — Row 0 (Y=64) --- +add_frames("skeleton_walk", 4, 0, 64, 16, 32) +add_frames("skeleton_attack", 3, 64, 64, 16, 32) +add_frames("skeleton_death", 4, 112, 64, 16, 32) +add_frames("bat_fly", 4, 176, 64, 16, 32) +add_frames("bat_death", 2, 240, 64, 16, 32) + +# --- Enemy sprites (16x32) — Row 1 (Y=96) --- +add_frames("knight_walk", 4, 0, 96, 16, 32) +add_frames("knight_attack", 3, 64, 96, 16, 32) +add_frames("knight_death", 4, 112, 96, 16, 32) +add_frames("medusa_float", 4, 176, 96, 16, 32) +add_frames("medusa_death", 2, 240, 96, 16, 32) + +# --- Enemy sprites (16x32) — Row 2 (Y=128) --- +add_frames("ghost_drift", 4, 0, 128, 16, 32) +add_frames("ghost_phase", 3, 64, 128, 16, 32) +add_frames("ghost_death", 3, 112, 128, 16, 32) +add_frames("gargoyle_perch", 2, 160, 128, 16, 32) +add_frames("gargoyle_swoop", 4, 192, 128, 16, 32) +add_frames("gargoyle_death", 3, 256, 128, 16, 32) +add_frames("projectile", 3, 304, 128, 16, 16) + +# --- Bitmask tiles (Y 160-208): 16 tiles per group, 4 groups --- +# Matches sprite_atlas.rs: add_bitmask_tiles(sheet, group, 0, Y, 16) +for group_idx, group_name in enumerate(["castle", "underground", "sacred", "fortress"]): + base_y = 160 + group_idx * 16 + for i in range(16): + SPRITES.append((f"{group_name}_tile_{i}", i * 16, base_y, 16, 16)) + +# Theme-specific decorative tiles (X=256 in each row) +deco_x = 256 +SPRITES.append(("castle_bookshelf", deco_x, 160, 16, 16)) +SPRITES.append(("castle_banner", deco_x + 16, 160, 16, 16)) +SPRITES.append(("castle_pillar_top", deco_x + 32, 160, 16, 16)) +SPRITES.append(("castle_pillar_mid", deco_x + 48, 160, 16, 16)) +SPRITES.append(("underground_coffin", deco_x, 176, 16, 16)) +SPRITES.append(("underground_bones", deco_x + 16, 176, 16, 16)) +SPRITES.append(("underground_mushroom", deco_x + 32, 176, 16, 16)) +SPRITES.append(("sacred_altar", deco_x, 192, 16, 16)) +SPRITES.append(("sacred_candle", deco_x + 16, 192, 16, 16)) +SPRITES.append(("sacred_rune", deco_x + 32, 192, 16, 16)) +SPRITES.append(("fortress_weapon_rack", deco_x, 208, 16, 16)) +SPRITES.append(("fortress_anvil", deco_x + 16, 208, 16, 16)) +SPRITES.append(("fortress_shield", deco_x + 32, 208, 16, 16)) + +# Legacy stone_brick aliases (at Y=160, same as castle bitmask tiles) +# These overlap with castle_tile_* but the draw function only runs once per position +for idx, name in enumerate([ + "stone_brick_top", "stone_brick_inner", "stone_brick_left", + "stone_brick_right", "stone_brick_top_left", "stone_brick_top_right", + "stone_brick_bottom_left", "stone_brick_bottom_right", +]): + # Map legacy names to the same atlas positions as castle_tile_* + # stone_brick_top maps to castle_tile_14, etc. (matching sprite_atlas.rs aliases) + SPRITES.append((name, idx * 16, 160, 16, 16)) + +# --- Shared tiles (Y=224): platforms, spikes, checkpoints, etc. --- +tile_x = 0 +tile_y = 224 +add_frames("platform", 3, tile_x, tile_y, 16, 16) +tile_x += 48 +add_frames("spikes", 2, tile_x, tile_y, 16, 16) +tile_x += 32 + +add_frames("checkpoint_flag_down", 2, tile_x, tile_y, 16, 16) +tile_x += 32 +add_frames("checkpoint_flag_up", 2, tile_x, tile_y, 16, 16) +tile_x += 32 +add_frames("finish_gate", 2, tile_x, tile_y, 16, 16) +tile_x += 32 +SPRITES.append(("ladder", tile_x, tile_y, 16, 16)) +tile_x += 16 +add_frames("breakable_wall", 2, tile_x, tile_y, 16, 16) +tile_x += 32 +add_frames("torch", 4, tile_x, tile_y, 16, 16) +tile_x += 64 +SPRITES.append(("stained_glass", tile_x, tile_y, 16, 16)) +tile_x += 16 + +# Water tiles +SPRITES.append(("water_surface", tile_x, tile_y, 16, 16)) +tile_x += 16 +SPRITES.append(("water_body", tile_x, tile_y, 16, 16)) +tile_x += 16 + +# Decorative tiles +SPRITES.append(("cobweb", tile_x, tile_y, 16, 16)) +tile_x += 16 +SPRITES.append(("chain_0", tile_x, tile_y, 16, 16)) +tile_x += 16 +SPRITES.append(("chain_1", tile_x, tile_y, 16, 16)) + +# --- Power-ups + HUD (Y=288) --- +pu_x = 0 +pu_y = 288 +for name in [ + "powerup_holy_water", "powerup_crucifix", "powerup_speed_boots", + "powerup_double_jump", "powerup_armor", "powerup_invincibility", + "powerup_whip_extend", +]: + SPRITES.append((name, pu_x, pu_y, 16, 16)) + pu_x += 16 + +SPRITES.append(("heart_full", 0, 304, 16, 16)) +SPRITES.append(("heart_empty", 16, 304, 16, 16)) + +prop_x = 32 +prop_y = 304 +for name in ["prop_candelabra", "prop_cross", "prop_gravestone"]: + SPRITES.append((name, prop_x, prop_y, 16, 16)) + prop_x += 16 + +# --- Particle sprites (Y=352) --- +part_x = 0 +part_y = 352 +add_frames("particle_dust", 4, part_x, part_y, 8, 8) +part_x += 32 +add_frames("particle_spark", 3, part_x, part_y, 8, 8) +part_x += 24 +add_frames("particle_blood", 3, part_x, part_y, 8, 8) +part_x += 24 +add_frames("particle_fire", 4, part_x, part_y, 8, 8) +part_x += 32 +add_frames("particle_magic", 3, part_x, part_y, 8, 8) +part_x += 24 +add_frames("particle_smoke", 3, part_x, part_y, 8, 8) +part_x += 24 +add_frames("particle_debris", 3, part_x, part_y, 8, 8) +# Additional particle sprites (Phase 8) +part_x += 24 +add_frames("particle_water", 3, part_x, part_y, 8, 8) +part_x += 24 +add_frames("particle_ember", 3, part_x, part_y, 8, 8) + +# --- Ambient particle sprites (8x8, Y=356) --- +add_frames("particle_sparkle", 2, 0, 356, 8, 8) +add_frames("particle_snowflake", 2, 16, 356, 8, 8) +add_frames("particle_page", 2, 32, 356, 8, 8) + +# --- VFX sprites (32x32, Y=384) --- +add_frames("vfx_slash", 5, 0, 384, 32, 32) +add_frames("vfx_magic_circle", 4, 160, 384, 32, 32) +add_frames("vfx_hit_spark", 4, 288, 384, 32, 32) + +# ═══════════════════════════════════════════════════════════════════ +# GothicVania-style pixel art generation +# ═══════════════════════════════════════════════════════════════════ + +# Gothic color palette +PAL = { + "skin": (220, 190, 160), + "skin_shadow": (180, 150, 120), + "hair_dark": (40, 30, 50), + "cape_red": (160, 40, 40), + "cape_shadow": (100, 25, 30), + "armor_steel": (160, 160, 170), + "armor_shadow": (100, 100, 115), + "cloth_dark": (50, 40, 60), + "cloth_mid": (80, 60, 90), + "boots": (60, 40, 30), + "bone": (220, 210, 190), + "bone_shadow": (170, 160, 140), + "bone_dark": (130, 120, 100), + "bat_purple": (90, 50, 110), + "bat_wing": (120, 70, 140), + "knight_steel": (140, 145, 160), + "knight_shadow": (90, 95, 110), + "knight_gold": (200, 170, 60), + "medusa_green": (60, 140, 80), + "medusa_scale": (40, 100, 60), + "medusa_hair": (80, 160, 100), + "stone_light": (120, 110, 100), + "stone_mid": (90, 82, 75), + "stone_dark": (65, 58, 52), + "stone_mortar": (75, 70, 62), + "wood_light": (140, 105, 65), + "wood_mid": (110, 80, 50), + "wood_dark": (80, 55, 35), + "spike_metal": (140, 50, 50), + "spike_tip": (200, 80, 80), + "gold": (240, 200, 60), + "gold_shadow": (200, 160, 40), + "cyan": (60, 200, 220), + "cyan_shadow": (40, 150, 170), + "fire_bright": (255, 220, 80), + "fire_mid": (240, 160, 40), + "fire_dark": (200, 80, 20), + "glass_purple": (160, 80, 180), + "glass_blue": (80, 120, 200), + "glass_lead": (60, 60, 70), + "water_surface": (60, 140, 200), + "water_body": (40, 90, 160), + "water_highlight": (100, 180, 240), +} + +# Theme-specific tile palettes for bitmask tiles +TILE_PALETTES = { + "castle": { + "light": (120, 110, 100), + "mid": (90, 82, 75), + "dark": (65, 58, 52), + "mortar": (75, 70, 62), + "accent": (110, 90, 80), + }, + "underground": { + "light": (80, 110, 95), + "mid": (55, 85, 70), + "dark": (35, 60, 50), + "mortar": (45, 70, 58), + "accent": (70, 100, 85), + }, + "sacred": { + "light": (130, 120, 95), + "mid": (100, 90, 70), + "dark": (70, 62, 50), + "mortar": (85, 78, 60), + "accent": (140, 125, 80), + }, + "fortress": { + "light": (115, 120, 130), + "mid": (85, 90, 100), + "dark": (60, 65, 75), + "mortar": (70, 75, 85), + "accent": (100, 105, 120), + }, +} + + +def px(draw, x, y, color, alpha=255): + """Draw a single pixel with optional alpha.""" + if alpha < 255: + draw.point((x, y), fill=color + (alpha,)) + else: + draw.point((x, y), fill=color + (255,)) + + +def rect(draw, x, y, w, h, color, alpha=255): + """Draw a filled rectangle.""" + c = color + (alpha,) + draw.rectangle([x, y, x + w - 1, y + h - 1], fill=c) + + +def outline_rect(draw, x, y, w, h, color): + """Draw an outlined rectangle.""" + draw.rectangle([x, y, x + w - 1, y + h - 1], outline=color + (255,)) + + +# ── Player sprite drawing ────────────────────────────────────────── + +def draw_player_idle(draw, bx, by, frame): + """Castlevania hero: cape, whip at side, standing pose.""" + bob = [0, 0, -1, -1, 0, 0][frame % 6] + y = by + bob + # Boots + rect(draw, bx + 4, y + 26, 3, 4, PAL["boots"]) + rect(draw, bx + 9, y + 26, 3, 4, PAL["boots"]) + # Legs + rect(draw, bx + 5, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 9, y + 22, 2, 5, PAL["cloth_dark"]) + # Torso + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 5, y + 15, 6, 6, PAL["cloth_dark"]) + # Cape (behind) + rect(draw, bx + 2, y + 12, 3, 14, PAL["cape_red"]) + rect(draw, bx + 2, y + 14, 2, 12, PAL["cape_shadow"]) + # Belt + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + # Arms + rect(draw, bx + 3, y + 15, 2, 6, PAL["skin"]) + rect(draw, bx + 11, y + 15, 2, 6, PAL["skin"]) + # Head + rect(draw, bx + 5, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 5, y + 7, 6, 6, PAL["skin_shadow"]) + # Hair + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 4, y + 6, 2, 6, PAL["hair_dark"]) + # Eyes + px(draw, bx + 7, y + 9, (255, 255, 255)) + px(draw, bx + 10, y + 9, (255, 255, 255)) + px(draw, bx + 8, y + 9, (30, 30, 60)) + px(draw, bx + 11, y + 9, (30, 30, 60)) + + +def draw_player_walk(draw, bx, by, frame): + """Walking animation with leg alternation.""" + leg_offset = [0, 1, 2, 1, 0, -1][frame % 6] + y = by + # Boots (alternating) + rect(draw, bx + 4 + leg_offset, y + 26, 3, 4, PAL["boots"]) + rect(draw, bx + 9 - leg_offset, y + 26, 3, 4, PAL["boots"]) + # Legs + rect(draw, bx + 5 + leg_offset, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 9 - leg_offset, y + 22, 2, 5, PAL["cloth_dark"]) + # Body (same as idle) + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 5, y + 15, 6, 6, PAL["cloth_dark"]) + # Cape billowing + cape_w = 3 + abs(leg_offset) + rect(draw, bx + 1, y + 12, cape_w, 14, PAL["cape_red"]) + rect(draw, bx + 1, y + 14, cape_w - 1, 12, PAL["cape_shadow"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + rect(draw, bx + 3, y + 15, 2, 6, PAL["skin"]) + rect(draw, bx + 11, y + 15, 2, 5, PAL["skin"]) + # Head + rect(draw, bx + 5, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 4, y + 6, 2, 6, PAL["hair_dark"]) + px(draw, bx + 7, y + 9, (255, 255, 255)) + px(draw, bx + 10, y + 9, (255, 255, 255)) + px(draw, bx + 8, y + 9, (30, 30, 60)) + px(draw, bx + 11, y + 9, (30, 30, 60)) + + +def draw_player_jump(draw, bx, by, frame): + """Jump: stretched pose, arms up.""" + y = by + [-1, -2, -1][frame % 3] + rect(draw, bx + 5, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 9, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 5, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 9, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 2, y + 14, 3, 10, PAL["cape_red"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + # Arms raised + rect(draw, bx + 3, y + 12, 2, 4, PAL["skin"]) + rect(draw, bx + 11, y + 12, 2, 4, PAL["skin"]) + rect(draw, bx + 5, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 4, y + 6, 2, 6, PAL["hair_dark"]) + px(draw, bx + 7, y + 9, (255, 255, 255)) + px(draw, bx + 10, y + 9, (255, 255, 255)) + + +def draw_player_fall(draw, bx, by, frame): + """Falling: legs spread, cape billowing up.""" + y = by + [0, 1, 0][frame % 3] + rect(draw, bx + 3, y + 26, 3, 4, PAL["boots"]) + rect(draw, bx + 10, y + 26, 3, 4, PAL["boots"]) + rect(draw, bx + 4, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 10, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + # Cape billowing up + rect(draw, bx + 1, y + 10, 4, 12, PAL["cape_red"]) + rect(draw, bx + 1, y + 8, 3, 6, PAL["cape_shadow"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + rect(draw, bx + 3, y + 16, 2, 5, PAL["skin"]) + rect(draw, bx + 11, y + 16, 2, 5, PAL["skin"]) + rect(draw, bx + 5, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + px(draw, bx + 7, y + 9, (255, 255, 255)) + px(draw, bx + 10, y + 9, (255, 255, 255)) + + +def draw_player_attack(draw, bx, by, frame): + """Whip attack: arm extended with whip arc.""" + y = by + rect(draw, bx + 5, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 9, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 5, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 9, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 2, y + 14, 3, 10, PAL["cape_red"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + # Extended arm + whip + arm_ext = min(frame, 4) + rect(draw, bx + 11, y + 15, 2 + arm_ext, 2, PAL["skin"]) + # Whip + whip_len = min(frame * 2, 8) + if whip_len > 0: + for i in range(whip_len): + wx = bx + 13 + arm_ext + i + wy = y + 14 + (i * i) // 6 + if wx < bx + 16 and wy < by + 32: + px(draw, wx, wy, PAL["wood_light"]) + rect(draw, bx + 3, y + 16, 2, 5, PAL["skin"]) + rect(draw, bx + 5, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 4, y + 6, 2, 6, PAL["hair_dark"]) + px(draw, bx + 7, y + 9, (255, 255, 255)) + px(draw, bx + 10, y + 9, (255, 255, 255)) + + +def draw_player_hurt(draw, bx, by, frame): + """Hurt: recoil pose, red flash.""" + y = by + [1, -1][frame % 2] + rect(draw, bx + 6, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 8, y + 26, 2, 4, PAL["boots"]) + rect(draw, bx + 6, y + 22, 4, 5, PAL["cloth_dark"]) + rect(draw, bx + 4, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 2, y + 14, 3, 10, PAL["cape_red"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["gold_shadow"]) + rect(draw, bx + 3, y + 16, 2, 5, (220, 160, 160)) + rect(draw, bx + 11, y + 16, 2, 5, (220, 160, 160)) + rect(draw, bx + 5, y + 6, 6, 8, (220, 180, 170)) + rect(draw, bx + 4, y + 4, 8, 4, PAL["hair_dark"]) + px(draw, bx + 7, y + 9, (255, 100, 100)) + px(draw, bx + 10, y + 9, (255, 100, 100)) + + +def draw_player_dead(draw, bx, by, frame): + """Dead: falling over, then lying flat.""" + tilt = min(frame, 3) + y = by + if tilt < 2: + # Falling over + rect(draw, bx + 5, y + 26, 6, 4, PAL["boots"]) + rect(draw, bx + 4, y + 14 + tilt * 3, 8, 12 - tilt * 3, PAL["cloth_dark"]) + rect(draw, bx + 2, y + 16 + tilt * 2, 3, 8, PAL["cape_red"]) + rect(draw, bx + 5, y + 6 + tilt * 2, 6, 8, PAL["skin"]) + rect(draw, bx + 4, y + 4 + tilt * 2, 8, 4, PAL["hair_dark"]) + else: + # Lying flat on ground + rect(draw, bx + 2, y + 26, 12, 4, PAL["cape_red"]) + rect(draw, bx + 3, y + 24, 10, 3, PAL["cloth_dark"]) + rect(draw, bx + 3, y + 22, 6, 3, PAL["skin"]) + rect(draw, bx + 2, y + 21, 8, 2, PAL["hair_dark"]) + + +def draw_player_run(draw, bx, by, frame): + """Running: leaned forward with speed lines.""" + leg = [0, 2, 3, 2, 0, -2, -3, -2][frame % 8] + y = by + # Boots (wide stride) + rect(draw, bx + 3 + leg, y + 26, 3, 4, PAL["boots"]) + rect(draw, bx + 10 - leg, y + 26, 3, 4, PAL["boots"]) + # Legs + rect(draw, bx + 4 + leg, y + 22, 2, 5, PAL["cloth_dark"]) + rect(draw, bx + 10 - leg, y + 22, 2, 5, PAL["cloth_dark"]) + # Body (leaned forward) + rect(draw, bx + 5, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 6, y + 15, 6, 6, PAL["cloth_dark"]) + # Cape streaming behind + cape_w = 4 + abs(leg) + rect(draw, bx + 1, y + 12, cape_w, 14, PAL["cape_red"]) + rect(draw, bx + 1, y + 14, cape_w - 1, 12, PAL["cape_shadow"]) + rect(draw, bx + 5, y + 21, 8, 1, PAL["gold_shadow"]) + # Arms pumping + rect(draw, bx + 3, y + 14, 2, 6, PAL["skin"]) + rect(draw, bx + 12, y + 16, 2, 4, PAL["skin"]) + # Head (leaned) + rect(draw, bx + 6, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 5, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 5, y + 6, 2, 6, PAL["hair_dark"]) + px(draw, bx + 8, y + 9, (255, 255, 255)) + px(draw, bx + 11, y + 9, (255, 255, 255)) + # Speed lines + if frame % 2 == 0: + px(draw, bx + 1, y + 18, (200, 200, 220), 120) + px(draw, bx + 0, y + 20, (200, 200, 220), 80) + + +def draw_player_wall_slide(draw, bx, by, frame): + """Wall slide: pressed against wall, sliding down.""" + y = by + [0, 1, 0][frame % 3] + # Boots pressed against wall + rect(draw, bx + 10, y + 27, 3, 3, PAL["boots"]) + rect(draw, bx + 10, y + 24, 3, 4, PAL["boots"]) + # Legs together against wall + rect(draw, bx + 9, y + 22, 3, 6, PAL["cloth_dark"]) + # Body pressed to right side + rect(draw, bx + 6, y + 14, 7, 8, PAL["cloth_mid"]) + rect(draw, bx + 7, y + 15, 5, 6, PAL["cloth_dark"]) + # Cape hanging away from wall + rect(draw, bx + 2, y + 13, 4, 13, PAL["cape_red"]) + rect(draw, bx + 2, y + 15, 3, 11, PAL["cape_shadow"]) + rect(draw, bx + 6, y + 21, 7, 1, PAL["gold_shadow"]) + # Arms reaching up + rect(draw, bx + 11, y + 10, 2, 5, PAL["skin"]) + rect(draw, bx + 5, y + 16, 2, 5, PAL["skin"]) + # Head looking up + rect(draw, bx + 7, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 6, y + 4, 8, 4, PAL["hair_dark"]) + px(draw, bx + 9, y + 9, (255, 255, 255)) + px(draw, bx + 11, y + 9, (255, 255, 255)) + + +def draw_player_crouch(draw, bx, by, frame): + """Crouch: squished down pose.""" + y = by + [0, 0, 1][frame % 3] + # Boots (wider stance) + rect(draw, bx + 3, y + 27, 3, 3, PAL["boots"]) + rect(draw, bx + 10, y + 27, 3, 3, PAL["boots"]) + # Legs (bent) + rect(draw, bx + 4, y + 24, 3, 4, PAL["cloth_dark"]) + rect(draw, bx + 9, y + 24, 3, 4, PAL["cloth_dark"]) + # Body (compressed) + rect(draw, bx + 4, y + 18, 8, 6, PAL["cloth_mid"]) + rect(draw, bx + 5, y + 19, 6, 4, PAL["cloth_dark"]) + # Cape (draped low) + rect(draw, bx + 2, y + 17, 3, 10, PAL["cape_red"]) + rect(draw, bx + 2, y + 19, 2, 8, PAL["cape_shadow"]) + rect(draw, bx + 4, y + 23, 8, 1, PAL["gold_shadow"]) + # Arms + rect(draw, bx + 3, y + 19, 2, 4, PAL["skin"]) + rect(draw, bx + 11, y + 19, 2, 4, PAL["skin"]) + # Head (ducked) + rect(draw, bx + 5, y + 12, 6, 6, PAL["skin"]) + rect(draw, bx + 4, y + 10, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 4, y + 12, 2, 4, PAL["hair_dark"]) + px(draw, bx + 7, y + 14, (255, 255, 255)) + px(draw, bx + 10, y + 14, (255, 255, 255)) + + +def draw_player_dash(draw, bx, by, frame): + """Dash: horizontal streaked motion.""" + y = by + # Afterimage trail (fading) + alpha = max(0, 160 - frame * 40) + if alpha > 0: + rect(draw, bx + 0, y + 14, 4, 12, PAL["cape_red"], alpha) + rect(draw, bx + 0, y + 16, 3, 8, PAL["cape_shadow"], alpha // 2) + # Boots (together, horizontal) + rect(draw, bx + 8, y + 26, 4, 4, PAL["boots"]) + # Legs (extended back) + rect(draw, bx + 6, y + 22, 4, 5, PAL["cloth_dark"]) + # Body (horizontal lunge) + rect(draw, bx + 5, y + 14, 8, 8, PAL["cloth_mid"]) + rect(draw, bx + 6, y + 15, 6, 6, PAL["cloth_dark"]) + rect(draw, bx + 5, y + 21, 8, 1, PAL["gold_shadow"]) + # Arms (forward thrust) + rect(draw, bx + 12, y + 15, 3, 2, PAL["skin"]) + rect(draw, bx + 3, y + 17, 2, 4, PAL["skin"]) + # Head + rect(draw, bx + 6, y + 6, 6, 8, PAL["skin"]) + rect(draw, bx + 5, y + 4, 8, 4, PAL["hair_dark"]) + rect(draw, bx + 5, y + 6, 2, 6, PAL["hair_dark"]) + px(draw, bx + 8, y + 9, (255, 255, 255)) + px(draw, bx + 11, y + 9, (255, 255, 255)) + # Speed streaks + for i in range(3): + sx = bx + 1 + i + sy = y + 16 + i * 3 + px(draw, sx, sy, (220, 220, 240), 140 - i * 40) + + +# ── Enemy sprite drawing ─────────────────────────────────────────── + +def draw_skeleton(draw, bx, by, frame, action="walk"): + """Skeleton enemy: bone-colored, visible ribcage.""" + bob = [0, -1, 0, 1][frame % 4] if action == "walk" else 0 + y = by + bob + if action == "death": + # Crumbling apart + scatter = frame * 2 + for i in range(6): + ox = random.Random(frame * 10 + i).randint(-scatter, scatter) + oy = random.Random(frame * 10 + i + 50).randint(-scatter, scatter) + rect(draw, bx + 6 + ox, y + 12 + i * 3 + oy, 3, 2, PAL["bone"]) + return + # Feet + rect(draw, bx + 5, y + 28, 2, 2, PAL["bone_dark"]) + rect(draw, bx + 9, y + 28, 2, 2, PAL["bone_dark"]) + # Legs (thin bones) + px(draw, bx + 6, y + 24, PAL["bone"]) + px(draw, bx + 6, y + 25, PAL["bone"]) + px(draw, bx + 6, y + 26, PAL["bone"]) + px(draw, bx + 10, y + 24, PAL["bone"]) + px(draw, bx + 10, y + 25, PAL["bone"]) + px(draw, bx + 10, y + 26, PAL["bone"]) + # Ribcage + rect(draw, bx + 5, y + 16, 6, 8, PAL["bone_shadow"]) + for rib_y in range(3): + px(draw, bx + 6, y + 17 + rib_y * 2, PAL["bone"]) + px(draw, bx + 9, y + 17 + rib_y * 2, PAL["bone"]) + # Skull + rect(draw, bx + 5, y + 8, 6, 8, PAL["bone"]) + rect(draw, bx + 6, y + 9, 4, 6, PAL["bone_shadow"]) + # Eye sockets + px(draw, bx + 6, y + 11, (20, 10, 10)) + px(draw, bx + 9, y + 11, (20, 10, 10)) + # Red eye glow + px(draw, bx + 6, y + 11, (180, 30, 30)) + px(draw, bx + 9, y + 11, (180, 30, 30)) + # Arms + if action == "attack": + rect(draw, bx + 11, y + 17, 4, 1, PAL["bone"]) + rect(draw, bx + 14, y + 15, 1, 3, PAL["bone"]) # raised arm + else: + rect(draw, bx + 3, y + 17, 2, 5, PAL["bone_shadow"]) + rect(draw, bx + 11, y + 17, 2, 5, PAL["bone_shadow"]) + + +def draw_bat(draw, bx, by, frame, alive=True): + """Bat: dark purple body with spread wings.""" + if not alive: + # Falling with folded wings + alpha = max(0, 180 - frame * 60) + rect(draw, bx + 6, by + 16, 4, 6, PAL["bat_purple"], alpha) + return + y = by + [0, -2, -1, 1][frame % 4] + wing_spread = [4, 6, 5, 3][frame % 4] + # Body + rect(draw, bx + 6, y + 14, 4, 6, PAL["bat_purple"]) + # Wings + rect(draw, bx + 6 - wing_spread, y + 14, wing_spread, 3, PAL["bat_wing"]) + rect(draw, bx + 10, y + 14, wing_spread, 3, PAL["bat_wing"]) + # Wing membrane detail + for i in range(wing_spread): + px(draw, bx + 6 - wing_spread + i, y + 16, PAL["bat_purple"], 200) + px(draw, bx + 10 + i, y + 16, PAL["bat_purple"], 200) + # Eyes + px(draw, bx + 7, y + 15, (255, 60, 60)) + px(draw, bx + 9, y + 15, (255, 60, 60)) + # Ears + px(draw, bx + 6, y + 13, PAL["bat_purple"]) + px(draw, bx + 9, y + 13, PAL["bat_purple"]) + + +def draw_knight(draw, bx, by, frame, action="walk"): + """Armored knight: heavy steel plate, sword.""" + bob = [0, 0, -1, 0][frame % 4] if action == "walk" else 0 + y = by + bob + if action == "death": + scatter = frame * 2 + for i in range(5): + ox = random.Random(frame * 7 + i).randint(-scatter, scatter) + oy = random.Random(frame * 7 + i + 30).randint(0, scatter) + rect(draw, bx + 5 + ox, y + 14 + i * 3 + oy, 4, 3, PAL["knight_steel"]) + return + # Boots + rect(draw, bx + 4, y + 27, 3, 3, PAL["knight_shadow"]) + rect(draw, bx + 9, y + 27, 3, 3, PAL["knight_shadow"]) + # Leg armor + rect(draw, bx + 5, y + 22, 2, 6, PAL["knight_steel"]) + rect(draw, bx + 9, y + 22, 2, 6, PAL["knight_steel"]) + # Chest plate + rect(draw, bx + 4, y + 14, 8, 8, PAL["knight_steel"]) + rect(draw, bx + 5, y + 15, 6, 6, PAL["knight_shadow"]) + # Gold trim + rect(draw, bx + 4, y + 14, 8, 1, PAL["knight_gold"]) + rect(draw, bx + 4, y + 21, 8, 1, PAL["knight_gold"]) + # Helmet + rect(draw, bx + 4, y + 6, 8, 8, PAL["knight_steel"]) + rect(draw, bx + 5, y + 7, 6, 6, PAL["knight_shadow"]) + # Visor slit + rect(draw, bx + 6, y + 10, 4, 1, (20, 20, 30)) + # Plume + rect(draw, bx + 6, y + 4, 4, 3, PAL["cape_red"]) + # Shield arm + rect(draw, bx + 2, y + 15, 3, 7, PAL["knight_steel"]) + rect(draw, bx + 2, y + 16, 2, 5, PAL["knight_shadow"]) + # Sword arm + if action == "attack": + rect(draw, bx + 11, y + 12, 2, 2, PAL["skin"]) + rect(draw, bx + 12, y + 8, 1, 6, (200, 200, 210)) # raised sword + px(draw, bx + 12, y + 7, (240, 240, 255)) # sword tip + else: + rect(draw, bx + 11, y + 15, 2, 6, PAL["skin"]) + rect(draw, bx + 12, y + 20, 1, 6, (200, 200, 210)) # sword down + + +def draw_medusa(draw, bx, by, frame, alive=True): + """Medusa: serpentine body, snake hair, floating.""" + if not alive: + alpha = max(0, 200 - frame * 80) + rect(draw, bx + 4, by + 14, 8, 10, PAL["medusa_green"], alpha) + return + y = by + [0, -1, -2, -1][frame % 4] + # Serpentine lower body (tail) + wave = [0, 1, 0, -1][frame % 4] + rect(draw, bx + 5 + wave, y + 22, 6, 8, PAL["medusa_scale"]) + rect(draw, bx + 6 + wave, y + 28, 4, 2, PAL["medusa_green"]) + # Scale pattern + for i in range(3): + px(draw, bx + 6 + wave, y + 23 + i * 2, PAL["medusa_green"]) + px(draw, bx + 9 + wave, y + 23 + i * 2, PAL["medusa_green"]) + # Torso + rect(draw, bx + 5, y + 14, 6, 8, PAL["medusa_green"]) + # Face + rect(draw, bx + 5, y + 8, 6, 6, PAL["skin"]) + # Snake hair + for i in range(4): + sx = bx + 4 + i * 2 + sy_offset = [0, -1, 0, 1][(frame + i) % 4] + px(draw, sx, y + 5 + sy_offset, PAL["medusa_hair"]) + px(draw, sx, y + 4 + sy_offset, PAL["medusa_hair"]) + px(draw, sx + 1, y + 3 + sy_offset, PAL["medusa_hair"]) + # Eyes (glowing) + px(draw, bx + 6, y + 10, (200, 255, 100)) + px(draw, bx + 9, y + 10, (200, 255, 100)) + # Arms + rect(draw, bx + 3, y + 15, 2, 4, PAL["skin"]) + rect(draw, bx + 11, y + 15, 2, 4, PAL["skin"]) + + +def draw_ghost(draw, bx, by, frame, action="drift"): + """Ghost: ethereal floating enemy, translucent.""" + if action == "death": + alpha = max(0, 180 - frame * 60) + rect(draw, bx + 4, by + 10, 8, 14, (180, 200, 220), alpha) + return + y = by + [0, -1, -2, -1][frame % 4] + alpha = 180 if action == "drift" else [180, 120, 60][frame % 3] + # Wispy lower body + wave = [0, 1, 0, -1][frame % 4] + rect(draw, bx + 4 + wave, y + 22, 8, 8, (160, 180, 200), alpha - 40) + rect(draw, bx + 5, y + 26, 2, 4, (140, 160, 180), alpha - 60) + rect(draw, bx + 9, y + 26, 2, 4, (140, 160, 180), alpha - 60) + # Body + rect(draw, bx + 4, y + 12, 8, 10, (180, 200, 220), alpha) + rect(draw, bx + 5, y + 13, 6, 8, (200, 220, 240), alpha - 20) + # Head + rect(draw, bx + 5, y + 6, 6, 6, (200, 210, 230), alpha) + # Eyes (glowing) + px(draw, bx + 6, y + 9, (100, 200, 255), min(255, alpha + 40)) + px(draw, bx + 9, y + 9, (100, 200, 255), min(255, alpha + 40)) + # Mouth + px(draw, bx + 7, y + 11, (60, 60, 80), alpha) + px(draw, bx + 8, y + 11, (60, 60, 80), alpha) + + +def draw_gargoyle(draw, bx, by, frame, action="perch"): + """Gargoyle: stone winged creature.""" + if action == "death": + scatter = frame * 2 + for i in range(5): + ox = random.Random(frame * 11 + i).randint(-scatter, scatter) + oy = random.Random(frame * 11 + i + 40).randint(0, scatter) + rect(draw, bx + 6 + ox, by + 14 + i * 3 + oy, 3, 2, PAL["stone_mid"]) + return + y = by + ([0, 0][frame % 2] if action == "perch" else [0, -1, -2, -1][frame % 4]) + # Legs/talons + rect(draw, bx + 4, y + 27, 3, 3, PAL["stone_dark"]) + rect(draw, bx + 9, y + 27, 3, 3, PAL["stone_dark"]) + # Body + rect(draw, bx + 4, y + 16, 8, 12, PAL["stone_mid"]) + rect(draw, bx + 5, y + 17, 6, 10, PAL["stone_dark"]) + # Wings + if action == "swoop": + wing_spread = [4, 6, 5, 3][frame % 4] + rect(draw, bx + 4 - wing_spread, y + 14, wing_spread, 5, PAL["stone_mid"]) + rect(draw, bx + 12, y + 14, wing_spread, 5, PAL["stone_mid"]) + else: + # Folded wings + rect(draw, bx + 2, y + 16, 3, 8, PAL["stone_mid"]) + rect(draw, bx + 11, y + 16, 3, 8, PAL["stone_mid"]) + # Head (horned) + rect(draw, bx + 5, y + 8, 6, 8, PAL["stone_mid"]) + rect(draw, bx + 6, y + 9, 4, 6, PAL["stone_dark"]) + # Horns + px(draw, bx + 4, y + 7, PAL["stone_light"]) + px(draw, bx + 4, y + 6, PAL["stone_light"]) + px(draw, bx + 11, y + 7, PAL["stone_light"]) + px(draw, bx + 11, y + 6, PAL["stone_light"]) + # Eyes + px(draw, bx + 6, y + 11, (255, 100, 40)) + px(draw, bx + 9, y + 11, (255, 100, 40)) + + +# ── Tile drawing ─────────────────────────────────────────────────── + +def draw_bitmask_tile(draw, bx, by, group, mask_idx): + """Draw a themed bitmask tile. mask_idx: 0-15 (UDLR = 4 bits). + + Bit layout: 0=Up, 1=Down, 2=Left, 3=Right. + A set bit means there IS a neighbor in that direction. + """ + pal = TILE_PALETTES.get(group, TILE_PALETTES["castle"]) + has_up = bool(mask_idx & 1) + has_down = bool(mask_idx & 2) + has_left = bool(mask_idx & 4) + has_right = bool(mask_idx & 8) + + # Base fill + rect(draw, bx, by, 16, 16, pal["mid"]) + + # Brick pattern: horizontal mortar lines + rect(draw, bx, by + 7, 16, 1, pal["mortar"]) + rect(draw, bx, by + 15, 16, 1, pal["mortar"]) + # Vertical mortar (offset between rows for brick pattern) + rect(draw, bx + 7, by, 1, 8, pal["mortar"]) + rect(draw, bx + 3, by + 8, 1, 8, pal["mortar"]) + rect(draw, bx + 11, by + 8, 1, 8, pal["mortar"]) + + # Per-brick highlights + rect(draw, bx + 1, by + 1, 5, 1, pal["light"]) + rect(draw, bx + 9, by + 1, 5, 1, pal["light"]) + rect(draw, bx + 1, by + 9, 5, 1, pal["light"]) + # Per-brick shadows + rect(draw, bx + 1, by + 6, 5, 1, pal["dark"]) + rect(draw, bx + 9, by + 6, 5, 1, pal["dark"]) + + # Edge treatment based on exposed sides (no neighbor) + if not has_up: + # Exposed top: cap/highlight + rect(draw, bx, by, 16, 2, pal["light"]) + rect(draw, bx, by, 16, 1, pal["accent"]) + if not has_down: + # Exposed bottom: shadow edge + rect(draw, bx, by + 14, 16, 2, pal["dark"]) + if not has_left: + # Exposed left: highlight + rect(draw, bx, by, 2, 16, pal["light"]) + if not has_right: + # Exposed right: shadow + rect(draw, bx + 14, by, 2, 16, pal["dark"]) + + # Corner accents for external corners + if not has_up and not has_left: + px(draw, bx, by, pal["accent"]) + px(draw, bx + 1, by, pal["accent"]) + px(draw, bx, by + 1, pal["accent"]) + if not has_up and not has_right: + px(draw, bx + 15, by, pal["accent"]) + px(draw, bx + 14, by, pal["accent"]) + px(draw, bx + 15, by + 1, pal["accent"]) + + +def draw_stone_brick(draw, bx, by, variant): + """Draw detailed stone brick with mortar lines.""" + rect(draw, bx, by, 16, 16, PAL["stone_mid"]) + # Mortar lines (horizontal) + rect(draw, bx, by + 7, 16, 1, PAL["stone_mortar"]) + rect(draw, bx, by + 15, 16, 1, PAL["stone_mortar"]) + # Mortar lines (vertical, offset per row) + rect(draw, bx + 7, by, 1, 8, PAL["stone_mortar"]) + rect(draw, bx + 3, by + 8, 1, 8, PAL["stone_mortar"]) + rect(draw, bx + 11, by + 8, 1, 8, PAL["stone_mortar"]) + # Highlights and shadows for depth + rect(draw, bx + 1, by + 1, 5, 1, PAL["stone_light"]) + rect(draw, bx + 9, by + 1, 5, 1, PAL["stone_light"]) + rect(draw, bx + 1, by + 9, 5, 1, PAL["stone_light"]) + # Shadow on bottom edges + rect(draw, bx + 1, by + 6, 5, 1, PAL["stone_dark"]) + rect(draw, bx + 9, by + 6, 5, 1, PAL["stone_dark"]) + # Variant-specific edge highlighting + if "top" in variant and "left" not in variant and "right" not in variant: + rect(draw, bx, by, 16, 2, PAL["stone_light"]) + rect(draw, bx, by, 16, 1, (140, 130, 120)) + if "left" in variant: + rect(draw, bx, by, 2, 16, PAL["stone_light"]) + if "right" in variant: + rect(draw, bx + 14, by, 2, 16, PAL["stone_dark"]) + + +def draw_platform(draw, bx, by, variant): + """Wooden platform with plank details.""" + rect(draw, bx, by, 16, 16, PAL["wood_mid"]) + # Top planks + rect(draw, bx, by, 16, 4, PAL["wood_light"]) + rect(draw, bx, by, 16, 1, (160, 120, 75)) + # Plank lines + rect(draw, bx + 5, by, 1, 4, PAL["wood_dark"]) + rect(draw, bx + 11, by, 1, 4, PAL["wood_dark"]) + # Support beams underneath + rect(draw, bx + 2, by + 5, 2, 11, PAL["wood_dark"]) + rect(draw, bx + 12, by + 5, 2, 11, PAL["wood_dark"]) + # Cross brace + for i in range(8): + px(draw, bx + 4 + i, by + 7 + i, PAL["wood_dark"]) + + +def draw_spikes(draw, bx, by, variant): + """Metal spikes pointing upward.""" + rect(draw, bx, by + 12, 16, 4, PAL["stone_dark"]) + for i in range(4): + sx = bx + i * 4 + # Spike triangle + for row in range(10): + w = 3 - (row * 3) // 10 + if w > 0: + x_off = (3 - w) // 2 + c = PAL["spike_tip"] if row < 3 else PAL["spike_metal"] + rect(draw, sx + 1 + x_off, by + 12 - row, w, 1, c) + + +def draw_torch(draw, bx, by, frame): + """Wall torch with animated flame.""" + # Bracket + rect(draw, bx + 6, by + 8, 4, 8, PAL["stone_dark"]) + rect(draw, bx + 5, by + 8, 6, 2, PAL["stone_light"]) + # Torch stick + rect(draw, bx + 7, by + 4, 2, 6, PAL["wood_dark"]) + # Flame (animated) + flame_h = [5, 6, 5, 7][frame % 4] + flame_w = [3, 4, 3, 4][frame % 4] + fx = bx + 8 - flame_w // 2 + fy = by + 4 - flame_h + rect(draw, fx, fy + 2, flame_w, flame_h - 2, PAL["fire_mid"]) + rect(draw, fx + 1, fy, flame_w - 2, flame_h - 1, PAL["fire_bright"]) + px(draw, bx + 7, fy + flame_h - 1, PAL["fire_dark"]) + px(draw, bx + 8, fy + flame_h - 1, PAL["fire_dark"]) + + +def draw_checkpoint_flag(draw, bx, by, frame, activated): + """Checkpoint flag on a pole.""" + # Pole + rect(draw, bx + 7, by + 2, 2, 14, PAL["stone_light"]) + # Base + rect(draw, bx + 4, by + 14, 8, 2, PAL["stone_dark"]) + # Flag + flag_color = PAL["gold"] if activated else PAL["stone_dark"] + wave = [0, 1, 0, -1][frame % 4] if activated else 0 + rect(draw, bx + 9, by + 2 + wave, 5, 4, flag_color) + if activated: + px(draw, bx + 10, by + 3 + wave, PAL["gold_shadow"]) + + +def draw_finish_gate(draw, bx, by, frame): + """Ornate finish gate with pulsing glow.""" + # Stone pillars + rect(draw, bx + 1, by + 2, 4, 14, PAL["stone_light"]) + rect(draw, bx + 11, by + 2, 4, 14, PAL["stone_light"]) + # Arch top + rect(draw, bx + 1, by + 1, 14, 3, PAL["stone_light"]) + rect(draw, bx + 3, by, 10, 2, PAL["gold"]) + # Gate bars + for i in range(3): + rect(draw, bx + 5 + i * 2, by + 3, 1, 13, PAL["knight_gold"]) + # Pulsing gem + glow = [200, 240, 255, 240][frame % 4] + px(draw, bx + 7, by + 1, (glow, glow // 2, glow // 4)) + px(draw, bx + 8, by + 1, (glow, glow // 2, glow // 4)) + + +def draw_ladder(draw, bx, by): + """Wooden ladder.""" + rect(draw, bx + 3, by, 2, 16, PAL["wood_mid"]) + rect(draw, bx + 11, by, 2, 16, PAL["wood_mid"]) + # Rungs + for i in range(4): + rect(draw, bx + 3, by + 2 + i * 4, 10, 2, PAL["wood_light"]) + rect(draw, bx + 4, by + 3 + i * 4, 8, 1, PAL["wood_dark"]) + + +def draw_breakable_wall(draw, bx, by, variant): + """Cracked stone wall.""" + draw_stone_brick(draw, bx, by, "inner") + # Cracks + crack_color = PAL["stone_dark"] + # Diagonal crack + for i in range(6): + px(draw, bx + 3 + i, by + 4 + i, crack_color) + for i in range(4): + px(draw, bx + 8 + i, by + 2 + i, crack_color) + # Loose mortar + if variant == 1: + px(draw, bx + 5, by + 10, PAL["stone_mortar"]) + px(draw, bx + 10, by + 8, PAL["stone_mortar"]) + + +def draw_stained_glass(draw, bx, by): + """Gothic stained glass window.""" + # Frame + outline_rect(draw, bx + 1, by + 1, 14, 14, PAL["glass_lead"]) + # Arch top + rect(draw, bx + 2, by + 1, 12, 2, PAL["glass_lead"]) + # Glass panels + rect(draw, bx + 2, by + 3, 6, 5, PAL["glass_purple"]) + rect(draw, bx + 8, by + 3, 6, 5, PAL["glass_blue"]) + rect(draw, bx + 2, by + 9, 6, 5, PAL["glass_blue"]) + rect(draw, bx + 8, by + 9, 6, 5, PAL["glass_purple"]) + # Cross divider + rect(draw, bx + 7, by + 3, 2, 11, PAL["glass_lead"]) + rect(draw, bx + 2, by + 8, 12, 1, PAL["glass_lead"]) + # Light spot + px(draw, bx + 5, by + 5, (200, 150, 220), 200) + px(draw, bx + 10, by + 11, (150, 180, 240), 200) + + +def draw_water_tile(draw, bx, by, is_surface): + """Water tile: surface has wave pattern, body is translucent blue.""" + if is_surface: + # Surface with wave highlights + rect(draw, bx, by + 4, 16, 12, PAL["water_body"], 180) + # Wave crests + for i in range(8): + wave_y = by + 2 + int(math.sin(i * 0.8) * 2) + rect(draw, bx + i * 2, wave_y, 2, 3, PAL["water_surface"], 200) + # Highlights + px(draw, bx + 3, by + 3, PAL["water_highlight"], 200) + px(draw, bx + 11, by + 5, PAL["water_highlight"], 180) + else: + rect(draw, bx, by, 16, 16, PAL["water_body"], 160) + # Caustic light patterns + for i in range(3): + cx = bx + 3 + i * 5 + cy = by + 4 + i * 4 + px(draw, cx, cy, PAL["water_highlight"], 120) + px(draw, cx + 1, cy, PAL["water_highlight"], 80) + + +def draw_cobweb(draw, bx, by): + """Cobweb decoration: thin strands in a triangular pattern.""" + c = (180, 180, 190) + # Corner-to-corner strands + for i in range(14): + # Top-left to bottom-right diagonal strands + px(draw, bx + i, by + i, c, 120 - i * 5) + # Horizontal strands + if i < 12: + px(draw, bx + i + 2, by + i // 2 + 1, c, 80) + # Web connections + for i in range(5): + px(draw, bx + 2 + i * 2, by + 3, c, 100) + px(draw, bx + 4 + i * 2, by + 6, c, 80) + px(draw, bx, by, c, 160) + + +def draw_chain(draw, bx, by, frame): + """Hanging chain link: alternates link orientation per frame.""" + link_color = (140, 130, 120) + highlight = (180, 170, 160) + # Vertical chain with alternating link shapes + for i in range(4): + cy = by + i * 4 + if (i + frame) % 2 == 0: + # Horizontal oval link + rect(draw, bx + 5, cy, 6, 3, link_color, 200) + px(draw, bx + 6, cy, highlight, 200) + else: + # Vertical oval link + rect(draw, bx + 6, cy, 4, 4, link_color, 200) + px(draw, bx + 7, cy, highlight, 180) + + +# ── Decorative tile drawing ────────────────────────────────────── + +def draw_bookshelf(draw, bx, by): + """Castle bookshelf decoration.""" + rect(draw, bx, by, 16, 16, PAL["wood_dark"]) + # Shelves + for i in range(3): + sy = by + 1 + i * 5 + rect(draw, bx + 1, sy, 14, 1, PAL["wood_light"]) + # Books + colors = [(140, 40, 40), (40, 80, 140), (60, 120, 60), (120, 100, 60)] + for j in range(5): + bw = 2 + (j % 2) + rect(draw, bx + 1 + j * 3, sy + 1, bw, 4, colors[j % 4]) + + +def draw_banner(draw, bx, by): + """Hanging banner/tapestry.""" + # Pole + rect(draw, bx + 2, by, 12, 2, PAL["stone_light"]) + # Banner fabric + rect(draw, bx + 3, by + 2, 10, 12, PAL["cape_red"]) + rect(draw, bx + 4, by + 3, 8, 10, PAL["cape_shadow"]) + # Emblem + rect(draw, bx + 6, by + 5, 4, 4, (200, 170, 50)) + # Frayed bottom + for i in range(5): + px(draw, bx + 3 + i * 2, by + 14, PAL["cape_red"]) + + +def draw_pillar(draw, bx, by, is_top): + """Stone pillar section.""" + rect(draw, bx + 3, by, 10, 16, PAL["stone_mid"]) + rect(draw, bx + 4, by, 1, 16, PAL["stone_light"]) + rect(draw, bx + 12, by, 1, 16, PAL["stone_dark"]) + if is_top: + # Capital + rect(draw, bx + 1, by, 14, 3, PAL["stone_light"]) + rect(draw, bx + 2, by + 3, 12, 1, PAL["stone_mortar"]) + else: + # Fluting lines + for i in range(3): + rect(draw, bx + 5 + i * 2, by, 1, 16, PAL["stone_mortar"]) + + +def draw_coffin(draw, bx, by): + """Underground coffin decoration.""" + rect(draw, bx + 2, by + 4, 12, 10, PAL["wood_dark"]) + rect(draw, bx + 3, by + 5, 10, 8, PAL["wood_mid"]) + # Cross on lid + rect(draw, bx + 7, by + 5, 2, 7, PAL["stone_light"]) + rect(draw, bx + 5, by + 7, 6, 2, PAL["stone_light"]) + + +def draw_bones(draw, bx, by): + """Scattered bone pile.""" + c = PAL["bone"] + s = PAL["bone_shadow"] + # Crossbones + for i in range(8): + px(draw, bx + 3 + i, by + 8 + i // 2, c) + px(draw, bx + 12 - i, by + 8 + i // 2, s) + # Skull + rect(draw, bx + 5, by + 4, 6, 5, c) + rect(draw, bx + 6, by + 5, 4, 3, s) + px(draw, bx + 6, by + 6, PAL["bone_dark"]) + px(draw, bx + 9, by + 6, PAL["bone_dark"]) + + +def draw_mushroom(draw, bx, by): + """Glowing underground mushroom.""" + # Stem + rect(draw, bx + 6, by + 8, 4, 8, (180, 170, 150)) + # Cap + rect(draw, bx + 3, by + 5, 10, 4, (80, 160, 100)) + rect(draw, bx + 4, by + 4, 8, 2, (100, 180, 120)) + # Glow spots + px(draw, bx + 5, by + 6, (140, 220, 160), 200) + px(draw, bx + 10, by + 7, (140, 220, 160), 180) + + +def draw_altar(draw, bx, by): + """Sacred altar.""" + # Base + rect(draw, bx + 1, by + 10, 14, 6, PAL["stone_light"]) + rect(draw, bx + 2, by + 8, 12, 3, PAL["stone_mid"]) + # Top surface + rect(draw, bx + 3, by + 7, 10, 2, PAL["gold"]) + # Candle + rect(draw, bx + 7, by + 3, 2, 5, (220, 210, 190)) + px(draw, bx + 7, by + 2, PAL["fire_bright"]) + px(draw, bx + 8, by + 2, PAL["fire_mid"]) + + +def draw_sacred_candle(draw, bx, by): + """Tall sacred candle.""" + rect(draw, bx + 5, by + 10, 6, 6, PAL["stone_mid"]) + rect(draw, bx + 6, by + 3, 4, 8, (220, 210, 190)) + # Flame + rect(draw, bx + 7, by + 1, 2, 3, PAL["fire_bright"]) + px(draw, bx + 7, by, PAL["fire_mid"]) + px(draw, bx + 8, by, PAL["fire_mid"]) + + +def draw_rune(draw, bx, by): + """Glowing rune inscription.""" + rect(draw, bx, by, 16, 16, PAL["stone_dark"]) + # Rune glyph (simple geometric) + c = (100, 160, 220) + rect(draw, bx + 4, by + 2, 8, 1, c, 200) + rect(draw, bx + 4, by + 13, 8, 1, c, 200) + rect(draw, bx + 4, by + 2, 1, 12, c, 180) + rect(draw, bx + 11, by + 2, 1, 12, c, 180) + # Inner cross + rect(draw, bx + 6, by + 5, 4, 1, c, 160) + rect(draw, bx + 7, by + 4, 2, 8, c, 160) + + +def draw_weapon_rack(draw, bx, by): + """Fortress weapon rack.""" + rect(draw, bx + 1, by + 2, 2, 14, PAL["wood_dark"]) + rect(draw, bx + 13, by + 2, 2, 14, PAL["wood_dark"]) + rect(draw, bx + 1, by + 6, 14, 2, PAL["wood_mid"]) + # Swords + rect(draw, bx + 4, by + 1, 1, 10, PAL["armor_steel"]) + rect(draw, bx + 7, by + 1, 1, 10, PAL["armor_steel"]) + rect(draw, bx + 10, by + 1, 1, 10, PAL["armor_steel"]) + + +def draw_anvil(draw, bx, by): + """Fortress anvil.""" + rect(draw, bx + 3, by + 8, 10, 6, PAL["stone_dark"]) + rect(draw, bx + 2, by + 6, 12, 3, PAL["armor_steel"]) + rect(draw, bx + 4, by + 4, 8, 3, PAL["armor_shadow"]) + # Horn + rect(draw, bx + 1, by + 5, 2, 2, PAL["armor_steel"]) + + +def draw_shield(draw, bx, by): + """Fortress shield on wall.""" + rect(draw, bx + 3, by + 2, 10, 12, PAL["armor_steel"]) + rect(draw, bx + 4, by + 3, 8, 10, PAL["armor_shadow"]) + # Cross emblem + rect(draw, bx + 7, by + 4, 2, 8, PAL["knight_gold"]) + rect(draw, bx + 5, by + 7, 6, 2, PAL["knight_gold"]) + + +# ── Power-up and UI drawing ─────────────────────────────────────── + +def draw_powerup(draw, bx, by, kind): + """Draw a power-up icon in GothicVania style.""" + # Background glow circle + for dy in range(-5, 6): + for dx in range(-5, 6): + if dx * dx + dy * dy <= 25: + px(draw, bx + 8 + dx, by + 8 + dy, (40, 80, 40), 60) + + colors = { + "holy_water": ((80, 140, 220), (40, 80, 160)), + "crucifix": ((220, 200, 60), (180, 160, 40)), + "speed_boots": ((60, 200, 100), (30, 140, 60)), + "double_jump": ((200, 160, 255), (140, 100, 200)), + "armor": ((180, 180, 190), (120, 120, 140)), + "invincibility": ((255, 220, 80), (220, 180, 40)), + "whip_extend": ((200, 120, 60), (160, 80, 30)), + } + primary, secondary = colors.get(kind, ((200, 200, 200), (150, 150, 150))) + + if kind == "holy_water": + # Bottle shape + rect(draw, bx + 6, by + 3, 4, 2, (200, 200, 220)) + rect(draw, bx + 5, by + 5, 6, 8, primary) + rect(draw, bx + 6, by + 6, 4, 6, secondary) + px(draw, bx + 7, by + 7, (140, 200, 255)) + elif kind == "crucifix": + rect(draw, bx + 7, by + 3, 2, 11, primary) + rect(draw, bx + 4, by + 5, 8, 2, primary) + px(draw, bx + 7, by + 5, secondary) + px(draw, bx + 8, by + 5, secondary) + elif kind == "speed_boots": + rect(draw, bx + 4, by + 8, 8, 4, primary) + rect(draw, bx + 3, by + 10, 3, 3, secondary) + rect(draw, bx + 10, by + 10, 3, 3, secondary) + # Lightning bolt + px(draw, bx + 7, by + 5, (255, 255, 100)) + px(draw, bx + 8, by + 6, (255, 255, 100)) + px(draw, bx + 7, by + 7, (255, 255, 100)) + elif kind == "double_jump": + # Wings + rect(draw, bx + 3, by + 6, 4, 3, primary) + rect(draw, bx + 9, by + 6, 4, 3, primary) + rect(draw, bx + 6, by + 8, 4, 4, secondary) + px(draw, bx + 4, by + 5, primary) + px(draw, bx + 11, by + 5, primary) + elif kind == "armor": + rect(draw, bx + 5, by + 4, 6, 8, primary) + rect(draw, bx + 6, by + 5, 4, 6, secondary) + rect(draw, bx + 5, by + 4, 6, 1, (200, 200, 210)) + elif kind == "invincibility": + # Star shape + rect(draw, bx + 6, by + 3, 4, 10, primary) + rect(draw, bx + 3, by + 5, 10, 4, primary) + px(draw, bx + 7, by + 6, secondary) + px(draw, bx + 8, by + 6, secondary) + px(draw, bx + 7, by + 7, (255, 255, 200)) + px(draw, bx + 8, by + 7, (255, 255, 200)) + elif kind == "whip_extend": + # Coiled whip + for i in range(6): + rect(draw, bx + 4 + i, by + 6 + (i % 3), 2, 2, primary) + rect(draw, bx + 10, by + 5, 2, 4, secondary) + + +def draw_heart(draw, bx, by, full): + """Pixel heart icon.""" + color = (220, 40, 40) if full else (80, 60, 60) + shadow = (160, 20, 20) if full else (60, 45, 45) + # Heart shape + rect(draw, bx + 2, by + 4, 4, 3, color) + rect(draw, bx + 10, by + 4, 4, 3, color) + rect(draw, bx + 1, by + 5, 14, 4, color) + rect(draw, bx + 2, by + 9, 12, 2, color) + rect(draw, bx + 3, by + 11, 10, 1, color) + rect(draw, bx + 4, by + 12, 8, 1, color) + rect(draw, bx + 5, by + 13, 6, 1, shadow) + rect(draw, bx + 6, by + 14, 4, 1, shadow) + # Highlight + if full: + px(draw, bx + 4, by + 5, (255, 120, 120)) + px(draw, bx + 5, by + 5, (255, 160, 160)) + + +def draw_prop(draw, bx, by, kind): + """Decorative props.""" + if kind == "candelabra": + rect(draw, bx + 7, by + 6, 2, 10, PAL["knight_gold"]) + rect(draw, bx + 4, by + 5, 8, 2, PAL["knight_gold"]) + # Candles + for cx in [bx + 4, bx + 7, bx + 10]: + rect(draw, cx, by + 2, 2, 4, (230, 220, 200)) + px(draw, cx, by + 1, PAL["fire_bright"]) + elif kind == "cross": + rect(draw, bx + 7, by + 3, 2, 12, PAL["stone_light"]) + rect(draw, bx + 4, by + 5, 8, 2, PAL["stone_light"]) + elif kind == "gravestone": + rect(draw, bx + 3, by + 6, 10, 10, PAL["stone_mid"]) + rect(draw, bx + 4, by + 3, 8, 4, PAL["stone_mid"]) + rect(draw, bx + 5, by + 2, 6, 2, PAL["stone_light"]) + # RIP text hint + px(draw, bx + 6, by + 8, PAL["stone_dark"]) + px(draw, bx + 8, by + 8, PAL["stone_dark"]) + px(draw, bx + 10, by + 8, PAL["stone_dark"]) + + +# ── Particle drawing ────────────────────────────────────────────── + +def draw_particle(draw, bx, by, kind, frame): + """Draw 8x8 particle sprites.""" + if kind == "dust": + alpha = [200, 160, 120, 80][frame % 4] + size = [3, 4, 3, 2][frame % 4] + cx = bx + 4 - size // 2 + cy = by + 4 - size // 2 + rect(draw, cx, cy, size, size, (180, 170, 150), alpha) + elif kind == "spark": + alpha = [255, 200, 140][frame % 3] + rect(draw, bx + 3, by + 3, 2, 2, (255, 240, 100), alpha) + px(draw, bx + 4, by + 2, (255, 255, 200), alpha) + px(draw, bx + 2, by + 4, (255, 255, 200), alpha) + elif kind == "blood": + alpha = [255, 200, 140][frame % 3] + size = [3, 2, 2][frame % 3] + rect(draw, bx + 3, by + 3, size, size, (180, 20, 20), alpha) + elif kind == "fire": + colors = [(255, 220, 80), (240, 160, 40), (220, 100, 20), (180, 60, 10)] + c = colors[frame % 4] + h = [4, 5, 4, 3][frame % 4] + rect(draw, bx + 2, by + 8 - h, 4, h, c, 230) + px(draw, bx + 3, by + 8 - h - 1, (255, 255, 200), 180) + elif kind == "magic": + colors = [(100, 255, 150), (80, 200, 255), (200, 150, 255)] + c = colors[frame % 3] + rect(draw, bx + 2, by + 2, 4, 4, c, 200) + px(draw, bx + 3, by + 3, (255, 255, 255), 180) + elif kind == "smoke": + alpha = [160, 120, 80][frame % 3] + size = [3, 4, 5][frame % 3] + cx = bx + 4 - size // 2 + cy = by + 4 - size // 2 + rect(draw, cx, cy, size, size, (120, 110, 130), alpha) + elif kind == "debris": + alpha = [255, 200, 140][frame % 3] + rect(draw, bx + 2, by + 3, 3, 3, PAL["stone_mid"], alpha) + px(draw, bx + 3, by + 2, PAL["stone_light"], alpha) + elif kind == "water": + alpha = [200, 160, 100][frame % 3] + rect(draw, bx + 2, by + 2, 3, 4, PAL["water_surface"], alpha) + px(draw, bx + 3, by + 1, PAL["water_highlight"], alpha) + elif kind == "ember": + colors = [(255, 200, 60), (255, 160, 40), (200, 100, 20)] + c = colors[frame % 3] + px(draw, bx + 3, by + 3, c, 255) + px(draw, bx + 4, by + 4, c, 180) + + +def draw_ambient_particle(draw, bx, by, kind, frame): + """Draw 8x8 ambient particle sprites (sparkle, snowflake, page).""" + if kind == "sparkle": + alpha = [255, 180][frame % 2] + px(draw, bx + 3, by + 2, (255, 255, 200), alpha) + px(draw, bx + 4, by + 3, (255, 255, 220), alpha) + px(draw, bx + 3, by + 4, (255, 255, 200), alpha) + px(draw, bx + 2, by + 3, (255, 255, 200), alpha) + px(draw, bx + 3, by + 3, (255, 255, 255), alpha) + elif kind == "snowflake": + alpha = [220, 180][frame % 2] + # Cross shape + rect(draw, bx + 3, by + 1, 2, 6, (220, 230, 255), alpha) + rect(draw, bx + 1, by + 3, 6, 2, (220, 230, 255), alpha) + # Diagonal accents + px(draw, bx + 2, by + 2, (200, 210, 240), alpha) + px(draw, bx + 5, by + 2, (200, 210, 240), alpha) + px(draw, bx + 2, by + 5, (200, 210, 240), alpha) + px(draw, bx + 5, by + 5, (200, 210, 240), alpha) + elif kind == "page": + alpha = [240, 200][frame % 2] + rect(draw, bx + 1, by + 1, 5, 6, (220, 210, 190), alpha) + rect(draw, bx + 2, by + 2, 3, 4, (200, 190, 170), alpha) + # Text lines + px(draw, bx + 2, by + 3, (100, 90, 80), alpha) + px(draw, bx + 3, by + 3, (100, 90, 80), alpha) + px(draw, bx + 2, by + 5, (100, 90, 80), alpha) + + +# ── VFX sprite drawing ─────────────────────────────────────────── + +def draw_vfx_slash(draw, bx, by, frame): + """32x32 slash arc VFX.""" + alpha = max(0, 255 - frame * 40) + # Arc sweep that grows with each frame + arc_len = min(frame + 1, 5) * 4 + for i in range(arc_len): + angle = (i / arc_len) * 2.5 + cx = int(bx + 16 + math.cos(angle) * (8 + frame * 2)) + cy = int(by + 16 + math.sin(angle) * (8 + frame * 2)) + if bx <= cx < bx + 32 and by <= cy < by + 32: + px(draw, cx, cy, (255, 255, 220), alpha) + if cx + 1 < bx + 32: + px(draw, cx + 1, cy, (255, 240, 180), alpha - 40) + if cy + 1 < by + 32: + px(draw, cx, cy + 1, (255, 220, 140), alpha - 60) + # Core glow + rect(draw, bx + 12, by + 12, 8, 8, (255, 255, 200), alpha // 3) + + +def draw_vfx_magic_circle(draw, bx, by, frame): + """32x32 rotating magic circle VFX.""" + cx, cy = bx + 16, by + 16 + radius = 10 + alpha = [200, 220, 240, 220][frame % 4] + color = (120, 80, 220) + # Circle outline + for i in range(24): + angle = (i / 24) * math.pi * 2 + frame * 0.5 + px_x = int(cx + math.cos(angle) * radius) + px_y = int(cy + math.sin(angle) * radius) + if bx <= px_x < bx + 32 and by <= px_y < by + 32: + px(draw, px_x, px_y, color, alpha) + # Inner rune lines + for i in range(6): + angle = (i / 6) * math.pi * 2 + frame * 0.3 + ix = int(cx + math.cos(angle) * 5) + iy = int(cy + math.sin(angle) * 5) + if bx <= ix < bx + 32 and by <= iy < by + 32: + px(draw, ix, iy, (180, 140, 255), alpha) + # Center glow + rect(draw, bx + 14, by + 14, 4, 4, (200, 160, 255), alpha // 2) + + +def draw_vfx_hit_spark(draw, bx, by, frame): + """32x32 impact spark VFX.""" + alpha = max(0, 255 - frame * 50) + cx, cy = bx + 16, by + 16 + # Expanding star burst + spread = 3 + frame * 3 + for i in range(8): + angle = (i / 8) * math.pi * 2 + for d in range(spread): + sx = int(cx + math.cos(angle) * d) + sy = int(cy + math.sin(angle) * d) + if bx <= sx < bx + 32 and by <= sy < by + 32: + a = max(0, alpha - d * 20) + color = (255, 255 - d * 15, 100 - d * 10) if d < 5 else (255, 200, 60) + color = tuple(max(0, min(255, c)) for c in color) + px(draw, sx, sy, color, a) + # Core flash + rect(draw, bx + 14, by + 14, 4, 4, (255, 255, 220), alpha) + + +# ═══════════════════════════════════════════════════════════════════ +# Atlas builder +# ═══════════════════════════════════════════════════════════════════ + +def build_atlas(): + """Build the 1024x512 sprite atlas with GothicVania-style pixel art.""" + atlas = Image.new("RGBA", (ATLAS_W, ATLAS_H), (0, 0, 0, 0)) + draw = ImageDraw.Draw(atlas) + metadata = {} + + random.seed(42) # Deterministic + + # Draw all sprites with proper pixel art + for name, x, y, w, h in SPRITES: + draw_gothic_sprite(draw, name, x, y, w, h) + metadata[name] = {"x": x, "y": y, "w": w, "h": h} + + # Legacy aliases + legacy_map = { + "player_idle_0": "player_idle_0", + "player_walk_0": "player_walk_0", + "player_jump": "player_jump_0", + "player_fall": "player_fall_0", + "player_attack_0": "player_attack_0", + "player_hurt": "player_hurt_0", + "player_dead": "player_dead_0", + "skeleton_walk_0": "skeleton_walk_0", + "skeleton_walk_1": "skeleton_walk_1", + "bat_fly_0": "bat_fly_0", + "bat_fly_1": "bat_fly_1", + "knight_walk_0": "knight_walk_0", + "knight_walk_1": "knight_walk_1", + "medusa_float_0": "medusa_float_0", + "medusa_float_1": "medusa_float_1", + "projectile": "projectile_0", + "stone_brick": "stone_brick_top", + "checkpoint_flag": "checkpoint_flag_down_0", + "finish_gate": "finish_gate_0", + "breakable_wall": "breakable_wall_0", + "torch": "torch_0", + } + + os.makedirs(OUT_DIR, exist_ok=True) + atlas_path = os.path.join(OUT_DIR, "platformer_atlas.png") + atlas.save(atlas_path, "PNG") + print(f"Saved atlas: {atlas_path} ({atlas.size[0]}x{atlas.size[1]})") + + json_path = os.path.join(OUT_DIR, "platformer_atlas.json") + output = {"atlas_width": ATLAS_W, "atlas_height": ATLAS_H, "sprites": metadata, "legacy_aliases": legacy_map} + with open(json_path, "w") as f: + json.dump(output, f, indent=2) + print(f"Saved metadata: {json_path} ({len(metadata)} sprites)") + + return atlas, metadata + + +def draw_gothic_sprite(draw, name, x, y, w, h): + """Route sprite drawing to the appropriate handler.""" + # Extract frame number from name + parts = name.rsplit("_", 1) + frame = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0 + + # Player sprites + if name.startswith("player_idle"): + draw_player_idle(draw, x, y, frame) + elif name.startswith("player_walk"): + draw_player_walk(draw, x, y, frame) + elif name.startswith("player_jump"): + draw_player_jump(draw, x, y, frame) + elif name.startswith("player_fall"): + draw_player_fall(draw, x, y, frame) + elif name.startswith("player_attack"): + draw_player_attack(draw, x, y, frame) + elif name.startswith("player_hurt"): + draw_player_hurt(draw, x, y, frame) + elif name.startswith("player_dead"): + draw_player_dead(draw, x, y, frame) + elif name.startswith("player_run"): + draw_player_run(draw, x, y, frame) + elif name.startswith("player_wall_slide"): + draw_player_wall_slide(draw, x, y, frame) + elif name.startswith("player_crouch"): + draw_player_crouch(draw, x, y, frame) + elif name.startswith("player_dash"): + draw_player_dash(draw, x, y, frame) + # Enemy sprites + elif name.startswith("skeleton_walk"): + draw_skeleton(draw, x, y, frame, "walk") + elif name.startswith("skeleton_attack"): + draw_skeleton(draw, x, y, frame, "attack") + elif name.startswith("skeleton_death"): + draw_skeleton(draw, x, y, frame, "death") + elif name.startswith("bat_fly"): + draw_bat(draw, x, y, frame, alive=True) + elif name.startswith("bat_death"): + draw_bat(draw, x, y, frame, alive=False) + elif name.startswith("knight_walk"): + draw_knight(draw, x, y, frame, "walk") + elif name.startswith("knight_attack"): + draw_knight(draw, x, y, frame, "attack") + elif name.startswith("knight_death"): + draw_knight(draw, x, y, frame, "death") + elif name.startswith("medusa_float"): + draw_medusa(draw, x, y, frame, alive=True) + elif name.startswith("medusa_death"): + draw_medusa(draw, x, y, frame, alive=False) + elif name.startswith("ghost_drift"): + draw_ghost(draw, x, y, frame, "drift") + elif name.startswith("ghost_phase"): + draw_ghost(draw, x, y, frame, "phase") + elif name.startswith("ghost_death"): + draw_ghost(draw, x, y, frame, "death") + elif name.startswith("gargoyle_perch"): + draw_gargoyle(draw, x, y, frame, "perch") + elif name.startswith("gargoyle_swoop"): + draw_gargoyle(draw, x, y, frame, "swoop") + elif name.startswith("gargoyle_death"): + draw_gargoyle(draw, x, y, frame, "death") + elif name.startswith("projectile"): + draw_projectile(draw, x, y, frame) + # Bitmask tiles (castle_tile_0-15, underground_tile_0-15, etc.) + elif name.startswith("castle_tile_"): + draw_bitmask_tile(draw, x, y, "castle", int(name.split("_")[-1])) + elif name.startswith("underground_tile_"): + draw_bitmask_tile(draw, x, y, "underground", int(name.split("_")[-1])) + elif name.startswith("sacred_tile_"): + draw_bitmask_tile(draw, x, y, "sacred", int(name.split("_")[-1])) + elif name.startswith("fortress_tile_"): + draw_bitmask_tile(draw, x, y, "fortress", int(name.split("_")[-1])) + # Decorative tiles + elif name.startswith("castle_") and name not in ("castle_bookshelf", "castle_banner", + "castle_pillar_top", "castle_pillar_mid"): + pass # handled above by castle_tile_ + elif name == "castle_bookshelf": + draw_bookshelf(draw, x, y) + elif name == "castle_banner": + draw_banner(draw, x, y) + elif name in ("castle_pillar_top", "castle_pillar_mid"): + draw_pillar(draw, x, y, is_top=("top" in name)) + elif name == "underground_coffin": + draw_coffin(draw, x, y) + elif name == "underground_bones": + draw_bones(draw, x, y) + elif name == "underground_mushroom": + draw_mushroom(draw, x, y) + elif name == "sacred_altar": + draw_altar(draw, x, y) + elif name == "sacred_candle": + draw_sacred_candle(draw, x, y) + elif name == "sacred_rune": + draw_rune(draw, x, y) + elif name == "fortress_weapon_rack": + draw_weapon_rack(draw, x, y) + elif name == "fortress_anvil": + draw_anvil(draw, x, y) + elif name == "fortress_shield": + draw_shield(draw, x, y) + # Legacy stone_brick aliases (draw same as castle tiles) + elif name.startswith("stone_brick"): + draw_stone_brick(draw, x, y, name.replace("stone_brick_", "")) + elif name.startswith("platform"): + draw_platform(draw, x, y, frame) + elif name.startswith("spikes"): + draw_spikes(draw, x, y, frame) + elif name.startswith("checkpoint_flag_down"): + draw_checkpoint_flag(draw, x, y, frame, activated=False) + elif name.startswith("checkpoint_flag_up"): + draw_checkpoint_flag(draw, x, y, frame, activated=True) + elif name.startswith("finish_gate"): + draw_finish_gate(draw, x, y, frame) + elif name == "ladder": + draw_ladder(draw, x, y) + elif name.startswith("breakable_wall"): + draw_breakable_wall(draw, x, y, frame) + elif name.startswith("torch_"): + draw_torch(draw, x, y, frame) + elif name == "stained_glass": + draw_stained_glass(draw, x, y) + elif name == "water_surface": + draw_water_tile(draw, x, y, is_surface=True) + elif name == "water_body": + draw_water_tile(draw, x, y, is_surface=False) + elif name == "cobweb": + draw_cobweb(draw, x, y) + elif name.startswith("chain_"): + draw_chain(draw, x, y, frame) + # Power-ups + elif name.startswith("powerup_"): + kind = name.replace("powerup_", "") + draw_powerup(draw, x, y, kind) + # Hearts + elif name == "heart_full": + draw_heart(draw, x, y, full=True) + elif name == "heart_empty": + draw_heart(draw, x, y, full=False) + # Props + elif name.startswith("prop_"): + draw_prop(draw, x, y, name.replace("prop_", "")) + # VFX sprites + elif name.startswith("vfx_slash"): + draw_vfx_slash(draw, x, y, frame) + elif name.startswith("vfx_magic_circle"): + draw_vfx_magic_circle(draw, x, y, frame) + elif name.startswith("vfx_hit_spark"): + draw_vfx_hit_spark(draw, x, y, frame) + # Ambient particles + elif name.startswith("particle_sparkle"): + draw_ambient_particle(draw, x, y, "sparkle", frame) + elif name.startswith("particle_snowflake"): + draw_ambient_particle(draw, x, y, "snowflake", frame) + elif name.startswith("particle_page"): + draw_ambient_particle(draw, x, y, "page", frame) + # Particles + elif name.startswith("particle_"): + kind = name.rsplit("_", 1)[0].replace("particle_", "") + draw_particle(draw, x, y, kind, frame) + + +def draw_projectile(draw, bx, by, frame): + """Enemy projectile: spinning energy orb.""" + colors = [(200, 80, 200), (220, 100, 220), (180, 60, 180)] + c = colors[frame % 3] + # Orb + rect(draw, bx + 4, by + 4, 8, 8, c) + rect(draw, bx + 5, by + 5, 6, 6, (240, 140, 240)) + # Core glow + rect(draw, bx + 6, by + 6, 4, 4, (255, 200, 255)) + # Spark trails + px(draw, bx + 3, by + 7, c, 180) + px(draw, bx + 12, by + 7, c, 180) + + +def build_background(): + """Build the 512x512 parallax background (3 layers stacked vertically).""" + BG_SIZE = 512 + bg = Image.new("RGBA", (BG_SIZE, BG_SIZE), (0, 0, 0, 0)) + draw = ImageDraw.Draw(bg) + random.seed(42) + + layer_h = BG_SIZE // 3 + + # Layer 0 (sky): deep gothic night sky + for y in range(layer_h): + t = y / layer_h + r = int(12 + t * 18) + g = int(8 + t * 15) + b = int(30 + t * 35) + draw.line([(0, y), (BG_SIZE - 1, y)], fill=(r, g, b, 255)) + # Stars + for _ in range(60): + sx = random.randint(0, BG_SIZE - 1) + sy = random.randint(0, layer_h - 1) + brightness = random.randint(120, 255) + size = random.choice([1, 1, 1, 2]) + if size == 1: + draw.point((sx, sy), fill=(brightness, brightness, brightness, 255)) + else: + draw.point((sx, sy), fill=(brightness, brightness, brightness, 255)) + draw.point((sx + 1, sy), fill=(brightness, brightness, brightness, 180)) + draw.point((sx, sy + 1), fill=(brightness, brightness, brightness, 180)) + # Moon + for dy in range(-8, 9): + for dx in range(-8, 9): + if dx * dx + dy * dy <= 64: + alpha = max(0, 255 - (dx * dx + dy * dy) * 3) + draw.point( + (400 + dx, 30 + dy), + fill=(220, 210, 180, alpha), + ) + + # Layer 1 (mid-ground): gothic castle silhouettes + y_base = layer_h + for y in range(layer_h): + t = y / layer_h + r = int(20 + t * 20) + g = int(15 + t * 15) + b = int(35 + t * 25) + draw.line([(0, y_base + y), (BG_SIZE - 1, y_base + y)], fill=(r, g, b, 255)) + # Distant castle silhouettes + castle_color = (15, 10, 25, 255) + # Large castle + draw.rectangle([80, y_base + 60, 180, y_base + layer_h], fill=castle_color) + draw.rectangle([90, y_base + 40, 110, y_base + 65], fill=castle_color) + draw.rectangle([140, y_base + 45, 165, y_base + 65], fill=castle_color) + # Towers with pointed tops + for tx in [95, 100, 145, 155]: + draw.polygon( + [(tx - 3, y_base + 40), (tx + 3, y_base + 40), (tx, y_base + 30)], + fill=castle_color, + ) + # Distant hills + for x in range(BG_SIZE): + hill_h = int(25 + 18 * math.sin(x * 0.015) + 12 * math.sin(x * 0.04 + 0.7)) + for y in range(hill_h): + py = y_base + layer_h - 1 - y + if y_base <= py < y_base + layer_h: + draw.point((x, py), fill=(18, 12, 30, 255)) + # Window lights on castle + for wx in range(90, 170, 12): + for wy in range(y_base + 70, y_base + layer_h - 10, 15): + if random.random() > 0.5: + draw.rectangle( + [wx, wy, wx + 3, wy + 4], + fill=(200, 170, 60, 140), + ) + + # Layer 2 (near-ground): cemetery/church buildings + y_base = layer_h * 2 + for y in range(layer_h): + t = y / layer_h + r = int(30 + t * 25) + g = int(22 + t * 18) + b = int(45 + t * 25) + draw.line([(0, y_base + y), (BG_SIZE - 1, y_base + y)], fill=(r, g, b, 255)) + bldg_color = (12, 8, 20, 255) + # Buildings with varied heights + bx = 0 + while bx < BG_SIZE: + bw = random.randint(20, 45) + bh = random.randint(40, 80) + draw.rectangle([bx, y_base + layer_h - bh, bx + bw, y_base + layer_h], fill=bldg_color) + # Pointed roof + if random.random() > 0.4: + draw.polygon( + [ + (bx, y_base + layer_h - bh), + (bx + bw, y_base + layer_h - bh), + (bx + bw // 2, y_base + layer_h - bh - 15), + ], + fill=bldg_color, + ) + # Windows + for wx in range(bx + 4, bx + bw - 4, 8): + for wy_off in range(10, bh - 8, 12): + if random.random() > 0.35: + wy = y_base + layer_h - bh + wy_off + draw.rectangle( + [wx, wy, wx + 3, wy + 5], + fill=(220, 190, 80, 160), + ) + # Cross on some buildings + if random.random() > 0.6: + cx = bx + bw // 2 + cy = y_base + layer_h - bh - 5 + draw.rectangle([cx - 1, cy - 4, cx + 1, cy + 2], fill=bldg_color) + draw.rectangle([cx - 3, cy - 2, cx + 3, cy], fill=bldg_color) + bx += bw + random.randint(2, 10) + + # Ground fog strip at bottom of layer 2 + for x in range(BG_SIZE): + for y in range(8): + alpha = int(80 * (1 - y / 8)) + py = y_base + layer_h - 1 - y + draw.point((x, py), fill=(60, 50, 70, alpha)) + + bg_path = os.path.join(OUT_DIR, "platformer_bg.png") + bg.save(bg_path, "PNG") + print(f"Saved background: {bg_path} ({bg.size[0]}x{bg.size[1]})") + + +if __name__ == "__main__": + build_atlas() + build_background() + print("Done!") diff --git a/tests/browser/visual-audit.spec.js b/tests/browser/visual-audit.spec.js new file mode 100644 index 0000000..cb052f6 --- /dev/null +++ b/tests/browser/visual-audit.spec.js @@ -0,0 +1,508 @@ +/** + * Visual Audit: Platformer Castlevania Style + * + * Captures 6 screenshots across different gameplay moments for visual analysis. + * Designed for the Castlevania GBA/DS restyle feedback loop: + * 1. Playwright captures screenshots → disk + * 2. Claude Code reads them (multimodal) + * 3. Gemini analyzes against reference aesthetics + * 4. Improvements get implemented + * + * Saves artifacts to tests/browser/results/visual-audit/ + * Requires the breakpoint server running on http://127.0.0.1:8080 + */ +import { test, expect } from '@playwright/test'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import WebSocket from 'ws'; +import { + MSG, decode, joinRoomMsg, + parseJoinRoomResponse, parseGameStart, parseGameState, + parsePlatformerState, +} from './helpers/protocol.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = path.join(__dirname, 'results', 'visual-audit'); + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +test.describe.configure({ timeout: 180_000 }); + +// ============================================================================ +// Helpers (same patterns as visual-debug.spec.js) +// ============================================================================ + +function collectConsole(page) { + const messages = []; + page.on('console', msg => { + messages.push({ type: msg.type(), text: msg.text(), ts: Date.now() }); + }); + page.on('pageerror', err => { + messages.push({ type: 'pageerror', text: err.message, ts: Date.now() }); + }); + return messages; +} + +async function installWsInterceptor(page) { + await page.addInitScript(() => { + window.__wsMessages = []; + window.__wsInstance = null; + const OrigWS = window.WebSocket; + window.WebSocket = function (...args) { + const ws = new OrigWS(...args); + window.__wsInstance = ws; + ws.addEventListener('message', (event) => { + if (event.data instanceof Blob) { + event.data.arrayBuffer().then(buf => { + const bytes = new Uint8Array(buf); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + window.__wsMessages.push(btoa(binary)); + }); + } else if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + window.__wsMessages.push(btoa(binary)); + } + }); + return ws; + }; + window.WebSocket.prototype = OrigWS.prototype; + window.WebSocket.CONNECTING = OrigWS.CONNECTING; + window.WebSocket.OPEN = OrigWS.OPEN; + window.WebSocket.CLOSING = OrigWS.CLOSING; + window.WebSocket.CLOSED = OrigWS.CLOSED; + }); +} + +async function extractRoomCode(page, maxWaitMs = 15000) { + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + const b64Messages = await page.evaluate(() => window.__wsMessages || []); + for (const b64 of b64Messages) { + try { + const buf = Buffer.from(b64, 'base64'); + const msg = decode(buf); + if (msg.type === MSG.JOIN_ROOM_RESPONSE && msg.payload) { + const resp = parseJoinRoomResponse(msg.payload); + if (resp.success && resp.roomCode) return resp.roomCode; + } + } catch { /* ignore */ } + } + await page.waitForTimeout(500); + } + return null; +} + +function connectPlayer2(wsUrl, roomCode) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + const received = []; + let playerId = null; + ws.on('open', () => { + ws.send(joinRoomMsg(roomCode, 'AuditBot', [200, 100, 50])); + }); + ws.on('message', (data) => { + const buf = Buffer.from(data); + try { + const decoded = decode(buf); + received.push(decoded); + if (decoded.type === MSG.JOIN_ROOM_RESPONSE) { + const resp = parseJoinRoomResponse(decoded.payload); + if (resp.success) { + playerId = resp.playerId; + resolve({ ws, playerId, received }); + } else { + reject(new Error(`Player 2 join failed: ${resp.error}`)); + } + } + } catch { /* ignore */ } + }); + ws.on('error', (err) => reject(new Error(`P2 WS error: ${err.message}`))); + setTimeout(() => reject(new Error('P2 join timed out')), 10000); + }); +} + +async function launchPlatformer(page) { + await installWsInterceptor(page); + await page.goto('/'); + // Wait for WASM init (swiftshader is slow) + await page.waitForTimeout(15000); + const canvas = page.locator('#game-canvas'); + await expect(canvas).toBeAttached({ timeout: 5000 }); + + // Select platform-racer via HTML data-testid button + const gameBtn = page.locator('[data-testid="game-btn-platform-racer"]'); + await gameBtn.click({ force: true }); + await page.waitForTimeout(500); + + // Create Room via HTML button + const createBtn = page.locator('[data-testid="btn-create"]'); + await createBtn.click({ force: true }); + const roomCode = await extractRoomCode(page, 15000); + if (!roomCode) return null; + + // Connect P2 + let p2; + try { + p2 = await connectPlayer2('ws://127.0.0.1:8080/ws', roomCode); + } catch { + return null; + } + await page.waitForTimeout(3000); + + // Start game via HTML button + try { + const startBtn = page.locator('[data-testid="btn-start"]'); + await startBtn.click({ force: true }); + } catch { + await page.waitForTimeout(2000); + try { + const startBtn = page.locator('[data-testid="btn-start"]'); + await startBtn.click({ force: true }); + } catch { return null; } + } + + // Wait for GAME_START message + let gameStarted = false; + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + if (p2.received.find(m => m.type === MSG.GAME_START)) { + gameStarted = true; + break; + } + await page.waitForTimeout(500); + } + if (!gameStarted) return null; + + const gsMsg = p2.received.find(m => m.type === MSG.GAME_START); + const gs = parseGameStart(gsMsg.payload); + + // Wait for game to initialize and render + await page.waitForTimeout(3000); + const box = await canvas.boundingBox(); + return { canvas, box, p2, hostPlayerId: gs.hostId, actualGame: gs.gameName }; +} + +/** + * Sample WebGL pixels at a grid across the canvas. + */ +async function samplePixels(page, gridSize = 10) { + return page.evaluate((gs) => { + const c = document.getElementById('game-canvas'); + if (!c) return { error: 'no canvas' }; + const gl = c.getContext('webgl2'); + if (!gl) return { error: 'no webgl2' }; + const w = gl.drawingBufferWidth; + const h = gl.drawingBufferHeight; + return new Promise(resolve => { + requestAnimationFrame(() => { + const px = new Uint8Array(4); + const samples = []; + for (let gy = 0; gy < gs; gy++) { + for (let gx = 0; gx < gs; gx++) { + const x = Math.floor((gx + 0.5) * w / gs); + const y = Math.floor((gy + 0.5) * h / gs); + gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px); + samples.push({ + gx, gy, x, y, + r: px[0], g: px[1], b: px[2], a: px[3], + }); + } + } + resolve({ width: w, height: h, samples }); + }); + }); + }, gridSize); +} + +/** + * Compute a color histogram from WebGL readPixels (full canvas). + * Buckets RGB into 4-bit bins (16 levels per channel → 4096 buckets). + */ +async function computeColorHistogram(page) { + return page.evaluate(() => { + const c = document.getElementById('game-canvas'); + if (!c) return { error: 'no canvas' }; + const gl = c.getContext('webgl2'); + if (!gl) return { error: 'no webgl2' }; + const w = gl.drawingBufferWidth; + const h = gl.drawingBufferHeight; + return new Promise(resolve => { + requestAnimationFrame(() => { + const pixels = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + const totalPixels = w * h; + + // Aggregate into 16-level bins per channel + const rHist = new Array(16).fill(0); + const gHist = new Array(16).fill(0); + const bHist = new Array(16).fill(0); + + // Track dominant colors (quantize to 4-bit) + const colorCounts = {}; + let darkPixels = 0; + let brightPixels = 0; + + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + + const rBin = r >> 4; + const gBin = g >> 4; + const bBin = b >> 4; + + rHist[rBin]++; + gHist[gBin]++; + bHist[bBin]++; + + // Track luminance + const lum = 0.299 * r + 0.587 * g + 0.114 * b; + if (lum < 40) darkPixels++; + if (lum > 200) brightPixels++; + + // Top colors (quantized) + const key = `${rBin},${gBin},${bBin}`; + colorCounts[key] = (colorCounts[key] || 0) + 1; + } + + // Sort and take top 20 colors + const topColors = Object.entries(colorCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([key, count]) => { + const [r, g, b] = key.split(',').map(Number); + return { + rgb: `rgb(${r * 16 + 8},${g * 16 + 8},${b * 16 + 8})`, + bin: key, + count, + pct: ((count / totalPixels) * 100).toFixed(2), + }; + }); + + resolve({ + totalPixels, + width: w, + height: h, + darkPixelPct: ((darkPixels / totalPixels) * 100).toFixed(2), + brightPixelPct: ((brightPixels / totalPixels) * 100).toFixed(2), + rHistogram: rHist, + gHistogram: gHist, + bHistogram: bHist, + topColors, + }); + }); + }); + }); +} + +/** + * Capture canvas content as PNG via readPixels (bypasses preserveDrawingBuffer issue). + * Uses readPixels inside requestAnimationFrame to get the actual rendered frame, + * then reconstructs the image data. + */ +async function captureCanvas(page, filename) { + const pngDataUrl = await page.evaluate(() => { + const c = document.getElementById('game-canvas'); + if (!c) return null; + const gl = c.getContext('webgl2'); + if (!gl) return null; + const w = gl.drawingBufferWidth; + const h = gl.drawingBufferHeight; + return new Promise(resolve => { + requestAnimationFrame(() => { + // Read pixels from WebGL framebuffer + const pixels = new Uint8Array(w * h * 4); + gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels); + // WebGL readPixels is bottom-up, flip to top-down + const flipped = new Uint8Array(w * h * 4); + for (let row = 0; row < h; row++) { + const srcOff = row * w * 4; + const dstOff = (h - 1 - row) * w * 4; + flipped.set(pixels.subarray(srcOff, srcOff + w * 4), dstOff); + } + // Write to a 2D canvas for PNG export + const c2 = document.createElement('canvas'); + c2.width = w; + c2.height = h; + const ctx = c2.getContext('2d'); + const imgData = ctx.createImageData(w, h); + imgData.data.set(flipped); + ctx.putImageData(imgData, 0, 0); + resolve(c2.toDataURL('image/png')); + }); + }); + }); + if (pngDataUrl) { + const base64 = pngDataUrl.replace(/^data:image\/png;base64,/, ''); + const filePath = path.join(OUT_DIR, filename); + fs.writeFileSync(filePath, Buffer.from(base64, 'base64')); + return filePath; + } + return null; +} + +function saveJson(filename, data) { + const filePath = path.join(OUT_DIR, filename); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + return filePath; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test.describe('Visual Audit: Platformer Castlevania Style', () => { + + test('capture platformer scenes for visual audit', async ({ page }) => { + const messages = collectConsole(page); + const result = await launchPlatformer(page); + if (!result) { test.skip(); return; } + const { canvas, box, p2 } = result; + + // Focus canvas for keyboard input + await canvas.click({ position: { x: box.width * 0.5, y: box.height * 0.5 } }); + + const screenshots = []; + const histograms = []; + const pixelGrids = []; + + // --- Screenshot 1: Initial spawn room --- + console.log('Capturing audit-01-initial: waiting 5s for initial scene...'); + await page.waitForTimeout(5000); + const s1 = await captureCanvas(page, 'audit-01-initial.png'); + screenshots.push({ name: 'audit-01-initial', path: s1 }); + histograms.push({ name: 'audit-01-initial', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-01-initial', data: await samplePixels(page, 10) }); + + // --- Screenshot 2: Movement (hold D for 3s) --- + console.log('Capturing audit-02-movement: holding D for 3s...'); + await page.keyboard.down('KeyD'); + await page.waitForTimeout(3000); + await page.keyboard.up('KeyD'); + await page.waitForTimeout(500); + const s2 = await captureCanvas(page, 'audit-02-movement.png'); + screenshots.push({ name: 'audit-02-movement', path: s2 }); + histograms.push({ name: 'audit-02-movement', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-02-movement', data: await samplePixels(page, 10) }); + + // --- Screenshot 3: Jump (press Space, wait 1s) --- + console.log('Capturing audit-03-jump: pressing Space...'); + await page.keyboard.press('Space'); + await page.waitForTimeout(1000); + const s3 = await captureCanvas(page, 'audit-03-jump.png'); + screenshots.push({ name: 'audit-03-jump', path: s3 }); + histograms.push({ name: 'audit-03-jump', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-03-jump', data: await samplePixels(page, 10) }); + + // --- Screenshot 4: Exploration (hold D 8s + jump sequences) --- + console.log('Capturing audit-04-exploration: exploring for 8s...'); + await page.keyboard.down('KeyD'); + await page.waitForTimeout(2000); + await page.keyboard.press('Space'); + await page.waitForTimeout(2000); + await page.keyboard.press('Space'); + await page.waitForTimeout(2000); + await page.keyboard.press('Space'); + await page.waitForTimeout(2000); + await page.keyboard.up('KeyD'); + await page.waitForTimeout(1000); + const s4 = await captureCanvas(page, 'audit-04-exploration.png'); + screenshots.push({ name: 'audit-04-exploration', path: s4 }); + histograms.push({ name: 'audit-04-exploration', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-04-exploration', data: await samplePixels(page, 10) }); + + // --- Screenshot 5: Dark area (continue moving, wait 3s) --- + console.log('Capturing audit-05-dark-area: moving further + waiting...'); + await page.keyboard.down('KeyD'); + await page.waitForTimeout(3000); + await page.keyboard.up('KeyD'); + await page.waitForTimeout(3000); + const s5 = await captureCanvas(page, 'audit-05-dark-area.png'); + screenshots.push({ name: 'audit-05-dark-area', path: s5 }); + histograms.push({ name: 'audit-05-dark-area', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-05-dark-area', data: await samplePixels(page, 10) }); + + // --- Screenshot 6: Combat (press F for attack, wait 2s) --- + console.log('Capturing audit-06-combat: pressing F (attack)...'); + await page.keyboard.press('KeyF'); + await page.waitForTimeout(500); + await page.keyboard.press('KeyF'); + await page.waitForTimeout(1500); + const s6 = await captureCanvas(page, 'audit-06-combat.png'); + screenshots.push({ name: 'audit-06-combat', path: s6 }); + histograms.push({ name: 'audit-06-combat', data: await computeColorHistogram(page) }); + pixelGrids.push({ name: 'audit-06-combat', data: await samplePixels(page, 10) }); + + // --- Collect game state from P2 --- + const states = p2.received.filter(m => m.type === MSG.GAME_STATE); + const latestState = states.length > 0 + ? parseGameState(states[states.length - 1].payload) : null; + let parsedState = null; + if (latestState) { + try { parsedState = parsePlatformerState(latestState.stateData); } catch { /* ignore */ } + } + + // --- Collect console errors/panics --- + const errors = messages.filter(m => m.type === 'error' || m.type === 'pageerror').map(m => m.text); + const wasmPanics = messages.filter(m => + m.text.includes('panicked at') || + m.text.includes('BorrowMutError') || + m.text.includes('RuntimeError: unreachable') + ).map(m => m.text); + const breakpointLogs = messages.filter(m => + m.text.includes('BREAKPOINT:') + ).map(m => m.text); + + // --- Save summary JSON --- + const summary = { + timestamp: new Date().toISOString(), + screenshots: screenshots.map(s => ({ name: s.name, captured: !!s.path })), + histograms, + pixelGrids, + gameState: { + totalTicks: states.length, + latestTick: latestState?.tick ?? null, + parsedState, + }, + console: { + totalMessages: messages.length, + errors, + wasmPanics, + breakpointLogs: breakpointLogs.slice(0, 50), + }, + }; + saveJson('summary.json', summary); + + // --- Log results --- + console.log(`\n=== VISUAL AUDIT SUMMARY ===`); + console.log(`Screenshots captured: ${screenshots.filter(s => s.path).length}/6`); + console.log(`Game ticks received: ${states.length}`); + console.log(`Console errors: ${errors.length}`); + console.log(`WASM panics: ${wasmPanics.length}`); + for (const h of histograms) { + console.log(`\n--- ${h.name} color profile ---`); + console.log(` Dark pixels: ${h.data.darkPixelPct}%`); + console.log(` Bright pixels: ${h.data.brightPixelPct}%`); + if (h.data.topColors) { + console.log(` Top 5 colors:`); + for (const c of h.data.topColors.slice(0, 5)) { + console.log(` ${c.rgb} — ${c.pct}%`); + } + } + } + + // Assertions: screenshots captured and game is running + expect(screenshots.filter(s => s.path).length).toBeGreaterThanOrEqual(4); + expect(states.length).toBeGreaterThan(0); + + p2.ws.close(); + }); +}); diff --git a/web/assets/sprites/platformer_atlas.json b/web/assets/sprites/platformer_atlas.json new file mode 100644 index 0000000..ee9e6a6 --- /dev/null +++ b/web/assets/sprites/platformer_atlas.json @@ -0,0 +1,1739 @@ +{ + "atlas_width": 1024, + "atlas_height": 512, + "sprites": { + "player_idle_0": { + "x": 0, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_1": { + "x": 16, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_2": { + "x": 32, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_3": { + "x": 48, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_4": { + "x": 64, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_5": { + "x": 80, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_6": { + "x": 96, + "y": 0, + "w": 16, + "h": 32 + }, + "player_idle_7": { + "x": 112, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_0": { + "x": 128, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_1": { + "x": 144, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_2": { + "x": 160, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_3": { + "x": 176, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_4": { + "x": 192, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_5": { + "x": 208, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_6": { + "x": 224, + "y": 0, + "w": 16, + "h": 32 + }, + "player_walk_7": { + "x": 240, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_0": { + "x": 256, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_1": { + "x": 272, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_2": { + "x": 288, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_3": { + "x": 304, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_4": { + "x": 320, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_5": { + "x": 336, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_6": { + "x": 352, + "y": 0, + "w": 16, + "h": 32 + }, + "player_run_7": { + "x": 368, + "y": 0, + "w": 16, + "h": 32 + }, + "player_jump_0": { + "x": 384, + "y": 0, + "w": 16, + "h": 32 + }, + "player_jump_1": { + "x": 400, + "y": 0, + "w": 16, + "h": 32 + }, + "player_jump_2": { + "x": 416, + "y": 0, + "w": 16, + "h": 32 + }, + "player_jump_3": { + "x": 432, + "y": 0, + "w": 16, + "h": 32 + }, + "player_fall_0": { + "x": 448, + "y": 0, + "w": 16, + "h": 32 + }, + "player_fall_1": { + "x": 464, + "y": 0, + "w": 16, + "h": 32 + }, + "player_fall_2": { + "x": 480, + "y": 0, + "w": 16, + "h": 32 + }, + "player_fall_3": { + "x": 496, + "y": 0, + "w": 16, + "h": 32 + }, + "player_attack_0": { + "x": 0, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_1": { + "x": 16, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_2": { + "x": 32, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_3": { + "x": 48, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_4": { + "x": 64, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_5": { + "x": 80, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_6": { + "x": 96, + "y": 32, + "w": 16, + "h": 32 + }, + "player_attack_7": { + "x": 112, + "y": 32, + "w": 16, + "h": 32 + }, + "player_hurt_0": { + "x": 128, + "y": 32, + "w": 16, + "h": 32 + }, + "player_hurt_1": { + "x": 144, + "y": 32, + "w": 16, + "h": 32 + }, + "player_hurt_2": { + "x": 160, + "y": 32, + "w": 16, + "h": 32 + }, + "player_hurt_3": { + "x": 176, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_0": { + "x": 192, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_1": { + "x": 208, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_2": { + "x": 224, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_3": { + "x": 240, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_4": { + "x": 256, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dead_5": { + "x": 272, + "y": 32, + "w": 16, + "h": 32 + }, + "player_wall_slide_0": { + "x": 288, + "y": 32, + "w": 16, + "h": 32 + }, + "player_wall_slide_1": { + "x": 304, + "y": 32, + "w": 16, + "h": 32 + }, + "player_wall_slide_2": { + "x": 320, + "y": 32, + "w": 16, + "h": 32 + }, + "player_crouch_0": { + "x": 336, + "y": 32, + "w": 16, + "h": 32 + }, + "player_crouch_1": { + "x": 352, + "y": 32, + "w": 16, + "h": 32 + }, + "player_crouch_2": { + "x": 368, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dash_0": { + "x": 384, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dash_1": { + "x": 400, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dash_2": { + "x": 416, + "y": 32, + "w": 16, + "h": 32 + }, + "player_dash_3": { + "x": 432, + "y": 32, + "w": 16, + "h": 32 + }, + "skeleton_walk_0": { + "x": 0, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_walk_1": { + "x": 16, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_walk_2": { + "x": 32, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_walk_3": { + "x": 48, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_attack_0": { + "x": 64, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_attack_1": { + "x": 80, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_attack_2": { + "x": 96, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_death_0": { + "x": 112, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_death_1": { + "x": 128, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_death_2": { + "x": 144, + "y": 64, + "w": 16, + "h": 32 + }, + "skeleton_death_3": { + "x": 160, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_fly_0": { + "x": 176, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_fly_1": { + "x": 192, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_fly_2": { + "x": 208, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_fly_3": { + "x": 224, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_death_0": { + "x": 240, + "y": 64, + "w": 16, + "h": 32 + }, + "bat_death_1": { + "x": 256, + "y": 64, + "w": 16, + "h": 32 + }, + "knight_walk_0": { + "x": 0, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_walk_1": { + "x": 16, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_walk_2": { + "x": 32, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_walk_3": { + "x": 48, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_attack_0": { + "x": 64, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_attack_1": { + "x": 80, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_attack_2": { + "x": 96, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_death_0": { + "x": 112, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_death_1": { + "x": 128, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_death_2": { + "x": 144, + "y": 96, + "w": 16, + "h": 32 + }, + "knight_death_3": { + "x": 160, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_float_0": { + "x": 176, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_float_1": { + "x": 192, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_float_2": { + "x": 208, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_float_3": { + "x": 224, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_death_0": { + "x": 240, + "y": 96, + "w": 16, + "h": 32 + }, + "medusa_death_1": { + "x": 256, + "y": 96, + "w": 16, + "h": 32 + }, + "ghost_drift_0": { + "x": 0, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_drift_1": { + "x": 16, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_drift_2": { + "x": 32, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_drift_3": { + "x": 48, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_phase_0": { + "x": 64, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_phase_1": { + "x": 80, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_phase_2": { + "x": 96, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_death_0": { + "x": 112, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_death_1": { + "x": 128, + "y": 128, + "w": 16, + "h": 32 + }, + "ghost_death_2": { + "x": 144, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_perch_0": { + "x": 160, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_perch_1": { + "x": 176, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_swoop_0": { + "x": 192, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_swoop_1": { + "x": 208, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_swoop_2": { + "x": 224, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_swoop_3": { + "x": 240, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_death_0": { + "x": 256, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_death_1": { + "x": 272, + "y": 128, + "w": 16, + "h": 32 + }, + "gargoyle_death_2": { + "x": 288, + "y": 128, + "w": 16, + "h": 32 + }, + "projectile_0": { + "x": 304, + "y": 128, + "w": 16, + "h": 16 + }, + "projectile_1": { + "x": 320, + "y": 128, + "w": 16, + "h": 16 + }, + "projectile_2": { + "x": 336, + "y": 128, + "w": 16, + "h": 16 + }, + "castle_tile_0": { + "x": 0, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_1": { + "x": 16, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_2": { + "x": 32, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_3": { + "x": 48, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_4": { + "x": 64, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_5": { + "x": 80, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_6": { + "x": 96, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_7": { + "x": 112, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_8": { + "x": 128, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_9": { + "x": 144, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_10": { + "x": 160, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_11": { + "x": 176, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_12": { + "x": 192, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_13": { + "x": 208, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_14": { + "x": 224, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_tile_15": { + "x": 240, + "y": 160, + "w": 16, + "h": 16 + }, + "underground_tile_0": { + "x": 0, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_1": { + "x": 16, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_2": { + "x": 32, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_3": { + "x": 48, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_4": { + "x": 64, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_5": { + "x": 80, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_6": { + "x": 96, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_7": { + "x": 112, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_8": { + "x": 128, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_9": { + "x": 144, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_10": { + "x": 160, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_11": { + "x": 176, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_12": { + "x": 192, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_13": { + "x": 208, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_14": { + "x": 224, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_tile_15": { + "x": 240, + "y": 176, + "w": 16, + "h": 16 + }, + "sacred_tile_0": { + "x": 0, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_1": { + "x": 16, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_2": { + "x": 32, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_3": { + "x": 48, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_4": { + "x": 64, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_5": { + "x": 80, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_6": { + "x": 96, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_7": { + "x": 112, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_8": { + "x": 128, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_9": { + "x": 144, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_10": { + "x": 160, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_11": { + "x": 176, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_12": { + "x": 192, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_13": { + "x": 208, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_14": { + "x": 224, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_tile_15": { + "x": 240, + "y": 192, + "w": 16, + "h": 16 + }, + "fortress_tile_0": { + "x": 0, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_1": { + "x": 16, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_2": { + "x": 32, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_3": { + "x": 48, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_4": { + "x": 64, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_5": { + "x": 80, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_6": { + "x": 96, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_7": { + "x": 112, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_8": { + "x": 128, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_9": { + "x": 144, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_10": { + "x": 160, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_11": { + "x": 176, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_12": { + "x": 192, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_13": { + "x": 208, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_14": { + "x": 224, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_tile_15": { + "x": 240, + "y": 208, + "w": 16, + "h": 16 + }, + "castle_bookshelf": { + "x": 256, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_banner": { + "x": 272, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_pillar_top": { + "x": 288, + "y": 160, + "w": 16, + "h": 16 + }, + "castle_pillar_mid": { + "x": 304, + "y": 160, + "w": 16, + "h": 16 + }, + "underground_coffin": { + "x": 256, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_bones": { + "x": 272, + "y": 176, + "w": 16, + "h": 16 + }, + "underground_mushroom": { + "x": 288, + "y": 176, + "w": 16, + "h": 16 + }, + "sacred_altar": { + "x": 256, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_candle": { + "x": 272, + "y": 192, + "w": 16, + "h": 16 + }, + "sacred_rune": { + "x": 288, + "y": 192, + "w": 16, + "h": 16 + }, + "fortress_weapon_rack": { + "x": 256, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_anvil": { + "x": 272, + "y": 208, + "w": 16, + "h": 16 + }, + "fortress_shield": { + "x": 288, + "y": 208, + "w": 16, + "h": 16 + }, + "stone_brick_top": { + "x": 0, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_inner": { + "x": 16, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_left": { + "x": 32, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_right": { + "x": 48, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_top_left": { + "x": 64, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_top_right": { + "x": 80, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_bottom_left": { + "x": 96, + "y": 160, + "w": 16, + "h": 16 + }, + "stone_brick_bottom_right": { + "x": 112, + "y": 160, + "w": 16, + "h": 16 + }, + "platform_0": { + "x": 0, + "y": 224, + "w": 16, + "h": 16 + }, + "platform_1": { + "x": 16, + "y": 224, + "w": 16, + "h": 16 + }, + "platform_2": { + "x": 32, + "y": 224, + "w": 16, + "h": 16 + }, + "spikes_0": { + "x": 48, + "y": 224, + "w": 16, + "h": 16 + }, + "spikes_1": { + "x": 64, + "y": 224, + "w": 16, + "h": 16 + }, + "checkpoint_flag_down_0": { + "x": 80, + "y": 224, + "w": 16, + "h": 16 + }, + "checkpoint_flag_down_1": { + "x": 96, + "y": 224, + "w": 16, + "h": 16 + }, + "checkpoint_flag_up_0": { + "x": 112, + "y": 224, + "w": 16, + "h": 16 + }, + "checkpoint_flag_up_1": { + "x": 128, + "y": 224, + "w": 16, + "h": 16 + }, + "finish_gate_0": { + "x": 144, + "y": 224, + "w": 16, + "h": 16 + }, + "finish_gate_1": { + "x": 160, + "y": 224, + "w": 16, + "h": 16 + }, + "ladder": { + "x": 176, + "y": 224, + "w": 16, + "h": 16 + }, + "breakable_wall_0": { + "x": 192, + "y": 224, + "w": 16, + "h": 16 + }, + "breakable_wall_1": { + "x": 208, + "y": 224, + "w": 16, + "h": 16 + }, + "torch_0": { + "x": 224, + "y": 224, + "w": 16, + "h": 16 + }, + "torch_1": { + "x": 240, + "y": 224, + "w": 16, + "h": 16 + }, + "torch_2": { + "x": 256, + "y": 224, + "w": 16, + "h": 16 + }, + "torch_3": { + "x": 272, + "y": 224, + "w": 16, + "h": 16 + }, + "stained_glass": { + "x": 288, + "y": 224, + "w": 16, + "h": 16 + }, + "water_surface": { + "x": 304, + "y": 224, + "w": 16, + "h": 16 + }, + "water_body": { + "x": 320, + "y": 224, + "w": 16, + "h": 16 + }, + "cobweb": { + "x": 336, + "y": 224, + "w": 16, + "h": 16 + }, + "chain_0": { + "x": 352, + "y": 224, + "w": 16, + "h": 16 + }, + "chain_1": { + "x": 368, + "y": 224, + "w": 16, + "h": 16 + }, + "powerup_holy_water": { + "x": 0, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_crucifix": { + "x": 16, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_speed_boots": { + "x": 32, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_double_jump": { + "x": 48, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_armor": { + "x": 64, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_invincibility": { + "x": 80, + "y": 288, + "w": 16, + "h": 16 + }, + "powerup_whip_extend": { + "x": 96, + "y": 288, + "w": 16, + "h": 16 + }, + "heart_full": { + "x": 0, + "y": 304, + "w": 16, + "h": 16 + }, + "heart_empty": { + "x": 16, + "y": 304, + "w": 16, + "h": 16 + }, + "prop_candelabra": { + "x": 32, + "y": 304, + "w": 16, + "h": 16 + }, + "prop_cross": { + "x": 48, + "y": 304, + "w": 16, + "h": 16 + }, + "prop_gravestone": { + "x": 64, + "y": 304, + "w": 16, + "h": 16 + }, + "particle_dust_0": { + "x": 0, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_dust_1": { + "x": 8, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_dust_2": { + "x": 16, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_dust_3": { + "x": 24, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_spark_0": { + "x": 32, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_spark_1": { + "x": 40, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_spark_2": { + "x": 48, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_blood_0": { + "x": 56, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_blood_1": { + "x": 64, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_blood_2": { + "x": 72, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_fire_0": { + "x": 80, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_fire_1": { + "x": 88, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_fire_2": { + "x": 96, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_fire_3": { + "x": 104, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_magic_0": { + "x": 112, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_magic_1": { + "x": 120, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_magic_2": { + "x": 128, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_smoke_0": { + "x": 136, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_smoke_1": { + "x": 144, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_smoke_2": { + "x": 152, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_debris_0": { + "x": 160, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_debris_1": { + "x": 168, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_debris_2": { + "x": 176, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_water_0": { + "x": 184, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_water_1": { + "x": 192, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_water_2": { + "x": 200, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_ember_0": { + "x": 208, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_ember_1": { + "x": 216, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_ember_2": { + "x": 224, + "y": 352, + "w": 8, + "h": 8 + }, + "particle_sparkle_0": { + "x": 0, + "y": 356, + "w": 8, + "h": 8 + }, + "particle_sparkle_1": { + "x": 8, + "y": 356, + "w": 8, + "h": 8 + }, + "particle_snowflake_0": { + "x": 16, + "y": 356, + "w": 8, + "h": 8 + }, + "particle_snowflake_1": { + "x": 24, + "y": 356, + "w": 8, + "h": 8 + }, + "particle_page_0": { + "x": 32, + "y": 356, + "w": 8, + "h": 8 + }, + "particle_page_1": { + "x": 40, + "y": 356, + "w": 8, + "h": 8 + }, + "vfx_slash_0": { + "x": 0, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_slash_1": { + "x": 32, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_slash_2": { + "x": 64, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_slash_3": { + "x": 96, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_slash_4": { + "x": 128, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_magic_circle_0": { + "x": 160, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_magic_circle_1": { + "x": 192, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_magic_circle_2": { + "x": 224, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_magic_circle_3": { + "x": 256, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_hit_spark_0": { + "x": 288, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_hit_spark_1": { + "x": 320, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_hit_spark_2": { + "x": 352, + "y": 384, + "w": 32, + "h": 32 + }, + "vfx_hit_spark_3": { + "x": 384, + "y": 384, + "w": 32, + "h": 32 + } + }, + "legacy_aliases": { + "player_idle_0": "player_idle_0", + "player_walk_0": "player_walk_0", + "player_jump": "player_jump_0", + "player_fall": "player_fall_0", + "player_attack_0": "player_attack_0", + "player_hurt": "player_hurt_0", + "player_dead": "player_dead_0", + "skeleton_walk_0": "skeleton_walk_0", + "skeleton_walk_1": "skeleton_walk_1", + "bat_fly_0": "bat_fly_0", + "bat_fly_1": "bat_fly_1", + "knight_walk_0": "knight_walk_0", + "knight_walk_1": "knight_walk_1", + "medusa_float_0": "medusa_float_0", + "medusa_float_1": "medusa_float_1", + "projectile": "projectile_0", + "stone_brick": "stone_brick_top", + "checkpoint_flag": "checkpoint_flag_down_0", + "finish_gate": "finish_gate_0", + "breakable_wall": "breakable_wall_0", + "torch": "torch_0" + } +} diff --git a/web/assets/sprites/platformer_atlas.png b/web/assets/sprites/platformer_atlas.png new file mode 100644 index 0000000..9589b59 Binary files /dev/null and b/web/assets/sprites/platformer_atlas.png differ diff --git a/web/assets/sprites/platformer_bg.png b/web/assets/sprites/platformer_bg.png new file mode 100644 index 0000000..4380cd0 Binary files /dev/null and b/web/assets/sprites/platformer_bg.png differ diff --git a/web/index.html b/web/index.html index 54e7117..5d5e58b 100644 --- a/web/index.html +++ b/web/index.html @@ -63,7 +63,6 @@

BREAKPOINT

Mode @@ -131,10 +130,24 @@

BREAKPOINT

+ + + + + +