refactor: huge refactor into node/graph-based movement system

This commit is contained in:
2025-07-28 12:23:57 -05:00
parent 413f9f156f
commit 464d6f9ca6
24 changed files with 868 additions and 2067 deletions

5
Cargo.lock generated
View File

@@ -192,6 +192,7 @@ dependencies = [
"sdl2", "sdl2",
"serde", "serde",
"serde_json", "serde_json",
"smallvec",
"spin_sleep", "spin_sleep",
"thiserror 1.0.69", "thiserror 1.0.69",
"tracing", "tracing",
@@ -392,9 +393,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.11.0" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "spin_sleep" name = "spin_sleep"

View File

@@ -17,14 +17,14 @@ pathfinding = "4.14"
once_cell = "1.21.3" once_cell = "1.21.3"
thiserror = "1.0" thiserror = "1.0"
anyhow = "1.0" anyhow = "1.0"
glam = "0.30.4" glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
smallvec = "1.15.1"
[profile.release] [profile.release]
lto = true lto = true
panic = "abort" panic = "abort"
panic-strategy = "abort"
opt-level = "z" opt-level = "z"
[target.'cfg(target_os = "windows")'.dependencies.winapi] [target.'cfg(target_os = "windows")'.dependencies.winapi]

152
src/app.rs Normal file
View File

@@ -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<Window>,
event_pump: EventPump,
backbuffer: Texture<'a>,
paused: bool,
last_tick: Instant,
}
impl<'a> App<'a> {
pub fn new() -> Result<Self> {
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<WindowContext> = 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
}
}
}

View File

@@ -22,7 +22,6 @@ pub enum Asset {
Wav2, Wav2,
Wav3, Wav3,
Wav4, Wav4,
FontKonami,
Atlas, Atlas,
AtlasJson, AtlasJson,
// Add more as needed // Add more as needed
@@ -37,7 +36,6 @@ impl Asset {
Wav2 => "sound/waka/2.ogg", Wav2 => "sound/waka/2.ogg",
Wav3 => "sound/waka/3.ogg", Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.ogg", Wav4 => "sound/waka/4.ogg",
FontKonami => "konami.ttf",
Atlas => "atlas.png", Atlas => "atlas.png",
AtlasJson => "atlas.json", AtlasJson => "atlas.json",
} }
@@ -54,7 +52,6 @@ mod imp {
Asset::Wav2 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/2.ogg")), 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::Wav3 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/3.ogg")),
Asset::Wav4 => Cow::Borrowed(include_bytes!("../assets/game/sound/waka/4.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::Atlas => Cow::Borrowed(include_bytes!("../assets/game/atlas.png")),
Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")), Asset::AtlasJson => Cow::Borrowed(include_bytes!("../assets/game/atlas.json")),
} }

View File

@@ -81,6 +81,7 @@ impl Audio {
self.muted = mute; self.muted = mute;
} }
/// Returns `true` if the audio is muted.
pub fn is_muted(&self) -> bool { pub fn is_muted(&self) -> bool {
self.muted self.muted
} }

View File

@@ -1,7 +1,11 @@
//! This module contains all the constants used in the game. //! This module contains all the constants used in the game.
use std::time::Duration;
use glam::UVec2; 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. /// The size of each cell, in pixels.
pub const CELL_SIZE: u32 = 8; pub const CELL_SIZE: u32 = 8;
/// The size of the game board, in cells. /// The size of the game board, in cells.
@@ -39,58 +43,6 @@ pub enum MapTile {
Tunnel, 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. /// The raw layout of the game board, as a 2D array of characters.
pub const RAW_BOARD: [&str; BOARD_CELL_SIZE.y as usize] = [ 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 ##.# ", " #.## 1 ##.# ",
" #.## ###==### ##.# ", " #.## ###==### ##.# ",
"######.## # # ##.######", "######.## ######## ##.######",
"T . #2 3 4 # . T", "T . ######## . T",
"######.## # # ##.######", "######.## ######## ##.######",
" #.## ######## ##.# ", " #.## ######## ##.# ",
" #.## ##.# ", " #.## ##.# ",
" #.## ######## ##.# ", " #.## ######## ##.# ",

View File

@@ -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<Window>, _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<Window>, 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<Window>, map: &Map, next_cell: UVec2) {
Self::draw_cell(canvas, map, next_cell, Color::YELLOW);
}
pub fn draw_valid_positions(canvas: &mut Canvas<Window>, 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<Window>, 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);
}
}
}
}

View File

