diff --git a/Cargo.lock b/Cargo.lock index 9abb849..b2de53f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,6 +192,7 @@ dependencies = [ "sdl2", "serde", "serde_json", + "smallvec", "spin_sleep", "thiserror 1.0.69", "tracing", @@ -392,9 +393,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.11.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "spin_sleep" diff --git a/Cargo.toml b/Cargo.toml index ebabc4e..5a393b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,14 +17,14 @@ pathfinding = "4.14" once_cell = "1.21.3" thiserror = "1.0" anyhow = "1.0" -glam = "0.30.4" +glam = { version = "0.30.4", features = [] } serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.141" +smallvec = "1.15.1" [profile.release] lto = true panic = "abort" -panic-strategy = "abort" opt-level = "z" [target.'cfg(target_os = "windows")'.dependencies.winapi] diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..126810e --- /dev/null +++ b/src/app.rs @@ -0,0 +1,152 @@ +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use sdl2::event::{Event, WindowEvent}; +use sdl2::keyboard::Keycode; +use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; +use sdl2::video::{Window, WindowContext}; +use sdl2::EventPump; +use tracing::{error, event}; + +use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; +use crate::game::Game; + +#[cfg(target_os = "emscripten")] +use crate::emscripten; + +#[cfg(not(target_os = "emscripten"))] +fn sleep(value: Duration) { + spin_sleep::sleep(value); +} + +#[cfg(target_os = "emscripten")] +fn sleep(value: Duration) { + emscripten::emscripten::sleep(value.as_millis() as u32); +} + +pub struct App<'a> { + game: Game, + canvas: Canvas, + event_pump: EventPump, + backbuffer: Texture<'a>, + paused: bool, + last_tick: Instant, +} + +impl<'a> App<'a> { + pub fn new() -> Result { + let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?; + let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?; + let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?; + let ttf_context = sdl2::ttf::init().map_err(|e| anyhow!(e.to_string()))?; + + let window = video_subsystem + .window( + "Pac-Man", + (CANVAS_SIZE.x as f32 * SCALE).round() as u32, + (CANVAS_SIZE.y as f32 * SCALE).round() as u32, + ) + .resizable() + .position_centered() + .build()?; + + let mut canvas = window.into_canvas().build()?; + canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?; + + let texture_creator_static: &'static TextureCreator = Box::leak(Box::new(canvas.texture_creator())); + + let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem); + game.audio.set_mute(cfg!(debug_assertions)); + + let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?; + backbuffer.set_scale_mode(ScaleMode::Nearest); + + let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?; + + // Initial draw + game.draw(&mut canvas, &mut backbuffer)?; + game.present_backbuffer(&mut canvas, &backbuffer)?; + + Ok(Self { + game, + canvas, + event_pump, + backbuffer, + paused: false, + last_tick: Instant::now(), + }) + } + + pub fn run(&mut self) -> bool { + { + let start = Instant::now(); + + for event in self.event_pump.poll_iter() { + match event { + Event::Window { win_event, .. } => match win_event { + WindowEvent::Hidden => { + event!(tracing::Level::DEBUG, "Window hidden"); + } + WindowEvent::Shown => { + event!(tracing::Level::DEBUG, "Window shown"); + } + _ => {} + }, + Event::Quit { .. } + | Event::KeyDown { + keycode: Some(Keycode::Escape) | Some(Keycode::Q), + .. + } => { + event!(tracing::Level::INFO, "Exit requested. Exiting..."); + return false; + } + Event::KeyDown { + keycode: Some(Keycode::P), + .. + } => { + self.paused = !self.paused; + event!(tracing::Level::INFO, "{}", if self.paused { "Paused" } else { "Unpaused" }); + } + Event::KeyDown { + keycode: Some(Keycode::Space), + .. + } => { + self.game.debug_mode = !self.game.debug_mode; + } + Event::KeyDown { keycode, .. } => { + self.game.keyboard_event(keycode.unwrap()); + } + _ => {} + } + } + + let dt = self.last_tick.elapsed().as_secs_f32(); + self.last_tick = Instant::now(); + + if !self.paused { + self.game.tick(dt); + if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { + error!("Failed to draw game: {e}"); + } + if let Err(e) = self.game.present_backbuffer(&mut self.canvas, &self.backbuffer) { + error!("Failed to present backbuffer: {e}"); + } + } + + if start.elapsed() < LOOP_TIME { + let time = LOOP_TIME.saturating_sub(start.elapsed()); + if time != Duration::ZERO { + sleep(time); + } + } else { + event!( + tracing::Level::WARN, + "Game loop behind schedule by: {:?}", + start.elapsed() - LOOP_TIME + ); + } + + true + } + } +} diff --git a/src/asset.rs b/src/asset.rs index 9ca467d..a907eba 100644 --- a/src/asset.rs +++ b/src/asset.rs @@ -22,7 +22,6 @@ pub enum Asset { Wav2, Wav3, Wav4, - FontKonami, Atlas, AtlasJson, // Add more as needed @@ -37,7 +36,6 @@ impl Asset { Wav2 => "sound/waka/2.ogg", Wav3 => "sound/waka/3.ogg", Wav4 => "sound/waka/4.ogg", - FontKonami => "konami.ttf", Atlas => "atlas.png", AtlasJson => "atlas.json", } @@ -54,7 +52,6 @@ mod imp { Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")), Asset::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")), Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.ogg")), - Asset::FontKonami => Cow::Borrowed(include_bytes!("../assets/game/konami.ttf")), Asset::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")), Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")), } diff --git a/src/audio.rs b/src/audio.rs index 547544c..32fd45e 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -81,6 +81,7 @@ impl Audio { self.muted = mute; } + /// Returns `true` if the audio is muted. pub fn is_muted(&self) -> bool { self.muted } diff --git a/src/constants.rs b/src/constants.rs index f558458..cc3fe1c 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,7 +1,11 @@ //! This module contains all the constants used in the game. +use std::time::Duration; + use glam::UVec2; +pub const LOOP_TIME: Duration = Duration::from_nanos((1_000_000_000.0 / 60.0) as u64); + /// The size of each cell, in pixels. pub const CELL_SIZE: u32 = 8; /// The size of the game board, in cells. @@ -39,58 +43,6 @@ pub enum MapTile { Tunnel, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum FruitType { - Cherry, - Strawberry, - Orange, - Apple, - Melon, - Galaxian, - Bell, - Key, -} - -impl FruitType { - pub const ALL: [FruitType; 8] = [ - FruitType::Cherry, - FruitType::Strawberry, - FruitType::Orange, - FruitType::Apple, - FruitType::Melon, - FruitType::Galaxian, - FruitType::Bell, - FruitType::Key, - ]; - - pub fn score(self) -> u32 { - match self { - FruitType::Cherry => 100, - FruitType::Strawberry => 300, - FruitType::Orange => 500, - FruitType::Apple => 700, - FruitType::Melon => 1000, - FruitType::Galaxian => 2000, - FruitType::Bell => 3000, - FruitType::Key => 5000, - } - } - - pub fn index(self) -> usize { - match self { - FruitType::Cherry => 0, - FruitType::Strawberry => 1, - FruitType::Orange => 2, - FruitType::Apple => 3, - FruitType::Melon => 4, - FruitType::Galaxian => 5, - FruitType::Bell => 6, - FruitType::Key => 7, - } - } -} - /// The raw layout of the game board, as a 2D array of characters. pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [ "############################", @@ -106,9 +58,9 @@ pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [ " #.##### ## #####.# ", " #.## 1 ##.# ", " #.## ###==### ##.# ", - "######.## # # ##.######", - "T . #2 3 4 # . T", - "######.## # # ##.######", + "######.## ######## ##.######", + "T . ######## . T", + "######.## ######## ##.######", " #.## ######## ##.# ", " #.## ##.# ", " #.## ######## ##.# ", diff --git a/src/debug.rs b/src/debug.rs deleted file mode 100644 index 5ae2696..0000000 --- a/src/debug.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Debug rendering utilities for Pac-Man. -use crate::{ - constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}, - entity::ghost::Ghost, - map::Map, -}; -use glam::{IVec2, UVec2}; -use sdl2::{pixels::Color, render::Canvas, video::Window}; - -#[derive(PartialEq, Eq, Clone, Copy)] -pub enum DebugMode { - None, - Grid, - Pathfinding, - ValidPositions, -} - -pub struct DebugRenderer; - -impl DebugRenderer { - pub fn draw_cell(canvas: &mut Canvas, _map: &Map, cell: UVec2, color: Color) { - let position = Map::cell_to_pixel(cell); - canvas.set_draw_color(color); - canvas - .draw_rect(sdl2::rect::Rect::new(position.x, position.y, CELL_SIZE, CELL_SIZE)) - .expect("Could not draw rectangle"); - } - - pub fn draw_debug_grid(canvas: &mut Canvas, map: &Map, pacman_cell: UVec2) { - for x in 0..BOARD_CELL_SIZE.x { - for y in 0..BOARD_CELL_SIZE.y { - let tile = map.get_tile(IVec2::new(x as i32, y as i32)).unwrap_or(MapTile::Empty); - let cell = UVec2::new(x, y); - let mut color = None; - if cell == pacman_cell { - Self::draw_cell(canvas, map, cell, Color::CYAN); - } else { - color = match tile { - MapTile::Empty => None, - MapTile::Wall => Some(Color::BLUE), - MapTile::Pellet => Some(Color::RED), - MapTile::PowerPellet => Some(Color::MAGENTA), - MapTile::StartingPosition(_) => Some(Color::GREEN), - MapTile::Tunnel => Some(Color::CYAN), - }; - } - if let Some(color) = color { - Self::draw_cell(canvas, map, cell, color); - } - } - } - } - - pub fn draw_next_cell(canvas: &mut Canvas, map: &Map, next_cell: UVec2) { - Self::draw_cell(canvas, map, next_cell, Color::YELLOW); - } - - pub fn draw_valid_positions(canvas: &mut Canvas, map: &mut Map) { - let valid_positions_vec = map.get_valid_playable_positions().clone(); - for &pos in &valid_positions_vec { - Self::draw_cell(canvas, map, pos, Color::RGB(255, 140, 0)); - } - } - - pub fn draw_pathfinding(canvas: &mut Canvas, ghost: &Ghost, map: &Map) { - let target = ghost.get_target_tile(); - if let Some((path, _)) = ghost.get_path_to_target(target.unwrap().as_uvec2()) { - for pos in &path { - Self::draw_cell(canvas, map, *pos, Color::YELLOW); - } - } - } -} diff --git a/src/entity/direction.rs b/src/entity/direction.rs index 0c9e00c..24ca810 100644 --- a/src/entity/direction.rs +++ b/src/entity/direction.rs @@ -1,10 +1,6 @@ -//! This module defines the `Direction` enum, which is used to represent the -//! direction of an entity. use glam::IVec2; -use sdl2::keyboard::Keycode; -/// An enum representing the direction of an entity. -#[derive(Debug, Copy, Clone, PartialEq)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Direction { Up, Down, @@ -13,48 +9,29 @@ pub enum Direction { } impl Direction { - /// Returns the angle of the direction in degrees. - pub fn angle(&self) -> f64 { - match self { - Direction::Right => 0f64, - Direction::Down => 90f64, - Direction::Left => 180f64, - Direction::Up => 270f64, - } - } - - /// Returns the offset of the direction as a tuple of (x, y). - pub fn offset(&self) -> IVec2 { - match self { - Direction::Right => IVec2::new(1, 0), - Direction::Down => IVec2::new(0, 1), - Direction::Left => IVec2::new(-1, 0), - Direction::Up => IVec2::new(0, -1), - } - } - - /// Returns the opposite direction. pub fn opposite(&self) -> Direction { match self { - Direction::Right => Direction::Left, + Direction::Up => Direction::Down, Direction::Down => Direction::Up, Direction::Left => Direction::Right, - Direction::Up => Direction::Down, + Direction::Right => Direction::Left, } } - /// Creates a `Direction` from a `Keycode`. - /// - /// # Arguments - /// - /// * `keycode` - The keycode to convert. - pub fn from_keycode(keycode: Keycode) -> Option { - match keycode { - Keycode::D | Keycode::Right => Some(Direction::Right), - Keycode::A | Keycode::Left => Some(Direction::Left), - Keycode::W | Keycode::Up => Some(Direction::Up), - Keycode::S | Keycode::Down => Some(Direction::Down), - _ => None, + pub fn to_ivec2(&self) -> IVec2 { + (*self).into() + } +} + +impl From for IVec2 { + fn from(dir: Direction) -> Self { + match dir { + Direction::Up => -IVec2::Y, + Direction::Down => IVec2::Y, + Direction::Left => -IVec2::X, + Direction::Right => IVec2::X, } } } + +pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right]; diff --git a/src/entity/edible.rs b/src/entity/edible.rs deleted file mode 100644 index b5360a2..0000000 --- a/src/entity/edible.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Edible entity for Pac-Man: pellets, power pellets, and fruits. -use crate::constants::{FruitType, MapTile, BOARD_CELL_SIZE}; -use crate::entity::{Entity, Renderable, StaticEntity}; -use crate::map::Map; -use crate::texture::animated::AnimatedTexture; -use crate::texture::blinking::BlinkingTexture; -use anyhow::Result; -use glam::{IVec2, UVec2}; -use sdl2::render::WindowCanvas; -use std::cell::RefCell; -use std::rc::Rc; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EdibleKind { - Pellet, - PowerPellet, - Fruit(FruitType), -} - -pub enum EdibleSprite { - Pellet(AnimatedTexture), - PowerPellet(BlinkingTexture), -} - -pub struct Edible { - pub base: StaticEntity, - pub kind: EdibleKind, - pub sprite: EdibleSprite, -} - -impl Edible { - pub fn new_pellet(cell_position: UVec2, sprite: AnimatedTexture) -> Self { - let pixel_position = Map::cell_to_pixel(cell_position); - Edible { - base: StaticEntity::new(pixel_position, cell_position), - kind: EdibleKind::Pellet, - sprite: EdibleSprite::Pellet(sprite), - } - } - pub fn new_power_pellet(cell_position: UVec2, sprite: BlinkingTexture) -> Self { - let pixel_position = Map::cell_to_pixel(cell_position); - Edible { - base: StaticEntity::new(pixel_position, cell_position), - kind: EdibleKind::PowerPellet, - sprite: EdibleSprite::PowerPellet(sprite), - } - } - - /// Checks collision with Pac-Man (or any entity) - pub fn collide(&self, pacman: &dyn Entity) -> bool { - self.base.cell_position == pacman.base().cell_position - } -} - -impl Entity for Edible { - fn base(&self) -> &StaticEntity { - &self.base - } -} - -impl Renderable for Edible { - fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> { - let pos = self.base.pixel_position; - let dest = match &mut self.sprite { - EdibleSprite::Pellet(sprite) => { - let tile = sprite.current_tile(); - let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2); - let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2); - sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32) - } - EdibleSprite::PowerPellet(sprite) => { - let tile = sprite.animation.current_tile(); - let x = pos.x + ((crate::constants::CELL_SIZE as i32 - tile.size.x as i32) / 2); - let y = pos.y + ((crate::constants::CELL_SIZE as i32 - tile.size.y as i32) / 2); - sdl2::rect::Rect::new(x, y, tile.size.x as u32, tile.size.y as u32) - } - }; - - match &mut self.sprite { - EdibleSprite::Pellet(sprite) => sprite.render(canvas, dest), - EdibleSprite::PowerPellet(sprite) => sprite.render(canvas, dest), - } - } -} - -/// Reconstruct all edibles from the original map layout -pub fn reconstruct_edibles( - map: Rc>, - pellet_sprite: AnimatedTexture, - power_pellet_sprite: BlinkingTexture, - _fruit_sprite: AnimatedTexture, -) -> Vec { - let mut edibles = Vec::new(); - for x in 0..BOARD_CELL_SIZE.x { - for y in 0..BOARD_CELL_SIZE.y { - let tile = map.borrow().get_tile(IVec2::new(x as i32, y as i32)); - match tile { - Some(MapTile::Pellet) => { - edibles.push(Edible::new_pellet(UVec2::new(x, y), pellet_sprite.clone())); - } - Some(MapTile::PowerPellet) => { - edibles.push(Edible::new_power_pellet(UVec2::new(x, y), power_pellet_sprite.clone())); - } - // Fruits can be added here if you have fruit positions - _ => {} - } - } - } - edibles -} diff --git a/src/entity/ghost.rs b/src/entity/ghost.rs deleted file mode 100644 index 01dc214..0000000 --- a/src/entity/ghost.rs +++ /dev/null @@ -1,510 +0,0 @@ -use rand::rngs::SmallRng; -use rand::Rng; -use rand::SeedableRng; - -use crate::constants::MapTile; -use crate::constants::BOARD_CELL_SIZE; -use crate::entity::direction::Direction; -use crate::entity::pacman::Pacman; -use crate::entity::speed::SimpleTickModulator; -use crate::entity::{Entity, MovableEntity, Moving, Renderable}; -use crate::map::Map; -use crate::texture::{ - animated::AnimatedTexture, blinking::BlinkingTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, - sprite::SpriteAtlas, -}; -use anyhow::Result; -use glam::{IVec2, UVec2}; -use sdl2::pixels::Color; -use sdl2::render::WindowCanvas; -use std::cell::RefCell; -use std::rc::Rc; - -/// The different modes a ghost can be in -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum GhostMode { - /// Chase mode - ghost actively pursues Pac-Man using its unique strategy - Chase, - /// Scatter mode - ghost heads to its home corner - Scatter, - /// Frightened mode - ghost moves randomly and can be eaten - Frightened, - /// Eyes mode - ghost returns to the ghost house after being eaten - Eyes, - /// House mode - ghost is in the ghost house, waiting to exit - House(HouseMode), -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum HouseMode { - Entering, - Exiting, - Waiting, -} - -/// The different ghost personalities -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum GhostType { - Blinky, // Red - Shadow - Pinky, // Pink - Speedy - Inky, // Cyan - Bashful - Clyde, // Orange - Pokey -} - -impl GhostType { - /// Returns the color of the ghost. - pub fn color(&self) -> Color { - match self { - GhostType::Blinky => Color::RGB(255, 0, 0), - GhostType::Pinky => Color::RGB(255, 184, 255), - GhostType::Inky => Color::RGB(0, 255, 255), - GhostType::Clyde => Color::RGB(255, 184, 82), - } - } -} - -/// Base ghost struct that contains common functionality -pub struct Ghost { - /// Shared movement and position fields. - pub base: MovableEntity, - /// The current mode of the ghost - pub mode: GhostMode, - /// The type/personality of this ghost - pub ghost_type: GhostType, - /// Reference to Pac-Man for targeting - pub pacman: Rc>, - pub texture: DirectionalAnimatedTexture, - pub frightened_texture: BlinkingTexture, - pub eyes_texture: DirectionalAnimatedTexture, - pub house_offset: i32, - pub current_house_offset: i32, -} - -impl Ghost { - /// Creates a new ghost instance - pub fn new( - ghost_type: GhostType, - starting_position: UVec2, - atlas: Rc>, - map: Rc>, - pacman: Rc>, - house_offset: i32, - ) -> Ghost { - let pixel_position = Map::cell_to_pixel(starting_position); - let name = match ghost_type { - GhostType::Blinky => "blinky", - GhostType::Pinky => "pinky", - GhostType::Inky => "inky", - GhostType::Clyde => "clyde", - }; - let get = |dir: &str, suffix: &str| get_atlas_tile(&atlas, &format!("ghost/{name}/{dir}_{suffix}.png")); - - let texture = DirectionalAnimatedTexture::new( - vec![get("up", "a"), get("up", "b")], - vec![get("down", "a"), get("down", "b")], - vec![get("left", "a"), get("left", "b")], - vec![get("right", "a"), get("right", "b")], - 25, - ); - - let frightened_texture = BlinkingTexture::new( - AnimatedTexture::new( - vec![ - get_atlas_tile(&atlas, "ghost/frightened/blue_a.png"), - get_atlas_tile(&atlas, "ghost/frightened/blue_b.png"), - ], - 10, - ), - 45, - 15, - ); - - let eyes_get = |dir: &str| get_atlas_tile(&atlas, &format!("ghost/eyes/{dir}.png")); - - let eyes_texture = DirectionalAnimatedTexture::new( - vec![eyes_get("up")], - vec![eyes_get("down")], - vec![eyes_get("left")], - vec![eyes_get("right")], - 0, - ); - - Ghost { - base: MovableEntity::new( - pixel_position, - starting_position, - Direction::Left, - SimpleTickModulator::new(0.9375), - map, - ), - mode: GhostMode::House(HouseMode::Waiting), - ghost_type, - pacman, - texture, - frightened_texture, - eyes_texture, - house_offset, - current_house_offset: house_offset, - } - } - - /// Gets the target tile for this ghost based on its current mode - pub fn get_target_tile(&self) -> Option { - match self.mode { - GhostMode::Scatter => Some(self.get_scatter_target()), - GhostMode::Chase => Some(self.get_chase_target()), - GhostMode::Frightened => Some(self.get_random_target()), - GhostMode::Eyes => Some(self.get_house_target()), - GhostMode::House(_) => None, - } - } - - /// Gets this ghost's home corner target for scatter mode - fn get_scatter_target(&self) -> IVec2 { - match self.ghost_type { - GhostType::Blinky => IVec2::new(25, 0), // Top right - GhostType::Pinky => IVec2::new(2, 0), // Top left - GhostType::Inky => IVec2::new(27, 35), // Bottom right - GhostType::Clyde => IVec2::new(0, 35), // Bottom left - } - } - - /// Gets a random adjacent tile for frightened mode - fn get_random_target(&self) -> IVec2 { - let mut rng = SmallRng::from_os_rng(); - let mut possible_moves = Vec::new(); - - // Check all four directions - for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { - // Don't allow reversing direction - if *dir == self.base.direction.opposite() { - continue; - } - - let next_cell = self.base.next_cell(Some(*dir)); - if !matches!(self.base.map.borrow().get_tile(next_cell), Some(MapTile::Wall)) { - possible_moves.push(next_cell); - } - } - - if possible_moves.is_empty() { - // No valid moves, must reverse - self.base.next_cell(Some(self.base.direction.opposite())) - } else { - // Choose a random valid move - possible_moves[rng.random_range(0..possible_moves.len())] - } - } - - /// Gets the ghost house target for returning eyes - fn get_house_target(&self) -> IVec2 { - IVec2::new(13, 14) // Center of ghost house - } - - /// Gets this ghost's chase mode target based on its personality - fn get_chase_target(&self) -> IVec2 { - let pacman = self.pacman.borrow(); - let pacman_cell = pacman.base().cell_position; - let pacman_direction = pacman.base.direction; - - match self.ghost_type { - GhostType::Blinky => { - // Blinky (Red) - Directly targets Pac-Man's current position - IVec2::new(pacman_cell.x as i32, pacman_cell.y as i32) - } - GhostType::Pinky => { - // Pinky (Pink) - Targets 4 cells ahead of Pac-Man in his direction - let offset = pacman_direction.offset(); - let target_x = (pacman_cell.x as i32) + (offset.x * 4); - let target_y = (pacman_cell.y as i32) + (offset.y * 4); - IVec2::new(target_x, target_y) - } - GhostType::Inky => { - // Inky (Cyan) - Uses Blinky's position and Pac-Man's position to calculate target - // For now, just target Pac-Man with some randomness - let mut rng = SmallRng::from_os_rng(); - let random_offset_x = rng.random_range(-2..=2); - let random_offset_y = rng.random_range(-2..=2); - IVec2::new( - (pacman_cell.x as i32) + random_offset_x, - (pacman_cell.y as i32) + random_offset_y, - ) - } - GhostType::Clyde => { - // Clyde (Orange) - Targets Pac-Man when far, runs to scatter corner when close - let distance = ((self.base.base.cell_position.x as i32 - pacman_cell.x as i32).pow(2) - + (self.base.base.cell_position.y as i32 - pacman_cell.y as i32).pow(2)) - as f32; - let distance = distance.sqrt(); - - if distance > 8.0 { - // Far from Pac-Man - chase - IVec2::new(pacman_cell.x as i32, pacman_cell.y as i32) - } else { - // Close to Pac-Man - scatter to bottom left - IVec2::new(0, 35) - } - } - } - } - - /// Calculates the path to the target tile using the A* algorithm. - pub fn get_path_to_target(&self, target: UVec2) -> Option<(Vec, u32)> { - let start = self.base.base.cell_position; - let map = self.base.map.borrow(); - use pathfinding::prelude::dijkstra; - dijkstra( - &start, - |&p| { - let mut successors = vec![]; - let tile = map.get_tile(IVec2::new(p.x as i32, p.y as i32)); - // Tunnel wrap: if currently in a tunnel, add the opposite exit as a neighbor - if let Some(MapTile::Tunnel) = tile { - if p.x == 0 { - successors.push((UVec2::new(BOARD_CELL_SIZE.x - 2, p.y), 1)); - } else if p.x == BOARD_CELL_SIZE.x - 1 { - successors.push((UVec2::new(1, p.y), 1)); - } - } - for dir in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { - let offset = dir.offset(); - let next_p = IVec2::new(p.x as i32 + offset.x, p.y as i32 + offset.y); - if let Some(tile) = map.get_tile(next_p) { - if tile == MapTile::Wall { - continue; - } - let next_u = UVec2::new(next_p.x as u32, next_p.y as u32); - successors.push((next_u, 1)); - } - } - successors - }, - |&p| p == target, - ) - } - - /// Changes the ghost's mode and handles direction reversal - pub fn set_mode(&mut self, new_mode: GhostMode) { - // Don't reverse if going to/from frightened or if in house - let should_reverse = !matches!(self.mode, GhostMode::House(_)) - && !matches!(new_mode, GhostMode::House(_)) - && !matches!(self.mode, GhostMode::Frightened) - && !matches!(new_mode, GhostMode::Frightened); - - self.mode = new_mode; - - self.base.speed.set_speed(match new_mode { - GhostMode::Chase => 0.9375, - GhostMode::Scatter => 0.85, - GhostMode::Frightened => 0.7, - GhostMode::Eyes => 1.5, - GhostMode::House(_) => 0.7, - }); - - if should_reverse { - self.base.set_direction_if_valid(self.base.direction.opposite()); - } - } - - pub fn tick(&mut self) { - if let GhostMode::House(house_mode) = self.mode { - match house_mode { - HouseMode::Waiting => { - // Ghosts in waiting mode move up and down - if self.base.is_grid_aligned() { - self.base.update_cell_position(); - - // Simple up and down movement - let current_pos = self.base.base.cell_position; - let start_pos = UVec2::new(13, 14); // Center of ghost house - - if current_pos.y > start_pos.y + 1 { - // Too far down, move up - self.base.set_direction_if_valid(Direction::Up); - } else if current_pos.y < start_pos.y - 1 { - // Too far up, move down - self.base.set_direction_if_valid(Direction::Down); - } else if self.base.direction == Direction::Up { - // At top, switch to down - self.base.set_direction_if_valid(Direction::Down); - } else if self.base.direction == Direction::Down { - // At bottom, switch to up - self.base.set_direction_if_valid(Direction::Up); - } - } - } - HouseMode::Exiting => { - // Ghosts exiting move towards the exit - if self.base.is_grid_aligned() { - self.base.update_cell_position(); - - let exit_pos = UVec2::new(13, 11); - let current_pos = self.base.base.cell_position; - - // Determine direction to exit - if current_pos.y > exit_pos.y { - // Need to move up - self.base.set_direction_if_valid(Direction::Up); - } else if current_pos.y == exit_pos.y && current_pos.x != exit_pos.x { - // At exit level, move horizontally to center - if current_pos.x < exit_pos.x { - self.base.set_direction_if_valid(Direction::Right); - } else { - self.base.set_direction_if_valid(Direction::Left); - } - } else if current_pos == exit_pos { - // Reached exit, transition to chase mode - self.mode = GhostMode::Chase; - self.current_house_offset = 0; // Reset offset - } - } - } - HouseMode::Entering => { - // Ghosts entering move towards their starting position - if self.base.is_grid_aligned() { - self.base.update_cell_position(); - - let start_pos = UVec2::new(13, 14); // Center of ghost house - let current_pos = self.base.base.cell_position; - - // Determine direction to starting position - if current_pos.y < start_pos.y { - // Need to move down - self.base.set_direction_if_valid(Direction::Down); - } else if current_pos.y == start_pos.y && current_pos.x != start_pos.x { - // At house level, move horizontally to center - if current_pos.x < start_pos.x { - self.base.set_direction_if_valid(Direction::Right); - } else { - self.base.set_direction_if_valid(Direction::Left); - } - } else if current_pos == start_pos { - // Reached starting position, switch to waiting - self.mode = GhostMode::House(HouseMode::Waiting); - } - } - } - } - - // Update house offset for smooth transitions - if self.current_house_offset != 0 { - // Gradually reduce offset when turning - if self.base.direction == Direction::Left || self.base.direction == Direction::Right { - if self.current_house_offset > 0 { - self.current_house_offset -= 1; - } else if self.current_house_offset < 0 { - self.current_house_offset += 1; - } - } - } - - self.base.tick(); - self.texture.tick(); - self.frightened_texture.tick(); - self.eyes_texture.tick(); - return; - } - - // Normal ghost behavior - if self.base.is_grid_aligned() { - self.base.update_cell_position(); - if !self.base.handle_tunnel() { - // Pathfinding logic (only if not in tunnel) - if let Some(target_tile) = self.get_target_tile() { - if let Some((path, _)) = self.get_path_to_target(target_tile.as_uvec2()) { - if path.len() > 1 { - let next_move = path[1]; - let x = self.base.base.cell_position.x; - let y = self.base.base.cell_position.y; - let dx = next_move.x as i32 - x as i32; - let dy = next_move.y as i32 - y as i32; - let new_direction = if dx > 0 { - Direction::Right - } else if dx < 0 { - Direction::Left - } else if dy > 0 { - Direction::Down - } else { - Direction::Up - }; - self.base.set_direction_if_valid(new_direction); - } - } - } - } - } - - // Handle house offset transition when turning - if self.current_house_offset != 0 { - if self.base.direction == Direction::Left || self.base.direction == Direction::Right { - if self.current_house_offset > 0 { - self.current_house_offset -= 1; - } else if self.current_house_offset < 0 { - self.current_house_offset += 1; - } - } - } - - self.base.tick(); // Handles wall collision and movement - self.texture.tick(); - self.frightened_texture.tick(); - self.eyes_texture.tick(); - } -} - -impl Moving for Ghost { - fn tick_movement(&mut self) { - self.base.tick_movement(); - } - fn tick(&mut self) { - self.base.tick(); - } - fn update_cell_position(&mut self) { - self.base.update_cell_position(); - } - fn next_cell(&self, direction: Option) -> IVec2 { - self.base.next_cell(direction) - } - fn is_wall_ahead(&self, direction: Option) -> bool { - self.base.is_wall_ahead(direction) - } - fn handle_tunnel(&mut self) -> bool { - self.base.handle_tunnel() - } - fn is_grid_aligned(&self) -> bool { - self.base.is_grid_aligned() - } - fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool { - self.base.set_direction_if_valid(new_direction) - } -} - -impl Renderable for Ghost { - fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> { - let mut pos = self.base.base.pixel_position; - let dir = self.base.direction; - - // Apply house offset if in house mode or transitioning - if matches!(self.mode, GhostMode::House(_)) || self.current_house_offset != 0 { - pos.x += self.current_house_offset; - } - - match self.mode { - GhostMode::Frightened => { - let tile = self.frightened_texture.animation.current_tile(); - let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32); - self.frightened_texture.render(canvas, dest) - } - GhostMode::Eyes => { - let tile = self.eyes_texture.up.first().unwrap(); - let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32); - self.eyes_texture.render(canvas, dest, dir) - } - _ => { - let tile = self.texture.up.first().unwrap(); - let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, tile.size.x as u32, tile.size.y as u32); - self.texture.render(canvas, dest, dir) - } - } - } -} diff --git a/src/entity/graph.rs b/src/entity/graph.rs new file mode 100644 index 0000000..1c1e46b --- /dev/null +++ b/src/entity/graph.rs @@ -0,0 +1,274 @@ +use glam::Vec2; +use smallvec::SmallVec; + +use super::direction::Direction; + +/// A unique identifier for a node, represented by its index in the graph's storage. +pub type NodeId = usize; + +/// Represents a directed edge from one node to another with a given weight (e.g., distance). +#[derive(Debug, Clone, Copy)] +pub struct Edge { + pub target: NodeId, + pub distance: f32, + pub direction: Direction, +} + +#[derive(Debug)] +pub struct Node { + pub position: Vec2, +} + +/// A generic, arena-based graph. +/// The graph owns all node data and connection information. +pub struct Graph { + nodes: Vec, + adjacency_list: Vec>, +} + +impl Graph { + /// Creates a new, empty graph. + pub fn new() -> Self { + Graph { + nodes: Vec::new(), + adjacency_list: Vec::new(), + } + } + + /// Adds a new node with the given data to the graph and returns its ID. + pub fn add_node(&mut self, data: Node) -> NodeId { + let id = self.nodes.len(); + self.nodes.push(data); + self.adjacency_list.push(SmallVec::new()); + id + } + + /// Adds a directed edge between two nodes. + pub fn add_edge( + &mut self, + from: NodeId, + to: NodeId, + distance: Option, + direction: Direction, + ) -> Result<(), &'static str> { + let edge = Edge { + target: to, + distance: match distance { + Some(distance) => { + if distance <= 0.0 { + return Err("Edge distance must be positive."); + } + distance + } + None => { + // If no distance is provided, calculate it based on the positions of the nodes + let from_pos = self.nodes[from].position; + let to_pos = self.nodes[to].position; + from_pos.distance(to_pos) + } + }, + direction, + }; + + if from >= self.adjacency_list.len() { + return Err("From node does not exist."); + } + + let adjacency_list = &mut self.adjacency_list[from]; + + // Check if the edge already exists in this direction or to the same target + if let Some(err) = adjacency_list.iter().find_map(|e| { + if e.direction == direction { + Some(Err("Edge already exists in this direction.")) + } else if e.target == to { + Some(Err("Edge already exists.")) + } else { + None + } + }) { + return err; + } + + adjacency_list.push(edge); + + Ok(()) + } + + /// Retrieves an immutable reference to a node's data. + pub fn get_node(&self, id: NodeId) -> Option<&Node> { + self.nodes.get(id) + } + + pub fn node_count(&self) -> usize { + self.nodes.len() + } + + /// Finds a specific edge from a source node to a target node. + pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option<&Edge> { + self.adjacency_list.get(from)?.iter().find(|edge| edge.target == to) + } + + pub fn find_edge_in_direction(&self, from: NodeId, direction: Direction) -> Option<&Edge> { + self.adjacency_list.get(from)?.iter().find(|edge| edge.direction == direction) + } +} + +// Default implementation for creating an empty graph. +impl Default for Graph { + fn default() -> Self { + Self::new() + } +} + +// --- Traversal State and Logic --- + +/// Represents the traverser's current position within the graph. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Position { + /// The traverser is located exactly at a node. + AtNode(NodeId), + /// The traverser is on an edge between two nodes. + BetweenNodes { + from: NodeId, + to: NodeId, + /// The floating-point distance traversed along the edge from the `from` node. + traversed: f32, + }, +} + +impl Position { + pub fn is_at_node(&self) -> bool { + matches!(self, Position::AtNode(_)) + } + + pub fn from_node_id(&self) -> NodeId { + match self { + Position::AtNode(id) => *id, + Position::BetweenNodes { from, .. } => *from, + } + } + + pub fn to_node_id(&self) -> Option { + match self { + Position::AtNode(_) => None, + Position::BetweenNodes { to, .. } => Some(*to), + } + } + + pub fn is_stopped(&self) -> bool { + matches!(self, Position::AtNode(_)) + } +} + +/// Manages a traversal session over a graph. +/// It holds a reference to the graph and the current position state. +pub struct Traverser { + pub position: Position, + pub direction: Direction, + pub next_direction: Option<(Direction, u8)>, +} + +impl Traverser { + /// Creates a new traverser starting at the given node ID. + pub fn new(graph: &Graph, start_node: NodeId, initial_direction: Direction) -> Self { + let mut traverser = Traverser { + position: Position::AtNode(start_node), + direction: initial_direction, + next_direction: Some((initial_direction, 1)), + }; + + // This will kickstart the traverser into motion + traverser.advance(graph, 0.0); + + traverser + } + + pub fn set_next_direction(&mut self, new_direction: Direction) { + if self.direction != new_direction { + self.next_direction = Some((new_direction, 30)); + } + } + + pub fn advance(&mut self, graph: &Graph, distance: f32) { + // Decrement the remaining frames for the next direction + if let Some((direction, remaining)) = self.next_direction { + if remaining > 0 { + self.next_direction = Some((direction, remaining - 1)); + } else { + self.next_direction = None; + } + } + + match self.position { + Position::AtNode(node_id) => { + // We're not moving, but a buffered direction is available. + if let Some((next_direction, _)) = self.next_direction { + if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { + // Start moving in that direction + self.position = Position::BetweenNodes { + from: node_id, + to: edge.target, + traversed: distance.max(0.0), + }; + self.direction = next_direction; + } + + self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it + } + } + Position::BetweenNodes { from, to, traversed } => { + // There is no point in any of the next logic if we don't travel at all + if distance <= 0.0 { + return; + } + + let edge = graph + .find_edge(from, to) + .expect("Inconsistent state: Traverser is on a non-existent edge."); + + let new_traversed = traversed + distance; + + if new_traversed < edge.distance { + // Still on the same edge, just update the distance. + self.position = Position::BetweenNodes { + from, + to, + traversed: new_traversed, + }; + } else { + let overflow = new_traversed - edge.distance; + let mut moved = false; + + // If we buffered a direction, try to find an edge in that direction + if let Some((next_dir, _)) = self.next_direction { + if let Some(edge) = graph.find_edge_in_direction(to, next_dir) { + self.position = Position::BetweenNodes { + from: to, + to: edge.target, + traversed: overflow, + }; + + self.direction = next_dir; // Remember our new direction + self.next_direction = None; // Consume the buffered direction + moved = true; + } + } + + // If we didn't move, try to continue in the current direction + if !moved { + if let Some(edge) = graph.find_edge_in_direction(to, self.direction) { + self.position = Position::BetweenNodes { + from: to, + to: edge.target, + traversed: overflow, + }; + } else { + self.position = Position::AtNode(to); + self.next_direction = None; + } + } + } + } + } + } +} diff --git a/src/entity/mod.rs b/src/entity/mod.rs index 0ae1b10..aa9d987 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,205 +1,3 @@ pub mod direction; -pub mod edible; -pub mod ghost; +pub mod graph; pub mod pacman; -pub mod speed; - -use crate::{ - constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, CELL_SIZE}, - entity::{direction::Direction, speed::SimpleTickModulator}, - map::Map, -}; -use anyhow::Result; -use glam::{IVec2, UVec2}; -use sdl2::render::WindowCanvas; -use std::cell::RefCell; -use std::rc::Rc; - -/// A trait for game objects that can be moved and rendered. -pub trait Entity { - /// Returns a reference to the base entity (position, etc). - fn base(&self) -> &StaticEntity; - - /// Returns true if the entity is colliding with the other entity. - fn is_colliding(&self, other: &dyn Entity) -> bool { - let a = self.base().cell_position; - let b = other.base().cell_position; - a == b - } -} - -/// A trait for entities that can move and interact with the map. -pub trait Moving { - fn tick(&mut self) { - self.base_tick(); - } - fn base_tick(&mut self) { - if self.is_grid_aligned() { - self.on_grid_aligned(); - } - self.tick_movement(); - } - /// Called when the entity is grid-aligned. Default does nothing. - fn on_grid_aligned(&mut self) {} - /// Handles movement and wall collision. Default uses tick logic from MovableEntity. - fn tick_movement(&mut self); - fn update_cell_position(&mut self); - fn next_cell(&self, direction: Option) -> IVec2; - fn is_wall_ahead(&self, direction: Option) -> bool; - fn handle_tunnel(&mut self) -> bool; - fn is_grid_aligned(&self) -> bool; - fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool; -} - -/// Trait for entities that support queued direction changes. -pub trait QueuedDirection: Moving { - fn next_direction(&self) -> Option; - fn set_next_direction(&mut self, dir: Option); - /// Handles a requested direction change if possible. - fn handle_direction_change(&mut self) -> bool { - if let Some(next_direction) = self.next_direction() { - if self.set_direction_if_valid(next_direction) { - self.set_next_direction(None); - return true; - } - } - false - } -} - -/// A struct for static (non-moving) entities with position only. -pub struct StaticEntity { - pub pixel_position: IVec2, - pub cell_position: UVec2, -} - -impl StaticEntity { - pub fn new(pixel_position: IVec2, cell_position: UVec2) -> Self { - Self { - pixel_position, - cell_position, - } - } -} - -/// A struct for movable game entities with position, direction, speed, and modulation. -pub struct MovableEntity { - pub base: StaticEntity, - pub direction: Direction, - pub speed: SimpleTickModulator, - pub in_tunnel: bool, - pub map: Rc>, -} - -impl MovableEntity { - pub fn new( - pixel_position: IVec2, - cell_position: UVec2, - direction: Direction, - speed: SimpleTickModulator, - map: Rc>, - ) -> Self { - Self { - base: StaticEntity::new(pixel_position, cell_position), - direction, - speed, - in_tunnel: false, - map, - } - } - - /// Returns the position within the current cell, in pixels. - pub fn internal_position(&self) -> UVec2 { - UVec2::new( - (self.base.pixel_position.x as u32) % CELL_SIZE, - (self.base.pixel_position.y as u32) % CELL_SIZE, - ) - } -} - -impl Entity for MovableEntity { - fn base(&self) -> &StaticEntity { - &self.base - } -} - -impl Moving for MovableEntity { - fn tick_movement(&mut self) { - if self.speed.next() && !self.is_wall_ahead(None) { - match self.direction { - Direction::Right => self.base.pixel_position.x += 1, - Direction::Left => self.base.pixel_position.x -= 1, - Direction::Up => self.base.pixel_position.y -= 1, - Direction::Down => self.base.pixel_position.y += 1, - } - if self.is_grid_aligned() { - self.update_cell_position(); - } - } - if self.is_grid_aligned() { - self.update_cell_position(); - } - } - fn update_cell_position(&mut self) { - self.base.cell_position = UVec2::new( - (self.base.pixel_position.x as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.x, - (self.base.pixel_position.y as u32 / CELL_SIZE) - BOARD_CELL_OFFSET.y, - ); - } - fn next_cell(&self, direction: Option) -> IVec2 { - let IVec2 { x, y } = direction.unwrap_or(self.direction).offset(); - IVec2::new(self.base.cell_position.x as i32 + x, self.base.cell_position.y as i32 + y) - } - fn is_wall_ahead(&self, direction: Option) -> bool { - let next_cell = self.next_cell(direction); - matches!(self.map.borrow().get_tile(next_cell), Some(MapTile::Wall)) - } - fn handle_tunnel(&mut self) -> bool { - let x = self.base.cell_position.x; - let at_left_tunnel = x == 0; - let at_right_tunnel = x == BOARD_CELL_SIZE.x - 1; - - // Reset tunnel state if we're not at a tunnel position - if !at_left_tunnel && !at_right_tunnel { - self.in_tunnel = false; - return false; - } - - // If we're already in a tunnel, stay in tunnel state - if self.in_tunnel { - return true; - } - - // Enter the tunnel and teleport to the other side - let new_x = if at_left_tunnel { BOARD_CELL_SIZE.x - 2 } else { 1 }; - self.base.cell_position.x = new_x; - self.base.pixel_position = Map::cell_to_pixel(self.base.cell_position); - - self.in_tunnel = true; - true - } - fn is_grid_aligned(&self) -> bool { - self.internal_position() == UVec2::ZERO - } - fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool { - if new_direction == self.direction { - return false; - } - if self.is_wall_ahead(Some(new_direction)) { - return false; - } - self.direction = new_direction; - true - } -} - -impl Entity for StaticEntity { - fn base(&self) -> &StaticEntity { - self - } -} - -/// A trait for entities that can be rendered to the screen. -pub trait Renderable { - fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()>; -} diff --git a/src/entity/pacman.rs b/src/entity/pacman.rs index 7687576..2a1126b 100644 --- a/src/entity/pacman.rs +++ b/src/entity/pacman.rs @@ -1,143 +1,93 @@ -//! This module defines the Pac-Man entity, including its behavior and rendering. -use anyhow::Result; -use glam::{IVec2, UVec2}; -use sdl2::render::WindowCanvas; -use std::cell::RefCell; -use std::rc::Rc; +use glam::Vec2; -use crate::{ - entity::speed::SimpleTickModulator, - entity::{direction::Direction, Entity, MovableEntity, Moving, QueuedDirection, Renderable, StaticEntity}, - map::Map, - texture::{animated::AnimatedTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, sprite::SpriteAtlas}, -}; +use crate::constants::BOARD_PIXEL_OFFSET; +use crate::entity::direction::Direction; +use crate::entity::graph::{Graph, NodeId, Position, Traverser}; +use crate::texture::animated::AnimatedTexture; +use crate::texture::directional::DirectionalAnimatedTexture; +use crate::texture::sprite::SpriteAtlas; +use sdl2::keyboard::Keycode; +use sdl2::rect::Rect; +use sdl2::render::{Canvas, RenderTarget}; +use std::collections::HashMap; -/// The Pac-Man entity. pub struct Pacman { - /// Shared movement and position fields. - pub base: MovableEntity, - /// The next direction of Pac-Man, which will be applied when Pac-Man is next aligned with the grid. - pub next_direction: Option, - /// Whether Pac-Man is currently stopped. - pub stopped: bool, - pub skip_move_tick: bool, - pub texture: DirectionalAnimatedTexture, - pub death_animation: AnimatedTexture, -} - -impl Entity for Pacman { - fn base(&self) -> &StaticEntity { - &self.base.base - } -} - -impl Moving for Pacman { - fn tick_movement(&mut self) { - if self.skip_move_tick { - self.skip_move_tick = false; - return; - } - self.base.tick_movement(); - } - fn update_cell_position(&mut self) { - self.base.update_cell_position(); - } - fn next_cell(&self, direction: Option) -> IVec2 { - self.base.next_cell(direction) - } - fn is_wall_ahead(&self, direction: Option) -> bool { - self.base.is_wall_ahead(direction) - } - fn handle_tunnel(&mut self) -> bool { - self.base.handle_tunnel() - } - fn is_grid_aligned(&self) -> bool { - self.base.is_grid_aligned() - } - fn set_direction_if_valid(&mut self, new_direction: Direction) -> bool { - self.base.set_direction_if_valid(new_direction) - } - fn on_grid_aligned(&mut self) { - Pacman::update_cell_position(self); - if !::handle_tunnel(self) { - ::handle_direction_change(self); - if !self.stopped && ::is_wall_ahead(self, None) { - self.stopped = true; - } else if self.stopped && !::is_wall_ahead(self, None) { - self.stopped = false; - } - } - } -} - -impl QueuedDirection for Pacman { - fn next_direction(&self) -> Option { - self.next_direction - } - fn set_next_direction(&mut self, dir: Option) { - self.next_direction = dir; - } + pub traverser: Traverser, + texture: DirectionalAnimatedTexture, } impl Pacman { - /// Creates a new `Pacman` instance. - pub fn new(starting_position: UVec2, atlas: Rc>, map: Rc>) -> Pacman { - let pixel_position = Map::cell_to_pixel(starting_position); - let get = |name: &str| get_atlas_tile(&atlas, name); + pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self { + let mut textures = HashMap::new(); + let mut stopped_textures = HashMap::new(); - Pacman { - base: MovableEntity::new( - pixel_position, - starting_position, - Direction::Right, - SimpleTickModulator::new(1f32), - map, - ), - next_direction: None, - stopped: false, - skip_move_tick: false, - texture: DirectionalAnimatedTexture::new( - vec![get("pacman/up_a.png"), get("pacman/up_b.png"), get("pacman/full.png")], - vec![get("pacman/down_a.png"), get("pacman/down_b.png"), get("pacman/full.png")], - vec![get("pacman/left_a.png"), get("pacman/left_b.png"), get("pacman/full.png")], - vec![get("pacman/right_a.png"), get("pacman/right_b.png"), get("pacman/full.png")], - 8, - ), - death_animation: AnimatedTexture::new( - (0..=10) - .map(|i| get_atlas_tile(&atlas, &format!("pacman/death/{i}.png"))) - .collect(), - 5, - ), + for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] { + let moving_prefix = match direction { + Direction::Up => "pacman/up", + Direction::Down => "pacman/down", + Direction::Left => "pacman/left", + Direction::Right => "pacman/right", + }; + let moving_tiles = vec![ + SpriteAtlas::get_tile(&atlas, &format!("{}_a.png", moving_prefix)).unwrap(), + SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap(), + SpriteAtlas::get_tile(&atlas, "pacman/full.png").unwrap(), + ]; + + let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap()]; + + textures.insert(direction, AnimatedTexture::new(moving_tiles, 0.08)); + stopped_textures.insert(direction, AnimatedTexture::new(stopped_tiles, 0.1)); + } + + Self { + traverser: Traverser::new(graph, start_node, Direction::Left), + texture: DirectionalAnimatedTexture::new(textures, stopped_textures), } } - /// Returns the internal position of Pac-Man, rounded down to the nearest even number. - fn internal_position_even(&self) -> UVec2 { - let pos = self.base.internal_position(); - UVec2::new((pos.x / 2) * 2, (pos.y / 2) * 2) + pub fn tick(&mut self, dt: f32, graph: &Graph) { + self.traverser.advance(graph, dt * 60.0 * 1.125); + self.texture.tick(dt); } - pub fn tick(&mut self) { - ::tick(self); - self.texture.tick(); + pub fn handle_key(&mut self, keycode: Keycode) { + let direction = match keycode { + Keycode::Up => Some(Direction::Up), + Keycode::Down => Some(Direction::Down), + Keycode::Left => Some(Direction::Left), + Keycode::Right => Some(Direction::Right), + _ => None, + }; + + if let Some(direction) = direction { + self.traverser.set_next_direction(direction); + } } -} -impl Renderable for Pacman { - fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> { - let pos = self.base.base.pixel_position; - let dir = self.base.direction; + fn get_pixel_pos(&self, graph: &Graph) -> Vec2 { + match self.traverser.position { + Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position, + Position::BetweenNodes { from, to, traversed } => { + let from_pos = graph.get_node(from).unwrap().position; + let to_pos = graph.get_node(to).unwrap().position; + let weight = from_pos.distance(to_pos); + from_pos.lerp(to_pos, traversed / weight) + } + } + } - // Center the 16x16 sprite on the 8x8 cell by offsetting by -4 - let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, 16, 16); + pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, graph: &Graph) { + let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2(); + let dest = Rect::new(pixel_pos.x - 8, pixel_pos.y - 8, 16, 16); + let is_stopped = self.traverser.position.is_stopped(); - if self.stopped { - // When stopped, show the full sprite (mouth open) - self.texture.render_stopped(canvas, dest, dir)?; + if is_stopped { + self.texture + .render_stopped(canvas, atlas, dest, self.traverser.direction) + .unwrap(); } else { - self.texture.render(canvas, dest, dir)?; + self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap(); } - Ok(()) } } diff --git a/src/entity/speed.rs b/src/entity/speed.rs deleted file mode 100644 index 273aa44..0000000 --- a/src/entity/speed.rs +++ /dev/null @@ -1,56 +0,0 @@ -//! This module provides a tick modulator, which can be used to slow down -//! operations by a percentage. -/// A tick modulator allows you to slow down operations by a percentage. -/// -/// Unfortunately, switching to floating point numbers for entities can induce floating point errors, slow down calculations -/// and make the game less deterministic. This is why we use a speed modulator instead. -/// Additionally, with small integers, lowering the speed by a percentage is not possible. For example, if we have a speed of 2, -/// and we want to slow it down by 10%, we would need to slow it down by 0.2. However, since we are using integers, we can't. -/// The only amount you can slow it down by is 1, which is 50% of the speed. -/// -/// The basic principle of the Speed Modulator is to instead 'skip' movement ticks every now and then. -/// At 60 ticks per second, skips could happen several times per second, or once every few seconds. -/// Whatever it be, as long as the tick rate is high enough, the human eye will not be able to tell the difference. -/// -/// For example, if we want to slow down the speed by 10%, we would need to skip every 10th tick. -pub trait TickModulator { - /// Creates a new tick modulator. - /// - /// # Arguments - /// - /// * `percent` - The percentage to slow down by, from 0.0 to 1.0. - fn new(percent: f32) -> Self; - /// Returns whether or not the operation should be performed on this tick. - fn next(&mut self) -> bool; - fn set_speed(&mut self, speed: f32); -} - -/// A simple tick modulator that skips every Nth tick. -pub struct SimpleTickModulator { - accumulator: f32, - pixels_per_tick: f32, -} - -// TODO: Add tests for the tick modulator to ensure that it is working correctly. -// TODO: Look into average precision and binary code modulation strategies to see -// if they would be a better fit for this use case. -impl SimpleTickModulator { - pub fn new(pixels_per_tick: f32) -> Self { - Self { - accumulator: 0f32, - pixels_per_tick: pixels_per_tick * 0.47, - } - } - pub fn set_speed(&mut self, pixels_per_tick: f32) { - self.pixels_per_tick = pixels_per_tick; - } - pub fn next(&mut self) -> bool { - self.accumulator += self.pixels_per_tick; - if self.accumulator >= 1f32 { - self.accumulator -= 1f32; - true - } else { - false - } - } -} diff --git a/src/game.rs b/src/game.rs index d861eac..12e4292 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,71 +1,59 @@ //! This module contains the main game logic and state. -use std::cell::RefCell; -use std::ops::Not; -use std::rc::Rc; +use std::time::{Duration, Instant}; use anyhow::Result; use glam::UVec2; +use sdl2::{ + image::LoadTexture, + keyboard::Keycode, + pixels::Color, + render::{Canvas, RenderTarget, Texture, TextureCreator}, + video::WindowContext, +}; -use sdl2::image::LoadTexture; -use sdl2::keyboard::Keycode; - -use sdl2::render::{Texture, TextureCreator}; -use sdl2::video::WindowContext; -use sdl2::{pixels::Color, render::Canvas, video::Window}; - -use crate::asset::{get_asset_bytes, Asset}; -use crate::audio::Audio; -use crate::constants::RAW_BOARD; -use crate::debug::{DebugMode, DebugRenderer}; -use crate::entity::direction::Direction; -use crate::entity::edible::{reconstruct_edibles, Edible, EdibleKind}; -use crate::entity::ghost::{Ghost, GhostMode, GhostType, HouseMode}; -use crate::entity::pacman::Pacman; -use crate::entity::Renderable; -use crate::map::Map; -use crate::texture::animated::AnimatedTexture; -use crate::texture::blinking::BlinkingTexture; -use crate::texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}; -use crate::texture::text::TextTexture; -use crate::texture::{get_atlas_tile, sprite}; +use crate::{ + asset::{get_asset_bytes, Asset}, + audio::Audio, + constants::RAW_BOARD, + entity::pacman::Pacman, + map::Map, + texture::{ + sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas}, + text::TextTexture, + }, +}; /// The main game state. /// /// Contains all the information necessary to run the game, including /// the game state, rendering resources, and audio. pub struct Game { - // Game state - pacman: Rc>, - blinky: Ghost, - pinky: Ghost, - inky: Ghost, - clyde: Ghost, - edibles: Vec, - map: Rc>, - score: u32, - debug_mode: DebugMode, - - // FPS tracking - fps_1s: f64, - fps_10s: f64, + pub score: u32, + pub map: Map, + pub pacman: Pacman, + pub debug_mode: bool, // Rendering resources - atlas: Rc>, + atlas: SpriteAtlas, map_texture: AtlasTile, text_texture: TextTexture, + debug_text_texture: TextTexture, // Audio pub audio: Audio, } impl Game { - /// Creates a new `Game` instance. pub fn new( texture_creator: &TextureCreator, _ttf_context: &sdl2::ttf::Sdl2TtfContext, _audio_subsystem: &sdl2::AudioSubsystem, ) -> Game { - let map = Rc::new(RefCell::new(Map::new(RAW_BOARD))); + let map = Map::new(RAW_BOARD); + + let _pacman_start_pos = map.find_starting_position(0).unwrap(); + let pacman_start_node = 0; // TODO: Find the actual start node + let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset"); let atlas_texture = unsafe { let texture = texture_creator @@ -75,318 +63,65 @@ impl Game { }; let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset"); let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON"); - let atlas = Rc::new(RefCell::new(SpriteAtlas::new(atlas_texture, atlas_mapper))); - let pacman = Rc::new(RefCell::new(Pacman::new( - UVec2::new(1, 1), - Rc::clone(&atlas), - Rc::clone(&map), - ))); + let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); - // Find starting positions - let pacman_start = map.borrow().find_starting_position(0).unwrap_or(UVec2::new(13, 23)); - let blinky_start = map.borrow().find_starting_position(1).unwrap_or(UVec2::new(13, 11)); - let pinky_start = map.borrow().find_starting_position(2).unwrap_or(UVec2::new(13, 14)); - let inky_start = map.borrow().find_starting_position(3).unwrap_or(UVec2::new(13, 14)); - let clyde_start = map.borrow().find_starting_position(4).unwrap_or(UVec2::new(13, 14)); - - // Update Pac-Man to proper starting position - { - let mut pacman_mut = pacman.borrow_mut(); - pacman_mut.base.base.pixel_position = Map::cell_to_pixel(pacman_start); - pacman_mut.base.base.cell_position = pacman_start; - } - - let mut blinky = Ghost::new( - GhostType::Blinky, - blinky_start, - Rc::clone(&atlas), - Rc::clone(&map), - Rc::clone(&pacman), - -8, - ); - blinky.mode = GhostMode::Chase; - - let mut pinky = Ghost::new( - GhostType::Pinky, - pinky_start, - Rc::clone(&atlas), - Rc::clone(&map), - Rc::clone(&pacman), - 8, - ); - pinky.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - - let mut inky = Ghost::new( - GhostType::Inky, - inky_start, - Rc::clone(&atlas), - Rc::clone(&map), - Rc::clone(&pacman), - -8, - ); - inky.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - - let mut clyde = Ghost::new( - GhostType::Clyde, - clyde_start, - Rc::clone(&atlas), - Rc::clone(&map), - Rc::clone(&pacman), - 8, - ); - clyde.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - let mut map_texture = get_atlas_tile(&atlas, "maze/full.png"); + let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile"); map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9)); - let edibles = reconstruct_edibles( - Rc::clone(&map), - AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/pellet.png")], 0), - BlinkingTexture::new( - AnimatedTexture::new(vec![get_atlas_tile(&atlas, "maze/energizer.png")], 0), - 17, - 17, - ), - AnimatedTexture::new(vec![get_atlas_tile(&atlas, "edible/cherry.png")], 0), - ); - let text_texture = TextTexture::new(Rc::clone(&atlas), 1.0); + let text_texture = TextTexture::new(1.0); + let debug_text_texture = TextTexture::new(0.5); let audio = Audio::new(); + let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas); Game { - pacman, - blinky, - pinky, - inky, - clyde, - edibles, - map, score: 0, - debug_mode: DebugMode::None, - atlas, + map, + pacman, + debug_mode: false, map_texture, text_texture, + debug_text_texture, audio, - fps_1s: 0.0, - fps_10s: 0.0, + atlas, } } - /// Handles a keyboard event. pub fn keyboard_event(&mut self, keycode: Keycode) { - // Change direction - let direction = Direction::from_keycode(keycode); - if direction.is_some() { - self.pacman.borrow_mut().next_direction = direction; - return; - } + self.pacman.handle_key(keycode); - // Toggle debug mode - if keycode == Keycode::Space { - self.debug_mode = match self.debug_mode { - DebugMode::None => DebugMode::Grid, - DebugMode::Grid => DebugMode::Pathfinding, - DebugMode::Pathfinding => DebugMode::ValidPositions, - DebugMode::ValidPositions => DebugMode::None, - }; - return; - } - - // Toggle mute if keycode == Keycode::M { - self.audio.set_mute(self.audio.is_muted().not()); + self.audio.set_mute(!self.audio.is_muted()); return; } - - // Reset game - if keycode == Keycode::R { - self.reset(); - } } - /// Adds points to the score. - /// - /// # Arguments - /// - /// * `points` - The number of points to add. - pub fn add_score(&mut self, points: u32) { - self.score += points; + pub fn tick(&mut self, dt: f32) { + self.pacman.tick(dt, &self.map.graph); } - /// Updates the FPS tracking values. - pub fn update_fps(&mut self, fps_1s: f64, fps_10s: f64) { - self.fps_1s = fps_1s; - self.fps_10s = fps_10s; + pub fn draw(&mut self, canvas: &mut Canvas, backbuffer: &mut Texture) -> Result<()> { + canvas.with_texture_canvas(backbuffer, |canvas| { + canvas.set_draw_color(Color::BLACK); + canvas.clear(); + self.map.render(canvas, &mut self.atlas, &mut self.map_texture); + self.pacman.render(canvas, &mut self.atlas, &self.map.graph); + })?; + + Ok(()) } - /// Resets the game to its initial state. - pub fn reset(&mut self) { - // Reset the map to restore all pellets - { - let mut map = self.map.borrow_mut(); - map.reset(); - } - - // Reset the score - self.score = 0; - - // Reset entities to their proper starting positions - { - let map = self.map.borrow(); - - // Reset Pac-Man to proper starting position - if let Some(pacman_start) = map.find_starting_position(0) { - let mut pacman = self.pacman.borrow_mut(); - pacman.base.base.pixel_position = Map::cell_to_pixel(pacman_start); - pacman.base.base.cell_position = pacman_start; - pacman.base.in_tunnel = false; - pacman.base.direction = Direction::Right; - pacman.next_direction = None; - pacman.stopped = false; - } - - // Reset ghosts to their starting positions - if let Some(blinky_start) = map.find_starting_position(1) { - self.blinky.base.base.pixel_position = Map::cell_to_pixel(blinky_start); - self.blinky.base.base.cell_position = blinky_start; - self.blinky.base.in_tunnel = false; - self.blinky.base.direction = Direction::Left; - self.blinky.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - } - - if let Some(pinky_start) = map.find_starting_position(2) { - self.pinky.base.base.pixel_position = Map::cell_to_pixel(pinky_start); - self.pinky.base.base.cell_position = pinky_start; - self.pinky.base.in_tunnel = false; - self.pinky.base.direction = Direction::Down; - self.pinky.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - } - - if let Some(inky_start) = map.find_starting_position(3) { - self.inky.base.base.pixel_position = Map::cell_to_pixel(inky_start); - self.inky.base.base.cell_position = inky_start; - self.inky.base.in_tunnel = false; - self.inky.base.direction = Direction::Up; - self.inky.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - } - - if let Some(clyde_start) = map.find_starting_position(4) { - self.clyde.base.base.pixel_position = Map::cell_to_pixel(clyde_start); - self.clyde.base.base.cell_position = clyde_start; - self.clyde.base.in_tunnel = false; - self.clyde.base.direction = Direction::Down; - self.clyde.mode = crate::entity::ghost::GhostMode::House(crate::entity::ghost::HouseMode::Waiting); - } - } - - self.edibles = reconstruct_edibles( - Rc::clone(&self.map), - AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/pellet.png")], 0), - BlinkingTexture::new( - AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "maze/energizer.png")], 0), - 12, - 12, - ), - AnimatedTexture::new(vec![get_atlas_tile(&self.atlas, "edible/cherry.png")], 0), - ); - } - - /// Advances the game by one tick. - pub fn tick(&mut self) { - self.tick_entities(); - self.handle_edible_collisions(); - self.tick_entities(); - } - fn tick_entities(&mut self) { - self.pacman.borrow_mut().tick(); - self.blinky.tick(); - self.pinky.tick(); - self.inky.tick(); - self.clyde.tick(); - for edible in self.edibles.iter_mut() { - if let EdibleKind::PowerPellet = edible.kind { - if let crate::entity::edible::EdibleSprite::PowerPellet(texture) = &mut edible.sprite { - texture.tick(); - } - } - } - } - fn handle_edible_collisions(&mut self) { - let pacman = self.pacman.borrow(); - let mut eaten_indices = vec![]; - for (i, edible) in self.edibles.iter().enumerate() { - if edible.collide(&*pacman) { - eaten_indices.push(i); - } - } - drop(pacman); - for &i in eaten_indices.iter().rev() { - let edible = &self.edibles[i]; - match edible.kind { - EdibleKind::Pellet => { - self.add_score(10); - self.audio.eat(); - } - EdibleKind::PowerPellet => { - self.add_score(50); - self.audio.eat(); - } - EdibleKind::Fruit(_fruit) => { - self.add_score(100); - self.audio.eat(); - } - } - self.edibles.remove(i); - // Set Pac-Man to skip the next movement tick - self.pacman.borrow_mut().skip_move_tick = true; - } - } - - /// Draws the entire game to the canvas using a backbuffer. - pub fn draw(&mut self, window_canvas: &mut Canvas, backbuffer: &mut Texture) -> Result<()> { - window_canvas - .with_texture_canvas(backbuffer, |texture_canvas| { - let this = self as *mut Self; - let this = unsafe { &mut *this }; - texture_canvas.set_draw_color(Color::BLACK); - texture_canvas.clear(); - this.map.borrow_mut().render(texture_canvas, &mut this.map_texture); - for edible in this.edibles.iter_mut() { - let _ = edible.render(texture_canvas); - } - let _ = this.pacman.borrow_mut().render(texture_canvas); - let _ = this.blinky.render(texture_canvas); - let _ = this.pinky.render(texture_canvas); - let _ = this.inky.render(texture_canvas); - let _ = this.clyde.render(texture_canvas); - match this.debug_mode { - DebugMode::Grid => { - DebugRenderer::draw_debug_grid( - texture_canvas, - &this.map.borrow(), - this.pacman.borrow().base.base.cell_position, - ); - let next_cell = ::next_cell(&*this.pacman.borrow(), None); - DebugRenderer::draw_next_cell(texture_canvas, &this.map.borrow(), next_cell.as_uvec2()); - } - DebugMode::ValidPositions => { - DebugRenderer::draw_valid_positions(texture_canvas, &mut this.map.borrow_mut()); - } - DebugMode::Pathfinding => { - DebugRenderer::draw_pathfinding(texture_canvas, &this.blinky, &this.map.borrow()); - } - DebugMode::None => {} - } - }) - .map_err(|e| anyhow::anyhow!(format!("Failed to render to backbuffer: {e}"))) - } - pub fn present_backbuffer(&mut self, canvas: &mut Canvas, backbuffer: &Texture) -> Result<()> { - canvas.set_draw_color(Color::BLACK); - canvas.clear(); + pub fn present_backbuffer(&mut self, canvas: &mut Canvas, backbuffer: &Texture) -> Result<()> { canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?; - self.render_ui_on(canvas); + if self.debug_mode { + self.map + .debug_render_nodes(canvas, &mut self.atlas, &mut self.debug_text_texture); + } + self.draw_hud(canvas)?; canvas.present(); Ok(()) } - fn render_ui_on(&mut self, canvas: &mut sdl2::render::Canvas) { + fn draw_hud(&mut self, canvas: &mut Canvas) -> Result<()> { + let score_text = self.score.to_string(); let lives = 3; let score_text = format!("{:02}", self.score); let x_offset = 4; @@ -396,11 +131,13 @@ impl Game { self.text_texture.set_scale(1.0); let _ = self.text_texture.render( canvas, + &mut self.atlas, &format!("{lives}UP HIGH SCORE "), UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), ); let _ = self.text_texture.render( canvas, + &mut self.atlas, &score_text, UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), ); @@ -414,5 +151,7 @@ impl Game { // IVec2::new(10, 10), // Color::RGB(255, 255, 0), // Yellow color for FPS display // ); + + Ok(()) } } diff --git a/src/helper.rs b/src/helper.rs deleted file mode 100644 index 93db48f..0000000 --- a/src/helper.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! This module contains helper functions that are used throughout the game. - -use glam::UVec2; - -/// Checks if two grid positions are adjacent to each other -/// -/// # Arguments -/// * `a` - First position as (x, y) coordinates -/// * `b` - Second position as (x, y) coordinates -/// * `diagonal` - Whether to consider diagonal adjacency (true) or only orthogonal (false) -/// -/// # Returns -/// * `true` if positions are adjacent according to the diagonal parameter -/// * `false` otherwise -pub fn is_adjacent(a: UVec2, b: UVec2, diagonal: bool) -> bool { - let dx = a.x.abs_diff(b.x); - let dy = a.y.abs_diff(b.y); - if diagonal { - dx <= 1 && dy <= 1 && (dx != 0 || dy != 0) - } else { - (dx == 1 && dy == 0) || (dx == 0 && dy == 1) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_orthogonal_adjacency() { - // Test orthogonal adjacency (diagonal = false) - - // Same position should not be adjacent - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), false)); - - // Adjacent positions should be true - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false)); // Right - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false)); // Down - assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), false)); // Left - assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), false)); // Up - - // Diagonal positions should be false - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), false)); - assert!(!is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), false)); - - // Positions more than 1 step away should be false - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), false)); - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), false)); - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), false)); - } - - #[test] - fn test_diagonal_adjacency() { - // Test diagonal adjacency (diagonal = true) - - // Same position should not be adjacent - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 0), true)); - - // Orthogonal adjacent positions should be true - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), true)); // Right - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), true)); // Down - assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 1), true)); // Left - assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(1, 0), true)); // Up - - // Diagonal adjacent positions should be true - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true)); // Down-right - assert!(is_adjacent(UVec2::new(1, 0), UVec2::new(0, 1), true)); // Down-left - assert!(is_adjacent(UVec2::new(0, 1), UVec2::new(1, 0), true)); // Up-right - assert!(is_adjacent(UVec2::new(1, 1), UVec2::new(0, 0), true)); // Up-left - - // Positions more than 1 step away should be false - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 0), true)); - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(0, 2), true)); - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(2, 2), true)); - assert!(!is_adjacent(UVec2::new(0, 0), UVec2::new(1, 2), true)); - } - - #[test] - fn test_edge_cases() { - // Test with larger coordinates - assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 100), false)); - assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(100, 101), false)); - assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 100), false)); - - assert!(is_adjacent(UVec2::new(100, 100), UVec2::new(101, 101), true)); - assert!(!is_adjacent(UVec2::new(100, 100), UVec2::new(102, 102), true)); - - // Test with zero coordinates - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 0), false)); - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(0, 1), false)); - assert!(is_adjacent(UVec2::new(0, 0), UVec2::new(1, 1), true)); - } - - #[test] - fn test_commutative_property() { - // The function should work the same regardless of parameter order - assert_eq!( - is_adjacent(UVec2::new(1, 2), UVec2::new(2, 2), false), - is_adjacent(UVec2::new(2, 2), UVec2::new(1, 2), false) - ); - - assert_eq!( - is_adjacent(UVec2::new(1, 2), UVec2::new(2, 3), true), - is_adjacent(UVec2::new(2, 3), UVec2::new(1, 2), true) - ); - } -} diff --git a/src/main.rs b/src/main.rs index 6261a3e..dc3f6da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,9 @@ #![windows_subsystem = "windows"] -use crate::constants::{CANVAS_SIZE, SCALE}; -use crate::game::Game; -use sdl2::event::{Event, WindowEvent}; -use sdl2::keyboard::Keycode; -use std::time::{Duration, Instant}; -use tracing::event; +use std::time::Duration; + +use crate::{app::App, constants::LOOP_TIME}; +use tracing::info; use tracing_error::ErrorLayer; use tracing_subscriber::layer::SubscriberExt; @@ -52,28 +50,17 @@ unsafe fn attach_console() { // Do NOT call AllocConsole here - we don't want a console when launched from Explorer } +mod app; mod asset; mod audio; mod constants; -mod debug; #[cfg(target_os = "emscripten")] mod emscripten; mod entity; mod game; -mod helper; mod map; mod texture; -#[cfg(not(target_os = "emscripten"))] -fn sleep(value: Duration) { - spin_sleep::sleep(value); -} - -#[cfg(target_os = "emscripten")] -fn sleep(value: Duration) { - emscripten::emscripten::sleep(value.as_millis() as u32); -} - /// The main entry point of the application. /// /// This function initializes SDL, the window, the game state, and then enters @@ -85,14 +72,6 @@ pub fn main() { attach_console(); } - let sdl_context = sdl2::init().unwrap(); - let video_subsystem = sdl_context.video().unwrap(); - let audio_subsystem = sdl_context.audio().unwrap(); - let ttf_context = sdl2::ttf::init().unwrap(); - - // Set nearest-neighbor scaling for pixelated rendering - sdl2::hint::set("SDL_RENDER_SCALE_QUALITY", "nearest"); - // Setup tracing let subscriber = tracing_subscriber::fmt() .with_ansi(cfg!(not(target_os = "emscripten"))) @@ -102,169 +81,16 @@ pub fn main() { tracing::subscriber::set_global_default(subscriber).expect("Could not set global default"); - let window = video_subsystem - .window( - "Pac-Man", - (CANVAS_SIZE.x as f32 * SCALE).round() as u32, - (CANVAS_SIZE.y as f32 * SCALE).round() as u32, - ) - .resizable() - .position_centered() - .build() - .expect("Could not initialize window"); + let mut app = App::new().expect("Could not create app"); - let mut canvas = window.into_canvas().build().expect("Could not build canvas"); + info!("Starting game loop ({:?})", LOOP_TIME); - canvas - .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) - .expect("Could not set logical size"); - - let texture_creator = canvas.texture_creator(); - let texture_creator_static: &'static sdl2::render::TextureCreator = - Box::leak(Box::new(texture_creator)); - let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem); - game.audio.set_mute(cfg!(debug_assertions)); - - // Create a backbuffer texture for drawing - let mut backbuffer = texture_creator_static - .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y) - .expect("Could not create backbuffer texture"); - - let mut event_pump = sdl_context.event_pump().expect("Could not get SDL EventPump"); - - // Initial draw and tick - if let Err(e) = game.draw(&mut canvas, &mut backbuffer) { - eprintln!("Initial draw failed: {e}"); - } - if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) { - eprintln!("Initial present failed: {e}"); - } - game.tick(); - - // The target time for each frame of the game loop (60 FPS). - let loop_time = Duration::from_secs(1) / 60; - - let mut paused = false; - - // FPS tracking - let mut frame_times_1s = Vec::new(); - let mut frame_times_10s = Vec::new(); - let mut last_frame_time = Instant::now(); - - event!(tracing::Level::INFO, "Starting game loop ({:?})", loop_time); - let mut main_loop = move || { - let start = Instant::now(); - let current_frame_time = Instant::now(); - let frame_duration = current_frame_time.duration_since(last_frame_time); - last_frame_time = current_frame_time; - - // Update FPS tracking - frame_times_1s.push(frame_duration); - frame_times_10s.push(frame_duration); - - // Keep only last 1 second of data (assuming 60 FPS = ~60 frames) - while frame_times_1s.len() > 60 { - frame_times_1s.remove(0); - } - - // Keep only last 10 seconds of data - while frame_times_10s.len() > 600 { - frame_times_10s.remove(0); - } - - // Calculate FPS averages - let fps_1s = if !frame_times_1s.is_empty() { - let total_time: Duration = frame_times_1s.iter().sum(); - if total_time > Duration::ZERO { - frame_times_1s.len() as f64 / total_time.as_secs_f64() - } else { - 0.0 - } - } else { - 0.0 - }; - - let fps_10s = if !frame_times_10s.is_empty() { - let total_time: Duration = frame_times_10s.iter().sum(); - if total_time > Duration::ZERO { - frame_times_10s.len() as f64 / total_time.as_secs_f64() - } else { - 0.0 - } - } else { - 0.0 - }; - - // TODO: Fix key repeat delay issues by using a queue for keyboard events. - // This would allow for instant key repeat without being affected by the - // main loop's tick rate. - for event in event_pump.poll_iter() { - match event { - Event::Window { win_event, .. } => match win_event { - WindowEvent::Hidden => { - event!(tracing::Level::DEBUG, "Window hidden"); - } - WindowEvent::Shown => { - event!(tracing::Level::DEBUG, "Window shown"); - } - _ => {} - }, - // Handle quitting keys or window close - Event::Quit { .. } - | Event::KeyDown { - keycode: Some(Keycode::Escape) | Some(Keycode::Q), - .. - } => { - event!(tracing::Level::INFO, "Exit requested. Exiting..."); - return false; - } - Event::KeyDown { - keycode: Some(Keycode::P), - .. - } => { - paused = !paused; - event!(tracing::Level::INFO, "{}", if paused { "Paused" } else { "Unpaused" }); - } - Event::KeyDown { keycode, .. } => { - game.keyboard_event(keycode.unwrap()); - } - _ => {} - } - } - - // TODO: Implement a proper pausing mechanism that does not interfere with - // statistic gathering and other background tasks. - if !paused { - game.tick(); - if let Err(e) = game.draw(&mut canvas, &mut backbuffer) { - eprintln!("Failed to draw game: {e}"); - } - if let Err(e) = game.present_backbuffer(&mut canvas, &backbuffer) { - eprintln!("Failed to present backbuffer: {e}"); - } - } - - // Update game with FPS data - game.update_fps(fps_1s, fps_10s); - - if start.elapsed() < loop_time { - let time = loop_time.saturating_sub(start.elapsed()); - if time != Duration::ZERO { - sleep(time); - } - } else { - event!( - tracing::Level::WARN, - "Game loop behind schedule by: {:?}", - start.elapsed() - loop_time - ); - } - - true - }; + #[cfg(target_os = "emscripten")] + emscripten::set_main_loop_callback(app.run); + #[cfg(not(target_os = "emscripten"))] loop { - if !main_loop() { + if !app.run() { break; } } diff --git a/src/map.rs b/src/map.rs index 0e944ec..1f882ce 100644 --- a/src/map.rs +++ b/src/map.rs @@ -1,16 +1,18 @@ //! This module defines the game map and provides functions for interacting with it. -use rand::rngs::SmallRng; -use rand::seq::IteratorRandom; -use rand::SeedableRng; -use crate::constants::{MapTile, BOARD_CELL_OFFSET, BOARD_CELL_SIZE, BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE, CELL_SIZE}; -use crate::texture::sprite::AtlasTile; -use glam::{IVec2, UVec2}; -use once_cell::sync::OnceCell; -use sdl2::rect::Rect; -use sdl2::render::Canvas; -use sdl2::video::Window; -use std::collections::{HashSet, VecDeque}; +use crate::constants::{MapTile, BOARD_CELL_SIZE, BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE, CELL_SIZE}; +use crate::entity::direction::DIRECTIONS; +use crate::texture::sprite::{AtlasTile, SpriteAtlas}; +use glam::{IVec2, UVec2, Vec2}; +use sdl2::pixels::Color; +use sdl2::rect::{Point, Rect}; +use sdl2::render::{Canvas, RenderTarget}; +use smallvec::SmallVec; +use std::collections::{HashMap, VecDeque}; +use tracing::info; + +use crate::entity::graph::{Graph, Node}; +use crate::texture::text::TextTexture; /// The game map. /// @@ -19,8 +21,8 @@ use std::collections::{HashSet, VecDeque}; pub struct Map { /// The current state of the map. current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize], - /// The default state of the map. - default: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize], + /// The node map for entity movement. + pub graph: Graph, } impl Map { @@ -31,7 +33,7 @@ impl Map { /// * `raw_board` - A 2D array of characters representing the board layout. pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map { let mut map = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]; - + let mut house_door = SmallVec::<[IVec2; 2]>::new(); for (y, line) in raw_board.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) { for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) { let tile = match character { @@ -40,127 +42,114 @@ impl Map { 'o' => MapTile::PowerPellet, ' ' => MapTile::Empty, 'T' => MapTile::Tunnel, - c @ '0' | c @ '1' | c @ '2' | c @ '3' | c @ '4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8), - '=' => MapTile::Empty, + c @ '0'..='4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8), + '=' => { + house_door.push(IVec2::new(x as i32, y as i32)); + MapTile::Wall + } _ => panic!("Unknown character in board: {character}"), }; map[x][y] = tile; } } - Map { - current: map, - default: map, + if house_door.len() != 2 { + panic!("House door must have exactly 2 positions"); } + + let mut graph = Self::create_graph(&map); + + let house_door_node_id = { + let offset = Vec2::splat(CELL_SIZE as f32 / 2.0); + + let position_a = house_door[0].as_vec2() * Vec2::splat(CELL_SIZE as f32) + offset; + let position_b = house_door[1].as_vec2() * Vec2::splat(CELL_SIZE as f32) + offset; + info!("Position A: {position_a}, Position B: {position_b}"); + let position = position_a.lerp(position_b, 0.5); + + graph.add_node(Node { position }) + }; + info!("House door node id: {house_door_node_id}"); + + Map { current: map, graph } } - /// Resets the map to its original state. - pub fn reset(&mut self) { - // Restore the map to its original state - for (x, col) in self.current.iter_mut().enumerate().take(BOARD_CELL_SIZE.x as usize) { - for (y, cell) in col.iter_mut().enumerate().take(BOARD_CELL_SIZE.y as usize) { - *cell = self.default[x][y]; - } - } - } + fn create_graph(map: &[[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]) -> Graph { + let mut graph = Graph::new(); + let mut grid_to_node = HashMap::new(); - /// Returns the tile at the given cell coordinates. - /// - /// # Arguments - /// - /// * `cell` - The cell coordinates, in grid coordinates. - pub fn get_tile(&self, cell: IVec2) -> Option { - let x = cell.x as usize; - let y = cell.y as usize; + let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0); - if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize { - return None; - } + // Find a starting point for the graph generation, preferably Pac-Man's position. + let start_pos = (0..BOARD_CELL_SIZE.y) + .flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32))) + .find(|&p| matches!(map[p.x as usize][p.y as usize], MapTile::StartingPosition(0))) + .unwrap_or_else(|| { + // Fallback to any valid walkable tile if Pac-Man's start is not found + (0..BOARD_CELL_SIZE.y) + .flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32))) + .find(|&p| { + matches!( + map[p.x as usize][p.y as usize], + MapTile::Pellet + | MapTile::PowerPellet + | MapTile::Empty + | MapTile::Tunnel + | MapTile::StartingPosition(_) + ) + }) + .expect("No valid starting position found on map for graph generation") + }); - Some(self.current[x][y]) - } - - /// Sets the tile at the given cell coordinates. - /// - /// # Arguments - /// - /// * `cell` - The cell coordinates, in grid coordinates. - /// * `tile` - The tile to set. - pub fn set_tile(&mut self, cell: IVec2, tile: MapTile) -> bool { - let x = cell.x as usize; - let y = cell.y as usize; - - if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize { - return false; - } - - self.current[x][y] = tile; - true - } - - /// Converts cell coordinates to pixel coordinates. - /// - /// # Arguments - /// - /// * `cell` - The cell coordinates, in grid coordinates. - pub fn cell_to_pixel(cell: UVec2) -> IVec2 { - IVec2::new( - (cell.x * CELL_SIZE) as i32, - ((cell.y + BOARD_CELL_OFFSET.y) * CELL_SIZE) as i32, - ) - } - - /// Returns a reference to a cached vector of all valid playable positions in the maze. - /// This is computed once using a flood fill from a random pellet, and then cached. - pub fn get_valid_playable_positions(&mut self) -> &Vec { - use MapTile::*; - static CACHE: OnceCell> = OnceCell::new(); - if let Some(cached) = CACHE.get() { - return cached; - } - // Find a random starting pellet - let mut pellet_positions = vec![]; - for (x, col) in self.current.iter().enumerate().take(BOARD_CELL_SIZE.x as usize) { - for (y, &cell) in col.iter().enumerate().take(BOARD_CELL_SIZE.y as usize) { - match cell { - Pellet | PowerPellet => pellet_positions.push(UVec2::new(x as u32, y as u32)), - _ => {} - } - } - } - let mut rng = SmallRng::from_os_rng(); - let &start = pellet_positions - .iter() - .choose(&mut rng) - .expect("No pellet found for flood fill"); - // Flood fill - let mut visited = HashSet::new(); let mut queue = VecDeque::new(); + queue.push_back(start_pos); - queue.push_back(start); - while let Some(pos) = queue.pop_front() { - if !visited.insert(pos) { - continue; - } + let pos = Vec2::new( + (start_pos.x * CELL_SIZE as i32) as f32, + (start_pos.y * CELL_SIZE as i32) as f32, + ) + cell_offset; + let node_id = graph.add_node(Node { position: pos }); + grid_to_node.insert(start_pos, node_id); - match self.current[pos.x as usize][pos.y as usize] { - Empty | Pellet | PowerPellet => { - for offset in [IVec2::new(-1, 0), IVec2::new(1, 0), IVec2::new(0, -1), IVec2::new(0, 1)] { - let neighbor = (pos.as_ivec2() + offset).as_uvec2(); - if neighbor.x < BOARD_CELL_SIZE.x && neighbor.y < BOARD_CELL_SIZE.y { - let neighbor_tile = self.current[neighbor.x as usize][neighbor.y as usize]; - if matches!(neighbor_tile, Empty | Pellet | PowerPellet) { - queue.push_back(neighbor); - } - } - } + while let Some(grid_pos) = queue.pop_front() { + for &dir in DIRECTIONS.iter() { + let neighbor = grid_pos + dir.to_ivec2(); + + if neighbor.x < 0 + || neighbor.x >= BOARD_CELL_SIZE.x as i32 + || neighbor.y < 0 + || neighbor.y >= BOARD_CELL_SIZE.y as i32 + { + continue; + } + + if grid_to_node.contains_key(&neighbor) { + continue; + } + + if matches!( + map[neighbor.x as usize][neighbor.y as usize], + MapTile::Pellet | MapTile::PowerPellet | MapTile::Empty | MapTile::Tunnel | MapTile::StartingPosition(_) + ) { + let pos = + Vec2::new((neighbor.x * CELL_SIZE as i32) as f32, (neighbor.y * CELL_SIZE as i32) as f32) + cell_offset; + let node_id = graph.add_node(Node { position: pos }); + grid_to_node.insert(neighbor, node_id); + queue.push_back(neighbor); } - StartingPosition(_) | Wall | Tunnel => {} } } - let mut result: Vec = visited.into_iter().collect(); - result.sort_unstable_by_key(|v| (v.x, v.y)); - CACHE.get_or_init(|| result) + + for (grid_pos, &node_id) in &grid_to_node { + for &dir in DIRECTIONS.iter() { + let neighbor = grid_pos + dir.to_ivec2(); + + if let Some(&neighbor_id) = grid_to_node.get(&neighbor) { + graph.add_edge(node_id, neighbor_id, None, dir).expect("Failed to add edge"); + } + } + } + graph } /// Finds the starting position for a given entity ID. @@ -186,13 +175,51 @@ impl Map { } /// Renders the map to the given canvas using the provided map texture. - pub fn render(&self, canvas: &mut Canvas, map_texture: &mut AtlasTile) { + pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) { let dest = Rect::new( BOARD_PIXEL_OFFSET.x as i32, BOARD_PIXEL_OFFSET.y as i32, BOARD_PIXEL_SIZE.x, BOARD_PIXEL_SIZE.y, ); - let _ = map_texture.render(canvas, dest); + let _ = map_texture.render(canvas, atlas, dest); + } + + pub fn debug_render_nodes(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, text: &mut TextTexture) { + for i in 0..self.graph.node_count() { + let node = self.graph.get_node(i).unwrap(); + let pos = node.position + BOARD_PIXEL_OFFSET.as_vec2(); + + // Draw connections + // TODO: fix this + // canvas.set_draw_color(Color::BLUE); + + // for neighbor in node.neighbors() { + // let end_pos = neighbor.get(&self.node_map).position + BOARD_PIXEL_OFFSET.as_vec2(); + // canvas + // .draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32)) + // .unwrap(); + // } + + // Draw node + // let color = if pacman.position.from_node_idx() == i.into() { + // Color::GREEN + // } else if let Some(to_idx) = pacman.position.to_node_idx() { + // if to_idx == i.into() { + // Color::CYAN + // } else { + // Color::RED + // } + // } else { + // Color::RED + // }; + canvas.set_draw_color(Color::GREEN); + canvas + .fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32))) + .unwrap(); + + // Draw node index + // text.render(canvas, atlas, &i.to_string(), pos.as_uvec2()).unwrap(); + } } } diff --git a/src/texture/animated.rs b/src/texture/animated.rs index 20d3ed8..24576bd 100644 --- a/src/texture/animated.rs +++ b/src/texture/animated.rs @@ -1,47 +1,41 @@ -//! This module provides a simple animation and atlas system for textures. use anyhow::Result; -use sdl2::render::WindowCanvas; +use sdl2::rect::Rect; +use sdl2::render::{Canvas, RenderTarget}; -use crate::texture::sprite::AtlasTile; +use crate::texture::sprite::{AtlasTile, SpriteAtlas}; -/// An animated texture using a texture atlas. #[derive(Clone)] pub struct AnimatedTexture { - pub frames: Vec, - pub ticks_per_frame: u32, - pub ticker: u32, - pub paused: bool, + tiles: Vec, + frame_duration: f32, + current_frame: usize, + time_bank: f32, } impl AnimatedTexture { - pub fn new(frames: Vec, ticks_per_frame: u32) -> Self { - AnimatedTexture { - frames, - ticks_per_frame, - ticker: 0, - paused: false, + pub fn new(tiles: Vec, frame_duration: f32) -> Self { + Self { + tiles, + frame_duration, + current_frame: 0, + time_bank: 0.0, } } - /// Advances the animation by one tick, unless paused. - pub fn tick(&mut self) { - if self.paused || self.ticks_per_frame == 0 { - return; + pub fn tick(&mut self, dt: f32) { + self.time_bank += dt; + while self.time_bank >= self.frame_duration { + self.time_bank -= self.frame_duration; + self.current_frame = (self.current_frame + 1) % self.tiles.len(); } - - self.ticker += 1; } - pub fn current_tile(&mut self) -> &mut AtlasTile { - if self.ticks_per_frame == 0 { - return &mut self.frames[0]; - } - let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len(); - &mut self.frames[frame_index] + pub fn current_tile(&self) -> &AtlasTile { + &self.tiles[self.current_frame] } - pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> { - let tile = self.current_tile(); - tile.render(canvas, dest) + pub fn render(&self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> { + let mut tile = self.current_tile().clone(); + tile.render(canvas, atlas, dest) } } diff --git a/src/texture/blinking.rs b/src/texture/blinking.rs index afdf6fe..58eeda7 100644 --- a/src/texture/blinking.rs +++ b/src/texture/blinking.rs @@ -1,48 +1,36 @@ -//! A texture that blinks on/off for a specified number of ticks. -use anyhow::Result; -use sdl2::render::WindowCanvas; - -use crate::texture::animated::AnimatedTexture; +use crate::texture::sprite::AtlasTile; #[derive(Clone)] pub struct BlinkingTexture { - pub animation: AnimatedTexture, - pub on_ticks: u32, - pub off_ticks: u32, - pub ticker: u32, - pub visible: bool, + tile: AtlasTile, + blink_duration: f32, + time_bank: f32, + is_on: bool, } impl BlinkingTexture { - pub fn new(animation: AnimatedTexture, on_ticks: u32, off_ticks: u32) -> Self { - BlinkingTexture { - animation, - on_ticks, - off_ticks, - ticker: 0, - visible: true, + pub fn new(tile: AtlasTile, blink_duration: f32) -> Self { + Self { + tile, + blink_duration, + time_bank: 0.0, + is_on: true, } } - /// Advances the blinking state by one tick. - pub fn tick(&mut self) { - self.animation.tick(); - self.ticker += 1; - if self.visible && self.ticker >= self.on_ticks { - self.visible = false; - self.ticker = 0; - } else if !self.visible && self.ticker >= self.off_ticks { - self.visible = true; - self.ticker = 0; + pub fn tick(&mut self, dt: f32) { + self.time_bank += dt; + if self.time_bank >= self.blink_duration { + self.time_bank -= self.blink_duration; + self.is_on = !self.is_on; } } - /// Renders the blinking texture. - pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> { - if self.visible { - self.animation.render(canvas, dest) - } else { - Ok(()) - } + pub fn is_on(&self) -> bool { + self.is_on + } + + pub fn tile(&self) -> &AtlasTile { + &self.tile } } diff --git a/src/texture/directional.rs b/src/texture/directional.rs index b0e9ce9..2b80d2c 100644 --- a/src/texture/directional.rs +++ b/src/texture/directional.rs @@ -1,65 +1,57 @@ -//! A texture that changes based on the direction of an entity. -use crate::entity::direction::Direction; -use crate::texture::sprite::AtlasTile; use anyhow::Result; -use sdl2::render::WindowCanvas; +use sdl2::rect::Rect; +use sdl2::render::{Canvas, RenderTarget}; +use std::collections::HashMap; +use crate::entity::direction::Direction; +use crate::texture::animated::AnimatedTexture; +use crate::texture::sprite::SpriteAtlas; + +#[derive(Clone)] pub struct DirectionalAnimatedTexture { - pub up: Vec, - pub down: Vec, - pub left: Vec, - pub right: Vec, - pub ticker: u32, - pub ticks_per_frame: u32, + textures: HashMap, + stopped_textures: HashMap, } impl DirectionalAnimatedTexture { - pub fn new( - up: Vec, - down: Vec, - left: Vec, - right: Vec, - ticks_per_frame: u32, - ) -> Self { + pub fn new(textures: HashMap, stopped_textures: HashMap) -> Self { Self { - up, - down, - left, - right, - ticker: 0, - ticks_per_frame, + textures, + stopped_textures, } } - pub fn tick(&mut self) { - self.ticker += 1; + pub fn tick(&mut self, dt: f32) { + for texture in self.textures.values_mut() { + texture.tick(dt); + } } - pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> { - let frames = match direction { - Direction::Up => &mut self.up, - Direction::Down => &mut self.down, - Direction::Left => &mut self.left, - Direction::Right => &mut self.right, - }; - - let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len(); - let tile = &mut frames[frame_index]; - - tile.render(canvas, dest) + pub fn render( + &self, + canvas: &mut Canvas, + atlas: &mut SpriteAtlas, + dest: Rect, + direction: Direction, + ) -> Result<()> { + if let Some(texture) = self.textures.get(&direction) { + texture.render(canvas, atlas, dest) + } else { + Ok(()) + } } - pub fn render_stopped(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect, direction: Direction) -> Result<()> { - let frames = match direction { - Direction::Up => &mut self.up, - Direction::Down => &mut self.down, - Direction::Left => &mut self.left, - Direction::Right => &mut self.right, - }; - - // Show the last frame (full sprite) when stopped - let tile = &mut frames[1]; - - tile.render(canvas, dest) + pub fn render_stopped( + &self, + canvas: &mut Canvas, + atlas: &mut SpriteAtlas, + dest: Rect, + direction: Direction, + ) -> Result<()> { + if let Some(texture) = self.stopped_textures.get(&direction) { + texture.render(canvas, atlas, dest) + } else { + Ok(()) + } } } diff --git a/src/texture/mod.rs b/src/texture/mod.rs index 113a91f..95aeee5 100644 --- a/src/texture/mod.rs +++ b/src/texture/mod.rs @@ -1,14 +1,5 @@ -use std::cell::RefCell; -use std::rc::Rc; - -use crate::texture::sprite::{AtlasTile, SpriteAtlas}; - pub mod animated; pub mod blinking; pub mod directional; pub mod sprite; pub mod text; - -pub fn get_atlas_tile(atlas: &Rc>, name: &str) -> AtlasTile { - SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {name}")) -} diff --git a/src/texture/sprite.rs b/src/texture/sprite.rs index d87e3cf..65a577e 100644 --- a/src/texture/sprite.rs +++ b/src/texture/sprite.rs @@ -4,9 +4,7 @@ use sdl2::pixels::Color; use sdl2::rect::Rect; use sdl2::render::{Canvas, RenderTarget, Texture}; use serde::Deserialize; -use std::cell::RefCell; use std::collections::HashMap; -use std::rc::Rc; #[derive(Clone, Debug, Deserialize)] pub struct AtlasMapper { @@ -21,26 +19,28 @@ pub struct MapperFrame { pub height: u16, } -#[derive(Clone)] +#[derive(Copy, Clone, Debug)] pub struct AtlasTile { - pub atlas: Rc>, pub pos: U16Vec2, pub size: U16Vec2, pub color: Option, } impl AtlasTile { - pub fn render(&mut self, canvas: &mut Canvas, dest: Rect) -> Result<()> { - let color = self - .color - .unwrap_or(self.atlas.borrow().default_color.unwrap_or(Color::WHITE)); - self.render_with_color(canvas, dest, color) + pub fn render(&mut self, canvas: &mut Canvas, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> { + let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE)); + self.render_with_color(canvas, atlas, dest, color) } - pub fn render_with_color(&mut self, canvas: &mut Canvas, dest: Rect, color: Color) -> Result<()> { + pub fn render_with_color( + &mut self, + canvas: &mut Canvas, + atlas: &mut SpriteAtlas, + dest: Rect, + color: Color, + ) -> Result<()> { let src = Rect::new(self.pos.x as i32, self.pos.y as i32, self.size.x as u32, self.size.y as u32); - let mut atlas = self.atlas.borrow_mut(); if atlas.last_modulation != Some(color) { atlas.texture.set_color_mod(color.r, color.g, color.b); atlas.last_modulation = Some(color); @@ -68,10 +68,8 @@ impl SpriteAtlas { } } - pub fn get_tile(atlas: &Rc>, name: &str) -> Option { - let atlas_ref = atlas.borrow(); - atlas_ref.tiles.get(name).map(|frame| AtlasTile { - atlas: Rc::clone(atlas), + pub fn get_tile(&self, name: &str) -> Option { + self.tiles.get(name).map(|frame| AtlasTile { pos: U16Vec2::new(frame.x, frame.y), size: U16Vec2::new(frame.width, frame.height), color: None, @@ -87,6 +85,6 @@ impl SpriteAtlas { } } -pub unsafe fn texture_to_static<'a>(texture: Texture<'a>) -> Texture<'static> { +pub unsafe fn texture_to_static(texture: Texture) -> Texture<'static> { std::mem::transmute(texture) } diff --git a/src/texture/text.rs b/src/texture/text.rs index 695744f..6dd2a19 100644 --- a/src/texture/text.rs +++ b/src/texture/text.rs @@ -50,38 +50,34 @@ use anyhow::Result; use glam::UVec2; use sdl2::render::{Canvas, RenderTarget}; -use std::cell::RefCell; use std::collections::HashMap; -use std::rc::Rc; use crate::texture::sprite::{AtlasTile, SpriteAtlas}; /// A text texture that renders characters from the atlas. pub struct TextTexture { - atlas: Rc>, char_map: HashMap, scale: f32, } impl TextTexture { /// Creates a new text texture with the given atlas and scale. - pub fn new(atlas: Rc>, scale: f32) -> Self { + pub fn new(scale: f32) -> Self { Self { - atlas, char_map: HashMap::new(), scale, } } /// Maps a character to its atlas tile, handling special characters. - fn get_char_tile(&mut self, c: char) -> Option { + fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option { if let Some(tile) = self.char_map.get(&c) { - return Some(tile.clone()); + return Some(*tile); } let tile_name = self.char_to_tile_name(c)?; - let tile = SpriteAtlas::get_tile(&self.atlas, &tile_name)?; - self.char_map.insert(c, tile.clone()); + let tile = atlas.get_tile(&tile_name)?; + self.char_map.insert(c, tile); Some(tile) } @@ -89,9 +85,7 @@ impl TextTexture { fn char_to_tile_name(&self, c: char) -> Option { let name = match c { // Letters A-Z - 'A'..='Z' => format!("text/{c}.png"), - // Numbers 0-9 - '0'..='9' => format!("text/{c}.png"), + 'A'..='Z' | '0'..='9' => format!("text/{c}.png"), // Special characters '!' => "text/!.png".to_string(), '-' => "text/-.png".to_string(), @@ -108,15 +102,21 @@ impl TextTexture { } /// Renders a string of text at the given position. - pub fn render(&mut self, canvas: &mut Canvas, text: &str, position: UVec2) -> Result<()> { + pub fn render( + &mut self, + canvas: &mut Canvas, + atlas: &mut SpriteAtlas, + text: &str, + position: UVec2, + ) -> Result<()> { let mut x_offset = 0; let char_width = (8.0 * self.scale) as u32; let char_height = (8.0 * self.scale) as u32; for c in text.chars() { - if let Some(mut tile) = self.get_char_tile(c) { + if let Some(mut tile) = self.get_char_tile(atlas, c) { let dest = sdl2::rect::Rect::new((position.x + x_offset) as i32, position.y as i32, char_width, char_height); - tile.render(canvas, dest)?; + tile.render(canvas, atlas, dest)?; } // Always advance x_offset for all characters (including spaces) x_offset += char_width; @@ -136,7 +136,7 @@ impl TextTexture { } /// Calculates the width of a string in pixels at the current scale. - pub fn text_width(&mut self, text: &str) -> u32 { + pub fn text_width(&self, text: &str) -> u32 { let char_width = (8.0 * self.scale) as u32; let mut width = 0;