Compare commits

...

9 Commits

Author SHA1 Message Date
183a432116 test: add tests for collision, items, directional, sprite
enum macros for FruitKind
2025-08-12 09:18:53 -05:00
ead1466b2d chore: specify 'llvm-tools-preview' toolchain component for coverage in toolchain file 2025-08-12 00:22:27 -05:00
8ef09a4e3e test: drop minimal_test_board, use RAW_BOARD constant, item generation tests 2025-08-11 23:26:28 -05:00
33672d8d5a feat: implement collision detection system for entities 2025-08-11 23:24:23 -05:00
1dc8aca373 feat: item collection & collisions, pellet & energizer generation 2025-08-11 22:45:36 -05:00
02089a78da chore: downgrade toolchain to 1.86 on all versions
This is just because managing both 1.86 and 1.88 is really annoying, so
it's better to just be unified. There's no real point to using 1.88
besides more clippy warnings, which are already impeding my work right
now. So we're downgrading.
2025-08-11 22:10:41 -05:00
1f8e7c6d71 fix: resolve clippy warnings, inline format vars, use tracing to log warnings 2025-08-11 22:09:08 -05:00
27079e127d feat!: implement proper error handling, drop most expect() & unwrap() usages 2025-08-11 20:23:39 -05:00
5e9bb3535e ci: add dependabot config 2025-08-11 19:24:52 -05:00
34 changed files with 1351 additions and 249 deletions

20
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
dependencies:
patterns:
- "*"

View File

@@ -14,19 +14,19 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: macos-13 - os: macos-13
target: x86_64-apple-darwin target: x86_64-apple-darwin
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: macos-latest - os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
artifact_name: pacman artifact_name: pacman
toolchain: 1.88.0 toolchain: 1.86.0
- os: windows-latest - os: windows-latest
target: x86_64-pc-windows-gnu target: x86_64-pc-windows-gnu
artifact_name: pacman.exe artifact_name: pacman.exe
toolchain: 1.88.0 toolchain: 1.86.0
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout - name: Checkout

View File

@@ -4,7 +4,7 @@ on: ["push", "pull_request"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0 RUST_TOOLCHAIN: 1.86.0
jobs: jobs:
test: test:

26
Cargo.lock generated
View File

@@ -89,6 +89,12 @@ version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.10.0" version = "2.10.0"
@@ -194,6 +200,8 @@ dependencies = [
"serde_json", "serde_json",
"smallvec", "smallvec",
"spin_sleep", "spin_sleep",
"strum",
"strum_macros",
"thiserror 1.0.69", "thiserror 1.0.69",
"tracing", "tracing",
"tracing-error", "tracing-error",
@@ -406,6 +414,24 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.104" version = "2.0.104"

View File

@@ -21,6 +21,8 @@ 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" smallvec = "1.15.1"
strum = "0.27.2"
strum_macros = "0.27.2"
[profile.release] [profile.release]
lto = true lto = true

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.86.0"
components = ["rustfmt", "llvm-tools-preview", "clippy"]

View File

@@ -1,6 +1,5 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use glam::Vec2; use glam::Vec2;
use sdl2::event::{Event, WindowEvent}; use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
@@ -9,6 +8,8 @@ use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::EventPump;
use tracing::{error, event}; use tracing::{error, event};
use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE}; use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game; use crate::game::Game;
use crate::platform::get_platform; use crate::platform::get_platform;
@@ -24,14 +25,14 @@ pub struct App<'a> {
} }
impl App<'_> { impl App<'_> {
pub fn new() -> Result<Self> { pub fn new() -> GameResult<Self> {
// Initialize platform-specific console // Initialize platform-specific console
get_platform().init_console().map_err(|e| anyhow!(e))?; get_platform().init_console()?;
let sdl_context = sdl2::init().map_err(|e| anyhow!(e))?; let sdl_context = sdl2::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let video_subsystem = sdl_context.video().map_err(|e| anyhow!(e))?; let video_subsystem = sdl_context.video().map_err(|e| GameError::Sdl(e.to_string()))?;
let audio_subsystem = sdl_context.audio().map_err(|e| anyhow!(e))?; let audio_subsystem = sdl_context.audio().map_err(|e| GameError::Sdl(e.to_string()))?;
let ttf_context = sdl2::ttf::init().map_err(|e| anyhow!(e.to_string()))?; let ttf_context = sdl2::ttf::init().map_err(|e| GameError::Sdl(e.to_string()))?;
let window = video_subsystem let window = video_subsystem
.window( .window(
@@ -41,24 +42,31 @@ impl App<'_> {
) )
.resizable() .resizable()
.position_centered() .position_centered()
.build()?; .build()
.map_err(|e| GameError::Sdl(e.to_string()))?;
let mut canvas = window.into_canvas().build()?; let mut canvas = window.into_canvas().build().map_err(|e| GameError::Sdl(e.to_string()))?;
canvas.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)?; canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator_static: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); 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); let mut game = Game::new(texture_creator_static, &ttf_context, &audio_subsystem)?;
game.audio.set_mute(cfg!(debug_assertions)); game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator_static.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)?; let mut backbuffer = texture_creator_static
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest); backbuffer.set_scale_mode(ScaleMode::Nearest);
let event_pump = sdl_context.event_pump().map_err(|e| anyhow!(e))?; let event_pump = sdl_context.event_pump().map_err(|e| GameError::Sdl(e.to_string()))?;
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer)?; game.draw(&mut canvas, &mut backbuffer)
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)?; .map_err(|e| GameError::Sdl(e.to_string()))?;
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(Self { Ok(Self {
game, game,
@@ -109,8 +117,8 @@ impl App<'_> {
} => { } => {
self.game.debug_mode = !self.game.debug_mode; self.game.debug_mode = !self.game.debug_mode;
} }
Event::KeyDown { keycode, .. } => { Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(keycode.unwrap()); self.game.keyboard_event(key);
} }
Event::MouseMotion { x, y, .. } => { Event::MouseMotion { x, y, .. } => {
// Convert window coordinates to logical coordinates // Convert window coordinates to logical coordinates
@@ -126,13 +134,13 @@ impl App<'_> {
if !self.paused { if !self.paused {
self.game.tick(dt); self.game.tick(dt);
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
error!("Failed to draw game: {e}"); error!("Failed to draw game: {}", e);
} }
if let Err(e) = self if let Err(e) = self
.game .game
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos) .present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
{ {
error!("Failed to present backbuffer: {e}"); error!("Failed to present backbuffer: {}", e);
} }
} }

128
src/entity/collision.rs Normal file
View File

@@ -0,0 +1,128 @@
use smallvec::SmallVec;
use std::collections::HashMap;
use crate::entity::traversal::Position;
/// Trait for entities that can participate in collision detection.
pub trait Collidable {
/// Returns the current position of this entity.
fn position(&self) -> Position;
/// Checks if this entity is colliding with another entity.
#[allow(dead_code)]
fn is_colliding_with(&self, other: &dyn Collidable) -> bool {
positions_overlap(&self.position(), &other.position())
}
}
/// System for tracking entities by their positions for efficient collision detection.
#[derive(Default)]
pub struct CollisionSystem {
/// Maps node IDs to lists of entity IDs that are at that node
node_entities: HashMap<usize, Vec<EntityId>>,
/// Maps entity IDs to their current positions
entity_positions: HashMap<EntityId, Position>,
/// Next available entity ID
next_id: EntityId,
}
/// Unique identifier for an entity in the collision system
pub type EntityId = u32;
impl CollisionSystem {
/// Registers an entity with the collision system and returns its ID
pub fn register_entity(&mut self, position: Position) -> EntityId {
let id = self.next_id;
self.next_id += 1;
self.entity_positions.insert(id, position);
self.update_node_entities(id, position);
id
}
/// Updates an entity's position
pub fn update_position(&mut self, entity_id: EntityId, new_position: Position) {
if let Some(old_position) = self.entity_positions.get(&entity_id) {
// Remove from old nodes
self.remove_from_nodes(entity_id, *old_position);
}
// Update position and add to new nodes
self.entity_positions.insert(entity_id, new_position);
self.update_node_entities(entity_id, new_position);
}
/// Removes an entity from the collision system
#[allow(dead_code)]
pub fn remove_entity(&mut self, entity_id: EntityId) {
if let Some(position) = self.entity_positions.remove(&entity_id) {
self.remove_from_nodes(entity_id, position);
}
}
/// Gets all entity IDs at a specific node
pub fn entities_at_node(&self, node: usize) -> &[EntityId] {
self.node_entities.get(&node).map(|v| v.as_slice()).unwrap_or(&[])
}
/// Gets all entity IDs that could collide with an entity at the given position
pub fn potential_collisions(&self, position: &Position) -> Vec<EntityId> {
let mut collisions = Vec::new();
let nodes = get_nodes(position);
for node in nodes {
collisions.extend(self.entities_at_node(node));
}
// Remove duplicates
collisions.sort_unstable();
collisions.dedup();
collisions
}
/// Updates the node_entities map when an entity's position changes
fn update_node_entities(&mut self, entity_id: EntityId, position: Position) {
let nodes = get_nodes(&position);
for node in nodes {
self.node_entities.entry(node).or_default().push(entity_id);
}
}
/// Removes an entity from all nodes it was previously at
fn remove_from_nodes(&mut self, entity_id: EntityId, position: Position) {
let nodes = get_nodes(&position);
for node in nodes {
if let Some(entities) = self.node_entities.get_mut(&node) {
entities.retain(|&id| id != entity_id);
if entities.is_empty() {
self.node_entities.remove(&node);
}
}
}
}
}
/// Checks if two positions overlap (entities are at the same location).
fn positions_overlap(a: &Position, b: &Position) -> bool {
let a_nodes = get_nodes(a);
let b_nodes = get_nodes(b);
// Check if any nodes overlap
a_nodes.iter().any(|a_node| b_nodes.contains(a_node))
// TODO: More complex overlap detection, the above is a simple check, but it could become an early filter for more precise calculations later
}
/// Gets all nodes that an entity is currently at or between.
fn get_nodes(pos: &Position) -> SmallVec<[usize; 2]> {
let mut nodes = SmallVec::new();
match pos {
Position::AtNode(node) => nodes.push(*node),
Position::BetweenNodes { from, to, .. } => {
nodes.push(*from);
nodes.push(*to);
}
}
nodes
}