@@ -1,10 +1,6 @@
//! This module defines the `Direction` enum, which is used to represent the
//! direction of an entity.
use glam::IVec2; use glam::IVec2;
use sdl2::keyboard::Keycode;
/// An enum representing the direction of an entity. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum Direction { pub enum Direction {
Up, Up,
Down, Down,
@@ -13,48 +9,29 @@ pub enum Direction {
} }
impl 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 { pub fn opposite(&self) -> Direction {
match self { match self {
Direction::Right => Direction::Left, Direction::Up => Direction::Down,
Direction::Down => Direction::Up, Direction::Down => Direction::Up,
Direction::Left => Direction::Right, Direction::Left => Direction::Right,
Direction::Up => Direction::Down, Direction::Right => Direction::Left,
} }
} }
/// Creates a `Direction` from a `Keycode`. pub fn to_ivec2(&self) -> IVec2 {
/// (*self).into()
/// # Arguments }
/// }
/// * `keycode` - The keycode to convert.
pub fn from_keycode(keycode: Keycode) -> Option<Direction> { impl From<Direction> for IVec2 {
match keycode { fn from(dir: Direction) -> Self {
Keycode::D | Keycode::Right => Some(Direction::Right), match dir {
Keycode::A | Keycode::Left => Some(Direction::Left), Direction::Up => -IVec2::Y,
Keycode::W | Keycode::Up => Some(Direction::Up), Direction::Down => IVec2::Y,
Keycode::S | Keycode::Down => Some(Direction::Down), Direction::Left => -IVec2::X,
_ => None, Direction::Right => IVec2::X,
} }
} }
} }
pub const DIRECTIONS: [Direction; 4] = [Direction::Up, Direction::Down, Direction::Left, Direction::Right];

View File

@@ -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<RefCell<Map>>,
pellet_sprite: AnimatedTexture,
power_pellet_sprite: BlinkingTexture,
_fruit_sprite: AnimatedTexture,
) -> Vec<Edible> {
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
}

View File

@@ -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<RefCell<Pacman>>,
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<RefCell<SpriteAtlas>>,
map: Rc<RefCell<Map>>,
pacman: Rc<RefCell<Pacman>>,
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<IVec2> {
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<UVec2>, 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<Direction>) -> IVec2 {
self.base.next_cell(direction)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> 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)
}
}
}
}

274
src/entity/graph.rs Normal file
View File

@@ -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<Node>,
adjacency_list: Vec<SmallVec<[Edge; 4]>>,
}
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<f32>,
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<NodeId> {
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;
}
}
}
}
}
}
}

View File

@@ -1,205 +1,3 @@
pub mod direction; pub mod direction;
pub mod edible; pub mod graph;
pub mod ghost;
pub mod pacman; 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<Direction>) -> IVec2;
fn is_wall_ahead(&self, direction: Option<Direction>) -> 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<Direction>;
fn set_next_direction(&mut self, dir: Option<Direction>);
/// 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<RefCell<Map>>,
}
impl MovableEntity {
pub fn new(
pixel_position: IVec2,
cell_position: UVec2,
direction: Direction,
speed: SimpleTickModulator,
map: Rc<RefCell<Map>>,
) -> 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<Direction>) -> 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<Direction>) -> 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<()>;
}

View File

