Compare commits

...

19 Commits

Author SHA1 Message Date
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
250cf2fc89 fix: avoid rendering path lines between far apart cells 2025-08-11 18:39:01 -05:00
57975495a9 fix: calculate more static, stable offsets for path debug rendering 2025-08-11 16:00:23 -05:00
f3e7a780e2 fix: drop problematic ctrl-c keybind for bacon, reconfigure binds 2025-08-11 15:46:26 -05:00
ee6cb0a670 refactor: implement entity trait, common abstraction for movement & rendering 2025-08-11 15:46:04 -05:00
b3df34b405 fix: crash when entering right tunnel due to overflowing pixel position calculation 2025-08-11 15:44:04 -05:00
dbafa17670 chore: add bacon.toml config file 2025-08-11 15:25:53 -05:00
d9c8f97903 feat: pathfinding for ghosts, add debug rendering of paths 2025-08-11 15:25:39 -05:00
ad2ec35bfb chore: remove unused tracing debug invocations 2025-08-11 15:23:23 -05:00
6331ba0b2f refactor: move graph traversal code into traversal.rs 2025-08-11 14:05:28 -05:00
3d275b8e85 fix: clippy inline format args 2025-08-11 14:05:28 -05:00
bd61db9aae chore: remove unnecessary names, merge audit.yaml with tests.yaml, plural tests.yaml 2025-08-11 14:05:28 -05:00
ed8bd07518 fix: site rendering, fix SVG colors, remove header, viewport scaling, simplify 2025-08-11 12:20:52 -05:00
27705f1ba2 feat: implement ghost entities, movement & rendering 2025-08-11 11:54:05 -05:00
37 changed files with 1892 additions and 450 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

@@ -1,27 +0,0 @@
name: Audit
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
jobs:
audit:
name: Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run security audit
run: cargo audit

View File

@@ -1,5 +1,4 @@
name: Builds
on: ["push", "pull_request"]
permissions:
@@ -15,19 +14,19 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: macos-13
target: x86_64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: pacman
toolchain: 1.88.0
toolchain: 1.86.0
- os: windows-latest
target: x86_64-pc-windows-gnu
artifact_name: pacman.exe
toolchain: 1.88.0
toolchain: 1.86.0
runs-on: ${{ matrix.os }}
steps:
- name: Checkout

View File

@@ -1,4 +1,4 @@
name: Coverage
name: Code Coverage
on: ["push", "pull_request"]
@@ -8,7 +8,6 @@ env:
jobs:
coverage:
name: Code Coverage
runs-on: ubuntu-latest
steps:
- name: Checkout code

View File

@@ -1,14 +1,13 @@
name: Tests
name: Tests & Checks
on: ["push", "pull_request"]
env:
CARGO_TERM_COLOR: always
RUST_TOOLCHAIN: 1.88.0
RUST_TOOLCHAIN: 1.86.0
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
@@ -52,3 +51,8 @@ jobs:
- name: Check formatting
run: cargo fmt -- --check
- uses: taiki-e/install-action@cargo-audit
- name: Run security audit
run: cargo audit

1
Cargo.lock generated
View File

