feat(simulation): D-252 — Facing is view-only; NPC gaze moves to path-follow intent (T-1093)
apply_move no longer writes Facing (was the only movement-facing coupling, player-only); the player's view changes solely via explicit SetFacing. NPC path-follow now sets Facing to the step direction — a strict improvement recorded as a D-252 correction: NPCs previously never received Facing from movement, their cones sat at spawn direction while walking. Bump-to-turn retired (blocked moves change nothing) with a regression test for each semantic. Wire schema unchanged; player_facing docs now say view/aim. Gauntlet fixtures regenerated (facing octants now reflect view-only semantics); client replay + live-roundtrip suites green against the new server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -86,7 +86,10 @@ pub struct ObserverSnapshot {
|
||||
pub tick: u64,
|
||||
/// Game time data for client HUD display (D-031)
|
||||
pub game_time: GameTime,
|
||||
/// Player character's facing direction for vision cone (D-015)
|
||||
/// Player character's view direction — the vision-cone heading (D-015).
|
||||
/// View-only since D-252: this is the aim (SetFacing / mouse octant), NOT
|
||||
/// the movement direction. The client derives body heading from position
|
||||
/// deltas (D-248); accepted moves no longer rotate this.
|
||||
pub player_facing: FacingDirection,
|
||||
/// Player's current movement stance for HUD display (#449, D-053).
|
||||
/// Defaults to Walk when stance component is absent.
|
||||
@@ -364,8 +367,9 @@ pub struct InventoryItem {
|
||||
pub slot: u8,
|
||||
}
|
||||
|
||||
/// 8-directional facing direction, matching movement system.
|
||||
/// 8-directional facing direction — the view/aim heading, not movement.
|
||||
/// Used for vision cone computation (D-015) and snapshot wire format.
|
||||
/// View-only since D-252: set via SetFacing, never by accepted moves.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
|
||||
pub enum FacingDirection {
|
||||
#[default]
|
||||
|
||||
@@ -15,8 +15,10 @@ use crate::bridge::types::{FacingDirection, VisibilitySector};
|
||||
use crate::perception::shadowcast::VisibilityMap;
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
/// Component tracking which direction an entity faces.
|
||||
/// Updated by the input system when an entity moves.
|
||||
/// Component tracking which direction an entity faces — the view/aim heading,
|
||||
/// not the movement direction (view-only since D-252). Set explicitly: the
|
||||
/// player's via `handle_set_facing` (SetFacing input), an NPC's via the
|
||||
/// path-follow system's step-direction intent (`simulation::path_follow`).
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Facing(pub FacingDirection);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::bridge::types::MovementStance;
|
||||
use crate::knowledge::types::SoundRange;
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::sound::{SoundEvent, SoundEventEmitter, SoundEventKind};
|
||||
use crate::simulation::stance::Stance;
|
||||
|
||||
@@ -476,10 +475,9 @@ pub fn apply_move(
|
||||
commands.entity(entity).insert(MoveIntent {
|
||||
target: TilePosition::new(pos.x + dx, pos.y + dy, pos.z),
|
||||
});
|
||||
// Update facing direction based on movement (D-015 vision cone)
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Facing(facing_from_delta(dx, dy)));
|
||||
// Facing is view-only (D-252): accepted moves no longer overwrite it. The
|
||||
// player's Facing changes only via the explicit SetFacing input path
|
||||
// (perception::vision_cone::handle_set_facing).
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1454,4 +1452,97 @@ mod tests {
|
||||
schedule.run(&mut world);
|
||||
assert!(world.get::<MoveIntent>(player).is_some());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Facing is view-only (D-252): movement never writes Facing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn accepted_move_does_not_change_explicit_facing() {
|
||||
use crate::bridge::types::FacingDirection;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Player aims east via an explicit SetFacing (represented directly here).
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Facing(FacingDirection::East),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems(process_player_input);
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
// The move is accepted…
|
||||
assert!(
|
||||
world.get::<MoveIntent>(player).is_some(),
|
||||
"move should be accepted"
|
||||
);
|
||||
// …but the explicit aim is untouched — Facing is view-only (D-252).
|
||||
assert_eq!(
|
||||
world.get::<Facing>(player).unwrap().0,
|
||||
FacingDirection::East,
|
||||
"accepted move must not overwrite explicit Facing (D-252)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_move_does_not_change_facing() {
|
||||
// "Bump-to-turn" is retired (D-252): a move blocked by terrain leaves
|
||||
// both position and Facing unchanged.
|
||||
use crate::bridge::types::FacingDirection;
|
||||
use crate::perception::vision_cone::Facing;
|
||||
|
||||
let mut world = bevy_ecs::world::World::new();
|
||||
world.insert_resource(InputQueue::default());
|
||||
world.insert_resource(SimulationTime::default());
|
||||
world.init_resource::<crate::knowledge::EntityRegistry>();
|
||||
|
||||
// Wall the tile directly north of the player (y-down: north = y-1).
|
||||
let mut map = WalkabilityMap::new(10, 10, 1);
|
||||
map.set_walkable(&TilePosition::new(5, 4, 0), false);
|
||||
world.insert_resource(map);
|
||||
|
||||
let player = world
|
||||
.spawn((
|
||||
PlayerCharacter,
|
||||
TilePosition::new(5, 5, 0),
|
||||
Facing(FacingDirection::East),
|
||||
))
|
||||
.id();
|
||||
|
||||
let mut schedule = bevy_ecs::schedule::Schedule::default();
|
||||
schedule.add_systems((process_player_input, validate_movement).chain());
|
||||
|
||||
world.resource_mut::<InputQueue>().push(PlayerInput {
|
||||
tick: 0,
|
||||
action: PlayerAction::MoveNorth,
|
||||
});
|
||||
schedule.run(&mut world);
|
||||
|
||||
// Position held by the wall…
|
||||
assert_eq!(
|
||||
*world.get::<TilePosition>(player).unwrap(),
|
||||
TilePosition::new(5, 5, 0),
|
||||
"blocked move should not change position"
|
||||
);
|
||||
// …and Facing unchanged — no bump-to-turn (D-252).
|
||||
assert_eq!(
|
||||
world.get::<Facing>(player).unwrap().0,
|
||||
FacingDirection::East,
|
||||
"blocked move must not change Facing (D-252)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
use bevy_ecs::prelude::*;
|
||||
|
||||
use crate::npc::Npc;
|
||||
use crate::simulation::movement::MoveIntent;
|
||||
use crate::perception::vision_cone::{facing_from_delta, Facing};
|
||||
use crate::simulation::movement::{MoveIntent, TilePosition};
|
||||
use crate::simulation::pathfinding::{ComputedPath, PathBlocked};
|
||||
use crate::simulation::tier::ActiveSim;
|
||||
|
||||
@@ -57,21 +58,38 @@ impl MovementSpeed {
|
||||
pub fn follow_paths(
|
||||
mut commands: Commands,
|
||||
mut query: Query<
|
||||
(Entity, &mut ComputedPath, Option<&mut MovementSpeed>),
|
||||
(
|
||||
Entity,
|
||||
&mut ComputedPath,
|
||||
Option<&TilePosition>,
|
||||
Option<&mut MovementSpeed>,
|
||||
),
|
||||
(With<Npc>, With<ActiveSim>),
|
||||
>,
|
||||
) {
|
||||
for (entity, mut path, speed_opt) in query.iter_mut() {
|
||||
for (entity, mut path, pos_opt, speed_opt) in query.iter_mut() {
|
||||
if let Some(mut speed) = speed_opt {
|
||||
if !speed.should_step() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(next_pos) = path.next_step() {
|
||||
if let Some(next_pos) = path.next_step().copied() {
|
||||
// Look where you walk is now AI intent (D-252): the path-follow
|
||||
// system sets Facing to the step direction explicitly, rather than
|
||||
// relying on the movement system to couple facing to motion.
|
||||
if let Some(pos) = pos_opt {
|
||||
let dx = next_pos.x - pos.x;
|
||||
let dy = next_pos.y - pos.y;
|
||||
if dx != 0 || dy != 0 {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(Facing(facing_from_delta(dx, dy)));
|
||||
}
|
||||
}
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(MoveIntent { target: *next_pos });
|
||||
.insert(MoveIntent { target: next_pos });
|
||||
path.advance();
|
||||
}
|
||||
|
||||
@@ -92,6 +110,7 @@ pub fn cleanup_path_blocked(mut commands: Commands, query: Query<Entity, With<Pa
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::bridge::types::FacingDirection;
|
||||
use crate::simulation::movement::TilePosition;
|
||||
use crate::simulation::pathfinding::ComputedPath;
|
||||
|
||||
@@ -125,6 +144,11 @@ mod tests {
|
||||
// Path advanced to index 1
|
||||
let path = world.get::<ComputedPath>(entity).unwrap();
|
||||
assert_eq!(path.current_index, 1);
|
||||
// D-252: the step direction (east) is set as explicit gaze intent.
|
||||
assert_eq!(
|
||||
world.get::<Facing>(entity).unwrap().0,
|
||||
FacingDirection::East
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -151,6 +175,11 @@ mod tests {
|
||||
assert!(world.get::<ComputedPath>(entity).is_none());
|
||||
// But MoveIntent was still created
|
||||
assert!(world.get::<MoveIntent>(entity).is_some());
|
||||
// D-252: the step direction (east) is set as explicit gaze intent.
|
||||
assert_eq!(
|
||||
world.get::<Facing>(entity).unwrap().0,
|
||||
FacingDirection::East
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,6 +217,11 @@ mod tests {
|
||||
world.get::<MoveIntent>(entity).unwrap().target,
|
||||
TilePosition::new(1, 0, 0)
|
||||
);
|
||||
// D-252: gaze intent set on the step tick, not the throttled ticks.
|
||||
assert_eq!(
|
||||
world.get::<Facing>(entity).unwrap().0,
|
||||
FacingDirection::East
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user