View File

@@ -7,15 +7,21 @@
use pathfinding::prelude::dijkstra; use pathfinding::prelude::dijkstra;
use rand::prelude::*; use rand::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use tracing::error;
use crate::entity::direction::Direction; use crate::entity::{
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; collision::Collidable,
use crate::entity::r#trait::Entity; direction::Direction,
use crate::entity::traversal::Traverser; graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use crate::error::{EntityError, GameError, GameResult, TextureError};
/// Determines if a ghost can traverse a given edge. /// Determines if a ghost can traverse a given edge.
/// ///
/// Ghosts can move through edges that allow all entities or ghost-only edges. /// Ghosts can move through edges that allow all entities or ghost-only edges.
@@ -101,7 +107,9 @@ impl Entity for Ghost {
self.choose_random_direction(graph); self.choose_random_direction(graph);
} }
self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse); if let Err(e) = self.traverser.advance(graph, dt * 60.0 * self.speed, &can_ghost_traverse) {
error!("Ghost movement error: {}", e);
}
self.texture.tick(dt); self.texture.tick(dt);
} }
} }
@@ -111,7 +119,7 @@ impl Ghost {
/// ///
/// Sets up animated textures for all four directions with moving and stopped states. /// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through two sprite variants. /// The moving animation cycles through two sprite variants.
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> Self { pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None]; let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None];
@@ -123,27 +131,51 @@ impl Ghost {
Direction::Right => "right", Direction::Right => "right",
}; };
let moving_tiles = vec![ let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")).unwrap(), SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b")).unwrap(), .ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "b"))
.ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"b"
)))
})?,
]; ];
let stopped_tiles = let stopped_tiles =
vec![ vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a")) SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.unwrap(), .ok_or_else(|| {
GameError::Texture(TextureError::AtlasTileNotFound(format!(
"ghost/{}/{}_{}.png",
ghost_type.as_str(),
moving_prefix,
"a"
)))
})?,
]; ];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.2).expect("Invalid frame duration")); textures[direction.as_usize()] =
Some(AnimatedTexture::new(moving_tiles, 0.2).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] = stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration")); Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
} }
Self { Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse), traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
ghost_type, ghost_type,
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
speed: ghost_type.base_speed(), speed: ghost_type.base_speed(),
} })
} }
/// Chooses a random available direction at the current intersection. /// Chooses a random available direction at the current intersection.
@@ -179,9 +211,9 @@ impl Ghost {
/// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm. /// Calculates the shortest path from the ghost's current position to a target node using Dijkstra's algorithm.
/// ///
/// Returns a vector of NodeIds representing the path, or None if no path exists. /// Returns a vector of NodeIds representing the path, or an error if pathfinding fails.
/// The path includes the current node and the target node. /// The path includes the current node and the target node.
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> Option<Vec<NodeId>> { pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult<Vec<NodeId>> {
let start_node = self.traverser.position.from_node_id(); let start_node = self.traverser.position.from_node_id();
// Use Dijkstra's algorithm to find the shortest path // Use Dijkstra's algorithm to find the shortest path
@@ -198,7 +230,12 @@ impl Ghost {
|&node_id| node_id == target, |&node_id| node_id == target,
); );
result.map(|(path, _cost)| path) result.map(|(path, _cost)| path).ok_or_else(|| {
GameError::Entity(EntityError::PathfindingFailed(format!(
"No path found from node {} to target {}",
start_node, target
)))
})
} }
/// Returns the ghost's color for debug rendering. /// Returns the ghost's color for debug rendering.
@@ -211,3 +248,9 @@ impl Ghost {
} }
} }
} }
impl Collidable for Ghost {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

115
src/entity/item.rs Normal file
View File

@@ -0,0 +1,115 @@
use crate::{
constants,
entity::{collision::Collidable, graph::Graph},
error::EntityError,
texture::sprite::{Sprite, SpriteAtlas},
};
use sdl2::render::{Canvas, RenderTarget};
use strum_macros::{EnumCount, EnumIter};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemType {
Pellet,
Energizer,
#[allow(dead_code)]
Fruit {
kind: FruitKind,
},
}
impl ItemType {
pub fn get_score(self) -> u32 {
match self {
ItemType::Pellet => 10,
ItemType::Energizer => 50,
ItemType::Fruit { kind } => kind.get_score(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, EnumIter, EnumCount)]
#[allow(dead_code)]
pub enum FruitKind {
Apple,
Strawberry,
Orange,
Melon,
Bell,
Key,
Galaxian,
}
impl FruitKind {
#[allow(dead_code)]
pub fn index(self) -> u8 {
match self {
FruitKind::Apple => 0,
FruitKind::Strawberry => 1,
FruitKind::Orange => 2,
FruitKind::Melon => 3,
FruitKind::Bell => 4,
FruitKind::Key => 5,
FruitKind::Galaxian => 6,
}
}
pub fn get_score(self) -> u32 {
match self {
FruitKind::Apple => 100,
FruitKind::Strawberry => 300,
FruitKind::Orange => 500,
FruitKind::Melon => 700,
FruitKind::Bell => 1000,
FruitKind::Key => 2000,
FruitKind::Galaxian => 3000,
}
}
}
pub struct Item {
pub node_index: usize,
pub item_type: ItemType,
pub sprite: Sprite,
pub collected: bool,
}
impl Item {
pub fn new(node_index: usize, item_type: ItemType, sprite: Sprite) -> Self {
Self {
node_index,
item_type,
sprite,
collected: false,
}
}
pub fn is_collected(&self) -> bool {
self.collected
}
pub fn collect(&mut self) {
self.collected = true;
}
pub fn get_score(&self) -> u32 {
self.item_type.get_score()
}
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> anyhow::Result<()> {
if !self.collected {
let node = graph
.get_node(self.node_index)
.ok_or(EntityError::NodeNotFound(self.node_index))?;
let position = node.position + constants::BOARD_PIXEL_OFFSET.as_vec2();
self.sprite.render(canvas, atlas, position)
} else {
Ok(())
}
}
}
impl Collidable for Item {
fn position(&self) -> crate::entity::traversal::Position {
crate::entity::traversal::Position::AtNode(self.node_index)
}
}

View File

@@ -1,6 +1,8 @@
pub mod collision;
pub mod direction; pub mod direction;
pub mod ghost; pub mod ghost;
pub mod graph; pub mod graph;
pub mod item;
pub mod pacman; pub mod pacman;
pub mod r#trait; pub mod r#trait;
pub mod traversal; pub mod traversal;

View File

@@ -4,14 +4,20 @@
//! animation, and rendering. Pac-Man moves through the game graph using //! animation, and rendering. Pac-Man moves through the game graph using
//! a traverser and displays directional animated textures. //! a traverser and displays directional animated textures.
use crate::entity::direction::Direction; use crate::entity::{
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; collision::Collidable,
use crate::entity::r#trait::Entity; direction::Direction,
use crate::entity::traversal::Traverser; graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode; use sdl2::keyboard::Keycode;
use tracing::error;
use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge. /// Determines if Pac-Man can traverse a given edge.
/// ///
@@ -57,7 +63,9 @@ impl Entity for Pacman {
} }
fn tick(&mut self, dt: f32, graph: &Graph) { fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse); if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
error!("Pac-Man movement error: {}", e);
}
self.texture.tick(dt); self.texture.tick(dt);
} }
} }
@@ -67,7 +75,7 @@ impl Pacman {
/// ///
/// Sets up animated textures for all four directions with moving and stopped states. /// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through open mouth, closed mouth, and full sprites. /// The moving animation cycles through open mouth, closed mouth, and full sprites.
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> Self { pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None]; let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None]; let mut stopped_textures = [None, None, None, None];
@@ -79,22 +87,27 @@ impl Pacman {
Direction::Right => "pacman/right", Direction::Right => "pacman/right",
}; };
let moving_tiles = vec![ let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(), .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(), SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
]; ];
let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap()]; let stopped_tiles = vec![SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08).expect("Invalid frame duration")); textures[direction.as_usize()] =
Some(AnimatedTexture::new(moving_tiles, 0.08).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] = stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).expect("Invalid frame duration")); Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
} }
Self { Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
} })
} }
/// Handles keyboard input to change Pac-Man's direction. /// Handles keyboard input to change Pac-Man's direction.
@@ -115,3 +128,9 @@ impl Pacman {
} }
} }
} }
impl Collidable for Pacman {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

View File

@@ -10,6 +10,7 @@ use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, Graph, NodeId}; use crate::entity::graph::{Edge, Graph, NodeId};
use crate::entity::traversal::{Position, Traverser}; use crate::entity::traversal::{Position, Traverser};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
@@ -48,21 +49,24 @@ pub trait Entity {
/// ///
/// Converts the graph position to screen coordinates, accounting for /// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite. /// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 { fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self.traverser().position { let pos = match self.traverser().position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position, Position::AtNode(node_id) => {
let node = graph.get_node(node_id).ok_or(EntityError::NodeNotFound(node_id))?;
node.position
}
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position; let from_node = graph.get_node(from).ok_or(EntityError::NodeNotFound(from))?;
let to_pos = graph.get_node(to).unwrap().position; let to_node = graph.get_node(to).ok_or(EntityError::NodeNotFound(to))?;
let edge = graph.find_edge(from, to).unwrap(); let edge = graph.find_edge(from, to).ok_or(EntityError::EdgeNotFound { from, to })?;
from_pos + (to_pos - from_pos) * (traversed / edge.distance) from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
} }
}; };
Vec2::new( Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32, pos.x + crate::constants::BOARD_PIXEL_OFFSET.x as f32,
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
) ))
} }
/// Returns the current node ID that the entity is at or moving towards. /// Returns the current node ID that the entity is at or moving towards.
@@ -88,8 +92,8 @@ pub trait Entity {
/// ///
/// Draws the appropriate directional sprite based on the entity's /// Draws the appropriate directional sprite based on the entity's
/// current movement state and direction. /// current movement state and direction.
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) { fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
let pixel_pos = self.get_pixel_pos(graph); let pixel_pos = self.get_pixel_pos(graph)?;
let dest = crate::helpers::centered_with_size( let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32), glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16), glam::UVec2::new(16, 16),
@@ -98,11 +102,13 @@ pub trait Entity {
if self.traverser().position.is_stopped() { if self.traverser().position.is_stopped() {
self.texture() self.texture()
.render_stopped(canvas, atlas, dest, self.traverser().direction) .render_stopped(canvas, atlas, dest, self.traverser().direction)
.expect("Failed to render entity"); .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} else { } else {
self.texture() self.texture()
.render(canvas, atlas, dest, self.traverser().direction) .render(canvas, atlas, dest, self.traverser().direction)
.expect("Failed to render entity"); .map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} }
Ok(())
} }
} }

