mirror of
https://github.com/Xevion/Pac-Man.git
synced 2025-12-06 09:15:46 -06:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b88895e82f | |||
| 2f0c734d13 | |||
| e96b3159d7 | |||
| 8c95ecc547 | |||
| 02a98c9f32 | |||
| 7f95c0233e | |||
| a531228b95 | |||
| de86f383bf | |||
| bd811ee783 | |||
| 57d7f75940 | |||
| c5d6ea28e1 | |||
| 730daed20a | |||
| b9bae99a4c | |||
| 2c65048fb0 | |||
| 3388d77ec5 | |||
| 242da2e263 | |||
| 70fb2b9503 | |||
| 0aa056a0ae |
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -571,13 +571,15 @@ version = "0.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bevy_ecs",
|
||||
"bitflags 2.9.1",
|
||||
"glam 0.30.5",
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"pathfinding",
|
||||
"phf",
|
||||
"rand 0.9.2",
|
||||
"rand",
|
||||
"sdl2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -637,29 +639,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 +673,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 +725,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,18 +974,18 @@ 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",
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -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,18 @@ 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"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -63,4 +65,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"] }
|
||||
|
||||
36
src/app.rs
36
src/app.rs
@@ -1,23 +1,24 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use glam::Vec2;
|
||||
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
||||
use sdl2::render::TextureCreator;
|
||||
use sdl2::ttf::Sdl2TtfContext;
|
||||
use sdl2::video::{Window, WindowContext};
|
||||
use sdl2::video::WindowContext;
|
||||
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
|
||||
use tracing::{error, warn};
|
||||
use tracing::{field, info, warn};
|
||||
|
||||
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_tick: Instant,
|
||||
focused: bool,
|
||||
cursor_pos: Vec2,
|
||||
_cursor_pos: Vec2,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -46,11 +47,10 @@ impl App {
|
||||
.build()
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?;
|
||||
|
||||
let mut canvas = Box::leak(Box::new(
|
||||
let canvas = Box::leak(Box::new(
|
||||
window
|
||||
.into_canvas()
|
||||
.accelerated()
|
||||
.present_vsync()
|
||||
.build()
|
||||
.map_err(|e| GameError::Sdl(e.to_string()))?,
|
||||
));
|
||||
@@ -64,17 +64,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(),
|
||||
cursor_pos: Vec2::ZERO,
|
||||
_cursor_pos: Vec2::ZERO,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,17 +110,23 @@ impl App {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if let Err(e) = self.game.draw(&mut self.canvas, &mut self.backbuffer) {
|
||||
// error!("Failed to draw game: {}", e);
|
||||
// }
|
||||
// Show timings if the loop took more than 25% of the loop time
|
||||
let show_timings = start.elapsed() > (LOOP_TIME / 4);
|
||||
if show_timings || true {
|
||||
if let Some(timings) = self.game.world.get_resource::<SystemTimings>() {
|
||||
let mut timings = timings.timings.lock();
|
||||
let total = timings.values().sum::<Duration>();
|
||||
info!("Total: {:?}, Timings: {:?}", total, field::debug(&timings));
|
||||
timings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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
149
src/ecs/mod.rs
@@ -1,149 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,17 +1,21 @@
|
||||
use glam::Vec2;
|
||||
|
||||
use crate::ecs::NodeId;
|
||||
use crate::systems::movement::NodeId;
|
||||
|
||||
use super::direction::Direction;
|
||||
|
||||
/// Defines who can traverse a given edge.
|
||||
use bitflags::bitflags;
|
||||
|
||||
bitflags! {
|
||||
/// Defines who can traverse a given edge using flags for fast checking.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum EdgePermissions {
|
||||
/// Anyone can use this edge.
|
||||
#[default]
|
||||
All,
|
||||
/// Only ghosts can use this edge.
|
||||
GhostsOnly,
|
||||
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() {
|
||||
|
||||
@@ -5,4 +5,3 @@ pub mod graph;
|
||||
pub mod item;
|
||||
pub mod pacman;
|
||||
pub mod r#trait;
|
||||
pub mod traversal;
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
// use tracing::error;
|
||||
|
||||
// use crate::error::GameResult;
|
||||
|
||||
// use super::direction::Direction;
|
||||
// use super::graph::{Edge, Graph, NodeId};
|
||||
|
||||
// /// Manages an entity's movement through the graph.
|
||||
// ///
|
||||
// /// A `Traverser` encapsulates the state of an entity's position and direction,
|
||||
// /// providing a way to advance along the graph's paths based on a given distance.
|
||||
// /// It also handles direction changes, buffering the next intended direction.
|
||||
// pub struct Traverser {
|
||||
// /// The current position of the traverser in the graph.
|
||||
// pub position: Position,
|
||||
// /// The current direction of movement.
|
||||
// pub direction: Direction,
|
||||
// /// Buffered direction change with remaining frame count for timing.
|
||||
// ///
|
||||
// /// The `u8` value represents the number of frames remaining before
|
||||
// /// the buffered direction expires. This allows for responsive controls
|
||||
// /// by storing direction changes for a limited time.
|
||||
// pub next_direction: Option<(Direction, u8)>,
|
||||
// }
|
||||
|
||||
// impl Traverser {
|
||||
// /// Creates a new traverser starting at the given node ID.
|
||||
// ///
|
||||
// /// The traverser will immediately attempt to start moving in the initial direction.
|
||||
// pub fn new<F>(graph: &Graph, start_node: NodeId, initial_direction: Direction, can_traverse: &F) -> Self
|
||||
// where
|
||||
// F: Fn(Edge) -> bool,
|
||||
// {
|
||||
// let mut traverser = Traverser {
|
||||
// position: Position::AtNode(start_node),
|
||||
// direction: initial_direction,
|
||||
// next_direction: Some((initial_direction, 1)),
|
||||
// };
|
||||
|
||||
// // This will kickstart the traverser into motion
|
||||
// if let Err(e) = traverser.advance(graph, 0.0, can_traverse) {
|
||||
// error!("Traverser initialization error: {}", e);
|
||||
// }
|
||||
|
||||
// traverser
|
||||
// }
|
||||
|
||||
// /// Sets the next direction for the traverser to take.
|
||||
// ///
|
||||
// /// The direction is buffered and will be applied at the next opportunity,
|
||||
// /// typically when the traverser reaches a new node. This allows for responsive
|
||||
// /// controls, as the new direction is stored for a limited time.
|
||||
// pub fn set_next_direction(&mut self, new_direction: Direction) {
|
||||
// if self.direction != new_direction {
|
||||
// self.next_direction = Some((new_direction, 30));
|
||||
// }
|
||||
// }
|
||||
|
||||
// /// Advances the traverser along the graph by a specified distance.
|
||||
// ///
|
||||
// /// This method updates the traverser's position based on its current state
|
||||
// /// and the distance to travel.
|
||||
// ///
|
||||
// /// - If at a node, it checks for a buffered direction to start moving.
|
||||
// /// - If between nodes, it moves along the current edge.
|
||||
// /// - If it reaches a node, it attempts to transition to a new edge based on
|
||||
// /// the buffered direction or by continuing straight.
|
||||
// /// - If no valid move is possible, it stops at the node.
|
||||
// ///
|
||||
// /// Returns an error if the movement is invalid (e.g., trying to move in an impossible direction).
|
||||
// pub fn advance<F>(&mut self, graph: &Graph, distance: f32, can_traverse: &F) -> GameResult<()>
|
||||
// where
|
||||
// F: Fn(Edge) -> bool,
|
||||
// {
|
||||
// // Decrement the remaining frames for the next direction
|
||||
// if let Some((direction, remaining)) = self.next_direction {
|
||||
// if remaining > 0 {
|
||||
// self.next_direction = Some((direction, remaining - 1));
|
||||
// } else {
|
||||
// self.next_direction = None;
|
||||
// }
|
||||
// }
|
||||
|
||||
// match self.position {
|
||||
// Position::AtNode(node_id) => {
|
||||
// // We're not moving, but a buffered direction is available.
|
||||
// if let Some((next_direction, _)) = self.next_direction {
|
||||
// if let Some(edge) = graph.find_edge_in_direction(node_id, next_direction) {
|
||||
// if can_traverse(edge) {
|
||||
// // Start moving in that direction
|
||||
// self.position = Position::BetweenNodes {
|
||||
// from: node_id,
|
||||
// to: edge.target,
|
||||
// traversed: distance.max(0.0),
|
||||
// };
|
||||
// self.direction = next_direction;
|
||||
// } else {
|
||||
// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
|
||||
// format!(
|
||||
// "Cannot traverse edge from {} to {} in direction {:?}",
|
||||
// node_id, edge.target, next_direction
|
||||
// ),
|
||||
// )));
|
||||
// }
|
||||
// } else {
|
||||
// return Err(crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(
|
||||
// format!("No edge found in direction {:?} from node {}", next_direction, node_id),
|
||||
// )));
|
||||
// }
|
||||
|
||||
// self.next_direction = None; // Consume the buffered direction regardless of whether we started moving with it
|
||||
// }
|
||||
// }
|
||||
// Position::BetweenNodes { from, to, traversed } => {
|
||||
// // There is no point in any of the next logic if we don't travel at all
|
||||
// if distance <= 0.0 {
|
||||
// return Ok(());
|
||||
// }
|
||||
|
||||
// let edge = graph.find_edge(from, to).ok_or_else(|| {
|
||||
// crate::error::GameError::Entity(crate::error::EntityError::InvalidMovement(format!(
|
||||
// "Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
|
||||
// from, to
|
||||
// )))
|
||||
// })?;
|
||||
|
||||
// let new_traversed = traversed + distance;
|
||||
|
||||
// if new_traversed < edge.distance {
|
||||
// // Still on the same edge, just update the distance.
|
||||
// self.position = Position::BetweenNodes {
|
||||
// from,
|
||||
// to,
|
||||
// traversed: new_traversed,
|
||||
// };
|
||||
// } else {
|
||||
// let overflow = new_traversed - edge.distance;
|
||||
// let mut moved = false;
|
||||
|
||||
// // If we buffered a direction, try to find an edge in that direction
|
||||
// if let Some((next_dir, _)) = self.next_direction {
|
||||
// if let Some(edge) = graph.find_edge_in_direction(to, next_dir) {
|
||||
// if can_traverse(edge) {
|
||||
// self.position = Position::BetweenNodes {
|
||||
// from: to,
|
||||
// to: edge.target,
|
||||
// traversed: overflow,
|
||||
// };
|
||||
|
||||
// self.direction = next_dir; // Remember our new direction
|
||||
// self.next_direction = None; // Consume the buffered direction
|
||||
// moved = true;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// // If we didn't move, try to continue in the current direction
|
||||
// if !moved {
|
||||
// if let Some(edge) = graph.find_edge_in_direction(to, self.direction) {
|
||||
// if can_traverse(edge) {
|
||||
// self.position = Position::BetweenNodes {
|
||||
// from: to,
|
||||
// to: edge.target,
|
||||
// traversed: overflow,
|
||||
// };
|
||||
// } else {
|
||||
// self.position = Position::AtNode(to);
|
||||
// self.next_direction = None;
|
||||
// }
|
||||
// } else {
|
||||
// self.position = Position::AtNode(to);
|
||||
// self.next_direction = None;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// Ok(())
|
||||
// }
|
||||
// }
|
||||
23
src/events.rs
Normal file
23
src/events.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameCommand {
|
||||
Exit,
|
||||
MovePlayer(crate::entity::direction::Direction),
|
||||
ToggleDebug,
|
||||
MuteAudio,
|
||||
ResetLevel,
|
||||
TogglePause,
|
||||
}
|
||||
|
||||
#[derive(Event, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GameEvent {
|
||||
Command(GameCommand),
|
||||
Collision(Entity, Entity),
|
||||
}
|
||||
|
||||
impl From<GameCommand> for GameEvent {
|
||||
fn from(command: GameCommand) -> Self {
|
||||
GameEvent::Command(command)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
134
src/game/mod.rs
134
src/game/mod.rs
@@ -3,38 +3,42 @@
|
||||
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
|
||||
|
||||
use crate::constants::CANVAS_SIZE;
|
||||
use crate::ecs::interact::interact_system;
|
||||
use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource};
|
||||
use crate::ecs::{DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity};
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::entity::{graph, traversal};
|
||||
use crate::error::{GameError, GameResult, TextureError};
|
||||
use crate::input::commands::GameCommand;
|
||||
use crate::events::GameEvent;
|
||||
use crate::map::builder::Map;
|
||||
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::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 bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World};
|
||||
use sdl2::image::LoadTexture;
|
||||
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator};
|
||||
use sdl2::render::{Canvas, ScaleMode, 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::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
constants,
|
||||
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas},
|
||||
events::GameCommand,
|
||||
map::render::MapRenderer,
|
||||
systems::input::Bindings,
|
||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||
};
|
||||
|
||||
use self::events::GameEvent;
|
||||
|
||||
pub mod events;
|
||||
pub mod state;
|
||||
|
||||
/// The `Game` struct is the main entry point for the game.
|
||||
@@ -96,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()))?;
|
||||
|
||||
@@ -131,17 +135,32 @@ impl Game {
|
||||
|
||||
let player = PlayerBundle {
|
||||
player: PlayerControlled,
|
||||
position: Position::AtNode(pacman_start_node),
|
||||
velocity: Velocity::default(),
|
||||
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);
|
||||
@@ -152,23 +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 {
|
||||
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((handle_input, interact_system, directional_render_system, render_system).chain());
|
||||
schedule.add_systems(
|
||||
(
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -203,7 +286,6 @@ impl Game {
|
||||
// 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<()> {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
use crate::entity::direction::Direction;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum GameCommand {
|
||||
MovePlayer(Direction),
|
||||
Exit,
|
||||
TogglePause,
|
||||
ToggleDebug,
|
||||
MuteAudio,
|
||||
ResetLevel,
|
||||
}
|
||||
@@ -4,12 +4,12 @@ pub mod app;
|
||||
pub mod asset;
|
||||
pub mod audio;
|
||||
pub mod constants;
|
||||
pub mod ecs;
|
||||
pub mod entity;
|
||||
pub mod error;
|
||||
pub mod events;
|
||||
pub mod game;
|
||||
pub mod helpers;
|
||||
pub mod input;
|
||||
pub mod map;
|
||||
pub mod platform;
|
||||
pub mod systems;
|
||||
pub mod texture;
|
||||
|
||||
@@ -10,14 +10,14 @@ mod asset;
|
||||
mod audio;
|
||||
mod constants;
|
||||
|
||||
mod ecs;
|
||||
mod entity;
|
||||
mod error;
|
||||
mod events;
|
||||
mod game;
|
||||
mod helpers;
|
||||
mod input;
|
||||
mod map;
|
||||
mod platform;
|
||||
mod systems;
|
||||
mod texture;
|
||||
|
||||
/// The main entry point of the application.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
//! Map construction and building functionality.
|
||||
|
||||
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD};
|
||||
use crate::ecs::NodeId;
|
||||
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::texture::sprite::{Sprite, SpriteAtlas};
|
||||
use crate::systems::movement::NodeId;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
use bevy_ecs::resource::Resource;
|
||||
use glam::{IVec2, Vec2};
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
@@ -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}")))?;
|
||||
|
||||
|
||||
@@ -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
27
src/systems/blinking.rs
Normal 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
48
src/systems/collision.rs
Normal 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
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/systems/components.rs
Normal file
114
src/systems/components.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
|
||||
use bitflags::bitflags;
|
||||
|
||||
use crate::{
|
||||
entity::graph::TraversalFlags,
|
||||
systems::movement::{Movable, MovementState, Position},
|
||||
texture::{animated::AnimatedTexture, sprite::AtlasTile},
|
||||
};
|
||||
|
||||
/// A tag component for entities that are controlled by the player.
|
||||
#[derive(Default, Component)]
|
||||
pub struct PlayerControlled;
|
||||
|
||||
/// A tag component denoting the type of entity.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EntityType {
|
||||
Player,
|
||||
Ghost,
|
||||
Pellet,
|
||||
PowerPellet,
|
||||
}
|
||||
|
||||
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.
|
||||
///
|
||||
/// This is intended to be modified by other entities allowing animation.
|
||||
#[derive(Component)]
|
||||
pub struct Renderable {
|
||||
pub sprite: AtlasTile,
|
||||
pub layer: u8,
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
/// 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],
|
||||
}
|
||||
|
||||
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)]
|
||||
pub struct PlayerBundle {
|
||||
pub player: PlayerControlled,
|
||||
pub position: Position,
|
||||
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)]
|
||||
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);
|
||||
45
src/systems/control.rs
Normal file
45
src/systems/control.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use bevy_ecs::{
|
||||
event::{EventReader, EventWriter},
|
||||
prelude::ResMut,
|
||||
query::With,
|
||||
system::Query,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
error::GameError,
|
||||
events::{GameCommand, GameEvent},
|
||||
systems::components::{GlobalState, PlayerControlled},
|
||||
systems::movement::Movable,
|
||||
};
|
||||
|
||||
// Handles player input and control
|
||||
pub fn player_system(
|
||||
mut events: EventReader<GameEvent>,
|
||||
mut state: ResMut<GlobalState>,
|
||||
mut players: Query<&mut Movable, With<PlayerControlled>>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
) {
|
||||
// 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!("No/multiple entities queried for player system: {}", e)).into());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle events
|
||||
for event in events.read() {
|
||||
if let GameEvent::Command(command) = event {
|
||||
match command {
|
||||
GameCommand::MovePlayer(direction) => {
|
||||
movable.requested_direction = Some(*direction);
|
||||
}
|
||||
GameCommand::Exit => {
|
||||
state.exit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy_ecs::{
|
||||
event::EventWriter,
|
||||
resource::Resource,
|
||||
system::{Commands, 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, game::events::GameEvent, input::commands::GameCommand};
|
||||
|
||||
pub mod commands;
|
||||
use crate::{
|
||||
entity::direction::Direction,
|
||||
events::{GameCommand, GameEvent},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Resource)]
|
||||
pub struct Bindings {
|
||||
@@ -42,7 +39,7 @@ impl Default for Bindings {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_input(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>, mut pump: NonSendMut<&'static mut EventPump>) {
|
||||
pub fn input_system(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>, mut pump: NonSendMut<&'static mut EventPump>) {
|
||||
for event in pump.poll_iter() {
|
||||
match event {
|
||||
Event::Quit { .. } => {
|
||||
13
src/systems/mod.rs
Normal file
13
src/systems/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
//! The Entity-Component-System (ECS) module.
|
||||
//!
|
||||
//! 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;
|
||||
272
src/systems/movement.rs
Normal file
272
src/systems/movement.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
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};
|
||||
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 {
|
||||
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 MovementState, &mut Movable, &mut Position, &EntityType)>,
|
||||
mut errors: EventWriter<GameError>,
|
||||
) {
|
||||
for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() {
|
||||
let distance = movable.speed * 60.0 * delta_time.0;
|
||||
|
||||
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 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,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
errors.write(
|
||||
EntityError::InvalidMovement(format!(
|
||||
"No edge found in direction {:?} from node {}",
|
||||
requested_direction, position.node
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// 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: Moving on non-existent edge from {} to {}",
|
||||
current_node, target_node
|
||||
))
|
||||
.into(),
|
||||
);
|
||||
*movement_state = MovementState::Stopped;
|
||||
position.edge_progress = None;
|
||||
}
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/systems/profiling.rs
Normal file
32
src/systems/profiling.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use bevy_ecs::prelude::Resource;
|
||||
use bevy_ecs::system::{IntoSystem, System};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub struct SystemTimings {
|
||||
pub timings: Mutex<HashMap<&'static str, Duration>>,
|
||||
}
|
||||
|
||||
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(mut timings) = world.get_resource_mut::<SystemTimings>() {
|
||||
timings.timings.lock().insert(name, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,51 @@
|
||||
use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity};
|
||||
use crate::error::{EntityError, GameError, TextureError};
|
||||
use crate::error::{GameError, TextureError};
|
||||
use crate::map::builder::Map;
|
||||
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)>,
|
||||
mut renderables: Query<(&MovementState, &Movable, &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()
|
||||
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[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;
|
||||
@@ -31,7 +53,10 @@ pub fn directional_render_system(
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-send resource for the map texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||
pub struct MapTextureResource(pub Texture<'static>);
|
||||
|
||||
/// A non-send resource for the backbuffer texture. This just wraps the texture with a type so it can be differentiated when exposed as a resource.
|
||||
pub struct BackbufferResource(pub Texture<'static>);
|
||||
|
||||
pub fn render_system(
|
||||
@@ -40,9 +65,13 @@ pub fn render_system(
|
||||
mut backbuffer: NonSendMut<BackbufferResource>,
|
||||
mut atlas: NonSendMut<SpriteAtlas>,
|
||||
map: Res<Map>,
|
||||
mut 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();
|
||||
@@ -55,13 +84,12 @@ 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() {
|
||||
for (_, renderable, position) in renderables.iter() {
|
||||
let pos = position.get_pixel_pos(&map.graph);
|
||||
match pos {
|
||||
Ok(pos) => {
|
||||
@@ -92,4 +120,6 @@ pub fn render_system(
|
||||
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
|
||||
|
||||
canvas.present();
|
||||
|
||||
dirty.0 = false;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
use crate::error::{AnimatedTextureError, GameError, GameResult, TextureError};
|
||||
use crate::texture::sprite::{AtlasTile, SpriteAtlas};
|
||||
use crate::texture::sprite::AtlasTile;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnimatedTexture {
|
||||
@@ -40,12 +37,6 @@ impl AnimatedTexture {
|
||||
&self.tiles[self.current_frame]
|
||||
}
|
||||
|
||||
pub fn render<T: RenderTarget>(&self, canvas: &mut Canvas<T>, atlas: &mut SpriteAtlas, dest: Rect) -> GameResult<()> {
|
||||
let mut tile = *self.current_tile();
|
||||
tile.render(canvas, atlas, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the current frame index.
|
||||
#[allow(dead_code)]
|
||||
pub fn current_frame(&self) -> usize {
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
use sdl2::rect::Rect;
|
||||
use sdl2::render::{Canvas, RenderTarget};
|
||||
|
||||
use crate::entity::direction::Direction;
|
||||
use crate::error::GameResult;
|
||||
use crate::texture::animated::AnimatedTexture;
|
||||
use crate::texture::sprite::SpriteAtlas;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DirectionalAnimatedTexture {
|
||||
textures: [Option<AnimatedTexture>; 4],
|
||||
stopped_textures: [Option<AnimatedTexture>; 4],
|
||||
}
|
||||
|
||||
impl DirectionalAnimatedTexture {
|
||||
pub fn new(textures: [Option<AnimatedTexture>; 4], stopped_textures: [Option<AnimatedTexture>; 4]) -> Self {
|
||||
Self {
|
||||
textures,
|
||||
stopped_textures,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, dt: f32) {
|
||||
for texture in self.textures.iter_mut().flatten() {
|
||||
texture.tick(dt);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> GameResult<()> {
|
||||
if let Some(texture) = &self.textures[direction.as_usize()] {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_stopped<T: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<T>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
direction: Direction,
|
||||
) -> GameResult<()> {
|
||||
if let Some(texture) = &self.stopped_textures[direction.as_usize()] {
|
||||
texture.render(canvas, atlas, dest)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the texture has a direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_direction(&self, direction: Direction) -> bool {
|
||||
self.textures[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
/// Returns true if the texture has a stopped direction.
|
||||
#[allow(dead_code)]
|
||||
pub fn has_stopped_direction(&self, direction: Direction) -> bool {
|
||||
self.stopped_textures[direction.as_usize()].is_some()
|
||||
}
|
||||
|
||||
/// Returns the number of textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn texture_count(&self) -> usize {
|
||||
self.textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
|
||||
/// Returns the number of stopped textures.
|
||||
#[allow(dead_code)]
|
||||
pub fn stopped_texture_count(&self) -> usize {
|
||||
self.stopped_textures.iter().filter(|t| t.is_some()).count()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod animated;
|
||||
pub mod blinking;
|
||||
pub mod directional;
|
||||
pub mod sprite;
|
||||
pub mod text;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use bevy_ecs::resource::Resource;
|
||||
use glam::U16Vec2;
|
||||
use sdl2::pixels::Color;
|
||||
use sdl2::rect::Rect;
|
||||
@@ -9,33 +8,6 @@ use std::collections::HashMap;
|
||||
|
||||
use crate::error::TextureError;
|
||||
|
||||
/// A simple sprite for stationary items like pellets and energizers.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Sprite {
|
||||
pub atlas_tile: AtlasTile,
|
||||
}
|
||||
|
||||
impl Sprite {
|
||||
pub fn new(atlas_tile: AtlasTile) -> Self {
|
||||
Self { atlas_tile }
|
||||
}
|
||||
|
||||
pub fn render<C: RenderTarget>(
|
||||
&self,
|
||||
canvas: &mut Canvas<C>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
position: glam::Vec2,
|
||||
) -> Result<(), TextureError> {
|
||||
let dest = crate::helpers::centered_with_size(
|
||||
glam::IVec2::new(position.x as i32, position.y as i32),
|
||||
glam::UVec2::new(self.atlas_tile.size.x as u32, self.atlas_tile.size.y as u32),
|
||||
);
|
||||
let mut tile = self.atlas_tile;
|
||||
tile.render(canvas, atlas, dest)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AtlasMapper {
|
||||
pub frames: HashMap<String, MapperFrame>,
|
||||
@@ -49,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,
|
||||
@@ -58,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,
|
||||
@@ -69,7 +41,7 @@ impl AtlasTile {
|
||||
}
|
||||
|
||||
pub fn render_with_color<C: RenderTarget>(
|
||||
&mut self,
|
||||
&self,
|
||||
canvas: &mut Canvas<C>,
|
||||
atlas: &mut SpriteAtlas,
|
||||
dest: Rect,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
use pacman::entity::collision::{Collidable, CollisionSystem};
|
||||
use pacman::entity::traversal::Position;
|
||||
|
||||
struct MockCollidable {
|
||||
pos: Position,
|
||||
}
|
||||
|
||||
impl Collidable for MockCollidable {
|
||||
fn position(&self) -> Position {
|
||||
self.pos
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_colliding_with() {
|
||||
let entity1 = MockCollidable {
|
||||
pos: Position::AtNode(1),
|
||||
};
|
||||
let entity2 = MockCollidable {
|
||||
pos: Position::AtNode(1),
|
||||
};
|
||||
let entity3 = MockCollidable {
|
||||
pos: Position::AtNode(2),
|
||||
};
|
||||
let entity4 = MockCollidable {
|
||||
pos: Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
assert!(entity1.is_colliding_with(&entity2));
|
||||
assert!(!entity1.is_colliding_with(&entity3));
|
||||
assert!(entity1.is_colliding_with(&entity4));
|
||||
assert!(entity3.is_colliding_with(&entity4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collision_system_register_and_query() {
|
||||
let mut collision_system = CollisionSystem::default();
|
||||
|
||||
let pos1 = Position::AtNode(1);
|
||||
let entity1 = collision_system.register_entity(pos1);
|
||||
|
||||
let pos2 = Position::BetweenNodes {
|
||||
from: 1,
|
||||
to: 2,
|
||||
traversed: 0.5,
|
||||
};
|
||||
let entity2 = collision_system.register_entity(pos2);
|
||||
|
||||
let pos3 = Position::AtNode(3);
|
||||
let entity3 = collision_system.register_entity(pos3);
|
||||
|
||||
// Test entities_at_node
|
||||
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
|
||||
assert_eq!(collision_system.entities_at_node(2), &[entity2]);
|
||||
assert_eq!(collision_system.entities_at_node(3), &[entity3]);
|
||||
assert_eq!(collision_system.entities_at_node(4), &[] as &[u32]);
|
||||
|
||||
// Test potential_collisions
|
||||
let mut collisions1 = collision_system.potential_collisions(&pos1);
|
||||
collisions1.sort_unstable();
|
||||
assert_eq!(collisions1, vec![entity1, entity2]);
|
||||
|
||||
let mut collisions2 = collision_system.potential_collisions(&pos2);
|
||||
collisions2.sort_unstable();
|
||||
assert_eq!(collisions2, vec![entity1, entity2]);
|
||||
|
||||
let mut collisions3 = collision_system.potential_collisions(&pos3);
|
||||
collisions3.sort_unstable();
|
||||
assert_eq!(collisions3, vec![entity3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collision_system_update() {
|
||||
let mut collision_system = CollisionSystem::default();
|
||||
|
||||
let entity1 = collision_system.register_entity(Position::AtNode(1));
|
||||
|
||||
assert_eq!(collision_system.entities_at_node(1), &[entity1]);
|
||||
assert_eq!(collision_system.entities_at_node(2), &[] as &[u32]);
|
||||
|
||||
collision_system.update_position(entity1, Position::AtNode(2));
|
||||
|
||||
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
|
||||
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
|
||||
|
||||
collision_system.update_position(
|
||||
entity1,
|
||||
Position::BetweenNodes {
|
||||
from: 2,
|
||||
to: 3,
|
||||
traversed: 0.1,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
|
||||
assert_eq!(collision_system.entities_at_node(2), &[entity1]);
|
||||
assert_eq!(collision_system.entities_at_node(3), &[entity1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collision_system_remove() {
|
||||
let mut collision_system = CollisionSystem::default();
|
||||
|
||||
let entity1 = collision_system.register_entity(Position::AtNode(1));
|
||||
let entity2 = collision_system.register_entity(Position::AtNode(1));
|
||||
|
||||
assert_eq!(collision_system.entities_at_node(1), &[entity1, entity2]);
|
||||
|
||||
collision_system.remove_entity(entity1);
|
||||
|
||||
assert_eq!(collision_system.entities_at_node(1), &[entity2]);
|
||||
|
||||
collision_system.remove_entity(entity2);
|
||||
assert_eq!(collision_system.entities_at_node(1), &[] as &[u32]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use pacman::{
|
||||
asset::{get_asset_bytes, Asset},
|
||||
game::state::ATLAS_FRAMES,
|
||||
game::ATLAS_FRAMES,
|
||||
texture::sprite::{AtlasMapper, SpriteAtlas},
|
||||
};
|
||||
use sdl2::{
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::texture::animated::AnimatedTexture;
|
||||
use pacman::texture::directional::DirectionalAnimatedTexture;
|
||||
use pacman::texture::sprite::AtlasTile;
|
||||
use sdl2::pixels::Color;
|
||||
|
||||
fn mock_atlas_tile(id: u32) -> AtlasTile {
|
||||
AtlasTile {
|
||||
pos: U16Vec2::new(0, 0),
|
||||
size: U16Vec2::new(16, 16),
|
||||
color: Some(Color::RGB(id as u8, 0, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_animated_texture(id: u32) -> AnimatedTexture {
|
||||
AnimatedTexture::new(vec![mock_atlas_tile(id)], 0.1).expect("Invalid frame duration")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_partial_directions() {
|
||||
let mut textures = [None, None, None, None];
|
||||
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
assert_eq!(texture.texture_count(), 1);
|
||||
assert!(texture.has_direction(Direction::Up));
|
||||
assert!(!texture.has_direction(Direction::Down));
|
||||
assert!(!texture.has_direction(Direction::Left));
|
||||
assert!(!texture.has_direction(Direction::Right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_all_directions() {
|
||||
let mut textures = [None, None, None, None];
|
||||
let directions = [
|
||||
(Direction::Up, 1),
|
||||
(Direction::Down, 2),
|
||||
(Direction::Left, 3),
|
||||
(Direction::Right, 4),
|
||||
];
|
||||
|
||||
for (direction, id) in directions {
|
||||
textures[direction.as_usize()] = Some(mock_animated_texture(id));
|
||||
}
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
assert_eq!(texture.texture_count(), 4);
|
||||
for direction in &[Direction::Up, Direction::Down, Direction::Left, Direction::Right] {
|
||||
assert!(texture.has_direction(*direction));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_stopped() {
|
||||
let mut stopped_textures = [None, None, None, None];
|
||||
stopped_textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
|
||||
|
||||
let texture = DirectionalAnimatedTexture::new([None, None, None, None], stopped_textures);
|
||||
|
||||
assert_eq!(texture.stopped_texture_count(), 1);
|
||||
assert!(texture.has_stopped_direction(Direction::Up));
|
||||
assert!(!texture.has_stopped_direction(Direction::Down));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_directional_texture_tick() {
|
||||
let mut textures = [None, None, None, None];
|
||||
textures[Direction::Up.as_usize()] = Some(mock_animated_texture(1));
|
||||
let mut texture = DirectionalAnimatedTexture::new(textures, [None, None, None, None]);
|
||||
|
||||
// This is a bit of a placeholder, since we can't inspect the inner state easily.
|
||||
// We're just ensuring the tick method runs without panicking.
|
||||
texture.tick(0.1);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use pacman::constants::RAW_BOARD;
|
||||
use pacman::map::builder::Map;
|
||||
|
||||
mod collision;
|
||||
mod item;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
use pacman::entity::ghost::{Ghost, GhostType};
|
||||
use pacman::entity::graph::Graph;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
|
||||
|
||||
for ghost_type in &ghost_types {
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_creation() {
|
||||
let graph = Graph::new();
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
let ghost = Ghost::new(&graph, 0, GhostType::Blinky, &atlas).unwrap();
|
||||
|
||||
assert_eq!(ghost.ghost_type, GhostType::Blinky);
|
||||
assert_eq!(ghost.traverser.position.from_node_id(), 0);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::entity::graph::{EdgePermissions, Graph, Node};
|
||||
use pacman::entity::traversal::{Position, Traverser};
|
||||
use pacman::entity::graph::{Graph, Node, TraversalFlags};
|
||||
|
||||
fn create_test_graph() -> Graph {
|
||||
let mut graph = Graph::new();
|
||||
@@ -79,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]
|
||||
@@ -119,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();
|
||||
@@ -150,68 +149,3 @@ fn should_find_edge_between_nodes() {
|
||||
let non_existent_edge = graph.find_edge(0, 99);
|
||||
assert!(non_existent_edge.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_basic() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Left, &|_| true);
|
||||
|
||||
traverser.set_next_direction(Direction::Up);
|
||||
assert!(traverser.next_direction.is_some());
|
||||
assert_eq!(traverser.next_direction.unwrap().0, Direction::Up);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_advance() {
|
||||
let graph = create_test_graph();
|
||||
let mut traverser = Traverser::new(&graph, 0, Direction::Right, &|_| true);
|
||||
|
||||
traverser.advance(&graph, 5.0, &|_| true).unwrap();
|
||||
|
||||
match traverser.position {
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
assert_eq!(from, 0);
|
||||
assert_eq!(to, 1);
|
||||
assert_eq!(traversed, 5.0);
|
||||
}
|
||||
_ => panic!("Expected to be between nodes"),
|
||||
}
|
||||
|
||||
traverser.advance(&graph, 3.0, &|_| true).unwrap();
|
||||
|
||||
match traverser.position {
|
||||
Position::BetweenNodes { from, to, traversed } => {
|
||||
assert_eq!(from, 0);
|
||||
assert_eq!(to, 1);
|
||||
assert_eq!(traversed, 8.0);
|
||||
}
|
||||
_ => panic!("Expected to be between nodes"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_traverser_with_permissions() {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
|
||||
graph
|
||||
.add_edge(node1, node2, false, None, Direction::Right, EdgePermissions::GhostsOnly)
|
||||
.unwrap();
|
||||
|
||||
// Pacman can't traverse ghost-only edges
|
||||
let mut traverser = Traverser::new(&graph, node1, Direction::Right, &|edge| {
|
||||
matches!(edge.permissions, EdgePermissions::All)
|
||||
});
|
||||
|
||||
traverser
|
||||
.advance(&graph, 5.0, &|edge| matches!(edge.permissions, EdgePermissions::All))
|
||||
.unwrap();
|
||||
|
||||
// Should still be at the node since it can't traverse
|
||||
assert!(traverser.position.is_at_node());
|
||||
}
|
||||
|
||||
@@ -1,53 +1,46 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::{
|
||||
entity::{
|
||||
collision::Collidable,
|
||||
item::{FruitKind, Item, ItemType},
|
||||
},
|
||||
texture::sprite::{AtlasTile, Sprite},
|
||||
};
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
// use glam::U16Vec2;
|
||||
// use pacman::texture::sprite::{AtlasTile, Sprite};
|
||||
|
||||
#[test]
|
||||
fn test_item_type_get_score() {
|
||||
assert_eq!(ItemType::Pellet.get_score(), 10);
|
||||
assert_eq!(ItemType::Energizer.get_score(), 50);
|
||||
// #[test]
|
||||
// fn test_item_type_get_score() {
|
||||
// assert_eq!(ItemType::Pellet.get_score(), 10);
|
||||
// assert_eq!(ItemType::Energizer.get_score(), 50);
|
||||
|
||||
let fruit = ItemType::Fruit { kind: FruitKind::Apple };
|
||||
assert_eq!(fruit.get_score(), 100);
|
||||
}
|
||||
// let fruit = ItemType::Fruit { kind: FruitKind::Apple };
|
||||
// assert_eq!(fruit.get_score(), 100);
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_fruit_kind_increasing_score() {
|
||||
// Build a list of fruit kinds, sorted by their index
|
||||
let mut kinds = FruitKind::iter()
|
||||
.map(|kind| (kind.index(), kind.get_score()))
|
||||
.collect::<Vec<_>>();
|
||||
kinds.sort_unstable_by_key(|(index, _)| *index);
|
||||
// #[test]
|
||||
// fn test_fruit_kind_increasing_score() {
|
||||
// // Build a list of fruit kinds, sorted by their index
|
||||
// let mut kinds = FruitKind::iter()
|
||||
// .map(|kind| (kind.index(), kind.get_score()))
|
||||
// .collect::<Vec<_>>();
|
||||
// kinds.sort_unstable_by_key(|(index, _)| *index);
|
||||
|
||||
assert_eq!(kinds.len(), FruitKind::COUNT);
|
||||
// assert_eq!(kinds.len(), FruitKind::COUNT);
|
||||
|
||||
// Check that the score increases as expected
|
||||
for window in kinds.windows(2) {
|
||||
let ((_, prev), (_, next)) = (window[0], window[1]);
|
||||
assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}");
|
||||
}
|
||||
}
|
||||
// // Check that the score increases as expected
|
||||
// for window in kinds.windows(2) {
|
||||
// let ((_, prev), (_, next)) = (window[0], window[1]);
|
||||
// assert!(prev < next, "Fruits should have increasing scores, but {prev:?} < {next:?}");
|
||||
// }
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn test_item_creation_and_collection() {
|
||||
let atlas_tile = AtlasTile {
|
||||
pos: U16Vec2::new(0, 0),
|
||||
size: U16Vec2::new(16, 16),
|
||||
color: None,
|
||||
};
|
||||
let sprite = Sprite::new(atlas_tile);
|
||||
let mut item = Item::new(0, ItemType::Pellet, sprite);
|
||||
// #[test]
|
||||
// fn test_item_creation_and_collection() {
|
||||
// let atlas_tile = AtlasTile {
|
||||
// pos: U16Vec2::new(0, 0),
|
||||
// size: U16Vec2::new(16, 16),
|
||||
// color: None,
|
||||
// };
|
||||
// let sprite = Sprite::new(atlas_tile);
|
||||
// let mut item = Item::new(0, ItemType::Pellet, sprite);
|
||||
|
||||
assert!(!item.is_collected());
|
||||
assert_eq!(item.get_score(), 10);
|
||||
assert_eq!(item.position().from_node_id(), 0);
|
||||
// assert!(!item.is_collected());
|
||||
// assert_eq!(item.get_score(), 10);
|
||||
// assert_eq!(item.position().from_node_id(), 0);
|
||||
|
||||
item.collect();
|
||||
assert!(item.is_collected());
|
||||
}
|
||||
// item.collect();
|
||||
// assert!(item.is_collected());
|
||||
// }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use glam::Vec2;
|
||||
use pacman::constants::{CELL_SIZE, RAW_BOARD};
|
||||
use pacman::map::builder::Map;
|
||||
use sdl2::render::Texture;
|
||||
|
||||
#[test]
|
||||
fn test_map_creation() {
|
||||
@@ -34,60 +33,60 @@ fn test_map_node_positions() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_items() {
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
// #[test]
|
||||
// fn test_generate_items() {
|
||||
// use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
// use std::collections::HashMap;
|
||||
|
||||
let map = Map::new(RAW_BOARD).unwrap();
|
||||
// let map = Map::new(RAW_BOARD).unwrap();
|
||||
|
||||
// Create a minimal atlas for testing
|
||||
let mut frames = HashMap::new();
|
||||
frames.insert(
|
||||
"maze/pellet.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
"maze/energizer.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 8,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
);
|
||||
// // Create a minimal atlas for testing
|
||||
// let mut frames = HashMap::new();
|
||||
// frames.insert(
|
||||
// "maze/pellet.png".to_string(),
|
||||
// MapperFrame {
|
||||
// x: 0,
|
||||
// y: 0,
|
||||
// width: 8,
|
||||
// height: 8,
|
||||
// },
|
||||
// );
|
||||
// frames.insert(
|
||||
// "maze/energizer.png".to_string(),
|
||||
// MapperFrame {
|
||||
// x: 8,
|
||||
// y: 0,
|
||||
// width: 8,
|
||||
// height: 8,
|
||||
// },
|
||||
// );
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let texture = unsafe { std::mem::transmute::<usize, Texture<'static>>(0usize) };
|
||||
let atlas = SpriteAtlas::new(texture, mapper);
|
||||
// let mapper = AtlasMapper { frames };
|
||||
// let texture = unsafe { std::mem::transmute::<usize, Texture<'static>>(0usize) };
|
||||
// let atlas = SpriteAtlas::new(texture, mapper);
|
||||
|
||||
let items = map.generate_items(&atlas).unwrap();
|
||||
// let items = map.generate_items(&atlas).unwrap();
|
||||
|
||||
// Verify we have items
|
||||
assert!(!items.is_empty());
|
||||
// // Verify we have items
|
||||
// assert!(!items.is_empty());
|
||||
|
||||
// Count different types
|
||||
let pellet_count = items
|
||||
.iter()
|
||||
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet))
|
||||
.count();
|
||||
let energizer_count = items
|
||||
.iter()
|
||||
.filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer))
|
||||
.count();
|
||||
// // Count different types
|
||||
// let pellet_count = items
|
||||
// .iter()
|
||||
// .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Pellet))
|
||||
// .count();
|
||||
// let energizer_count = items
|
||||
// .iter()
|
||||
// .filter(|item| matches!(item.item_type, pacman::entity::item::ItemType::Energizer))
|
||||
// .count();
|
||||
|
||||
// Should have both types
|
||||
assert_eq!(pellet_count, 240);
|
||||
assert_eq!(energizer_count, 4);
|
||||
// // Should have both types
|
||||
// assert_eq!(pellet_count, 240);
|
||||
// assert_eq!(energizer_count, 4);
|
||||
|
||||
// All items should be uncollected initially
|
||||
assert!(items.iter().all(|item| !item.is_collected()));
|
||||
// // All items should be uncollected initially
|
||||
// assert!(items.iter().all(|item| !item.is_collected()));
|
||||
|
||||
// All items should have valid node indices
|
||||
assert!(items.iter().all(|item| item.node_index < map.graph.node_count()));
|
||||
}
|
||||
// // All items should have valid node indices
|
||||
// assert!(items.iter().all(|item| item.node_index < map.graph.node_count()));
|
||||
// }
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::entity::graph::{Graph, Node};
|
||||
use pacman::entity::pacman::Pacman;
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_graph() -> Graph {
|
||||
let mut graph = Graph::new();
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(16.0, 0.0),
|
||||
});
|
||||
let node3 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 16.0),
|
||||
});
|
||||
|
||||
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
graph.connect(node1, node3, false, None, Direction::Down).unwrap();
|
||||
|
||||
graph
|
||||
}
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("pacman/{dir}_a.png"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("pacman/{dir}_b.png"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
frames.insert(
|
||||
"pacman/full.png".to_string(),
|
||||
MapperFrame {
|
||||
x: 64,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pacman_creation() {
|
||||
let graph = create_test_graph();
|
||||
let atlas = create_test_atlas();
|
||||
let pacman = Pacman::new(&graph, 0, &atlas).unwrap();
|
||||
|
||||
assert!(pacman.traverser.position.is_at_node());
|
||||
assert_eq!(pacman.traverser.direction, Direction::Left);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
use pacman::entity::direction::Direction;
|
||||
use pacman::entity::ghost::{Ghost, GhostType};
|
||||
use pacman::entity::graph::{Graph, Node};
|
||||
use pacman::texture::sprite::{AtlasMapper, MapperFrame, SpriteAtlas};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn create_test_atlas() -> SpriteAtlas {
|
||||
let mut frames = HashMap::new();
|
||||
let directions = ["up", "down", "left", "right"];
|
||||
let ghost_types = ["blinky", "pinky", "inky", "clyde"];
|
||||
|
||||
for ghost_type in &ghost_types {
|
||||
for (i, dir) in directions.iter().enumerate() {
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "a"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 0,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
frames.insert(
|
||||
format!("ghost/{}/{}_{}.png", ghost_type, dir, "b"),
|
||||
MapperFrame {
|
||||
x: i as u16 * 16,
|
||||
y: 16,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mapper = AtlasMapper { frames };
|
||||
let dummy_texture = unsafe { std::mem::zeroed() };
|
||||
SpriteAtlas::new(dummy_texture, mapper)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_pathfinding() {
|
||||
// Create a simple test graph
|
||||
let mut graph = Graph::new();
|
||||
|
||||
// Add nodes in a simple line: 0 -> 1 -> 2
|
||||
let node0 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(10.0, 0.0),
|
||||
});
|
||||
let node2 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(20.0, 0.0),
|
||||
});
|
||||
|
||||
// Connect the nodes
|
||||
graph.connect(node0, node1, false, None, Direction::Right).unwrap();
|
||||
graph.connect(node1, node2, false, None, Direction::Right).unwrap();
|
||||
|
||||
// Create a test atlas for the ghost
|
||||
let atlas = create_test_atlas();
|
||||
|
||||
// Create a ghost at node 0
|
||||
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
|
||||
|
||||
// Test pathfinding from node 0 to node 2
|
||||
let path = ghost.calculate_path_to_target(&graph, node2);
|
||||
|
||||
assert!(path.is_ok());
|
||||
let path = path.unwrap();
|
||||
assert!(
|
||||
path == vec![node0, node1, node2] || path == vec![node2, node1, node0],
|
||||
"Path was not what was expected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_pathfinding_no_path() {
|
||||
// Create a test graph with disconnected components
|
||||
let mut graph = Graph::new();
|
||||
|
||||
let node0 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
let node1 = graph.add_node(Node {
|
||||
position: glam::Vec2::new(10.0, 0.0),
|
||||
});
|
||||
|
||||
// Don't connect the nodes
|
||||
let atlas = create_test_atlas();
|
||||
let ghost = Ghost::new(&graph, node0, GhostType::Blinky, &atlas).unwrap();
|
||||
|
||||
// Test pathfinding when no path exists
|
||||
let path = ghost.calculate_path_to_target(&graph, node1);
|
||||
|
||||
assert!(path.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ghost_debug_colors() {
|
||||
let atlas = create_test_atlas();
|
||||
let mut graph = Graph::new();
|
||||
let node = graph.add_node(Node {
|
||||
position: glam::Vec2::new(0.0, 0.0),
|
||||
});
|
||||
|
||||
let blinky = Ghost::new(&graph, node, GhostType::Blinky, &atlas).unwrap();
|
||||
let pinky = Ghost::new(&graph, node, GhostType::Pinky, &atlas).unwrap();
|
||||
let inky = Ghost::new(&graph, node, GhostType::Inky, &atlas).unwrap();
|
||||
let clyde = Ghost::new(&graph, node, GhostType::Clyde, &atlas).unwrap();
|
||||
|
||||
// Test that each ghost has a different debug color
|
||||
let colors = std::collections::HashSet::from([
|
||||
blinky.debug_color(),
|
||||
pinky.debug_color(),
|
||||
inky.debug_color(),
|
||||
clyde.debug_color(),
|
||||
]);
|
||||
assert_eq!(colors.len(), 4, "All ghost colors should be unique");
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use glam::U16Vec2;
|
||||
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, Sprite, SpriteAtlas};
|
||||
use pacman::texture::sprite::{AtlasMapper, AtlasTile, MapperFrame, SpriteAtlas};
|
||||
use sdl2::pixels::Color;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -92,12 +92,3 @@ fn test_atlas_tile_new_and_with_color() {
|
||||
let tile_with_color = tile.with_color(color);
|
||||
assert_eq!(tile_with_color.color, Some(color));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sprite_new() {
|
||||
let atlas_tile = AtlasTile::new(U16Vec2::new(0, 0), U16Vec2::new(16, 16), None);
|
||||
let sprite = Sprite::new(atlas_tile);
|
||||
|
||||
assert_eq!(sprite.atlas_tile.pos, atlas_tile.pos);
|
||||
assert_eq!(sprite.atlas_tile.size, atlas_tile.size);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user