Gaussian idle

This commit is contained in:
2026-03-22 11:29:50 +00:00
parent b2dc0cb864
commit 2f456f80b8
14 changed files with 354 additions and 106 deletions
+184 -73
View File
@@ -1,101 +1,212 @@
//! Idle task implementation — wandering behaviour refactored here.
//! Idle task — Gaussian wander with loiter behaviour.
//!
//! This module contains the logic that was previously in the wandering
//! system. Now it's called by the task executor when Task::Idle is active.
//! # Wander model
//! Targets are selected from all standable tiles across loaded chunks,
//! weighted by Gaussian falloff from origin. Closer tiles are more likely;
//! distant tiles occasionally chosen. No hard radius cutoff.
//!
//! # Loiter model
//! On arrival there is a configurable chance of entering a loiter pause.
//! During loiter the entity stands still and may flip their sprite direction
//! once or twice to simulate "looking around". Duration and flip timing are
//! randomised within configured bounds.
//!
//! # Configuration
//! All tuning values come from assets/entities/<type>.toml via
//! EntityBehaviourRegistry. No constants in this file.
use crate::constants::{ITILE_SIZE, TILE_SIZE};
use crate::entities::behaviour::IdleBehaviour;
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::components::Task;
use crate::world::tiles::tilemap::TileMap;
use crate::entities::tasks::components::{IdleState, Task};
use crate::world::chunks::ChunkMap;
use crate::world::chunks::CHUNK_SIZE;
use crate::world::tiles::TileMap;
use bevy::prelude::*;
use bevy_rand::prelude::*;
use rand::RngExt;
/// Execute Idle task: set target for wandering, let pathfinding handle movement.
/// Execute one tick of Task::Idle.
/// Called by task_executor_system when current task is Idle.
pub(super) fn execute_idle(
entity: Entity,
task: &mut Task,
transform: &Transform,
ambulatory: &mut Ambulatory,
sprite: &mut Sprite,
tilemap: &TileMap,
chunk_map: &ChunkMap,
behaviour: &IdleBehaviour,
rng: &mut WyRand,
current_tick: u32,
) {
let Task::Idle {
target,
wander_radius,
origin,
sigma_world,
state,
} = task
else {
return;
};
let entity_pos = transform.translation.as_ivec3();
// Track if target actually changed this tick
let mut target_changed = false;
// Use distance threshold for arrival check - equality never triggers due to float truncation
let dist_sq = (transform.translation.x - target.x as f32).powi(2)
+ (transform.translation.y - target.y as f32).powi(2);
let arrived = dist_sq < (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
// If reached target or target is no longer standable, pick new target
if arrived || !tilemap.is_standable(*target) {
if let Some(new_target) = pick_wander_target(origin, *wander_radius, tilemap, rng) {
*target = new_target;
target_changed = true;
}
}
// Only reset path if target changed or no path exists
if target_changed || ambulatory.current_path.is_none() {
// +1.0 sub-tile offset matching original wandering system
ambulatory.target = Some(Vec3::new(
target.x as f32,
target.y as f32,
target.z as f32 + 1.0,
));
ambulatory.current_path = None;
ambulatory.path_index = 0;
}
}
/// Pick a random standable tile within wander radius of origin.
/// Uses reservoir sampling to avoid heap allocation.
/// Searches for actual surface z at each XY position (like original wandering system).
fn pick_wander_target(
origin: &IVec3,
radius: i32,
tilemap: &TileMap,
rng: &mut WyRand,
) -> Option<IVec3> {
let mut chosen = None;
let mut count = 0u32;
for dx in -radius..=radius {
for dy in -radius..=radius {
// Search for actual surface z at this XY - same logic as original wandering
for z in -3i32..=4i32 {
let floor_pos = IVec3::new(
origin.x + dx * ITILE_SIZE,
origin.y + dy * ITILE_SIZE,
z * ITILE_SIZE,
);
// Check if there's a floor tile at this z
if tilemap.floor_tiles.get(&floor_pos).is_some() {
// Check if standable one tile above
let above = IVec3::new(floor_pos.x, floor_pos.y, floor_pos.z + ITILE_SIZE);
if tilemap.is_standable(above) {
count += 1;
// Reservoir sampling: 1/count chance to replace chosen
if rng.random_range(0..count) == 0 {
chosen = Some(above);
}
break; // found surface at this XY, stop z search
}
match state {
IdleState::Picking => {
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
Some(target) => {
ambulatory.target = Some(Vec3::new(
target.x as f32,
target.y as f32,
target.z as f32 + 1.0,
));
ambulatory.current_path = None;
ambulatory.path_index = 0;
*state = IdleState::Moving { target };
}
None => {
// No standable tile found yet (early startup, chunk loading).
// Leave ambulatory.target as-is, try again next tick.
}
}
}
IdleState::Moving { target } => {
let dx = transform.translation.x - target.x as f32;
let dy = transform.translation.y - target.y as f32;
let dist_sq = dx * dx + dy * dy;
let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
let arrived = dist_sq < arrive_threshold_sq;
let stuck = ambulatory.target.is_none() && ambulatory.current_path.is_none();
if arrived || stuck {
ambulatory.target = None;
ambulatory.current_path = None;
let loiter_roll: f32 = rng.random();
if loiter_roll < behaviour.loiter_chance {
let duration_range = behaviour.loiter_max_ticks - behaviour.loiter_min_ticks;
let duration =
behaviour.loiter_min_ticks + rng.random_range(0..=duration_range);
let flip1: f32 = rng.random();
let flip2: f32 = rng.random();
let flips_remaining = (flip1 < behaviour.flip_chance) as u8
+ (flip2 < behaviour.flip_chance) as u8;
let next_flip_at = if flips_remaining > 0 {
current_tick.saturating_add(rng.random_range(1..=duration / 2))
} else {
u32::MAX
};
*state = IdleState::Loitering {
ticks_remaining: duration,
flips_remaining,
next_flip_at,
};
} else {
*state = IdleState::Picking;
}
}
}
IdleState::Loitering {
ticks_remaining,
flips_remaining,
next_flip_at,
} => {
if *flips_remaining > 0 && current_tick >= *next_flip_at {
sprite.flip_x = !sprite.flip_x;
*flips_remaining -= 1;
if *flips_remaining > 0 && *ticks_remaining > 2 {
*next_flip_at =
current_tick.saturating_add(rng.random_range(1..=*ticks_remaining / 2));
}
}
if *ticks_remaining == 0 {
sprite.flip_x = false;
*state = IdleState::Picking;
} else {
*ticks_remaining -= 1;
}
}
}
}
/// Pick a target tile using Gaussian-weighted reservoir sampling across all
/// loaded chunks. Tiles closer to `origin` are exponentially more likely.
///
/// Returns a standable world-unit tile position, or None if no candidate found
/// (chunk not yet loaded, or all tiles above the search z range).
fn pick_gaussian_target(
origin: &IVec3,
sigma_world: f32,
tilemap: &TileMap,
chunk_map: &ChunkMap,
rng: &mut WyRand,
) -> Option<IVec3> {
let two_sigma_sq = 2.0 * sigma_world * sigma_world;
let mut chosen: Option<IVec3> = None;
let mut weight_sum = 0.0f32;
for &chunk_pos in chunk_map.loaded_chunks.keys() {
let has_all_neighbours = chunk_map
.loaded_chunks
.contains_key(&(chunk_pos + IVec2::X))
&& chunk_map
.loaded_chunks
.contains_key(&(chunk_pos - IVec2::X))
&& chunk_map
.loaded_chunks
.contains_key(&(chunk_pos + IVec2::Y))
&& chunk_map
.loaded_chunks
.contains_key(&(chunk_pos - IVec2::Y));
if !has_all_neighbours {
continue;
}
const SAMPLES_PER_CHUNK: usize = 4;
for _ in 0..SAMPLES_PER_CHUNK {
let local_x = rng.random_range(0..CHUNK_SIZE);
let local_y = rng.random_range(0..CHUNK_SIZE);
let world_x = (chunk_pos.x * CHUNK_SIZE + local_x) * ITILE_SIZE;
let world_y = (chunk_pos.y * CHUNK_SIZE + local_y) * ITILE_SIZE;
let Some(candidate) = find_surface(world_x, world_y, tilemap) else {
continue;
};
let dx = (candidate.x - origin.x) as f32;
let dy = (candidate.y - origin.y) as f32;
let dist_sq = dx * dx + dy * dy;
let weight = (-dist_sq / two_sigma_sq).exp();
weight_sum += weight;
let accept: f32 = rng.random();
if accept < weight / weight_sum {
chosen = Some(candidate);
}
}
}
chosen
}
/// Find the standable tile at a given XY world position by scanning z levels.
/// Returns the tile one above the highest floor tile (where entity stands).
/// Returns None if no standable surface found in range.
#[inline]
fn find_surface(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option<IVec3> {
for z in -3i32..=4i32 {
let floor_pos = IVec3::new(world_x, world_y, z * ITILE_SIZE);
if tilemap.floor_tiles.contains_key(&floor_pos) {
let above = IVec3::new(world_x, world_y, floor_pos.z + ITILE_SIZE);
if tilemap.is_standable(above) {
return Some(above);
}
}
}
None
}