This commit is contained in:
2026-03-22 11:50:53 +00:00
parent 2f456f80b8
commit cb5b18008d
10 changed files with 79 additions and 66 deletions
+5 -6
View File
@@ -1,11 +1,10 @@
[idle] [idle]
# Standard deviation of wander spread in world units. # Standard deviation of wander spread in chunks.
# 3 chunks * CHUNK_SIZE(8) * ITILE_SIZE(16) = 384.0 sigma_chunks = 3.0
sigma_world = 384.0
# Probability (0.0-1.0) that arriving at a target triggers a loiter pause. # Probability (0.0-1.0) that arriving at a target triggers a loiter pause.
loiter_chance = 0.6 loiter_chance = 0.6
# Loiter duration range in FixedUpdate ticks (~60 TPS). # Loiter duration range in FixedUpdate ticks (~60 TPS).
loiter_min_ticks = 60 # ~1 second loiter_min_ticks = 60
loiter_max_ticks = 240 # ~4 seconds loiter_max_ticks = 240
# Probability each of the two flip opportunities fires. # Probability each of the two flip opportunities fires.
flip_chance = 0.5 flip_chance = 0.5
+2 -2
View File
@@ -1,6 +1,6 @@
[idle] [idle]
sigma_world = 256.0 sigma_chunks = 2.0
loiter_chance = 0.7 loiter_chance = 0.7
loiter_min_ticks = 90 loiter_min_ticks = 90
loiter_max_ticks = 300 loiter_max_ticks = 300
flip_chance = 0.4 flip_chance = 0.4
+2 -2
View File
@@ -1,6 +1,6 @@
[idle] [idle]
sigma_world = 192.0 sigma_chunks = 1.5
loiter_chance = 0.3 loiter_chance = 0.3
loiter_min_ticks = 20 loiter_min_ticks = 20
loiter_max_ticks = 80 loiter_max_ticks = 80
flip_chance = 0.3 flip_chance = 0.3
+3 -3
View File
@@ -1,9 +1,9 @@
initial_chunk_radius = 5 initial_chunk_radius = 15
[display] [display]
vsync = "mailbox" vsync = "mailbox"
[spawn_counts] [spawn_counts]
dorfs = 50 dorfs = 5
pigs = 5 pigs = 5
rabbits = 15 rabbits = 5
+39 -7
View File
@@ -4,6 +4,8 @@
//! EntityBehaviourRegistry::load(), inserted as a Resource, and exposed //! EntityBehaviourRegistry::load(), inserted as a Resource, and exposed
//! globally via OnceLock for use in async contexts. //! globally via OnceLock for use in async contexts.
use crate::constants::ITILE_SIZE;
use crate::world::chunks::CHUNK_SIZE;
use bevy::prelude::*; use bevy::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
@@ -11,8 +13,25 @@ use std::sync::OnceLock;
static BEHAVIOUR_GLOBAL: OnceLock<HashMap<String, EntityBehaviour>> = OnceLock::new(); static BEHAVIOUR_GLOBAL: OnceLock<HashMap<String, EntityBehaviour>> = OnceLock::new();
/// Deserialization shape matching assets/entities/<type>.toml.
/// sigma_chunks is human-friendly (e.g. 3.0); converted to sigma_world
/// at load time so runtime code never deals with chunk arithmetic.
#[derive(Deserialize)]
struct IdleBehaviourToml {
sigma_chunks: f32,
loiter_chance: f32,
loiter_min_ticks: u32,
loiter_max_ticks: u32,
flip_chance: f32,
}
#[derive(Deserialize)]
struct EntityBehaviourToml {
idle: IdleBehaviourToml,
}
/// Idle/wandering behaviour parameters for one entity type. /// Idle/wandering behaviour parameters for one entity type.
#[derive(Deserialize, Debug, Clone)] #[derive(Debug, Clone)]
pub struct IdleBehaviour { pub struct IdleBehaviour {
/// Standard deviation of Gaussian wander spread in world units. /// Standard deviation of Gaussian wander spread in world units.
pub sigma_world: f32, pub sigma_world: f32,
@@ -27,7 +46,7 @@ pub struct IdleBehaviour {
} }
/// All behaviour config for one entity type. /// All behaviour config for one entity type.
#[derive(Deserialize, Debug, Clone)] #[derive(Debug, Clone)]
pub struct EntityBehaviour { pub struct EntityBehaviour {
pub idle: IdleBehaviour, pub idle: IdleBehaviour,
} }
@@ -44,8 +63,19 @@ impl EntityBehaviourRegistry {
let path = format!("assets/entities/{}.toml", name); let path = format!("assets/entities/{}.toml", name);
let src = std::fs::read_to_string(&path) let src = std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("assets/entities/{}.toml not found", name)); .unwrap_or_else(|_| panic!("assets/entities/{}.toml not found", name));
let behaviour: EntityBehaviour = toml::from_str(&src) let toml: EntityBehaviourToml = toml::from_str(&src)
.unwrap_or_else(|e| panic!("Failed to parse {}.toml: {}", name, e)); .unwrap_or_else(|e| panic!("Failed to parse {}.toml: {}", name, e));
let sigma_world = toml.idle.sigma_chunks * CHUNK_SIZE as f32 * ITILE_SIZE as f32;
let behaviour = EntityBehaviour {
idle: IdleBehaviour {
sigma_world,
loiter_chance: toml.idle.loiter_chance,
loiter_min_ticks: toml.idle.loiter_min_ticks,
loiter_max_ticks: toml.idle.loiter_max_ticks,
flip_chance: toml.idle.flip_chance,
},
};
map.insert(name.to_string(), behaviour); map.insert(name.to_string(), behaviour);
} }
Self(map) Self(map)
@@ -56,12 +86,14 @@ impl EntityBehaviourRegistry {
BEHAVIOUR_GLOBAL.get_or_init(|| registry.0.clone()); BEHAVIOUR_GLOBAL.get_or_init(|| registry.0.clone());
} }
/// Get behaviour config for an entity type. /// Get behaviour config for an entity type. Returns a static reference —
pub fn global_get(entity_type: &str) -> EntityBehaviour { /// zero allocation, zero copy. The &'static lifetime is valid for the
/// program's entire runtime because the OnceLock owns the map.
pub fn global_get(entity_type: &str) -> &'static EntityBehaviour {
BEHAVIOUR_GLOBAL BEHAVIOUR_GLOBAL
.get() .get()
.and_then(|m| m.get(entity_type).cloned()) .and_then(|m| m.get(entity_type))
.unwrap_or_else(|| panic!("No behaviour config for entity type '{}'", entity_type)) .unwrap_or_else(|| panic!("No behaviour config for '{}'", entity_type))
} }
} }
+3 -1
View File
@@ -59,7 +59,9 @@ impl Dorf {
tasks: VecDeque::from([Task::Idle { tasks: VecDeque::from([Task::Idle {
origin, origin,
sigma_world: behaviour.idle.sigma_world, sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking, state: IdleState::Picking {
retry_after_tick: 0,
},
}]), }]),
}, },
task_state: TaskState::Pending, task_state: TaskState::Pending,
+2 -2
View File
@@ -17,8 +17,8 @@ use std::collections::VecDeque;
/// Sub-state of Task::Idle. /// Sub-state of Task::Idle.
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub enum IdleState { pub enum IdleState {
/// Pick a new target this tick. /// Pick a new target. Retry cooldown prevents CPU spike when no tiles found.
Picking, Picking { retry_after_tick: u32 },
/// Walking toward target. /// Walking toward target.
Moving { target: IVec3 }, Moving { target: IVec3 },
/// Standing still, loitering. /// Standing still, loitering.
+6 -5
View File
@@ -24,7 +24,6 @@ pub fn task_executor_system(
mut commands: Commands, mut commands: Commands,
tilemap: Res<TileMap>, tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>, 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 tick: Local<u32>,
mut query: Query<( mut query: Query<(
@@ -53,12 +52,13 @@ pub fn task_executor_system(
let origin = transform.translation.as_ivec3(); let origin = transform.translation.as_ivec3();
let behaviour = EntityBehaviourRegistry::global_get(entity_type.0); 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() {
queue.push(Task::Idle { queue.push(Task::Idle {
origin, origin,
sigma_world: behaviour.idle.sigma_world, sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking, state: IdleState::Picking {
retry_after_tick: 0,
},
}); });
} }
@@ -138,12 +138,13 @@ pub fn task_executor_system(
}); });
} }
// 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() {
queue.push(Task::Idle { queue.push(Task::Idle {
origin, origin,
sigma_world: behaviour.idle.sigma_world, sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking, state: IdleState::Picking {
retry_after_tick: 0,
},
}); });
} }
} }
+14 -34
View File
@@ -1,20 +1,3 @@
//! Idle task — Gaussian wander with loiter behaviour.
//!
//! # 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::constants::{ITILE_SIZE, TILE_SIZE};
use crate::entities::behaviour::IdleBehaviour; use crate::entities::behaviour::IdleBehaviour;
use crate::entities::shared_components::Ambulatory; use crate::entities::shared_components::Ambulatory;
@@ -26,8 +9,6 @@ use bevy::prelude::*;
use bevy_rand::prelude::*; use bevy_rand::prelude::*;
use rand::RngExt; use rand::RngExt;
/// 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(
task: &mut Task, task: &mut Task,
transform: &Transform, transform: &Transform,
@@ -49,7 +30,11 @@ pub(super) fn execute_idle(
}; };
match state { match state {
IdleState::Picking => { IdleState::Picking { retry_after_tick } => {
if current_tick < *retry_after_tick {
return;
}
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) { match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
Some(target) => { Some(target) => {
ambulatory.target = Some(Vec3::new( ambulatory.target = Some(Vec3::new(
@@ -62,8 +47,7 @@ pub(super) fn execute_idle(
*state = IdleState::Moving { target }; *state = IdleState::Moving { target };
} }
None => { None => {
// No standable tile found yet (early startup, chunk loading). *retry_after_tick = current_tick.saturating_add(30);
// Leave ambulatory.target as-is, try again next tick.
} }
} }
} }
@@ -75,9 +59,9 @@ pub(super) fn execute_idle(
let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5); let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
let arrived = dist_sq < arrive_threshold_sq; let arrived = dist_sq < arrive_threshold_sq;
let stuck = ambulatory.target.is_none() && ambulatory.current_path.is_none(); let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
if arrived || stuck { if arrived || no_nav {
ambulatory.target = None; ambulatory.target = None;
ambulatory.current_path = None; ambulatory.current_path = None;
@@ -104,7 +88,9 @@ pub(super) fn execute_idle(
next_flip_at, next_flip_at,
}; };
} else { } else {
*state = IdleState::Picking; *state = IdleState::Picking {
retry_after_tick: 0,
};
} }
} }
} }
@@ -126,7 +112,9 @@ pub(super) fn execute_idle(
if *ticks_remaining == 0 { if *ticks_remaining == 0 {
sprite.flip_x = false; sprite.flip_x = false;
*state = IdleState::Picking; *state = IdleState::Picking {
retry_after_tick: 0,
};
} else { } else {
*ticks_remaining -= 1; *ticks_remaining -= 1;
} }
@@ -134,11 +122,6 @@ pub(super) fn execute_idle(
} }
} }
/// 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( fn pick_gaussian_target(
origin: &IVec3, origin: &IVec3,
sigma_world: f32, sigma_world: f32,
@@ -194,9 +177,6 @@ fn pick_gaussian_target(
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] #[inline]
fn find_surface(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option<IVec3> { fn find_surface(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option<IVec3> {
for z in -3i32..=4i32 { for z in -3i32..=4i32 {
+3 -4
View File
@@ -5,7 +5,7 @@ use crate::entities::item::{
initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer, initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer,
}; };
use crate::game::SpawnDelay; use crate::game::SpawnDelay;
use crate::plugins::{CargoPlugin, TasksPlugin, EntitiesPlugin}; use crate::plugins::{CargoPlugin, EntitiesPlugin, TasksPlugin};
use crate::world::WorldPlugin; use crate::world::WorldPlugin;
mod camera; mod camera;
@@ -34,7 +34,7 @@ fn main() {
.set(WindowPlugin { .set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: String::from("Dorf"), title: String::from("Dorf"),
present_mode: bevy::window::PresentMode::Mailbox, present_mode: bevy::window::PresentMode::AutoVsync,
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()
@@ -69,7 +69,6 @@ fn main() {
entities::shared_systems::digging::dig_system, entities::shared_systems::digging::dig_system,
), ),
) )
.insert_resource(ItemRotationTimer::default()) .insert_resource(ItemRotationTimer::default())
.add_systems(Update, initialize_item_rotation_state) .add_systems(Update, initialize_item_rotation_state)
.add_systems( .add_systems(
@@ -78,4 +77,4 @@ fn main() {
) )
.add_systems(Update, debug::entity_dump::dump_entity_positions) .add_systems(Update, debug::entity_dump::dump_entity_positions)
.run(); .run();
} }