View File

@@ -1,3 +1,7 @@
use tracing::error;
use crate::error::GameResult;
use super::direction::Direction; use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId}; use super::graph::{Edge, Graph, NodeId};
@@ -82,7 +86,9 @@ impl Traverser {
}; };
// This will kickstart the traverser into motion // This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse); if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
error!("Traverser initialization error: {}", e);
}
traverser traverser
} }
@@ -108,7 +114,9 @@ impl Traverser {
/// - If it reaches a node, it attempts to transition to a new edge based on /// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight. /// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node. /// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) ///
/// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
where where
F: Fn(Edge) -> bool, F: Fn(Edge) -> bool,
{ {
@@ -134,7 +142,18 @@ impl Traverser {
traversed: distance.max(0.0), traversed: distance.max(0.0),
}; };
self.direction = next_direction; self.direction = next_direction;
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!(
"Cannot traverse edge from {} to {} in direction {:?}",
node_id, edge.target, next_direction
),
)));
} }
} else {
return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
format!("No edge found in direction {:?} from node {}", next_direction, node_id),
)));
} }
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
@@ -143,12 +162,15 @@ impl Traverser {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all // There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 { if distance <= 0.0 {
return; return Ok(());
} }
let edge = graph let edge = graph.find_edge(from, to).ok_or_else(|| {
.find_edge(from, to) crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
.expect("Inconsistent state: Traverser is on a non-existent edge."); "Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
)))
})?;
let new_traversed = traversed + distance; let new_traversed = traversed + distance;
@@ -201,5 +223,7 @@ impl Traverser {
} }
} }
} }
Ok(())
} }
} }

156
src/error.rs Normal file
View File

