Compare commits

...

10 Commits

35 changed files with 2710 additions and 1462 deletions

771
Cargo.lock generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@ serde_json = "1.0.142"
smallvec = "1.15.1" smallvec = "1.15.1"
strum = "0.27.2" strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
phf = { version = "0.11", features = ["macros"] }
bevy_ecs = "0.16.1"
[profile.release] [profile.release]
lto = true lto = true
@@ -57,3 +59,8 @@ aarch64-apple-darwin = { triplet = "arm64-osx" }
[target.'cfg(target_os = "emscripten")'.dependencies] [target.'cfg(target_os = "emscripten")'.dependencies]
libc = "0.2.175" libc = "0.2.175"
[build-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
phf = { version = "0.11", features = ["macros"] }

View File

@@ -1,6 +1,6 @@
# Pac-Man # Pac-Man
[![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] [![Tests Status][badge-test]][test] [![Build Status][badge-build]][build] [![If you're seeing this, Coveralls.io is broken again and it's not my fault.][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/tests.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-build]: https://github.com/Xevion/Pac-Man/actions/workflows/build.yaml/badge.svg

50
build.rs Normal file
View File

@@ -0,0 +1,50 @@
use std::collections::HashMap;
use std::env;
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct AtlasMapper {
frames: HashMap<String, MapperFrame>,
}
#[derive(Copy, Clone, Debug, Deserialize)]
struct MapperFrame {
x: u16,
y: u16,
width: u16,
height: u16,
}
fn main() {
let path = Path::new(&env::var("OUT_DIR").unwrap()).join("atlas_data.rs");
let mut file = BufWriter::new(File::create(&path).unwrap());
let atlas_json = include_str!("./assets/game/atlas.json");
let atlas_mapper: AtlasMapper = serde_json::from_str(atlas_json).unwrap();
writeln!(&mut file, "use phf::phf_map;").unwrap();
writeln!(&mut file, "use crate::texture::sprite::MapperFrame;").unwrap();
writeln!(
&mut file,
"pub static ATLAS_FRAMES: phf::Map<&'static str, MapperFrame> = phf_map! {{"
)
.unwrap();
for (name, frame) in atlas_mapper.frames {
writeln!(
&mut file,
" \"{}\" => MapperFrame {{ x: {}, y: {}, width: {}, height: {} }},",
name, frame.x, frame.y, frame.width, frame.height
)
.unwrap();
}
writeln!(&mut file, "}};").unwrap();
println!("cargo:rerun-if-changed=assets/game/atlas.json");
}

View File

@@ -1,13 +1,11 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use glam::Vec2; use glam::Vec2;
use sdl2::event::{Event, WindowEvent};
use sdl2::keyboard::Keycode;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::ttf::Sdl2TtfContext; use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::{error, event}; use tracing::{error, warn};
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};
@@ -16,12 +14,9 @@ use crate::game::Game;
use crate::platform::get_platform; use crate::platform::get_platform;
pub struct App { pub struct App {
game: Game, pub game: Game,
canvas: Canvas<Window>,
event_pump: &'static mut EventPump,
backbuffer: Texture<'static>,
paused: bool,
last_tick: Instant, last_tick: Instant,
focused: bool,
cursor_pos: Vec2, cursor_pos: Vec2,
} }
@@ -51,33 +46,33 @@ impl App {
.build() .build()
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
let mut canvas = window.into_canvas().build().map_err(|e| GameError::Sdl(e.to_string()))?; let mut canvas = Box::leak(Box::new(
window
.into_canvas()
.accelerated()
.present_vsync()
.build()
.map_err(|e| GameError::Sdl(e.to_string()))?,
));
canvas canvas
.set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y) .set_logical_size(CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
let texture_creator: &'static TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator())); let texture_creator: &'static mut TextureCreator<WindowContext> = Box::leak(Box::new(canvas.texture_creator()));
let mut game = Game::new(texture_creator)?; let game = Game::new(canvas, texture_creator, event_pump)?;
// game.audio.set_mute(cfg!(debug_assertions)); // game.audio.set_mute(cfg!(debug_assertions));
let mut backbuffer = texture_creator
.create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest);
// Initial draw // Initial draw
game.draw(&mut canvas, &mut backbuffer) // game.draw(&mut canvas, &mut backbuffer)
.map_err(|e| GameError::Sdl(e.to_string()))?; // .map_err(|e| GameError::Sdl(e.to_string()))?;
game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO) // game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
.map_err(|e| GameError::Sdl(e.to_string()))?; // .map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(Self { Ok(App {
game, game,
canvas, focused: true,
event_pump,
backbuffer,
paused: false,
last_tick: Instant::now(), last_tick: Instant::now(),
cursor_pos: Vec2::ZERO, cursor_pos: Vec2::ZERO,
}) })
@@ -87,78 +82,51 @@ impl App {
{ {
let start = Instant::now(); let start = Instant::now();
for event in self.event_pump.poll_iter() { // for event in self
match event { // .game
Event::Window { win_event, .. } => match win_event { // .world
WindowEvent::Hidden => { // .get_non_send_resource_mut::<&'static mut EventPump>()
event!(tracing::Level::DEBUG, "Window hidden"); // .unwrap()
} // .poll_iter()
WindowEvent::Shown => { // {
event!(tracing::Level::DEBUG, "Window shown"); // match event {
} // Event::Window { win_event, .. } => match win_event {
_ => {} // WindowEvent::FocusGained => {
}, // self.focused = true;
// It doesn't really make sense to have this available in the browser // }
#[cfg(not(target_os = "emscripten"))] // WindowEvent::FocusLost => {
Event::Quit { .. } // self.focused = false;
| Event::KeyDown { // }
keycode: Some(Keycode::Escape) | Some(Keycode::Q), // _ => {}
.. // },
} => { // Event::MouseMotion { x, y, .. } => {
event!(tracing::Level::INFO, "Exit requested. Exiting..."); // // Convert window coordinates to logical coordinates
return false; // self.cursor_pos = Vec2::new(x as f32, y as f32);
} // }
Event::KeyDown { // _ => {}
keycode: Some(Keycode::P), // }
.. // }
} => {
self.paused = !self.paused;
event!(tracing::Level::INFO, "{}", if self.paused { "Paused" } else { "Unpaused" });
}
Event::KeyDown {
keycode: Some(Keycode::Space),
..
} => {
self.game.toggle_debug_mode();
}
Event::KeyDown { keycode: Some(key), .. } => {
self.game.keyboard_event(key);
}
Event::MouseMotion { x, y, .. } => {
// Convert window coordinates to logical coordinates
self.cursor_pos = Vec2::new(x as f32, y as f32);
}
_ => {}
}
}
let dt = self.last_tick.elapsed().as_secs_f32(); let dt = self.last_tick.elapsed().as_secs_f32();
self.last_tick = Instant::now(); self.last_tick = Instant::now();
if !self.paused { let exit = self.game.tick(dt);
self.game.tick(dt);
if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) { if exit {
error!("Failed to draw game: {}", e); return false;
}
if let Err(e) = self
.game
.present_backbuffer(&mut self.canvas, &self.backbuffer, self.cursor_pos)
{
error!("Failed to present backbuffer: {}", e);
}
} }
// if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
// error!("Failed to draw game: {}", e);
// }
if start.elapsed() < LOOP_TIME { if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed()); let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO { if time != Duration::ZERO {
get_platform().sleep(time); get_platform().sleep(time, self.focused);
} }
} else { } else {
event!( warn!("Game loop behind schedule by: {:?}", start.elapsed() - LOOP_TIME);
tracing::Level::WARN,
"Game loop behind schedule by: {:?}",
start.elapsed() - LOOP_TIME
);
} }
true true

View File

@@ -12,8 +12,6 @@ pub enum Asset {
Wav3, Wav3,
Wav4, Wav4,
Atlas, Atlas,
AtlasJson,
// Add more as needed
} }
impl Asset { impl Asset {
@@ -26,7 +24,6 @@ impl Asset {
Wav3 => "sound/waka/3.ogg", Wav3 => "sound/waka/3.ogg",
Wav4 => "sound/waka/4.ogg", Wav4 => "sound/waka/4.ogg",
Atlas => "atlas.png", Atlas => "atlas.png",
AtlasJson => "atlas.json",
} }
} }
} }
@@ -36,6 +33,7 @@ mod imp {
use crate::error::AssetError; use crate::error::AssetError;
use crate::platform::get_platform; use crate::platform::get_platform;
/// Returns the raw bytes of the given asset.
pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> { pub fn get_asset_bytes(asset: Asset) -> Result<Cow<'static, [u8]>, AssetError> {
get_platform().get_asset_bytes(asset) get_platform().get_asset_bytes(asset)
} }

