feat: implement generic optimized collision system

This commit is contained in:
2025-08-15 12:21:29 -05:00
parent c5d6ea28e1
commit 57d7f75940
12 changed files with 242 additions and 46 deletions

1
Cargo.lock generated
View File

@@ -571,6 +571,7 @@ version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bevy_ecs", "bevy_ecs",
"bitflags 2.9.1",
"glam 0.30.5", "glam 0.30.5",
"lazy_static", "lazy_static",
"libc", "libc",

View File

@@ -25,6 +25,7 @@ strum = "0.27.2"
strum_macros = "0.27.2" strum_macros = "0.27.2"
phf = { version = "0.11", features = ["macros"] } phf = { version = "0.11", features = ["macros"] }
bevy_ecs = "0.16.1" bevy_ecs = "0.16.1"
bitflags = "2.9.1"
[profile.release] [profile.release]
lto = true lto = true

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 { pub enum GameEvent {
Command(GameCommand), Command(GameCommand),
Collision(Entity, Entity),
} }
impl From<GameCommand> for GameEvent { impl From<GameCommand> for GameEvent {

View File

@@ -7,28 +7,33 @@ use crate::entity::direction::Direction;
use crate::error::{GameError, GameResult, TextureError}; use crate::error::{GameError, GameResult, TextureError};
use crate::events::GameEvent; use crate::events::GameEvent;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{ use crate::systems::blinking::Blinking;
DeltaTime, DirectionalAnimated, EntityType, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity, use crate::systems::{
blinking::blinking_system,
collision::collision_system,
components::{
Collider, CollisionLayer, DeltaTime, DirectionalAnimated, EntityType, GlobalState, ItemBundle, ItemCollider,
PacmanCollider, PlayerBundle, PlayerControlled, Position, Renderable, Score, ScoreResource, Velocity,
},
control::player_system,
input::input_system,
movement::movement_system,
render::{directional_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 crate::texture::animated::AnimatedTexture;
use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::IntoScheduleConfigs; use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::system::ResMut; use bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World};
use bevy_ecs::{schedule::Schedule, world::World};
use sdl2::image::LoadTexture; use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; 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::{ use crate::{
asset::{get_asset_bytes, Asset},
constants, constants,
events::GameCommand,
map::render::MapRenderer,
systems::input::Bindings,
texture::sprite::{AtlasMapper, SpriteAtlas}, texture::sprite::{AtlasMapper, SpriteAtlas},
}; };
@@ -138,12 +143,18 @@ impl Game {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") sprite: 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())))?,
layer: 0, layer: 0,
visible: true,
}, },
directional_animated: DirectionalAnimated { directional_animated: DirectionalAnimated {
textures, textures,
stopped_textures, stopped_textures,
}, },
entity_type: EntityType::Player, 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); world.insert_non_send_resource(atlas);
@@ -154,23 +165,29 @@ impl Game {
world.insert_resource(map); world.insert_resource(map);
world.insert_resource(GlobalState { exit: false }); world.insert_resource(GlobalState { exit: false });
world.insert_resource(ScoreResource(0));
world.insert_resource(Bindings::default()); world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32)); world.insert_resource(DeltaTime(0f32));
world.add_observer(|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>| match *event { world.add_observer(
GameEvent::Command(command) => match command { |event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, mut score: ResMut<ScoreResource>| match *event {
GameCommand::Exit => { GameEvent::Command(command) => match command {
state.exit = true; GameCommand::Exit => {
} state.exit = true;
_ => {} }
_ => {}
},
GameEvent::Collision(a, b) => {}
}, },
}); );
schedule.add_systems( schedule.add_systems(
( (
input_system, input_system,
player_system, player_system,
movement_system, movement_system,
collision_system,
blinking_system,
directional_render_system, directional_render_system,
render_system, render_system,
) )
@@ -180,6 +197,50 @@ impl Game {
// Spawn player // Spawn player
world.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::AtNode(node_id),
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 }) Ok(Game { world, schedule })
} }
@@ -213,7 +274,6 @@ impl Game {
// match event { // match event {
// GameEvent::Command(command) => self.handle_command(command), // GameEvent::Command(command) => self.handle_command(command),
// } // }
// }
// } // }
// /// Resets the game state, randomizing ghost positions and resetting Pac-Man // /// Resets the game state, randomizing ghost positions and resetting Pac-Man

View File

@@ -33,6 +33,8 @@ pub struct Map {
pub grid_to_node: HashMap<IVec2, NodeId>, pub grid_to_node: HashMap<IVec2, NodeId>,
/// A mapping of the starting positions of the entities. /// A mapping of the starting positions of the entities.
pub start_positions: NodePositions, 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 { impl Map {
@@ -153,6 +155,14 @@ impl Map {
graph, graph,
grid_to_node, grid_to_node,
start_positions, start_positions,
tiles: map,
})
}
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)
}) })
} }

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;
}
}
}

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

