feat(simulation): add follow mechanic (#241)

Follow verb on interaction dispatcher. FollowTarget component tracks
target entity, proximity ticks, and LOS-lost ticks. update_follow_state
system: observation events fire at double frequency while following,
NPC suspicion increases via stress when player within 2 tiles for 60+
ticks (configurable). Follow ends on LOS lost timeout, suspicion
threshold crossed, or player issues different action. FollowStateWire
emitted in ObserverSnapshot for client HUD.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 14:39:47 +01:00
co-authored by Claude Opus 4.6
parent f31be6f866
commit 0af86b1f0b
3 changed files with 1132 additions and 9 deletions
+84 -2
View File
@@ -180,7 +180,16 @@ pub fn process_player_input(
PlayerAction::Interact {
target_entity_id,
ref verb,
} => match verb.as_deref() {
} => {
// Cancel follow when player uses any non-Follow verb (#241).
if verb.as_deref() != Some("Follow") {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
.entity(player_entity)
.remove::<crate::simulation::follow::FollowTarget>();
}
}
match verb.as_deref() {
Some("Take") => {
handle_take(
&mut commands,
@@ -202,6 +211,16 @@ pub fn process_player_input(
target_entity_id,
);
}
Some("Follow") => {
handle_follow(
&mut commands,
&registry,
&player_query,
&all_positions,
target_entity_id,
current_tick,
);
}
Some("Confront") => {
handle_confront(
&mut commands,
@@ -228,7 +247,7 @@ pub fn process_player_input(
verb,
);
}
},
}}
PlayerAction::WalkAway => {
if let Ok((player_entity, _, _, _)) = player_query.single() {
commands
@@ -501,6 +520,69 @@ fn handle_confront(
);
}
/// Handle Follow verb: designate an NPC as follow target (#241).
/// Sets FollowTarget on the player entity. Replaces any existing follow target.
/// Server-side range check: Follow requires CLOSE_RANGE (same as Talk).
#[allow(clippy::type_complexity)]
fn handle_follow(
commands: &mut Commands,
registry: &EntityRegistry,
player_query: &Query<
(
Entity,
&TilePosition,
Option<&mut Stance>,
Option<&mut PlayerMoveCooldown>,
),
With<PlayerCharacter>,
>,
all_positions: &Query<&TilePosition>,
target_entity_id: Option<u64>,
current_tick: u64,
) {
let Some(target_id) = target_entity_id else {
tracing::warn!("Follow verb without target_entity_id");
return;
};
let Ok((player_entity, player_pos, _, _)) = player_query.single() else {
return;
};
let target_stable = StableId(target_id);
let Some(target_entity) = registry.to_entity(&target_stable) else {
tracing::warn!(target_id, "Follow: target entity not in registry");
return;
};
// Server-side range check: reject Follow if target is beyond close range
if let Ok(target_pos) = all_positions.get(target_entity) {
let distance = player_pos
.manhattan_distance(target_pos)
.unwrap_or(u32::MAX);
if distance > crate::simulation::interaction::CLOSE_RANGE {
tracing::info!(
target_id,
distance,
"Follow: target out of range (max {})",
crate::simulation::interaction::CLOSE_RANGE,
);
return;
}
}
commands
.entity(player_entity)
.insert(crate::simulation::follow::FollowTarget {
target: target_entity,
started_tick: current_tick,
proximity_ticks: 0,
los_lost_ticks: 0,
});
tracing::debug!(target_id, "Follow: FollowTarget set on player");
}
/// Handle Place verb: remove an item from inventory and place it on the ground
/// at the player's current position. Removes CarriedBy + InventorySlot, adds
/// TilePosition at the player's current tile.