@@ -0,0 +1,156 @@
//! Centralized error types for the Pac-Man game.
//!
//! This module defines all error types used throughout the application,
//! providing a consistent error handling approach.
use thiserror::Error;
/// Main error type for the Pac-Man game.
///
/// This is the primary error type that should be used in public APIs.
/// It can represent any error that can occur during game operation.
#[derive(Error, Debug)]
pub enum GameError {
#[error("Asset error: {0}")]
Asset(#[from] crate::asset::AssetError),
#[error("Platform error: {0}")]
Platform(#[from] crate::platform::PlatformError),
#[error("Map parsing error: {0}")]
MapParse(#[from] crate::map::parser::ParseError),
#[error("Map error: {0}")]
Map(#[from] MapError),
#[error("Texture error: {0}")]
Texture(#[from] TextureError),
#[error("Entity error: {0}")]
Entity(#[from] EntityError),
#[error("Game state error: {0}")]
GameState(#[from] GameStateError),
#[error("SDL error: {0}")]
Sdl(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Invalid state: {0}")]
InvalidState(String),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Configuration error: {0}")]
Config(String),
}
/// Errors related to texture operations.
#[derive(Error, Debug)]
pub enum TextureError {
#[error("Animated texture error: {0}")]
Animated(#[from] crate::texture::animated::AnimatedTextureError),
#[error("Failed to load texture: {0}")]
LoadFailed(String),
#[error("Texture not found in atlas: {0}")]
AtlasTileNotFound(String),
#[error("Invalid texture format: {0}")]
InvalidFormat(String),
#[error("Rendering failed: {0}")]
RenderFailed(String),
}
/// Errors related to entity operations.
#[derive(Error, Debug)]
pub enum EntityError {
#[error("Node not found in graph: {0}")]
NodeNotFound(usize),
#[error("Edge not found: from {from} to {to}")]
EdgeNotFound { from: usize, to: usize },
#[error("Invalid movement: {0}")]
InvalidMovement(String),
#[error("Pathfinding failed: {0}")]
PathfindingFailed(String),
}
/// Errors related to game state operations.
#[derive(Error, Debug)]
pub enum GameStateError {}
/// Errors related to map operations.
#[derive(Error, Debug)]
pub enum MapError {
#[error("Node not found: {0}")]
NodeNotFound(usize),
#[error("Invalid map configuration: {0}")]
InvalidConfig(String),
}
/// Result type for game operations.
pub type GameResult<T> = Result<T, GameError>;
/// Helper trait for converting other error types to GameError.
pub trait IntoGameError<T> {
#[allow(dead_code)]
fn into_game_error(self) -> GameResult<T>;
}
impl<T, E> IntoGameError<T> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn into_game_error(self) -> GameResult<T> {
self.map_err(|e| GameError::InvalidState(e.to_string()))
}
}
/// Helper trait for converting Option to GameResult with a custom error.
pub trait OptionExt<T> {
#[allow(dead_code)]
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError;
}
impl<T> OptionExt<T> for Option<T> {
fn ok_or_game_error<F>(self, f: F) -> GameResult<T>
where
F: FnOnce() -> GameError,
{
self.ok_or_else(f)
}
}
/// Helper trait for converting Result to GameResult with context.
pub trait ResultExt<T, E> {
#[allow(dead_code)]
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError;
}
impl<T, E> ResultExt<T, E> for Result<T, E>
where
E: std::error::Error + Send + Sync + 'static,
{
fn with_context<F>(self, f: F) -> GameResult<T>
where
F: FnOnce(&E) -> GameError,
{
self.map_err(|e| f(&e))
}
}

View File

@@ -1,6 +1,5 @@
//! This module contains the main game logic and state. //! This module contains the main game logic and state.
use anyhow::Result;
use glam::{UVec2, Vec2}; use glam::{UVec2, Vec2};
use rand::{rngs::SmallRng, Rng, SeedableRng}; use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{ use sdl2::{
@@ -11,12 +10,16 @@ use sdl2::{
video::WindowContext, video::WindowContext,
}; };
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::{ use crate::{
asset::{get_asset_bytes, Asset}, asset::{get_asset_bytes, Asset},
audio::Audio, audio::Audio,
constants::{CELL_SIZE, RAW_BOARD}, constants::{CELL_SIZE, RAW_BOARD},
entity::{ entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType}, ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman, pacman::Pacman,
r#trait::Entity, r#trait::Entity,
}, },
@@ -36,8 +39,15 @@ pub struct Game {
pub map: Map, pub map: Map,
pub pacman: Pacman, pub pacman: Pacman,
pub ghosts: Vec<Ghost>, pub ghosts: Vec<Ghost>,
pub items: Vec<Item>,
pub debug_mode: bool, pub debug_mode: bool,
// Collision system
collision_system: CollisionSystem,
pacman_id: EntityId,
ghost_ids: Vec<EntityId>,
item_ids: Vec<EntityId>,
// Rendering resources // Rendering resources
atlas: SpriteAtlas, atlas: SpriteAtlas,
map_texture: AtlasTile, map_texture: AtlasTile,
@@ -52,56 +62,96 @@ impl Game {
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 { ) -> GameResult<Game> {
let map = Map::new(RAW_BOARD); let map = Map::new(RAW_BOARD)?;
let pacman_start_pos = map.find_starting_position(0).unwrap(); let pacman_start_pos = map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
let pacman_start_node = *map let pacman_start_node = *map
.grid_to_node .grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32)) .get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph"); .ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
let atlas_bytes = get_asset_bytes(Asset::Atlas).expect("Failed to load asset"); let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = unsafe { let atlas_texture = unsafe {
let texture = texture_creator let texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
.load_texture_bytes(&atlas_bytes) if e.to_string().contains("format") || e.to_string().contains("unsupported") {
.expect("Could not load atlas texture from asset API"); GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
} else {
GameError::Texture(TextureError::LoadFailed(e.to_string()))
}
})?;
sprite::texture_to_static(texture) sprite::texture_to_static(texture)
}; };
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset"); let atlas_json = get_asset_bytes(Asset::AtlasJson)?;
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)?;
let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper); let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png").expect("Failed to load map tile"); let mut map_texture = SpriteAtlas::get_tile(&atlas, "maze/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/full.png".to_string())))?;
map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9)); map_texture.color = Some(Color::RGB(0x20, 0x20, 0xf9));
let text_texture = TextTexture::new(1.0); let text_texture = TextTexture::new(1.0);
let audio = Audio::new(); let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas); let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
// Generate items (pellets and energizers)
let items = map.generate_items(&atlas)?;
// Create ghosts at random positions // Create ghosts at random positions
let mut ghosts = Vec::new(); let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng(); let mut rng = SmallRng::from_os_rng();
if map.graph.node_count() == 0 {
return Err(GameError::Config("Game map has no nodes - invalid configuration".to_string()));
// TODO: This is a bug, we should handle this better
}
for &ghost_type in &ghost_types { for &ghost_type in &ghost_types {
// Find a random node for the ghost to start at // Find a random node for the ghost to start at
let random_node = rng.random_range(0..map.graph.node_count()); let random_node = rng.random_range(0..map.graph.node_count());
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas); let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas)?;
ghosts.push(ghost); ghosts.push(ghost);
} }
Game { // Initialize collision system
let mut collision_system = CollisionSystem::default();
// Register Pac-Man
let pacman_id = collision_system.register_entity(pacman.position());
// Register items
let mut item_ids = Vec::new();
for item in &items {
let item_id = collision_system.register_entity(item.position());
item_ids.push(item_id);
}
// Register ghosts
let mut ghost_ids = Vec::new();
for ghost in &ghosts {
let ghost_id = collision_system.register_entity(ghost.position());
ghost_ids.push(ghost_id);
}
Ok(Game {
score: 0, score: 0,
map, map,
pacman, pacman,
ghosts, ghosts,
items,
debug_mode: false, debug_mode: false,
collision_system,
pacman_id,
ghost_ids,
item_ids,
map_texture, map_texture,
text_texture, text_texture,
audio, audio,
atlas, atlas,
} })
} }
pub fn keyboard_event(&mut self, keycode: Keycode) { pub fn keyboard_event(&mut self, keycode: Keycode) {
@@ -112,21 +162,29 @@ impl Game {
} }
if keycode == Keycode::R { if keycode == Keycode::R {
self.reset_game_state(); if let Err(e) = self.reset_game_state() {
tracing::error!("Failed to reset game state: {}", e);
}
} }
} }
/// Resets the game state, randomizing ghost positions and resetting Pac-Man /// Resets the game state, randomizing ghost positions and resetting Pac-Man
fn reset_game_state(&mut self) { fn reset_game_state(&mut self) -> GameResult<()> {
// Reset Pac-Man to starting position // Reset Pac-Man to starting position
let pacman_start_pos = self.map.find_starting_position(0).unwrap(); let pacman_start_pos = self
.map
.find_starting_position(0)
.ok_or_else(|| GameError::NotFound("Pac-Man starting position".to_string()))?;
let pacman_start_node = *self let pacman_start_node = *self
.map .map
.grid_to_node .grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32)) .get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.expect("Pac-Man starting position not found in graph"); .ok_or_else(|| GameError::NotFound("Pac-Man starting position not found in graph".to_string()))?;
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas); self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?;
// Reset items
self.items = self.map.generate_items(&self.atlas)?;
// Randomize ghost positions // Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
@@ -134,8 +192,30 @@ impl Game {
for (i, ghost) in self.ghosts.iter_mut().enumerate() { for (i, ghost) in self.ghosts.iter_mut().enumerate() {
let random_node = rng.random_range(0..self.map.graph.node_count()); let random_node = rng.random_range(0..self.map.graph.node_count());
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas); *ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas)?;
} }
// Reset collision system
self.collision_system = CollisionSystem::default();
// Re-register Pac-Man
self.pacman_id = self.collision_system.register_entity(self.pacman.position());
// Re-register items
self.item_ids.clear();
for item in &self.items {
let item_id = self.collision_system.register_entity(item.position());
self.item_ids.push(item_id);
}
// Re-register ghosts
self.ghost_ids.clear();
for ghost in &self.ghosts {
let ghost_id = self.collision_system.register_entity(ghost.position());
self.ghost_ids.push(ghost_id);
}
Ok(())
} }
pub fn tick(&mut self, dt: f32) { pub fn tick(&mut self, dt: f32) {
@@ -145,21 +225,88 @@ impl Game {
for ghost in &mut self.ghosts { for ghost in &mut self.ghosts {
ghost.tick(dt, &self.map.graph); ghost.tick(dt, &self.map.graph);
} }
// Update collision system positions
self.update_collision_positions();
// Check for collisions
self.check_collisions();
} }
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> Result<()> { fn update_collision_positions(&mut self) {
canvas.with_texture_canvas(backbuffer, |canvas| { // Update Pac-Man's position
canvas.set_draw_color(Color::BLACK); self.collision_system.update_position(self.pacman_id, self.pacman.position());
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all ghosts // Update ghost positions
for ghost in &self.ghosts { for (ghost, &ghost_id) in self.ghosts.iter().zip(&self.ghost_ids) {
ghost.render(canvas, &mut self.atlas, &self.map.graph); self.collision_system.update_position(ghost_id, ghost.position());
}
}
fn check_collisions(&mut self) {
// Check Pac-Man vs Items
let potential_collisions = self.collision_system.potential_collisions(&self.pacman.position());
for entity_id in potential_collisions {
if entity_id != self.pacman_id {
// Check if this is an item collision
if let Some(item_index) = self.find_item_by_id(entity_id) {
let item = &mut self.items[item_index];
if !item.is_collected() {
item.collect();
self.score += item.get_score();
// Handle energizer effects
if matches!(item.item_type, crate::entity::item::ItemType::Energizer) {
// TODO: Make ghosts frightened
tracing::info!("Energizer collected! Ghosts should become frightened.");
}
}
}
// Check if this is a ghost collision
if let Some(_ghost_index) = self.find_ghost_by_id(entity_id) {
// TODO: Handle Pac-Man being eaten by ghost
tracing::info!("Pac-Man collided with ghost!");
}
} }
}
}
self.pacman.render(canvas, &mut self.atlas, &self.map.graph); fn find_item_by_id(&self, entity_id: EntityId) -> Option<usize> {
})?; self.item_ids.iter().position(|&id| id == entity_id)
}
fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
self.ghost_ids.iter().position(|&id| id == entity_id)
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
canvas
.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
// Render all items
for item in &self.items {
if let Err(e) = item.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render item: {}", e);
}
}
// Render all ghosts
for ghost in &self.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render ghost: {}", e);
}
}
if let Err(e) = self.pacman.render(canvas, &mut self.atlas, &self.map.graph) {
tracing::error!("Failed to render pacman: {}", e);
}
})
.map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(()) Ok(())
} }
@@ -169,11 +316,17 @@ impl Game {
canvas: &mut Canvas<T>, canvas: &mut Canvas<T>,
backbuffer: &Texture, backbuffer: &Texture,
cursor_pos: glam::Vec2, cursor_pos: glam::Vec2,
) -> Result<()> { ) -> GameResult<()> {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?; canvas
.copy(backbuffer, None, None)
.map_err(|e| GameError::Sdl(e.to_string()))?;
if self.debug_mode { if self.debug_mode {
self.map if let Err(e) = self
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos); .map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos)
{
tracing::error!("Failed to render debug cursor: {}", e);
}
self.render_pathfinding_debug(canvas)?; self.render_pathfinding_debug(canvas)?;
} }
self.draw_hud(canvas)?; self.draw_hud(canvas)?;
@@ -185,11 +338,11 @@ impl Game {
/// ///
/// Each ghost's path is drawn in its respective color with a small offset /// Each ghost's path is drawn in its respective color with a small offset
/// to prevent overlapping lines. /// to prevent overlapping lines.
fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> Result<()> { fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
let pacman_node = self.pacman.current_node_id(); let pacman_node = self.pacman.current_node_id();
for ghost in self.ghosts.iter() { for ghost in self.ghosts.iter() {
if let Some(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) { if let Ok(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) {
if path.len() < 2 { if path.len() < 2 {
continue; // Skip if path is too short continue; // Skip if path is too short
} }
@@ -215,7 +368,11 @@ impl Game {
// Calculate offset positions for all nodes using the same perpendicular direction // Calculate offset positions for all nodes using the same perpendicular direction
let mut offset_positions = Vec::new(); let mut offset_positions = Vec::new();
for &node_id in &path { for &node_id in &path {
let node = self.map.graph.get_node(node_id).unwrap(); let node = self
.map
.graph
.get_node(node_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(node_id)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
offset_positions.push(pos + offset); offset_positions.push(pos + offset);
} }
@@ -231,7 +388,7 @@ impl Game {
// Draw the line // Draw the line
canvas canvas
.draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32)) .draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
.map_err(anyhow::Error::msg)?; .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
} }
} }
@@ -240,7 +397,7 @@ impl Game {
Ok(()) Ok(())
} }
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> { fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
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;
@@ -248,18 +405,22 @@ impl Game {
let lives_offset = 3; let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32); let score_offset = 7 - (score_text.len() as i32);
self.text_texture.set_scale(1.0); self.text_texture.set_scale(1.0);
let _ = self.text_texture.render( if let Err(e) = self.text_texture.render(
canvas, canvas,
&mut self.atlas, &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( tracing::error!("Failed to render HUD text: {}", e);
}
if let Err(e) = self.text_texture.render(
canvas, canvas,
&mut self.atlas, &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),
); ) {
tracing::error!("Failed to render score text: {}", e);
}
// Display FPS information in top-left corner // Display FPS information in top-left corner
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); // let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);

View File

@@ -5,6 +5,7 @@ pub mod asset;
pub mod audio; pub mod audio;
pub mod constants; pub mod constants;
pub mod entity; pub mod entity;
pub mod error;
pub mod game; pub mod game;
pub mod helpers; pub mod helpers;
pub mod map; pub mod map;

View File

@@ -11,6 +11,7 @@ mod audio;
mod constants; mod constants;
mod entity; mod entity;
mod error;
mod game; mod game;
mod helpers; mod helpers;
mod map; mod map;

View File

@@ -1,16 +1,19 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::entity::item::{Item, ItemType};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, Sprite, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2}; use glam::{IVec2, UVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
use tracing::debug; use tracing::debug;
use crate::error::{GameResult, MapError};
/// The starting positions of the entities in the game. /// The starting positions of the entities in the game.
#[allow(dead_code)] #[allow(dead_code)]
pub struct NodePositions { pub struct NodePositions {
@@ -47,8 +50,8 @@ impl Map {
/// ///
/// This function will panic if the board layout contains unknown characters or if /// This function will panic if the board layout contains unknown characters or if
/// the house door is not defined by exactly two '=' characters. /// the house door is not defined by exactly two '=' characters.
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]) -> GameResult<Map> {
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout"); let parsed_map = MapTileParser::parse_board(raw_board)?;
let map = parsed_map.tiles; let map = parsed_map.tiles;
let house_door = parsed_map.house_door; let house_door = parsed_map.house_door;
@@ -61,7 +64,8 @@ impl Map {
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0); let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// Find a starting point for the graph generation, preferably Pac-Man's position. // Find a starting point for the graph generation, preferably Pac-Man's position.
let start_pos = pacman_start.expect("Pac-Man's starting position not found"); let start_pos =
pacman_start.ok_or_else(|| MapError::InvalidConfig("Pac-Man's starting position not found".to_string()))?;
// Add the starting position to the graph/queue // Add the starting position to the graph/queue
let mut queue = VecDeque::new(); let mut queue = VecDeque::new();
@@ -114,7 +118,7 @@ impl Map {
// Connect the new node to the source node // Connect the new node to the source node
graph graph
.connect(*source_node_id, new_node_id, false, None, dir) .connect(*source_node_id, new_node_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
@@ -129,7 +133,7 @@ impl Map {
if let Some(&neighbor_id) = grid_to_node.get(&neighbor) { if let Some(&neighbor_id) = grid_to_node.get(&neighbor) {
graph graph
.connect(node_id, neighbor_id, false, None, dir) .connect(node_id, neighbor_id, false, None, dir)
.expect("Failed to add edge"); .map_err(|e| MapError::InvalidConfig(format!("Failed to add edge: {e}")))?;
} }
} }
} }
@@ -137,7 +141,7 @@ impl Map {
// Build house structure // Build house structure
let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) = let (house_entrance_node_id, left_center_node_id, center_center_node_id, right_center_node_id) =
Self::build_house(&mut graph, &grid_to_node, &house_door); Self::build_house(&mut graph, &grid_to_node, &house_door)?;
let start_positions = NodePositions { let start_positions = NodePositions {
pacman: grid_to_node[&start_pos], pacman: grid_to_node[&start_pos],
@@ -148,15 +152,15 @@ impl Map {
}; };
// Build tunnel connections // Build tunnel connections
Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends); Self::build_tunnels(&mut graph, &grid_to_node, &tunnel_ends)?;
Map { Ok(Map {
current: map, current: map,
graph, graph,
grid_to_node, grid_to_node,
start_positions, start_positions,
pacman_start, pacman_start,
} })
} }
/// Finds the starting position for a given entity ID. /// Finds the starting position for a given entity ID.
@@ -184,6 +188,44 @@ impl Map {
MapRenderer::render_map(canvas, atlas, map_texture); MapRenderer::render_map(canvas, atlas, map_texture);
} }
/// Generates Item entities for pellets and energizers from the parsed map.
pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// Pre-load sprites to avoid repeated texture lookups
let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
.ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
.ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?;
// Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
let mut items = Vec::with_capacity(250);
// Parse the raw board once
let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
let map = parsed_map.tiles;
// Iterate through the map and collect items more efficiently
for (x, row) in map.iter().enumerate() {
for (y, tile) in row.iter().enumerate() {
match tile {
MapTile::Pellet | MapTile::PowerPellet => {
let grid_pos = IVec2::new(x as i32, y as i32);
if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
let (item_type, sprite) = match tile {
MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
_ => unreachable!(), // We already filtered for these types
};
items.push(Item::new(node_id, item_type, sprite));
}
}
_ => {}
}
}
}
Ok(items)
}
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
/// ///
/// This function provides interactive debugging by highlighting the nearest node /// This function provides interactive debugging by highlighting the nearest node
@@ -194,8 +236,8 @@ impl Map {
text_renderer: &mut crate::texture::text::TextTexture, text_renderer: &mut crate::texture::text::TextTexture,
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
cursor_pos: glam::Vec2, cursor_pos: glam::Vec2,
) { ) -> GameResult<()> {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos); MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos)
} }
/// Builds the house structure in the graph. /// Builds the house structure in the graph.
@@ -203,21 +245,32 @@ impl Map {
graph: &mut Graph, graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>, grid_to_node: &HashMap<IVec2, NodeId>,
house_door: &[Option<IVec2>; 2], house_door: &[Option<IVec2>; 2],
) -> (usize, usize, usize, usize) { ) -> GameResult<(usize, usize, usize, usize)> {
// Calculate the position of the house entrance node // Calculate the position of the house entrance node
let (house_entrance_node_id, house_entrance_node_position) = { let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids // Translate the grid positions to the actual node ids
let left_node = grid_to_node let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2())) .get(
.expect("Left house door node not found"); &(house_door[0]
.ok_or_else(|| MapError::InvalidConfig("First house door position not acquired".to_string()))?
+ Direction::Left.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Left house door node not found".to_string()))?;
let right_node = grid_to_node let right_node = grid_to_node
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2())) .get(
.expect("Right house door node not found"); &(house_door[1]
.ok_or_else(|| MapError::InvalidConfig("Second house door position not acquired".to_string()))?
+ Direction::Right.as_ivec2()),
)
.ok_or_else(|| MapError::InvalidConfig("Right house door node not found".to_string()))?;
// Calculate the position of the house node // Calculate the position of the house node
let (node_id, node_position) = { let (node_id, node_position) = {
let left_pos = graph.get_node(*left_node).unwrap().position; let left_pos = graph.get_node(*left_node).ok_or(MapError::NodeNotFound(*left_node))?.position;
let right_pos = graph.get_node(*right_node).unwrap().position; let right_pos = graph
.get_node(*right_node)
.ok_or(MapError::NodeNotFound(*right_node))?
.position;
let house_node = graph.add_node(Node { let house_node = graph.add_node(Node {
position: left_pos.lerp(right_pos, 0.5), position: left_pos.lerp(right_pos, 0.5),
}); });
@@ -227,16 +280,16 @@ impl Map {
// Connect the house door to the left and right nodes // Connect the house door to the left and right nodes
graph graph
.connect(node_id, *left_node, true, None, Direction::Left) .connect(node_id, *left_node, true, None, Direction::Left)
.expect("Failed to connect house door to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to left node: {e}")))?;
graph graph
.connect(node_id, *right_node, true, None, Direction::Right) .connect(node_id, *right_node, true, None, Direction::Right)
.expect("Failed to connect house door to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house door to right node: {e}")))?;
(node_id, node_position) (node_id, node_position)
}; };
// A helper function to help create the various 'lines' of nodes within the house // A helper function to help create the various 'lines' of nodes within the house
let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> (NodeId, NodeId) { let create_house_line = |graph: &mut Graph, center_pos: Vec2| -> GameResult<(NodeId, NodeId)> {
// Place the nodes at, above, and below the center position // Place the nodes at, above, and below the center position
let center_node_id = graph.add_node(Node { position: center_pos }); let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node { let top_node_id = graph.add_node(Node {
@@ -249,12 +302,12 @@ impl Map {
// Connect the center node to the top and bottom nodes // Connect the center node to the top and bottom nodes
graph graph
.connect(center_node_id, top_node_id, false, None, Direction::Up) .connect(center_node_id, top_node_id, false, None, Direction::Up)
.expect("Failed to connect house line to left node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to top node: {e}")))?;
graph graph
.connect(center_node_id, bottom_node_id, false, None, Direction::Down) .connect(center_node_id, bottom_node_id, false, None, Direction::Down)
.expect("Failed to connect house line to right node"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house line to bottom node: {e}")))?;
(center_node_id, top_node_id) Ok((center_node_id, top_node_id))
}; };
// Calculate the position of the center line's center node // Calculate the position of the center line's center node
@@ -262,7 +315,7 @@ impl Map {
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2(); house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// Create the center line // Create the center line
let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position); let (center_center_node_id, center_top_node_id) = create_house_line(graph, center_line_center_position)?;
// Create a ghost-only, two-way connection for the house door. // Create a ghost-only, two-way connection for the house door.
// This prevents Pac-Man from entering or exiting through the door. // This prevents Pac-Man from entering or exiting through the door.
@@ -275,7 +328,7 @@ impl Map {
Direction::Down, Direction::Down,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only entrance to house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?;
graph graph
.add_edge( .add_edge(
@@ -286,49 +339,54 @@ impl Map {
Direction::Up, Direction::Up,
EdgePermissions::GhostsOnly, EdgePermissions::GhostsOnly,
) )
.expect("Failed to create ghost-only exit from house"); .map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?;
// Create the left line // Create the left line
let (left_center_node_id, _) = create_house_line( let (left_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
// Create the right line // Create the right line
let (right_center_node_id, _) = create_house_line( let (right_center_node_id, _) = create_house_line(
graph, graph,
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
); )?;
debug!("Left center node id: {left_center_node_id}"); debug!("Left center node id: {left_center_node_id}");
// Connect the center line to the left and right lines // Connect the center line to the left and right lines
graph graph
.connect(center_center_node_id, left_center_node_id, false, None, Direction::Left) .connect(center_center_node_id, left_center_node_id, false, None, Direction::Left)
.expect("Failed to connect house entrance to left top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to left top line: {e}")))?;
graph graph
.connect(center_center_node_id, right_center_node_id, false, None, Direction::Right) .connect(center_center_node_id, right_center_node_id, false, None, Direction::Right)
.expect("Failed to connect house entrance to right top line"); .map_err(|e| MapError::InvalidConfig(format!("Failed to connect house entrance to right top line: {e}")))?;
debug!("House entrance node id: {house_entrance_node_id}"); debug!("House entrance node id: {house_entrance_node_id}");
( Ok((
house_entrance_node_id, house_entrance_node_id,
left_center_node_id, left_center_node_id,
center_center_node_id, center_center_node_id,
right_center_node_id, right_center_node_id,
) ))
} }
/// Builds the tunnel connections in the graph. /// Builds the tunnel connections in the graph.
fn build_tunnels(graph: &mut Graph, grid_to_node: &HashMap<IVec2, NodeId>, tunnel_ends: &[Option<IVec2>; 2]) { fn build_tunnels(
graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>,
tunnel_ends: &[Option<IVec2>; 2],
) -> GameResult<()> {
// Create the hidden tunnel nodes // Create the hidden tunnel nodes
let left_tunnel_hidden_node_id = { let left_tunnel_hidden_node_id = {
let left_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[0].expect("Left tunnel end not found")]; let left_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[0].ok_or_else(|| MapError::InvalidConfig("Left tunnel end not found".to_string()))?];
let left_tunnel_entrance_node = graph let left_tunnel_entrance_node = graph
.get_node(left_tunnel_entrance_node_id) .get_node(left_tunnel_entrance_node_id)
.expect("Left tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Left tunnel entrance node not found".to_string()))?;
graph graph
.add_connected( .add_connected(
@@ -339,15 +397,21 @@ impl Map {
+ (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect left tunnel entrance to left tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel entrance to left tunnel hidden node: {}",
e
))
})?
}; };
// Create the right tunnel nodes // Create the right tunnel nodes
let right_tunnel_hidden_node_id = { let right_tunnel_hidden_node_id = {
let right_tunnel_entrance_node_id = grid_to_node[&tunnel_ends[1].expect("Right tunnel end not found")]; let right_tunnel_entrance_node_id =
grid_to_node[&tunnel_ends[1].ok_or_else(|| MapError::InvalidConfig("Right tunnel end not found".to_string()))?];
let right_tunnel_entrance_node = graph let right_tunnel_entrance_node = graph
.get_node(right_tunnel_entrance_node_id) .get_node(right_tunnel_entrance_node_id)
.expect("Right tunnel entrance node not found"); .ok_or_else(|| MapError::InvalidConfig("Right tunnel entrance node not found".to_string()))?;
graph graph
.add_connected( .add_connected(
@@ -358,7 +422,12 @@ impl Map {
+ (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(), + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
}, },
) )
.expect("Failed to connect right tunnel entrance to right tunnel hidden node") .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect right tunnel entrance to right tunnel hidden node: {}",
e
))
})?
}; };
// Connect the left tunnel hidden node to the right tunnel hidden node // Connect the left tunnel hidden node to the right tunnel hidden node
@@ -370,6 +439,13 @@ impl Map {
Some(0.0), Some(0.0),
Direction::Left, Direction::Left,
) )
.expect("Failed to connect left tunnel hidden node to right tunnel hidden node"); .map_err(|e| {
MapError::InvalidConfig(format!(
"Failed to connect left tunnel hidden node to right tunnel hidden node: {}",
e
))
})?;
Ok(())
} }
} }