@@ -192,6 +192,7 @@ dependencies = [
"sdl2",
"serde",
"serde_json",
"smallvec",
"spin_sleep",
"thiserror 1.0.69",
"tracing",

View File

@@ -20,6 +20,7 @@ anyhow = "1.0"
glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141"
smallvec = "1.15.1"
[profile.release]
lto = true

View File

@@ -2,14 +2,14 @@
[![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![Code Coverage][badge-coverage]][coverage] [![Online Demo][badge-online-demo]][demo] [![Last Commit][badge-last-commit]][commits]
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml/badge.svg
[badge-test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml/badge.svg
[badge-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg
[badge-coverage]: https://coveralls.io/repos/github/Xevion/Pac-Man/badge.svg?branch=master
[badge-demo]: https://img.shields.io/github/deployments/Xevion/Pac-Man/github-pages?label=GitHub%20Pages
[badge-online-demo]: https://img.shields.io/badge/GitHub%20Pages-Demo-brightgreen
[badge-last-commit]: https://img.shields.io/github/last-commit/Xevion/Pac-Man
[build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/test.yaml
[test]: https://github.com/Xevion/Pac-Man/actions/workflows/tests.yaml
[coverage]: https://coveralls.io/github/Xevion/Pac-Man?branch=master
[demo]: https://xevion.github.io/Pac-Man/
[commits]: https://github.com/Xevion/Pac-Man/commits/master

View File

@@ -30,7 +30,7 @@
width="80"
height="80"
viewBox="0 0 250 250"
class="fill-yellow-400 text-white"
class="fill-yellow-400 [&>.octo-arm,.octo-body]:fill-black"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
@@ -46,16 +46,12 @@
</a>
<div class="min-h-screen flex flex-col">
<header class="pt-10">
<h1 class="text-4xl arcade-title scaled-text">Pac-Man in Rust</h1>
</header>
<main class="flex-1 flex items-center justify-center px-4">
<div class="w-full max-w-5xl">
<canvas
id="canvas"
oncontextmenu="event.preventDefault()"
class="block bg-black w-full max-w-[90vw] h-auto rounded-xl shadow-[inset_0_0_0_2px_rgba(255,255,255,0.12),0_10px_30px_rgba(0,0,0,0.8)]"
class="block w-full h-full max-h-[90vh] aspect-square"
></canvas>
<div

61
bacon.toml Normal file
View File

@@ -0,0 +1,61 @@
# This is a configuration file for the bacon tool
#
# Complete help on configuration: https://dystroy.org/bacon/config/
#
# You may check the current default at
# https://github.com/Canop/bacon/blob/main/defaults/default-bacon.toml
default_job = "check"
env.CARGO_TERM_COLOR = "always"
[jobs.check]
command = ["cargo", "check"]
need_stdout = false
[jobs.check-all]
command = ["cargo", "check", "--all-targets"]
need_stdout = false
# Run clippy on the default target
[jobs.clippy]
command = ["cargo", "clippy"]
need_stdout = false
# Run clippy on all targets
[jobs.clippy-all]
command = ["cargo", "clippy", "--all-targets"]
need_stdout = false
[jobs.test]
command = [
"cargo", "nextest", "run",
"--hide-progress-bar", "--failure-output", "final"
]
need_stdout = true
analyzer = "nextest"
[jobs.doc]
command = ["cargo", "doc", "--no-deps"]
need_stdout = false
# If the doc compiles, then it opens in your browser and bacon switches to the previous job
[jobs.doc-open]
command = ["cargo", "doc", "--no-deps", "--open"]
need_stdout = false
on_success = "back" # so that we don't open the browser at each change
[jobs.run]
command = [
"cargo", "run",
]
need_stdout = true
allow_warnings = true
background = false
on_change_strategy = "kill_then_restart"
# kill = ["pkill", "-TERM", "-P"]'
[keybindings]
c = "job:clippy"
alt-c = "job:check"
ctrl-alt-c = "job:check-all"
shift-c = "job:clippy-all"

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "1.86.0"

View File

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

256
src/entity/ghost.rs Normal file
View File

@@ -0,0 +1,256 @@
//! Ghost entity implementation.
//!
//! This module contains the ghost character logic, including movement,
//! animation, and rendering. Ghosts move through the game graph using
//! a traverser and display directional animated textures.
use pathfinding::prelude::dijkstra;
use rand::prelude::*;
use smallvec::SmallVec;
use tracing::error;
use crate::entity::{
collision::Collidable,
direction::Direction,
graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use crate::error::{EntityError, GameError, GameResult, TextureError};
/// Determines if a ghost can traverse a given edge.
///
/// Ghosts can move through edges that allow all entities or ghost-only edges.
fn can_ghost_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly)
}
/// The four classic ghost types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhostType {
Blinky,
Pinky,
Inky,
Clyde,
}
impl GhostType {
/// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str {
match self {
GhostType::Blinky => "blinky",
GhostType::Pinky => "pinky",
GhostType::Inky => "inky",
GhostType::Clyde => "clyde",
}
}
/// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 {
match self {
GhostType::Blinky => 1.0,
GhostType::Pinky => 0.95,
GhostType::Inky => 0.9,
GhostType::Clyde => 0.85,
}
}
}
/// A ghost entity that roams the game world.
///
/// Ghosts move through the game world using a graph-based navigation system
/// and display directional animated sprites. They randomly choose directions
/// at each intersection.
pub struct Ghost {
/// Handles movement through the game graph
pub traverser: Traverser,
/// The type of ghost (affects appearance and speed)
pub ghost_type: GhostType,
/// Manages directional animated textures for different movement states
texture: DirectionalAnimatedTexture,
/// Current movement speed
speed: f32,
}
impl Entity for Ghost {
fn traverser(&self) -> &Traverser {
&self.traverser
}
fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
self.speed
}
fn can_traverse(&self, edge: Edge) -> bool {
can_ghost_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
// Choose random direction when at a node
if self.traverser.position.is_at_node() {
self.choose_random_direction(graph);
}
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);
}
}
impl Ghost {
/// Creates a new ghost instance at the specified starting node.
///
/// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through two sprite variants.
pub fn new(graph: &Graph, start_node: NodeId, ghost_type: GhostType, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None];
for direction in Direction::DIRECTIONS {
let moving_prefix = match direction {
Direction::Up => "up",
Direction::Down => "down",
Direction::Left => "left",
Direction::Right => "right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.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 =
vec![
SpriteAtlas::get_tile(atlas, &format!("ghost/{}/{}_{}.png", ghost_type.as_str(), moving_prefix, "a"))
.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).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
stopped_textures[direction.as_usize()] =
Some(AnimatedTexture::new(stopped_tiles, 0.1).map_err(|e| GameError::Texture(TextureError::Animated(e)))?);
}
Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_ghost_traverse),
ghost_type,
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
speed: ghost_type.base_speed(),
})
}
/// Chooses a random available direction at the current intersection.
fn choose_random_direction(&mut self, graph: &Graph) {
let current_node = self.traverser.position.from_node_id();
let intersection = &graph.adjacency_list[current_node];
// Collect all available directions
let mut available_directions = SmallVec::<[_; 4]>::new();
for direction in Direction::DIRECTIONS {
if let Some(edge) = intersection.get(direction) {
if can_ghost_traverse(edge) {
available_directions.push(direction);
}
}
}
// Choose a random direction (avoid reversing unless necessary)
if !available_directions.is_empty() {
let mut rng = SmallRng::from_os_rng();
// Filter out the opposite direction if possible, but allow it if we have limited options
let opposite = self.traverser.direction.opposite();
let filtered_directions: Vec<_> = available_directions
.iter()
.filter(|&&dir| dir != opposite || available_directions.len() <= 2)
.collect();
if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
self.traverser.set_next_direction(*random_direction);
}
}
}
/// 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 an error if pathfinding fails.
/// The path includes the current node and the target node.
pub fn calculate_path_to_target(&self, graph: &Graph, target: NodeId) -> GameResult<Vec<NodeId>> {
let start_node = self.traverser.position.from_node_id();
// Use Dijkstra's algorithm to find the shortest path
let result = dijkstra(
&start_node,
|&node_id| {
// Get all edges from the current node
graph.adjacency_list[node_id]
.edges()
.filter(|edge| can_ghost_traverse(*edge))
.map(|edge| (edge.target, (edge.distance * 100.0) as u32))
.collect::<Vec<_>>()
},
|&node_id| node_id == target,
);
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.
pub fn debug_color(&self) -> sdl2::pixels::Color {
match self.ghost_type {
GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
}
}
}
impl Collidable for Ghost {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

View File

@@ -111,7 +111,7 @@ impl Graph {
}
/// Connects a new node to the graph and adds an edge between the existing node and the new node.
pub fn connect_node(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
pub fn add_connected(&mut self, from: NodeId, direction: Direction, new_node: Node) -> Result<NodeId, &'static str> {
let to = self.add_node(new_node);
self.connect(from, to, false, None, direction)?;
Ok(to)
@@ -236,208 +236,3 @@ impl Default for Graph {
Self::new()
}
}
// --- Traversal State and Logic ---
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// Manages an entity's movement through the graph.
///
/// A `Traverser` encapsulates the state of an entity's position and direction,
/// providing a way to advance along the graph's paths based on a given distance.
/// It also handles direction changes, buffering the next intended direction.
pub struct Traverser {
/// The current position of the traverser in the graph.
pub position: Position,
/// The current direction of movement.
pub direction: Direction,
/// Buffered direction change with remaining frame count for timing.
///
/// The `u8` value represents the number of frames remaining before
/// the buffered direction expires. This allows for responsive controls
/// by storing direction changes for a limited time.
pub next_direction: Option<(Direction, u8)>,
}
impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
next_direction: Some((initial_direction, 1)),
};
// This will kickstart the traverser into motion
traverser.advance(graph, 0.0, can_traverse);
traverser
}
/// Sets the next direction for the traverser to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the traverser reaches a new node. This allows for responsive
/// controls, as the new direction is stored for a limited time.
pub fn set_next_direction(&mut self, new_direction: Direction) {
if self.direction != new_direction {
self.next_direction = Some((new_direction, 30));
}
}
/// Advances the traverser along the graph by a specified distance.
///
/// This method updates the traverser's position based on its current state
/// and the distance to travel.
///
/// - If at a node, it checks for a buffered direction to start moving.
/// - If between nodes, it moves along the current edge.
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F)
where
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
self.next_direction = Some((direction, remaining - 1));
} else {
self.next_direction = None;
}
}
match self.position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(edge) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
self.direction = next_direction;
}
}
self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = graph
.find_edge(from, to)
.expect("Inconsistent state: Traverser is on a non-existent edge.");
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
self.position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
}
}
}
}
}
}

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

