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:
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
®istry,
|
||||
&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.
|
||||
|
||||
@@ -208,9 +208,9 @@ pub fn compute_nearby_interactions(
|
||||
let mut verbs = Vec::new();
|
||||
|
||||
if is_npc.is_some() {
|
||||
// NPC verb logic — unchanged from #404
|
||||
// NPC verb logic — Talk + ExamineNpc (#404), Follow (#241)
|
||||
if is_close {
|
||||
// Default priority: Talk first, Observe second.
|
||||
// Default priority: Talk first, Observe second, Follow third.
|
||||
// Observer adjusts priority for POI entities.
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Talk,
|
||||
@@ -224,8 +224,14 @@ pub fn compute_nearby_interactions(
|
||||
priority: 2,
|
||||
available: true,
|
||||
});
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::Follow,
|
||||
label: "Follow".into(),
|
||||
priority: 3,
|
||||
available: true,
|
||||
});
|
||||
} else {
|
||||
// Mid range: only Examine NPC (Talk requires close range)
|
||||
// Mid range: only Examine NPC (Talk + Follow require close range)
|
||||
verbs.push(VerbOption {
|
||||
kind: VerbKind::ExamineNpc,
|
||||
label: "Examine NPC".into(),
|
||||
@@ -346,7 +352,7 @@ mod tests {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn npc_in_close_range_gets_talk_and_observe() {
|
||||
fn npc_in_close_range_gets_talk_examine_follow() {
|
||||
let mut world = setup_world();
|
||||
spawn_player(&mut world, 5, 5);
|
||||
world.spawn((Npc, TilePosition::new(5, 6, 0), Interactable));
|
||||
@@ -357,11 +363,13 @@ mod tests {
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 3);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].priority, 1);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].priority, 2);
|
||||
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
|
||||
assert_eq!(buffer.interactions[0].verbs[2].priority, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -845,10 +853,11 @@ mod tests {
|
||||
|
||||
let buffer = read_buffer(&mut world);
|
||||
assert_eq!(buffer.interactions.len(), 1);
|
||||
// Should get NPC verbs (Talk + ExamineNpc), NOT Terminal verbs (Use + Observe)
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 2);
|
||||
// Should get NPC verbs (Talk + ExamineNpc + Follow), NOT Terminal verbs (Use + Observe)
|
||||
assert_eq!(buffer.interactions[0].verbs.len(), 3);
|
||||
assert_eq!(buffer.interactions[0].verbs[0].kind, VerbKind::Talk);
|
||||
assert_eq!(buffer.interactions[0].verbs[1].kind, VerbKind::ExamineNpc);
|
||||
assert_eq!(buffer.interactions[0].verbs[2].kind, VerbKind::Follow);
|
||||
}
|
||||
|
||||
/// All ObjectType primary verbs are close_only (except Observe).
|
||||
|
||||
Reference in New Issue
Block a user