From 2f456f80b8c5c5fda8a716bfb15465ab930e2899 Mon Sep 17 00:00:00 2001 From: popertots Date: Sun, 22 Mar 2026 11:29:50 +0000 Subject: [PATCH] Gaussian idle --- assets/entities/dorf.toml | 11 ++ assets/entities/pig.toml | 6 + assets/entities/rabbit.toml | 6 + src/entities/behaviour/mod.rs | 3 + src/entities/behaviour/registry.rs | 70 ++++++++ src/entities/livestock/pig.rs | 4 +- src/entities/livestock/rabbit.rs | 3 + src/entities/mod.rs | 1 + src/entities/sentient/dorf.rs | 10 +- src/entities/tasks/components.rs | 33 +++- src/entities/tasks/executor.rs | 48 ++++-- src/entities/tasks/idle.rs | 257 +++++++++++++++++++++-------- src/entities/tasks/mod.rs | 2 +- src/world/mod.rs | 6 +- 14 files changed, 354 insertions(+), 106 deletions(-) create mode 100644 assets/entities/dorf.toml create mode 100644 assets/entities/pig.toml create mode 100644 assets/entities/rabbit.toml create mode 100644 src/entities/behaviour/mod.rs create mode 100644 src/entities/behaviour/registry.rs diff --git a/assets/entities/dorf.toml b/assets/entities/dorf.toml new file mode 100644 index 0000000..8e8220b --- /dev/null +++ b/assets/entities/dorf.toml @@ -0,0 +1,11 @@ +[idle] +# Standard deviation of wander spread in world units. +# 3 chunks * CHUNK_SIZE(8) * ITILE_SIZE(16) = 384.0 +sigma_world = 384.0 +# Probability (0.0-1.0) that arriving at a target triggers a loiter pause. +loiter_chance = 0.6 +# Loiter duration range in FixedUpdate ticks (~60 TPS). +loiter_min_ticks = 60 # ~1 second +loiter_max_ticks = 240 # ~4 seconds +# Probability each of the two flip opportunities fires. +flip_chance = 0.5 \ No newline at end of file diff --git a/assets/entities/pig.toml b/assets/entities/pig.toml new file mode 100644 index 0000000..3927d9e --- /dev/null +++ b/assets/entities/pig.toml @@ -0,0 +1,6 @@ +[idle] +sigma_world = 256.0 +loiter_chance = 0.7 +loiter_min_ticks = 90 +loiter_max_ticks = 300 +flip_chance = 0.4 \ No newline at end of file diff --git a/assets/entities/rabbit.toml b/assets/entities/rabbit.toml new file mode 100644 index 0000000..620cd73 --- /dev/null +++ b/assets/entities/rabbit.toml @@ -0,0 +1,6 @@ +[idle] +sigma_world = 192.0 +loiter_chance = 0.3 +loiter_min_ticks = 20 +loiter_max_ticks = 80 +flip_chance = 0.3 \ No newline at end of file diff --git a/src/entities/behaviour/mod.rs b/src/entities/behaviour/mod.rs new file mode 100644 index 0000000..7e824e1 --- /dev/null +++ b/src/entities/behaviour/mod.rs @@ -0,0 +1,3 @@ +pub mod registry; + +pub use registry::{EntityBehaviourRegistry, EntityType, IdleBehaviour}; diff --git a/src/entities/behaviour/registry.rs b/src/entities/behaviour/registry.rs new file mode 100644 index 0000000..1d2ec66 --- /dev/null +++ b/src/entities/behaviour/registry.rs @@ -0,0 +1,70 @@ +//! Per-entity behaviour configuration loaded from assets/entities/*.toml. +//! +//! Follows the DropTableRegistry pattern — loaded once at startup via +//! EntityBehaviourRegistry::load(), inserted as a Resource, and exposed +//! globally via OnceLock for use in async contexts. + +use bevy::prelude::*; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::OnceLock; + +static BEHAVIOUR_GLOBAL: OnceLock> = OnceLock::new(); + +/// Idle/wandering behaviour parameters for one entity type. +#[derive(Deserialize, Debug, Clone)] +pub struct IdleBehaviour { + /// Standard deviation of Gaussian wander spread in world units. + pub sigma_world: f32, + /// Probability (0.0–1.0) of entering loiter on arrival. + pub loiter_chance: f32, + /// Minimum loiter duration in FixedUpdate ticks. + pub loiter_min_ticks: u32, + /// Maximum loiter duration in FixedUpdate ticks. + pub loiter_max_ticks: u32, + /// Probability each flip opportunity fires during loiter. + pub flip_chance: f32, +} + +/// All behaviour config for one entity type. +#[derive(Deserialize, Debug, Clone)] +pub struct EntityBehaviour { + pub idle: IdleBehaviour, +} + +/// Registry of all loaded entity behaviours, keyed by entity type name. +#[derive(Resource)] +pub struct EntityBehaviourRegistry(pub HashMap); + +impl EntityBehaviourRegistry { + /// Load all entity TOML files at startup. + pub fn load() -> Self { + let mut map = HashMap::new(); + for name in &["dorf", "rabbit", "pig"] { + let path = format!("assets/entities/{}.toml", name); + let src = std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("assets/entities/{}.toml not found", name)); + let behaviour: EntityBehaviour = toml::from_str(&src) + .unwrap_or_else(|e| panic!("Failed to parse {}.toml: {}", name, e)); + map.insert(name.to_string(), behaviour); + } + Self(map) + } + + /// Initialise the global OnceLock for async/non-ECS access. + pub fn init_global(registry: &EntityBehaviourRegistry) { + BEHAVIOUR_GLOBAL.get_or_init(|| registry.0.clone()); + } + + /// Get behaviour config for an entity type. + pub fn global_get(entity_type: &str) -> EntityBehaviour { + BEHAVIOUR_GLOBAL + .get() + .and_then(|m| m.get(entity_type).cloned()) + .unwrap_or_else(|| panic!("No behaviour config for entity type '{}'", entity_type)) + } +} + +/// Marker component identifying an entity's type for behaviour lookup. +#[derive(Component, Debug, Clone, Copy)] +pub struct EntityType(pub &'static str); diff --git a/src/entities/livestock/pig.rs b/src/entities/livestock/pig.rs index 9b078a5..43c9c16 100644 --- a/src/entities/livestock/pig.rs +++ b/src/entities/livestock/pig.rs @@ -1,6 +1,7 @@ use crate::config::GameConfig; use crate::constants::TILE_SIZE; use crate::constants::*; +use crate::entities::behaviour::EntityType; use crate::entities::shared_components::Ambulatory; use crate::game::SpawnDelay; use crate::world::VisibleGameEntity; @@ -14,7 +15,7 @@ pub struct Pig { sprite: Sprite, transform: Transform, visibility: Visibility, - // Pigs have no inventory — PersonalInventory is not attached to this entity. + entity_type: EntityType, } impl Pig { @@ -40,6 +41,7 @@ impl Pig { }, transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)), visibility: Visibility::Hidden, + entity_type: EntityType("pig"), } } } diff --git a/src/entities/livestock/rabbit.rs b/src/entities/livestock/rabbit.rs index e5a0eb6..a93588f 100644 --- a/src/entities/livestock/rabbit.rs +++ b/src/entities/livestock/rabbit.rs @@ -1,5 +1,6 @@ use crate::config::GameConfig; use crate::constants::*; +use crate::entities::behaviour::EntityType; use crate::entities::shared_components::Ambulatory; use crate::entities::shared_systems::constants::DEFAULT_DIG_INTERVAL_SECS; use crate::entities::shared_systems::digging::Digger; @@ -16,6 +17,7 @@ pub struct Rabbit { transform: Transform, visibility: Visibility, digger: Digger, + entity_type: EntityType, } impl Rabbit { @@ -42,6 +44,7 @@ impl Rabbit { transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)), visibility: Visibility::Hidden, digger: Digger::new(DEFAULT_DIG_INTERVAL_SECS), + entity_type: EntityType("rabbit"), } } } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 02441e2..4416bfe 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,3 +1,4 @@ +pub mod behaviour; pub mod cargo; pub mod item; pub mod livestock; diff --git a/src/entities/sentient/dorf.rs b/src/entities/sentient/dorf.rs index 7cee8e3..070c6c3 100644 --- a/src/entities/sentient/dorf.rs +++ b/src/entities/sentient/dorf.rs @@ -1,9 +1,10 @@ use crate::config::GameConfig; use crate::constants::TILE_SIZE; use crate::constants::*; +use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType}; use crate::entities::cargo::{CarryVisualState, HaulSlot}; use crate::entities::shared_components::Ambulatory; -use crate::entities::tasks::{Task, TaskQueue, TaskState}; +use crate::entities::tasks::{IdleState, Task, TaskQueue, TaskState}; use crate::game::SpawnDelay; use crate::world::VisibleGameEntity; use bevy::prelude::*; @@ -21,6 +22,7 @@ pub struct Dorf { carry_visual: CarryVisualState, task_queue: TaskQueue, task_state: TaskState, + entity_type: EntityType, } impl Dorf { @@ -30,6 +32,7 @@ impl Dorf { let normal_sprite = asset_server.load("dorf.png"); let carry_sprite = asset_server.load("dorf.png"); let origin = position.as_ivec3(); + let behaviour = EntityBehaviourRegistry::global_get("dorf"); Dorf { ambulatory: Ambulatory { @@ -54,12 +57,13 @@ impl Dorf { carry_visual: CarryVisualState::new(normal_sprite, carry_sprite), task_queue: TaskQueue { tasks: VecDeque::from([Task::Idle { - target: origin, - wander_radius: 10, origin, + sigma_world: behaviour.idle.sigma_world, + state: IdleState::Picking, }]), }, task_state: TaskState::Pending, + entity_type: EntityType("dorf"), } } } diff --git a/src/entities/tasks/components.rs b/src/entities/tasks/components.rs index 6555319..19f2113 100644 --- a/src/entities/tasks/components.rs +++ b/src/entities/tasks/components.rs @@ -14,6 +14,24 @@ use bevy::prelude::*; use std::collections::VecDeque; +/// Sub-state of Task::Idle. +#[derive(Clone, Debug, PartialEq)] +pub enum IdleState { + /// Pick a new target this tick. + Picking, + /// Walking toward target. + Moving { target: IVec3 }, + /// Standing still, loitering. + Loitering { + /// Ticks remaining in loiter. + ticks_remaining: u32, + /// Remaining sprite-flip opportunities (starts at 0, 1, or 2). + flips_remaining: u8, + /// Tick count at which the next flip fires. + next_flip_at: u32, + }, +} + /// A single task an entity can execute. /// /// Tasks are self-contained: they carry all data needed for execution. @@ -27,17 +45,14 @@ use std::collections::VecDeque; #[derive(Clone, Debug, PartialEq)] #[repr(u8)] pub enum Task { - /// Idle/wandering behaviour. Refactored from existing wandering system. - /// Target is a standable tile within wander_radius; entity moves toward it. - /// When reached, picks new random target. Never "completes" — cycles forever - /// until replaced by a higher-priority task. Idle { - /// Current wander target tile (world position). - target: IVec3, - /// Max distance from origin to wander (Chebyshev). - wander_radius: i32, - /// Origin tile for wander bounds. + /// Home position in world units — centre of Gaussian distribution. + /// Set at spawn, does not drift. origin: IVec3, + /// Standard deviation in world units. Read from EntityBehaviourRegistry. + sigma_world: f32, + /// Current execution sub-state. + state: IdleState, }, /// Move to a tile within "close enough" threshold. diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index a344a48..ccf49b8 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -6,14 +6,15 @@ //! 3. On completion/failure, update TaskState, fire event, pop task //! 4. If queue empty after pop, assign default Task::Idle //! -//! Uses Changed + Changed to minimize queries. +//! Uses Changed + Changed to minimise queries. -use crate::constants::ITILE_SIZE; +use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType}; use crate::entities::shared_components::Ambulatory; -use crate::entities::tasks::components::{Task, TaskQueue, TaskState}; +use crate::entities::tasks::components::{IdleState, Task, TaskQueue, TaskState}; use crate::entities::tasks::events::{TaskClaimed, TaskCompleted, TaskFailed}; use crate::entities::tasks::idle::execute_idle; -use crate::world::tiles::tilemap::TileMap; +use crate::world::chunks::ChunkMap; +use crate::world::tiles::TileMap; use bevy::prelude::*; use bevy_rand::prelude::*; use rand::{RngExt, SeedableRng}; @@ -22,13 +23,18 @@ use rand::{RngExt, SeedableRng}; pub fn task_executor_system( mut commands: Commands, tilemap: Res, + chunk_map: Res, + behaviour_registry: Res, mut rng_q: Query<&mut WyRand, With>, + mut tick: Local, mut query: Query<( Entity, &mut TaskQueue, &mut TaskState, &mut Ambulatory, &Transform, + &mut Sprite, + &EntityType, )>, mut claimed_writer: MessageWriter, mut completed_writer: MessageWriter, @@ -38,14 +44,21 @@ pub fn task_executor_system( return; }; - for (entity, mut queue, mut state, mut ambulatory, transform) in query.iter_mut() { - // If queue empty, assign default Idle task + *tick = tick.wrapping_add(1); + let current_tick = *tick; + + for (entity, mut queue, mut state, mut ambulatory, transform, mut sprite, entity_type) in + query.iter_mut() + { + let origin = transform.translation.as_ivec3(); + let behaviour = EntityBehaviourRegistry::global_get(entity_type.0); + + // If queue empty, assign Idle with Gaussian params from TOML if queue.is_empty() { - let origin = transform.translation.as_ivec3(); queue.push(Task::Idle { - target: origin, - wander_radius: 64, origin, + sigma_world: behaviour.idle.sigma_world, + state: IdleState::Picking, }); } @@ -66,20 +79,22 @@ pub fn task_executor_system( match current_task { Task::Idle { .. } => { execute_idle( - entity, current_task, transform, &mut ambulatory, + &mut sprite, &tilemap, + &chunk_map, + &behaviour.idle, &mut rng, + current_tick, ); } Task::GoTo { target, threshold_tiles, } => { - let entity_pos = (transform.translation / ITILE_SIZE as f32).as_ivec3(); - // Chebyshev distance (max of absolute differences) + let entity_pos = (transform.translation / 16.0f32).as_ivec3(); let distance = (entity_pos.x - target.x) .abs() .max((entity_pos.y - target.y).abs()); @@ -87,7 +102,6 @@ pub fn task_executor_system( if distance <= *threshold_tiles || !tilemap.is_standable(*target) { *state = TaskState::Completed; } else { - // Set target - pathfinding handles the rest ambulatory.target = Some(Vec3::new( target.x as f32, target.y as f32, @@ -96,7 +110,6 @@ pub fn task_executor_system( ambulatory.current_path = None; } } - // Stage 3 placeholders - log and fail for now Task::ChopTree { .. } | Task::HaulObject { .. } | Task::DropHauled { .. } => { debug!( "Task {} unimplemented for entity {:?}", @@ -125,13 +138,12 @@ pub fn task_executor_system( }); } - // If terminal task completed, ensure Idle is queued + // If terminal task completed, push Idle with Gaussian params if completed_task.is_terminal() && queue.is_empty() { - let origin = (transform.translation / ITILE_SIZE as f32).as_ivec3(); queue.push(Task::Idle { - target: origin, - wander_radius: 64, origin, + sigma_world: behaviour.idle.sigma_world, + state: IdleState::Picking, }); } } diff --git a/src/entities/tasks/idle.rs b/src/entities/tasks/idle.rs index 43b55b9..d8f3c86 100644 --- a/src/entities/tasks/idle.rs +++ b/src/entities/tasks/idle.rs @@ -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/.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 { - 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 { + let two_sigma_sq = 2.0 * sigma_world * sigma_world; + let mut chosen: Option = 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 { + 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 +} diff --git a/src/entities/tasks/mod.rs b/src/entities/tasks/mod.rs index 8d622f9..b7fa3eb 100644 --- a/src/entities/tasks/mod.rs +++ b/src/entities/tasks/mod.rs @@ -3,7 +3,7 @@ pub mod events; pub mod executor; pub mod idle; -pub use components::{Task, TaskQueue, TaskState}; +pub use components::{IdleState, Task, TaskQueue, TaskState}; pub use events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed}; pub use executor::task_executor_system; diff --git a/src/world/mod.rs b/src/world/mod.rs index 6c236ff..77de088 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -36,7 +36,11 @@ impl Plugin for WorldPlugin { fn build(&self, app: &mut App) { let drop_registry = crate::entities::item::drop_table::DropTableRegistry::load(); crate::entities::item::drop_table::DropTableRegistry::init_global(&drop_registry); - app.insert_resource(drop_registry) + app.insert_resource(drop_registry); + + let behaviour_registry = crate::entities::behaviour::EntityBehaviourRegistry::load(); + crate::entities::behaviour::EntityBehaviourRegistry::init_global(&behaviour_registry); + app.insert_resource(behaviour_registry) .init_resource::() .init_resource::() .init_resource::()