@@ -1,143 +1,93 @@
//! This module defines the Pac-Man entity, including its behavior and rendering. use glam::Vec2;
use anyhow::Result;
use glam::{IVec2, UVec2};
use sdl2::render::WindowCanvas;
use std::cell::RefCell;
use std::rc::Rc;
use crate::{ use crate::constants::BOARD_PIXEL_OFFSET;
entity::speed::SimpleTickModulator, use crate::entity::direction::Direction;
entity::{direction::Direction, Entity, MovableEntity, Moving, QueuedDirection, Renderable, StaticEntity}, use crate::entity::graph::{Graph, NodeId, Position, Traverser};
map::Map, use crate::texture::animated::AnimatedTexture;
texture::{animated::AnimatedTexture, directional::DirectionalAnimatedTexture, get_atlas_tile, sprite::SpriteAtlas}, 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 { pub struct Pacman {
/// Shared movement and position fields. pub traverser: Traverser,
pub base: MovableEntity, texture: DirectionalAnimatedTexture,
/// The next direction of Pac-Man, which will be applied when Pac-Man is next aligned with the grid.
pub next_direction: Option<Direction>,
/// 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<Direction>) -> IVec2 {
self.base.next_cell(direction)
}
fn is_wall_ahead(&self, direction: Option<Direction>) -> 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 !<Pacman as Moving>::handle_tunnel(self) {
<Pacman as QueuedDirection>::handle_direction_change(self);
if !self.stopped && <Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = true;
} else if self.stopped && !<Pacman as Moving>::is_wall_ahead(self, None) {
self.stopped = false;
}
}
}
}
impl QueuedDirection for Pacman {
fn next_direction(&self) -> Option<Direction> {
self.next_direction
}
fn set_next_direction(&mut self, dir: Option<Direction>) {
self.next_direction = dir;
}
} }
impl Pacman { impl Pacman {
/// Creates a new `Pacman` instance. pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self {
pub fn new(starting_position: UVec2, atlas: Rc<RefCell<SpriteAtlas>>, map: Rc<RefCell<Map>>) -> Pacman { let mut textures = HashMap::new();
let pixel_position = Map::cell_to_pixel(starting_position); let mut stopped_textures = HashMap::new();
let get = |name: &str| get_atlas_tile(&atlas, name);
Pacman { for &direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
base: MovableEntity::new( let moving_prefix = match direction {
pixel_position, Direction::Up => "pacman/up",
starting_position, Direction::Down => "pacman/down",
Direction::Right, Direction::Left => "pacman/left",
SimpleTickModulator::new(1f32), Direction::Right => "pacman/right",
map, };
), let moving_tiles = vec![
next_direction: None, SpriteAtlas::get_tile(&atlas, &format!("{}_a.png", moving_prefix)).unwrap(),
stopped: false, SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap(),
skip_move_tick: false, SpriteAtlas::get_tile(&atlas, "pacman/full.png").unwrap(),
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")], let stopped_tiles = vec![SpriteAtlas::get_tile(&atlas, &format!("{}_b.png", moving_prefix)).unwrap()];
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")], textures.insert(direction, AnimatedTexture::new(moving_tiles, 0.08));
8, stopped_textures.insert(direction, AnimatedTexture::new(stopped_tiles, 0.1));
), }
death_animation: AnimatedTexture::new(
(0..=10) Self {
.map(|i| get_atlas_tile(&atlas, &format!("pacman/death/{i}.png"))) traverser: Traverser::new(graph, start_node, Direction::Left),
.collect(), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
5,
),
} }
} }
/// Returns the internal position of Pac-Man, rounded down to the nearest even number. pub fn tick(&mut self, dt: f32, graph: &Graph) {
fn internal_position_even(&self) -> UVec2 { self.traverser.advance(graph, dt * 60.0 * 1.125);
let pos = self.base.internal_position(); self.texture.tick(dt);
UVec2::new((pos.x / 2) * 2, (pos.y / 2) * 2)
} }
pub fn tick(&mut self) { pub fn handle_key(&mut self, keycode: Keycode) {
<Pacman as Moving>::tick(self); let direction = match keycode {
self.texture.tick(); 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 get_pixel_pos(&self, graph: &Graph) -> Vec2 {
fn render(&mut self, canvas: &mut WindowCanvas) -> Result<()> { match self.traverser.position {
let pos = self.base.base.pixel_position; Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
let dir = self.base.direction; 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 pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let dest = sdl2::rect::Rect::new(pos.x - 4, pos.y - 4, 16, 16); 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 { if is_stopped {
// When stopped, show the full sprite (mouth open) self.texture
self.texture.render_stopped(canvas, dest, dir)?; .render_stopped(canvas, atlas, dest, self.traverser.direction)
.unwrap();
} else { } else {
self.texture.render(canvas, dest, dir)?; self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
} }
Ok(())
} }
} }

View File

@@ -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
}
}
}

View File

