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]
# Standard deviation of wander spread in world units.
# 3 chunks * CHUNK_SIZE(8) * ITILE_SIZE(16) = 384.0
sigma_world = 384.0
# Standard deviation of wander spread in chunks.
sigma_chunks = 3.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
loiter_min_ticks = 60
loiter_max_ticks = 240
# 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]
sigma_world = 256.0
sigma_chunks = 2.0
loiter_chance = 0.7
loiter_min_ticks = 90
loiter_max_ticks = 300
flip_chance = 0.4
flip_chance = 0.4
+2 -2
View File
@@ -1,6 +1,6 @@
[idle]
sigma_world = 192.0
sigma_chunks = 1.5
loiter_chance = 0.3
loiter_min_ticks = 20
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]
vsync = "mailbox"
[spawn_counts]
dorfs = 50
dorfs = 5
pigs = 5
rabbits = 15
rabbits = 5
+39 -7
View File
@@ -4,6 +4,8 @@
//! EntityBehaviourRegistry::load(), inserted as a Resource, and exposed
//! globally via OnceLock for use in async contexts.
use crate::constants::ITILE_SIZE;
use crate::world::chunks::CHUNK_SIZE;
use bevy::prelude::*;
use serde::Deserialize;
use std::collections::HashMap;
@@ -11,8 +13,25 @@ use std::sync::OnceLock;
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.
#[derive(Deserialize, Debug, Clone)]
#[derive(Debug, Clone)]
pub struct IdleBehaviour {
/// Standard deviation of Gaussian wander spread in world units.
pub sigma_world: f32,
@@ -27,7 +46,7 @@ pub struct IdleBehaviour {
}
/// All behaviour config for one entity type.
#[derive(Deserialize, Debug, Clone)]
#[derive(Debug, Clone)]
pub struct EntityBehaviour {
pub idle: IdleBehaviour,
}
@@ -44,8 +63,19 @@ impl EntityBehaviourRegistry {
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)
let toml: EntityBehaviourToml = toml::from_str(&src)
.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);
}
Self(map)
@@ -56,12 +86,14 @@ impl EntityBehaviourRegistry {
BEHAVIOUR_GLOBAL.get_or_init(|| registry.0.clone());
}
/// Get behaviour config for an entity type.
pub fn global_get(entity_type: &str) -> EntityBehaviour {
/// Get behaviour config for an entity type. Returns a static reference —
/// 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
.get()
.and_then(|m| m.get(entity_type).cloned())
.unwrap_or_else(|| panic!("No behaviour config for entity type '{}'", entity_type))
.and_then(|m| m.get(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 {
origin,
sigma_world: behaviour.idle.sigma_world,
state: IdleState::Picking,
state: IdleState::Picking {
retry_after_tick: 0,
},
}]),
},
task_state: TaskState::Pending,
+2 -2
View File
@@ -17,8 +17,8 @@ use std::collections::VecDeque;
/// Sub-state of Task::Idle.
#[derive(Clone, Debug, PartialEq)]
pub enum IdleState {
/// Pick a new target this tick.
Picking,
/// Pick a new target. Retry cooldown prevents CPU spike when no tiles found.
Picking { retry_after_tick: u32 },
/// Walking toward target.
Moving { target: IVec3 },
/// Standing still, loitering.
+6 -5
View File
@@ -24,7 +24,6 @@ pub fn task_executor_system(
mut commands: Commands,
tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>,
behaviour_registry: Res<EntityBehaviourRegistry>,
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
mut tick: Local<u32>,
mut query: Query<(
@@ -53,12 +52,13 @@ pub fn task_executor_system(
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() {
queue.push(Task::Idle {
origin,
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() {
queue.push(Task::Idle {
origin,
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::entities::behaviour::IdleBehaviour;
use crate::entities::shared_components::Ambulatory;
@@ -26,8 +9,6 @@ use bevy::prelude::*;
use bevy_rand::prelude::*;
use rand::RngExt;
/// Execute one tick of Task::Idle.
/// Called by task_executor_system when current task is Idle.
pub(super) fn execute_idle(
task: &mut Task,
transform: &Transform,
@@ -49,7 +30,11 @@ pub(super) fn execute_idle(
};
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) {
Some(target) => {
ambulatory.target = Some(Vec3::new(
@@ -62,8 +47,7 @@ pub(super) fn execute_idle(
*state = IdleState::Moving { target };
}
None => {
// No standable tile found yet (early startup, chunk loading).
// Leave ambulatory.target as-is, try again next tick.
*retry_after_tick = current_tick.saturating_add(30);
}
}
}
@@ -75,9 +59,9 @@ pub(super) fn execute_idle(
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();
let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
if arrived || stuck {
if arrived || no_nav {
ambulatory.target = None;
ambulatory.current_path = None;
@@ -104,7 +88,9 @@ pub(super) fn execute_idle(
next_flip_at,
};
} else {
*state = IdleState::Picking;
*state = IdleState::Picking {
retry_after_tick: 0,
};
}
}
}
@@ -126,7 +112,9 @@ pub(super) fn execute_idle(
if *ticks_remaining == 0 {
sprite.flip_x = false;
*state = IdleState::Picking;
*state = IdleState::Picking {
retry_after_tick: 0,
};
} else {
*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(
origin: &IVec3,
sigma_world: f32,
@@ -194,9 +177,6 @@ fn pick_gaussian_target(
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 {
+3 -4
View File
@@ -5,7 +5,7 @@ use crate::entities::item::{
initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer,
};
use crate::game::SpawnDelay;
use crate::plugins::{CargoPlugin, TasksPlugin, EntitiesPlugin};
use crate::plugins::{CargoPlugin, EntitiesPlugin, TasksPlugin};
use crate::world::WorldPlugin;
mod camera;
@@ -34,7 +34,7 @@ fn main() {
.set(WindowPlugin {
primary_window: Some(Window {
title: String::from("Dorf"),
present_mode: bevy::window::PresentMode::Mailbox,
present_mode: bevy::window::PresentMode::AutoVsync,
..Default::default()
}),
..Default::default()
@@ -69,7 +69,6 @@ fn main() {
entities::shared_systems::digging::dig_system,
),
)
.insert_resource(ItemRotationTimer::default())
.add_systems(Update, initialize_item_rotation_state)
.add_systems(
@@ -78,4 +77,4 @@ fn main() {
)
.add_systems(Update, debug::entity_dump::dump_entity_positions)
.run();
}
}