Gaussian idle
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
pub use registry::{EntityBehaviourRegistry, EntityType, IdleBehaviour};
|
||||||
@@ -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<HashMap<String, EntityBehaviour>> = 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<String, EntityBehaviour>);
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::config::GameConfig;
|
use crate::config::GameConfig;
|
||||||
use crate::constants::TILE_SIZE;
|
use crate::constants::TILE_SIZE;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
|
use crate::entities::behaviour::EntityType;
|
||||||
use crate::entities::shared_components::Ambulatory;
|
use crate::entities::shared_components::Ambulatory;
|
||||||
use crate::game::SpawnDelay;
|
use crate::game::SpawnDelay;
|
||||||
use crate::world::VisibleGameEntity;
|
use crate::world::VisibleGameEntity;
|
||||||
@@ -14,7 +15,7 @@ pub struct Pig {
|
|||||||
sprite: Sprite,
|
sprite: Sprite,
|
||||||
transform: Transform,
|
transform: Transform,
|
||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
// Pigs have no inventory — PersonalInventory is not attached to this entity.
|
entity_type: EntityType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Pig {
|
impl Pig {
|
||||||
@@ -40,6 +41,7 @@ impl Pig {
|
|||||||
},
|
},
|
||||||
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
||||||
visibility: Visibility::Hidden,
|
visibility: Visibility::Hidden,
|
||||||
|
entity_type: EntityType("pig"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::config::GameConfig;
|
use crate::config::GameConfig;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
|
use crate::entities::behaviour::EntityType;
|
||||||
use crate::entities::shared_components::Ambulatory;
|
use crate::entities::shared_components::Ambulatory;
|
||||||
use crate::entities::shared_systems::constants::DEFAULT_DIG_INTERVAL_SECS;
|
use crate::entities::shared_systems::constants::DEFAULT_DIG_INTERVAL_SECS;
|
||||||
use crate::entities::shared_systems::digging::Digger;
|
use crate::entities::shared_systems::digging::Digger;
|
||||||
@@ -16,6 +17,7 @@ pub struct Rabbit {
|
|||||||
transform: Transform,
|
transform: Transform,
|
||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
digger: Digger,
|
digger: Digger,
|
||||||
|
entity_type: EntityType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rabbit {
|
impl Rabbit {
|
||||||
@@ -42,6 +44,7 @@ impl Rabbit {
|
|||||||
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
transform: Transform::from_translation(position).with_scale(Vec3::splat(PIXEL_RATIO)),
|
||||||
visibility: Visibility::Hidden,
|
visibility: Visibility::Hidden,
|
||||||
digger: Digger::new(DEFAULT_DIG_INTERVAL_SECS),
|
digger: Digger::new(DEFAULT_DIG_INTERVAL_SECS),
|
||||||
|
entity_type: EntityType("rabbit"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod behaviour;
|
||||||
pub mod cargo;
|
pub mod cargo;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod livestock;
|
pub mod livestock;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::config::GameConfig;
|
use crate::config::GameConfig;
|
||||||
use crate::constants::TILE_SIZE;
|
use crate::constants::TILE_SIZE;
|
||||||
use crate::constants::*;
|
use crate::constants::*;
|
||||||
|
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
|
||||||
use crate::entities::cargo::{CarryVisualState, HaulSlot};
|
use crate::entities::cargo::{CarryVisualState, HaulSlot};
|
||||||
use crate::entities::shared_components::Ambulatory;
|
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::game::SpawnDelay;
|
||||||
use crate::world::VisibleGameEntity;
|
use crate::world::VisibleGameEntity;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -21,6 +22,7 @@ pub struct Dorf {
|
|||||||
carry_visual: CarryVisualState,
|
carry_visual: CarryVisualState,
|
||||||
task_queue: TaskQueue,
|
task_queue: TaskQueue,
|
||||||
task_state: TaskState,
|
task_state: TaskState,
|
||||||
|
entity_type: EntityType,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dorf {
|
impl Dorf {
|
||||||
@@ -30,6 +32,7 @@ impl Dorf {
|
|||||||
let normal_sprite = asset_server.load("dorf.png");
|
let normal_sprite = asset_server.load("dorf.png");
|
||||||
let carry_sprite = asset_server.load("dorf.png");
|
let carry_sprite = asset_server.load("dorf.png");
|
||||||
let origin = position.as_ivec3();
|
let origin = position.as_ivec3();
|
||||||
|
let behaviour = EntityBehaviourRegistry::global_get("dorf");
|
||||||
|
|
||||||
Dorf {
|
Dorf {
|
||||||
ambulatory: Ambulatory {
|
ambulatory: Ambulatory {
|
||||||
@@ -54,12 +57,13 @@ impl Dorf {
|
|||||||
carry_visual: CarryVisualState::new(normal_sprite, carry_sprite),
|
carry_visual: CarryVisualState::new(normal_sprite, carry_sprite),
|
||||||
task_queue: TaskQueue {
|
task_queue: TaskQueue {
|
||||||
tasks: VecDeque::from([Task::Idle {
|
tasks: VecDeque::from([Task::Idle {
|
||||||
target: origin,
|
|
||||||
wander_radius: 10,
|
|
||||||
origin,
|
origin,
|
||||||
|
sigma_world: behaviour.idle.sigma_world,
|
||||||
|
state: IdleState::Picking,
|
||||||
}]),
|
}]),
|
||||||
},
|
},
|
||||||
task_state: TaskState::Pending,
|
task_state: TaskState::Pending,
|
||||||
|
entity_type: EntityType("dorf"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,24 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use std::collections::VecDeque;
|
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.
|
/// A single task an entity can execute.
|
||||||
///
|
///
|
||||||
/// Tasks are self-contained: they carry all data needed for execution.
|
/// Tasks are self-contained: they carry all data needed for execution.
|
||||||
@@ -27,17 +45,14 @@ use std::collections::VecDeque;
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
#[repr(u8)]
|
#[repr(u8)]
|
||||||
pub enum Task {
|
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 {
|
Idle {
|
||||||
/// Current wander target tile (world position).
|
/// Home position in world units — centre of Gaussian distribution.
|
||||||
target: IVec3,
|
/// Set at spawn, does not drift.
|
||||||
/// Max distance from origin to wander (Chebyshev).
|
|
||||||
wander_radius: i32,
|
|
||||||
/// Origin tile for wander bounds.
|
|
||||||
origin: IVec3,
|
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.
|
/// Move to a tile within "close enough" threshold.
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
//! 3. On completion/failure, update TaskState, fire event, pop task
|
//! 3. On completion/failure, update TaskState, fire event, pop task
|
||||||
//! 4. If queue empty after pop, assign default Task::Idle
|
//! 4. If queue empty after pop, assign default Task::Idle
|
||||||
//!
|
//!
|
||||||
//! Uses Changed<TaskQueue> + Changed<TaskState> to minimize queries.
|
//! Uses Changed<TaskQueue> + Changed<TaskState> to minimise queries.
|
||||||
|
|
||||||
use crate::constants::ITILE_SIZE;
|
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
|
||||||
use crate::entities::shared_components::Ambulatory;
|
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::events::{TaskClaimed, TaskCompleted, TaskFailed};
|
||||||
use crate::entities::tasks::idle::execute_idle;
|
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::prelude::*;
|
||||||
use bevy_rand::prelude::*;
|
use bevy_rand::prelude::*;
|
||||||
use rand::{RngExt, SeedableRng};
|
use rand::{RngExt, SeedableRng};
|
||||||
@@ -22,13 +23,18 @@ use rand::{RngExt, SeedableRng};
|
|||||||
pub fn task_executor_system(
|
pub fn task_executor_system(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
tilemap: Res<TileMap>,
|
tilemap: Res<TileMap>,
|
||||||
|
chunk_map: Res<ChunkMap>,
|
||||||
|
behaviour_registry: Res<EntityBehaviourRegistry>,
|
||||||
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
|
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
|
||||||
|
mut tick: Local<u32>,
|
||||||
mut query: Query<(
|
mut query: Query<(
|
||||||
Entity,
|
Entity,
|
||||||
&mut TaskQueue,
|
&mut TaskQueue,
|
||||||
&mut TaskState,
|
&mut TaskState,
|
||||||
&mut Ambulatory,
|
&mut Ambulatory,
|
||||||
&Transform,
|
&Transform,
|
||||||
|
&mut Sprite,
|
||||||
|
&EntityType,
|
||||||
)>,
|
)>,
|
||||||
mut claimed_writer: MessageWriter<TaskClaimed>,
|
mut claimed_writer: MessageWriter<TaskClaimed>,
|
||||||
mut completed_writer: MessageWriter<TaskCompleted>,
|
mut completed_writer: MessageWriter<TaskCompleted>,
|
||||||
@@ -38,14 +44,21 @@ pub fn task_executor_system(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
for (entity, mut queue, mut state, mut ambulatory, transform) in query.iter_mut() {
|
*tick = tick.wrapping_add(1);
|
||||||
// If queue empty, assign default Idle task
|
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() {
|
if queue.is_empty() {
|
||||||
let origin = transform.translation.as_ivec3();
|
|
||||||
queue.push(Task::Idle {
|
queue.push(Task::Idle {
|
||||||
target: origin,
|
|
||||||
wander_radius: 64,
|
|
||||||
origin,
|
origin,
|
||||||
|
sigma_world: behaviour.idle.sigma_world,
|
||||||
|
state: IdleState::Picking,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,20 +79,22 @@ pub fn task_executor_system(
|
|||||||
match current_task {
|
match current_task {
|
||||||
Task::Idle { .. } => {
|
Task::Idle { .. } => {
|
||||||
execute_idle(
|
execute_idle(
|
||||||
entity,
|
|
||||||
current_task,
|
current_task,
|
||||||
transform,
|
transform,
|
||||||
&mut ambulatory,
|
&mut ambulatory,
|
||||||
|
&mut sprite,
|
||||||
&tilemap,
|
&tilemap,
|
||||||
|
&chunk_map,
|
||||||
|
&behaviour.idle,
|
||||||
&mut rng,
|
&mut rng,
|
||||||
|
current_tick,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Task::GoTo {
|
Task::GoTo {
|
||||||
target,
|
target,
|
||||||
threshold_tiles,
|
threshold_tiles,
|
||||||
} => {
|
} => {
|
||||||
let entity_pos = (transform.translation / ITILE_SIZE as f32).as_ivec3();
|
let entity_pos = (transform.translation / 16.0f32).as_ivec3();
|
||||||
// Chebyshev distance (max of absolute differences)
|
|
||||||
let distance = (entity_pos.x - target.x)
|
let distance = (entity_pos.x - target.x)
|
||||||
.abs()
|
.abs()
|
||||||
.max((entity_pos.y - target.y).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) {
|
if distance <= *threshold_tiles || !tilemap.is_standable(*target) {
|
||||||
*state = TaskState::Completed;
|
*state = TaskState::Completed;
|
||||||
} else {
|
} else {
|
||||||
// Set target - pathfinding handles the rest
|
|
||||||
ambulatory.target = Some(Vec3::new(
|
ambulatory.target = Some(Vec3::new(
|
||||||
target.x as f32,
|
target.x as f32,
|
||||||
target.y as f32,
|
target.y as f32,
|
||||||
@@ -96,7 +110,6 @@ pub fn task_executor_system(
|
|||||||
ambulatory.current_path = None;
|
ambulatory.current_path = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stage 3 placeholders - log and fail for now
|
|
||||||
Task::ChopTree { .. } | Task::HaulObject { .. } | Task::DropHauled { .. } => {
|
Task::ChopTree { .. } | Task::HaulObject { .. } | Task::DropHauled { .. } => {
|
||||||
debug!(
|
debug!(
|
||||||
"Task {} unimplemented for entity {:?}",
|
"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() {
|
if completed_task.is_terminal() && queue.is_empty() {
|
||||||
let origin = (transform.translation / ITILE_SIZE as f32).as_ivec3();
|
|
||||||
queue.push(Task::Idle {
|
queue.push(Task::Idle {
|
||||||
target: origin,
|
|
||||||
wander_radius: 64,
|
|
||||||
origin,
|
origin,
|
||||||
|
sigma_world: behaviour.idle.sigma_world,
|
||||||
|
state: IdleState::Picking,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
//! # Wander model
|
||||||
//! system. Now it's called by the task executor when Task::Idle is active.
|
//! 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::constants::{ITILE_SIZE, TILE_SIZE};
|
||||||
|
use crate::entities::behaviour::IdleBehaviour;
|
||||||
use crate::entities::shared_components::Ambulatory;
|
use crate::entities::shared_components::Ambulatory;
|
||||||
use crate::entities::tasks::components::Task;
|
use crate::entities::tasks::components::{IdleState, Task};
|
||||||
use crate::world::tiles::tilemap::TileMap;
|
use crate::world::chunks::ChunkMap;
|
||||||
|
use crate::world::chunks::CHUNK_SIZE;
|
||||||
|
use crate::world::tiles::TileMap;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy_rand::prelude::*;
|
use bevy_rand::prelude::*;
|
||||||
use rand::RngExt;
|
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(
|
pub(super) fn execute_idle(
|
||||||
entity: Entity,
|
|
||||||
task: &mut Task,
|
task: &mut Task,
|
||||||
transform: &Transform,
|
transform: &Transform,
|
||||||
ambulatory: &mut Ambulatory,
|
ambulatory: &mut Ambulatory,
|
||||||
|
sprite: &mut Sprite,
|
||||||
tilemap: &TileMap,
|
tilemap: &TileMap,
|
||||||
|
chunk_map: &ChunkMap,
|
||||||
|
behaviour: &IdleBehaviour,
|
||||||
rng: &mut WyRand,
|
rng: &mut WyRand,
|
||||||
|
current_tick: u32,
|
||||||
) {
|
) {
|
||||||
let Task::Idle {
|
let Task::Idle {
|
||||||
target,
|
|
||||||
wander_radius,
|
|
||||||
origin,
|
origin,
|
||||||
|
sigma_world,
|
||||||
|
state,
|
||||||
} = task
|
} = task
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let entity_pos = transform.translation.as_ivec3();
|
match state {
|
||||||
|
IdleState::Picking => {
|
||||||
// Track if target actually changed this tick
|
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
|
||||||
let mut target_changed = false;
|
Some(target) => {
|
||||||
|
ambulatory.target = Some(Vec3::new(
|
||||||
// Use distance threshold for arrival check - equality never triggers due to float truncation
|
target.x as f32,
|
||||||
let dist_sq = (transform.translation.x - target.x as f32).powi(2)
|
target.y as f32,
|
||||||
+ (transform.translation.y - target.y as f32).powi(2);
|
target.z as f32 + 1.0,
|
||||||
let arrived = dist_sq < (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
|
));
|
||||||
|
ambulatory.current_path = None;
|
||||||
// If reached target or target is no longer standable, pick new target
|
ambulatory.path_index = 0;
|
||||||
if arrived || !tilemap.is_standable(*target) {
|
*state = IdleState::Moving { target };
|
||||||
if let Some(new_target) = pick_wander_target(origin, *wander_radius, tilemap, rng) {
|
}
|
||||||
*target = new_target;
|
None => {
|
||||||
target_changed = true;
|
// No standable tile found yet (early startup, chunk loading).
|
||||||
}
|
// Leave ambulatory.target as-is, try again next tick.
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ pub mod events;
|
|||||||
pub mod executor;
|
pub mod executor;
|
||||||
pub mod idle;
|
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 events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed};
|
||||||
pub use executor::task_executor_system;
|
pub use executor::task_executor_system;
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -36,7 +36,11 @@ impl Plugin for WorldPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
let drop_registry = crate::entities::item::drop_table::DropTableRegistry::load();
|
let drop_registry = crate::entities::item::drop_table::DropTableRegistry::load();
|
||||||
crate::entities::item::drop_table::DropTableRegistry::init_global(&drop_registry);
|
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::<ChunkMap>()
|
.init_resource::<ChunkMap>()
|
||||||
.init_resource::<TerrainBlobStorage>()
|
.init_resource::<TerrainBlobStorage>()
|
||||||
.init_resource::<tiles::TilemapBenchmark>()
|
.init_resource::<tiles::TilemapBenchmark>()
|
||||||
|
|||||||
Reference in New Issue
Block a user