attempt 3

This commit is contained in:
2026-03-27 10:29:33 +00:00
parent 1f5dfe5182
commit bd1b45e4d7
20 changed files with 1192 additions and 865 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
use image::{GenericImageView, ImageBuffer, Rgba}; use image::{ImageBuffer, Rgba};
const TILE_PX: u32 = 16; const TILE_PX: u32 = 16;
+1 -1
View File
@@ -4,6 +4,6 @@ initial_chunk_radius = 6
vsync = "mailbox" vsync = "mailbox"
[spawn_counts] [spawn_counts]
dorfs = 2 dorfs = 5
pigs = 0 pigs = 0
rabbits = 0 rabbits = 0
+27 -33
View File
@@ -20,20 +20,6 @@ use crate::world::VisibleGameEntity;
/// Weight of a single log in kg. Enough to encumber a dorf carrying one. /// Weight of a single log in kg. Enough to encumber a dorf carrying one.
pub const LOG_WEIGHT_KG: u32 = 15; pub const LOG_WEIGHT_KG: u32 = 15;
/// Find the standable surface tile at world XY. Returns the floor tile
/// where an entity can stand, or None if not found.
/// The standable position IS the floor tile itself (has can_stand_in=true),
/// not the air above it.
fn find_surface_at(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) && tilemap.is_standable(floor_pos) {
return Some(floor_pos);
}
}
None
}
/// Spawn a Cargo log entity with a directional fall bias. /// Spawn a Cargo log entity with a directional fall bias.
/// ///
/// `tile_pos` — world position of the trunk tile this log came from. /// `tile_pos` — world position of the trunk tile this log came from.
@@ -56,7 +42,7 @@ pub fn spawn_log_cargo(
fall_direction: Vec2, fall_direction: Vec2,
log_sprite: Handle<Image>, log_sprite: Handle<Image>,
rng: &mut WyRand, rng: &mut WyRand,
) -> Option<Entity> { ) -> Option<(Entity, IVec3)> {
// Distance along fall direction: 03 tiles // Distance along fall direction: 03 tiles
let fall_tiles = rng.random_range(0u32..=3) as f32; let fall_tiles = rng.random_range(0u32..=3) as f32;
@@ -65,26 +51,34 @@ pub fn spawn_log_cargo(
let jitter_tiles = rng.random_range(0u32..=2) as f32 - 1.0; // -1, 0, or +1 let jitter_tiles = rng.random_range(0u32..=2) as f32 - 1.0; // -1, 0, or +1
let offset = fall_direction * fall_tiles + perp * jitter_tiles; let offset = fall_direction * fall_tiles + perp * jitter_tiles;
let biased_xy_x = tile_pos.x + (offset.x * ITILE_SIZE as f32).round() as i32;
let biased_xy_y = tile_pos.y + (offset.y * ITILE_SIZE as f32).round() as i32;
// Find the actual standable surface at this XY — logs land on the floor // Snap to grid
let biased_pos = find_surface_at(biased_xy_x, biased_xy_y, tilemap).unwrap_or(IVec3::new( let offset_tiles_x = offset.x.round() as i32;
biased_xy_x, let offset_tiles_y = offset.y.round() as i32;
biased_xy_y, let biased_xy_x = tile_pos.x + offset_tiles_x * ITILE_SIZE;
tile_pos.z, let biased_xy_y = tile_pos.y + offset_tiles_y * ITILE_SIZE;
));
// Find nearest free tile from biased position (increased radius for scattered logs) // Find the actual standable surface at this XY — start search from tree's Z
let mut biased_pos = IVec3::new(biased_xy_x, biased_xy_y, tile_pos.z);
// Search up and down for a standable tile (air above a solid floor)
let start_z_idx = tile_pos.z / ITILE_SIZE;
let mut found_surface = false;
// Search nearest Z first
let z_search_order = [0, -1, 1, -2, 2, -3, 3, -4, 4];
for dz in z_search_order {
let check_pos = IVec3::new(biased_xy_x, biased_xy_y, (start_z_idx + dz) * ITILE_SIZE);
if tilemap.is_standable(check_pos) {
biased_pos = check_pos;
found_surface = true;
break;
}
}
// Find nearest free tile from biased position
let drop_pos = tilemap.find_nearest_free_cargo_tile(biased_pos, 8, &[])?; let drop_pos = tilemap.find_nearest_free_cargo_tile(biased_pos, 8, &[])?;
// TODO: OuchEvent — if a living entity occupies drop_pos, they take impact damage.
// Check tilemap occupancy here when the combat/injury system exists.
// For now: debug log so we know when it would have fired.
// if occupancy.count_at_ivec3(drop_pos) > 0 {
// debug!("Log landed on occupied tile {:?} — ouch! (TODO: damage)", drop_pos);
// }
let entity = commands let entity = commands
.spawn(( .spawn((
Cargo { Cargo {
@@ -115,12 +109,12 @@ pub fn spawn_log_cargo(
"spawn_log_cargo: tile_pos={:?} drop_pos={:?} entity_z={}", "spawn_log_cargo: tile_pos={:?} drop_pos={:?} entity_z={}",
tile_pos, tile_pos,
drop_pos, drop_pos,
drop_pos.z as f32 / 16.0 - 1.0 drop_pos.z as f32 / 16.0
); );
tilemap tilemap
.place_cargo(drop_pos, entity) .place_cargo(drop_pos, entity)
.expect("place_cargo failed after find_nearest_free_cargo_tile succeeded"); .expect("place_cargo failed after find_nearest_free_cargo_tile succeeded");
Some(entity) Some((entity, drop_pos))
} }
+2 -2
View File
@@ -98,8 +98,8 @@ pub fn spawn_dorfs(
let mut rng = WyRand::seed_from_u64(seed); let mut rng = WyRand::seed_from_u64(seed);
for _ in 0..config.spawn_counts.dorfs { for _ in 0..config.spawn_counts.dorfs {
let raw_x = rng.random_range(-8.0f32..8.0f32); let raw_x = rng.random_range(-512.0f32..512.0f32);
let raw_y = rng.random_range(-8.0f32..8.0f32); let raw_y = rng.random_range(-512.0f32..512.0f32);
let grid_x = (raw_x / TILE_SIZE).round() * TILE_SIZE; let grid_x = (raw_x / TILE_SIZE).round() * TILE_SIZE;
let grid_y = (raw_y / TILE_SIZE).round() * TILE_SIZE; let grid_y = (raw_y / TILE_SIZE).round() * TILE_SIZE;
let grid_z = 35.0 * TILE_SIZE; let grid_z = 35.0 * TILE_SIZE;
+82 -64
View File
@@ -314,14 +314,10 @@ pub fn prepare_paths(
tilemap: Res<TileMap>, tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>, chunk_map: Res<ChunkMap>,
) { ) {
let mut paths_needed = 0;
let mut paths_computed = 0;
for (entity, mut ambulatory, transform) in query.iter_mut() { for (entity, mut ambulatory, transform) in query.iter_mut() {
if ambulatory.current_path.is_some() || ambulatory.target.is_none() { if ambulatory.current_path.is_some() || ambulatory.target.is_none() {
continue; continue;
} }
paths_needed += 1;
let Some(target) = ambulatory.target else { let Some(target) = ambulatory.target else {
continue; continue;
@@ -338,7 +334,6 @@ pub fn prepare_paths(
let path = calculate_path_benchmarked(&tilemap, start, goal); let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path); ambulatory.current_path = Some(path);
ambulatory.path_index = 0; ambulatory.path_index = 0;
paths_computed += 1;
} else if chunk_distance > PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS { } else if chunk_distance > PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS {
let chunk_path = calculate_chunk_path(&chunk_map, start_chunk, goal_chunk); let chunk_path = calculate_chunk_path(&chunk_map, start_chunk, goal_chunk);
let provisional_goal = goal; let provisional_goal = goal;
@@ -624,6 +619,10 @@ pub fn movement(
query query
.par_iter_mut() .par_iter_mut()
.for_each(|(mut ambulatory, mut transform)| { .for_each(|(mut ambulatory, mut transform)| {
info!(
"[PATH] Moving: target={:?} current={:?}",
ambulatory.target, transform.translation
);
let current_pos = transform.translation; let current_pos = transform.translation;
if !is_standable_tile(&tilemap, current_pos.as_ivec3()) { if !is_standable_tile(&tilemap, current_pos.as_ivec3()) {
// Entities can spawn above the loaded world range (z > Z_ABOVE*TILE_SIZE). // Entities can spawn above the loaded world range (z > Z_ABOVE*TILE_SIZE).
@@ -714,45 +713,24 @@ pub fn movement(
let forward_2d = our_dir; let forward_2d = our_dir;
let left_2d = Vec2::new(-forward_2d.y, forward_2d.x); let left_2d = Vec2::new(-forward_2d.y, forward_2d.x);
let right_2d = Vec2::new(forward_2d.y, -forward_2d.x); let right_2d = Vec2::new(forward_2d.y, -forward_2d.x);
let cur_z = transform.translation.z;
let candidates = [ let candidates = [
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(
(left_2d.x + forward_2d.x).signum() * TILE_SIZE, (left_2d.x + forward_2d.x).signum() * TILE_SIZE,
(left_2d.y + forward_2d.y).signum() * TILE_SIZE, (left_2d.y + forward_2d.y).signum() * TILE_SIZE,
0.0, 0.0,
), ),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(
(right_2d.x + forward_2d.x).signum() * TILE_SIZE, (right_2d.x + forward_2d.x).signum() * TILE_SIZE,
(right_2d.y + forward_2d.y).signum() * TILE_SIZE, (right_2d.y + forward_2d.y).signum() * TILE_SIZE,
0.0, 0.0,
), ),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0),
right_2d.x * TILE_SIZE,
right_2d.y * TILE_SIZE,
0.0,
),
cur_z,
TILE_SIZE,
),
]; ];
let sidestep = candidates.iter().copied().find(|&c| { let sidestep = candidates.iter().copied().find(|&c| {
@@ -783,45 +761,24 @@ pub fn movement(
let forward_2d = our_dir; let forward_2d = our_dir;
let left_2d = Vec2::new(-forward_2d.y, forward_2d.x); let left_2d = Vec2::new(-forward_2d.y, forward_2d.x);
let right_2d = Vec2::new(forward_2d.y, -forward_2d.x); let right_2d = Vec2::new(forward_2d.y, -forward_2d.x);
let cur_z = transform.translation.z;
let candidates = [ let candidates = [
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(
(left_2d.x + forward_2d.x).signum() * TILE_SIZE, (left_2d.x + forward_2d.x).signum() * TILE_SIZE,
(left_2d.y + forward_2d.y).signum() * TILE_SIZE, (left_2d.y + forward_2d.y).signum() * TILE_SIZE,
0.0, 0.0,
), ),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0), + Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(
(right_2d.x + forward_2d.x).signum() * TILE_SIZE, (right_2d.x + forward_2d.x).signum() * TILE_SIZE,
(right_2d.y + forward_2d.y).signum() * TILE_SIZE, (right_2d.y + forward_2d.y).signum() * TILE_SIZE,
0.0, 0.0,
), ),
cur_z,
TILE_SIZE,
),
snap_to_grid(
transform.translation transform.translation
+ Vec3::new( + Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0),
right_2d.x * TILE_SIZE,
right_2d.y * TILE_SIZE,
0.0,
),
cur_z,
TILE_SIZE,
),
]; ];
let sidestep = candidates.iter().copied().find(|&c| { let sidestep = candidates.iter().copied().find(|&c| {
@@ -945,16 +902,25 @@ fn validate_next_steps(tilemap: &TileMap, path: &[Vec3], start_index: usize, ste
} }
fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool { fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool {
tilemap.is_standable(pos) let result = tilemap.is_standable(pos);
} if !result {
let chunk_pos = crate::world::chunks::world_to_chunk(pos);
let chunk_exists = tilemap.chunks.contains_key(&chunk_pos);
let (local_x, local_y, z) = if let Some(c) = tilemap.chunks.get(&chunk_pos) {
crate::world::tiles::chunk_data::ChunkData::world_to_local(pos)
} else {
(0, 0, 0)
};
#[inline] let floor_exists = tilemap.floor_tiles.contains_key(&pos);
fn snap_to_grid(raw: Vec3, preserve_z: f32, tile_size: f32) -> Vec3 { let fixture_exists = tilemap.fixture_tiles.contains_key(&pos);
Vec3::new(
(raw.x / tile_size).round() * tile_size, info!(
(raw.y / tile_size).round() * tile_size, "[PATH] is_standable_tile=false: pos={:?} chunk={:?} chunk_loaded={} local=({},{},{}) floor={} fixture={}",
preserve_z, pos, chunk_pos, chunk_exists, local_x, local_y, z, floor_exists, fixture_exists
) );
}
result
} }
/// Sample a random standable tile on an edge of `next_chunk`, picking the one /// Sample a random standable tile on an edge of `next_chunk`, picking the one
@@ -1103,7 +1069,20 @@ fn directional_chunk_waypoint(
#[inline] #[inline]
fn get_tile_weight(tilemap: &TileMap, pos: IVec3) -> u8 { fn get_tile_weight(tilemap: &TileMap, pos: IVec3) -> u8 {
let floor_pos = IVec3::new(pos.x, pos.y, pos.z - ITILE_SIZE); let floor_pos = IVec3::new(pos.x, pos.y, pos.z - ITILE_SIZE);
tilemap.get_astar_weight(floor_pos) let weight = tilemap.get_astar_weight(floor_pos);
if weight == 100 {
let floor_tile = tilemap.floor_tiles.get(&floor_pos);
let fixture_tile = tilemap.fixture_tiles.get(&floor_pos);
let chunk_pos = crate::world::chunks::world_to_chunk(floor_pos);
let chunk_exists = tilemap.chunks.contains_key(&chunk_pos);
info!(
"[PATH] get_tile_weight=100 (default): pos={:?} floor_pos={:?} chunk_loaded={} floor={:?} fixture={:?}",
pos, floor_pos, chunk_exists,
floor_tile.map(|f| f.id.clone()),
fixture_tile.map(|f| f.id.clone())
);
}
weight
} }
/// Calculate movement cost including tile weight. /// Calculate movement cost including tile weight.
@@ -1119,7 +1098,10 @@ fn calculate_movement_cost(move_dir: IVec3, tile_weight: u8) -> i32 {
(1, 1, 0) => 14, // Diagonal (1, 1, 0) => 14, // Diagonal
(1, 0, 1) | (0, 1, 1) => 42, // Vertical + cardinal (1, 0, 1) | (0, 1, 1) => 42, // Vertical + cardinal
(1, 1, 1) => 56, // Vertical + diagonal (1, 1, 1) => 56, // Vertical + diagonal
_ => 0, _ => {
info!("[PATH] calculate_movement_cost=0: move_dir={:?}", move_dir);
return 0;
}
}; };
if base_cost == 0 { if base_cost == 0 {
@@ -1274,13 +1256,40 @@ pub fn calculate_provisional_path(
) -> Vec<Vec3> { ) -> Vec<Vec3> {
let timer = Instant::now(); let timer = Instant::now();
if !is_standable_tile(tilemap, start) { let start_standable = is_standable_tile(tilemap, start);
if !start_standable {
LOCAL_FAILED_PATHS.with(|f| { LOCAL_FAILED_PATHS.with(|f| {
*f.borrow_mut() += 1; *f.borrow_mut() += 1;
}); });
info!(
"[PATH] FAIL: start not standable start={:?} z_level={} floor={:?} fixture={:?}",
start,
start.z,
tilemap.floor_tiles.get(&start).map(|f| f.id.clone()),
tilemap.fixture_tiles.get(&start).map(|f| f.id.clone())
);
return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]; return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)];
} }
let goal_standable = is_standable_tile(tilemap, goal);
if !goal_standable {
info!(
"[PATH] WARN: goal not standable goal={:?} z_level={} floor={:?} fixture={:?}",
goal,
goal.z,
tilemap.floor_tiles.get(&goal).map(|f| f.id.clone()),
tilemap.fixture_tiles.get(&goal).map(|f| f.id.clone())
);
}
if !is_standable_tile(tilemap, goal) {
info!(
"[PATH] WARN: is_standable check false: goal={:?}, is_standable={}",
goal,
tilemap.is_standable(goal)
);
}
let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
let result = SCRATCHPAD.with(|s| { let result = SCRATCHPAD.with(|s| {
@@ -1318,6 +1327,10 @@ pub fn calculate_provisional_path(
} }
if nodes_expanded >= node_limit { if nodes_expanded >= node_limit {
info!(
"[PATH] FAIL: node_limit hit limit={} start={:?} goal={:?} expanded={}",
node_limit, start, goal, nodes_expanded
);
return (Vec::new(), nodes_expanded); return (Vec::new(), nodes_expanded);
} }
@@ -1326,9 +1339,10 @@ pub fn calculate_provisional_path(
for &move_dir in &ALLOWED_MOVES { for &move_dir in &ALLOWED_MOVES {
let neighbor_pos = current + move_dir; let neighbor_pos = current + move_dir;
if !is_standable_tile(tilemap, neighbor_pos) if !is_standable_tile(tilemap, neighbor_pos) {
|| scratch.closed_set.contains(&neighbor_pos) continue;
{ }
if scratch.closed_set.contains(&neighbor_pos) {
continue; continue;
} }
@@ -1353,6 +1367,10 @@ pub fn calculate_provisional_path(
} }
} }
info!(
"[PATH] FAIL: no_path_found start={:?} goal={:?} best_node={:?} best_h={}",
start, goal, best_node, best_h
);
( (
reconstruct_path(&scratch.came_from, best_node), reconstruct_path(&scratch.came_from, best_node),
nodes_expanded, nodes_expanded,
+41 -386
View File
@@ -1,150 +1,58 @@
//! Demo loop — one tree at a time, chop and haul to origin.
//!
//! # Behaviour
//! 1. Find the nearest standing tree trunk to (0,0) in the loaded world.
//! 2. Assign a ChopTree task to one idle dorf.
//! 3. When the tree is felled (trunk fixtures gone, Cargo logs exist):
//! - All logs enter the haul queue ordered by proximity to origin.
//! - Each tick: assign idle dorfs to the nearest unassigned log.
//! - Dorfs that finish hauling become available for the next log.
//! 4. When the haul queue is empty AND no in-progress hauls remain:
//! - Find the next tree.
//! 5. Dorfs with no task remain on Task::Idle (wander).
//!
//! # State machine
//! Tracked in DemoState resource.
//!
//! # Limitations (acceptable for demo)
//! - Only one tree targeted at a time.
//! - Does not use the JobQueue — tasks pushed directly.
//! - Does not handle dorf death mid-chop.
//! - Haul destination is a fixed search near IVec3::ZERO — not a stockpile.
use bevy::prelude::*; use bevy::prelude::*;
use rustc_hash::FxHashSet;
use smallvec::SmallVec;
use std::collections::VecDeque;
use crate::constants::ITILE_SIZE; use crate::constants::ITILE_SIZE;
use crate::entities::cargo::{Cargo, HaulSlot, Haulable}; use crate::entities::behaviour::EntityType;
use crate::entities::tasks::components::{ use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
ChopStep, HaulStep, Task, TaskQueue, TaskState, CHOP_TICKS_DEFAULT, use crate::entities::tasks::job_queue::{JobKind, JobQueue};
};
use crate::entities::tasks::events::{LogsSpawned, TaskFailed};
use crate::world::chunks::ChunkMap; use crate::world::chunks::ChunkMap;
use crate::world::generation::forestry::TreePart; use crate::world::generation::forestry::TreePart;
use crate::world::tiles::TileMap; use crate::world::tiles::TileMap;
/// Radius (in tiles, Chebyshev) to search for unclaimed logs after felling.
/// Logs scatter within ~3 tiles of trunk positions.
const LOG_SEARCH_RADIUS_TILES: i32 = 12;
/// Radius to search for a haul drop destination near origin.
const HAUL_DEST_SEARCH_RADIUS: i32 = 32;
/// Tracks what the demo loop is currently doing.
#[derive(Resource, Debug, Default)]
pub enum DemoState {
/// No active tree — searching for one.
#[default]
Idle,
/// A ChopTree task has been assigned to `chopper`.
/// `trunk_pos` is the lowest trunk tile of the target tree.
Chopping { trunk_pos: IVec3, chopper: Entity },
/// Tree has been felled. Working through the haul queue.
///
/// `unassigned` — logs not yet claimed, ordered nearest-to-origin first.
/// `in_progress` — dorf entities currently executing a HaulCargo task for this tree.
/// A dorf is removed when they return to idle (log dropped, task complete).
Hauling {
felled_trunk_pos: IVec3,
/// Queue of (log_entity, log_tile_pos) not yet assigned.
/// Front = highest priority (nearest to origin).
unassigned: VecDeque<(Entity, IVec3)>,
/// Dorf entities currently hauling a log for this tree.
in_progress: FxHashSet<Entity>,
},
}
/// The demo loop system. Runs in FixedUpdate after task_executor_system.
///
/// State transitions:
/// Idle → Chopping: found a tree, assigned ChopTree to one idle dorf
/// Chopping → Hauling: trunk no longer in fixture_tiles (tree felled)
/// Hauling → Idle: both unassigned and in_progress are empty
///
/// Performance: scans fixture_tiles once per state transition (infrequent),
/// not per tick. During Chopping and Hauling states the system does O(1) checks.
pub fn demo_system( pub fn demo_system(
mut demo_state: ResMut<DemoState>, mut job_queue: ResMut<JobQueue>,
tilemap: Res<TileMap>, tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>, chunk_map: Res<ChunkMap>,
tree_parts: Query<(Entity, &TreePart)>, tree_parts: Query<(Entity, &TreePart)>,
cargo_query: Query<(Entity, &Cargo), With<Haulable>>, dorf_query: Query<(&TaskQueue, &TaskState), With<EntityType>>,
haul_slot_query: Query<&HaulSlot>,
mut dorf_query: Query<(Entity, &mut TaskQueue, &mut TaskState, &Transform)>,
mut task_failed: MessageReader<TaskFailed>,
mut logs_spawned: MessageReader<LogsSpawned>,
) { ) {
// Handle task failures that should reset the demo state for retry let any_chopping = dorf_query.iter().any(|(queue, state)| {
for event in task_failed.read() { let is_active_or_completing = *state == TaskState::Active || *state == TaskState::Completed;
if event.reason == "no adjacent standable tile to approach tree" { if is_active_or_completing {
if let DemoState::Chopping { trunk_pos, chopper } = *demo_state { if let Some(current) = queue.current() {
warn!( return matches!(current, Task::ChopTree { .. });
"[DEMO] ChopTree failed for tree at {:?} (chopper={:?}): {}, resetting to Idle",
trunk_pos, chopper, event.reason
);
*demo_state = DemoState::Idle;
} }
} }
false
});
if !job_queue.has_fell_tree() && !any_chopping {
if let Some(trunk_pos) = find_tree_nearest_origin(&tilemap, &chunk_map, &tree_parts) {
job_queue.push(JobKind::FellTree { trunk_pos });
}
} }
// Handle logs spawned from felling — transition to Hauling if !job_queue.is_empty() {
// Collect events first to avoid double-mutable borrow conflict with demo_state let (fell, haul) = job_queue.debug_counts();
let pending_logs: Vec<_> = logs_spawned.read().collect(); info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul);
for event in pending_logs {
if let DemoState::Chopping {
trunk_pos,
chopper: _,
} = *demo_state
{
// Look up cargo positions from the newly spawned entities
let mut unassigned: VecDeque<(Entity, IVec3)> = VecDeque::new();
for &log_entity in event.log_entities.iter() {
if let Ok((_, cargo)) = cargo_query.get(log_entity) {
unassigned.push_back((log_entity, cargo.tile_pos));
}
}
info!(
"[DEMO] → Hauling: {} logs spawned from tree at {:?}",
unassigned.len(),
trunk_pos
);
*demo_state = DemoState::Hauling {
felled_trunk_pos: trunk_pos,
unassigned,
in_progress: Default::default(),
};
} }
} }
match &mut *demo_state { fn find_tree_nearest_origin(
DemoState::Idle => { tilemap: &TileMap,
// Find the nearest standing tree to (0,0). chunk_map: &ChunkMap,
// A "tree" is identified by a TreePart with is_trunk=true whose tree_parts: &Query<(Entity, &TreePart)>,
// tile_pos is still in fixture_tiles (not yet felled). ) -> Option<IVec3> {
let origin = IVec3::ZERO; let origin = IVec3::ZERO;
let mut best_xy: Option<(IVec2, i32)> = None;
let mut best: Option<(IVec3, i32)> = None; // (trunk_pos, chebyshev_dist)
for (_, part) in tree_parts.iter() { for (_, part) in tree_parts.iter() {
if !part.is_trunk { if !part.is_trunk {
continue; continue;
} }
if !tilemap.fixture_tiles.contains_key(&part.tile_pos) { if !tilemap.fixture_tiles.contains_key(&part.tile_pos) {
continue; // already felled continue;
} }
// Only consider trees in fully-loaded chunks
let chunk = crate::world::chunks::world_to_chunk(part.tile_pos); let chunk = crate::world::chunks::world_to_chunk(part.tile_pos);
let loaded = chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::X)) let loaded = chunk_map.loaded_chunks.contains_key(&(chunk + IVec2::X))
&& chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::X)) && chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::X))
@@ -158,282 +66,29 @@ pub fn demo_system(
let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE; let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE;
let dist = dx.max(dy); let dist = dx.max(dy);
if best.map_or(true, |(_, best_dist)| dist < best_dist) { if best_xy.map_or(true, |(_, best_dist)| dist < best_dist) {
best = Some((part.tile_pos, dist)); best_xy = Some((part.tile_pos.xy(), dist));
} }
} }
let Some((trunk_pos, _)) = best else { let Some((nearest_xy, _)) = best_xy else {
// No trees found — nothing to do return None;
return;
}; };
// Find the lowest trunk tile (minimum z) for this tree's XY column.
let lowest_trunk = tree_parts let lowest_trunk = tree_parts
.iter() .iter()
.filter(|(_, p)| { .filter_map(|(_, p)| {
p.is_trunk if p.is_trunk
&& p.tile_pos.x == trunk_pos.x && p.tile_pos.x == nearest_xy.x
&& p.tile_pos.y == trunk_pos.y && p.tile_pos.y == nearest_xy.y
&& tilemap.fixture_tiles.contains_key(&p.tile_pos) && tilemap.fixture_tiles.contains_key(&p.tile_pos)
})
.map(|(_, p)| p.tile_pos)
.min_by_key(|pos| pos.z)
.unwrap_or(trunk_pos);
// Find one idle dorf — prefer closest to the tree.
// Don't interrupt a dorf still carrying cargo.
let mut best_dorf: Option<(Entity, i32)> = None;
let mut considered = 0u32;
let mut rejected_busy = 0u32;
let mut rejected_hauling = 0u32;
for (entity, queue, state, transform) in dorf_query.iter() {
considered += 1;
if !is_idle_dorf(&queue, &state) {
rejected_busy += 1;
continue;
}
// Skip dorfs still carrying cargo
if haul_slot_query
.get(entity)
.map(|h| h.is_occupied())
.unwrap_or(false)
{ {
rejected_hauling += 1; Some(p.tile_pos)
continue;
}
let pos = transform.translation.as_ivec3();
let dx = (pos.x - lowest_trunk.x).abs() / ITILE_SIZE;
let dy = (pos.y - lowest_trunk.y).abs() / ITILE_SIZE;
let dist = dx.max(dy);
if best_dorf.map_or(true, |(_, d)| dist < d) {
best_dorf = Some((entity, dist));
}
}
let Some((chopper, _)) = best_dorf else {
info!(
"[DEMO] No idle dorf found for ChopTree (considered={} busy={} hauling={})",
considered, rejected_busy, rejected_hauling
);
return; // no idle dorfs available
};
// Assign ChopTree task
if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(chopper) {
queue.clear();
queue.push(Task::ChopTree {
trunk_pos: lowest_trunk,
chop_ticks: CHOP_TICKS_DEFAULT,
step: ChopStep::MovingToTree { approach: None },
});
*state = TaskState::Pending;
}
*demo_state = DemoState::Chopping {
trunk_pos: lowest_trunk,
chopper,
};
info!(
"[DEMO] → Chopping: chopper={:?} trunk={:?}",
chopper, lowest_trunk
);
}
DemoState::Chopping { trunk_pos, chopper } => {
let trunk_pos = *trunk_pos;
let chopper = *chopper;
// Check if the tree has been felled (fixture gone from tilemap)
if tilemap.fixture_tiles.contains_key(&trunk_pos) {
// Still standing — check chopper hasn't abandoned the task
if let Ok((_, queue, state, _)) = dorf_query.get(chopper) {
let still_chopping = queue.current().map_or(
false,
|t| matches!(t, Task::ChopTree { trunk_pos: tp, .. } if *tp == trunk_pos),
);
if !still_chopping && queue.is_empty() {
*demo_state = DemoState::Idle;
warn!("[DEMO] chopper {:?} abandoned ChopTree at {:?} — queue={:?} state={:?}",
chopper, trunk_pos,
queue.current().map(|t| t.name()),
state);
}
}
return;
}
// Tree is felled — transition to Hauling
info!("[DEMO] → Hauling: tree at {:?} felled", trunk_pos);
// Find all Cargo logs near the trunk position
let search_world = LOG_SEARCH_RADIUS_TILES * ITILE_SIZE;
let mut logs: SmallVec<[(Entity, IVec3, i32); 8]> = cargo_query
.iter()
.filter(|(_, cargo)| {
cargo.name == "log"
&& (cargo.tile_pos.x - trunk_pos.x).abs() <= search_world
&& (cargo.tile_pos.y - trunk_pos.y).abs() <= search_world
})
.map(|(e, cargo)| {
// Sort key: Chebyshev distance from origin
let dx = cargo.tile_pos.x.abs() / ITILE_SIZE;
let dy = cargo.tile_pos.y.abs() / ITILE_SIZE;
(e, cargo.tile_pos, dx.max(dy))
})
.collect();
// Nearest to origin first — dorfs haul the closest logs first
logs.sort_by_key(|(_, _, dist)| *dist);
if logs.is_empty() {
warn!("[DEMO] no logs found after felling {:?}", trunk_pos);
*demo_state = DemoState::Idle;
return;
}
// Convert to VecDeque, dropping sort key
let unassigned: VecDeque<(Entity, IVec3)> =
logs.into_iter().map(|(e, pos, _)| (e, pos)).collect();
info!("[DEMO] → Hauling: {} logs queued", unassigned.len());
*demo_state = DemoState::Hauling {
felled_trunk_pos: trunk_pos,
unassigned,
in_progress: FxHashSet::default(),
};
}
DemoState::Hauling {
felled_trunk_pos,
unassigned,
in_progress,
} => {
let felled_trunk_pos = *felled_trunk_pos;
// Remove dorfs that have returned to idle — their haul is complete
in_progress.retain(|&dorf_entity| {
dorf_query
.get(dorf_entity)
.map(|(_, queue, state, _)| !is_idle_dorf(queue, state))
.unwrap_or(false) // entity gone = treat as done
});
// Assign idle dorfs to unassigned logs
if !unassigned.is_empty() {
// Collect idle dorfs sorted by proximity to front of log queue
let next_log_pos = unassigned.front().map(|(_, p)| *p).unwrap_or(IVec3::ZERO);
let mut idle_dorfs: SmallVec<[(Entity, i32); 8]> = dorf_query
.iter()
.filter(|(_, queue, state, _)| is_idle_dorf(queue, state))
.map(|(e, _, _, transform)| {
let pos = transform.translation.as_ivec3();
let dx = (pos.x - next_log_pos.x).abs() / ITILE_SIZE;
let dy = (pos.y - next_log_pos.y).abs() / ITILE_SIZE;
(e, dx.max(dy))
})
.collect();
// Sort by distance — nearest dorf gets nearest log
idle_dorfs.sort_by_key(|(_, d)| *d);
// Drop distance, keep entity
let idle_dorfs: SmallVec<[Entity; 8]> =
idle_dorfs.into_iter().map(|(e, _)| e).collect();
// Track destinations reserved this tick to avoid assigning the same tile
// to multiple dorfs before any have physically dropped their cargo.
let mut reserved: SmallVec<[IVec3; 8]> = SmallVec::new();
for dorf_entity in idle_dorfs {
// Compute haul destination fresh for each assignment,
// excluding tiles already reserved this tick.
let dest = tilemap
.find_nearest_free_cargo_tile(
IVec3::ZERO,
HAUL_DEST_SEARCH_RADIUS,
&reserved,
)
.unwrap_or(IVec3::ZERO);
reserved.push(dest);
let Some((log_entity, log_pos)) = unassigned.pop_front() else {
break;
};
// Verify log still exists and is in cargo_tiles before assigning
if !tilemap.cargo_tiles.contains_key(&log_pos) {
// Log already picked up by someone else — skip it
continue;
}
if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(dorf_entity) {
queue.clear();
queue.push(Task::HaulCargo {
cargo_entity: log_entity,
cargo_pos: log_pos,
dest,
step: HaulStep::MovingToCargo { approach: None },
});
*state = TaskState::Pending;
in_progress.insert(dorf_entity);
info!(
"[DEMO] assigned HaulCargo log={:?} → dorf={:?} dest={:?}",
log_entity, dorf_entity, dest
);
} else { } else {
// Couldn't assign — put log back at front of queue None
unassigned.push_front((log_entity, log_pos));
break;
}
}
} }
})
.min_by_key(|pos| pos.z);
// Check if all work is done lowest_trunk
if unassigned.is_empty() && in_progress.is_empty() {
info!("[DEMO] → Idle: all hauled, seeking next tree");
*demo_state = DemoState::Idle;
}
}
}
}
/// Returns true if a dorf has no active task or is purely wandering idle.
/// Used to find dorfs available for task assignment.
#[inline]
fn is_idle_dorf(queue: &TaskQueue, state: &TaskState) -> bool {
// Dorf is available if: queue is empty, OR current task is Idle (wandering)
queue.is_empty()
|| matches!(queue.current(), Some(Task::Idle { .. }))
|| *state == TaskState::Pending
&& queue
.current()
.map_or(true, |t| matches!(t, Task::Idle { .. }))
}
/// Debug system — prints full task queue state whenever any TaskQueue changes.
/// Only compiles in debug builds.
#[cfg(debug_assertions)]
pub fn debug_task_queues(query: Query<(Entity, &TaskQueue, &TaskState), Changed<TaskQueue>>) {
for (entity, queue, state) in query.iter() {
let current = queue
.current()
.map(|t| format!("{}[{:?}]", t.name(), state))
.unwrap_or_else(|| format!("EMPTY[{:?}]", state));
let pending: Vec<&str> = queue.tasks.iter().skip(1).map(|t| t.name()).collect();
if pending.is_empty() {
info!("[TASK] {:?} → {}", entity, current);
} else {
info!(
"[TASK] {:?} → {} pending:[{}]",
entity,
current,
pending.join(",")
);
}
}
} }
+284 -54
View File
@@ -8,6 +8,7 @@
//! //!
//! Uses Changed<TaskQueue> + Changed<TaskState> to minimise queries. //! Uses Changed<TaskQueue> + Changed<TaskState> to minimise queries.
use crate::constants::ITILE_SIZE;
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType}; use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
use crate::entities::cargo::{Cargo, HaulSlot}; use crate::entities::cargo::{Cargo, HaulSlot};
use crate::entities::shared_components::Ambulatory; use crate::entities::shared_components::Ambulatory;
@@ -15,7 +16,8 @@ use crate::entities::tasks::components::{
ChopStep, DropStep, HaulStep, IdleState, Task, TaskQueue, TaskState, ChopStep, DropStep, HaulStep, IdleState, Task, TaskQueue, TaskState,
}; };
use crate::entities::tasks::events::{LogsSpawned, TaskClaimed, TaskCompleted, TaskFailed}; use crate::entities::tasks::events::{LogsSpawned, TaskClaimed, TaskCompleted, TaskFailed};
use crate::entities::tasks::idle::execute_idle; use crate::entities::tasks::job_queue::{JobKind, JobQueue};
use crate::entities::tasks::tasks::idle::execute_idle;
use crate::world::chunks::ChunkMap; use crate::world::chunks::ChunkMap;
use crate::world::generation::forestry::{fell_tree, TreePart}; use crate::world::generation::forestry::{fell_tree, TreePart};
use crate::world::tiles::tile_changed::TileChangedEvent; use crate::world::tiles::tile_changed::TileChangedEvent;
@@ -24,6 +26,7 @@ use crate::world::tiles::TileMap;
use bevy::prelude::*; use bevy::prelude::*;
use bevy_rand::prelude::*; use bevy_rand::prelude::*;
use smallvec::SmallVec; use smallvec::SmallVec;
use std::collections::VecDeque;
/// Main task executor. Runs in FixedUpdate. /// Main task executor. Runs in FixedUpdate.
pub fn task_executor_system( pub fn task_executor_system(
@@ -45,6 +48,7 @@ pub fn task_executor_system(
&EntityType, &EntityType,
Option<&mut HaulSlot>, Option<&mut HaulSlot>,
)>, )>,
mut job_queue: ResMut<JobQueue>,
mut claimed_writer: MessageWriter<TaskClaimed>, mut claimed_writer: MessageWriter<TaskClaimed>,
mut completed_writer: MessageWriter<TaskCompleted>, mut completed_writer: MessageWriter<TaskCompleted>,
mut failed_writer: MessageWriter<TaskFailed>, mut failed_writer: MessageWriter<TaskFailed>,
@@ -98,6 +102,8 @@ pub fn task_executor_system(
// Execute current task if Active // Execute current task if Active
if *state == TaskState::Active { if *state == TaskState::Active {
if let Some(current_task) = queue.current_mut() { if let Some(current_task) = queue.current_mut() {
let mut failed_reason: Option<&'static str> = None;
match current_task { match current_task {
Task::Idle { .. } => { Task::Idle { .. } => {
execute_idle( execute_idle(
@@ -139,6 +145,10 @@ pub fn task_executor_system(
} => match step { } => match step {
ChopStep::MovingToTree { ref mut approach } => { ChopStep::MovingToTree { ref mut approach } => {
if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) { if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) {
info!(
"TASK FAILED: {:?} for {:?} - {}",
current_task, entity, "tree already gone"
);
failed_writer.write(TaskFailed { failed_writer.write(TaskFailed {
entity, entity,
task: current_task.clone(), task: current_task.clone(),
@@ -187,10 +197,15 @@ pub fn task_executor_system(
ambulatory.current_path = None; ambulatory.current_path = None;
} }
None => { None => {
let reason = "no adjacent standable tile to approach tree";
info!(
"TASK FAILED: {:?} for {:?} - {}",
current_task, entity, reason
);
failed_writer.write(TaskFailed { failed_writer.write(TaskFailed {
entity, entity,
task: current_task.clone(), task: current_task.clone(),
reason: "no adjacent standable tile to approach tree", reason,
}); });
*state = TaskState::Failed; *state = TaskState::Failed;
continue; continue;
@@ -224,8 +239,9 @@ pub fn task_executor_system(
// If target is Some, pathfinding is handling movement — nothing to do // If target is Some, pathfinding is handling movement — nothing to do
} }
ChopStep::Chopping { ticks_remaining } => { ChopStep::Chopping { ticks_remaining } => {
info!("[EXECUTOR] Chop tick: {:?}", ticks_remaining);
if *ticks_remaining == 0 { if *ticks_remaining == 0 {
let trunk_positions = fell_tree( let (trunk_position, trunk_count) = fell_tree(
*trunk_pos, *trunk_pos,
&tree_parts, &tree_parts,
&mut commands, &mut commands,
@@ -243,9 +259,14 @@ pub fn task_executor_system(
fall_dir = Vec2::new(1.0, 0.0); // default: fall east fall_dir = Vec2::new(1.0, 0.0); // default: fall east
} }
let log_sprite: Handle<Image> = asset_server.load("log_cargo.png"); let log_sprite: Handle<Image> = asset_server.load("log_cargo.png");
let mut log_entities: SmallVec<[Entity; 8]> = SmallVec::new(); let mut log_entities: SmallVec<[(Entity, IVec3); 8]> =
for &pos in trunk_positions.iter() { SmallVec::new();
if let Some(log_entity) = // Loop from 0 up to the number of trunk segments found
for i in 0..trunk_count {
// Calculate the position for this specific log by offseting Z
let pos = trunk_position + IVec3::new(0, 0, i as i32);
if let Some((log_entity, drop_pos)) =
crate::entities::cargo::spawn_log_cargo( crate::entities::cargo::spawn_log_cargo(
&mut commands, &mut commands,
&mut tilemap_mut, &mut tilemap_mut,
@@ -255,14 +276,42 @@ pub fn task_executor_system(
&mut rng, &mut rng,
) )
{ {
log_entities.push(log_entity); log_entities.push((log_entity, drop_pos));
} }
} }
// Emit event so demo can queue HaulCargo tasks for these logs
if !log_entities.is_empty() { if !log_entities.is_empty() {
// Find surface Z at (0,0) - search for floor tile at different Z levels
use crate::constants::ITILE_SIZE;
let dest_z = (0..=4)
.find_map(|z_idx| {
let check_pos = IVec3::new(0, 0, z_idx * ITILE_SIZE);
if tilemap_mut.floor_tiles.contains_key(&check_pos) {
Some((z_idx + 1) * ITILE_SIZE)
} else {
None
}
})
.unwrap_or(16); // Default to z=16 if no floor found
let dest = IVec3::new(0, 0, dest_z);
for (cargo_entity, actual_cargo_pos) in log_entities.iter() {
job_queue.push(JobKind::HaulCargo {
cargo_entity: *cargo_entity,
cargo_pos: *actual_cargo_pos,
dest,
});
info!(
"[EXECUTOR] Added HaulCargo for cargo at {:?} -> {:?}",
actual_cargo_pos, dest
);
}
logs_spawned_writer.write(LogsSpawned { logs_spawned_writer.write(LogsSpawned {
log_entities, log_entities: log_entities
dest: IVec3::ZERO, .iter()
.map(|(e, _)| *e)
.collect(),
dest,
}); });
} }
*step = ChopStep::Done; *step = ChopStep::Done;
@@ -294,7 +343,14 @@ pub fn task_executor_system(
HaulStep::MovingToCargo { approach } => { HaulStep::MovingToCargo { approach } => {
use crate::constants::ITILE_SIZE; use crate::constants::ITILE_SIZE;
info!("[HAUL] {:?} MovingToCargo: cargo_pos={:?}, approach={:?}, target={:?}",
entity, cargo_pos, approach, ambulatory.target);
if !tilemap_mut.cargo_tiles.contains_key(cargo_pos) { if !tilemap_mut.cargo_tiles.contains_key(cargo_pos) {
info!(
"TASK FAILED: {:?} for {:?} - {}",
current_task, entity, "cargo no longer exists"
);
failed_writer.write(TaskFailed { failed_writer.write(TaskFailed {
entity, entity,
task: current_task.clone(), task: current_task.clone(),
@@ -305,40 +361,107 @@ pub fn task_executor_system(
} }
if approach.is_none() { if approach.is_none() {
// Search for nearest standable tile to approach cargo. const NODE_CAP: usize = 1024;
// Radius 0 = cargo tile itself (can stand in same tile as cargo). let mut frontier: VecDeque<IVec3> = VecDeque::new();
// Radius 1-2 = adjacent tiles if cargo tile is blocked. let mut visited: std::collections::HashSet<IVec3> =
std::collections::HashSet::new();
let cargo_z = cargo_pos.z; let cargo_z = cargo_pos.z;
let approach_tile = (0..=2i32).find_map(|radius: i32| {
for dx in -radius..=radius { for dx in -1i32..=1 {
for dy in -radius..=radius { for dy in -1i32..=1 {
if radius > 0 if dx == 0 && dy == 0 {
&& dx.abs() != radius
&& dy.abs() != radius
{
continue; continue;
} }
let candidate = IVec3::new( let neighbor = IVec3::new(
cargo_pos.x + dx * ITILE_SIZE, cargo_pos.x + dx * ITILE_SIZE,
cargo_pos.y + dy * ITILE_SIZE, cargo_pos.y + dy * ITILE_SIZE,
cargo_z, cargo_z,
); );
if tilemap_mut.is_standable(candidate) { if visited.insert(neighbor) {
return Some(candidate); frontier.push_back(neighbor);
} }
} }
} }
None
}); for dz in -1i32..=1 {
if dz == 0 {
continue;
}
let above = IVec3::new(
cargo_pos.x,
cargo_pos.y,
cargo_z + dz * ITILE_SIZE,
);
if visited.insert(above) {
frontier.push_back(above);
}
}
let mut approach_tile: Option<IVec3> = None;
while let Some(tile) = frontier.pop_front() {
if visited.len() > NODE_CAP {
break;
}
if !tilemap_mut.is_standable(tile) {
continue;
}
if tilemap_mut.cargo_tiles.contains_key(&tile) {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 && dz == 0 {
continue;
}
let neighbor = IVec3::new(
tile.x + dx * ITILE_SIZE,
tile.y + dy * ITILE_SIZE,
tile.z + dz * ITILE_SIZE,
);
if visited.insert(neighbor) {
frontier.push_back(neighbor);
}
}
}
}
continue;
}
if tilemap_mut.claimed_tiles.contains_key(&tile) {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 && dz == 0 {
continue;
}
let neighbor = IVec3::new(
tile.x + dx * ITILE_SIZE,
tile.y + dy * ITILE_SIZE,
tile.z + dz * ITILE_SIZE,
);
if visited.insert(neighbor) {
frontier.push_back(neighbor);
}
}
}
}
continue;
}
approach_tile = Some(tile);
break;
}
match approach_tile { match approach_tile {
Some(tile) => { Some(tile) => {
*approach = Some(tile); *approach = Some(tile);
info!( info!(
"[HAUL] {:?} target set: cargo={:?} approach={:?}", "[HAUL] {:?} setting target: cargo={:?} approach={:?} target={:?}",
entity, cargo_pos, tile entity, cargo_pos, tile, ambulatory.target
); );
// +1.0 z-offset for entity standing height (same as trees)
ambulatory.target = Some(Vec3::new( ambulatory.target = Some(Vec3::new(
tile.x as f32, tile.x as f32,
tile.y as f32, tile.y as f32,
@@ -359,22 +482,26 @@ pub fn task_executor_system(
} }
// Check arrival at cargo tile // Check arrival at cargo tile
if approach.is_some() && ambulatory.target.is_none() { // Use approach tile position for distance check, not target None
let dx = transform.translation.x - cargo_pos.x as f32; if let Some(approach_tile) = *approach {
let dy = transform.translation.y - cargo_pos.y as f32; let dx = transform.translation.x - approach_tile.x as f32;
let dy = transform.translation.y - approach_tile.y as f32;
let dist_sq = dx * dx + dy * dy; let dist_sq = dx * dx + dy * dy;
let pickup_range_sq = let arrive_sq =
(ITILE_SIZE as f32 * 1.5) * (ITILE_SIZE as f32 * 1.5); (ITILE_SIZE as f32 * 1.5) * (ITILE_SIZE as f32 * 1.5);
if dist_sq <= pickup_range_sq { info!("[HAUL] {:?} arrival check: approach={:?} dist_sq={:.1} arrive_sq={:.1} transform={:?}",
entity, approach_tile, dist_sq, arrive_sq, transform.translation.truncate());
if dist_sq <= arrive_sq {
info!( info!(
"[HAUL] {:?} arrived at cargo {:?}, picking up", "[HAUL] {:?} arrived at approach {:?}, picking up cargo at {:?}",
entity, cargo_entity entity, approach_tile, cargo_pos
); );
*step = HaulStep::PickingUp; *step = HaulStep::PickingUp;
} }
} }
} }
HaulStep::PickingUp => { HaulStep::PickingUp => {
info!("[HAUL] {:?} PickingUp: cargo_pos={:?}", entity, cargo_pos);
if haul.is_occupied() { if haul.is_occupied() {
failed_writer.write(TaskFailed { failed_writer.write(TaskFailed {
entity, entity,
@@ -390,7 +517,14 @@ pub fn task_executor_system(
"[HAUL] {:?} picked up {:?} → hauling to {:?}", "[HAUL] {:?} picked up {:?} → hauling to {:?}",
entity, cargo_entity, dest entity, cargo_entity, dest
); );
*step = HaulStep::MovingToDest { chosen_drop: None };
let drop_target =
find_drop_tile(&mut *tilemap_mut, *dest, entity);
tilemap_mut.claimed_tiles.insert(drop_target, entity);
*step = HaulStep::MovingToDest {
chosen_drop: Some(drop_target),
};
} else { } else {
failed_writer.write(TaskFailed { failed_writer.write(TaskFailed {
entity, entity,
@@ -401,38 +535,43 @@ pub fn task_executor_system(
} }
} }
HaulStep::MovingToDest { chosen_drop } => { HaulStep::MovingToDest { chosen_drop } => {
if chosen_drop.is_none() { let drop_pos = *chosen_drop;
*chosen_drop = Some( if let Some(drop) = drop_pos {
tilemap_mut
.find_nearest_free_cargo_tile(*dest, 8, &[])
.unwrap_or(*dest),
);
}
let drop_pos = chosen_drop.unwrap();
if ambulatory.target.is_none() { if ambulatory.target.is_none() {
info!("[HAUL] {:?} target set: drop at {:?}", entity, drop_pos); info!(
// +1.0 z-offset for entity standing height (same as approach) "[HAUL] {:?} setting target to drop at {:?}",
entity, drop
);
ambulatory.target = Some(Vec3::new( ambulatory.target = Some(Vec3::new(
drop_pos.x as f32, drop.x as f32,
drop_pos.y as f32, drop.y as f32,
drop_pos.z as f32 + 1.0, drop.z as f32 + 1.0,
)); ));
ambulatory.current_path = None; ambulatory.current_path = None;
} }
// Always check arrival distance, not gated by target status let dx = transform.translation.x - drop.x as f32;
let dx = transform.translation.x - drop_pos.x as f32; let dy = transform.translation.y - drop.y as f32;
let dy = transform.translation.y - drop_pos.y as f32;
let dist_sq = dx * dx + dy * dy; let dist_sq = dx * dx + dy * dy;
let arrive_sq = (crate::constants::TILE_SIZE as f32 * 1.5) let arrive_sq = (crate::constants::TILE_SIZE as f32 * 1.5)
* (crate::constants::TILE_SIZE as f32 * 1.5); * (crate::constants::TILE_SIZE as f32 * 1.5);
if dist_sq <= arrive_sq { if dist_sq <= arrive_sq {
ambulatory.target = None; ambulatory.target = None;
tilemap_mut.claimed_tiles.remove(&drop);
info!( info!(
"[HAUL] {:?} arrived at drop point {:?}", "[HAUL] {:?} arrived at drop point {:?}",
entity, drop_pos entity, drop
); );
*step = HaulStep::Dropping { drop_pos }; *step = HaulStep::Dropping { drop_pos: drop };
}
} else {
failed_writer.write(TaskFailed {
entity,
task: current_task.clone(),
reason: "drop target not set",
});
*state = TaskState::Failed;
continue;
} }
} }
HaulStep::Dropping { drop_pos } => { HaulStep::Dropping { drop_pos } => {
@@ -551,3 +690,94 @@ pub fn task_executor_system(
} }
} }
} }
fn find_drop_tile(tilemap: &mut TileMap, dest: IVec3, exclude_entity: Entity) -> IVec3 {
const SEARCH_RADIUS: i32 = 8;
const NODE_CAP: usize = 1024;
let mut frontier: VecDeque<IVec3> = VecDeque::new();
let mut visited: std::collections::HashSet<IVec3> = std::collections::HashSet::new();
let dest_z = dest.z;
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let neighbor = IVec3::new(dest.x + dx * ITILE_SIZE, dest.y + dy * ITILE_SIZE, dest_z);
if visited.insert(neighbor) {
frontier.push_back(neighbor);
}
}
}
for dz in -1i32..=1 {
if dz == 0 {
continue;
}
let above = IVec3::new(dest.x, dest.y, dest_z + dz * ITILE_SIZE);
if visited.insert(above) {
frontier.push_back(above);
}
}
while let Some(tile) = frontier.pop_front() {
if visited.len() > NODE_CAP {
break;
}
if !tilemap.is_standable(tile) {
continue;
}
if tilemap.cargo_tiles.contains_key(&tile) {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 && dz == 0 {
continue;
}
let neighbor = IVec3::new(
tile.x + dx * ITILE_SIZE,
tile.y + dy * ITILE_SIZE,
tile.z + dz * ITILE_SIZE,
);
if visited.insert(neighbor) {
frontier.push_back(neighbor);
}
}
}
}
continue;
}
if tilemap.claimed_tiles.contains_key(&tile) {
let claimant = tilemap.claimed_tiles.get(&tile).copied();
if claimant != Some(exclude_entity) {
for dz in -1i32..=1 {
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 && dz == 0 {
continue;
}
let neighbor = IVec3::new(
tile.x + dx * ITILE_SIZE,
tile.y + dy * ITILE_SIZE,
tile.z + dz * ITILE_SIZE,
);
if visited.insert(neighbor) {
frontier.push_back(neighbor);
}
}
}
}
continue;
}
}
return tile;
}
dest
}
-210
View File
@@ -1,210 +0,0 @@
use crate::constants::{ITILE_SIZE, TILE_SIZE};
use crate::entities::behaviour::IdleBehaviour;
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES};
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;
pub(super) fn execute_idle(
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 {
origin,
sigma_world,
state,
} = task
else {
return;
};
match state {
IdleState::Picking {
retry_after_tick,
retry_count,
} => {
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(
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 => {
*retry_count += 1;
if *retry_count >= IDLE_MAX_RETRIES {
panic!(
"Entity stuck: pick_gaussian_target returned None {} times \
consecutively. origin={:?} sigma_world={:.1} \
loaded_chunks={} \
— no standable tile found. Check tilemap state.",
retry_count,
origin,
sigma_world,
chunk_map.loaded_chunks.len(),
);
}
*retry_after_tick = current_tick.saturating_add(30);
}
}
}
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 no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
if arrived || no_nav {
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 {
retry_after_tick: 0,
retry_count: 0,
};
}
}
}
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 {
retry_after_tick: 0,
retry_count: 0,
};
} else {
*ticks_remaining -= 1;
}
}
}
}
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
}
#[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
}
+77
View File
@@ -0,0 +1,77 @@
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
use crate::entities::cargo::HaulSlot;
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
use crate::entities::tasks::jobs::{FellTreeJob, HaulCargoJob, IdleJob};
use crate::world::tiles::TileMap;
use bevy::prelude::*;
pub fn job_assignment_system(
mut job_queue: ResMut<JobQueue>,
tilemap: Res<TileMap>,
mut dorf_query: Query<
(
&mut TaskQueue,
&mut TaskState,
&Transform,
&HaulSlot,
&mut Ambulatory,
),
With<EntityType>,
>,
) {
for (mut queue, mut state, transform, haul_slot, mut ambulatory) in dorf_query.iter_mut() {
let is_idle = queue.is_empty() || matches!(queue.current(), Some(Task::Idle { .. }));
if !is_idle {
continue;
}
if haul_slot.is_occupied() {
continue;
}
let dorf_pos_ivec = transform.translation.as_ivec3();
let dorf_pos_2d = Vec2::new(transform.translation.x, transform.translation.y);
if let Some(job) = job_queue.pop_best_pathfinding(&tilemap, dorf_pos_ivec, dorf_pos_2d) {
info!(
"[ASSIGN] Job assigned to dorf at {:?}: {:?}",
dorf_pos_ivec.xy(),
job
);
// Stop the dorf in its tracks so the new Task can take over movement.
ambulatory.current_path = None;
ambulatory.target = None;
ambulatory.path_index = 0;
match job {
JobKind::FellTree { trunk_pos } => {
info!("[ASSIGN] Dorf assigned to FellTree at {:?}", trunk_pos);
queue.clear();
queue.push(FellTreeJob::start(trunk_pos));
*state = TaskState::Pending;
}
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => {
info!("[ASSIGN] Dorf assigned to HaulCargo at {:?}", cargo_pos);
queue.clear();
queue.push(HaulCargoJob::start(cargo_entity, cargo_pos, dest));
*state = TaskState::Pending;
}
}
} else if queue.is_empty() {
let behaviour = EntityBehaviourRegistry::global_get("dorf");
let origin = transform.translation.as_ivec3();
if queue.is_empty() {
queue.push(IdleJob::start(origin, &behaviour.idle));
*state = TaskState::Pending;
}
}
}
}
+205
View File
@@ -0,0 +1,205 @@
use crate::constants::ITILE_SIZE;
use crate::entities::shared_systems::pathfinding::calculate_provisional_path;
use bevy::prelude::*;
use smallvec::SmallVec;
use std::cmp::Ordering;
use std::collections::VecDeque;
#[derive(Clone, Debug)]
pub enum JobKind {
FellTree {
trunk_pos: IVec3,
},
HaulCargo {
cargo_entity: Entity,
cargo_pos: IVec3,
dest: IVec3,
},
}
impl JobKind {
#[inline]
pub fn priority(&self) -> u8 {
match self {
JobKind::FellTree { .. } => 2,
JobKind::HaulCargo { .. } => 1,
}
}
#[inline]
pub fn target(&self) -> IVec3 {
match self {
JobKind::FellTree { trunk_pos, .. } => *trunk_pos,
JobKind::HaulCargo { cargo_pos, .. } => *cargo_pos,
}
}
#[inline]
pub fn is_fell_tree(&self) -> bool {
matches!(self, JobKind::FellTree { .. })
}
#[inline]
pub fn is_haul_cargo(&self) -> bool {
matches!(self, JobKind::HaulCargo { .. })
}
}
struct Entry {
kind: JobKind,
claimed: bool,
}
#[derive(Resource, Default)]
pub struct JobQueue {
jobs: VecDeque<Entry>,
}
impl JobQueue {
#[inline]
pub fn push(&mut self, kind: JobKind) {
self.jobs.push_back(Entry {
kind,
claimed: false,
});
}
pub fn pop_best_pathfinding(
&mut self,
tilemap: &crate::world::tiles::TileMap,
dorf_pos: IVec3,
_dorf_pos_2d: Vec2,
) -> Option<JobKind> {
if self.jobs.is_empty() {
return None;
}
// 1. Gather all unclaimed jobs and score them for sorting.
// We store: (Queue Index, Priority, Rough Distance)
let mut candidates: Vec<(usize, u8, i32)> = self
.jobs
.iter()
.enumerate()
.filter(|(_, entry)| !entry.claimed)
.map(|(i, entry)| {
let target = entry.kind.target();
// Manhattan distance is a cheap heuristic for sorting
let dist = (target.x - dorf_pos.x).abs()
+ (target.y - dorf_pos.y).abs()
+ (target.z - dorf_pos.z).abs();
(i, entry.kind.priority(), dist)
})
.collect();
// 2. Sort: Highest Priority first. If tied, Shortest Distance first.
candidates.sort_by(|a, b| {
b.1.cmp(&a.1) // Descending priority
.then(a.2.cmp(&b.2)) // Ascending distance
});
let max_path_dist = 1024;
// 3. Evaluate the sorted jobs with actual pathfinding
for (idx, _pri, _dist) in candidates {
let entry = &self.jobs[idx];
let best_path_target = match &entry.kind {
JobKind::FellTree { trunk_pos } => {
let standable_tiles = Self::find_all_standable_adjacent(trunk_pos, tilemap);
let mut best_target = None;
let mut shortest_path_len = usize::MAX;
// Pathfind to EVERY standable adjacent tile to find the absolute closest one
for tile in standable_tiles {
let path =
calculate_provisional_path(tilemap, dorf_pos, tile, max_path_dist);
if !path.is_empty() && path.len() < shortest_path_len {
shortest_path_len = path.len();
best_target = Some(tile);
}
}
best_target
}
JobKind::HaulCargo { cargo_pos, .. } => {
let path =
calculate_provisional_path(tilemap, dorf_pos, *cargo_pos, max_path_dist);
if !path.is_empty() {
Some(*cargo_pos)
} else {
None
}
}
};
// 4. If we found a valid path to this job, claim it and return it
if let Some(target_pos) = best_path_target {
info!(
"[QUEUE] Job Claimed: kind={:?} target={:?} dorf={:?}",
entry.kind, target_pos, dorf_pos
);
self.jobs[idx].claimed = true;
// Note: JobKind needs `#[derive(Clone)]` if it doesn't have it already
return Some(self.jobs[idx].kind.clone());
}
}
info!("[QUEUE] No reachable jobs found for dorf at {:?}", dorf_pos);
None
}
/// Returns ALL standable tiles immediately adjacent to the tree on the same Z level.
// job_queue.rs
fn find_all_standable_adjacent(
trunk_pos: &IVec3,
tilemap: &crate::world::tiles::TileMap,
) -> SmallVec<[IVec3; 8]> {
let mut tiles = SmallVec::new();
// The standing position is the SAME Z as the trunk.
// If trunk is at 16, dorf stands at 16 (in the air).
let standing_z = trunk_pos.z;
for dx in -1i32..=1 {
for dy in -1i32..=1 {
if dx == 0 && dy == 0 {
continue;
}
let candidate = IVec3::new(
trunk_pos.x + dx * ITILE_SIZE,
trunk_pos.y + dy * ITILE_SIZE,
standing_z,
);
if tilemap.is_standable(candidate) {
tiles.push(candidate);
}
}
}
tiles
}
#[inline]
pub fn is_empty(&self) -> bool {
self.jobs.is_empty()
}
#[inline]
pub fn len(&self) -> usize {
self.jobs.len()
}
#[inline]
pub fn has_fell_tree(&self) -> bool {
self.jobs.iter().any(|e| e.kind.is_fell_tree())
}
pub fn debug_counts(&self) -> (usize, usize) {
let fell = self.jobs.iter().filter(|e| e.kind.is_fell_tree()).count();
let haul = self.jobs.iter().filter(|e| e.kind.is_haul_cargo()).count();
(fell, haul)
}
pub fn iter(&self) -> impl Iterator<Item = &JobKind> {
self.jobs.iter().map(|e| &e.kind)
}
}
+43
View File
@@ -0,0 +1,43 @@
use crate::entities::behaviour::IdleBehaviour;
use crate::entities::tasks::components::{ChopStep, HaulStep, IdleState, Task, CHOP_TICKS_DEFAULT};
use bevy::prelude::{Entity, IVec3};
pub struct FellTreeJob;
impl FellTreeJob {
pub fn start(trunk_pos: IVec3) -> Task {
Task::ChopTree {
trunk_pos,
chop_ticks: CHOP_TICKS_DEFAULT,
step: ChopStep::MovingToTree { approach: None },
}
}
}
pub struct HaulCargoJob;
impl HaulCargoJob {
pub fn start(cargo_entity: Entity, cargo_pos: IVec3, dest: IVec3) -> Task {
Task::HaulCargo {
cargo_entity,
cargo_pos,
dest,
step: HaulStep::MovingToCargo { approach: None },
}
}
}
pub struct IdleJob;
impl IdleJob {
pub fn start(origin: IVec3, behaviour: &IdleBehaviour) -> Task {
Task::Idle {
origin,
sigma_world: behaviour.sigma_world,
state: IdleState::Picking {
retry_after_tick: 0,
retry_count: 0,
},
}
}
}
+9 -4
View File
@@ -2,13 +2,18 @@ pub mod components;
pub mod demo; pub mod demo;
pub mod events; pub mod events;
pub mod executor; pub mod executor;
pub mod idle; pub mod job_assignment;
pub mod job_queue;
pub mod jobs;
pub mod queue_debug;
pub mod tasks;
pub use components::{IdleState, Task, TaskQueue, TaskState}; pub use components::{IdleState, Task, TaskQueue, TaskState};
#[cfg(debug_assertions)] pub use demo::demo_system;
pub use demo::debug_task_queues;
pub use demo::{demo_system, DemoState};
pub use events::{LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed}; pub use events::{LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed};
pub use executor::task_executor_system; pub use executor::task_executor_system;
pub use job_assignment::job_assignment_system;
pub use job_queue::{JobKind, JobQueue};
pub use queue_debug::queue_debug_system;
pub use crate::plugins::tasks::TasksPlugin; pub use crate::plugins::tasks::TasksPlugin;
+54
View File
@@ -0,0 +1,54 @@
use crate::entities::behaviour::EntityType;
use crate::entities::tasks::components::{TaskQueue, TaskState};
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
use bevy::prelude::*;
pub fn queue_debug_system(
job_queue: Res<JobQueue>,
dorf_query: Query<(&TaskQueue, &TaskState, &Transform), With<EntityType>>,
) {
let (fell_count, haul_count) = job_queue.debug_counts();
let total = job_queue.len();
info!(
"=== QUEUE === fell:{}, haul:{}, total:{}",
fell_count, haul_count, total
);
for (i, kind) in job_queue.iter().enumerate() {
match kind {
JobKind::FellTree { trunk_pos } => {
info!(" [{}] FellTree at {:?}", i, trunk_pos);
}
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => {
info!(
" [{}] HaulCargo entity:{:?} from {:?} to {:?}",
i, cargo_entity, cargo_pos, dest
);
}
}
}
info!("=== DORFS ===");
for (queue, state, transform) in dorf_query.iter() {
let pos = transform.translation.truncate();
let current_task = queue.current().map(|t| t.name()).unwrap_or("EMPTY");
let state_str = match *state {
TaskState::Pending => "PENDING",
TaskState::Active => "ACTIVE",
TaskState::Completed => "COMPLETED",
TaskState::Failed => "FAILED",
};
info!(
" Dorf at {:?}: task={} state={} queue_len={}",
pos,
current_task,
state_str,
queue.tasks.len()
);
}
}
+234
View File
@@ -0,0 +1,234 @@
use super::TaskResult;
use crate::constants::{ITILE_SIZE, TILE_SIZE};
use crate::entities::behaviour::IdleBehaviour;
use crate::entities::shared_components::Ambulatory;
use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES};
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;
pub fn execute_idle(
task: &mut Task,
transform: &Transform,
ambulatory: &mut Ambulatory,
sprite: &mut Sprite,
tilemap: &TileMap,
chunk_map: &ChunkMap,
behaviour: &IdleBehaviour,
rng: &mut WyRand,
current_tick: u32,
) -> TaskResult {
let Task::Idle {
origin,
sigma_world,
state,
} = task
else {
return TaskResult::Failed("not an idle task");
};
match state.clone() {
IdleState::Picking {
retry_after_tick,
retry_count,
} => {
if current_tick < retry_after_tick {
return TaskResult::Continue(0);
}
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
Some(floor_pos) => {
// 1. The PATHFINDER needs to target the air ABOVE the floor
let standable_target = floor_pos + IVec3::new(0, 0, ITILE_SIZE);
// 2. The AMBULATORY system (floats) needs the target + 1.0 offset
ambulatory.target = Some(Vec3::new(
standable_target.x as f32,
standable_target.y as f32,
standable_target.z as f32 + 1.0,
));
// 3. Reset movement state
ambulatory.current_path = None;
ambulatory.path_index = 0;
ambulatory.move_direction = Vec2::ZERO;
// 4. Update the Task State with the STANDABLE target
if let Task::Idle {
state: ref mut s, ..
} = task
{
*s = IdleState::Moving {
target: standable_target,
};
}
// Return the required TaskResult
TaskResult::Continue(0)
}
None => {
// If no target found, retry later
if let Task::Idle {
state: ref mut s, ..
} = task
{
*s = IdleState::Picking {
retry_after_tick: current_tick + 30,
retry_count: retry_count + 1,
};
}
TaskResult::Continue(0)
}
}
}
IdleState::Moving { target } => {
info!(
"[IDLE] Moving: target={:?} current={:?}",
target, transform.translation
);
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 no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
info!("[IDLE] Moving: arrived={} no_nav={}", arrived, no_nav);
if arrived || no_nav {
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
};
if let Task::Idle {
state: ref mut s, ..
} = task
{
*s = IdleState::Loitering {
ticks_remaining: duration,
flips_remaining,
next_flip_at,
};
}
} else {
if let Task::Idle {
state: ref mut s, ..
} = task
{
*s = IdleState::Picking {
retry_after_tick: 0,
retry_count: 0,
};
}
}
}
TaskResult::Continue(0)
}
IdleState::Loitering {
ticks_remaining,
flips_remaining,
next_flip_at,
} => {
if flips_remaining > 0 && current_tick >= next_flip_at {
sprite.flip_x = !sprite.flip_x;
}
if ticks_remaining == 0 {
sprite.flip_x = false;
if let Task::Idle {
state: ref mut s, ..
} = task
{
*s = IdleState::Picking {
retry_after_tick: 0,
retry_count: 0,
};
}
} else {
if let Task::Idle {
state:
IdleState::Loitering {
ref mut ticks_remaining,
..
},
..
} = task
{
*ticks_remaining -= 1;
}
}
TaskResult::Continue(0)
}
}
}
fn pick_gaussian_target(
origin: &IVec3,
sigma_world: f32,
tilemap: &TileMap,
_chunk_map: &ChunkMap, // No longer needed for O(1) lookups
rng: &mut WyRand,
) -> Option<IVec3> {
if tilemap.surface_positions.is_empty() {
return None;
}
let two_sigma_sq = 2.0 * sigma_world * sigma_world;
let mut chosen: Option<IVec3> = None;
let mut weight_sum = 0.0f32;
// We sample a fixed number of times globally from known surface tiles.
// This is O(SAMPLES) instead of O(CHUNKS * SAMPLES * TOTAL_TILES).
const TOTAL_SAMPLES: usize = 32;
for _ in 0..TOTAL_SAMPLES {
// 1. Pick a random surface tile directly from the pre-populated list
let idx = rng.random_range(0..tilemap.surface_positions.len());
let candidate = tilemap.surface_positions[idx];
// 2. Calculate Gaussian weight based on 2D distance
// (Allows dorfs to choose targets on different Z-levels if they are surface tiles)
let dx = (candidate.x - origin.x) as f32;
let dy = (candidate.y - origin.y) as f32;
let dist_sq = dx * dx + dy * dy;
// Use a small epsilon to prevent exp(0) issues
let weight = (-(dist_sq / two_sigma_sq)).exp().max(0.0001);
// 3. Reservoir Sampling: Update the chosen target based on relative weight
weight_sum += weight;
if rng.random_range(0.0..1.0) < (weight / weight_sum) {
chosen = Some(candidate);
}
}
if let Some(pos) = chosen {
info!(
"[IDLE] Target picked at {:?} (Weight Sum: {:.4})",
pos, weight_sum
);
}
chosen
}
+7
View File
@@ -0,0 +1,7 @@
pub mod idle;
pub enum TaskResult {
Complete,
Continue(u32),
Failed(&'static str),
}
+11 -19
View File
@@ -1,13 +1,7 @@
//! TasksPlugin — registers task infrastructure: executor, events, idle logic, demo loop.
//!
//! Systems:
//! - task_executor_system (FixedUpdate)
//! - demo_system (FixedUpdate, after task_executor_system)
//! - debug_task_queues (FixedUpdate, after demo_system, debug only)
use crate::entities::tasks::{ use crate::entities::tasks::{
demo_system, task_executor_system, DemoState, LogsSpawned, TaskBlocked, TaskClaimed, demo_system, job_assignment_system, job_queue::JobQueue, queue_debug_system,
TaskCompleted, TaskDropped, TaskFailed, task_executor_system, LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped,
TaskFailed,
}; };
use bevy::prelude::*; use bevy::prelude::*;
@@ -21,17 +15,15 @@ impl Plugin for TasksPlugin {
.add_message::<TaskDropped>() .add_message::<TaskDropped>()
.add_message::<TaskBlocked>() .add_message::<TaskBlocked>()
.add_message::<LogsSpawned>() .add_message::<LogsSpawned>()
.init_resource::<DemoState>() .init_resource::<JobQueue>()
.add_systems( .add_systems(
bevy::app::FixedUpdate, FixedUpdate,
demo_system.after(task_executor_system), (
demo_system,
job_assignment_system,
task_executor_system,
queue_debug_system,
),
); );
// Debug only — compiles away in release
#[cfg(debug_assertions)]
{
use crate::entities::tasks::debug_task_queues;
app.add_systems(bevy::app::FixedUpdate, debug_task_queues.after(demo_system));
}
} }
} }
+17 -2
View File
@@ -292,7 +292,8 @@ pub fn fell_tree(
tilemap: &mut TileMap, tilemap: &mut TileMap,
tile_changed: &mut MessageWriter<TileChangedEvent>, tile_changed: &mut MessageWriter<TileChangedEvent>,
occlusion: &mut MessageWriter<TileOcclusionEvent>, occlusion: &mut MessageWriter<TileOcclusionEvent>,
) -> SmallVec<[IVec3; 8]> { ) -> (IVec3, usize) {
// Changed return type
let target_chunk = world_to_chunk(trunk_pos); let target_chunk = world_to_chunk(trunk_pos);
let trunk_x = trunk_pos.x; let trunk_x = trunk_pos.x;
let trunk_y = trunk_pos.y; let trunk_y = trunk_pos.y;
@@ -333,5 +334,19 @@ pub fn fell_tree(
for pos in dirty_columns { for pos in dirty_columns {
occlusion.write(TileOcclusionEvent { tile_position: pos }); occlusion.write(TileOcclusionEvent { tile_position: pos });
} }
trunk_positions
let trunk_count = trunk_positions.len();
// Default to the input trunk_pos if for some reason no trunk parts were found
let lowest_trunk = trunk_positions
.iter()
.min_by_key(|p| p.z)
.cloned()
.unwrap_or(trunk_pos);
info!(
"[FORESTRY] fell_tree: trunk_pos={:?} lowest={:?} count={}",
trunk_pos, lowest_trunk, trunk_count
);
(lowest_trunk, trunk_count)
} }
+4
View File
@@ -329,6 +329,10 @@ pub fn apply_terrain_blobs(
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos }); occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
} }
for surface in blob.surface_positions.iter() {
tilemap.surface_positions.push(surface.0.as_ivec3());
}
forrestry_event_writer.write(ChunkForrestryEvent { forrestry_event_writer.write(ChunkForrestryEvent {
chunk_position: blob.chunk_pos, chunk_position: blob.chunk_pos,
floor_tiles: blob.surface_positions, floor_tiles: blob.surface_positions,
+9 -11
View File
@@ -96,14 +96,11 @@ impl ChunkData {
/// - (can_stand_in_floor OR can_stand_in_fixture) at (x, y, z) /// - (can_stand_in_floor OR can_stand_in_fixture) at (x, y, z)
/// - AND (can_stand_on_floor OR can_stand_on_fixture) at (x, y, z-1) /// - AND (can_stand_on_floor OR can_stand_on_fixture) at (x, y, z-1)
#[inline] #[inline]
pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool { // chunkdata.rs
// Bounds check: z must be within -Z_BELOW..Z_ABOVE
if z < -(Z_BELOW as i32) || z > (Z_ABOVE as i32) {
return false;
}
// Bounds check: local coords must be within chunk pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool {
if local_x < 0 || local_x >= CHUNK_SIZE || local_y < 0 || local_y >= CHUNK_SIZE { // 1. Bounds check (z is an index here, e.g., 0 to 64)
if z < 0 || z >= (Z_BELOW + Z_ABOVE) as i32 {
return false; return false;
} }
@@ -112,15 +109,15 @@ impl ChunkData {
let bit = idx % 32; let bit = idx % 32;
let mask = 1u32 << bit; let mask = 1u32 << bit;
// Am I in Air?
let in_floor = (self.stand_in_floor[word] & mask) != 0; let in_floor = (self.stand_in_floor[word] & mask) != 0;
let in_fixture = (self.stand_in_fixture[word] & mask) != 0; let in_fixture = (self.stand_in_fixture[word] & mask) != 0;
// Can't stand at the very bottom of the world // 2. Check the tile immediately below (z - 1)
if z <= -(Z_BELOW as i32) { if z <= 0 {
return false; return false;
} } // Bottom of the world
// Check tile below for "stand on"
let below_idx = Self::pos_to_index(local_x, local_y, z - 1); let below_idx = Self::pos_to_index(local_x, local_y, z - 1);
let below_word = below_idx / 32; let below_word = below_idx / 32;
let below_bit = below_idx % 32; let below_bit = below_idx % 32;
@@ -129,6 +126,7 @@ impl ChunkData {
let on_floor = (self.stand_on_floor[below_word] & below_mask) != 0; let on_floor = (self.stand_on_floor[below_word] & below_mask) != 0;
let on_fixture = (self.stand_on_fixture[below_word] & below_mask) != 0; let on_fixture = (self.stand_on_fixture[below_word] & below_mask) != 0;
// Logic: Current tile is passable AND tile below is solid
(in_floor || in_fixture) && (on_floor || on_fixture) (in_floor || in_fixture) && (on_floor || on_fixture)
} }
+6
View File
@@ -208,6 +208,12 @@ pub struct TileMap {
/// One Cargo entity per tile position. Enforces single-occupancy. /// One Cargo entity per tile position. Enforces single-occupancy.
/// Cargo does not affect standability — purely for lookup and placement validation. /// Cargo does not affect standability — purely for lookup and placement validation.
pub cargo_tiles: FxHashMap<IVec3, Entity>, pub cargo_tiles: FxHashMap<IVec3, Entity>,
/// Tiles claimed by dorfs for drop destinations. Prevents multiple dorfs
/// from targeting the same drop tile. Maps tile position -> entity doing the claim.
pub claimed_tiles: FxHashMap<IVec3, Entity>,
/// Surface tile positions for idle pathfinding and valid spawn targets.
/// Populated from TerrainBlob during terrain processing.
pub surface_positions: Vec<IVec3>,
} }
impl TileMap { impl TileMap {