Compare commits

...

13 Commits

21 changed files with 795 additions and 386 deletions

62
Cargo.lock generated
View File

@@ -528,6 +528,12 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "micromap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18c087666f377f857b49564f8791b481260c67825d6b337e1e38ddf54a985a88"
[[package]]
name = "nonmax"
version = "0.5.5"
@@ -571,13 +577,16 @@ version = "0.2.0"
dependencies = [
"anyhow",
"bevy_ecs",
"bitflags 2.9.1",
"glam 0.30.5",
"lazy_static",
"libc",
"micromap",
"once_cell",
"parking_lot",
"pathfinding",
"phf",
"rand 0.9.2",
"rand",
"sdl2",
"serde",
"serde_json",
@@ -586,6 +595,7 @@ dependencies = [
"strum",
"strum_macros",
"thiserror",
"thousands",
"tracing",
"tracing-error",
"tracing-subscriber",
@@ -637,29 +647,30 @@ dependencies = [
[[package]]
name = "phf"
version = "0.11.3"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_macros",
"phf_shared",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand",
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
dependencies = [
"phf_generator",
"phf_shared",
@@ -670,9 +681,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.11.3"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
@@ -722,30 +733,15 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_core 0.9.3",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
[[package]]
name = "rand_core"
version = "0.9.3"
@@ -986,24 +982,30 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.12"
version = "2.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
checksum = "0b0949c3a6c842cbde3f1686d6eea5a010516deb7085f79db747562d4102f41e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
version = "2.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
checksum = "cc5b44b4ab9c2fdd0e0512e6bece8388e214c0749f5862b114cc5b7a25daf227"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thousands"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
[[package]]
name = "thread_local"
version = "1.1.7"

View File

@@ -6,7 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tracing = { version = "0.1.40", features = ["max_level_debug", "release_max_level_debug"]}
tracing = { version = "0.1.41", features = ["max_level_debug", "release_max_level_debug"]}
tracing-error = "0.2.0"
tracing-subscriber = {version = "0.3.17", features = ["env-filter"]}
lazy_static = "1.5.0"
@@ -15,16 +15,20 @@ spin_sleep = "1.3.2"
rand = { version = "0.9.2", default-features = false, features = ["small_rng", "os_rng"] }
pathfinding = "4.14"
once_cell = "1.21.3"
thiserror = "2.0"
thiserror = "2.0.14"
anyhow = "1.0"
glam = { version = "0.30.5", features = [] }
glam = "0.30.5"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
smallvec = "1.15.1"
strum = "0.27.2"
strum_macros = "0.27.2"
phf = { version = "0.11", features = ["macros"] }
phf = { version = "0.12.1", features = ["macros"] }
bevy_ecs = "0.16.1"
bitflags = "2.9.1"
parking_lot = "0.12.3"
micromap = "0.1.0"
thousands = "0.2.0"
[profile.release]
lto = true
@@ -63,4 +67,4 @@ libc = "0.2.175"
[build-dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
phf = { version = "0.11", features = ["macros"] }
phf = { version = "0.12.1", features = ["macros"] }

View File

@@ -5,16 +5,19 @@ use sdl2::render::TextureCreator;
use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::WindowContext;
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::warn;
use thousands::Separable;
use tracing::info;
use crate::error::{GameError, GameResult};
use crate::constants::{CANVAS_SIZE, LOOP_TIME, SCALE};
use crate::game::Game;
use crate::platform::get_platform;
use crate::systems::profiling::SystemTimings;
pub struct App {
pub game: Game,
last_timings: Instant,
last_tick: Instant,
focused: bool,
_cursor_pos: Vec2,
@@ -50,7 +53,6 @@ impl App {
window
.into_canvas()
.accelerated()
.present_vsync()
.build()
.map_err(|e| GameError::Sdl(e.to_string()))?,
));
@@ -64,16 +66,11 @@ impl App {
let game = Game::new(canvas, texture_creator, event_pump)?;
// game.audio.set_mute(cfg!(debug_assertions));
// Initial draw
// game.draw(&mut canvas, &mut backbuffer)
// .map_err(|e| GameError::Sdl(e.to_string()))?;
// game.present_backbuffer(&mut canvas, &backbuffer, glam::Vec2::ZERO)
// .map_err(|e| GameError::Sdl(e.to_string()))?;
Ok(App {
game,
focused: true,
last_tick: Instant::now(),
last_timings: Instant::now() - Duration::from_secs_f32(0.5),
_cursor_pos: Vec2::ZERO,
})
}
@@ -116,17 +113,34 @@ impl App {
return false;
}
// if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
// error!("Failed to draw game: {}", e);
// }
if self.last_timings.elapsed() > Duration::from_secs(1) {
// Show timing statistics over the last 90 frames
if let Some(timings) = self.game.world.get_resource::<SystemTimings>() {
let stats = timings.get_stats();
let (total_avg, total_std) = timings.get_total_stats();
let mut individual_timings = String::new();
for (name, (avg, std_dev)) in stats.iter() {
individual_timings.push_str(&format!("{}={:?} ± {:?} ", name, avg, std_dev));
}
let effective_fps = match 1.0 / total_avg.as_secs_f64() {
f if f > 100.0 => (f as u32).separate_with_commas(),
f if f < 10.0 => format!("{:.1} FPS", f),
f => format!("{:.0} FPS", f),
};
info!("({effective_fps}) {total_avg:?} ± {total_std:?} ({individual_timings})");
}
self.last_timings = Instant::now();
}
// Sleep if we still have time left
if start.elapsed() < LOOP_TIME {
let time = LOOP_TIME.saturating_sub(start.elapsed());
if time != Duration::ZERO {
get_platform().sleep(time, self.focused);
}
} else {
warn!("Game loop behind schedule by: {:?}", start.elapsed() - LOOP_TIME);
}
true

View File

@@ -1,17 +1,21 @@
use glam::Vec2;
use crate::systems::components::NodeId;
use crate::systems::movement::NodeId;
use super::direction::Direction;
/// Defines who can traverse a given edge.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EdgePermissions {
/// Anyone can use this edge.
#[default]
All,
/// Only ghosts can use this edge.
GhostsOnly,
use bitflags::bitflags;
bitflags! {
/// Defines who can traverse a given edge using flags for fast checking.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct TraversalFlags: u8 {
const PACMAN = 1 << 0;
const GHOST = 1 << 1;
/// Convenience flag for edges that all entities can use
const ALL = Self::PACMAN.bits() | Self::GHOST.bits();
}
}
/// Represents a directed edge from one node to another with a given weight (e.g., distance).
@@ -24,7 +28,7 @@ pub struct Edge {
/// The cardinal direction of this edge.
pub direction: Direction,
/// Defines who is allowed to traverse this edge.
pub permissions: EdgePermissions,
pub traversal_flags: TraversalFlags,
}
/// Represents a node in the graph, defined by its position.
@@ -132,8 +136,8 @@ impl Graph {
return Err("To node does not exist.");
}
let edge_a = self.add_edge(from, to, replace, distance, direction, EdgePermissions::default());
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), EdgePermissions::default());
let edge_a = self.add_edge(from, to, replace, distance, direction, TraversalFlags::ALL);
let edge_b = self.add_edge(to, from, replace, distance, direction.opposite(), TraversalFlags::ALL);
if edge_a.is_err() && edge_b.is_err() {
return Err("Failed to connect nodes in both directions.");
@@ -161,7 +165,7 @@ impl Graph {
replace: bool,
distance: Option<f32>,
direction: Direction,
permissions: EdgePermissions,
traversal_flags: TraversalFlags,
) -> Result<(), &'static str> {
let edge = Edge {
target: to,
@@ -180,7 +184,7 @@ impl Graph {
}
},
direction,
permissions,
traversal_flags,
};
if from >= self.adjacency_list.len() {

View File

@@ -1,10 +1,19 @@
use bevy_ecs::event::Event;
use bevy_ecs::prelude::*;
use crate::systems::input::GameCommand;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameCommand {
Exit,
MovePlayer(crate::entity::direction::Direction),
ToggleDebug,
MuteAudio,
ResetLevel,
TogglePause,
}
#[derive(Debug, Clone, Copy, Event)]
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameEvent {
Command(GameCommand),
Collision(Entity, Entity),
}
impl From<GameCommand> for GameEvent {

View File

@@ -7,28 +7,35 @@ use crate::entity::direction::Direction;
use crate::error::{GameError, GameResult, TextureError};
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::systems::components::{
DeltaTime, DirectionalAnimated, EntityType, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity,
use crate::systems::blinking::Blinking;
use crate::systems::movement::{Movable, MovementState, Position};
use crate::systems::{
blinking::blinking_system,
collision::collision_system,
components::{
Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider,
PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource,
},
control::player_system,
input::input_system,
movement::movement_system,
profiling::{profile, SystemTimings},
render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource},
};
use crate::systems::control::player_system;
use crate::systems::movement::movement_system;
use crate::systems::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource};
use crate::texture::animated::AnimatedTexture;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::system::ResMut;
use bevy_ecs::{schedule::Schedule, world::World};
use bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World};
use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::video::{Window, WindowContext};
use sdl2::EventPump;
use crate::asset::{get_asset_bytes, Asset};
use crate::map::render::MapRenderer;
use crate::systems::input::{input_system, Bindings, GameCommand};
use crate::{
asset::{get_asset_bytes, Asset},
constants,
events::GameCommand,
map::render::MapRenderer,
systems::input::Bindings,
texture::sprite::{AtlasMapper, SpriteAtlas},
};
@@ -93,7 +100,7 @@ impl Game {
// Render map to texture
canvas
.with_texture_canvas(&mut map_texture, |map_canvas| {
MapRenderer::render_map(map_canvas, &mut atlas, &mut map_tiles);
MapRenderer::render_map(map_canvas, &mut atlas, &map_tiles);
})
.map_err(|e| GameError::Sdl(e.to_string()))?;
@@ -128,22 +135,32 @@ impl Game {
let player = PlayerBundle {
player: PlayerControlled,
position: Position::AtNode(pacman_start_node),
velocity: Velocity {
direction: Direction::Up,
next_direction: None,
speed: 1.125,
position: Position {
node: pacman_start_node,
edge_progress: None,
},
movement_state: MovementState::Stopped,
movable: Movable {
speed: 1.15,
current_direction: Direction::Left,
requested_direction: Some(Direction::Left), // Start moving left immediately
},
sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
layer: 0,
visible: true,
},
directional_animated: DirectionalAnimated {
textures,
stopped_textures,
},
entity_type: EntityType::Player,
collider: Collider {
size: constants::CELL_SIZE as f32 * 1.25,
layer: CollisionLayer::PACMAN,
},
pacman_collider: PacmanCollider,
};
world.insert_non_send_resource(atlas);
@@ -154,32 +171,87 @@ impl Game {
world.insert_resource(map);
world.insert_resource(GlobalState { exit: false });
world.insert_resource(ScoreResource(0));
world.insert_resource(SystemTimings::default());
world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32));
world.insert_resource(RenderDirty::default());
world.add_observer(|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>| match *event {
GameEvent::Command(command) => match command {
GameCommand::Exit => {
state.exit = true;
}
_ => {}
world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| match *event {
GameEvent::Command(command) => match command {
GameCommand::Exit => {
state.exit = true;
}
_ => {}
},
GameEvent::Collision(_a, _b) => {}
},
});
);
schedule.add_systems(
(
input_system,
player_system,
movement_system,
directional_render_system,
render_system,
profile("input", input_system),
profile("player", player_system),
profile("movement", movement_system),
profile("collision", collision_system),
profile("blinking", blinking_system),
profile("directional_render", directional_render_system),
)
.chain(),
);
schedule.add_systems((profile("dirty_render", dirty_render_system), profile("render", render_system)).chain());
// Spawn player
world.spawn(player);
// Spawn items
let pellet_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/pellet.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/pellet.png".to_string())))?;
let energizer_sprite = SpriteAtlas::get_tile(world.non_send_resource::<SpriteAtlas>(), "maze/energizer.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("maze/energizer.png".to_string())))?;
let nodes: Vec<_> = world.resource::<Map>().iter_nodes().map(|(id, tile)| (*id, *tile)).collect();
for (node_id, tile) in nodes {
let (item_type, score, sprite, size) = match tile {
crate::constants::MapTile::Pellet => (EntityType::Pellet, 10, pellet_sprite, constants::CELL_SIZE as f32 * 0.2),
crate::constants::MapTile::PowerPellet => (
EntityType::PowerPellet,
50,
energizer_sprite,
constants::CELL_SIZE as f32 * 0.9,
),
_ => continue,
};
let mut item = world.spawn(ItemBundle {
position: Position {
node: node_id,
edge_progress: None,
},
sprite: Renderable {
sprite,
layer: 1,
visible: true,
},
entity_type: item_type,
score: Score(score),
collider: Collider {
size,
layer: CollisionLayer::ITEM,
},
item_collider: ItemCollider,
});
if item_type == EntityType::PowerPellet {
item.insert(Blinking {
timer: 0.0,
interval: 0.2,
});
}
}
Ok(Game { world, schedule })
}
@@ -213,7 +285,6 @@ impl Game {
// match event {
// GameEvent::Command(command) => self.handle_command(command),
// }
// }
// }
// /// Resets the game state, randomizing ghost positions and resetting Pac-Man

View File

@@ -1,11 +1,10 @@
//! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node};
use crate::entity::graph::{Graph, Node, TraversalFlags};
use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer;
use crate::systems::components::NodeId;
use crate::systems::movement::NodeId;
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::resource::Resource;
use glam::{IVec2, Vec2};
@@ -33,6 +32,8 @@ pub struct Map {
pub grid_to_node: HashMap<IVec2, NodeId>,
/// A mapping of the starting positions of the entities.
pub start_positions: NodePositions,
/// The raw tile data for the map.
tiles: [[MapTile; BOARD_CELL_SIZE.y as usize]; BOARD_CELL_SIZE.x as usize],
}
impl Map {
@@ -153,46 +154,16 @@ impl Map {
graph,
grid_to_node,
start_positions,
tiles: map,
})
}
/// Generates Item entities for pellets and energizers from the parsed map.
// pub fn generate_items(&self, atlas: &SpriteAtlas) -> GameResult<Vec<Item>> {
// // Pre-load sprites to avoid repeated texture lookups
// let pellet_sprite = SpriteAtlas::get_tile(atlas, "maze/pellet.png")
// .ok_or_else(|| MapError::InvalidConfig("Pellet texture not found".to_string()))?;
// let energizer_sprite = SpriteAtlas::get_tile(atlas, "maze/energizer.png")
// .ok_or_else(|| MapError::InvalidConfig("Energizer texture not found".to_string()))?;
// // Pre-allocate with estimated capacity (typical Pac-Man maps have ~240 pellets + 4 energizers)
// let mut items = Vec::with_capacity(250);
// // Parse the raw board once
// let parsed_map = MapTileParser::parse_board(RAW_BOARD)?;
// let map = parsed_map.tiles;
// // Iterate through the map and collect items more efficiently
// for (x, row) in map.iter().enumerate() {
// for (y, tile) in row.iter().enumerate() {
// match tile {
// MapTile::Pellet | MapTile::PowerPellet => {
// let grid_pos = IVec2::new(x as i32, y as i32);
// if let Some(&node_id) = self.grid_to_node.get(&grid_pos) {
// let (item_type, sprite) = match tile {
// MapTile::Pellet => (ItemType::Pellet, Sprite::new(pellet_sprite)),
// MapTile::PowerPellet => (ItemType::Energizer, Sprite::new(energizer_sprite)),
// _ => unreachable!(), // We already filtered for these types
// };
// items.push(Item::new(node_id, item_type, sprite));
// }
// }
// _ => {}
// }
// }
// }
// Ok(items)
// }
pub fn iter_nodes(&self) -> impl Iterator<Item = (&NodeId, &MapTile)> {
self.grid_to_node.iter().map(move |(pos, node_id)| {
let tile = &self.tiles[pos.x as usize][pos.y as usize];
(node_id, tile)
})
}
/// Renders a debug visualization with cursor-based highlighting.
///
@@ -294,7 +265,7 @@ impl Map {
false,
None,
Direction::Down,
EdgePermissions::GhostsOnly,
TraversalFlags::GHOST,
)
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only entrance to house: {e}")))?;
@@ -305,7 +276,7 @@ impl Map {
false,
None,
Direction::Up,
EdgePermissions::GhostsOnly,
TraversalFlags::GHOST,
)
.map_err(|e| MapError::InvalidConfig(format!("Failed to create ghost-only exit from house: {e}")))?;

View File

@@ -19,7 +19,7 @@ impl MapRenderer {
///
/// This function draws the static map texture to the screen at the correct
/// position and scale.
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_tiles: &mut [AtlasTile]) {
pub fn render_map<T: RenderTarget>(canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, map_tiles: &[AtlasTile]) {
for (y, row) in TILE_MAP.iter().enumerate() {
for (x, &tile_index) in row.iter().enumerate() {
let mut tile = map_tiles[tile_index];

27
src/systems/blinking.rs Normal file
View File

@@ -0,0 +1,27 @@
use bevy_ecs::{
component::Component,
system::{Query, Res},
};
use crate::systems::components::{DeltaTime, Renderable};
#[derive(Component)]
pub struct Blinking {
pub timer: f32,
pub interval: f32,
}
/// Updates blinking entities by toggling their visibility at regular intervals.
///
/// This system manages entities that have both `Blinking` and `Renderable` components,
/// accumulating time and toggling visibility when the specified interval is reached.
pub fn blinking_system(time: Res<DeltaTime>, mut query: Query<(&mut Blinking, &mut Renderable)>) {
for (mut blinking, mut renderable) in query.iter_mut() {
blinking.timer += time.0;
if blinking.timer >= blinking.interval {
blinking.timer = 0.0;
renderable.visible = !renderable.visible;
}
}
}

48
src/systems/collision.rs Normal file
View File

@@ -0,0 +1,48 @@
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::query::With;
use bevy_ecs::system::{Query, Res};
use crate::error::GameError;
use crate::events::GameEvent;
use crate::map::builder::Map;
use crate::systems::components::{Collider, ItemCollider, PacmanCollider};
use crate::systems::movement::Position;
pub fn collision_system(
map: Res<Map>,
pacman_query: Query<(Entity, &Position, &Collider), With<PacmanCollider>>,
item_query: Query<(Entity, &Position, &Collider), With<ItemCollider>>,
mut events: EventWriter<GameEvent>,
mut errors: EventWriter<GameError>,
) {
// Check PACMAN × ITEM collisions
for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() {
for (item_entity, item_pos, item_collider) in item_query.iter() {
match (pacman_pos.get_pixel_pos(&map.graph), item_pos.get_pixel_pos(&map.graph)) {
(Ok(pacman_pixel), Ok(item_pixel)) => {
// Calculate the distance between the two entities's precise pixel positions
let distance = pacman_pixel.distance(item_pixel);
// Calculate the distance at which the two entities will collide
let collision_distance = (pacman_collider.size + item_collider.size) / 2.0;
// If the distance between the two entities is less than the collision distance, then the two entities are colliding
if distance < collision_distance {
events.write(GameEvent::Collision(pacman_entity, item_entity));
}
}
// Either or both of the pixel positions failed to get, so we need to report the error
(result_a, result_b) => {
for result in [result_a, result_b] {
if let Err(e) = result {
errors.write(GameError::InvalidState(format!(
"Collision system failed to get pixel positions for entities {:?} and {:?}: {}",
pacman_entity, item_entity, e
)));
}
}
}
}
}
}
}

View File

@@ -1,9 +1,9 @@
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use glam::Vec2;
use bitflags::bitflags;
use crate::{
entity::{direction::Direction, graph::Graph},
error::{EntityError, GameResult},
entity::graph::TraversalFlags,
systems::movement::{Movable, MovementState, Position},
texture::{animated::AnimatedTexture, sprite::AtlasTile},
};
@@ -18,7 +18,17 @@ pub enum EntityType {
Ghost,
Pellet,
PowerPellet,
Wall,
}
impl EntityType {
/// Returns the traversal flags for this entity type.
pub fn traversal_flags(&self) -> TraversalFlags {
match self {
EntityType::Player => TraversalFlags::PACMAN,
EntityType::Ghost => TraversalFlags::GHOST,
_ => TraversalFlags::empty(), // Static entities don't traverse
}
}
}
/// A component for entities that have a sprite, with a layer for ordering.
@@ -28,6 +38,7 @@ pub enum EntityType {
pub struct Renderable {
pub sprite: AtlasTile,
pub layer: u8,
pub visible: bool,
}
/// A component for entities that have a directional animated texture.
@@ -37,107 +48,55 @@ pub struct DirectionalAnimated {
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,
))
bitflags! {
#[derive(Component, Default, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CollisionLayer: u8 {
const PACMAN = 1 << 0;
const GHOST = 1 << 1;
const ITEM = 1 << 2;
}
}
impl Default for Position {
fn default() -> Self {
Position::AtNode(0)
}
#[derive(Component)]
pub struct Collider {
pub size: f32,
pub layer: CollisionLayer,
}
#[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(_))
}
/// Marker components for collision filtering optimization
#[derive(Component)]
pub struct PacmanCollider;
/// 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,
}
}
#[derive(Component)]
pub struct GhostCollider;
/// 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),
}
}
#[derive(Component)]
pub struct ItemCollider;
/// 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 next_direction: Option<(Direction, u8)>,
pub speed: f32,
}
#[derive(Component)]
pub struct Score(pub u32);
#[derive(Bundle)]
pub struct PlayerBundle {
pub player: PlayerControlled,
pub position: Position,
pub velocity: Velocity,
pub movement_state: MovementState,
pub movable: Movable,
pub sprite: Renderable,
pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType,
pub collider: Collider,
pub pacman_collider: PacmanCollider,
}
#[derive(Bundle)]
pub struct ItemBundle {
pub position: Position,
pub sprite: Renderable,
pub entity_type: EntityType,
pub score: Score,
pub collider: Collider,
pub item_collider: ItemCollider,
}
#[derive(Resource)]
@@ -145,5 +104,11 @@ pub struct GlobalState {
pub exit: bool,
}
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Resource)]
pub struct DeltaTime(pub f32);
#[derive(Resource, Default)]
pub struct RenderDirty(pub bool);