@@ -0,0 +1,101 @@
use crate::{
constants,
entity::{collision::Collidable, graph::Graph},
error::EntityError,
texture::sprite::{Sprite, SpriteAtlas},
};
use sdl2::render::{Canvas, RenderTarget};
#[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)]
#[allow(dead_code)]
pub enum FruitKind {
Apple,
Strawberry,
Orange,
Melon,
Bell,
Key,
Galaxian,
}
impl FruitKind {
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,3 +1,8 @@
pub mod collision;
pub mod direction;
pub mod ghost;
pub mod graph;
pub mod item;
pub mod pacman;
pub mod r#trait;
pub mod traversal;

View File

@@ -4,17 +4,20 @@
//! animation, and rendering. Pac-Man moves through the game graph using
//! a traverser and displays directional animated textures.
use glam::{UVec2, Vec2};
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId, Position, Traverser};
use crate::helpers::centered_with_size;
use crate::entity::{
collision::Collidable,
direction::Direction,
graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, RenderTarget};
use tracing::error;
use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge.
///
@@ -34,12 +37,45 @@ pub struct Pacman {
texture: DirectionalAnimatedTexture,
}
impl Entity for Pacman {
fn traverser(&self) -> &Traverser {
&self.traverser
}
fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser
}
fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture
}
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture
}
fn speed(&self) -> f32 {
1.125
}
fn can_traverse(&self, edge: Edge) -> bool {
can_pacman_traverse(edge)
}
fn tick(&mut self, dt: f32, graph: &Graph) {
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);
}
}
impl Pacman {
/// Creates a new Pac-Man instance at the specified starting node.
///
/// 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.
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 stopped_textures = [None, None, None, None];
@@ -51,31 +87,27 @@ impl Pacman {
Direction::Right => "pacman/right",
};
let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")).unwrap(),
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png")).unwrap(),
SpriteAtlas::get_tile(atlas, "pacman/full.png").unwrap(),
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
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()] =
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),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
}
}
/// Updates Pac-Man's position and animation state.
///
/// Advances movement through the graph and updates texture animation.
/// Movement speed is scaled by 60 FPS and a 1.125 multiplier.
pub fn tick(&mut self, dt: f32, graph: &Graph) {
self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse);
self.texture.tick(dt);
})
}
/// Handles keyboard input to change Pac-Man's direction.
@@ -95,36 +127,10 @@ impl Pacman {
self.traverser.set_next_direction(direction);
}
}
}
/// Calculates the current pixel position in the game world.
///
/// Interpolates between nodes when moving between them.
fn get_pixel_pos(&self, graph: &Graph) -> Vec2 {
match self.traverser.position {
Position::AtNode(node_id) => graph.get_node(node_id).unwrap().position,
Position::BetweenNodes { from, to, traversed } => {
let from_pos = graph.get_node(from).unwrap().position;
let to_pos = graph.get_node(to).unwrap().position;
from_pos.lerp(to_pos, traversed / from_pos.distance(to_pos))
}
}
}
/// Renders Pac-Man to the canvas.
///
/// Calculates screen position, determines if Pac-Man is stopped,
/// and renders the appropriate directional texture.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) {
let pixel_pos = self.get_pixel_pos(graph).round().as_ivec2() + BOARD_PIXEL_OFFSET.as_ivec2();
let dest = centered_with_size(pixel_pos, UVec2::new(16, 16));
let is_stopped = self.traverser.position.is_stopped();
if is_stopped {
self.texture
.render_stopped(canvas, atlas, dest, self.traverser.direction)
.unwrap();
} else {
self.texture.render(canvas, atlas, dest, self.traverser.direction).unwrap();
}
impl Collidable for Pacman {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

114
src/entity/trait.rs Normal file
View File

@@ -0,0 +1,114 @@
//! Entity trait for common movement and rendering functionality.
//!
//! This module defines a trait that captures the shared behavior between
//! different game entities like Ghosts and Pac-Man, including movement,
//! rendering, and position calculations.
use glam::Vec2;
use sdl2::render::{Canvas, RenderTarget};
use crate::entity::direction::Direction;
use crate::entity::graph::{Edge, Graph, NodeId};
use crate::entity::traversal::{Position, Traverser};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas;
/// Trait defining common functionality for game entities that move through the graph.
///
/// This trait provides a unified interface for entities that:
/// - Move through the game graph using a traverser
/// - Render using directional animated textures
/// - Have position calculations and movement speed
#[allow(dead_code)]
pub trait Entity {
/// Returns a reference to the entity's traverser for movement control.
fn traverser(&self) -> &Traverser;
/// Returns a mutable reference to the entity's traverser for movement control.
fn traverser_mut(&mut self) -> &mut Traverser;
/// Returns a reference to the entity's directional animated texture.
fn texture(&self) -> &DirectionalAnimatedTexture;
/// Returns a mutable reference to the entity's directional animated texture.
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture;
/// Returns the movement speed multiplier for this entity.
fn speed(&self) -> f32;
/// Determines if this entity can traverse a given edge.
fn can_traverse(&self, edge: Edge) -> bool;
/// Updates the entity's position and animation state.
///
/// This method advances movement through the graph and updates texture animation.
fn tick(&mut self, dt: f32, graph: &Graph);
/// Calculates the current pixel position in the game world.
///
/// Converts the graph position to screen coordinates, accounting for
/// the board offset and centering the sprite.
fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self.traverser().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 } => {
let from_node = graph.get_node(from).ok_or(EntityError::NodeNotFound(from))?;
let to_node = graph.get_node(to).ok_or(EntityError::NodeNotFound(to))?;
let edge = graph.find_edge(from, to).ok_or(EntityError::EdgeNotFound { from, to })?;
from_node.position + (to_node.position - from_node.position) * (traversed / edge.distance)
}
};
Ok(Vec2::new(
pos.x + crate::constants::BOARD_PIXEL_OFFSET.x 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.
///
/// If the entity is at a node, returns that node ID.
/// If the entity is between nodes, returns the node it's moving towards.
fn current_node_id(&self) -> NodeId {
match self.traverser().position {
Position::AtNode(node_id) => node_id,
Position::BetweenNodes { to, .. } => to,
}
}
/// Sets the next direction for the entity to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the entity reaches a new node.
fn set_next_direction(&mut self, direction: Direction) {
self.traverser_mut().set_next_direction(direction);
}
/// Renders the entity at its current position.
///
/// Draws the appropriate directional sprite based on the entity's
/// current movement state and direction.
fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, graph: &Graph) -> GameResult<()> {
let pixel_pos = self.get_pixel_pos(graph)?;
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pixel_pos.x as i32, pixel_pos.y as i32),
glam::UVec2::new(16, 16),
);
if self.traverser().position.is_stopped() {
self.texture()
.render_stopped(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
} else {
self.texture()
.render(canvas, atlas, dest, self.traverser().direction)
.map_err(|e| GameError::Texture(TextureError::RenderFailed(e.to_string())))?;
}
Ok(())
}
}