44
src/ecs/interact.rs Normal file
View File

@@ -0,0 +1,44 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
query::With,
system::{Query, ResMut},
};
use crate::{
ecs::{GlobalState, PlayerControlled, Velocity},
error::GameError,
game::events::GameEvent,
input::commands::GameCommand,
};
// Handles
pub fn interact_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut players: Query<(&PlayerControlled, &mut Velocity)>,
mut errors: EventWriter<GameError>,
) {
// Get the player's velocity (handling to ensure there is only one player)
let mut velocity = match players.single_mut() {
Ok((_, velocity)) => velocity,
Err(e) => {
errors.write(GameError::InvalidState(format!("Player not found: {}", e)).into());
return;
}
};
// Handle events
for event in events.read() {
match event {
GameEvent::Command(command) => match command {
GameCommand::MovePlayer(direction) => {
velocity.direction = *direction;
}
GameCommand::Exit => {
state.exit = true;
}
_ => {}
},
}
}
}

149
src/ecs/mod.rs Normal file
View File

@@ -0,0 +1,149 @@
//! The Entity-Component-System (ECS) module.
//!
//! This module contains all the ECS-related logic, including components, systems,
//! and resources.
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use glam::Vec2;
use crate::{
entity::{direction::Direction, graph::Graph, traversal},
error::{EntityError, GameResult},
texture::{
animated::AnimatedTexture,
directional::DirectionalAnimatedTexture,
sprite::{AtlasTile, Sprite},
},
};
/// A tag component for entities that are controlled by the player.
#[derive(Default, Component)]
pub struct PlayerControlled;
/// A component for entities that have a sprite, with a layer for ordering.
///
/// This is intended to be modified by other entities allowing animation.
#[derive(Component)]
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
}
/// A component for entities that have a directional animated texture.
#[derive(Component)]
pub struct DirectionalAnimated {
pub textures: [Option<AnimatedTexture>; 4],
pub stopped_textures: [Option<AnimatedTexture>; 4],
}
/// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize;
/// 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(Component, Debug, Copy, Clone, PartialEq)]
pub enum Position {
/// The traverser is located exactly at a node.
AtNode(NodeId),
/// The traverser is on an edge between two nodes.
BetweenNodes {
from: NodeId,
to: NodeId,
/// The floating-point distance traversed along the edge from the `from` node.
traversed: f32,
},
}
impl Position {
/// 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.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self {
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: *from, to: *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,
))
}
}
impl Default for Position {
fn default() -> Self {
Position::AtNode(0)
}
}
#[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(_))
}
}
/// A component for entities that have a velocity, with a direction and speed.
#[derive(Default, Component)]
pub struct Velocity {
pub direction: Direction,
pub speed: Option<f32>,
}
#[derive(Bundle)]
pub struct PlayerBundle {
pub player: PlayerControlled,
pub position: Position,
pub velocity: Velocity,
pub sprite: Renderable,
pub directional_animated: DirectionalAnimated,
}
#[derive(Resource)]
pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct DeltaTime(pub f32);
pub mod interact;
pub mod render;

95
src/ecs/render.rs Normal file
View File

@@ -0,0 +1,95 @@
use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity};
use crate::error::{EntityError, GameError, TextureError};
use crate::map::builder::Map;
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Query, Res};
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
/// Updates the directional animated texture of an entity.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable)>,
mut errors: EventWriter<GameError>,
) {
for (velocity, mut texture, mut renderable) in renderables.iter_mut() {
let texture = if velocity.speed.is_none() {
texture.stopped_textures[velocity.direction.as_usize()].as_mut()
} else {
texture.textures[velocity.direction.as_usize()].as_mut()
};
if let Some(texture) = texture {
texture.tick(dt.0);
renderable.sprite = *texture.current_tile();
} else {
errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into());
continue;
}
}
}
pub struct MapTextureResource(pub Texture<'static>);
pub struct BackbufferResource(pub Texture<'static>);
pub fn render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
map_texture: NonSendMut<MapTextureResource>,
mut backbuffer: NonSendMut<BackbufferResource>,
mut atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>,
mut renderables: Query<(Entity, &mut Renderable, &Position)>,
mut errors: EventWriter<GameError>,
) {
// Clear the main canvas first
canvas.set_draw_color(sdl2::pixels::Color::BLACK);
canvas.clear();
// Render to backbuffer
canvas
.with_texture_canvas(&mut backbuffer.0, |backbuffer_canvas| {
// Clear the backbuffer
backbuffer_canvas.set_draw_color(sdl2::pixels::Color::BLACK);
backbuffer_canvas.clear();
// Copy the pre-rendered map texture to the backbuffer
backbuffer_canvas
.copy(&map_texture.0, None, None)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
// Render all entities to the backbuffer
for (_, mut renderable, position) in renderables.iter_mut() {
let pos = position.get_pixel_pos(&map.graph);
match pos {
Ok(pos) => {
let dest = crate::helpers::centered_with_size(
glam::IVec2::new(pos.x as i32, pos.y as i32),
glam::UVec2::new(renderable.sprite.size.x as u32, renderable.sprite.size.y as u32),
);
renderable
.sprite
.render(backbuffer_canvas, &mut atlas, dest)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
}
Err(e) => {
errors.write(e.into());
}
}
}
})
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
// Copy backbuffer to main canvas and present
canvas
.copy(&backbuffer.0, None, None)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.present();
}

View File

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

View File

@@ -1,11 +1,13 @@
use glam::IVec2; use glam::IVec2;
/// The four cardinal directions. /// The four cardinal directions.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(usize)]
pub enum Direction { pub enum Direction {
Up, Up,
Down, Down,
Left, Left,
#[default]
Right, Right,
} }

View File

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

View File

@@ -1,9 +1,8 @@
use glam::Vec2; use glam::Vec2;
use super::direction::Direction; use crate::ecs::NodeId;
/// A unique identifier for a node, represented by its index in the graph's storage. use super::direction::Direction;
pub type NodeId = usize;
/// Defines who can traverse a given edge. /// Defines who can traverse a given edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]

View File

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

View File

@@ -1,134 +1,115 @@
//! Pac-Man entity implementation. // //! Pac-Man entity implementation.
//! // //!
//! This module contains the main player character logic, including movement, // //! This module contains the main player character logic, including movement,
//! 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::{ // use crate::entity::{
collision::Collidable, // collision::Collidable,
direction::Direction, // direction::Direction,
graph::{Edge, EdgePermissions, Graph, NodeId}, // graph::{Edge, EdgePermissions, Graph, NodeId},
r#trait::Entity, // r#trait::Entity,
traversal::Traverser, // traversal::Traverser,
}; // };
use crate::texture::animated::AnimatedTexture; // use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture; // use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::SpriteAtlas; // use crate::texture::sprite::SpriteAtlas;
use sdl2::keyboard::Keycode; // use tracing::error;
use tracing::error;
use crate::error::{GameError, GameResult, TextureError}; // use crate::error::{GameError, GameResult, TextureError};
/// Determines if Pac-Man can traverse a given edge. // /// Determines if Pac-Man can traverse a given edge.
/// // ///
/// Pac-Man can only move through edges that allow all entities. // /// Pac-Man can only move through edges that allow all entities.
fn can_pacman_traverse(edge: Edge) -> bool { // fn can_pacman_traverse(edge: Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All) // matches!(edge.permissions, EdgePermissions::All)
} // }
/// The main player character entity. // /// The main player character entity.
/// // ///
/// Pac-Man moves through the game world using a graph-based navigation system // /// Pac-Man moves through the game world using a graph-based navigation system
/// and displays directional animated sprites based on movement state. // /// and displays directional animated sprites based on movement state.
pub struct Pacman { // pub struct Pacman {
/// Handles movement through the game graph // /// Handles movement through the game graph
pub traverser: Traverser, // pub traverser: Traverser,
/// Manages directional animated textures for different movement states // /// Manages directional animated textures for different movement states
texture: DirectionalAnimatedTexture, // texture: DirectionalAnimatedTexture,
} // }
impl Entity for Pacman { // impl Entity for Pacman {
fn traverser(&self) -> &Traverser { // fn traverser(&self) -> &Traverser {
&self.traverser // &self.traverser
} // }
fn traverser_mut(&mut self) -> &mut Traverser { // fn traverser_mut(&mut self) -> &mut Traverser {
&mut self.traverser // &mut self.traverser
} // }
fn texture(&self) -> &DirectionalAnimatedTexture { // fn texture(&self) -> &DirectionalAnimatedTexture {
&self.texture // &self.texture
} // }
fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture { // fn texture_mut(&mut self) -> &mut DirectionalAnimatedTexture {
&mut self.texture // &mut self.texture
} // }
fn speed(&self) -> f32 { // fn speed(&self) -> f32 {
1.125 // 1.125
} // }
fn can_traverse(&self, edge: Edge) -> bool { // fn can_traverse(&self, edge: Edge) -> bool {
can_pacman_traverse(edge) // can_pacman_traverse(edge)
} // }
fn tick(&mut self, dt: f32, graph: &Graph) { // fn tick(&mut self, dt: f32, graph: &Graph) {
if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) { // if let Err(e) = self.traverser.advance(graph, dt * 60.0 * 1.125, &can_pacman_traverse) {
error!("Pac-Man movement error: {}", e); // error!("Pac-Man movement error: {}", e);
} // }
self.texture.tick(dt); // self.texture.tick(dt);
} // }
} // }
impl Pacman { // impl Pacman {
/// Creates a new Pac-Man instance at the specified starting node. // /// Creates a new Pac-Man instance at the specified starting node.
/// // ///
/// Sets up animated textures for all four directions with moving and stopped states. // /// Sets up animated textures for all four directions with moving and stopped states.
/// The moving animation cycles through open mouth, closed mouth, and full sprites. // /// The moving animation cycles through open mouth, closed mouth, and full sprites.
pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> { // pub fn new(graph: &Graph, start_node: NodeId, atlas: &SpriteAtlas) -> GameResult<Self> {
let mut textures = [None, None, None, None]; // let mut textures = [None, None, None, None];
let mut stopped_textures = [None, None, None, None]; // let mut stopped_textures = [None, None, None, None];
for direction in Direction::DIRECTIONS { // for direction in Direction::DIRECTIONS {
let moving_prefix = match direction { // let moving_prefix = match direction {
Direction::Up => "pacman/up", // Direction::Up => "pacman/up",
Direction::Down => "pacman/down", // Direction::Down => "pacman/down",
Direction::Left => "pacman/left", // Direction::Left => "pacman/left",
Direction::Right => "pacman/right", // Direction::Right => "pacman/right",
}; // };
let moving_tiles = vec![ // let moving_tiles = vec![
SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png")) // SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_a.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(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")) // SpriteAtlas::get_tile(atlas, &format!("{moving_prefix}_b.png"))
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?, // .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
SpriteAtlas::get_tile(atlas, "pacman/full.png") // SpriteAtlas::get_tile(atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, // .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")) // 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"))))?]; // .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?];
textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?); // textures[direction.as_usize()] = Some(AnimatedTexture::new(moving_tiles, 0.08)?);
stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?); // stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
} // }
Ok(Self { // Ok(Self {
traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse), // traverser: Traverser::new(graph, start_node, Direction::Left, &can_pacman_traverse),
texture: DirectionalAnimatedTexture::new(textures, stopped_textures), // texture: DirectionalAnimatedTexture::new(textures, stopped_textures),
}) // })
} // }
// }
/// Handles keyboard input to change Pac-Man's direction. // impl Collidable for Pacman {
/// // fn position(&self) -> crate::entity::traversal::Position {
/// Maps arrow keys to directions and queues the direction change // self.traverser.position
/// for the next valid intersection. // }
pub fn handle_key(&mut self, keycode: Keycode) { // }
let direction = match keycode {
Keycode::Up => Some(Direction::Up),
Keycode::Down => Some(Direction::Down),
Keycode::Left => Some(Direction::Left),
Keycode::Right => Some(Direction::Right),
_ => None,
};
if let Some(direction) = direction {
self.traverser.set_next_direction(direction);
}
}
}
impl Collidable for Pacman {
fn position(&self) -> crate::entity::traversal::Position {
self.traverser.position
}
}