View File

@@ -1,45 +1,45 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
system::{Query, ResMut},
prelude::ResMut,
query::With,
system::Query,
};
use crate::{
error::GameError,
events::GameEvent,
systems::{
components::{GlobalState, PlayerControlled, Velocity},
input::GameCommand,
},
events::{GameCommand, GameEvent},
systems::components::{GlobalState, PlayerControlled},
systems::movement::Movable,
};
// Handles
// Handles player input and control
pub fn player_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut players: Query<(&PlayerControlled, &mut Velocity)>,
mut players: Query<&mut Movable, With<PlayerControlled>>,
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,
// Get the player's movable component (ensuring there is only one player)
let mut movable = match players.single_mut() {
Ok(movable) => movable,
Err(e) => {
errors.write(GameError::InvalidState(format!("Player not found: {}", e)).into());
errors.write(GameError::InvalidState(format!("No/multiple entities queried for player system: {}", e)).into());
return;
}
};
// Handle events
for event in events.read() {
match event {
GameEvent::Command(command) => match command {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
velocity.next_direction = Some((*direction, 90));
movable.requested_direction = Some(*direction);
}
GameCommand::Exit => {
state.exit = true;
}
_ => {}
},
}
}
}
}

View File

@@ -1,23 +1,12 @@
use std::collections::HashMap;
use bevy_ecs::{
event::EventWriter,
resource::Resource,
system::{NonSendMut, Res},
};
use bevy_ecs::{event::EventWriter, prelude::Res, resource::Resource, system::NonSendMut};
use sdl2::{event::Event, keyboard::Keycode, EventPump};
use crate::{entity::direction::Direction, events::GameEvent};
#[derive(Debug, Clone, Copy)]
pub enum GameCommand {
MovePlayer(Direction),
Exit,
TogglePause,
ToggleDebug,
MuteAudio,
ResetLevel,
}
use crate::{
entity::direction::Direction,
events::{GameCommand, GameEvent},
};
#[derive(Debug, Clone, Resource)]
pub struct Bindings {
@@ -59,7 +48,6 @@ pub fn input_system(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>,
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

@@ -3,8 +3,11 @@
//! This module contains all the ECS-related logic, including components, systems,
//! and resources.
pub mod blinking;
pub mod collision;
pub mod components;
pub mod control;
pub mod input;
pub mod movement;
pub mod profiling;
pub mod render;

View File

@@ -1,131 +1,270 @@
use crate::entity::graph::{Edge, EdgePermissions, Graph};
use crate::error::{EntityError, GameError};
use crate::entity::graph::Graph;
use crate::entity::{direction::Direction, graph::Edge};
use crate::error::{EntityError, GameError, GameResult};
use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, EntityType, Position, Velocity};
use crate::systems::components::{DeltaTime, EntityType};
use bevy_ecs::component::Component;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{Query, Res};
use glam::Vec2;
/// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize;
/// Progress along an edge between two nodes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EdgeProgress {
pub target_node: NodeId,
/// Progress from 0.0 (at source node) to 1.0 (at target node)
pub progress: f32,
}
/// Pure spatial position component - works for both static and dynamic entities.
#[derive(Component, Debug, Copy, Clone, PartialEq)]
pub struct Position {
/// The current/primary node this entity is at or traveling from
pub node: NodeId,
/// If Some, entity is traveling between nodes. If None, entity is stationary at node.
pub edge_progress: Option<EdgeProgress>,
}
/// Explicit movement state - only for entities that can move.
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub enum MovementState {
Stopped,
Moving { direction: Direction },
}
/// Movement capability and parameters - only for entities that can move.
#[derive(Component, Debug, Clone, Copy)]
pub struct Movable {
pub speed: f32,
pub current_direction: Direction,
pub requested_direction: Option<Direction>,
}
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.
///
/// # Errors
///
/// Returns an `EntityError` if the node or edge is not found.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match &self.edge_progress {
None => {
// Entity is stationary at a node
let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?;
node.position
}
Some(edge_progress) => {
// Entity is traveling between nodes
let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?;
let to_node = graph
.get_node(edge_progress.target_node)
.ok_or(EntityError::NodeNotFound(edge_progress.target_node))?;
// For zero-distance edges (tunnels), progress >= 1.0 means we're at the target
if edge_progress.progress >= 1.0 {
to_node.position
} else {
// Interpolate position based on progress
from_node.position + (to_node.position - from_node.position) * edge_progress.progress
}
}
};
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 {
node: 0,
edge_progress: None,
}
}
}
#[allow(dead_code)]
impl Position {
/// Returns `true` if the position is exactly at a node (not traveling).
pub fn is_at_node(&self) -> bool {
self.edge_progress.is_none()
}
/// Returns the `NodeId` of the current node (source of travel if moving).
pub fn current_node(&self) -> NodeId {
self.node
}
/// Returns the `NodeId` of the destination node, if currently traveling.
pub fn target_node(&self) -> Option<NodeId> {
self.edge_progress.as_ref().map(|ep| ep.target_node)
}
/// Returns `true` if the entity is traveling between nodes.
pub fn is_moving(&self) -> bool {
self.edge_progress.is_some()
}
}
fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
match entity_type {
EntityType::Player => matches!(edge.permissions, EdgePermissions::All),
EntityType::Ghost => matches!(edge.permissions, EdgePermissions::All | EdgePermissions::GhostsOnly),
_ => matches!(edge.permissions, EdgePermissions::All),
}
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
pub fn movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut Velocity, &mut Position, &EntityType)>,
mut entities: Query<(&mut MovementState, &mut Movable, &mut Position, &EntityType)>,
mut errors: EventWriter<GameError>,
) {
for (mut velocity, mut position, entity_type) in entities.iter_mut() {
let distance = velocity.speed * 60.0 * delta_time.0;
for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() {
let distance = movable.speed * 60.0 * delta_time.0;
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = velocity.next_direction {
if remaining > 0 {
velocity.next_direction = Some((direction, remaining - 1));
} else {
velocity.next_direction = None;
}
}
match *position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(node_id, next_direction) {
match *movement_state {
MovementState::Stopped => {
// Check if we have a requested direction to start moving
if let Some(requested_direction) = movable.requested_direction {
if let Some(edge) = map.graph.find_edge_in_direction(position.node, requested_direction) {
if can_traverse(*entity_type, edge) {
// Start moving in that direction
*position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance,
// Start moving in the requested direction
let progress = if edge.distance > 0.0 {
distance / edge.distance
} else {
// Zero-distance edge (tunnels) - immediately teleport
tracing::debug!("Entity entering tunnel from node {} to node {}", position.node, edge.target);
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: edge.target,
progress,
});
movable.current_direction = requested_direction;
movable.requested_direction = None;
*movement_state = MovementState::Moving {
direction: requested_direction,
};
velocity.direction = next_direction;
velocity.next_direction = None;
}
} else {
errors.write(
EntityError::InvalidMovement(format!(
"No edge found in direction {:?} from node {}",
next_direction, node_id
requested_direction, position.node
))
.into(),
);
}
}
}
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;
}
MovementState::Moving { direction } => {
// Continue moving or handle node transitions
let current_node = position.node;
if let Some(edge_progress) = &mut position.edge_progress {
// Extract target node before mutable operations
let target_node = edge_progress.target_node;
let edge = map
.graph
.find_edge(from, to)
.ok_or_else(|| {
// Get the current edge for distance calculation
let edge = map.graph.find_edge(current_node, target_node);
if let Some(edge) = edge {
// Update progress along the edge
if edge.distance > 0.0 {
edge_progress.progress += distance / edge.distance;
} else {
// Zero-distance edge (tunnels) - immediately complete
edge_progress.progress = 1.0;
}
if edge_progress.progress >= 1.0 {
// Reached the target node
let overflow = if edge.distance > 0.0 {
(edge_progress.progress - 1.0) * edge.distance
} else {
// Zero-distance edge - use remaining distance for overflow
distance
};
position.node = target_node;
position.edge_progress = None;
let mut continued_moving = false;
// Try to use requested direction first
if let Some(requested_direction) = movable.requested_direction {
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) {
if can_traverse(*entity_type, next_edge) {
let next_progress = if next_edge.distance > 0.0 {
overflow / next_edge.distance
} else {
// Zero-distance edge - immediately complete
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: next_edge.target,
progress: next_progress,
});
movable.current_direction = requested_direction;
movable.requested_direction = None;
*movement_state = MovementState::Moving {
direction: requested_direction,
};
continued_moving = true;
}
}
}
// If no requested direction or it failed, try to continue in current direction
if !continued_moving {
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) {
if can_traverse(*entity_type, next_edge) {
let next_progress = if next_edge.distance > 0.0 {
overflow / next_edge.distance
} else {
// Zero-distance edge - immediately complete
1.0
};
position.edge_progress = Some(EdgeProgress {
target_node: next_edge.target,
progress: next_progress,
});
// Keep current direction and movement state
continued_moving = true;
}
}
}
// If we couldn't continue moving, stop
if !continued_moving {
*movement_state = MovementState::Stopped;
movable.requested_direction = None;
}
}
} else {
// Edge not found - this is an inconsistent state
errors.write(
EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
"Inconsistent state: Moving on non-existent edge from {} to {}",
current_node, target_node
))
.into(),
);
return;
})
.unwrap();
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
*position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
*movement_state = MovementState::Stopped;
position.edge_progress = None;
}
} 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, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(to, next_dir) {
if can_traverse(*entity_type, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
velocity.direction = next_dir; // Remember our new direction
velocity.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) = map.graph.find_edge_in_direction(to, velocity.direction) {
if can_traverse(*entity_type, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
}
// Movement state says moving but no edge progress - this shouldn't happen
errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into());
*movement_state = MovementState::Stopped;
}
}
}

