feat(simulation): add tile collision system (#236)
TilePosition component with discrete grid coordinates, flat-storage WalkabilityMap resource with O(1) can_move_to() lookup, MoveIntent component and validate_movement system. Movement validated against walkability map each tick, blocking all NPC and player movement through unwalkable tiles. 11 unit tests + 1 integration test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,10 @@
|
||||
// Implements deterministic tick-based simulation (D-010 principle 4)
|
||||
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_ecs::schedule::IntoScheduleConfigs;
|
||||
|
||||
pub mod input;
|
||||
pub mod movement;
|
||||
pub mod rng;
|
||||
pub mod tier;
|
||||
pub mod time;
|
||||
@@ -18,7 +20,11 @@ impl Plugin for SimulationPlugin {
|
||||
app.init_resource::<time::SimulationTime>()
|
||||
.insert_resource(rng::SimRng::new(0))
|
||||
.init_resource::<input::InputQueue>()
|
||||
.add_systems(Update, time::advance_tick);
|
||||
.add_systems(Update, time::advance_tick)
|
||||
.add_systems(
|
||||
Update,
|
||||
movement::validate_movement.after(time::advance_tick),
|
||||
);
|
||||
|
||||
tracing::debug!("SimulationPlugin initialized");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// Tile-based movement and collision system
|
||||
// Implements Sprint 1 ticket #236: walkability map and movement validation
|
||||
// Y-down convention: North = y-1, South = y+1
|
||||
|
||||
use bevy_ecs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Tile position component for grid-based movement
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct TilePosition {
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub z: i32,
|
||||
}
|
||||
|
||||
impl TilePosition {
|
||||
pub fn new(x: i32, y: i32, z: i32) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
/// Calculate Manhattan distance to another position
|
||||
/// Returns None if positions are on different z-levels
|
||||
pub fn manhattan_distance(&self, other: &TilePosition) -> Option<u32> {
|
||||
if self.z != other.z {
|
||||
return None;
|
||||
}
|
||||
Some(self.x.abs_diff(other.x) + self.y.abs_diff(other.y))
|
||||
}
|
||||
|
||||
/// Returns the four cardinal neighbors (N/S/E/W) on the same z-level
|
||||
/// Y-down convention: North = y-1, South = y+1, East = x+1, West = x-1
|
||||
pub fn cardinal_neighbors(&self) -> [TilePosition; 4] {
|
||||
[
|
||||
TilePosition::new(self.x, self.y - 1, self.z), // North
|
||||
TilePosition::new(self.x, self.y + 1, self.z), // South
|
||||
TilePosition::new(self.x + 1, self.y, self.z), // East
|
||||
TilePosition::new(self.x - 1, self.y, self.z), // West
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// Walkability map resource for tile collision
|
||||
/// Flat storage with index = z*w*h + y*w + x
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct WalkabilityMap {
|
||||
width: i32,
|
||||
height: i32,
|
||||
z_levels: i32,
|
||||
tiles: Vec<bool>, // true = walkable, false = blocked
|
||||
}
|
||||
|
||||
impl WalkabilityMap {
|
||||
/// Create a new walkability map with all tiles walkable
|
||||
pub fn new(width: i32, height: i32, z_levels: i32) -> Self {
|
||||
let size = (width * height * z_levels) as usize;
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
z_levels,
|
||||
tiles: vec![true; size],
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new walkability map with all tiles blocked
|
||||
pub fn new_blocked(width: i32, height: i32, z_levels: i32) -> Self {
|
||||
let size = (width * height * z_levels) as usize;
|
||||
Self {
|
||||
width,
|
||||
height,
|
||||
z_levels,
|
||||
tiles: vec![false; size],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn width(&self) -> i32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
pub fn height(&self) -> i32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
pub fn z_levels(&self) -> i32 {
|
||||
self.z_levels
|
||||
}
|
||||
|
||||
/// Check if a position is within map bounds
|
||||
pub fn in_bounds(&self, pos: &TilePosition) -> bool {
|
||||
pos.x >= 0
|
||||
&& pos.x < self.width
|
||||
&& pos.y >= 0
|
||||
&& pos.y < self.height
|
||||
&& pos.z >= 0
|
||||
&& pos.z < self.z_levels
|
||||
}
|
||||
|
||||
/// Check if movement to a position is valid (in bounds and walkable)
|
||||
pub fn can_move_to(&self, pos: &TilePosition) -> bool {
|
||||
if !self.in_bounds(pos) {
|
||||
return false;
|
||||
}
|
||||
let idx = self.index(pos);
|
||||
self.tiles[idx]
|
||||
}
|
||||
|
||||
/// Set walkability of a tile
|
||||
/// Panics if position is out of bounds
|
||||
pub fn set_walkable(&mut self, pos: &TilePosition, walkable: bool) {
|
||||
assert!(
|
||||
self.in_bounds(pos),
|
||||
"Position {:?} out of bounds ({}x{}x{})",
|
||||
pos,
|
||||
self.width,
|
||||
self.height,
|
||||
self.z_levels
|
||||
);
|
||||
let idx = self.index(pos);
|
||||
self.tiles[idx] = walkable;
|
||||
}
|
||||
|
||||
/// Calculate flat storage index for a position
|
||||
fn index(&self, pos: &TilePosition) -> usize {
|
||||
(pos.z * self.width * self.height + pos.y * self.width + pos.x) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// Component representing an intent to move to a target tile
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct MoveIntent {
|
||||
pub target: TilePosition,
|
||||
}
|
||||
|
||||
/// System to validate and execute movement intents
|
||||
/// Checks walkability map and updates positions for valid moves
|
||||
/// Always removes MoveIntent component after processing
|
||||
pub fn validate_movement(
|
||||
mut commands: Commands,
|
||||
walkability: Option<Res<WalkabilityMap>>,
|
||||
mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>,
|
||||
) {
|
||||
// If no walkability map exists, reject all move intents
|
||||
let Some(map) = walkability else {
|
||||
for (entity, _, _) in query.iter() {
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
for (entity, intent, mut position) in query.iter_mut() {
|
||||
if map.can_move_to(&intent.target) {
|
||||
tracing::trace!(
|
||||
"Entity {:?} moving from {:?} to {:?}",
|
||||
entity,
|
||||
*position,
|
||||
intent.target
|
||||
);
|
||||
*position = intent.target;
|
||||
} else {
|
||||
tracing::trace!(
|
||||
"Entity {:?} blocked at {:?}, cannot move to {:?}",
|
||||
entity,
|
||||
*position,
|
||||
intent.target
|
||||
);
|
||||
}
|
||||
commands.entity(entity).remove::<MoveIntent>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tile_position_equality() {
|
||||
let pos1 = TilePosition::new(5, 10, 0);
|
||||
let pos2 = TilePosition::new(5, 10, 0);
|
||||
let pos3 = TilePosition::new(5, 11, 0);
|
||||
|
||||
assert_eq!(pos1, pos2);
|
||||
assert_ne!(pos1, pos3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manhattan_distance_same_level() {
|
||||
let pos1 = TilePosition::new(0, 0, 0);
|
||||
let pos2 = TilePosition::new(3, 4, 0);
|
||||
|
||||
assert_eq!(pos1.manhattan_distance(&pos2), Some(7));
|
||||
assert_eq!(pos2.manhattan_distance(&pos1), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manhattan_distance_different_level_returns_none() {
|
||||
let pos1 = TilePosition::new(0, 0, 0);
|
||||
let pos2 = TilePosition::new(0, 0, 1);
|
||||
|
||||
assert_eq!(pos1.manhattan_distance(&pos2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinal_neighbors_correct() {
|
||||
let pos = TilePosition::new(5, 5, 2);
|
||||
let neighbors = pos.cardinal_neighbors();
|
||||
|
||||
assert_eq!(neighbors[0], TilePosition::new(5, 4, 2)); // North (y-1)
|
||||
assert_eq!(neighbors[1], TilePosition::new(5, 6, 2)); // South (y+1)
|
||||
assert_eq!(neighbors[2], TilePosition::new(6, 5, 2)); // East (x+1)
|
||||
assert_eq!(neighbors[3], TilePosition::new(4, 5, 2)); // West (x-1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkability_map_default_all_walkable() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
|
||||
assert!(map.can_move_to(&TilePosition::new(0, 0, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(9, 9, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkability_map_out_of_bounds_not_walkable() {
|
||||
let map = WalkabilityMap::new(10, 10, 1);
|
||||
|
||||
// Negative coordinates
|
||||
assert!(!map.can_move_to(&TilePosition::new(-1, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, -1, 0)));
|
||||
|
||||
// Over bounds
|
||||
assert!(!map.can_move_to(&TilePosition::new(10, 0, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 10, 0)));
|
||||
assert!(!map.can_move_to(&TilePosition::new(0, 0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkability_map_set_blocked() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
let blocked_pos = TilePosition::new(5, 5, 0);
|
||||
|
||||
map.set_walkable(&blocked_pos, false);
|
||||
|
||||
assert!(!map.can_move_to(&blocked_pos));
|
||||
assert!(map.can_move_to(&TilePosition::new(5, 6, 0))); // Adjacent still walkable
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkability_map_multi_z_level() {
|
||||
let mut map = WalkabilityMap::new(10, 10, 3);
|
||||
let blocked_pos = TilePosition::new(5, 5, 1);
|
||||
|
||||
map.set_walkable(&blocked_pos, false);
|
||||
|
||||
// z=1 is blocked
|
||||
assert!(!map.can_move_to(&blocked_pos));
|
||||
|
||||
// z=0 and z=2 at same x,y are walkable
|
||||
assert!(map.can_move_to(&TilePosition::new(5, 5, 0)));
|
||||
assert!(map.can_move_to(&TilePosition::new(5, 5, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_movement_allows_walkable() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Position updated to target
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(entity).unwrap(),
|
||||
TilePosition::new(5, 4, 0)
|
||||
);
|
||||
|
||||
// Intent removed
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_movement_blocks_unwalkable() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(5, 4, 0), false);
|
||||
world.insert_resource(map);
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Position unchanged
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(entity).unwrap(),
|
||||
TilePosition::new(5, 5, 0)
|
||||
);
|
||||
|
||||
// Intent removed
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_movement_blocks_out_of_bounds() {
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(WalkabilityMap::new(10, 10, 1));
|
||||
|
||||
let entity = world
|
||||
.spawn((
|
||||
TilePosition::new(0, 0, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(-1, 0, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(validate_movement);
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Position unchanged
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(entity).unwrap(),
|
||||
TilePosition::new(0, 0, 0)
|
||||
);
|
||||
|
||||
// Intent removed
|
||||
assert!(world.get::<MoveIntent>(entity).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
use bevy_app::prelude::*;
|
||||
use settled_reach_server::simulation::movement::*;
|
||||
use settled_reach_server::simulation::time::SimulationTime;
|
||||
use settled_reach_server::simulation::SimulationPlugin;
|
||||
|
||||
#[test]
|
||||
fn movement_validated_within_app() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(SimulationPlugin);
|
||||
|
||||
// Insert a walkability map with one blocked tile
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(3, 3, 0), false);
|
||||
app.insert_resource(map);
|
||||
|
||||
// Spawn mover (walkable target) and blocked entity
|
||||
let mover = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
TilePosition::new(5, 5, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(5, 4, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
let blocked = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
TilePosition::new(3, 4, 0),
|
||||
MoveIntent {
|
||||
target: TilePosition::new(3, 3, 0),
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
// Mover moved
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(mover).unwrap(),
|
||||
TilePosition::new(5, 4, 0)
|
||||
);
|
||||
// Blocked stayed
|
||||
assert_eq!(
|
||||
*app.world().get::<TilePosition>(blocked).unwrap(),
|
||||
TilePosition::new(3, 4, 0)
|
||||
);
|
||||
// Tick advanced
|
||||
assert_eq!(app.world().resource::<SimulationTime>().tick, 1);
|
||||
// Both intents consumed
|
||||
assert!(app.world().get::<MoveIntent>(mover).is_none());
|
||||
assert!(app.world().get::<MoveIntent>(blocked).is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user