View File

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

View File

@@ -1,229 +1,181 @@
use tracing::error; // use tracing::error;
use crate::error::GameResult; // use crate::error::GameResult;
use super::direction::Direction; // use super::direction::Direction;
use super::graph::{Edge, Graph, NodeId}; // use super::graph::{Edge, Graph, NodeId};
/// Represents the current position of an entity traversing the graph. // /// Manages an entity's movement through the graph.
/// // ///
/// This enum allows for precise tracking of whether an entity is exactly at a node // /// A `Traverser` encapsulates the state of an entity's position and direction,
/// or moving along an edge between two nodes. // /// providing a way to advance along the graph's paths based on a given distance.
#[derive(Debug, PartialEq, Clone, Copy)] // /// It also handles direction changes, buffering the next intended direction.
pub enum Position { // pub struct Traverser {
/// The traverser is located exactly at a node. // /// The current position of the traverser in the graph.
AtNode(NodeId), // pub position: Position,
/// The traverser is on an edge between two nodes. // /// The current direction of movement.
BetweenNodes { // pub direction: Direction,
from: NodeId, // /// Buffered direction change with remaining frame count for timing.
to: NodeId, // ///
/// The floating-point distance traversed along the edge from the `from` node. // /// The `u8` value represents the number of frames remaining before
traversed: f32, // /// the buffered direction expires. This allows for responsive controls
}, // /// by storing direction changes for a limited time.
} // pub next_direction: Option<(Direction, u8)>,
// }
#[allow(dead_code)] // impl Traverser {
impl Position { // /// Creates a new traverser starting at the given node ID.
/// Returns `true` if the position is exactly at a node. // ///
pub fn is_at_node(&self) -> bool { // /// The traverser will immediately attempt to start moving in the initial direction.
matches!(self, Position::AtNode(_)) // 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)),
// };
/// Returns the `NodeId` of the current or most recently departed node. // // This will kickstart the traverser into motion
#[allow(clippy::wrong_self_convention)] // if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
pub fn from_node_id(&self) -> NodeId { // error!("Traverser initialization error: {}", e);
match self { // }
Position::AtNode(id) => *id,
Position::BetweenNodes { from, .. } => *from,
}
}
/// Returns the `NodeId` of the destination node, if currently on an edge. // traverser
#[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. // /// Sets the next direction for the traverser to take.
pub fn is_stopped(&self) -> bool { // ///
matches!(self, Position::AtNode(_)) // /// 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));
// }
// }
/// Manages an entity's movement through the graph. // /// Advances the traverser along the graph by a specified distance.
/// // ///
/// A `Traverser` encapsulates the state of an entity's position and direction, // /// This method updates the traverser's position based on its current state
/// providing a way to advance along the graph's paths based on a given distance. // /// and the distance to travel.
/// It also handles direction changes, buffering the next intended direction. // ///
pub struct Traverser { // /// - If at a node, it checks for a buffered direction to start moving.
/// The current position of the traverser in the graph. // /// - If between nodes, it moves along the current edge.
pub position: Position, // /// - If it reaches a node, it attempts to transition to a new edge based on
/// The current direction of movement. // /// the buffered direction or by continuing straight.
pub direction: Direction, // /// - If no valid move is possible, it stops at the node.
/// Buffered direction change with remaining frame count for timing. // ///
/// // /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
/// The `u8` value represents the number of frames remaining before // pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
/// the buffered direction expires. This allows for responsive controls // where
/// by storing direction changes for a limited time. // F: Fn(Edge) -> bool,
pub next_direction: Option<(Direction, u8)>, // {
} // // 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;
// }
// }
impl Traverser { // match self.position {
/// Creates a new traverser starting at the given node ID. // Position::AtNode(node_id) => {
/// // // We're not moving, but a buffered direction is available.
/// The traverser will immediately attempt to start moving in the initial direction. // if let Some((next_direction, _)) = self.next_direction {
pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self // if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
where // if can_traverse(edge) {
F: Fn(Edge) -> bool, // // Start moving in that direction
{ // self.position = Position::BetweenNodes {
let mut traverser = Traverser { // from: node_id,
position: Position::AtNode(start_node), // to: edge.target,
direction: initial_direction, // traversed: distance.max(0.0),
next_direction: Some((initial_direction, 1)), // };
}; // 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),
// )));
// }
// This will kickstart the traverser into motion // self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
if let Err(e) = traverser.advance(graph, 0.0, can_traverse) { // }
error!("Traverser initialization error: {}", e); // }
} // 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(());
// }
traverser // 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
// )))
// })?;
/// Sets the next direction for the traverser to take. // let new_traversed = traversed + distance;
///
/// 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. // if new_traversed < edge.distance {
/// // // Still on the same edge, just update the distance.
/// This method updates the traverser's position based on its current state // self.position = Position::BetweenNodes {
/// and the distance to travel. // from,
/// // to,
/// - If at a node, it checks for a buffered direction to start moving. // traversed: new_traversed,
/// - If between nodes, it moves along the current edge. // };
/// - If it reaches a node, it attempts to transition to a new edge based on // } else {
/// the buffered direction or by continuing straight. // let overflow = new_traversed - edge.distance;
/// - If no valid move is possible, it stops at the node. // let mut moved = false;
///
/// 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 { // // If we buffered a direction, try to find an edge in that direction
Position::AtNode(node_id) => { // if let Some((next_dir, _)) = self.next_direction {
// We're not moving, but a buffered direction is available. // if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
if let Some((next_direction, _)) = self.next_direction { // if can_traverse(edge) {
if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) { // self.position = Position::BetweenNodes {
if can_traverse(edge) { // from: to,
// Start moving in that direction // to: edge.target,
self.position = Position::BetweenNodes { // traversed: overflow,
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 // self.direction = next_dir; // Remember our new direction
} // self.next_direction = None; // Consume the buffered direction
} // moved = true;
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(|| { // // If we didn't move, try to continue in the current direction
crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!( // if !moved {
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.", // if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
from, to // 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;
// }
// }
// }
// }
// }
let new_traversed = traversed + distance; // Ok(())
// }
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(())
}
}

View File

@@ -5,11 +5,13 @@
use std::io; use std::io;
use bevy_ecs::event::Event;
/// Main error type for the Pac-Man game. /// Main error type for the Pac-Man game.
/// ///
/// This is the primary error type that should be used in public APIs. /// This is the primary error type that should be used in public APIs.
/// It can represent any error that can occur during game operation. /// It can represent any error that can occur during game operation.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug, Event)]
pub enum GameError { pub enum GameError {
#[error("Asset error: {0}")] #[error("Asset error: {0}")]
Asset(#[from] AssetError), Asset(#[from] AssetError),

14
src/game/events.rs Normal file
View File

@@ -0,0 +1,14 @@
use bevy_ecs::event::Event;
use crate::input::commands::GameCommand;
#[derive(Debug, Clone, Copy, Event)]
pub enum GameEvent {
Command(GameCommand),
}
impl From<GameCommand> for GameEvent {
fn from(command: GameCommand) -> Self {
GameEvent::Command(command)
}
}

View File

@@ -1,327 +1,515 @@
//! This module contains the main game logic and state. //! This module contains the main game logic and state.
use glam::{UVec2, Vec2}; include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
use rand::{rngs::SmallRng, Rng, SeedableRng};
use sdl2::{ use crate::constants::CANVAS_SIZE;
keyboard::Keycode, use crate::ecs::interact::interact_system;
pixels::Color, use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource};
render::{Canvas, RenderTarget, Texture, TextureCreator}, use crate::ecs::{DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity};
video::WindowContext, use crate::entity::direction::Direction;
use crate::entity::{graph, traversal};
use crate::error::{GameError, GameResult, TextureError};
use crate::input::commands::GameCommand;
use crate::map::builder::Map;
use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::Sprite;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::system::{Commands, ResMut};
use bevy_ecs::{schedule::Schedule, world::World};
use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
use crate::asset::{get_asset_bytes, Asset};
use crate::input::{handle_input, Bindings};
use crate::map::render::MapRenderer;
use crate::{
constants,
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
}; };
use crate::error::{EntityError, GameError, GameResult}; use self::events::GameEvent;
use crate::entity::{
collision::{Collidable, CollisionSystem, EntityId},
ghost::{Ghost, GhostType},
pacman::Pacman,
r#trait::Entity,
};
pub mod events;
pub mod state; pub mod state;
use state::GameState;
/// The `Game` struct is the main entry point for the game. /// The `Game` struct is the main entry point for the game.
/// ///
/// It contains the game's state and logic, and is responsible for /// It contains the game's state and logic, and is responsible for
/// handling user input, updating the game state, and rendering the game. /// handling user input, updating the game state, and rendering the game.
pub struct Game { pub struct Game {
state: GameState, pub world: World,
pub schedule: Schedule,
} }
impl Game { impl Game {
pub fn new(texture_creator: &'static TextureCreator<WindowContext>) -> GameResult<Game> { pub fn new(
let state = GameState::new(texture_creator)?; canvas: &'static mut Canvas<Window>,
texture_creator: &'static mut TextureCreator<WindowContext>,
event_pump: &'static mut EventPump,
) -> GameResult<Game> {
let mut world = World::default();
let mut schedule = Schedule::default();
Ok(Game { state }) EventRegistry::register_event::<GameError>(&mut world);
} EventRegistry::register_event::<GameEvent>(&mut world);
pub fn keyboard_event(&mut self, keycode: Keycode) { let mut backbuffer = texture_creator
self.state.pacman.handle_key(keycode); .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
.map_err(|e| GameError::Sdl(e.to_string()))?;
backbuffer.set_scale_mode(ScaleMode::Nearest);
if keycode == Keycode::M { let mut map_texture = texture_creator
self.state.audio.set_mute(!self.state.audio.is_muted()); .create_texture_target(None, CANVAS_SIZE.x, CANVAS_SIZE.y)
} .map_err(|e| GameError::Sdl(e.to_string()))?;
map_texture.set_scale_mode(ScaleMode::Nearest);
if keycode == Keycode::R { // Load atlas and create map texture
if let Err(e) = self.reset_game_state() { let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
tracing::error!("Failed to reset game state: {}", e); let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
if e.to_string().contains("format") || e.to_string().contains("unsupported") {
GameError::Texture(crate::error::TextureError::InvalidFormat(format!(
"Unsupported texture format: {e}"
)))
} else {
GameError::Texture(crate::error::TextureError::LoadFailed(e.to_string()))
} }
} })?;
}
/// Resets the game state, randomizing ghost positions and resetting Pac-Man let atlas_mapper = AtlasMapper {
fn reset_game_state(&mut self) -> GameResult<()> { frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(),
let pacman_start_node = self.state.map.start_positions.pacman; };
self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?; let mut atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
// Reset items // Create map tiles
self.state.items = self.state.map.generate_items(&self.state.atlas)?; let mut map_tiles = Vec::with_capacity(35);
for i in 0..35 {
// Randomize ghost positions let tile_name = format!("maze/tiles/{}.png", i);
let ghost_types = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]; let tile = atlas.get_tile(&tile_name).unwrap();
let mut rng = SmallRng::from_os_rng(); map_tiles.push(tile);
for (i, ghost) in self.state.ghosts.iter_mut().enumerate() {
let random_node = rng.random_range(0..self.state.map.graph.node_count());
*ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?;
} }
// Reset collision system // Render map to texture
self.state.collision_system = CollisionSystem::default();
// Re-register Pac-Man
self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position());
// Re-register items
self.state.item_ids.clear();
for item in &self.state.items {
let item_id = self.state.collision_system.register_entity(item.position());
self.state.item_ids.push(item_id);
}
// Re-register ghosts
self.state.ghost_ids.clear();
for ghost in &self.state.ghosts {
let ghost_id = self.state.collision_system.register_entity(ghost.position());
self.state.ghost_ids.push(ghost_id);
}
Ok(())
}
pub fn tick(&mut self, dt: f32) {
self.state.pacman.tick(dt, &self.state.map.graph);
// Update all ghosts
for ghost in &mut self.state.ghosts {
ghost.tick(dt, &self.state.map.graph);
}
// Update collision system positions
self.update_collision_positions();
// Check for collisions
self.check_collisions();
}
/// Toggles the debug mode on and off.
///
/// When debug mode is enabled, the game will render additional information
/// that is useful for debugging, such as the collision grid and entity paths.
pub fn toggle_debug_mode(&mut self) {
self.state.debug_mode = !self.state.debug_mode;
}
fn update_collision_positions(&mut self) {
// Update Pac-Man's position
self.state
.collision_system
.update_position(self.state.pacman_id, self.state.pacman.position());
// Update ghost positions
for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) {
self.state.collision_system.update_position(ghost_id, ghost.position());
}
}
fn check_collisions(&mut self) {
// Check Pac-Man vs Items
let potential_collisions = self
.state
.collision_system
.potential_collisions(&self.state.pacman.position());
for entity_id in potential_collisions {
if entity_id != self.state.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.state.items[item_index];
if !item.is_collected() {
item.collect();
self.state.score += item.get_score();
self.state.audio.eat();
// 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.state.item_ids.iter().position(|&id| id == entity_id)
}
fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
self.state.ghost_ids.iter().position(|&id| id == entity_id)
}
pub fn draw<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
canvas canvas
.with_texture_canvas(backbuffer, |canvas| { .with_texture_canvas(&mut map_texture, |map_canvas| {
canvas.set_draw_color(Color::BLACK); MapRenderer::render_map(map_canvas, &mut atlas, &mut map_tiles);
canvas.clear();
self.state
.map
.render(canvas, &mut self.state.atlas, &mut self.state.map_tiles);
// Render all items
for item in &self.state.items {
if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render item: {}", e);
}
}
// Render all ghosts
for ghost in &self.state.ghosts {
if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render ghost: {}", e);
}
}
if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
tracing::error!("Failed to render pacman: {}", e);
}
}) })
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(()) let map = Map::new(constants::RAW_BOARD)?;
} let pacman_start_node = map.start_positions.pacman;
pub fn present_backbuffer<T: RenderTarget>( let mut textures = [None, None, None, None];
&mut self, let mut stopped_textures = [None, None, None, None];
canvas: &mut Canvas<T>,
backbuffer: &Texture, for direction in Direction::DIRECTIONS {
cursor_pos: glam::Vec2, let moving_prefix = match direction {
) -> GameResult<()> { Direction::Up => "pacman/up",
canvas Direction::Down => "pacman/down",
.copy(backbuffer, None, None) Direction::Left => "pacman/left",
.map_err(|e| GameError::Sdl(e.to_string()))?; Direction::Right => "pacman/right",
if self.state.debug_mode { };
if let Err(e) = let moving_tiles = vec![
self.state SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_a.png"))
.map .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_a.png"))))?,
.debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos) SpriteAtlas::get_tile(&atlas, &format!("{moving_prefix}_b.png"))
{ .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound(format!("{moving_prefix}_b.png"))))?,
tracing::error!("Failed to render debug cursor: {}", e); SpriteAtlas::get_tile(&atlas, "pacman/full.png")
} .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
self.render_pathfinding_debug(canvas)?; ];
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)?);
stopped_textures[direction.as_usize()] = Some(AnimatedTexture::new(stopped_tiles, 0.1)?);
} }
self.draw_hud(canvas)?;
canvas.present(); let player = PlayerBundle {
Ok(()) player: PlayerControlled,
position: Position::AtNode(pacman_start_node),
velocity: Velocity::default(),
sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
layer: 0,
},
directional_animated: DirectionalAnimated {
textures,
stopped_textures,
},
};
world.insert_non_send_resource(atlas);
world.insert_non_send_resource(event_pump);
world.insert_non_send_resource(canvas);
world.insert_non_send_resource(BackbufferResource(backbuffer));
world.insert_non_send_resource(MapTextureResource(map_texture));
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32));
world.add_observer(|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>| match *event {
GameEvent::Command(command) => match command {
GameCommand::Exit => {
state.exit = true;
}
_ => {}
},
});
schedule.add_systems((handle_input, interact_system, directional_render_system, render_system).chain());
// Spawn player
world.spawn(player);
Ok(Game { world, schedule })
} }
/// Renders pathfinding debug lines from each ghost to Pac-Man. // fn handle_command(&mut self, command: crate::input::commands::GameCommand) {
// use crate::input::commands::GameCommand;
// match command {
// GameCommand::MovePlayer(direction) => {
// self.state.pacman.set_next_direction(direction);
// }
// GameCommand::ToggleDebug => {
// self.toggle_debug_mode();
// }
// GameCommand::MuteAudio => {
// let is_muted = self.state.audio.is_muted();
// self.state.audio.set_mute(!is_muted);
// }
// GameCommand::ResetLevel => {
// if let Err(e) = self.reset_game_state() {
// tracing::error!("Failed to reset game state: {}", e);
// }
// }
// GameCommand::TogglePause => {
// self.state.paused = !self.state.paused;
// }
// GameCommand::Exit => {}
// }
// }
// fn process_events(&mut self) {
// while let Some(event) = self.state.event_queue.pop_front() {
// match event {
// GameEvent::Command(command) => self.handle_command(command),
// }
// }
// }
// /// Resets the game state, randomizing ghost positions and resetting Pac-Man
// fn reset_game_state(&mut self) -> GameResult<()> {
// let pacman_start_node = self.state.map.start_positions.pacman;
// self.state.pacman = Pacman::new(&self.state.map.graph, pacman_start_node, &self.state.atlas)?;
// // Reset items
// self.state.items = self.state.map.generate_items(&self.state.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.state.ghosts.iter_mut().enumerate() {
// let random_node = rng.random_range(0..self.state.map.graph.node_count());
// *ghost = Ghost::new(&self.state.map.graph, random_node, ghost_types[i], &self.state.atlas)?;
// }
// // Reset collision system
// self.state.collision_system = CollisionSystem::default();
// // Re-register Pac-Man
// self.state.pacman_id = self.state.collision_system.register_entity(self.state.pacman.position());
// // Re-register items
// self.state.item_ids.clear();
// for item in &self.state.items {
// let item_id = self.state.collision_system.register_entity(item.position());
// self.state.item_ids.push(item_id);
// }
// // Re-register ghosts
// self.state.ghost_ids.clear();
// for ghost in &self.state.ghosts {
// let ghost_id = self.state.collision_system.register_entity(ghost.position());
// self.state.ghost_ids.push(ghost_id);
// }
// Ok(())
// }
/// Ticks the game state.
/// ///
/// Each ghost's path is drawn in its respective color with a small offset /// Returns true if the game should exit.
/// to prevent overlapping lines. pub fn tick(&mut self, dt: f32) -> bool {
fn render_pathfinding_debug<T: RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> { self.world.insert_resource(DeltaTime(dt));
let pacman_node = self.state.pacman.current_node_id();
for ghost in self.state.ghosts.iter() { // Run all systems
if let Ok(path) = ghost.calculate_path_to_target(&self.state.map.graph, pacman_node) { self.schedule.run(&mut self.world);
if path.len() < 2 {
continue; // Skip if path is too short
}
// Set the ghost's color let state = self
canvas.set_draw_color(ghost.debug_color()); .world
.get_resource::<GlobalState>()
.expect("GlobalState could not be acquired");
// Calculate offset based on ghost index to prevent overlapping lines return state.exit;
// 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 // // Process any events that have been posted (such as unpausing)
// let first_node = self.map.graph.get_node(path[0]).unwrap(); // self.process_events();
// 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 // // If the game is paused, we don't need to do anything beyond returning
let offset = match ghost.ghost_type { // if self.state.paused {
GhostType::Blinky => Vec2::new(0.25, 0.5), // return false;
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 // self.schedule.run(&mut self.world);
let mut offset_positions = Vec::new();
for &node_id in &path {
let node = self
.state
.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 // self.state.pacman.tick(dt, &self.state.map.graph);
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) > (crate::constants::CELL_SIZE * 16).pow(2) as f32 {
continue;
}
// Draw the line // // Update all ghosts
canvas // for ghost in &mut self.state.ghosts {
.draw_line((from.x as i32, from.y as i32), (to.x as i32, to.y as i32)) // ghost.tick(dt, &self.state.map.graph);
.map_err(|e| GameError::Sdl(e.to_string()))?; // }
}
}
}
}
Ok(()) // // Update collision system positions
// self.update_collision_positions();
// // Check for collisions
// self.check_collisions();
} }
fn draw_hud<T: RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> { // /// Toggles the debug mode on and off.
let lives = 3; // ///
let score_text = format!("{:02}", self.state.score); // /// When debug mode is enabled, the game will render additional information
let x_offset = 4; // /// that is useful for debugging, such as the collision grid and entity paths.
let y_offset = 2; // pub fn toggle_debug_mode(&mut self) {
let lives_offset = 3; // self.state.debug_mode = !self.state.debug_mode;
let score_offset = 7 - (score_text.len() as i32); // }
self.state.text_texture.set_scale(1.0);
if let Err(e) = self.state.text_texture.render(
canvas,
&mut self.state.atlas,
&format!("{lives}UP HIGH SCORE "),
UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
) {
tracing::error!("Failed to render HUD text: {}", e);
}
if let Err(e) = self.state.text_texture.render(
canvas,
&mut self.state.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 // fn update_collision_positions(&mut self) {
// let fps_text = format!("FPS: {:.1} (1s) / {:.1} (10s)", self.fps_1s, self.fps_10s); // // Update Pac-Man's position
// self.render_text_on( // self.state
// canvas, // .collision_system
// &*texture_creator, // .update_position(self.state.pacman_id, self.state.pacman.position());
// &fps_text,
// IVec2::new(10, 10),
// Color::RGB(255, 255, 0), // Yellow color for FPS display
// );
Ok(()) // // Update ghost positions
} // for (ghost, &ghost_id) in self.state.ghosts.iter().zip(&self.state.ghost_ids) {
// self.state.collision_system.update_position(ghost_id, ghost.position());
// }
// }
// fn check_collisions(&mut self) {
// // Check Pac-Man vs Items
// let potential_collisions = self
// .state
// .collision_system
// .potential_collisions(&self.state.pacman.position());
// for entity_id in potential_collisions {
// if entity_id != self.state.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.state.items[item_index];
// if !item.is_collected() {
// item.collect();
// self.state.score += item.get_score();
// self.state.audio.eat();
// // 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.state.item_ids.iter().position(|&id| id == entity_id)
// }
// fn find_ghost_by_id(&self, entity_id: EntityId) -> Option<usize> {
// self.state.ghost_ids.iter().position(|&id| id == entity_id)
// }
// pub fn draw<T: sdl2::render::RenderTarget>(&mut self, canvas: &mut Canvas<T>, backbuffer: &mut Texture) -> GameResult<()> {
// // Only render the map texture once and cache it
// if !self.state.map_rendered {
// let mut map_texture = self
// .state
// .texture_creator
// .create_texture_target(None, constants::CANVAS_SIZE.x, constants::CANVAS_SIZE.y)
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// canvas
// .with_texture_canvas(&mut map_texture, |map_canvas| {
// let mut map_tiles = Vec::with_capacity(35);
// for i in 0..35 {
// let tile_name = format!("maze/tiles/{}.png", i);
// let tile = SpriteAtlas::get_tile(&self.state.atlas, &tile_name).unwrap();
// map_tiles.push(tile);
// }
// MapRenderer::render_map(map_canvas, &mut self.state.atlas, &mut map_tiles);
// })
// .map_err(|e| crate::error::GameError::Sdl(e.to_string()))?;
// self.state.map_texture = Some(map_texture);
// self.state.map_rendered = true;
// }
// canvas.set_draw_color(Color::BLACK);
// canvas.clear();
// if let Some(ref map_texture) = self.state.map_texture {
// canvas.copy(map_texture, None, None).unwrap();
// }
// // Render all items
// for item in &self.state.items {
// if let Err(e) = item.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render item: {}", e);
// }
// }
// // Render all ghosts
// for ghost in &self.state.ghosts {
// if let Err(e) = ghost.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render ghost: {}", e);
// }
// }
// if let Err(e) = self.state.pacman.render(canvas, &mut self.state.atlas, &self.state.map.graph) {
// tracing::error!("Failed to render pacman: {}", e);
// }
// if self.state.debug_mode {
// if let Err(e) =
// self.state
// .map
// .debug_render_with_cursor(canvas, &mut self.state.text_texture, &mut self.state.atlas, cursor_pos)
// {
// tracing::error!("Failed to render debug cursor: {}", e);
// }
// self.render_pathfinding_debug(canvas)?;
// }
// self.draw_hud(canvas)?;
// canvas.present();
// Ok(())
// }
// /// 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: sdl2::render::RenderTarget>(&self, canvas: &mut Canvas<T>) -> GameResult<()> {
// let pacman_node = self.state.pacman.current_node_id();
// for ghost in self.state.ghosts.iter() {
// if let Ok(path) = ghost.calculate_path_to_target(&self.state.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 => glam::Vec2::new(0.25, 0.5),
// GhostType::Pinky => glam::Vec2::new(-0.25, -0.25),
// GhostType::Inky => glam::Vec2::new(0.5, -0.5),
// GhostType::Clyde => glam::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
// .state
// .map
// .graph
// .get_node(node_id)
// .ok_or(crate::error::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) > (crate::constants::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| crate::error::GameError::Sdl(e.to_string()))?;
// }
// }
// }
// }
// Ok(())
// }
// fn draw_hud<T: sdl2::render::RenderTarget>(&mut self, canvas: &mut Canvas<T>) -> GameResult<()> {
// let lives = 3;
// let score_text = format!("{:02}", self.state.score);
// let x_offset = 4;
// let y_offset = 2;
// let lives_offset = 3;
// let score_offset = 7 - (score_text.len() as i32);
// self.state.text_texture.set_scale(1.0);
// if let Err(e) = self.state.text_texture.render(
// canvas,
// &mut self.state.atlas,
// &format!("{lives}UP HIGH SCORE "),
// glam::UVec2::new(8 * lives_offset as u32 + x_offset, y_offset),
// ) {
// tracing::error!("Failed to render HUD text: {}", e);
// }
// if let Err(e) = self.state.text_texture.render(
// canvas,
// &mut self.state.atlas,
// &score_text,
// glam::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);
// // self.render_text_on(
// // canvas,
// // &*texture_creator,
// // &fps_text,
// // IVec2::new(10, 10),
// // Color::RGB(255, 255, 0), // Yellow color for FPS display
// // );
// Ok(())
// }
} }

