Gaussian idle
This commit is contained in:
+184
-73
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user