@@ -1,71 +1,59 @@
//! This module contains the main game logic and state. //! This module contains the main game logic and state.
use std::cell::RefCell; use std::time::{Duration, Instant};
use std::ops::Not;
use std::rc::Rc;
use anyhow::Result; use anyhow::Result;
use glam::UVec2; use glam::UVec2;
use sdl2::{
image::LoadTexture,
keyboard::Keycode,
pixels::Color,
render::{Canvas, RenderTarget, Texture, TextureCreator},
video::WindowContext,
};
use sdl2::image::LoadTexture; use crate::{
use sdl2::keyboard::Keycode; asset::{get_asset_bytes, Asset},
audio::Audio,
use sdl2::render::{Texture, TextureCreator}; constants::RAW_BOARD,
use sdl2::video::WindowContext; entity::pacman::Pacman,
use sdl2::{pixels::Color, render::Canvas, video::Window}; map::Map,
texture::{
use crate::asset::{get_asset_bytes, Asset}; sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
use crate::audio::Audio; text::TextTexture,
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};
/// The main game state. /// The main game state.
/// ///
/// Contains all the information necessary to run the game, including /// Contains all the information necessary to run the game, including
/// the game state, rendering resources, and audio. /// the game state, rendering resources, and audio.
pub struct Game { pub struct Game {
// Game state pub score: u32,
pacman: Rc<RefCell<Pacman>>, pub map: Map,
blinky: Ghost, pub pacman: Pacman,
pinky: Ghost, pub debug_mode: bool,
inky: Ghost,
clyde: Ghost,
edibles: Vec<Edible>,
map: Rc<RefCell<Map>>,
score: u32,
debug_mode: DebugMode,
// FPS tracking
fps_1s: f64,
fps_10s: f64,
// Rendering resources // Rendering resources
atlas: Rc<RefCell<SpriteAtlas>>, atlas: SpriteAtlas,
map_texture: AtlasTile, map_texture: AtlasTile,
text_texture: TextTexture, text_texture: TextTexture,
debug_text_texture: TextTexture,
// Audio // Audio
pub audio: Audio, pub audio: Audio,
} }
impl Game { impl Game {
/// Creates a new `Game` instance.
pub fn new( pub fn new(
texture_creator: &TextureCreator<WindowContext>, texture_creator: &TextureCreator<WindowContext>,
_ttf_context: &sdl2::ttf::Sdl2TtfContext, _ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem, _audio_subsystem: &sdl2::AudioSubsystem,
) -> Game { ) -> 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_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset");
let atlas_texture = unsafe { let atlas_texture = unsafe {
let texture = texture_creator 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_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_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 atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let pacman = Rc::new(RefCell::new(Pacman::new(
UVec2::new(1, 1),
Rc::clone(&atlas),
Rc::clone(&map),
)));
// Find starting positions let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile");
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");
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9)); map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let edibles = reconstruct_edibles( let text_texture = TextTexture::new(1.0);
Rc::clone(&map), let debug_text_texture = TextTexture::new(0.5);
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 audio = Audio::new(); let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
Game { Game {
pacman,
blinky,
pinky,
inky,
clyde,
edibles,
map,
score: 0, score: 0,
debug_mode: DebugMode::None, map,
atlas, pacman,
debug_mode: false,
map_texture, map_texture,
text_texture, text_texture,
debug_text_texture,
audio, audio,
fps_1s: 0.0, atlas,
fps_10s: 0.0,
} }
} }
/// Handles a keyboard event.
pub fn keyboard_event(&mut self, keycode: Keycode) { pub fn keyboard_event(&mut self, keycode: Keycode) {
// Change direction self.pacman.handle_key(keycode);
let direction = Direction::from_keycode(keycode);
if direction.is_some() {
self.pacman.borrow_mut().next_direction = direction;
return;
}
// 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 { if keycode == Keycode::M {
self.audio.set_mute(self.audio.is_muted().not()); self.audio.set_mute(!self.audio.is_muted());
return; return;
} }
// Reset game
if keycode == Keycode::R {
self.reset();
}
} }
/// Adds points to the score. pub fn tick(&mut self, dt: f32) {
/// self.pacman.tick(dt, &self.map.graph);
/// # Arguments
///
/// * `points` - The number of points to add.
pub fn add_score(&mut self, points: u32) {
self.score += points;
} }
/// Updates the FPS tracking values. pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> {
pub fn update_fps(&mut self, fps_1s: f64, fps_10s: f64) { canvas.with_texture_canvas(backbuffer, |canvas| {
self.fps_1s = fps_1s;
self.fps_10s = fps_10s;
}
/// 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<Window>, 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 = <Pacman as crate::entity::Moving>::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<Window>, backbuffer: &Texture) -> Result<()> {
canvas.set_draw_color(Color::BLACK); canvas.set_draw_color(Color::BLACK);
canvas.clear(); canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
})?;
Ok(())
}
pub fn present_backbuffer<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &Texture) -> Result<()> {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?; 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(); canvas.present();
Ok(()) Ok(())
} }
fn render_ui_on<C: sdl2::render::RenderTarget>(&mut self, canvas: &mut sdl2::render::Canvas<C>) { fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
let score_text = self.score.to_string();
let lives = 3; let lives = 3;
let score_text = format!("{:02}", self.score); let score_text = format!("{:02}", self.score);
let x_offset = 4; let x_offset = 4;
@@ -396,11 +131,13 @@ impl Game {
self.text_texture.set_scale(1.0); self.text_texture.set_scale(1.0);
let _ = self.text_texture.render( let _ = self.text_texture.render(
canvas, canvas,
&mut self.atlas,
&format!("{lives}UP HIGH SCORE "), &format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset), UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
); );
let _ = self.text_texture.render( let _ = self.text_texture.render(
canvas, canvas,
&mut self.atlas,
&score_text, &score_text,
UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset), UVec2::new(8 * score_offset as u32 + x_offset, 8 + y_offset),
); );
@@ -414,5 +151,7 @@ impl Game {
// IVec2::new(10, 10), // IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display // Color::RGB(255, 255, 0), // Yellow color for FPS display
// ); // );
Ok(())
} }
} }

View File

@@ -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)
);
}
}

View File

