feat: rewrite movement systems separately for player/ghosts

This commit is contained in:
2025-08-16 11:44:10 -05:00
parent 514a447162
commit 78300bdf9c
11 changed files with 476 additions and 357 deletions

View File

@@ -8,23 +8,22 @@ use crate::error::{GameError, GameResult, TextureError};
use crate::events::GameEvent; use crate::events::GameEvent;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::blinking::Blinking; use crate::systems::blinking::Blinking;
use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::movement::{BufferedDirection, Position, Velocity};
use crate::systems::player::player_movement_system;
use crate::systems::profiling::SystemId; use crate::systems::profiling::SystemId;
use crate::systems::{ use crate::systems::{
audio::{audio_system, AudioEvent, AudioResource}, audio::{audio_system, AudioEvent, AudioResource},
blinking::blinking_system, blinking::blinking_system,
collision::collision_system, collision::collision_system,
components::{ components::{
AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, GhostBehavior, GhostBundle, GhostCollider, GhostType, AudioState, Collider, DeltaTime, DirectionalAnimated, EntityType, Ghost, GhostBundle, GhostCollider, GlobalState,
GlobalState, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ItemBundle, ItemCollider, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, ScoreResource,
ScoreResource,
}, },
control::player_system,
debug::{debug_render_system, DebugState, DebugTextureResource}, debug::{debug_render_system, DebugState, DebugTextureResource},
ghost::ghost_system, ghost::ghost_movement_system,
input::input_system, input::input_system,
item::item_system, item::item_system,
movement::movement_system, player::player_control_system,
profiling::{profile, SystemTimings}, profiling::{profile, SystemTimings},
render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource}, render::{directional_render_system, dirty_render_system, render_system, BackbufferResource, MapTextureResource},
}; };
@@ -159,16 +158,12 @@ impl Game {
let player = PlayerBundle { let player = PlayerBundle {
player: PlayerControlled, player: PlayerControlled,
position: Position { position: Position::Stopped { node: pacman_start_node },
node: pacman_start_node, velocity: Velocity {
edge_progress: None,
},
movement_state: MovementState::Stopped,
movable: Movable {
speed: 1.15, speed: 1.15,
current_direction: Direction::Left, direction: Direction::Left,
requested_direction: Some(Direction::Left), // Start moving left immediately
}, },
buffered_direction: BufferedDirection::None,
sprite: Renderable { sprite: Renderable {
sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png") sprite: SpriteAtlas::get_tile(&atlas, "pacman/full.png")
.ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?, .ok_or_else(|| GameError::Texture(TextureError::AtlasTileNotFound("pacman/full.png".to_string())))?,
@@ -214,9 +209,9 @@ impl Game {
schedule.add_systems( schedule.add_systems(
( (
profile(SystemId::Input, input_system), profile(SystemId::Input, input_system),
profile(SystemId::Player, player_system), profile(SystemId::PlayerControls, player_control_system),
profile(SystemId::Ghost, ghost_system), profile(SystemId::PlayerMovement, player_movement_system),
profile(SystemId::Movement, movement_system), profile(SystemId::Ghost, ghost_movement_system),
profile(SystemId::Collision, collision_system), profile(SystemId::Collision, collision_system),
profile(SystemId::Item, item_system), profile(SystemId::Item, item_system),
profile(SystemId::Audio, audio_system), profile(SystemId::Audio, audio_system),
@@ -270,10 +265,7 @@ impl Game {
}; };
let mut item = world.spawn(ItemBundle { let mut item = world.spawn(ItemBundle {
position: Position { position: Position::Stopped { node: node_id },
node: node_id,
edge_progress: None,
},
sprite: Renderable { sprite: Renderable {
sprite, sprite,
layer: 1, layer: 1,
@@ -301,10 +293,10 @@ impl Game {
let ghost_start_positions = { let ghost_start_positions = {
let map = world.resource::<Map>(); let map = world.resource::<Map>();
[ [
(GhostType::Blinky, map.start_positions.blinky), (Ghost::Blinky, map.start_positions.blinky),
(GhostType::Pinky, map.start_positions.pinky), (Ghost::Pinky, map.start_positions.pinky),
(GhostType::Inky, map.start_positions.inky), (Ghost::Inky, map.start_positions.inky),
(GhostType::Clyde, map.start_positions.clyde), (Ghost::Clyde, map.start_positions.clyde),
] ]
}; };
@@ -364,17 +356,11 @@ impl Game {
} }
GhostBundle { GhostBundle {
ghost_type, ghost: ghost_type,
ghost_behavior: GhostBehavior::default(), position: Position::Stopped { node: start_node },
position: Position { velocity: Velocity {
node: start_node,
edge_progress: None,
},
movement_state: MovementState::Stopped,
movable: Movable {
speed: ghost_type.base_speed(), speed: ghost_type.base_speed(),
current_direction: Direction::Left, direction: Direction::Left,
requested_direction: Some(Direction::Left), // Start with some movement
}, },
sprite: Renderable { sprite: Renderable {
sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else( sprite: SpriteAtlas::get_tile(atlas, &format!("ghost/{}/left_a.png", ghost_type.as_str())).ok_or_else(

View File

@@ -19,7 +19,10 @@ pub fn collision_system(
// Check PACMAN × ITEM collisions // Check PACMAN × ITEM collisions
for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() { for (pacman_entity, pacman_pos, pacman_collider) in pacman_query.iter() {
for (item_entity, item_pos, item_collider) in item_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)) { match (
pacman_pos.get_pixel_position(&map.graph),
item_pos.get_pixel_position(&map.graph),
) {
(Ok(pacman_pixel), Ok(item_pixel)) => { (Ok(pacman_pixel), Ok(item_pixel)) => {
// Calculate the distance between the two entities's precise pixel positions // Calculate the distance between the two entities's precise pixel positions
let distance = pacman_pixel.distance(item_pixel); let distance = pacman_pixel.distance(item_pixel);

View File

@@ -3,7 +3,7 @@ use bitflags::bitflags;
use crate::{ use crate::{
entity::graph::TraversalFlags, entity::graph::TraversalFlags,
systems::movement::{Movable, MovementState, Position}, systems::movement::{BufferedDirection, Position, Velocity},
texture::{animated::AnimatedTexture, sprite::AtlasTile}, texture::{animated::AnimatedTexture, sprite::AtlasTile},
}; };
@@ -11,61 +11,42 @@ use crate::{
#[derive(Default, Component)] #[derive(Default, Component)]
pub struct PlayerControlled; pub struct PlayerControlled;
/// The four classic ghost types.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] #[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub enum GhostType { pub enum Ghost {
Blinky, Blinky,
Pinky, Pinky,
Inky, Inky,
Clyde, Clyde,
} }
impl GhostType { impl Ghost {
/// Returns the ghost type name for atlas lookups. /// Returns the ghost type name for atlas lookups.
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
GhostType::Blinky => "blinky", Ghost::Blinky => "blinky",
GhostType::Pinky => "pinky", Ghost::Pinky => "pinky",
GhostType::Inky => "inky", Ghost::Inky => "inky",
GhostType::Clyde => "clyde", Ghost::Clyde => "clyde",
} }
} }
/// Returns the base movement speed for this ghost type. /// Returns the base movement speed for this ghost type.
pub fn base_speed(self) -> f32 { pub fn base_speed(self) -> f32 {
match self { match self {
GhostType::Blinky => 1.0, Ghost::Blinky => 1.0,
GhostType::Pinky => 0.95, Ghost::Pinky => 0.95,
GhostType::Inky => 0.9, Ghost::Inky => 0.9,
GhostType::Clyde => 0.85, Ghost::Clyde => 0.85,
} }
} }
/// Returns the ghost's color for debug rendering. /// Returns the ghost's color for debug rendering.
pub fn debug_color(&self) -> sdl2::pixels::Color { pub fn debug_color(&self) -> sdl2::pixels::Color {
match self { match self {
GhostType::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red Ghost::Blinky => sdl2::pixels::Color::RGB(255, 0, 0), // Red
GhostType::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink Ghost::Pinky => sdl2::pixels::Color::RGB(255, 182, 255), // Pink
GhostType::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan Ghost::Inky => sdl2::pixels::Color::RGB(0, 255, 255), // Cyan
GhostType::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange Ghost::Clyde => sdl2::pixels::Color::RGB(255, 182, 85), // Orange
}
}
}
/// Ghost AI behavior component - controls randomized movement decisions.
#[derive(Component)]
pub struct GhostBehavior {
/// Timer for making new direction decisions
pub decision_timer: f32,
/// Interval between direction decisions (in seconds)
pub decision_interval: f32,
}
impl Default for GhostBehavior {
fn default() -> Self {
Self {
decision_timer: 0.0,
decision_interval: 0.5, // Make decisions every half second
} }
} }
} }
@@ -135,8 +116,8 @@ pub struct ItemCollider;
pub struct PlayerBundle { pub struct PlayerBundle {
pub player: PlayerControlled, pub player: PlayerControlled,
pub position: Position, pub position: Position,
pub movement_state: MovementState, pub velocity: Velocity,
pub movable: Movable, pub buffered_direction: BufferedDirection,
pub sprite: Renderable, pub sprite: Renderable,
pub directional_animated: DirectionalAnimated, pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType, pub entity_type: EntityType,
@@ -155,11 +136,9 @@ pub struct ItemBundle {
#[derive(Bundle)] #[derive(Bundle)]
pub struct GhostBundle { pub struct GhostBundle {
pub ghost_type: GhostType, pub ghost: Ghost,
pub ghost_behavior: GhostBehavior,
pub position: Position, pub position: Position,
pub movement_state: MovementState, pub velocity: Velocity,
pub movable: Movable,
pub sprite: Renderable, pub sprite: Renderable,
pub directional_animated: DirectionalAnimated, pub directional_animated: DirectionalAnimated,
pub entity_type: EntityType, pub entity_type: EntityType,

View File

@@ -1,58 +0,0 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
prelude::ResMut,
query::With,
system::Query,
};
use crate::{
error::GameError,
events::{GameCommand, GameEvent},
systems::components::{AudioState, GlobalState, PlayerControlled},
systems::debug::DebugState,
systems::movement::Movable,
};
// Handles player input and control
pub fn player_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
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
)));
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;
}
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
}
}
}

View File

@@ -187,7 +187,7 @@ pub fn debug_render_system(
DebugState::Collision => { DebugState::Collision => {
debug_canvas.set_draw_color(Color::GREEN); debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() { for (collider, position) in colliders.iter() {
let pos = position.get_pixel_pos(&map.graph).unwrap(); let pos = position.get_pixel_position(&map.graph).unwrap();
// Transform position and size using common methods // Transform position and size using common methods
let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size); let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size);

View File

@@ -3,11 +3,11 @@ use rand::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use crate::{ use crate::{
entity::direction::Direction, entity::{direction::Direction, graph::Edge},
map::builder::Map, map::builder::Map,
systems::{ systems::{
components::{DeltaTime, EntityType, GhostBehavior, GhostType}, components::{DeltaTime, Ghost},
movement::{Movable, Position}, movement::{Position, Velocity},
}, },
}; };
@@ -15,27 +15,55 @@ use crate::{
/// ///
/// This system runs on all ghosts and makes periodic decisions about /// This system runs on all ghosts and makes periodic decisions about
/// which direction to move in when they reach intersections. /// which direction to move in when they reach intersections.
pub fn ghost_system( pub fn ghost_movement_system(
map: Res<Map>, map: Res<Map>,
delta_time: Res<DeltaTime>, delta_time: Res<DeltaTime>,
mut ghosts: Query<(&mut GhostBehavior, &mut Movable, &Position, &EntityType, &GhostType)>, mut ghosts: Query<(&mut Ghost, &mut Velocity, &mut Position)>,
) { ) {
for (mut ghost_behavior, mut movable, position, entity_type, _ghost_type) in ghosts.iter_mut() { for (mut ghost, mut velocity, mut position) in ghosts.iter_mut() {
// Only process ghosts let mut distance = velocity.speed * 60.0 * delta_time.0;
if *entity_type != EntityType::Ghost { loop {
continue; match *position {
} Position::Stopped { node: current_node } => {
let intersection = &map.graph.adjacency_list[current_node];
let opposite = velocity.direction.opposite();
// Update decision timer let mut non_opposite_options: SmallVec<[Edge; 3]> = SmallVec::new();
ghost_behavior.decision_timer += delta_time.0;
// Check if we should make a new direction decision // Collect all available directions that ghosts can traverse
let should_decide = ghost_behavior.decision_timer >= ghost_behavior.decision_interval; for edge in Direction::DIRECTIONS.iter().flat_map(|d| intersection.get(*d)) {
let at_intersection = position.is_at_node(); if edge.traversal_flags.contains(crate::entity::graph::TraversalFlags::GHOST) {
if edge.direction != opposite {
non_opposite_options.push(edge);
}
}
}
if should_decide && at_intersection { let new_edge: Edge = if non_opposite_options.is_empty() {
choose_random_direction(&map, &mut movable, position); if let Some(edge) = intersection.get(opposite) {
ghost_behavior.decision_timer = 0.0; edge
} else {
break;
}
} else {
*non_opposite_options.choose(&mut SmallRng::from_os_rng()).unwrap()
};
velocity.direction = new_edge.direction;
*position = Position::Moving {
from: current_node,
to: new_edge.target,
remaining_distance: new_edge.distance,
};
}
Position::Moving { .. } => {
if let Some(overflow) = position.tick(distance) {
distance = overflow;
} else {
break;
}
}
}
} }
} }
} }
@@ -44,7 +72,7 @@ pub fn ghost_system(
/// ///
/// This function mirrors the behavior from the old ghost implementation, /// This function mirrors the behavior from the old ghost implementation,
/// preferring not to reverse direction unless it's the only option. /// preferring not to reverse direction unless it's the only option.
fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position) { fn choose_random_direction(map: &Map, velocity: &mut Velocity, position: &Position) {
let current_node = position.current_node(); let current_node = position.current_node();
let intersection = &map.graph.adjacency_list[current_node]; let intersection = &map.graph.adjacency_list[current_node];
@@ -64,14 +92,14 @@ fn choose_random_direction(map: &Map, movable: &mut Movable, position: &Position
let mut rng = SmallRng::from_os_rng(); let mut rng = SmallRng::from_os_rng();
// Filter out the opposite direction if possible, but allow it if we have limited options // Filter out the opposite direction if possible, but allow it if we have limited options
let opposite = movable.current_direction.opposite(); let opposite = velocity.direction.opposite();
let filtered_directions: Vec<_> = available_directions let filtered_directions: Vec<_> = available_directions
.iter() .iter()
.filter(|&&dir| dir != opposite || available_directions.len() <= 2) .filter(|&&dir| dir != opposite || available_directions.len() <= 2)
.collect(); .collect();
if let Some(&random_direction) = filtered_directions.choose(&mut rng) { if let Some(&random_direction) = filtered_directions.choose(&mut rng) {
movable.requested_direction = Some(*random_direction); velocity.direction = *random_direction;
} }
} }
} }

View File

@@ -7,12 +7,12 @@ pub mod audio;
pub mod blinking; pub mod blinking;
pub mod collision; pub mod collision;
pub mod components; pub mod components;
pub mod control;
pub mod debug; pub mod debug;
pub mod formatting; pub mod formatting;
pub mod ghost; pub mod ghost;
pub mod input; pub mod input;
pub mod item; pub mod item;
pub mod movement; pub mod movement;
pub mod player;
pub mod profiling; pub mod profiling;
pub mod render; pub mod render;

View File

@@ -2,48 +2,44 @@ use crate::entity::graph::Graph;
use crate::entity::{direction::Direction, graph::Edge}; use crate::entity::{direction::Direction, graph::Edge};
use crate::error::{EntityError, GameError, GameResult}; use crate::error::{EntityError, GameError, GameResult};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, EntityType}; use crate::systems::components::{DeltaTime, EntityType, PlayerControlled};
use bevy_ecs::component::Component; use bevy_ecs::component::Component;
use bevy_ecs::event::EventWriter; use bevy_ecs::event::EventWriter;
use bevy_ecs::query::With;
use bevy_ecs::system::{Query, Res}; use bevy_ecs::system::{Query, Res};
use glam::Vec2; use glam::Vec2;
/// A unique identifier for a node, represented by its index in the graph's storage. /// A unique identifier for a node, represented by its index in the graph's storage.
pub type NodeId = usize; pub type NodeId = usize;
/// Progress along an edge between two nodes. /// A component that represents the speed and cardinal direction of an entity.
#[derive(Debug, Clone, Copy, PartialEq)] /// Speed is static, only applied when the entity has an edge to traverse.
pub struct EdgeProgress { /// Direction is dynamic, but is controlled externally.
pub target_node: NodeId, #[derive(Component, Debug, Copy, Clone, PartialEq)]
/// Progress from 0.0 (at source node) to 1.0 (at target node) pub struct Velocity {
pub progress: f32, pub speed: f32,
pub direction: Direction,
}
/// A component that represents a direction change that is only remembered for a period of time.
/// This is used to allow entities to change direction before they reach their current target node (which consumes their buffered direction).
#[derive(Component, Debug, Copy, Clone, PartialEq)]
pub enum BufferedDirection {
None,
Some { direction: Direction, remaining_time: f32 },
} }
/// Pure spatial position component - works for both static and dynamic entities. /// Pure spatial position component - works for both static and dynamic entities.
#[derive(Component, Debug, Copy, Clone, PartialEq, Default)] #[derive(Component, Debug, Copy, Clone, PartialEq)]
pub struct Position { pub enum Position {
/// The current/primary node this entity is at or traveling from Stopped {
pub node: NodeId, node: NodeId,
/// If Some, entity is traveling between nodes. If None, entity is stationary at node. },
pub edge_progress: Option<EdgeProgress>, Moving {
} from: NodeId,
to: NodeId,
/// Explicit movement state - only for entities that can move. remaining_distance: f32,
#[derive(Component, Debug, Clone, Copy, PartialEq, Default)]
pub enum MovementState {
#[default]
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 { impl Position {
@@ -55,26 +51,32 @@ impl Position {
/// # Errors /// # Errors
/// ///
/// Returns an `EntityError` if the node or edge is not found. /// Returns an `EntityError` if the node or edge is not found.
pub fn get_pixel_pos(&self, graph: &Graph) -> GameResult<Vec2> { pub fn get_pixel_position(&self, graph: &Graph) -> GameResult<Vec2> {
let pos = match &self.edge_progress { let pos = match &self {
None => { Position::Stopped { node } => {
// Entity is stationary at a node // Entity is stationary at a node
let node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; let node = graph.get_node(*node).ok_or(EntityError::NodeNotFound(*node))?;
node.position node.position
} }
Some(edge_progress) => { Position::Moving {
from,
to,
remaining_distance,
} => {
// Entity is traveling between nodes // Entity is traveling between nodes
let from_node = graph.get_node(self.node).ok_or(EntityError::NodeNotFound(self.node))?; let from_node = graph.get_node(*from).ok_or(EntityError::NodeNotFound(*from))?;
let to_node = graph let to_node = graph.get_node(*to).ok_or(EntityError::NodeNotFound(*to))?;
.get_node(edge_progress.target_node) let edge = graph
.ok_or(EntityError::NodeNotFound(edge_progress.target_node))?; .find_edge(*from, *to)
.ok_or(EntityError::EdgeNotFound { from: *from, to: *to })?;
// For zero-distance edges (tunnels), progress >= 1.0 means we're at the target // For zero-distance edges (tunnels), progress >= 1.0 means we're at the target
if edge_progress.progress >= 1.0 { if edge.distance == 0.0 {
to_node.position to_node.position
} else { } else {
// Interpolate position based on progress // Interpolate position based on progress
from_node.position + (to_node.position - from_node.position) * edge_progress.progress let progress = 1.0 - (*remaining_distance / edge.distance);
from_node.position.lerp(to_node.position, progress)
} }
} }
}; };
@@ -84,183 +86,218 @@ impl Position {
pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32, pos.y + crate::constants::BOARD_PIXEL_OFFSET.y as f32,
)) ))
} }
}
#[allow(dead_code)] /// Moves the position by a given distance towards it's current target node.
impl Position { ///
/// Returns the overflow distance, if any.
pub fn tick(&mut self, distance: f32) -> Option<f32> {
if distance <= 0.0 || self.is_at_node() {
return None;
}
match self {
Position::Moving {
to, remaining_distance, ..
} => {
// If the remaining distance is less than or equal the distance, we'll reach the target
if *remaining_distance <= distance {
let overflow: Option<f32> = if *remaining_distance != distance {
Some(distance - *remaining_distance)
} else {
None
};
*self = Position::Stopped { node: *to };
return overflow;
}
*remaining_distance -= distance;
None
}
_ => unreachable!(),
}
}
/// Returns `true` if the position is exactly at a node (not traveling). /// Returns `true` if the position is exactly at a node (not traveling).
pub fn is_at_node(&self) -> bool { pub fn is_at_node(&self) -> bool {
self.edge_progress.is_none() matches!(self, Position::Stopped { .. })
} }
/// Returns the `NodeId` of the current node (source of travel if moving). /// Returns the `NodeId` of the current node (source of travel if moving).
pub fn current_node(&self) -> NodeId { pub fn current_node(&self) -> NodeId {
self.node match self {
Position::Stopped { node } => *node,
Position::Moving { from, .. } => *from,
}
} }
/// Returns the `NodeId` of the destination node, if currently traveling. /// Returns the `NodeId` of the destination node, if currently traveling.
pub fn target_node(&self) -> Option<NodeId> { pub fn target_node(&self) -> Option<NodeId> {
self.edge_progress.as_ref().map(|ep| ep.target_node) match self {
Position::Stopped { .. } => None,
Position::Moving { to, .. } => Some(*to),
}
} }
/// Returns `true` if the entity is traveling between nodes. /// Returns `true` if the entity is traveling between nodes.
pub fn is_moving(&self) -> bool { pub fn is_moving(&self) -> bool {
self.edge_progress.is_some() matches!(self, Position::Moving { .. })
} }
} }
fn can_traverse(entity_type: EntityType, edge: Edge) -> bool { // pub fn movement_system(
let entity_flags = entity_type.traversal_flags(); // map: Res<Map>,
edge.traversal_flags.contains(entity_flags) // delta_time: Res<DeltaTime>,
} // mut entities: Query<(&mut Position, &mut Movable, &EntityType)>,
// mut errors: EventWriter<GameError>,
// ) {
// for (mut position, mut movable, entity_type) in entities.iter_mut() {
// let distance = movable.speed * 60.0 * delta_time.0;
pub fn movement_system( // match *position {
map: Res<Map>, // Position::Stopped { .. } => {
delta_time: Res<DeltaTime>, // // Check if we have a requested direction to start moving
mut entities: Query<(&mut MovementState, &mut Movable, &mut Position, &EntityType)>, // if let Some(requested_direction) = movable.requested_direction {
mut errors: EventWriter<GameError>, // if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), requested_direction) {
) { // if can_traverse(*entity_type, edge) {
for (mut movement_state, mut movable, mut position, entity_type) in entities.iter_mut() { // // Start moving in the requested direction
let distance = movable.speed * 60.0 * delta_time.0; // 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.current_node(),
// edge.target
// );
// 1.0
// };
match *movement_state { // *position = Position::Moving {
MovementState::Stopped => { // from: position.current_node(),
// Check if we have a requested direction to start moving // to: edge.target,
if let Some(requested_direction) = movable.requested_direction { // remaining_distance: progress,
if let Some(edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { // };
if can_traverse(*entity_type, edge) { // movable.current_direction = requested_direction;
// Start moving in the requested direction // movable.requested_direction = None;
let progress = if edge.distance > 0.0 { // }
distance / edge.distance // } else {
} else { // errors.write(
// Zero-distance edge (tunnels) - immediately teleport // EntityError::InvalidMovement(format!(
tracing::debug!("Entity entering tunnel from node {} to node {}", position.node, edge.target); // "No edge found in direction {:?} from node {}",
1.0 // requested_direction,
}; // position.current_node()
// ))
// .into(),
// );
// }
// }
// }
// Position::Moving {
// from,
// to,
// remaining_distance,
// } => {
// // Continue moving or handle node transitions
// let current_node = *from;
// if let Some(edge) = map.graph.find_edge(current_node, *to) {
// // Extract target node before mutable operations
// let target_node = *to;
position.edge_progress = Some(EdgeProgress { // // Get the current edge for distance calculation
target_node: edge.target, // let edge = map.graph.find_edge(current_node, target_node);
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 // if let Some(edge) = edge {
let edge = map.graph.find_edge(current_node, target_node); // // Update progress along the edge
// if edge.distance > 0.0 {
// *remaining_distance += distance / edge.distance;
// } else {
// // Zero-distance edge (tunnels) - immediately complete
// *remaining_distance = 1.0;
// }
if let Some(edge) = edge { // if *remaining_distance >= 1.0 {
// Update progress along the edge // // Reached the target node
if edge.distance > 0.0 { // let overflow = if edge.distance > 0.0 {
edge_progress.progress += distance / edge.distance; // (*remaining_distance - 1.0) * edge.distance
} else { // } else {
// Zero-distance edge (tunnels) - immediately complete // // Zero-distance edge - use remaining distance for overflow
edge_progress.progress = 1.0; // distance
} // };
// *position = Position::Stopped { node: target_node };
if edge_progress.progress >= 1.0 { // let mut continued_moving = false;
// 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
// };
// Try to use requested direction first // *position = Position::Moving {
if let Some(requested_direction) = movable.requested_direction { // from: position.current_node(),
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, requested_direction) { // to: next_edge.target,
if can_traverse(*entity_type, next_edge) { // remaining_distance: next_progress,
let next_progress = if next_edge.distance > 0.0 { // };
overflow / next_edge.distance // movable.current_direction = requested_direction;
} else { // movable.requested_direction = None;
// Zero-distance edge - immediately complete // continued_moving = true;
1.0 // }
}; // }
// }
position.edge_progress = Some(EdgeProgress { // // If no requested direction or it failed, try to continue in current direction
target_node: next_edge.target, // if !continued_moving {
progress: next_progress, // if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) {
}); // if can_traverse(*entity_type, next_edge) {
movable.current_direction = requested_direction; // let next_progress = if next_edge.distance > 0.0 {
movable.requested_direction = None; // overflow / next_edge.distance
*movement_state = MovementState::Moving { // } else {
direction: requested_direction, // // Zero-distance edge - immediately complete
}; // 1.0
continued_moving = true; // };
}
}
}
// If no requested direction or it failed, try to continue in current direction // *position = Position::Moving {
if !continued_moving { // from: position.current_node(),
if let Some(next_edge) = map.graph.find_edge_in_direction(position.node, direction) { // to: next_edge.target,
if can_traverse(*entity_type, next_edge) { // remaining_distance: next_progress,
let next_progress = if next_edge.distance > 0.0 { // };
overflow / next_edge.distance // // Keep current direction and movement state
} else { // continued_moving = true;
// Zero-distance edge - immediately complete // }
1.0 // }
}; // }
position.edge_progress = Some(EdgeProgress { // // If we couldn't continue moving, stop
target_node: next_edge.target, // if !continued_moving {
progress: next_progress, // *movement_state = MovementState::Stopped;
}); // movable.requested_direction = None;
// Keep current direction and movement state // }
continued_moving = true; // }
} // } else {
} // // Edge not found - this is an inconsistent state
} // errors.write(
// EntityError::InvalidMovement(format!(
// If we couldn't continue moving, stop // "Inconsistent state: Moving on non-existent edge from {} to {}",
if !continued_moving { // current_node, target_node
*movement_state = MovementState::Stopped; // ))
movable.requested_direction = None; // .into(),
} // );
} // *movement_state = MovementState::Stopped;
} else { // position.edge_progress = None;
// Edge not found - this is an inconsistent state // }
errors.write( // } else {
EntityError::InvalidMovement(format!( // // Movement state says moving but no edge progress - this shouldn't happen
"Inconsistent state: Moving on non-existent edge from {} to {}", // errors.write(EntityError::InvalidMovement("Entity in Moving state but no edge progress".to_string()).into());
current_node, target_node // *movement_state = MovementState::Stopped;
)) // }
.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;
}
}
}
}
}

143
src/systems/player.rs Normal file
View File

@@ -0,0 +1,143 @@
use bevy_ecs::{
event::{EventReader, EventWriter},
prelude::ResMut,
query::With,
system::{Query, Res},
};
use crate::{
entity::graph::Edge,
error::GameError,
events::{GameCommand, GameEvent},
map::builder::Map,
systems::{
components::{AudioState, DeltaTime, EntityType, GlobalState, PlayerControlled},
debug::DebugState,
movement::{BufferedDirection, Position, Velocity},
},
};
// Handles player input and control
pub fn player_control_system(
mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut audio_state: ResMut<AudioState>,
mut players: Query<(&mut BufferedDirection), With<PlayerControlled>>,
mut errors: EventWriter<GameError>,
) {
// Get the player's movable component (ensuring there is only one player)
let mut buffered_direction = match players.single_mut() {
Ok(buffered_direction) => buffered_direction,
Err(e) => {
errors.write(GameError::InvalidState(format!(
"No/multiple entities queried for player system: {}",
e
)));
return;
}
};
// Handle events
for event in events.read() {
if let GameEvent::Command(command) = event {
match command {
GameCommand::MovePlayer(direction) => {
*buffered_direction = BufferedDirection::Some {
direction: *direction,
remaining_time: 0.25,
};
}
GameCommand::Exit => {
state.exit = true;
}
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
GameCommand::MuteAudio => {
audio_state.muted = !audio_state.muted;
tracing::info!("Audio {}", if audio_state.muted { "muted" } else { "unmuted" });
}
_ => {}
}
}
}
}
fn can_traverse(entity_type: EntityType, edge: Edge) -> bool {
let entity_flags = entity_type.traversal_flags();
edge.traversal_flags.contains(entity_flags)
}
pub fn player_movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut Position, &mut Velocity, &mut BufferedDirection), With<PlayerControlled>>,
mut errors: EventWriter<GameError>,
) {
for (mut position, mut velocity, mut buffered_direction) in entities.iter_mut() {
// Decrement the buffered direction remaining time
if let BufferedDirection::Some {
direction,
remaining_time,
} = *buffered_direction
{
if remaining_time <= 0.0 {
*buffered_direction = BufferedDirection::None;
} else {
*buffered_direction = BufferedDirection::Some {
direction,
remaining_time: remaining_time - delta_time.0,
};
}
}
let mut distance = velocity.speed * 60.0 * delta_time.0;
loop {
match *position {
Position::Stopped { .. } => {
// If there is a buffered direction, travel it's edge first if available.
if let BufferedDirection::Some { direction, .. } = *buffered_direction {
// If there's no edge in that direction, ignore the buffered direction.
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), direction) {
// If there is an edge in that direction (and it's traversable), start moving towards it and consume the buffered direction.
if can_traverse(EntityType::Player, edge) {
velocity.direction = edge.direction;
*position = Position::Moving {
from: position.current_node(),
to: edge.target,
remaining_distance: edge.distance,
};
*buffered_direction = BufferedDirection::None;
}
}
}
// If there is no buffered direction (or it's not yet valid), continue in the current direction.
if let Some(edge) = map.graph.find_edge_in_direction(position.current_node(), velocity.direction) {
if can_traverse(EntityType::Player, edge) {
velocity.direction = edge.direction;
*position = Position::Moving {
from: position.current_node(),
to: edge.target,
remaining_distance: edge.distance,
};
}
} else {
// No edge in our current direction either, erase the buffered direction and stop.
*buffered_direction = BufferedDirection::None;
break;
}
}
Position::Moving { .. } => {
if let Some(overflow) = position.tick(distance) {
distance = overflow;
} else {
break;
}
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ const TIMING_WINDOW_SIZE: usize = 30;
#[derive(EnumCount, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)] #[derive(EnumCount, IntoStaticStr, Debug, PartialEq, Eq, Hash, Copy, Clone)]
pub enum SystemId { pub enum SystemId {
Input, Input,
Player, PlayerControls,
Ghost, Ghost,
Movement, Movement,
Audio, Audio,
@@ -32,6 +32,7 @@ pub enum SystemId {
Present, Present,
Collision, Collision,
Item, Item,
PlayerMovement,
} }
impl Display for SystemId { impl Display for SystemId {

View File

@@ -1,7 +1,7 @@
use crate::error::{GameError, TextureError}; use crate::error::{GameError, TextureError};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable}; use crate::systems::components::{DeltaTime, DirectionalAnimated, RenderDirty, Renderable};
use crate::systems::movement::{Movable, MovementState, Position}; use crate::systems::movement::{Position, Velocity};
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity; use bevy_ecs::entity::Entity;
use bevy_ecs::event::EventWriter; use bevy_ecs::event::EventWriter;
@@ -26,12 +26,12 @@ pub fn dirty_render_system(
/// 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. /// 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( pub fn directional_render_system(
dt: Res<DeltaTime>, dt: Res<DeltaTime>,
mut renderables: Query<(&MovementState, &Movable, &mut DirectionalAnimated, &mut Renderable)>, mut renderables: Query<(&Position, &Velocity, &mut DirectionalAnimated, &mut Renderable)>,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
for (movement_state, movable, mut texture, mut renderable) in renderables.iter_mut() { for (position, velocity, mut texture, mut renderable) in renderables.iter_mut() {
let stopped = matches!(movement_state, MovementState::Stopped); let stopped = matches!(position, Position::Stopped { .. });
let current_direction = movable.current_direction; let current_direction = velocity.direction;
let texture = if stopped { let texture = if stopped {
texture.stopped_textures[current_direction.as_usize()].as_mut() texture.stopped_textures[current_direction.as_usize()].as_mut()
@@ -96,7 +96,7 @@ pub fn render_system(
continue; continue;
} }
let pos = position.get_pixel_pos(&map.graph); let pos = position.get_pixel_position(&map.graph);
match pos { match pos {
Ok(pos) => { Ok(pos) => {
let dest = crate::helpers::centered_with_size( let dest = crate::helpers::centered_with_size(