feat(simulation): add 8-directional movement

Add diagonal PlayerAction variants (MoveNortheast, MoveNorthwest,
MoveSoutheast, MoveSouthwest) and TilePosition::all_neighbors()
returning all 8 surrounding tiles. Genre-expected for immersive sim.

Establishes the movement pattern before pathfinding is built on top.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 20:15:18 +01:00
co-authored by Claude Opus 4.6
parent 0f5f73a927
commit bef4383196
3 changed files with 130 additions and 20 deletions
+5 -1
View File
@@ -47,13 +47,17 @@ pub struct PlayerInput {
pub action: PlayerAction,
}
/// Player action variants
/// Player action variants — 8-directional movement for immersive sim genre expectations
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PlayerAction {
MoveNorth,
MoveSouth,
MoveEast,
MoveWest,
MoveNortheast,
MoveNorthwest,
MoveSoutheast,
MoveSouthwest,
Interact,
UsePerceptionMode(String),
Pause,
+121 -19
View File
@@ -36,9 +36,6 @@ impl TilePosition {
/// 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.
///
/// TODO: Diagonal movement (8-directional) for genre expectations.
/// TODO: Chunk boundary awareness for neighbors in different chunks.
pub fn cardinal_neighbors(&self) -> [TilePosition; 4] {
[
TilePosition::new(self.x, self.y - 1, self.z), // North
@@ -48,6 +45,20 @@ impl TilePosition {
]
}
/// Returns all 8 neighbors (cardinal + diagonal) on the same z-level.
pub fn all_neighbors(&self) -> [TilePosition; 8] {
[
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
TilePosition::new(self.x + 1, self.y - 1, self.z), // Northeast
TilePosition::new(self.x - 1, self.y - 1, self.z), // Northwest
TilePosition::new(self.x + 1, self.y + 1, self.z), // Southeast
TilePosition::new(self.x - 1, self.y + 1, self.z), // Southwest
]
}
/// Convert to f32 coordinates for VisibleEntity wire format (D-020).
/// Maps tile center to float position (tile 0 → 0.5, tile 1 → 1.5, etc.)
pub fn to_render_coords(&self) -> (f32, f32, i32) {
@@ -122,8 +133,6 @@ impl ChunkData {
/// Stores walkability per tile in CHUNK_SIZE x CHUNK_SIZE chunks.
/// Supports chunk load/unload for future borderless generation.
/// Unloaded chunks are treated as unwalkable.
///
/// TODO: Entity-entity collision (multiple entities on same tile).
#[derive(Resource, Debug, Clone)]
pub struct WalkabilityMap {
chunks: HashMap<ChunkCoord, ChunkData>,
@@ -213,37 +222,48 @@ pub struct MoveIntent {
}
/// System to validate and execute movement intents.
/// Checks walkability map and updates positions for valid moves.
/// Checks walkability map AND entity-entity collision before allowing moves.
/// Processes all intents in a single pass: first collect occupied tiles from
/// entities without intents, then resolve movers in order — first valid claim
/// to a tile wins.
/// Always removes MoveIntent component after processing.
pub fn validate_movement(
mut commands: Commands,
walkability: Option<Res<WalkabilityMap>>,
mut query: Query<(Entity, &MoveIntent, &mut TilePosition)>,
mut movers: Query<(Entity, &MoveIntent, &mut TilePosition)>,
stationary: Query<(Entity, &TilePosition), Without<MoveIntent>>,
) {
let Some(map) = walkability else {
tracing::warn!("No WalkabilityMap loaded — rejecting all move intents");
for (entity, _, _) in query.iter() {
for (entity, _, _) in movers.iter() {
commands.entity(entity).remove::<MoveIntent>();
}
return;
};
for (entity, intent, mut position) in query.iter_mut() {
if map.can_move_to(&intent.target) {
// Collect tiles occupied by stationary entities (no MoveIntent)
let mut occupied: HashMap<TilePosition, Entity> = HashMap::new();
for (entity, pos) in stationary.iter() {
occupied.insert(*pos, entity);
}
for (entity, intent, mut position) in movers.iter_mut() {
let target = &intent.target;
if !map.can_move_to(target) {
tracing::trace!("Entity {:?} blocked by terrain at {:?}", entity, target);
} else if occupied.contains_key(target) {
tracing::trace!("Entity {:?} blocked by entity at {:?}", entity, target);
} else {
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
target
);
// Free old tile, claim new tile
occupied.remove(&*position);
*position = *target;
occupied.insert(*target, entity);
}
commands.entity(entity).remove::<MoveIntent>();
}
@@ -440,6 +460,88 @@ mod tests {
assert!(world.get::<MoveIntent>(entity).is_none());
}
#[test]
fn all_neighbors_correct() {
let pos = TilePosition::new(5, 5, 0);
let neighbors = pos.all_neighbors();
assert_eq!(neighbors[0], TilePosition::new(5, 4, 0)); // North
assert_eq!(neighbors[1], TilePosition::new(5, 6, 0)); // South
assert_eq!(neighbors[2], TilePosition::new(6, 5, 0)); // East
assert_eq!(neighbors[3], TilePosition::new(4, 5, 0)); // West
assert_eq!(neighbors[4], TilePosition::new(6, 4, 0)); // Northeast
assert_eq!(neighbors[5], TilePosition::new(4, 4, 0)); // Northwest
assert_eq!(neighbors[6], TilePosition::new(6, 6, 0)); // Southeast
assert_eq!(neighbors[7], TilePosition::new(4, 6, 0)); // Southwest
}
#[test]
fn validate_movement_blocks_occupied_tile() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
// Stationary entity at target tile
world.spawn(TilePosition::new(5, 4, 0));
// Mover tries to move into occupied tile
let mover = 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);
// Mover stayed put
assert_eq!(
*world.get::<TilePosition>(mover).unwrap(),
TilePosition::new(5, 5, 0)
);
assert!(world.get::<MoveIntent>(mover).is_none());
}
#[test]
fn validate_movement_two_movers_same_target_first_wins() {
let mut world = bevy_ecs::world::World::new();
world.insert_resource(WalkabilityMap::new(10, 10, 1));
let entity_a = world
.spawn((
TilePosition::new(5, 4, 0),
MoveIntent {
target: TilePosition::new(5, 5, 0),
},
))
.id();
let entity_b = world
.spawn((
TilePosition::new(5, 6, 0),
MoveIntent {
target: TilePosition::new(5, 5, 0),
},
))
.id();
let mut schedule = bevy_ecs::schedule::Schedule::default();
schedule.add_systems(validate_movement);
schedule.run(&mut world);
let pos_a = *world.get::<TilePosition>(entity_a).unwrap();
let pos_b = *world.get::<TilePosition>(entity_b).unwrap();
// Exactly one should have moved to (5,5), the other stays
let one_moved =
(pos_a == TilePosition::new(5, 5, 0)) ^ (pos_b == TilePosition::new(5, 5, 0));
assert!(one_moved, "exactly one entity should occupy the target");
assert_ne!(pos_a, pos_b, "both entities must not share a tile");
}
#[test]
fn validate_movement_blocks_unloaded_chunk() {
let mut world = bevy_ecs::world::World::new();
+4
View File
@@ -58,6 +58,10 @@ fn all_player_action_variants_roundtrip() {
PlayerAction::MoveSouth,
PlayerAction::MoveEast,
PlayerAction::MoveWest,
PlayerAction::MoveNortheast,
PlayerAction::MoveNorthwest,
PlayerAction::MoveSoutheast,
PlayerAction::MoveSouthwest,
PlayerAction::Interact,
PlayerAction::UsePerceptionMode("thermal".to_string()),
PlayerAction::Pause,