@@ -1,11 +1,9 @@
#![windows_subsystem = "windows"] #![windows_subsystem = "windows"]
use crate::constants::{CANVAS_SIZE, SCALE}; use std::time::Duration;
use crate::game::Game;
use sdl2::event::{Event, WindowEvent}; use crate::{app::App, constants::LOOP_TIME};
use sdl2::keyboard::Keycode; use tracing::info;
use std::time::{Duration, Instant};
use tracing::event;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::layer::SubscriberExt; 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 // Do NOT call AllocConsole here - we don't want a console when launched from Explorer
} }
mod app;
mod asset; mod asset;
mod audio; mod audio;
mod constants; mod constants;
mod debug;
#[cfg(target_os = "emscripten")] #[cfg(target_os = "emscripten")]
mod emscripten; mod emscripten;
mod entity; mod entity;
mod game; mod game;
mod helper;
mod map; mod map;
mod texture; 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. /// The main entry point of the application.
/// ///
/// This function initializes SDL, the window, the game state, and then enters /// This function initializes SDL, the window, the game state, and then enters
@@ -85,14 +72,6 @@ pub fn main() {
attach_console(); 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 // Setup tracing
let subscriber = tracing_subscriber::fmt() let subscriber = tracing_subscriber::fmt()
.with_ansi(cfg!(not(target_os = "emscripten"))) .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"); tracing::subscriber::set_global_default(subscriber).expect("Could not set global default");
let window = video_subsystem let mut app = App::new().expect("Could not create app");
.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 canvas = window.into_canvas().build().expect("Could not build canvas"); info!("Starting game loop ({:?})", LOOP_TIME);
canvas #[cfg(target_os = "emscripten")]
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) emscripten::set_main_loop_callback(app.run);
.expect("Could not set logical size");
let texture_creator = canvas.texture_creator();
let texture_creator_static: &'static sdl2::render::TextureCreator<sdl2::video::WindowContext> =
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(not(target_os = "emscripten"))]
loop { loop {
if !main_loop() { if !app.run() {
break; break;
} }
} }

View File

@@ -1,16 +1,18 @@
//! This module defines the game map and provides functions for interacting with it. //! 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::constants::{MapTile, BOARD_CELL_SIZE, BOARD_PIXEL_OFFSET, BOARD_PIXEL_SIZE, CELL_SIZE};
use crate::texture::sprite::AtlasTile; use crate::entity::direction::DIRECTIONS;
use glam::{IVec2, UVec2}; use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use once_cell::sync::OnceCell; use glam::{IVec2, UVec2, Vec2};
use sdl2::rect::Rect; use sdl2::pixels::Color;
use sdl2::render::Canvas; use sdl2::rect::{Point, Rect};
use sdl2::video::Window; use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashSet, VecDeque}; 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. /// The game map.
/// ///
@@ -19,8 +21,8 @@ use std::collections::{HashSet, VecDeque};
pub struct Map { pub struct Map {
/// The current state of the map. /// The current state of the map.
current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize], current: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
/// The default state of the map. /// The node map for entity movement.
default: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize], pub graph: Graph,
} }
impl Map { impl Map {
@@ -31,7 +33,7 @@ impl Map {
/// * `raw_board` - A 2D array of characters representing the board layout. /// * `raw_board` - A 2D array of characters representing the board layout.
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map { 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 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 (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) { for (x, character) in line.chars().enumerate().take(BOARD_CELL_SIZE.x as usize) {
let tile = match character { let tile = match character {
@@ -40,127 +42,114 @@ impl Map {
'o' => MapTile::PowerPellet, 'o' => MapTile::PowerPellet,
' ' => MapTile::Empty, ' ' => MapTile::Empty,
'T' => MapTile::Tunnel, 'T' => MapTile::Tunnel,
c @ '0' | c @ '1' | c @ '2' | c @ '3' | c @ '4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8), c @ '0'..='4' => MapTile::StartingPosition(c.to_digit(10).unwrap() as u8),
'=' => MapTile::Empty, '=' => {
house_door.push(IVec2::new(x as i32, y as i32));
MapTile::Wall
}
_ => panic!("Unknown character in board: {character}"), _ => panic!("Unknown character in board: {character}"),
}; };
map[x][y] = tile; map[x][y] = tile;
} }
} }
Map { if house_door.len() != 2 {
current: map, panic!("House door must have exactly 2 positions");
default: map,
}
} }
/// Resets the map to its original state. let mut graph = Self::create_graph(&map);
pub fn reset(&mut self) {
// Restore the map to its original state let house_door_node_id = {
for (x, col) in self.current.iter_mut().enumerate().take(BOARD_CELL_SIZE.x as usize) { let offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
for (y, cell) in col.iter_mut().enumerate().take(BOARD_CELL_SIZE.y as usize) {
*cell = self.default[x][y]; 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 }
} }
/// Returns the tile at the given cell coordinates. fn create_graph(map: &[[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]) -> Graph {
/// let mut graph = Graph::new();
/// # Arguments let mut grid_to_node = HashMap::new();
///
/// * `cell` - The cell coordinates, in grid coordinates.
pub fn get_tile(&self, cell: IVec2) -> Option<MapTile> {
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 { let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
return None;
}
Some(self.current[x][y]) // 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)))
/// Sets the tile at the given cell coordinates. .find(|&p| matches!(map[p.x as usize][p.y as usize], MapTile::StartingPosition(0)))
/// .unwrap_or_else(|| {
/// # Arguments // Fallback to any valid walkable tile if Pac-Man's start is not found
/// (0..BOARD_CELL_SIZE.y)
/// * `cell` - The cell coordinates, in grid coordinates. .flat_map(|y| (0..BOARD_CELL_SIZE.x).map(move |x| IVec2::new(x as i32, y as i32)))
/// * `tile` - The tile to set. .find(|&p| {
pub fn set_tile(&mut self, cell: IVec2, tile: MapTile) -> bool { matches!(
let x = cell.x as usize; map[p.x as usize][p.y as usize],
let y = cell.y as usize; MapTile::Pellet
| MapTile::PowerPellet
if x >= BOARD_CELL_SIZE.x as usize || y >= BOARD_CELL_SIZE.y as usize { | MapTile::Empty
return false; | MapTile::Tunnel
} | MapTile::StartingPosition(_)
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,
) )
} })
.expect("No valid starting position found on map for graph generation")
});
/// 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<UVec2> {
use MapTile::*;
static CACHE: OnceCell<Vec<UVec2>> = 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(); let mut queue = VecDeque::new();
queue.push_back(start_pos);
queue.push_back(start); let pos = Vec2::new(
while let Some(pos) = queue.pop_front() { (start_pos.x * CELL_SIZE as i32) as f32,
if !visited.insert(pos) { (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);
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; continue;
} }
match self.current[pos.x as usize][pos.y as usize] { if grid_to_node.contains_key(&neighbor) {
Empty | Pellet | PowerPellet => { continue;
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 { if matches!(
let neighbor_tile = self.current[neighbor.x as usize][neighbor.y as usize]; map[neighbor.x as usize][neighbor.y as usize],
if matches!(neighbor_tile, Empty | Pellet | PowerPellet) { 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); queue.push_back(neighbor);
} }
} }
} }
}
StartingPosition(_) | Wall | Tunnel => {} 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");
} }
} }
let mut result: Vec<UVec2> = visited.into_iter().collect(); }
result.sort_unstable_by_key(|v| (v.x, v.y)); graph
CACHE.get_or_init(|| result)
} }
/// Finds the starting position for a given entity ID. /// 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. /// Renders the map to the given canvas using the provided map texture.
pub fn render(&self, canvas: &mut Canvas<Window>, map_texture: &mut AtlasTile) { pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_texture: &mut AtlasTile) {
let dest = Rect::new( let dest = Rect::new(
BOARD_PIXEL_OFFSET.x as i32, BOARD_PIXEL_OFFSET.x as i32,
BOARD_PIXEL_OFFSET.y as i32, BOARD_PIXEL_OFFSET.y as i32,
BOARD_PIXEL_SIZE.x, BOARD_PIXEL_SIZE.x,
BOARD_PIXEL_SIZE.y, BOARD_PIXEL_SIZE.y,
); );
let _ = map_texture.render(canvas, dest); let _ = map_texture.render(canvas, atlas, dest);
}
pub fn debug_render_nodes<T: RenderTarget>(&self, canvas: &mut Canvas<T>, 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();
}
} }
} }