View File

@@ -1,140 +1,153 @@
use sdl2::{image::LoadTexture, render::TextureCreator, video::WindowContext}; // use std::collections::VecDeque;
use smallvec::SmallVec;
use crate::{ // use sdl2::{
asset::{get_asset_bytes, Asset}, // image::LoadTexture,
audio::Audio, // render::{Texture, TextureCreator},
constants::RAW_BOARD, // video::WindowContext,
entity::{ // };
collision::{Collidable, CollisionSystem}, // use smallvec::SmallVec;
ghost::{Ghost, GhostType},
item::Item,
pacman::Pacman,
},
error::{GameError, GameResult, TextureError},
game::EntityId,
map::Map,
texture::{
sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
text::TextTexture,
},
};
/// The `GameState` struct holds all the essential data for the game. // use crate::{
/// // asset::{get_asset_bytes, Asset},
/// This includes the score, map, entities (Pac-Man, ghosts, items), // audio::Audio,
/// collision system, and rendering resources. By centralizing the game's state, // constants::RAW_BOARD,
/// we can cleanly separate it from the game's logic, making it easier to manage // entity::{
/// and reason about. // collision::{Collidable, CollisionSystem, EntityId},
pub struct GameState { // ghost::{Ghost, GhostType},
pub score: u32, // item::Item,
pub map: Map, // pacman::Pacman,
pub map_tiles: Vec<AtlasTile>, // },
pub pacman: Pacman, // error::{GameError, GameResult, TextureError},
pub pacman_id: EntityId, // game::events::GameEvent,
pub ghosts: SmallVec<[Ghost; 4]>, // map::builder::Map,
pub ghost_ids: SmallVec<[EntityId; 4]>, // texture::{
pub items: Vec<Item>, // sprite::{AtlasMapper, SpriteAtlas},
pub item_ids: Vec<EntityId>, // text::TextTexture,
pub debug_mode: bool, // },
// };
// Collision system // include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
pub(crate) collision_system: CollisionSystem,
// Rendering resources // /// The `GameState` struct holds all the essential data for the game.
pub(crate) atlas: SpriteAtlas, // ///
pub(crate) text_texture: TextTexture, // /// This includes the score, map, entities (Pac-Man, ghosts, items),
// /// collision system, and rendering resources. By centralizing the game's state,
// /// we can cleanly separate it from the game's logic, making it easier to manage
// /// and reason about.
// pub struct GameState {
// pub paused: bool,
// Audio // pub score: u32,
pub audio: Audio, // pub map: Map,
} // pub pacman: Pacman,
// pub pacman_id: EntityId,
// pub ghosts: SmallVec<[Ghost; 4]>,
// pub ghost_ids: SmallVec<[EntityId; 4]>,
// pub items: Vec<Item>,
// pub item_ids: Vec<EntityId>,
// pub debug_mode: bool,
// pub event_queue: VecDeque<GameEvent>,
impl GameState { // // Collision system
/// Creates a new `GameState` by initializing all the game's data. // pub(crate) collision_system: CollisionSystem,
///
/// This function sets up the map, Pac-Man, ghosts, items, collision system,
/// and all rendering resources required to start the game. It returns a `GameResult`
/// to handle any potential errors during initialization.
pub fn new(texture_creator: &'static TextureCreator<WindowContext>) -> GameResult<Self> {
let map = Map::new(RAW_BOARD)?;
let pacman_start_node = map.start_positions.pacman; // // Rendering resources
// pub(crate) atlas: SpriteAtlas,
// pub(crate) text_texture: TextTexture,
let atlas_bytes = get_asset_bytes(Asset::Atlas)?; // // Audio
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { // pub audio: Audio,
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()))
}
})?;
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_tiles = Vec::with_capacity(35); // // Map texture pre-rendering
for i in 0..35 { // pub(crate) map_texture: Option<Texture<'static>>,
let tile_name = format!("maze/tiles/{}.png", i); // pub(crate) map_rendered: bool,
let tile = SpriteAtlas::get_tile(&atlas, &tile_name) // pub(crate) texture_creator: &'static TextureCreator<WindowContext>,
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?; // }
map_tiles.push(tile);
}
let text_texture = TextTexture::new(1.0); // impl GameState {
let audio = Audio::new(); // /// Creates a new `GameState` by initializing all the game's data.
let pacman = Pacman::new(&map.graph, pacman_start_node, &atlas)?; // ///
// /// This function sets up the map, Pac-Man, ghosts, items, collision system,
// /// and all rendering resources required to start the game. It returns a `GameResult`
// /// to handle any potential errors during initialization.
// pub fn new(texture_creator: &'static TextureCreator<WindowContext>) -> GameResult<Self> {
// let map = Map::new(RAW_BOARD)?;
// Generate items (pellets and energizers) // let start_node = map.start_positions.pacman;
let items = map.generate_items(&atlas)?;
// Initialize collision system // let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let mut collision_system = CollisionSystem::default(); // let atlas_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()))
// }
// })?;
// Register Pac-Man // let atlas_mapper = AtlasMapper {
let pacman_id = collision_system.register_entity(pacman.position()); // frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(),
// };
// let atlas = SpriteAtlas::new(atlas_texture, atlas_mapper);
// Register items // let text_texture = TextTexture::new(1.0);
let item_ids = items // let audio = Audio::new();
.iter() // let pacman = Pacman::new(&map.graph, start_node, &atlas)?;
.map(|item| collision_system.register_entity(item.position()))
.collect();
// Create and register ghosts // // Generate items (pellets and energizers)
let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde] // let items = map.generate_items(&atlas)?;
.iter()
.zip(
[
map.start_positions.blinky,
map.start_positions.pinky,
map.start_positions.inky,
map.start_positions.clyde,
]
.iter(),
)
.map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas))
.collect::<GameResult<SmallVec<[_; 4]>>>()?;
// Register ghosts // // Initialize collision system
let ghost_ids = ghosts // let mut collision_system = CollisionSystem::default();
.iter()
.map(|ghost| collision_system.register_entity(ghost.position()))
.collect();
Ok(Self { // // Register Pac-Man
map, // let pacman_id = collision_system.register_entity(pacman.position());
atlas,
map_tiles, // // Register items
pacman, // let item_ids = items
pacman_id, // .iter()
ghosts, // .map(|item| collision_system.register_entity(item.position()))
ghost_ids, // .collect();
items,
item_ids, // // Create and register ghosts
text_texture, // let ghosts = [GhostType::Blinky, GhostType::Pinky, GhostType::Inky, GhostType::Clyde]
audio, // .iter()
score: 0, // .zip(
debug_mode: false, // [
collision_system, // map.start_positions.blinky,
}) // map.start_positions.pinky,
} // map.start_positions.inky,
} // map.start_positions.clyde,
// ]
// .iter(),
// )
// .map(|(ghost_type, start_node)| Ghost::new(&map.graph, *start_node, *ghost_type, &atlas))
// .collect::<GameResult<SmallVec<[_; 4]>>>()?;
// // Register ghosts
// let ghost_ids = ghosts
// .iter()
// .map(|ghost| collision_system.register_entity(ghost.position()))
// .collect();
// Ok(Self {
// paused: false,
// map,
// atlas,
// pacman,
// pacman_id,
// ghosts,
// ghost_ids,
// items,
// item_ids,
// text_texture,
// audio,
// score: 0,
// debug_mode: false,
// collision_system,
// map_texture: None,
// map_rendered: false,
// texture_creator,
// event_queue: VecDeque::new(),
// })
// }
// }