View File

@@ -11,6 +11,8 @@ pub enum ParseError {
UnknownCharacter(char), UnknownCharacter(char),
#[error("House door must have exactly 2 positions, found {0}")] #[error("House door must have exactly 2 positions, found {0}")]
InvalidHouseDoorCount(usize), InvalidHouseDoorCount(usize),
#[error("Map parsing failed: {0}")]
ParseFailed(String),
} }
/// Represents the parsed data from a raw board layout. /// Represents the parsed data from a raw board layout.
@@ -67,6 +69,25 @@ impl MapTileParser {
/// Returns an error if the board contains unknown characters or if the house door /// Returns an error if the board contains unknown characters or if the house door
/// is not properly defined by exactly two '=' characters. /// is not properly defined by exactly two '=' characters.
pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> { pub fn parse_board(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Result<ParsedMap, ParseError> {
// Validate board dimensions
if raw_board.len() != BOARD_CELL_SIZE.y as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board height: expected {}, got {}",
BOARD_CELL_SIZE.y,
raw_board.len()
)));
}
for (i, line) in raw_board.iter().enumerate() {
if line.len() != BOARD_CELL_SIZE.x as usize {
return Err(ParseError::ParseFailed(format!(
"Invalid board width at line {}: expected {}, got {}",
i,
BOARD_CELL_SIZE.x,
line.len()
)));
}
}
let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize]; let mut tiles = [[MapTile::Empty; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize];
let mut house_door = [None; 2]; let mut house_door = [None; 2];
let mut tunnel_ends = [None; 2]; let mut tunnel_ends = [None; 2];

View File

@@ -7,6 +7,8 @@ use sdl2::pixels::Color;
use sdl2::rect::{Point, Rect}; use sdl2::rect::{Point, Rect};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult};
/// Handles rendering operations for the map. /// Handles rendering operations for the map.
pub struct MapRenderer; pub struct MapRenderer;
@@ -22,7 +24,9 @@ impl MapRenderer {
crate::constants::BOARD_PIXEL_SIZE.x, crate::constants::BOARD_PIXEL_SIZE.x,
crate::constants::BOARD_PIXEL_SIZE.y, crate::constants::BOARD_PIXEL_SIZE.y,
); );
let _ = map_texture.render(canvas, atlas, dest); if let Err(e) = map_texture.render(canvas, atlas, dest) {
tracing::error!("Failed to render map: {}", e);
}
} }
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
@@ -35,55 +39,67 @@ impl MapRenderer {
text_renderer: &mut TextTexture, text_renderer: &mut TextTexture,
atlas: &mut SpriteAtlas, atlas: &mut SpriteAtlas,
cursor_pos: Vec2, cursor_pos: Vec2,
) { ) -> GameResult<()> {
// Find the nearest node to the cursor // Find the nearest node to the cursor
let nearest_node = Self::find_nearest_node(graph, cursor_pos); let nearest_node = Self::find_nearest_node(graph, cursor_pos);
// Draw all connections in blue // Draw all connections in blue
canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
for edge in graph.adjacency_list[i].edges() { for edge in graph.adjacency_list[i].edges() {
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32)) .draw_line((pos.x as i32, pos.y as i32), (end_pos.x as i32, end_pos.y as i32))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
} }
// Draw all nodes in green // Draw all nodes in green
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); let node = graph.get_node(i).ok_or(GameError::Entity(EntityError::NodeNotFound(i)))?;
let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32))) .fill_rect(Rect::new(0, 0, 3, 3).centered_on(Point::new(pos.x as i32, pos.y as i32)))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
// Highlight connections from the nearest node in bright blue // Highlight connections from the nearest node in bright blue
if let Some(nearest_id) = nearest_node { if let Some(nearest_id) = nearest_node {
let nearest_pos = graph.get_node(nearest_id).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let nearest_pos = graph
.get_node(nearest_id)
.ok_or(GameError::Entity(EntityError::NodeNotFound(nearest_id)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections canvas.set_draw_color(Color::RGB(0, 255, 255)); // Bright cyan for highlighted connections
for edge in graph.adjacency_list[nearest_id].edges() { for edge in graph.adjacency_list[nearest_id].edges() {
let end_pos = graph.get_node(edge.target).unwrap().position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let end_pos = graph
.get_node(edge.target)
.ok_or(GameError::Entity(EntityError::NodeNotFound(edge.target)))?
.position
+ crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
canvas canvas
.draw_line( .draw_line(
(nearest_pos.x as i32, nearest_pos.y as i32), (nearest_pos.x as i32, nearest_pos.y as i32),
(end_pos.x as i32, end_pos.y as i32), (end_pos.x as i32, end_pos.y as i32),
) )
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
} }
// Highlight the nearest node in bright green // Highlight the nearest node in bright green
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
canvas canvas
.fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32))) .fill_rect(Rect::new(0, 0, 5, 5).centered_on(Point::new(nearest_pos.x as i32, nearest_pos.y as i32)))
.unwrap(); .map_err(|e| GameError::Sdl(e.to_string()))?;
// Draw node ID text (small, offset to top right) // Draw node ID text (small, offset to top right)
text_renderer.set_scale(0.5); // Small text text_renderer.set_scale(0.5); // Small text
@@ -92,8 +108,12 @@ impl MapRenderer {
(nearest_pos.x + 4.0) as u32, // Offset to the right (nearest_pos.x + 4.0) as u32, // Offset to the right
(nearest_pos.y - 6.0) as u32, // Offset to the top (nearest_pos.y - 6.0) as u32, // Offset to the top
); );
let _ = text_renderer.render(canvas, atlas, &id_text, text_pos); if let Err(e) = text_renderer.render(canvas, atlas, &id_text, text_pos) {
tracing::error!("Failed to render node ID text: {}", e);
}
} }
Ok(())
} }
/// Finds the nearest node to the given cursor position. /// Finds the nearest node to the given cursor position.
@@ -102,13 +122,14 @@ impl MapRenderer {
let mut nearest_distance = f32::INFINITY; let mut nearest_distance = f32::INFINITY;
for i in 0..graph.node_count() { for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap(); if let Some(node) = graph.get_node(i) {
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2(); let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
let distance = cursor_pos.distance(node_pos); let distance = cursor_pos.distance(node_pos);
if distance < nearest_distance { if distance < nearest_distance {
nearest_distance = distance; nearest_distance = distance;
nearest_id = Some(i); nearest_id = Some(i);
}
} }
} }