229
src/entity/traversal.rs Normal file
View File

@@ -0,0 +1,229 @@
use tracing::error;
use crate::error::GameResult;
use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId};
/// Represents the current position of an entity traversing the graph.
///
/// This enum allows for precise tracking of whether an entity is exactly at a node
/// or moving along an edge between two nodes.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node.
pub fn is_at_node(&self) -> bool {
matches!(self, Position::AtNode(_))
}
/// Returns the `NodeId` of the current or most recently departed node.
#[allow(clippy::wrong_self_convention)]
pub fn from_node_id(&self) -> NodeId {
match self {
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge.
#[allow(clippy::wrong_self_convention)]
pub fn to_node_id(&self) -> Option<NodeId> {
match self {
Position::AtNode(_) => None,
Position::BetweenNodes { to, .. } => Some(*to),
}
}
/// Returns `true` if the traverser is stopped at a node.
pub fn is_stopped(&self) -> bool {
matches!(self, Position::AtNode(_))
}
}
/// Manages an entity's movement through the graph.
///
/// A `Traverser` encapsulates the state of an entity's position and direction,
/// providing a way to advance along the graph's paths based on a given distance.
/// It also handles direction changes, buffering the next intended direction.
pub struct Traverser {
/// The current position of the traverser in the graph.
pub position: Position,
/// The current direction of movement.
pub direction: Direction,
/// Buffered direction change with remaining frame count for timing.
///
/// The `u8` value represents the number of frames remaining before
/// the buffered direction expires. This allows for responsive controls
/// by storing direction changes for a limited time.
pub next_direction: Option<(Direction, u8)>,
}
impl Traverser {
/// Creates a new traverser starting at the given node ID.
///
/// The traverser will immediately attempt to start moving in the initial direction.
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
where
F: Fn(Edge) -> bool,
{
let mut traverser = Traverser {
position: Position::AtNode(start_node),
direction: initial_direction,
next_direction: Some((initial_direction, 1)),
};
// This will kickstart the traverser into motion
if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
error!("Traverser initialization error: {}", e);
}
traverser
}
/// Sets the next direction for the traverser to take.
///
/// The direction is buffered and will be applied at the next opportunity,
/// typically when the traverser reaches a new node. This allows for responsive
/// controls, as the new direction is stored for a limited time.
pub fn set_next_direction(&mut self, new_direction: Direction) {
if self.direction != new_direction {
self.next_direction = Some((new_direction, 30));
}
}
/// Advances the traverser along the graph by a specified distance.
///
/// This method updates the traverser's position based on its current state
/// and the distance to travel.
///
/// - If at a node, it checks for a buffered direction to start moving.
/// - If between nodes, it moves along the current edge.
/// - If it reaches a node, it attempts to transition to a new edge based on
/// the buffered direction or by continuing straight.
/// - If no valid move is possible, it stops at the node.
///
/// 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
F: Fn(Edge) -> bool,
{
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = self.next_direction {
if remaining > 0 {
self.next_direction = Some((direction, remaining - 1));
} else {
self.next_direction = None;
}
}
match self.position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(edge) {
// Start moving in that direction
self.position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance.max(0.0),
};
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
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return Ok(());
}
let edge = graph.find_edge(from, to).ok_or_else(|| {
crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
)))
})?;
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
self.position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = self.next_direction {
if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
self.direction = next_dir; // Remember our new direction
self.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
if can_traverse(edge) {
self.position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
} else {
self.position = Position::AtNode(to);
self.next_direction = None;
}
}
}
}
}
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,7 +1,7 @@
//! This module contains the main game logic and state.
use anyhow::Result;
use glam::UVec2;
use glam::{UVec2, Vec2};
use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{
image::LoadTexture,
keyboard::Keycode,
@@ -10,11 +10,19 @@ use sdl2::{
video::WindowContext,
};
use crate::error::{EntityError, GameError, GameResult, TextureError};
use crate::{
asset::{get_asset_bytes, Asset},
audio::Audio,
constants::RAW_BOARD,
entity::pacman::Pacman,
constants::{CELL_SIZE, RAW_BOARD},
entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
r#trait::Entity,
},
map::Map,
texture::{
sprite::{self, AtlasMapper, AtlasTile, SpriteAtlas},
@@ -30,8 +38,16 @@ pub struct Game {
pub score: u32,
pub map: Map,
pub pacman: Pacman,
pub ghosts: Vec<Ghost>,
pub items: Vec<Item>,
pub debug_mode: bool,
// Collision system
collision_system: CollisionSystem,
pacman_id: EntityId,
ghost_ids: Vec<EntityId>,
item_ids: Vec<EntityId>,
// Rendering resources
atlas: SpriteAtlas,
map_texture: AtlasTile,
@@ -46,43 +62,96 @@ impl Game {
texture_creator: &TextureCreator<WindowContext>,
_ttf_context: &sdl2::ttf::Sdl2TtfContext,
_audio_subsystem: &sdl2::AudioSubsystem,
) -> Game {
let map = Map::new(RAW_BOARD);
) -> GameResult<Game> {
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
.grid_to_node
.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 texture = texture_creator
.load_texture_bytes(&atlas_bytes)
.expect("Could not load atlas texture from asset API");
let texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(TextureError::InvalidFormat(format!("Unsupported texture format: {e}")))
} else {
GameError::Texture(TextureError::LoadFailed(e.to_string()))
}
})?;
sprite::texture_to_static(texture)
};
let atlas_json = get_asset_bytes(Asset::AtlasJson).expect("Failed to load asset");
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json).expect("Could not parse atlas JSON");
let atlas_json = get_asset_bytes(Asset::AtlasJson)?;
let atlas_mapper: AtlasMapper = serde_json::from_slice(&atlas_json)?;
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));
let text_texture = TextTexture::new(1.0);
let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas);
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
Game {
// Generate items (pellets and energizers)
let items = map.generate_items(&atlas)?;
// Create ghosts at random positions
let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
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 {
// Find a random node for the ghost to start at
let random_node = rng.random_range(0..map.graph.node_count());
let ghost = Ghost::new(&map.graph, random_node, ghost_type, &atlas)?;
ghosts.push(ghost);
}
// 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,
map,
pacman,
ghosts,
items,
debug_mode: false,
collision_system,
pacman_id,
ghost_ids,
item_ids,
map_texture,
text_texture,
audio,
atlas,
}
})
}
pub fn keyboard_event(&mut self, keycode: Keycode) {
@@ -91,19 +160,153 @@ impl Game {
if keycode == Keycode::M {
self.audio.set_mute(!self.audio.is_muted());
}
if keycode == Keycode::R {
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
fn reset_game_state(&mut self) -> GameResult<()> {
// Reset Pac-Man to starting position
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
.map
.grid_to_node
.get(&glam::IVec2::new(pacman_start_pos.x as i32, pacman_start_pos.y as i32))
.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)?;
// Reset items
self.items = self.map.generate_items(&self.atlas)?;
// Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng();
for (i, ghost) in self.ghosts.iter_mut().enumerate() {
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)?;
}
// 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) {
self.pacman.tick(dt, &self.map.graph);
// Update all ghosts
for ghost in &mut self.ghosts {
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<()> {
canvas.with_texture_canvas(backbuffer, |canvas| {
canvas.set_draw_color(Color::BLACK);
canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture);
self.pacman.render(canvas, &mut self.atlas, &self.map.graph);
})?;
fn update_collision_positions(&mut self) {
// Update Pac-Man's position
self.collision_system.update_position(self.pacman_id, self.pacman.position());
// Update ghost positions
for (ghost, &ghost_id) in self.ghosts.iter().zip(&self.ghost_ids) {
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!");
}
}
}
}
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(())
}
@@ -113,18 +316,88 @@ impl Game {
canvas: &mut Canvas<T>,
backbuffer: &Texture,
cursor_pos: glam::Vec2,
) -> Result<()> {
canvas.copy(backbuffer, None, None).map_err(anyhow::Error::msg)?;
) -> GameResult<()> {
canvas
.copy(backbuffer, None, None)
.map_err(|e| GameError::Sdl(e.to_string()))?;
if self.debug_mode {
self.map
.debug_render_with_cursor(canvas, &mut self.text_texture, &mut self.atlas, cursor_pos);
if let Err(e) = self
.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.draw_hud(canvas)?;
canvas.present();
Ok(())
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> Result<()> {
/// Renders pathfinding debug lines from each ghost to Pac-Man.
///
/// Each ghost's path is drawn in its respective color with a small offset
/// to prevent overlapping lines.
fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
let pacman_node = self.pacman.current_node_id();
for ghost in self.ghosts.iter() {
if let Ok(path) = ghost.calculate_path_to_target(&self.map.graph, pacman_node) {
if path.len() < 2 {
continue; // Skip if path is too short
}
// Set the ghost's color
canvas.set_draw_color(ghost.debug_color());
// Calculate offset based on ghost index to prevent overlapping lines
// let offset = (i as f32) * 2.0 - 3.0; // Offset range: -3.0 to 3.0
// Calculate a consistent offset direction for the entire path
// let first_node = self.map.graph.get_node(path[0]).unwrap();
// let last_node = self.map.graph.get_node(path[path.len() - 1]).unwrap();
// Use the overall direction from start to end to determine the perpendicular offset
let offset = match ghost.ghost_type {
GhostType::Blinky => Vec2::new(0.25, 0.5),
GhostType::Pinky => Vec2::new(-0.25, -0.25),
GhostType::Inky => Vec2::new(0.5, -0.5),
GhostType::Clyde => Vec2::new(-0.5, 0.25),
} * 5.0;
// Calculate offset positions for all nodes using the same perpendicular direction
let mut offset_positions = Vec::new();
for &node_id in &path {
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();
offset_positions.push(pos + offset);
}
// Draw lines between the offset positions
for window in offset_positions.windows(2) {
if let (Some(from), Some(to)) = (window.first(), window.get(1)) {
// Skip if the distance is too far (used for preventing lines between tunnel portals)
if from.distance_squared(*to) > (CELL_SIZE * 16).pow(2) as f32 {
continue;
}
// Draw the line
canvas
.draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32))
.map_err(|e| GameError::Sdl(e.to_string()))?;
}
}
}
}
Ok(())
}
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
let lives = 3;
let score_text = format!("{:02}", self.score);
let x_offset = 4;
@@ -132,18 +405,22 @@ impl Game {
let lives_offset = 3;
let score_offset = 7 - (score_text.len() as i32);
self.text_texture.set_scale(1.0);
let _ = self.text_texture.render(
if let Err(e) = self.text_texture.render(
canvas,
&mut self.atlas,
&format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
);
let _ = self.text_texture.render(
) {
tracing::error!("Failed to render HUD text: {}", e);
}
if let Err(e) = self.text_texture.render(
canvas,
&mut self.atlas,
&score_text,
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
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s);

View File

@@ -2,10 +2,9 @@ use glam::{IVec2, UVec2};
use sdl2::rect::Rect;
pub fn centered_with_size(pixel_pos: IVec2, size: UVec2) -> Rect {
Rect::new(
pixel_pos.x - size.x as i32 / 2,
pixel_pos.y - size.y as i32 / 2,
size.x,
size.y,
)
// Ensure the position doesn't cause integer overflow when centering
let x = pixel_pos.x.saturating_sub(size.x as i32 / 2);
let y = pixel_pos.y.saturating_sub(size.y as i32 / 2);
Rect::new(x, y, size.x, size.y)
}

View File

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

View File

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

View File

@@ -1,16 +1,19 @@
//! 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::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::entity::item::{Item, ItemType};
use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
use crate::texture::sprite::{AtlasTile, Sprite, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque};
use tracing::debug;
use crate::error::{GameResult, MapError};
/// The starting positions of the entities in the game.
#[allow(dead_code)]
pub struct NodePositions {
@@ -47,8 +50,8 @@ impl Map {
///
/// This function will panic if the board layout contains unknown characters or if
/// the house door is not defined by exactly two '=' characters.
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> Map {
let parsed_map = MapTileParser::parse_board(raw_board).expect("Failed to parse board layout");
pub fn new(raw_board: [&str; BOARD_CELL_SIZE.y as usize]) -> GameResult<Map> {
let parsed_map = MapTileParser::parse_board(raw_board)?;
let map = parsed_map.tiles;
let house_door = parsed_map.house_door;
@@ -61,7 +64,8 @@ impl Map {
let cell_offset = Vec2::splat(CELL_SIZE as f32 / 2.0);
// 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
let mut queue = VecDeque::new();
@@ -114,7 +118,7 @@ impl Map {
// Connect the new node to the source node
graph
.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) {
graph
.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
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 {
pacman: grid_to_node[&start_pos],
@@ -148,15 +152,15 @@ impl Map {
};
// 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,
graph,
grid_to_node,
start_positions,
pacman_start,
}
})
}
/// Finds the starting position for a given entity ID.
@@ -184,6 +188,44 @@ impl Map {
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.
///
/// This function provides interactive debugging by highlighting the nearest node
@@ -194,8 +236,8 @@ impl Map {
text_renderer: &mut crate::texture::text::TextTexture,
atlas: &mut SpriteAtlas,
cursor_pos: glam::Vec2,
) {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos);
) -> GameResult<()> {
MapRenderer::debug_render_with_cursor(&self.graph, canvas, text_renderer, atlas, cursor_pos)
}
/// Builds the house structure in the graph.
@@ -203,21 +245,32 @@ impl Map {
graph: &mut Graph,
grid_to_node: &HashMap<IVec2, NodeId>,
house_door: &[Option<IVec2>; 2],
) -> (usize, usize, usize, usize) {
) -> GameResult<(usize, usize, usize, usize)> {
// Calculate the position of the house entrance node
let (house_entrance_node_id, house_entrance_node_position) = {
// Translate the grid positions to the actual node ids
let left_node = grid_to_node
.get(&(house_door[0].expect("First house door position not acquired") + Direction::Left.as_ivec2()))
.expect("Left house door node not found");
.get(
&(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
.get(&(house_door[1].expect("Second house door position not acquired") + Direction::Right.as_ivec2()))
.expect("Right house door node not found");
.get(
&(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
let (node_id, node_position) = {
let left_pos = graph.get_node(*left_node).unwrap().position;
let right_pos = graph.get_node(*right_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)
.ok_or(MapError::NodeNotFound(*right_node))?
.position;
let house_node = graph.add_node(Node {
position: left_pos.lerp(right_pos, 0.5),
});
@@ -227,16 +280,16 @@ impl Map {
// Connect the house door to the left and right nodes
graph
.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
.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)
};
// 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
let center_node_id = graph.add_node(Node { position: center_pos });
let top_node_id = graph.add_node(Node {
@@ -249,12 +302,12 @@ impl Map {
// Connect the center node to the top and bottom nodes
graph
.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
.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
@@ -262,7 +315,7 @@ impl Map {
house_entrance_node_position + (Direction::Down.as_ivec2() * (3 * CELL_SIZE as i32)).as_vec2();
// 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.
// This prevents Pac-Man from entering or exiting through the door.
@@ -275,7 +328,7 @@ impl Map {
Direction::Down,
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
.add_edge(
@@ -286,52 +339,57 @@ impl Map {
Direction::Up,
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
let (left_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Left.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
)?;
// Create the right line
let (right_center_node_id, _) = create_house_line(
graph,
center_line_center_position + (Direction::Right.as_ivec2() * (CELL_SIZE as i32 * 2)).as_vec2(),
);
)?;
debug!("Left center node id: {left_center_node_id}");
// Connect the center line to the left and right lines
graph
.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
.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}");
(
Ok((
house_entrance_node_id,
left_center_node_id,
center_center_node_id,
right_center_node_id,
)
))
}
/// 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
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
.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
.connect_node(
.add_connected(
left_tunnel_entrance_node_id,
Direction::Left,
Node {
@@ -339,18 +397,24 @@ impl Map {
+ (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
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
.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
.connect_node(
.add_connected(
right_tunnel_entrance_node_id,
Direction::Right,
Node {
@@ -358,7 +422,12 @@ impl Map {
+ (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
@@ -370,6 +439,13 @@ impl Map {
Some(0.0),
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),
#[error("House door must have exactly 2 positions, found {0}")]
InvalidHouseDoorCount(usize),
#[error("Map parsing failed: {0}")]
ParseFailed(String),
}
/// 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
/// is not properly defined by exactly two '=' characters.
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 house_door = [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::render::{Canvas, RenderTarget};
use crate::error::{EntityError, GameError, GameResult};
/// Handles rendering operations for the map.
pub struct MapRenderer;
@@ -22,7 +24,9 @@ impl MapRenderer {
crate::constants::BOARD_PIXEL_SIZE.x,
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.
@@ -35,65 +39,81 @@ impl MapRenderer {
text_renderer: &mut TextTexture,
atlas: &mut SpriteAtlas,
cursor_pos: Vec2,
) {
) -> GameResult<()> {
// Find the nearest node to the cursor
let nearest_node = Self::find_nearest_node(graph, cursor_pos);
// Draw all connections in blue
canvas.set_draw_color(Color::RGB(0, 0, 128)); // Dark blue for regular connections
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();
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
.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
canvas.set_draw_color(Color::RGB(0, 128, 0)); // Dark green for regular nodes
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();
canvas
.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
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
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
.draw_line(
(nearest_pos.x as i32, nearest_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
canvas.set_draw_color(Color::RGB(0, 255, 0)); // Bright green for highlighted node
canvas
.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)
text_renderer.set_scale(0.5); // Small text
let id_text = format!("#{}", nearest_id);
let id_text = format!("#{nearest_id}");
let text_pos = glam::UVec2::new(
(nearest_pos.x + 4.0) as u32, // Offset to the right
(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.
@@ -102,13 +122,14 @@ impl MapRenderer {
let mut nearest_distance = f32::INFINITY;
for i in 0..graph.node_count() {
let node = graph.get_node(i).unwrap();
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
let distance = cursor_pos.distance(node_pos);
if let Some(node) = graph.get_node(i) {
let node_pos = node.position + crate::constants::BOARD_PIXEL_OFFSET.as_vec2();
let distance = cursor_pos.distance(node_pos);
if distance < nearest_distance {
nearest_distance = distance;
nearest_id = Some(i);
if distance < nearest_distance {
nearest_distance = distance;
nearest_id = Some(i);
}
}
}

View File

@@ -6,6 +6,27 @@ use sdl2::render::{Canvas, RenderTarget, Texture};
use serde::Deserialize;
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)]
pub struct AtlasMapper {
pub frames: HashMap<String, MapperFrame>,

View File

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

View File

@@ -3,7 +3,7 @@ use pacman::map::Map;
#[test]
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.grid_to_node.is_empty());
@@ -16,6 +16,6 @@ fn test_game_map_creation() {
#[test]
fn test_game_score_initialization() {
// 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());
}

48
tests/ghost.rs Normal file
View File

@@ -0,0 +1,48 @@
use pacman::entity::ghost::{Ghost, GhostType};
use pacman::entity::graph::Graph;
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_test_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
let directions = ["up", "down", "left", "right"];
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
for ghost_type in &ghost_types {
for (i, dir) in directions.iter().enumerate() {
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
MapperFrame {
x: i as u16 * 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
MapperFrame {
x: i as u16 * 16,
y: 16,
width: 16,
height: 16,
},
);
}
}
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_ghost_creation() {
let graph = Graph::new();
let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap();
assert_eq!(ghost.ghost_type, GhostType::Blinky);
assert_eq!(ghost.traverser.position.from_node_id(), 0);
}

View File

@@ -1,5 +1,6 @@
use pacman::entity::direction::Direction;
use pacman::entity::graph::{EdgePermissions, Graph, Node, Position, Traverser};
use pacman::entity::graph::{EdgePermissions, Graph, Node};
use pacman::entity::traversal::{Position, Traverser};
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
@@ -100,7 +101,7 @@ fn test_traverser_advance() {
let graph = create_test_graph();
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 {
Position::BetweenNodes { from, to, traversed } => {
@@ -111,7 +112,7 @@ fn test_traverser_advance() {
_ => panic!("Expected to be between nodes"),
}
traverser.advance(&graph, 3.0, &|_| true);
traverser.advance(&graph, 3.0, &|_| true).unwrap();
match traverser.position {
Position::BetweenNodes { from, to, traversed } => {
@@ -142,7 +143,9 @@ fn test_traverser_with_permissions() {
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
assert!(traverser.position.is_at_node());

View File

@@ -41,7 +41,7 @@ fn create_minimal_test_board() -> [&'static str; BOARD_CELL_SIZE.y as usize] {
#[test]
fn test_map_creation() {
let board = create_minimal_test_board();
let map = Map::new(board);
let map = Map::new(board).unwrap();
assert!(map.graph.node_count() > 0);
assert!(!map.grid_to_node.is_empty());
@@ -60,7 +60,7 @@ fn test_map_creation() {
#[test]
fn test_map_starting_positions() {
let board = create_minimal_test_board();
let map = Map::new(board);
let map = Map::new(board).unwrap();
let pacman_pos = map.find_starting_position(0);
assert!(pacman_pos.is_some());
@@ -74,7 +74,7 @@ fn test_map_starting_positions() {
#[test]
fn test_map_node_positions() {
let board = create_minimal_test_board();
let map = Map::new(board);
let map = Map::new(board).unwrap();
for (grid_pos, &node_id) in &map.grid_to_node {
let node = map.graph.get_node(node_id).unwrap();

View File

@@ -67,7 +67,7 @@ fn create_test_atlas() -> SpriteAtlas {
fn test_pacman_creation() {
let graph = create_test_graph();
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_eq!(pacman.traverser.direction, Direction::Left);
@@ -77,7 +77,7 @@ fn test_pacman_creation() {
fn test_pacman_key_handling() {
let graph = create_test_graph();
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 = [
(Keycode::Up, Direction::Up),
@@ -96,7 +96,7 @@ fn test_pacman_key_handling() {
fn test_pacman_invalid_key() {
let graph = create_test_graph();
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_next_direction = pacman.traverser.next_direction;

View File

@@ -37,10 +37,10 @@ fn test_parse_board() {
#[test]
fn test_parse_board_invalid_character() {
let mut invalid_board = RAW_BOARD.clone();
invalid_board[0] = "###########################Z";
let mut invalid_board = RAW_BOARD.map(|s| s.to_string());
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!(matches!(result.unwrap_err(), ParseError::UnknownCharacter('Z')));
}

120
tests/pathfinding.rs Normal file
View File

@@ -0,0 +1,120 @@
use pacman::entity::direction::Direction;
use pacman::entity::ghost::{Ghost, GhostType};
use pacman::entity::graph::{Graph, Node};
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use std::collections::HashMap;
fn create_test_atlas() -> SpriteAtlas {
let mut frames = HashMap::new();
let directions = ["up", "down", "left", "right"];
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
for ghost_type in &ghost_types {
for (i, dir) in directions.iter().enumerate() {
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
MapperFrame {
x: i as u16 * 16,
y: 0,
width: 16,
height: 16,
},
);
frames.insert(
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
MapperFrame {
x: i as u16 * 16,
y: 16,
width: 16,
height: 16,
},
);
}
}
let mapper = AtlasMapper { frames };
let dummy_texture = unsafe { std::mem::zeroed() };
SpriteAtlas::new(dummy_texture, mapper)
}
#[test]
fn test_ghost_pathfinding() {
// Create a simple test graph
let mut graph = Graph::new();
// Add nodes in a simple line: 0 -> 1 -> 2
let node0 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node1 = graph.add_node(Node {
position: glam::Vec2::new(10.0, 0.0),
});
let node2 = graph.add_node(Node {
position: glam::Vec2::new(20.0, 0.0),
});
// Connect the nodes
graph.connect(node0, node1, false, None, Direction::Right).unwrap();
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
// Create a test atlas for the ghost
let atlas = create_test_atlas();
// Create a ghost at node 0
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding from node 0 to node 2
let path = ghost.calculate_path_to_target(&graph, node2);
assert!(path.is_ok());
let path = path.unwrap();
assert!(
path == vec![node0, node1, node2] || path == vec![node2, node1, node0],
"Path was not what was expected"
);
}
#[test]
fn test_ghost_pathfinding_no_path() {
// Create a test graph with disconnected components
let mut graph = Graph::new();
let node0 = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let node1 = graph.add_node(Node {
position: glam::Vec2::new(10.0, 0.0),
});
// Don't connect the nodes
let atlas = create_test_atlas();
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
// Test pathfinding when no path exists
let path = ghost.calculate_path_to_target(&graph, node1);
assert!(path.is_err());
}
#[test]
fn test_ghost_debug_colors() {
let atlas = create_test_atlas();
let mut graph = Graph::new();
let node = graph.add_node(Node {
position: glam::Vec2::new(0.0, 0.0),
});
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap();
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap();
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap();
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap();
// Test that each ghost has a different debug color
let colors = std::collections::HashSet::from([
blinky.debug_color(),
pinky.debug_color(),
inky.debug_color(),
clyde.debug_color(),
]);
assert_eq!(colors.len(), 4, "All ghost colors should be unique");
}