refactor: reorganize ecs components

This commit is contained in:
2025-08-15 09:17:43 -05:00
parent 70fb2b9503
commit 242da2e263
12 changed files with 291 additions and 294 deletions

View File

@@ -1,11 +1,11 @@
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use glam::Vec2; use glam::Vec2;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::TextureCreator;
use sdl2::ttf::Sdl2TtfContext; use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::{Window, WindowContext}; use sdl2::video::WindowContext;
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::{error, warn}; use tracing::warn;
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};

138
src/ecs/components.rs Normal file
View File

@@ -0,0 +1,138 @@
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource};
use glam::Vec2;
use crate::{
entity::{direction::Direction, graph::Graph},
error::{EntityError, GameResult},
texture::{animated::AnimatedTexture, sprite::AtlasTile},
};
/// 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 next_direction: Option<(Direction, u8)>,
pub speed: 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);

View File

@@ -1,141 +1,15 @@
use bevy_ecs::{ use bevy_ecs::{
event::{EventReader, EventWriter}, event::{EventReader, EventWriter},
system::{Query, Res, ResMut}, system::{Query, ResMut},
}; };
use tracing::debug;
use crate::entity::graph::EdgePermissions;
use crate::{ use crate::{
ecs::{DeltaTime, GlobalState, PlayerControlled, Position, Velocity}, ecs::components::{GlobalState, PlayerControlled, Velocity},
error::{EntityError, GameError}, error::GameError,
game::events::GameEvent, game::events::GameEvent,
input::commands::GameCommand, input::commands::GameCommand,
map::builder::Map,
}; };
pub fn movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>,
mut errors: EventWriter<GameError>,
) {
for (mut player, mut velocity, mut position) in entities.iter_mut() {
let distance = velocity.speed * 60.0 * delta_time.0;
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = velocity.next_direction {
if remaining > 0 {
velocity.next_direction = Some((direction, remaining - 1));
} else {
velocity.next_direction = None;
}
}
match *position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(&mut player, edge) {
// Start moving in that direction
*position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance,
};
velocity.direction = next_direction;
velocity.next_direction = None;
}
} else {
errors.write(
EntityError::InvalidMovement(format!(
"No edge found in direction {:?} from node {}",
next_direction, node_id
))
.into(),
);
}
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = map
.graph
.find_edge(from, to)
.ok_or_else(|| {
errors.write(
EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
))
.into(),
);
return;
})
.unwrap();
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
*position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(to, next_dir) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
velocity.direction = next_dir; // Remember our new direction
velocity.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = map.graph.find_edge_in_direction(to, velocity.direction) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
}
}
}
}
}
}
fn can_traverse(_player: &mut PlayerControlled, edge: crate::entity::graph::Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All)
}
// Handles // Handles
pub fn interact_system( pub fn interact_system(
mut events: EventReader<GameEvent>, mut events: EventReader<GameEvent>,

View File

@@ -3,148 +3,7 @@
//! This module contains all the ECS-related logic, including components, systems, //! This module contains all the ECS-related logic, including components, systems,
//! and resources. //! and resources.
use bevy_ecs::{bundle::Bundle, component::Component, resource::Resource}; pub mod components;
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 next_direction: Option<(Direction, u8)>,
pub speed: 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 interact;
pub mod movement;
pub mod render; pub mod render;

129
src/ecs/movement.rs Normal file
View File

@@ -0,0 +1,129 @@
use crate::ecs::components::{DeltaTime, PlayerControlled, Position, Velocity};
use crate::entity::graph::EdgePermissions;
use crate::error::{EntityError, GameError};
use crate::map::builder::Map;
use bevy_ecs::event::EventWriter;
use bevy_ecs::system::{Query, Res};
fn can_traverse(_player: &mut PlayerControlled, edge: crate::entity::graph::Edge) -> bool {
matches!(edge.permissions, EdgePermissions::All)
}
pub fn movement_system(
map: Res<Map>,
delta_time: Res<DeltaTime>,
mut entities: Query<(&mut PlayerControlled, &mut Velocity, &mut Position)>,
mut errors: EventWriter<GameError>,
) {
for (mut player, mut velocity, mut position) in entities.iter_mut() {
let distance = velocity.speed * 60.0 * delta_time.0;
// Decrement the remaining frames for the next direction
if let Some((direction, remaining)) = velocity.next_direction {
if remaining > 0 {
velocity.next_direction = Some((direction, remaining - 1));
} else {
velocity.next_direction = None;
}
}
match *position {
Position::AtNode(node_id) => {
// We're not moving, but a buffered direction is available.
if let Some((next_direction, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(node_id, next_direction) {
if can_traverse(&mut player, edge) {
// Start moving in that direction
*position = Position::BetweenNodes {
from: node_id,
to: edge.target,
traversed: distance,
};
velocity.direction = next_direction;
velocity.next_direction = None;
}
} else {
errors.write(
EntityError::InvalidMovement(format!(
"No edge found in direction {:?} from node {}",
next_direction, node_id
))
.into(),
);
}
}
}
Position::BetweenNodes { from, to, traversed } => {
// There is no point in any of the next logic if we don't travel at all
if distance <= 0.0 {
return;
}
let edge = map
.graph
.find_edge(from, to)
.ok_or_else(|| {
errors.write(
EntityError::InvalidMovement(format!(
"Inconsistent state: Traverser is on a non-existent edge from {} to {}.",
from, to
))
.into(),
);
return;
})
.unwrap();
let new_traversed = traversed + distance;
if new_traversed < edge.distance {
// Still on the same edge, just update the distance.
*position = Position::BetweenNodes {
from,
to,
traversed: new_traversed,
};
} else {
let overflow = new_traversed - edge.distance;
let mut moved = false;
// If we buffered a direction, try to find an edge in that direction
if let Some((next_dir, _)) = velocity.next_direction {
if let Some(edge) = map.graph.find_edge_in_direction(to, next_dir) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
velocity.direction = next_dir; // Remember our new direction
velocity.next_direction = None; // Consume the buffered direction
moved = true;
}
}
}
// If we didn't move, try to continue in the current direction
if !moved {
if let Some(edge) = map.graph.find_edge_in_direction(to, velocity.direction) {
if can_traverse(&mut player, edge) {
*position = Position::BetweenNodes {
from: to,
to: edge.target,
traversed: overflow,
};
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
} else {
*position = Position::AtNode(to);
velocity.next_direction = None;
}
}
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
use crate::ecs::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity}; use crate::ecs::components::{DeltaTime, DirectionalAnimated, Position, Renderable, Velocity};
use crate::error::{EntityError, GameError, TextureError}; use crate::error::{GameError, TextureError};
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::texture::sprite::SpriteAtlas; use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::entity::Entity; use bevy_ecs::entity::Entity;

View File

@@ -1,6 +1,6 @@
use glam::Vec2; use glam::Vec2;
use crate::ecs::NodeId; use crate::ecs::components::NodeId;
use super::direction::Direction; use super::direction::Direction;

View File

@@ -1,6 +1,4 @@
use tracing::error; use crate::ecs::components::Position;
use crate::ecs::{NodeId, Position};
use crate::error::GameResult; use crate::error::GameResult;
use super::direction::Direction; use super::direction::Direction;

View File

@@ -3,24 +3,24 @@
include!(concat!(env!("OUT_DIR"), "/atlas_data.rs")); include!(concat!(env!("OUT_DIR"), "/atlas_data.rs"));
use crate::constants::CANVAS_SIZE; use crate::constants::CANVAS_SIZE;
use crate::ecs::interact::{interact_system, movement_system}; use crate::ecs::components::{
DeltaTime, DirectionalAnimated, GlobalState, PlayerBundle, PlayerControlled, Position, Renderable, Velocity,
};
use crate::ecs::interact::interact_system;
use crate::ecs::movement::movement_system;
use crate::ecs::render::{directional_render_system, render_system, BackbufferResource, MapTextureResource}; 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::direction::Direction;
use crate::entity::{graph, traversal};
use crate::error::{GameError, GameResult, TextureError}; use crate::error::{GameError, GameResult, TextureError};
use crate::input::commands::GameCommand; use crate::input::commands::GameCommand;
use crate::map::builder::Map; use crate::map::builder::Map;
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use crate::texture::directional::DirectionalAnimatedTexture;
use crate::texture::sprite::Sprite;
use bevy_ecs::event::EventRegistry; use bevy_ecs::event::EventRegistry;
use bevy_ecs::observer::Trigger; use bevy_ecs::observer::Trigger;
use bevy_ecs::schedule::IntoScheduleConfigs; use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::system::{Commands, ResMut}; use bevy_ecs::system::ResMut;
use bevy_ecs::{schedule::Schedule, world::World}; use bevy_ecs::{schedule::Schedule, world::World};
use sdl2::image::LoadTexture; use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, Texture, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
use sdl2::EventPump; use sdl2::EventPump;
@@ -29,7 +29,7 @@ use crate::input::{handle_input, Bindings};
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
use crate::{ use crate::{
constants, constants,
texture::sprite::{AtlasMapper, AtlasTile, SpriteAtlas}, texture::sprite::{AtlasMapper, SpriteAtlas},
}; };
use self::events::GameEvent; use self::events::GameEvent;

View File

@@ -3,7 +3,7 @@ use std::collections::HashMap;
use bevy_ecs::{ use bevy_ecs::{
event::EventWriter, event::EventWriter,
resource::Resource, resource::Resource,
system::{Commands, NonSendMut, Res}, system::{NonSendMut, Res},
}; };
use sdl2::{event::Event, keyboard::Keycode, EventPump}; use sdl2::{event::Event, keyboard::Keycode, EventPump};

View File

@@ -1,12 +1,12 @@
//! Map construction and building functionality. //! Map construction and building functionality.
use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE, RAW_BOARD}; use crate::constants::{MapTile, BOARD_CELL_SIZE, CELL_SIZE};
use crate::ecs::NodeId; use crate::ecs::components::NodeId;
use crate::entity::direction::Direction; use crate::entity::direction::Direction;
use crate::entity::graph::{EdgePermissions, Graph, Node}; use crate::entity::graph::{EdgePermissions, Graph, Node};
use crate::map::parser::MapTileParser; use crate::map::parser::MapTileParser;
use crate::map::render::MapRenderer; use crate::map::render::MapRenderer;
use crate::texture::sprite::{Sprite, SpriteAtlas}; use crate::texture::sprite::SpriteAtlas;
use bevy_ecs::resource::Resource; use bevy_ecs::resource::Resource;
use glam::{IVec2, Vec2}; use glam::{IVec2, Vec2};
use sdl2::render::{Canvas, RenderTarget}; use sdl2::render::{Canvas, RenderTarget};

View File

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