View File

@@ -1,47 +1,41 @@
//! This module provides a simple animation and atlas system for textures.
use anyhow::Result; 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)] #[derive(Clone)]
pub struct AnimatedTexture { pub struct AnimatedTexture {
pub frames: Vec<AtlasTile>, tiles: Vec<AtlasTile>,
pub ticks_per_frame: u32, frame_duration: f32,
pub ticker: u32, current_frame: usize,
pub paused: bool, time_bank: f32,
} }
impl AnimatedTexture { impl AnimatedTexture {
pub fn new(frames: Vec<AtlasTile>, ticks_per_frame: u32) -> Self { pub fn new(tiles: Vec<AtlasTile>, frame_duration: f32) -> Self {
AnimatedTexture { Self {
frames, tiles,
ticks_per_frame, frame_duration,
ticker: 0, current_frame: 0,
paused: false, time_bank: 0.0,
} }
} }
/// Advances the animation by one tick, unless paused. pub fn tick(&mut self, dt: f32) {
pub fn tick(&mut self) { self.time_bank += dt;
if self.paused || self.ticks_per_frame == 0 { while self.time_bank >= self.frame_duration {
return; self.time_bank -= self.frame_duration;
self.current_frame = (self.current_frame + 1) % self.tiles.len();
}
} }
self.ticker += 1; pub fn current_tile(&self) -> &AtlasTile {
&self.tiles[self.current_frame]
} }
pub fn current_tile(&mut self) -> &mut AtlasTile { pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
if self.ticks_per_frame == 0 { let mut tile = self.current_tile().clone();
return &mut self.frames[0]; tile.render(canvas, atlas, dest)
}
let frame_index = (self.ticker / self.ticks_per_frame) as usize % self.frames.len();
&mut self.frames[frame_index]
}
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> {
let tile = self.current_tile();
tile.render(canvas, dest)
} }
} }

View File