102
src/systems/profiling.rs Normal file
View File

@@ -0,0 +1,102 @@
use bevy_ecs::prelude::Resource;
use bevy_ecs::system::{IntoSystem, System};
use micromap::Map;
use parking_lot::Mutex;
use std::collections::VecDeque;
use std::time::Duration;
const TIMING_WINDOW_SIZE: usize = 90; // 1.5 seconds at 60 FPS
#[derive(Resource, Default, Debug)]
pub struct SystemTimings {
pub timings: Mutex<Map<&'static str, VecDeque<Duration>, 15>>,
}
impl SystemTimings {
pub fn add_timing(&self, name: &'static str, duration: Duration) {
let mut timings = self.timings.lock();
let queue = timings.entry(name).or_insert_with(VecDeque::new);
queue.push_back(duration);
if queue.len() > TIMING_WINDOW_SIZE {
queue.pop_front();
}
}
pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), 10> {
let timings = self.timings.lock();
let mut stats = Map::new();
for (name, queue) in timings.iter() {
if queue.is_empty() {
continue;
}
let durations: Vec<f64> = queue.iter().map(|d| d.as_secs_f64() * 1000.0).collect();
let count = durations.len() as f64;
let sum: f64 = durations.iter().sum();
let mean = sum / count;
let variance = durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
stats.insert(
*name,
(
Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0),
),
);
}
stats
}
pub fn get_total_stats(&self) -> (Duration, Duration) {
let timings = self.timings.lock();
let mut all_durations = Vec::new();
for queue in timings.values() {
all_durations.extend(queue.iter().map(|d| d.as_secs_f64() * 1000.0));
}
if all_durations.is_empty() {
return (Duration::ZERO, Duration::ZERO);
}
let count = all_durations.len() as f64;
let sum: f64 = all_durations.iter().sum();
let mean = sum / count;
let variance = all_durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
(
Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0),
)
}
}
pub fn profile<S, M>(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World)
where
S: IntoSystem<(), (), M> + 'static,
{
let mut system: S::System = IntoSystem::into_system(system);
let mut is_initialized = false;
move |world: &mut bevy_ecs::world::World| {
if !is_initialized {
system.initialize(world);
is_initialized = true;
}
let start = std::time::Instant::now();
system.run((), world);
let duration = start.elapsed();
if let Some(timings) = world.get_resource::<SystemTimings>() {
timings.add_timing(name, duration);
}
}
}