View File

@@ -6,6 +6,27 @@ use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
/// A simple sprite for stationary items like pellets and energizers.
#[derive(Clone, Debug)]
pub struct Sprite {
pub atlas_tile: AtlasTile,
}
impl Sprite {
pub fn new(atlas_tile: AtlasTile) -> Self {
Self { atlas_tile }
}
pub fn render<C: RenderTarget>(&self, canvas: &mut Canvas<C>, atlas: &mut SpriteAtlas, position: glam::Vec2) -> Result<()> {
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(position.x as i32, position.y as i32),
glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32),
);
let mut tile = self.atlas_tile;
tile.render(canvas, atlas, dest)
}
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct AtlasMapper { pub struct AtlasMapper {
pub frames: HashMap<String, MapperFrame>, pub frames: HashMap<String, MapperFrame>,

View File

@@ -16,16 +16,16 @@ fn test_blinking_texture() {
let tile = mock_atlas_tile(1); let tile = mock_atlas_tile(1);
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
texture.tick(0.5); texture.tick(0.5);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
} }
#[test] #[test]
@@ -34,7 +34,7 @@ fn test_blinking_texture_partial_duration() {
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(0.625); texture.tick(0.625);
assert_eq!(texture.is_on(), false); assert!(!texture.is_on());
assert_eq!(texture.time_bank(), 0.125); assert_eq!(texture.time_bank(), 0.125);
} }
@@ -44,6 +44,6 @@ fn test_blinking_texture_negative_time() {
let mut texture = BlinkingTexture::new(tile, 0.5); let mut texture = BlinkingTexture::new(tile, 0.5);
texture.tick(-0.1); texture.tick(-0.1);
assert_eq!(texture.is_on(), true); assert!(texture.is_on());
assert_eq!(texture.time_bank(), -0.1); assert_eq!(texture.time_bank(), -0.1);
} }

