fix
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user