Compare commits

..

6 Commits

Author SHA1 Message Date
183a432116 test: add tests for collision, items, directional, sprite
enum macros for FruitKind
2025-08-12 09:18:53 -05:00
ead1466b2d chore: specify 'llvm-tools-preview' toolchain component for coverage in toolchain file 2025-08-12 00:22:27 -05:00
8ef09a4e3e test: drop minimal_test_board, use RAW_BOARD constant, item generation tests 2025-08-11 23:26:28 -05:00
33672d8d5a feat: implement collision detection system for entities 2025-08-11 23:24:23 -05:00
1dc8aca373 feat: item collection & collisions, pellet & energizer generation 2025-08-11 22:45:36 -05:00
02089a78da chore: downgrade toolchain to 1.86 on all versions
This is just because managing both 1.86 and 1.88 is really annoying, so
it's better to just be unified. There's no real point to using 1.88
besides more clippy warnings, which are already impeding my work right
now. So we're downgrading.
2025-08-11 22:10:41 -05:00
19 changed files with 777 additions and 59 deletions

View File

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

View File

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

26
Cargo.lock generated
View File

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

View File

@@ -21,6 +21,8 @@ glam = { version = "0.30.4", features = [] }
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.141" serde_json = "1.0.141"
smallvec = "1.15.1" smallvec = "1.15.1"
strum = "0.27.2"
strum_macros = "0.27.2"
[profile.release] [profile.release]
lto = true lto = true

3
rust-toolchain.toml Normal file
View File

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

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

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

View File