119
tests/collision.rs Normal file
View File

@@ -0,0 +1,119 @@
use pacman::entity::collision::{Collidable, CollisionSystem};
use pacman::entity::traversal::Position;
struct MockCollidable {
pos: Position,
}
impl Collidable for MockCollidable {
fn position(&self) -> Position {
self.pos
}
}
#[test]
fn test_is_colliding_with() {
let entity1 = MockCollidable {
pos: Position::AtNode(1),
};
let entity2 = MockCollidable {
pos: Position::AtNode(1),
};
let entity3 = MockCollidable {
pos: Position::AtNode(2),
};
let entity4 = MockCollidable {
pos: Position::BetweenNodes {
from: 1,
to: 2,
traversed: 0.5,
},
};
assert!(entity1.is_colliding_with(&entity2));
assert!(!entity1.is_colliding_with(&entity3));
assert!(entity1.is_colliding_with(&entity4));
assert!(entity3.is_colliding_with(&entity4));
}
#[test]
fn test_collision_system_register_and_query() {
let mut collision_system = CollisionSystem::default();
let pos1 = Position::AtNode(1);
let entity1 = collision_system.register_entity(pos1);
let pos2 = Position::BetweenNodes {
from: 1,
to: 2,
traversed: 0.5,
};
let entity2 = collision_system.register_entity(pos2);
let pos3 = Position::AtNode(3);
let entity3 = collision_system.register_entity(pos3);
// Test entities_at_node
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
assert_eq!(collision_system.entities_at_node(2), &[entity2]);
assert_eq!(collision_system.entities_at_node(3), &[entity3]);
assert_eq!(collision_system.entities_at_node(4), &[] as &[u32]);
// Test potential_collisions
let mut collisions1 = collision_system.potential_collisions(&pos1);
collisions1.sort_unstable();
assert_eq!(collisions1, vec![entity1, entity2]);
let mut collisions2 = collision_system.potential_collisions(&pos2);
collisions2.sort_unstable();
assert_eq!(collisions2, vec![entity1, entity2]);
let mut collisions3 = collision_system.potential_collisions(&pos3);
collisions3.sort_unstable();
assert_eq!(collisions3, vec![entity3]);
}
#[test]
fn test_collision_system_update() {
let mut collision_system = CollisionSystem::default();
let entity1 = collision_system.register_entity(Position::AtNode(1));
assert_eq!(collision_system.entities_at_node(1), &[entity1]);
assert_eq!(collision_system.entities_at_node(2), &[] as &[u32]);
collision_system.update_position(entity1, Position::AtNode(2));
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
collision_system.update_position(
entity1,
Position::BetweenNodes {
from: 2,
to: 3,
traversed: 0.1,
},
);
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
assert_eq!(collision_system.entities_at_node(3), &[entity1]);
}
#[test]
fn test_collision_system_remove() {
let mut collision_system = CollisionSystem::default();
let entity1 = collision_system.register_entity(Position::AtNode(1));
let entity2 = collision_system.register_entity(Position::AtNode(1));
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
collision_system.remove_entity(entity1);
assert_eq!(collision_system.entities_at_node(1), &[entity2]);
collision_system.remove_entity(entity2);
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
}

View File

@@ -52,3 +52,26 @@ fn test_directional_texture_all_directions() {
assert!(texture.has_direction(*direction)); assert!(texture.has_direction(*direction));
} }
} }
#[test]
fn test_directional_texture_stopped() {
let mut stopped_textures = [None, None, None, None];
stopped_textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
let texture = DirectionalAnimatedTexture::new([None, None, None, None], stopped_textures);
assert_eq!(texture.stopped_texture_count(), 1);
assert!(texture.has_stopped_direction(Direction::Up));
assert!(!texture.has_stopped_direction(Direction::Down));
}
#[test]
fn test_directional_texture_tick() {
let mut textures = [None, None, None, None];
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
let mut texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
// This is a bit of a placeholder, since we can't inspect the inner state easily.
// We're just ensuring the tick method runs without panicking.
texture.tick(0.1);
}

View File

@@ -1,9 +1,12 @@
use pacman::constants::RAW_BOARD; use pacman::constants::RAW_BOARD;
use pacman::map::Map; use pacman::map::Map;
mod collision;
mod item;
#[test] #[test]
fn test_game_map_creation() { fn test_game_map_creation() {
let map = Map::new(RAW_BOARD); let map = Map::new(RAW_BOARD).unwrap();
assert!(map.graph.node_count() > 0); assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty()); assert!(!map.grid_to_node.is_empty());
@@ -16,6 +19,6 @@ fn test_game_map_creation() {
#[test] #[test]
fn test_game_score_initialization() { fn test_game_score_initialization() {
// This would require creating a full Game instance, but we can test the concept // This would require creating a full Game instance, but we can test the concept
let map = Map::new(RAW_BOARD); let map = Map::new(RAW_BOARD).unwrap();
assert!(map.find_starting_position(0).is_some()); assert!(map.find_starting_position(0).is_some());
} }

View File

@@ -41,7 +41,7 @@ fn test_ghost_creation() {
let graph = Graph::new(); let graph = Graph::new();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap();
assert_eq!(ghost.ghost_type, GhostType::Blinky); assert_eq!(ghost.ghost_type, GhostType::Blinky);
assert_eq!(ghost.traverser.position.from_node_id(), 0); assert_eq!(ghost.traverser.position.from_node_id(), 0);

View File

@@ -101,7 +101,7 @@ fn test_traverser_advance() {
let graph = create_test_graph(); let graph = create_test_graph();
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true); let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
traverser.advance(&graph, 5.0, &|_| true); traverser.advance(&graph, 5.0, &|_| true).unwrap();
match traverser.position { match traverser.position {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
@@ -112,7 +112,7 @@ fn test_traverser_advance() {
_ => panic!("Expected to be between nodes"), _ => panic!("Expected to be between nodes"),
} }
traverser.advance(&graph, 3.0, &|_| true); traverser.advance(&graph, 3.0, &|_| true).unwrap();
match traverser.position { match traverser.position {
Position::BetweenNodes { from, to, traversed } => { Position::BetweenNodes { from, to, traversed } => {
@@ -143,7 +143,9 @@ fn test_traverser_with_permissions() {
matches!(edge.permissions, EdgePermissions::All) matches!(edge.permissions, EdgePermissions::All)
}); });
traverser.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All)); traverser
.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All))
.unwrap();
// Should still be at the node since it can't traverse // Should still be at the node since it can't traverse
assert!(traverser.position.is_at_node()); assert!(traverser.position.is_at_node());

53
tests/item.rs Normal file
View File

@@ -0,0 +1,53 @@
use glam::U16Vec2;
use pacman::{
entity::{
collision::Collidable,
item::{FruitKind, Item, ItemType},
},
texture::sprite::{AtlasTile, Sprite},
};
use strum::{EnumCount, IntoEnumIterator};
#[test]
fn test_item_type_get_score() {
assert_eq!(ItemType::Pellet.get_score(), 10);
assert_eq!(ItemType::Energizer.get_score(), 50);
let fruit = ItemType::Fruit { kind: FruitKind::Apple };
assert_eq!(fruit.get_score(), 100);
}
#[test]
fn test_fruit_kind_increasing_score() {
// Build a list of fruit kinds, sorted by their index
let mut kinds = FruitKind::iter()
.map(|kind| (kind.index(), kind.get_score()))
.collect::<Vec<_>>();
kinds.sort_unstable_by_key(|(index, _)| *index);
assert_eq!(kinds.len(), FruitKind::COUNT as usize);
// Check that the score increases as expected
for window in kinds.windows(2) {
let ((_, prev), (_, next)) = (window[0], window[1]);
assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}");
}
}
#[test]
fn test_item_creation_and_collection() {
let atlas_tile = AtlasTile {
pos: U16Vec2::new(0, 0),
size: U16Vec2::new(16, 16),
color: None,
};
let sprite = Sprite::new(atlas_tile);
let mut item = Item::new(0, ItemType::Pellet, sprite);
assert!(!item.is_collected());
assert_eq!(item.get_score(), 10);
assert_eq!(item.position().from_node_id(), 0);
item.collect();
assert!(item.is_collected());
}

