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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user