attempt 3
This commit is contained in:
+69
-414
@@ -1,439 +1,94 @@
|
||||
//! 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 rustc_hash::FxHashSet;
|
||||
use smallvec::SmallVec;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::constants::ITILE_SIZE;
|
||||
use crate::entities::cargo::{Cargo, HaulSlot, Haulable};
|
||||
use crate::entities::tasks::components::{
|
||||
ChopStep, HaulStep, Task, TaskQueue, TaskState, CHOP_TICKS_DEFAULT,
|
||||
};
|
||||
use crate::entities::tasks::events::{LogsSpawned, TaskFailed};
|
||||
use crate::entities::behaviour::EntityType;
|
||||
use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
|
||||
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
|
||||
use crate::world::chunks::ChunkMap;
|
||||
use crate::world::generation::forestry::TreePart;
|
||||
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(
|
||||
mut demo_state: ResMut<DemoState>,
|
||||
mut job_queue: ResMut<JobQueue>,
|
||||
tilemap: Res<TileMap>,
|
||||
chunk_map: Res<ChunkMap>,
|
||||
tree_parts: Query<(Entity, &TreePart)>,
|
||||
cargo_query: Query<(Entity, &Cargo), With<Haulable>>,
|
||||
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>,
|
||||
dorf_query: Query<(&TaskQueue, &TaskState), With<EntityType>>,
|
||||
) {
|
||||
// Handle task failures that should reset the demo state for retry
|
||||
for event in task_failed.read() {
|
||||
if event.reason == "no adjacent standable tile to approach tree" {
|
||||
if let DemoState::Chopping { trunk_pos, chopper } = *demo_state {
|
||||
warn!(
|
||||
"[DEMO] ChopTree failed for tree at {:?} (chopper={:?}): {}, resetting to Idle",
|
||||
trunk_pos, chopper, event.reason
|
||||
);
|
||||
*demo_state = DemoState::Idle;
|
||||
let any_chopping = dorf_query.iter().any(|(queue, state)| {
|
||||
let is_active_or_completing = *state == TaskState::Active || *state == TaskState::Completed;
|
||||
if is_active_or_completing {
|
||||
if let Some(current) = queue.current() {
|
||||
return matches!(current, Task::ChopTree { .. });
|
||||
}
|
||||
}
|
||||
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
|
||||
// Collect events first to avoid double-mutable borrow conflict with demo_state
|
||||
let pending_logs: Vec<_> = logs_spawned.read().collect();
|
||||
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 {
|
||||
DemoState::Idle => {
|
||||
// Find the nearest standing tree to (0,0).
|
||||
// A "tree" is identified by a TreePart with is_trunk=true whose
|
||||
// tile_pos is still in fixture_tiles (not yet felled).
|
||||
let origin = IVec3::ZERO;
|
||||
|
||||
let mut best: Option<(IVec3, i32)> = None; // (trunk_pos, chebyshev_dist)
|
||||
|
||||
for (_, part) in tree_parts.iter() {
|
||||
if !part.is_trunk {
|
||||
continue;
|
||||
}
|
||||
if !tilemap.fixture_tiles.contains_key(&part.tile_pos) {
|
||||
continue; // already felled
|
||||
}
|
||||
// Only consider trees in fully-loaded chunks
|
||||
let chunk = crate::world::chunks::world_to_chunk(part.tile_pos);
|
||||
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::Y))
|
||||
&& chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::Y));
|
||||
if !loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dx = (part.tile_pos.x - origin.x).abs() / ITILE_SIZE;
|
||||
let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE;
|
||||
let dist = dx.max(dy);
|
||||
|
||||
if best.map_or(true, |(_, best_dist)| dist < best_dist) {
|
||||
best = Some((part.tile_pos, dist));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((trunk_pos, _)) = best else {
|
||||
// No trees found — nothing to do
|
||||
return;
|
||||
};
|
||||
|
||||
// Find the lowest trunk tile (minimum z) for this tree's XY column.
|
||||
let lowest_trunk = tree_parts
|
||||
.iter()
|
||||
.filter(|(_, p)| {
|
||||
p.is_trunk
|
||||
&& p.tile_pos.x == trunk_pos.x
|
||||
&& p.tile_pos.y == trunk_pos.y
|
||||
&& 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;
|
||||
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 {
|
||||
// Couldn't assign — put log back at front of queue
|
||||
unassigned.push_front((log_entity, log_pos));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all work is done
|
||||
if unassigned.is_empty() && in_progress.is_empty() {
|
||||
info!("[DEMO] → Idle: all hauled, seeking next tree");
|
||||
*demo_state = DemoState::Idle;
|
||||
}
|
||||
}
|
||||
if !job_queue.is_empty() {
|
||||
let (fell, haul) = job_queue.debug_counts();
|
||||
info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { .. }))
|
||||
}
|
||||
fn find_tree_nearest_origin(
|
||||
tilemap: &TileMap,
|
||||
chunk_map: &ChunkMap,
|
||||
tree_parts: &Query<(Entity, &TreePart)>,
|
||||
) -> Option<IVec3> {
|
||||
let origin = IVec3::ZERO;
|
||||
let mut best_xy: Option<(IVec2, i32)> = None;
|
||||
|
||||
/// 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));
|
||||
for (_, part) in tree_parts.iter() {
|
||||
if !part.is_trunk {
|
||||
continue;
|
||||
}
|
||||
if !tilemap.fixture_tiles.contains_key(&part.tile_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pending: Vec<&str> = queue.tasks.iter().skip(1).map(|t| t.name()).collect();
|
||||
let chunk = crate::world::chunks::world_to_chunk(part.tile_pos);
|
||||
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::Y))
|
||||
&& chunk_map.loaded_chunks.contains_key(&(chunk - IVec2::Y));
|
||||
if !loaded {
|
||||
continue;
|
||||
}
|
||||
|
||||
if pending.is_empty() {
|
||||
info!("[TASK] {:?} → {}", entity, current);
|
||||
} else {
|
||||
info!(
|
||||
"[TASK] {:?} → {} pending:[{}]",
|
||||
entity,
|
||||
current,
|
||||
pending.join(",")
|
||||
);
|
||||
let dx = (part.tile_pos.x - origin.x).abs() / ITILE_SIZE;
|
||||
let dy = (part.tile_pos.y - origin.y).abs() / ITILE_SIZE;
|
||||
let dist = dx.max(dy);
|
||||
|
||||
if best_xy.map_or(true, |(_, best_dist)| dist < best_dist) {
|
||||
best_xy = Some((part.tile_pos.xy(), dist));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((nearest_xy, _)) = best_xy else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let lowest_trunk = tree_parts
|
||||
.iter()
|
||||
.filter_map(|(_, p)| {
|
||||
if p.is_trunk
|
||||
&& p.tile_pos.x == nearest_xy.x
|
||||
&& p.tile_pos.y == nearest_xy.y
|
||||
&& tilemap.fixture_tiles.contains_key(&p.tile_pos)
|
||||
{
|
||||
Some(p.tile_pos)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.min_by_key(|pos| pos.z);
|
||||
|
||||
lowest_trunk
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user