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:
2026-02-11 19:07:53 +01:00
co-authored by Claude Opus 4.6
parent 4ed15c1a38
commit 862ab9099f
3 changed files with 407 additions and 1 deletions
+7 -1
View File
@@ -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");
}
+346
View File
@@ -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());
}
}
+54
View File
@@ -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());
}