11
src/input/commands.rs Normal file
View File

@@ -0,0 +1,11 @@
use crate::entity::direction::Direction;
#[derive(Debug, Clone, Copy)]
pub enum GameCommand {
MovePlayer(Direction),
Exit,
TogglePause,
ToggleDebug,
MuteAudio,
ResetLevel,
}

61
src/input/mod.rs Normal file
View File

@@ -0,0 +1,61 @@
use std::collections::HashMap;
use bevy_ecs::{
event::EventWriter,
resource::Resource,
system::{Commands, NonSendMut, Res},
};
use sdl2::{event::Event, keyboard::Keycode, EventPump};
use crate::{entity::direction::Direction, game::events::GameEvent, input::commands::GameCommand};
pub mod commands;
#[derive(Debug, Clone, Resource)]
pub struct Bindings {
key_bindings: HashMap<Keycode, GameCommand>,
}
impl Default for Bindings {
fn default() -> Self {
let mut key_bindings = HashMap::new();
// Player movement
key_bindings.insert(Keycode::Up, GameCommand::MovePlayer(Direction::Up));
key_bindings.insert(Keycode::W, GameCommand::MovePlayer(Direction::Up));
key_bindings.insert(Keycode::Down, GameCommand::MovePlayer(Direction::Down));
key_bindings.insert(Keycode::S, GameCommand::MovePlayer(Direction::Down));
key_bindings.insert(Keycode::Left, GameCommand::MovePlayer(Direction::Left));
key_bindings.insert(Keycode::A, GameCommand::MovePlayer(Direction::Left));
key_bindings.insert(Keycode::Right, GameCommand::MovePlayer(Direction::Right));
key_bindings.insert(Keycode::D, GameCommand::MovePlayer(Direction::Right));
// Game actions
key_bindings.insert(Keycode::P, GameCommand::TogglePause);
key_bindings.insert(Keycode::Space, GameCommand::ToggleDebug);
key_bindings.insert(Keycode::M, GameCommand::MuteAudio);
key_bindings.insert(Keycode::R, GameCommand::ResetLevel);
key_bindings.insert(Keycode::Escape, GameCommand::Exit);
key_bindings.insert(Keycode::Q, GameCommand::Exit);
Self { key_bindings }
}
}
pub fn handle_input(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>, mut pump: NonSendMut<&'static mut EventPump>) {
for event in pump.poll_iter() {
match event {
Event::Quit { .. } => {
writer.write(GameEvent::Command(GameCommand::Exit));
}
Event::KeyDown { keycode: Some(key), .. } => {
let command = bindings.key_bindings.get(&key).copied();
if let Some(command) = command {
tracing::info!("triggering command: {:?}", command);
writer.write(GameEvent::Command(command));
}
}
_ => {}
}
}
}