@@ -9,10 +9,13 @@ use rand::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use tracing::error; use tracing::error;
use crate::entity::direction::Direction; use crate::entity::{
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; collision::Collidable,
use crate::entity::r#trait::Entity; direction::Direction,
use crate::entity::traversal::Traverser; graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
@@ -245,3 +248,9 @@ impl Ghost {
} }
} }
} }
impl Collidable for Ghost {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

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

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

View File

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

View File

@@ -4,10 +4,13 @@
//! animation, and rendering. Pac-Man moves through the game graph using //! animation, and rendering. Pac-Man moves through the game graph using
//! a traverser and displays directional animated textures. //! a traverser and displays directional animated textures.
use crate::entity::direction::Direction; use crate::entity::{
use crate::entity::graph::{Edge, EdgePermissions, Graph, NodeId}; collision::Collidable,
use crate::entity::r#trait::Entity; direction::Direction,
use crate::entity::traversal::Traverser; graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity,
traversal::Traverser,
};
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
@@ -125,3 +128,9 @@ impl Pacman {
} }
} }
} }
impl Collidable for Pacman {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

View File

@@ -17,7 +17,9 @@ use crate::{
audio::Audio, audio::Audio,
constants::{CELL_SIZE, RAW_BOARD}, constants::{CELL_SIZE, RAW_BOARD},
entity::{ entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType}, ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman, pacman::Pacman,
r#trait::Entity, r#trait::Entity,
}, },
@@ -37,8 +39,15 @@ pub struct Game {
pub map: Map, pub map: Map,
pub pacman: Pacman, pub pacman: Pacman,
pub ghosts: Vec<Ghost>, pub ghosts: Vec<Ghost>,
pub items: Vec<Item>,
pub debug_mode: bool, pub debug_mode: bool,
// Collision system
collision_system: CollisionSystem,
pacman_id: EntityId,
ghost_ids: Vec<EntityId>,
item_ids: Vec<EntityId>,
// Rendering resources // Rendering resources
atlas: SpriteAtlas, atlas: SpriteAtlas,
map_texture: AtlasTile, map_texture: AtlasTile,
@@ -87,6 +96,9 @@ impl Game {
let audio = Audio::new(); let audio = Audio::new();
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?; let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?;
// Generate items (pellets and energizers)
let items = map.generate_items(&atlas)?;
// Create ghosts at random positions // Create ghosts at random positions
let mut ghosts = Vec::new(); let mut ghosts = Vec::new();
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
@@ -104,12 +116,37 @@ impl Game {
ghosts.push(ghost); 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 { Ok(Game {
score: 0, score: 0,
map, map,
pacman, pacman,
ghosts, ghosts,
items,
debug_mode: false, debug_mode: false,
collision_system,
pacman_id,
ghost_ids,
item_ids,
map_texture, map_texture,
text_texture, text_texture,
audio, audio,
@@ -146,6 +183,9 @@ impl Game {
self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?; self.pacman = Pacman::new(&self.map.graph, pacman_start_node, &self.atlas)?;
// Reset items
self.items = self.map.generate_items(&self.atlas)?;
// Randomize ghost positions // Randomize ghost positions
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde];
let mut rng = SmallRng::from_os_rng(); let mut rng = SmallRng::from_os_rng();
@@ -155,6 +195,26 @@ impl Game {
*ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas)?; *ghost = Ghost::new(&self.map.graph, random_node, ghost_types[i], &self.atlas)?;
} }
// Reset collision system
self.collision_system = CollisionSystem::default();
// Re-register Pac-Man
self.pacman_id = self.collision_system.register_entity(self.pacman.position());
// Re-register items
self.item_ids.clear();
for item in &self.items {
let item_id = self.collision_system.register_entity(item.position());
self.item_ids.push(item_id);
}
// Re-register ghosts
self.ghost_ids.clear();
for ghost in &self.ghosts {
let ghost_id = self.collision_system.register_entity(ghost.position());
self.ghost_ids.push(ghost_id);
}
Ok(()) Ok(())
} }
@@ -165,6 +225,60 @@ impl Game {
for ghost in &mut self.ghosts { for ghost in &mut self.ghosts {
ghost.tick(dt, &self.map.graph); ghost.tick(dt, &self.map.graph);
} }
// Update collision system positions
self.update_collision_positions();
// Check for collisions
self.check_collisions();
}
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<()> { pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
@@ -174,6 +288,13 @@ impl Game {
canvas.clear(); canvas.clear();
self.map.render(canvas, &mut self.atlas, &mut self.map_texture); 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 // Render all ghosts
for ghost in &self.ghosts { for ghost in &self.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) { if let Err(e) = ghost.render(canvas, &mut self.atlas, &self.map.graph) {

View File

@@ -1,11 +1,12 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId}; use crate::entity::graph::{EdgePermissions, Graph, Node, NodeId};
use crate::entity::item::{Item, ItemType};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
use crate::texture::sprite::{AtlasTile, SpriteAtlas}; use crate::texture::sprite::{AtlasTile, Sprite, SpriteAtlas};
use glam::{IVec2, UVec2, Vec2}; use glam::{IVec2, UVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
@@ -187,6 +188,44 @@ impl Map {
MapRenderer::render_map(canvas, atlas, map_texture); MapRenderer::render_map(canvas, atlas, map_texture);
} }
/// Generates Item entities for pellets and energizers from the parsed map.
pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// Pre-load sprites to avoid repeated texture lookups
let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
.ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
.ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?;
// Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
let mut items = Vec::with_capacity(250);
// Parse the raw board once
let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
let map = parsed_map.tiles;
// Iterate through the map and collect items more efficiently
for (x, row) in map.iter().enumerate() {
for (y, tile) in row.iter().enumerate() {
match tile {
MapTile::Pellet | MapTile::PowerPellet => {
let grid_pos = IVec2::new(x as i32, y as i32);
if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
let (item_type, sprite) = match tile {
MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
_ => unreachable!(), // We already filtered for these types
};
items.push(Item::new(node_id, item_type, sprite));
}
}
_ => {}
}
}
}
Ok(items)
}
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
/// ///
/// This function provides interactive debugging by highlighting the nearest node /// This function provides interactive debugging by highlighting the nearest node

View File

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

119
tests/collision.rs Normal file
View File

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

View File

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

View File

@@ -1,6 +1,9 @@
use pacman::constants::RAW_BOARD; use pacman::constants::RAW_BOARD;
use pacman::map::Map; use pacman::map::Map;
mod collision;
mod item;
#[test] #[test]
fn test_game_map_creation() { fn test_game_map_creation() {
let map = Map::new(RAW_BOARD).unwrap(); let map = Map::new(RAW_BOARD).unwrap();

53
tests/item.rs Normal file
View File

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

View File

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

View File

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