@@ -1,48 +1,36 @@
//! A texture that blinks on/off for a specified number of ticks. use crate::texture::sprite::AtlasTile;
use anyhow::Result;
use sdl2::render::WindowCanvas;
use crate::texture::animated::AnimatedTexture;
#[derive(Clone)] #[derive(Clone)]
pub struct BlinkingTexture { pub struct BlinkingTexture {
pub animation: AnimatedTexture, tile: AtlasTile,
pub on_ticks: u32, blink_duration: f32,
pub off_ticks: u32, time_bank: f32,
pub ticker: u32, is_on: bool,
pub visible: bool,
} }
impl BlinkingTexture { impl BlinkingTexture {
pub fn new(animation: AnimatedTexture, on_ticks: u32, off_ticks: u32) -> Self { pub fn new(tile: AtlasTile, blink_duration: f32) -> Self {
BlinkingTexture { Self {
animation, tile,
on_ticks, blink_duration,
off_ticks, time_bank: 0.0,
ticker: 0, is_on: true,
visible: true,
} }
} }
/// Advances the blinking state by one tick. pub fn tick(&mut self, dt: f32) {
pub fn tick(&mut self) { self.time_bank += dt;
self.animation.tick(); if self.time_bank >= self.blink_duration {
self.ticker += 1; self.time_bank -= self.blink_duration;
if self.visible && self.ticker >= self.on_ticks { self.is_on = !self.is_on;
self.visible = false;
self.ticker = 0;
} else if !self.visible && self.ticker >= self.off_ticks {
self.visible = true;
self.ticker = 0;
} }
} }
/// Renders the blinking texture. pub fn is_on(&self) -> bool {
pub fn render(&mut self, canvas: &mut WindowCanvas, dest: sdl2::rect::Rect) -> Result<()> { self.is_on
if self.visible { }
self.animation.render(canvas, dest)
} else { pub fn tile(&self) -> &AtlasTile {
Ok(()) &self.tile
}
} }
} }

View File