@@ -0,0 +1,47 @@
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, 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,4 +1,5 @@
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use bitflags::bitflags;
use glam::Vec2; use glam::Vec2;
use crate::{ use crate::{
@@ -28,6 +29,7 @@ pub enum EntityType {
pub struct Renderable { pub struct Renderable {
pub sprite: AtlasTile, pub sprite: AtlasTile,
pub layer: u8, pub layer: u8,
pub visible: bool,
} }
/// A component for entities that have a directional animated texture. /// A component for entities that have a directional animated texture.
@@ -62,6 +64,10 @@ impl Position {
/// ///
/// 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.
///
/// # Errors
///
/// Returns an `EntityError` if the node or edge is not found.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> { pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match self { let pos = match self {
Position::AtNode(node_id) => { Position::AtNode(node_id) => {
@@ -130,6 +136,34 @@ pub struct Velocity {
pub speed: f32, pub speed: 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;
}
}
#[derive(Component)]
pub struct Collider {
pub size: f32,
pub layer: CollisionLayer,
}
/// Marker components for collision filtering optimization
#[derive(Component)]
pub struct PacmanCollider;
#[derive(Component)]
pub struct GhostCollider;
#[derive(Component)]
pub struct ItemCollider;
#[derive(Component)]
pub struct Score(pub u32);
#[derive(Bundle)] #[derive(Bundle)]
pub struct PlayerBundle { pub struct PlayerBundle {
pub player: PlayerControlled, pub player: PlayerControlled,
@@ -138,6 +172,18 @@ pub struct PlayerBundle {
pub sprite: Renderable, pub sprite: Renderable,
pub directional_animated: DirectionalAnimated, pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType, 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)] #[derive(Resource)]
@@ -145,5 +191,8 @@ pub struct GlobalState {
pub exit: bool, pub exit: bool,
} }
#[derive(Resource)]
pub struct ScoreResource(pub u32);
#[derive(Resource)] #[derive(Resource)]
pub struct DeltaTime(pub f32); pub struct DeltaTime(pub f32);

View File

@@ -1,16 +1,14 @@
use bevy_ecs::{ use bevy_ecs::{
event::{EventReader, EventWriter}, event::{EventReader, EventWriter},
prelude::ResMut,
query::With, query::With,
system::{Query, ResMut}, system::Query,
}; };
use crate::{ use crate::{
error::GameError, error::GameError,
events::GameEvent, events::{GameCommand, GameEvent},
systems::{ systems::components::{GlobalState, PlayerControlled, Velocity},
components::{GlobalState, PlayerControlled, Velocity},
input::GameCommand,
},
}; };
// Handles // Handles
@@ -41,6 +39,9 @@ pub fn player_system(
} }
_ => {} _ => {}
}, },
GameEvent::Collision(a, b) => {
tracing::info!("Collision between {:?} and {:?}", a, b);
}
} }
} }
} }

View File

@@ -1,23 +1,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use bevy_ecs::{ use bevy_ecs::{event::EventWriter, prelude::Res, resource::Resource, system::NonSendMut};
event::EventWriter,
resource::Resource,
system::{NonSendMut, Res},
};
use sdl2::{event::Event, keyboard::Keycode, EventPump}; use sdl2::{event::Event, keyboard::Keycode, EventPump};
use crate::{entity::direction::Direction, events::GameEvent}; use crate::{
entity::direction::Direction,
#[derive(Debug, Clone, Copy)] events::{GameCommand, GameEvent},
pub enum GameCommand { };
MovePlayer(Direction),
Exit,
TogglePause,
ToggleDebug,
MuteAudio,
ResetLevel,
}
#[derive(Debug, Clone, Resource)] #[derive(Debug, Clone, Resource)]
pub struct Bindings { pub struct Bindings {

View File

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

View File

@@ -1,4 +1,4 @@
use crate::entity::graph::{Edge, EdgePermissions, Graph}; use crate::entity::graph::{Edge, EdgePermissions};
use crate::error::{EntityError, GameError}; use crate::error::{EntityError, GameError};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, EntityType, Position, Velocity}; use crate::systems::components::{DeltaTime, EntityType, Position, Velocity};