View File

@@ -1,34 +1,51 @@
use crate::error::{GameError, TextureError};
use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity};
use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable};
use crate::systems::movement::{Movable, MovementState, Position};
use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{NonSendMut, Query, Res};
use bevy_ecs::prelude::{Changed, Or, RemovedComponents};
use bevy_ecs::system::{NonSendMut, Query, Res, ResMut};
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
pub fn dirty_render_system(
mut dirty: ResMut<RenderDirty>,
changed_renderables: Query<(), Or<(Changed<Renderable>, Changed<Position>)>>,
removed_renderables: RemovedComponents<Renderable>,
) {
if !changed_renderables.is_empty() || !removed_renderables.is_empty() {
dirty.0 = true;
}
}
/// Updates the directional animated texture of an entity.
///
/// This runs before the render system so it can update the sprite based on the current direction of travel, as well as whether the entity is moving.
pub fn directional_render_system(
dt: Res<DeltaTime>,
mut renderables: Query<(&Velocity, &mut DirectionalAnimated, &mut Renderable, &Position)>,
mut renderables: Query<(&MovementState, &Movable, &mut DirectionalAnimated, &mut Renderable)>,
mut errors: EventWriter<GameError>,
) {
for (velocity, mut texture, mut renderable, position) in renderables.iter_mut() {
let stopped = matches!(position, Position::AtNode(_));
for (movement_state, movable, mut texture, mut renderable) in renderables.iter_mut() {
let stopped = matches!(movement_state, MovementState::Stopped);
let current_direction = movable.current_direction;
let texture = if stopped {
texture.stopped_textures[velocity.direction.as_usize()].as_mut()
texture.stopped_textures[current_direction.as_usize()].as_mut()
} else {
texture.textures[velocity.direction.as_usize()].as_mut()
texture.textures[current_direction.as_usize()].as_mut()
};
if let Some(texture) = texture {
if !stopped {
texture.tick(dt.0);
}
renderable.sprite = *texture.current_tile();
let new_tile = *texture.current_tile();
if renderable.sprite != new_tile {
renderable.sprite = new_tile;
}
} else {
errors.write(TextureError::RenderFailed(format!("Entity has no texture")).into());
continue;
@@ -48,9 +65,13 @@ pub fn render_system(
mut backbuffer: NonSendMut<BackbufferResource>,
mut atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>,
renderables: Query<(Entity, &mut Renderable, &Position)>,
mut dirty: ResMut<RenderDirty>,
renderables: Query<(Entity, &Renderable, &Position)>,
mut errors: EventWriter<GameError>,
) {
if !dirty.0 {
return;
}
// Clear the main canvas first
canvas.set_draw_color(sdl2::pixels::Color::BLACK);
canvas.clear();
@@ -63,17 +84,16 @@ pub fn render_system(
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()));
if let Err(e) = backbuffer_canvas.copy(&map_texture.0, None, None) {
errors.write(TextureError::RenderFailed(e.to_string()).into());
}
// Render all entities to the backbuffer
for (_, mut renderable, position) in renderables
// .iter_mut()
// .sort_by_key::<&mut Renderable, _, _>(|(renderable, renderable, renderable)| renderable.layer)
// .collect()
{
for (_, renderable, position) in renderables.iter() {
if !renderable.visible {
continue;
}
let pos = position.get_pixel_pos(&map.graph);
match pos {
Ok(pos) => {
@@ -104,4 +124,6 @@ pub fn render_system(
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.present();
dirty.0 = false;
}

View File

@@ -21,7 +21,7 @@ pub struct MapperFrame {
pub height: u16,
}
#[derive(Copy, Clone, Debug)]
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct AtlasTile {
pub pos: U16Vec2,
pub size: U16Vec2,
@@ -30,7 +30,7 @@ pub struct AtlasTile {
impl AtlasTile {
pub fn render<C: RenderTarget>(
&mut self,
&self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
dest: Rect,
@@ -41,7 +41,7 @@ impl AtlasTile {
}
pub fn render_with_color<C: RenderTarget>(
&mut self,
&self,
canvas: &mut Canvas<C>,
atlas: &mut SpriteAtlas,
dest: Rect,

View File

@@ -103,9 +103,9 @@ impl TextTexture {
&self.char_map
}
pub fn get_tile(&mut self, c: char, atlas: &mut SpriteAtlas) -> Result<Option<&mut AtlasTile>> {
pub fn get_tile(&mut self, c: char, atlas: &mut SpriteAtlas) -> Result<Option<&AtlasTile>> {
if self.char_map.contains_key(&c) {
return Ok(self.char_map.get_mut(&c));
return Ok(self.char_map.get(&c));
}
if let Some(tile_name) = char_to_tile_name(c) {
@@ -113,7 +113,7 @@ impl TextTexture {
.get_tile(&tile_name)
.ok_or(GameError::Texture(TextureError::AtlasTileNotFound(tile_name)))?;
self.char_map.insert(c, tile);
Ok(self.char_map.get_mut(&c))
Ok(self.char_map.get(&c))
} else {
Ok(None)
}

View File

@@ -1,5 +1,5 @@
use pacman::entity::direction::Direction;
use pacman::entity::graph::{EdgePermissions, Graph, Node};
use pacman::entity::graph::{Graph, Node, TraversalFlags};
fn create_test_graph() -> Graph {
let mut graph = Graph::new();
@@ -78,11 +78,11 @@ fn test_graph_edge_permissions() {
});
graph
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
.add_edge(node1, node2, false, None, Direction::Right, TraversalFlags::GHOST)
.unwrap();
let edge = graph.find_edge_in_direction(node1, Direction::Right).unwrap();
assert_eq!(edge.permissions, EdgePermissions::GhostsOnly);
assert_eq!(edge.traversal_flags, TraversalFlags::GHOST);
}
#[test]
@@ -118,21 +118,21 @@ fn should_error_on_negative_edge_distance() {
position: glam::Vec2::new(16.0, 0.0),
});
let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, EdgePermissions::All);
let result = graph.add_edge(node1, node2, false, Some(-1.0), Direction::Right, TraversalFlags::ALL);
assert!(result.is_err());
}
#[test]
fn should_error_on_duplicate_edge_without_replace() {
let mut graph = create_test_graph();
let result = graph.add_edge(0, 1, false, None, Direction::Right, EdgePermissions::All);
let result = graph.add_edge(0, 1, false, None, Direction::Right, TraversalFlags::ALL);
assert!(result.is_err());
}
#[test]
fn should_allow_replacing_an_edge() {
let mut graph = create_test_graph();
let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, EdgePermissions::All);
let result = graph.add_edge(0, 1, true, Some(42.0), Direction::Right, TraversalFlags::ALL);
assert!(result.is_ok());
let edge = graph.find_edge(0, 1).unwrap();

40
tests/profiling.rs Normal file
View File

@@ -0,0 +1,40 @@
use pacman::systems::profiling::SystemTimings;
use std::time::Duration;
#[test]
fn test_timing_statistics() {
let timings = SystemTimings::default();
// Add some test data
timings.add_timing("test_system", Duration::from_millis(10));
timings.add_timing("test_system", Duration::from_millis(12));
timings.add_timing("test_system", Duration::from_millis(8));
let stats = timings.get_stats();
let (avg, std_dev) = stats.get("test_system").unwrap();
// Average should be 10ms, standard deviation should be small
assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0);
assert!(std_dev.as_millis() > 0);
let (total_avg, total_std) = timings.get_total_stats();
assert!((total_avg.as_millis() as f64 - 10.0).abs() < 1.0);
assert!(total_std.as_millis() > 0);
}
#[test]
fn test_window_size_limit() {
let timings = SystemTimings::default();
// Add more than 90 timings to test window size limit
for i in 0..100 {
timings.add_timing("test_system", Duration::from_millis(i));
}
let stats = timings.get_stats();
let (avg, _) = stats.get("test_system").unwrap();
// Should only keep the last 90 values, so average should be around 55ms
// (average of 10-99)
assert!((avg.as_millis() as f64 - 55.0).abs() < 5.0);
}