@@ -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 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 struct DirectionalAnimatedTexture {
pub up: Vec<AtlasTile>, textures: HashMap<Direction, AnimatedTexture>,
pub down: Vec<AtlasTile>, stopped_textures: HashMap<Direction, AnimatedTexture>,
pub left: Vec<AtlasTile>,
pub right: Vec<AtlasTile>,
pub ticker: u32,
pub ticks_per_frame: u32,
} }
impl DirectionalAnimatedTexture { impl DirectionalAnimatedTexture {
pub fn new( pub fn new(textures: HashMap<Direction, AnimatedTexture>, stopped_textures: HashMap<Direction, AnimatedTexture>) -> Self {
up: Vec<AtlasTile>,
down: Vec<AtlasTile>,
left: Vec<AtlasTile>,
right: Vec<AtlasTile>,
ticks_per_frame: u32,
) -> Self {
Self { Self {
up, textures,
down, stopped_textures,
left,
right,
ticker: 0,
ticks_per_frame,
} }
} }
pub fn tick(&mut self) { pub fn tick(&mut self, dt: f32) {
self.ticker += 1; 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, pub fn render<T: RenderTarget>(
Direction::Down => &mut self.down, &self,
Direction::Left => &mut self.left, canvas: &mut Canvas<T>,
Direction::Right => &mut self.right, atlas: &mut SpriteAtlas,
}; dest: Rect,
direction: Direction,
let frame_index = (self.ticker / self.ticks_per_frame) as usize % frames.len(); ) -> Result<()> {
let tile = &mut frames[frame_index]; if let Some(texture) = self.textures.get(&direction) {
texture.render(canvas, atlas, dest)
tile.render(canvas, 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, pub fn render_stopped<T: RenderTarget>(
Direction::Down => &mut self.down, &self,
Direction::Left => &mut self.left, canvas: &mut Canvas<T>,
Direction::Right => &mut self.right, atlas: &mut SpriteAtlas,
}; dest: Rect,
direction: Direction,
// Show the last frame (full sprite) when stopped ) -> Result<()> {
let tile = &mut frames[1]; if let Some(texture) = self.stopped_textures.get(&direction) {
texture.render(canvas, atlas, dest)
tile.render(canvas, dest) } else {
Ok(())
}
} }
} }

View File

@@ -1,14 +1,5 @@
use std::cell::RefCell;
use std::rc::Rc;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
pub mod animated; pub mod animated;
pub mod blinking; pub mod blinking;
pub mod directional; pub mod directional;
pub mod sprite; pub mod sprite;
pub mod text; pub mod text;
pub fn get_atlas_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> AtlasTile {
SpriteAtlas::get_tile(atlas, name).unwrap_or_else(|| panic!("Could not find tile {name}"))
}

View File

@@ -4,9 +4,7 @@ use sdl2::pixels::Color;
use sdl2::rect::Rect; use sdl2::rect::Rect;
use sdl2::render::{Canvas, RenderTarget, Texture}; use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize; use serde::Deserialize;
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::rc::Rc;
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AtlasMapper { pub struct AtlasMapper {
@@ -21,26 +19,28 @@ pub struct MapperFrame {
pub height: u16, pub height: u16,
} }
#[derive(Clone)] #[derive(Copy, Clone, Debug)]
pub struct AtlasTile { pub struct AtlasTile {
pub atlas: Rc<RefCell<SpriteAtlas>>,
pub pos: U16Vec2, pub pos: U16Vec2,
pub size: U16Vec2, pub size: U16Vec2,
pub color: Option<Color>, pub color: Option<Color>,
} }
impl AtlasTile { impl AtlasTile {
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect) -> Result<()> { pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, dest: Rect) -> Result<()> {
let color = self let color = self.color.unwrap_or(atlas.default_color.unwrap_or(Color::WHITE));
.color self.render_with_color(canvas, atlas, dest, color)
.unwrap_or(self.atlas.borrow().default_color.unwrap_or(Color::WHITE));
self.render_with_color(canvas, dest, color)
} }
pub fn render_with_color<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, dest: Rect, color: Color) -> Result<()> { pub fn render_with_color<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
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 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) { if atlas.last_modulation != Some(color) {
atlas.texture.set_color_mod(color.r, color.g, color.b); atlas.texture.set_color_mod(color.r, color.g, color.b);
atlas.last_modulation = Some(color); atlas.last_modulation = Some(color);
@@ -68,10 +68,8 @@ impl SpriteAtlas {
} }
} }
pub fn get_tile(atlas: &Rc<RefCell<SpriteAtlas>>, name: &str) -> Option<AtlasTile> { pub fn get_tile(&self, name: &str) -> Option<AtlasTile> {
let atlas_ref = atlas.borrow(); self.tiles.get(name).map(|frame| AtlasTile {
atlas_ref.tiles.get(name).map(|frame| AtlasTile {
atlas: Rc::clone(atlas),
pos: U16Vec2::new(frame.x, frame.y), pos: U16Vec2::new(frame.x, frame.y),
size: U16Vec2::new(frame.width, frame.height), size: U16Vec2::new(frame.width, frame.height),
color: None, 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) std::mem::transmute(texture)
} }

View File

@@ -50,38 +50,34 @@ use anyhow::Result;
use glam::UVec2; use glam::UVec2;
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::rc::Rc;
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, SpriteAtlas};
/// A text texture that renders characters from the atlas. /// A text texture that renders characters from the atlas.
pub struct TextTexture { pub struct TextTexture {
atlas: Rc<RefCell<SpriteAtlas>>,
char_map: HashMap<char, AtlasTile>, char_map: HashMap<char, AtlasTile>,
scale: f32, scale: f32,
} }
impl TextTexture { impl TextTexture {
/// Creates a new text texture with the given atlas and scale. /// Creates a new text texture with the given atlas and scale.
pub fn new(atlas: Rc<RefCell<SpriteAtlas>>, scale: f32) -> Self { pub fn new(scale: f32) -> Self {
Self { Self {
atlas,
char_map: HashMap::new(), char_map: HashMap::new(),
scale, scale,
} }
} }
/// Maps a character to its atlas tile, handling special characters. /// Maps a character to its atlas tile, handling special characters.
fn get_char_tile(&mut self, c: char) -> Option<AtlasTile> { fn get_char_tile(&mut self, atlas: &SpriteAtlas, c: char) -> Option<AtlasTile> {
if let Some(tile) = self.char_map.get(&c) { 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_name = self.char_to_tile_name(c)?;
let tile = SpriteAtlas::get_tile(&self.atlas, &tile_name)?; let tile = atlas.get_tile(&tile_name)?;
self.char_map.insert(c, tile.clone()); self.char_map.insert(c, tile);
Some(tile) Some(tile)
} }
@@ -89,9 +85,7 @@ impl TextTexture {
fn char_to_tile_name(&self, c: char) -> Option<String> { fn char_to_tile_name(&self, c: char) -> Option<String> {
let name = match c { let name = match c {
// Letters A-Z // Letters A-Z
'A'..='Z' => format!("text/{c}.png"), 'A'..='Z' | '0'..='9' => format!("text/{c}.png"),
// Numbers 0-9
'0'..='9' => format!("text/{c}.png"),
// Special characters // Special characters
'!' => "text/!.png".to_string(), '!' => "text/!.png".to_string(),
'-' => "text/-.png".to_string(), '-' => "text/-.png".to_string(),
@@ -108,15 +102,21 @@ impl TextTexture {
} }
/// Renders a string of text at the given position. /// Renders a string of text at the given position.
pub fn render<C: RenderTarget>(&mut self, canvas: &mut Canvas<C>, text: &str, position: UVec2) -> Result<()> { pub fn render<C: RenderTarget>(
&mut self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
text: &str,
position: UVec2,
) -> Result<()> {
let mut x_offset = 0; let mut x_offset = 0;
let char_width = (8.0 * self.scale) as u32; let char_width = (8.0 * self.scale) as u32;
let char_height = (8.0 * self.scale) as u32; let char_height = (8.0 * self.scale) as u32;
for c in text.chars() { 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); 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) // Always advance x_offset for all characters (including spaces)
x_offset += char_width; x_offset += char_width;
@@ -136,7 +136,7 @@ impl TextTexture {
} }
/// Calculates the width of a string in pixels at the current scale. /// 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 char_width = (8.0 * self.scale) as u32;
let mut width = 0; let mut width = 0;