View File

@@ -1,47 +1,11 @@
use glam::Vec2; use glam::Vec2;
use pacman::constants::{BOARD_CELL_SIZE, CELL_SIZE}; use pacman::constants::{BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use pacman::map::Map; use pacman::map::Map;
use sdl2::render::Texture;
fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
let mut board = [""; BOARD_CELL_SIZE.y as usize];
board[0] = "############################";
board[1] = "#............##............#";
board[2] = "#.####.#####.##.#####.####.#";
board[3] = "#o####.#####.##.#####.####o#";
board[4] = "#.####.#####.##.#####.####.#";
board[5] = "#..........................#";
board[6] = "#.####.##.########.##.####.#";
board[7] = "#.####.##.########.##.####.#";
board[8] = "#......##....##....##......#";
board[9] = "######.##### ## #####.######";
board[10] = " #.##### ## #####.# ";
board[11] = " #.## == ##.# ";
board[12] = " #.## ######## ##.# ";
board[13] = "######.## ######## ##.######";
board[14] = "T . ######## . T";
board[15] = "######.## ######## ##.######";
board[16] = " #.## ######## ##.# ";
board[17] = " #.## ##.# ";
board[18] = " #.## ######## ##.# ";
board[19] = "######.## ######## ##.######";
board[20] = "#............##............#";
board[21] = "#.####.#####.##.#####.####.#";
board[22] = "#.####.#####.##.#####.####.#";
board[23] = "#o..##.......X .......##..o#";
board[24] = "###.##.##.########.##.##.###";
board[25] = "###.##.##.########.##.##.###";
board[26] = "#......##....##....##......#";
board[27] = "#.##########.##.##########.#";
board[28] = "#.##########.##.##########.#";
board[29] = "#..........................#";
board[30] = "############################";
board
}
#[test] #[test]
fn test_map_creation() { fn test_map_creation() {
let board = create_minimal_test_board(); let map = Map::new(RAW_BOARD).unwrap();
let map = Map::new(board);
assert!(map.graph.node_count() > 0); assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty()); assert!(!map.grid_to_node.is_empty());
@@ -59,8 +23,7 @@ fn test_map_creation() {
#[test] #[test]
fn test_map_starting_positions() { fn test_map_starting_positions() {
let board = create_minimal_test_board(); let map = Map::new(RAW_BOARD).unwrap();
let map = Map::new(board);
let pacman_pos = map.find_starting_position(0); let pacman_pos = map.find_starting_position(0);
assert!(pacman_pos.is_some()); assert!(pacman_pos.is_some());
@@ -73,8 +36,7 @@ fn test_map_starting_positions() {
#[test] #[test]
fn test_map_node_positions() { fn test_map_node_positions() {
let board = create_minimal_test_board(); let map = Map::new(RAW_BOARD).unwrap();
let map = Map::new(board);
for (grid_pos, &node_id) in &map.grid_to_node { for (grid_pos, &node_id) in &map.grid_to_node {
let node = map.graph.get_node(node_id).unwrap(); let node = map.graph.get_node(node_id).unwrap();
@@ -84,3 +46,61 @@ fn test_map_node_positions() {
assert_eq!(node.position, expected_pos); assert_eq!(node.position, expected_pos);
} }
} }
#[test]
fn test_generate_items() {
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
let map = Map::new(RAW_BOARD).unwrap();
// Create a minimal atlas for testing
let mut frames = HashMap::new();
frames.insert(
"maze/pellet.png".to_string(),
MapperFrame {
x: 0,
y: 0,
width: 8,
height: 8,
},
);
frames.insert(
"maze/energizer.png".to_string(),
MapperFrame {
x: 8,
y: 0,
width: 8,
height: 8,
},
);
let mapper = AtlasMapper { frames };
let texture = unsafe { std::mem::transmute::<usize, Texture<'static>>(0usize) };
let atlas = SpriteAtlas::new(texture, mapper);
let items = map.generate_items(&atlas).unwrap();
// Verify we have items
assert!(!items.is_empty());
// Count different types
let pellet_count = items
.iter()
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet))
.count();
let energizer_count = items
.iter()
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer))
.count();
// Should have both types
assert_eq!(pellet_count, 240);
assert_eq!(energizer_count, 4);
// All items should be uncollected initially
assert!(items.iter().all(|item| !item.is_collected()));
// All items should have valid node indices
assert!(items.iter().all(|item| item.node_index < map.graph.node_count()));
}

View File

@@ -67,7 +67,7 @@ fn create_test_atlas() -> SpriteAtlas {
fn test_pacman_creation() { fn test_pacman_creation() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let pacman = Pacman::new(&graph, 0, &atlas); let pacman = Pacman::new(&graph, 0, &atlas).unwrap();
assert!(pacman.traverser.position.is_at_node()); assert!(pacman.traverser.position.is_at_node());
assert_eq!(pacman.traverser.direction, Direction::Left); assert_eq!(pacman.traverser.direction, Direction::Left);
@@ -77,7 +77,7 @@ fn test_pacman_creation() {
fn test_pacman_key_handling() { fn test_pacman_key_handling() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas); let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let test_cases = [ let test_cases = [
(Keycode::Up, Direction::Up), (Keycode::Up, Direction::Up),
@@ -96,7 +96,7 @@ fn test_pacman_key_handling() {
fn test_pacman_invalid_key() { fn test_pacman_invalid_key() {
let graph = create_test_graph(); let graph = create_test_graph();
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas); let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let original_direction = pacman.traverser.direction; let original_direction = pacman.traverser.direction;
let original_next_direction = pacman.traverser.next_direction; let original_next_direction = pacman.traverser.next_direction;

View File

@@ -37,10 +37,10 @@ fn test_parse_board() {
#[test] #[test]
fn test_parse_board_invalid_character() { fn test_parse_board_invalid_character() {
let mut invalid_board = RAW_BOARD.clone(); let mut invalid_board = RAW_BOARD.map(|s| s.to_string());
invalid_board[0] = "###########################Z"; invalid_board[0] = "###########################Z".to_string();
let result = MapTileParser::parse_board(invalid_board); let result = MapTileParser::parse_board(invalid_board.each_ref().map(|s| s.as_str()));
assert!(result.is_err()); assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z'))); assert!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
} }

View File

@@ -61,14 +61,17 @@ fn test_ghost_pathfinding() {
let atlas = create_test_atlas(); let atlas = create_test_atlas();
// Create a ghost at node 0 // Create a ghost at node 0
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding from node 0 to node 2 // Test pathfinding from node 0 to node 2
let path = ghost.calculate_path_to_target(&graph, node2); let path = ghost.calculate_path_to_target(&graph, node2);
assert!(path.is_some()); assert!(path.is_ok());
let path = path.unwrap(); let path = path.unwrap();
assert_eq!(path, vec![node0, node1, node2]); assert!(
path == vec![node0, node1, node2] || path == vec![node2, node1, node0],
"Path was not what was expected"
);
} }
#[test] #[test]
@@ -85,12 +88,12 @@ fn test_ghost_pathfinding_no_path() {
// Don't connect the nodes // Don't connect the nodes
let atlas = create_test_atlas(); let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas); let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding when no path exists // Test pathfinding when no path exists
let path = ghost.calculate_path_to_target(&graph, node1); let path = ghost.calculate_path_to_target(&graph, node1);
assert!(path.is_none()); assert!(path.is_err());
} }
#[test] #[test]
@@ -101,10 +104,10 @@ fn test_ghost_debug_colors() {
position: glam::Vec2::new(0.0, 0.0), position: glam::Vec2::new(0.0, 0.0),
}); });
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas); let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap();
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas); let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap();
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas); let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap();
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas); let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap();
// Test that each ghost has a different debug color // Test that each ghost has a different debug color
let colors = std::collections::HashSet::from([ let colors = std::collections::HashSet::from([

View File

@@ -1,4 +1,5 @@
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; use glam::U16Vec2;
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, Sprite, SpriteAtlas};
use sdl2::pixels::Color; use sdl2::pixels::Color;
use std::collections::HashMap; use std::collections::HashMap;
@@ -76,3 +77,27 @@ fn test_sprite_atlas_color() {
atlas.set_color(color); atlas.set_color(color);
assert_eq!(atlas.default_color(), Some(color)); assert_eq!(atlas.default_color(), Some(color));
} }
#[test]
fn test_atlas_tile_new_and_with_color() {
let pos = U16Vec2::new(10, 20);
let size = U16Vec2::new(30, 40);
let color = Color::RGB(100, 150, 200);
let tile = AtlasTile::new(pos, size, None);
assert_eq!(tile.pos, pos);
assert_eq!(tile.size, size);
assert_eq!(tile.color, None);
let tile_with_color = tile.with_color(color);
assert_eq!(tile_with_color.color, Some(color));
}
#[test]
fn test_sprite_new() {
let atlas_tile = AtlasTile::new(U16Vec2::new(0, 0), U16Vec2::new(16, 16), None);
let sprite = Sprite::new(atlas_tile);
assert_eq!(sprite.atlas_tile.pos, atlas_tile.pos);
assert_eq!(sprite.atlas_tile.size, atlas_tile.size);
}