View File

@@ -4,10 +4,12 @@ pub mod app;
pub mod asset; pub mod asset;
pub mod audio; pub mod audio;
pub mod constants; pub mod constants;
pub mod ecs;
pub mod entity; pub mod entity;
pub mod error; pub mod error;
pub mod game; pub mod game;
pub mod helpers; pub mod helpers;
pub mod input;
pub mod map; pub mod map;
pub mod platform; pub mod platform;
pub mod texture; pub mod texture;

View File

@@ -10,10 +10,12 @@ mod asset;
mod audio; mod audio;
mod constants; mod constants;
mod ecs;
mod entity; mod entity;
mod error; mod error;
mod game; mod game;
mod helpers; mod helpers;
mod input;
mod map; mod map;
mod platform; mod platform;
mod texture; mod texture;

View File

@@ -1,12 +1,13 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
use crate::ecs::NodeId;
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};
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, Sprite, SpriteAtlas}; use crate::texture::sprite::{Sprite, SpriteAtlas};
use bevy_ecs::resource::Resource;
use glam::{IVec2, Vec2}; use glam::{IVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};
use std::collections::{HashMap, VecDeque}; use std::collections::{HashMap, VecDeque};
@@ -24,6 +25,7 @@ pub struct NodePositions {
} }
/// The main map structure containing the game board and navigation graph. /// The main map structure containing the game board and navigation graph.
#[derive(Resource)]
pub struct Map { pub struct Map {
/// The node map for entity movement. /// The node map for entity movement.
pub graph: Graph, pub graph: Graph,
@@ -154,51 +156,43 @@ impl Map {
}) })
} }
/// Renders the map to the given canvas.
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_tiles: &mut [AtlasTile]) {
MapRenderer::render_map(canvas, atlas, map_tiles);
}
/// Generates Item entities for pellets and energizers from the parsed map. /// Generates Item entities for pellets and energizers from the parsed map.
pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> { // pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// Pre-load sprites to avoid repeated texture lookups // // Pre-load sprites to avoid repeated texture lookups
let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png") // let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
.ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?; // .ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png") // let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
.ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?; // .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) // // Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
let mut items = Vec::with_capacity(250); // let mut items = Vec::with_capacity(250);
// Parse the raw board once // // Parse the raw board once
let parsed_map = MapTileParser::parse_board(RAW_BOARD)?; // let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
let map = parsed_map.tiles; // let map = parsed_map.tiles;
// Iterate through the map and collect items more efficiently // // Iterate through the map and collect items more efficiently
for (x, row) in map.iter().enumerate() { // for (x, row) in map.iter().enumerate() {
for (y, tile) in row.iter().enumerate() { // for (y, tile) in row.iter().enumerate() {
match tile { // match tile {
MapTile::Pellet | MapTile::PowerPellet => { // MapTile::Pellet | MapTile::PowerPellet => {
let grid_pos = IVec2::new(x as i32, y as i32); // let grid_pos = IVec2::new(x as i32, y as i32);
if let Some(&node_id) = self.grid_to_node.get(&grid_pos) { // if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
let (item_type, sprite) = match tile { // let (item_type, sprite) = match tile {
MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)), // MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)), // MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
_ => unreachable!(), // We already filtered for these types // _ => unreachable!(), // We already filtered for these types
}; // };
items.push(Item::new(node_id, item_type, sprite)); // items.push(Item::new(node_id, item_type, sprite));
} // }
} // }
_ => {} // _ => {}
} // }
} // }
} // }
Ok(items) // Ok(items)
} // }
/// Renders a debug visualization with cursor-based highlighting. /// Renders a debug visualization with cursor-based highlighting.
/// ///

View File

@@ -4,6 +4,3 @@ pub mod builder;
pub mod layout; pub mod layout;
pub mod parser; pub mod parser;
pub mod render; pub mod render;
// Re-export main types for convenience
pub use builder::Map;

View File

@@ -11,8 +11,12 @@ use crate::platform::Platform;
pub struct DesktopPlatform; pub struct DesktopPlatform;
impl Platform for DesktopPlatform { impl Platform for DesktopPlatform {
fn sleep(&self, duration: Duration) { fn sleep(&self, duration: Duration, focused: bool) {
spin_sleep::sleep(duration); if focused {
spin_sleep::sleep(duration);
} else {
std::thread::sleep(duration);
}
} }
fn get_time(&self) -> f64 { fn get_time(&self) -> f64 {
@@ -72,7 +76,6 @@ impl Platform for DesktopPlatform {
Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))), Asset::Wav3 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/3.ogg"))),
Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))), Asset::Wav4 => Ok(Cow::Borrowed(include_bytes!("../../assets/game/sound/waka/4.ogg"))),
Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))), Asset::Atlas => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.png"))),
Asset::AtlasJson => Ok(Cow::Borrowed(include_bytes!("../../assets/game/atlas.json"))),
} }
} }
} }

View File

@@ -11,7 +11,7 @@ use crate::platform::Platform;
pub struct EmscriptenPlatform; pub struct EmscriptenPlatform;
impl Platform for EmscriptenPlatform { impl Platform for EmscriptenPlatform {
fn sleep(&self, duration: Duration) { fn sleep(&self, duration: Duration, _focused: bool) {
unsafe { unsafe {
emscripten_sleep(duration.as_millis() as u32); emscripten_sleep(duration.as_millis() as u32);
} }

View File

@@ -11,7 +11,7 @@ pub mod emscripten;
/// Platform abstraction trait that defines cross-platform functionality. /// Platform abstraction trait that defines cross-platform functionality.
pub trait Platform { pub trait Platform {
/// Sleep for the specified duration using platform-appropriate method. /// Sleep for the specified duration using platform-appropriate method.
fn sleep(&self, duration: Duration); fn sleep(&self, duration: Duration, focused: bool);
/// Get the current time in seconds since some reference point. /// Get the current time in seconds since some reference point.
/// This is available for future use in timing and performance monitoring. /// This is available for future use in timing and performance monitoring.

View File

@@ -1,4 +1,5 @@
use anyhow::Result; use anyhow::Result;
use bevy_ecs::resource::Resource;
use glam::U16Vec2; use glam::U16Vec2;
use sdl2::pixels::Color; use sdl2::pixels::Color;
use sdl2::rect::Rect; use sdl2::rect::Rect;

View File

@@ -2,7 +2,8 @@
use pacman::{ use pacman::{
asset::{get_asset_bytes, Asset}, asset::{get_asset_bytes, Asset},
texture::sprite::SpriteAtlas, game::state::ATLAS_FRAMES,
texture::sprite::{AtlasMapper, SpriteAtlas},
}; };
use sdl2::{ use sdl2::{
image::LoadTexture, image::LoadTexture,
@@ -28,12 +29,13 @@ pub fn setup_sdl() -> Result<(Canvas<Window>, TextureCreator<WindowContext>, Sdl
pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas { pub fn create_atlas(canvas: &mut sdl2::render::Canvas<sdl2::video::Window>) -> SpriteAtlas {
let texture_creator = canvas.texture_creator(); let texture_creator = canvas.texture_creator();
let atlas_bytes = get_asset_bytes(Asset::Atlas).unwrap(); let atlas_bytes = get_asset_bytes(Asset::Atlas).unwrap();
let atlas_json = get_asset_bytes(Asset::AtlasJson).unwrap();
let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap(); let texture = texture_creator.load_texture_bytes(&atlas_bytes).unwrap();
let texture: Texture<'static> = unsafe { std::mem::transmute(texture) }; let texture: Texture<'static> = unsafe { std::mem::transmute(texture) };
let mapper: pacman::texture::sprite::AtlasMapper = serde_json::from_slice(&atlas_json).unwrap(); let atlas_mapper = AtlasMapper {
frames: ATLAS_FRAMES.into_iter().map(|(k, v)| (k.to_string(), *v)).collect(),
};
SpriteAtlas::new(texture, mapper) SpriteAtlas::new(texture, atlas_mapper)
} }

View File

@@ -1,5 +1,5 @@
use pacman::constants::RAW_BOARD; use pacman::constants::RAW_BOARD;
use pacman::map::Map; use pacman::map::builder::Map;
mod collision; mod collision;
mod item; mod item;

View File

@@ -1,6 +1,6 @@
use glam::Vec2; use glam::Vec2;
use pacman::constants::{CELL_SIZE, RAW_BOARD}; use pacman::constants::{CELL_SIZE, RAW_BOARD};
use pacman::map::Map; use pacman::map::builder::Map;
use sdl2::render::Texture; use sdl2::render::Texture;
#[test] #[test]

View File

@@ -2,7 +2,6 @@ use pacman::entity::direction::Direction;
use pacman::entity::graph::{Graph, Node}; use pacman::entity::graph::{Graph, Node};
use pacman::entity::pacman::Pacman; use pacman::entity::pacman::Pacman;
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas}; use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
use sdl2::keyboard::Keycode;
use std::collections::HashMap; use std::collections::HashMap;
fn create_test_graph() -> Graph { fn create_test_graph() -> Graph {
@@ -72,36 +71,3 @@ fn test_pacman_creation() {
assert!(pacman.traverser.position.is_at_node()); assert!(pacman.traverser.position.is_at_node());
assert_eq!(pacman.traverser.direction, Direction::Left); assert_eq!(pacman.traverser.direction, Direction::Left);
} }
#[test]
fn test_pacman_key_handling() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let test_cases = [
(Keycode::Up, Direction::Up),
(Keycode::Down, Direction::Down),
(Keycode::Left, Direction::Left),
(Keycode::Right, Direction::Right),
];
for (key, expected_direction) in test_cases {
pacman.handle_key(key);
assert!(pacman.traverser.next_direction.is_some() || pacman.traverser.direction == expected_direction);
}
}
#[test]
fn test_pacman_invalid_key() {
let graph = create_test_graph();
let atlas = create_test_atlas();
let mut pacman = Pacman::new(&graph, 0, &atlas).unwrap();
let original_direction = pacman.traverser.direction;
let original_next_direction = pacman.traverser.next_direction;
pacman.handle_key(Keycode::Space);
assert_eq!(pacman.traverser.direction, original_direction);
assert_eq!(pacman.traverser.next_direction, original_next_direction);
}