demo part 2
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
//! 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 crate::constants::ITILE_SIZE;
|
||||
use crate::entities::cargo::{Cargo, Haulable};
|
||||
use crate::entities::tasks::components::{
|
||||
ChopStep, HaulStep, Task, TaskQueue, TaskState, CHOP_TICKS_DEFAULT,
|
||||
};
|
||||
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. Waiting for all visible logs to be claimed/hauled.
|
||||
/// Tracks which log entities have been assigned haul tasks.
|
||||
Hauling {
|
||||
felled_trunk_pos: IVec3,
|
||||
assigned_logs: 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: all assigned logs cleared from cargo_tiles (hauled)
|
||||
///
|
||||
/// 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>,
|
||||
tilemap: Res<TileMap>,
|
||||
chunk_map: Res<ChunkMap>,
|
||||
tree_parts: Query<(Entity, &TreePart)>,
|
||||
cargo_query: Query<(Entity, &Cargo), With<Haulable>>,
|
||||
mut dorf_query: Query<(Entity, &mut TaskQueue, &mut TaskState, &Transform)>,
|
||||
) {
|
||||
match &*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.
|
||||
// fell_tree is called with the lowest trunk pos.
|
||||
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.
|
||||
let mut best_dorf: Option<(Entity, i32)> = None;
|
||||
for (entity, queue, state, transform) in dorf_query.iter() {
|
||||
if !is_idle_dorf(&queue, &state) {
|
||||
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 {
|
||||
return; // no idle dorfs available
|
||||
};
|
||||
|
||||
// Assign ChopTree task
|
||||
if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(chopper) {
|
||||
// Clear current idle task and push ChopTree
|
||||
queue.clear();
|
||||
queue.push(Task::ChopTree {
|
||||
trunk_pos: lowest_trunk,
|
||||
chop_ticks: CHOP_TICKS_DEFAULT,
|
||||
step: ChopStep::MovingToTree,
|
||||
});
|
||||
*state = TaskState::Pending;
|
||||
}
|
||||
|
||||
*demo_state = DemoState::Chopping {
|
||||
trunk_pos: lowest_trunk,
|
||||
chopper,
|
||||
};
|
||||
|
||||
info!(
|
||||
"Demo: assigned ChopTree at {:?} to dorf {:?}",
|
||||
lowest_trunk, chopper
|
||||
);
|
||||
}
|
||||
|
||||
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, _, _)) = 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() {
|
||||
// Chopper abandoned — reassign
|
||||
*demo_state = DemoState::Idle;
|
||||
warn!("Demo: chopper {:?} abandoned ChopTree — resetting", chopper);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree is felled — transition to Hauling
|
||||
info!("Demo: tree at {:?} felled, assigning haul tasks", trunk_pos);
|
||||
|
||||
// Find all Cargo logs near the trunk position
|
||||
let search_world = LOG_SEARCH_RADIUS_TILES * ITILE_SIZE;
|
||||
let logs: Vec<(Entity, IVec3)> = 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)| (e, cargo.tile_pos))
|
||||
.collect();
|
||||
|
||||
if logs.is_empty() {
|
||||
// No logs found — tree may have had no trunks or logs all orphaned
|
||||
warn!("Demo: no logs found after felling {:?}", trunk_pos);
|
||||
*demo_state = DemoState::Idle;
|
||||
return;
|
||||
}
|
||||
|
||||
// Find haul destination — nearest standable tile to (0,0)
|
||||
let dest = tilemap
|
||||
.find_nearest_free_cargo_tile(IVec3::ZERO, HAUL_DEST_SEARCH_RADIUS)
|
||||
.unwrap_or(IVec3::ZERO);
|
||||
|
||||
// Assign one HaulCargo task per log to idle dorfs
|
||||
let mut assigned_logs: FxHashSet<Entity> = FxHashSet::default();
|
||||
let mut logs_iter = logs.iter();
|
||||
|
||||
// Re-collect idle dorfs (mutable query needed)
|
||||
// Must collect entities first to avoid double-borrow
|
||||
let idle_dorfs: Vec<Entity> = dorf_query
|
||||
.iter()
|
||||
.filter(|(_, queue, state, _)| is_idle_dorf(queue, state))
|
||||
.map(|(e, _, _, _)| e)
|
||||
.collect();
|
||||
|
||||
for dorf_entity in idle_dorfs {
|
||||
let Some(&(log_entity, log_pos)) = logs_iter.next() else {
|
||||
break; // more dorfs than logs — remaining dorfs stay idle
|
||||
};
|
||||
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,
|
||||
});
|
||||
*state = TaskState::Pending;
|
||||
assigned_logs.insert(log_entity);
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining unassigned logs stay on the floor — future dorfs
|
||||
// will be assigned when they become idle if the demo loops.
|
||||
// For simplicity: log unassigned count but don't retry this tick.
|
||||
let unassigned = logs.len().saturating_sub(assigned_logs.len());
|
||||
if unassigned > 0 {
|
||||
info!(
|
||||
"Demo: {} logs unassigned (not enough idle dorfs)",
|
||||
unassigned
|
||||
);
|
||||
}
|
||||
|
||||
*demo_state = DemoState::Hauling {
|
||||
felled_trunk_pos: trunk_pos,
|
||||
assigned_logs,
|
||||
};
|
||||
}
|
||||
|
||||
DemoState::Hauling {
|
||||
felled_trunk_pos,
|
||||
assigned_logs,
|
||||
} => {
|
||||
let felled_trunk_pos = *felled_trunk_pos;
|
||||
|
||||
// Check if all assigned logs have been hauled (removed from cargo_tiles)
|
||||
// Check via cargo_query: if the Cargo entity still exists
|
||||
// and is still in cargo_tiles, it hasn't been hauled yet.
|
||||
let remaining = assigned_logs
|
||||
.iter()
|
||||
.filter(|&&log_entity| {
|
||||
cargo_query
|
||||
.get(log_entity)
|
||||
.map(|(_, cargo)| tilemap.cargo_tiles.contains_key(&cargo.tile_pos))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.count();
|
||||
|
||||
if remaining == 0 {
|
||||
info!(
|
||||
"Demo: all logs hauled from {:?}, finding next tree",
|
||||
felled_trunk_pos
|
||||
);
|
||||
*demo_state = DemoState::Idle;
|
||||
}
|
||||
// else: still hauling, check again next tick (cheap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { .. }))
|
||||
}
|
||||
@@ -176,13 +176,23 @@ pub fn task_executor_system(
|
||||
&mut tile_changed,
|
||||
&mut occlusion,
|
||||
);
|
||||
// Compute fall direction: from chopper (entity's position) toward trunk
|
||||
let chopper_pos = transform.translation.truncate(); // XY only
|
||||
let trunk_xy = Vec2::new(trunk_pos.x as f32, trunk_pos.y as f32);
|
||||
let mut fall_dir = (trunk_xy - chopper_pos).normalize_or_zero();
|
||||
// If dorf is standing on the trunk (distance ~0), fall direction is arbitrary
|
||||
if fall_dir == Vec2::ZERO {
|
||||
fall_dir = Vec2::new(1.0, 0.0); // default: fall east
|
||||
}
|
||||
let log_sprite = asset_server.load("tree_trunk.png");
|
||||
for &pos in trunk_positions.iter() {
|
||||
crate::entities::cargo::spawn_log_cargo(
|
||||
&mut commands,
|
||||
&mut tilemap_mut,
|
||||
pos,
|
||||
fall_dir,
|
||||
log_sprite.clone(),
|
||||
&mut rng,
|
||||
);
|
||||
}
|
||||
*step = ChopStep::Done;
|
||||
@@ -291,8 +301,14 @@ pub fn task_executor_system(
|
||||
}
|
||||
HaulStep::Dropping { drop_pos } => {
|
||||
if let Some(cargo_entity) = haul.release() {
|
||||
let _ = tilemap_mut.place_cargo(*drop_pos, cargo_entity);
|
||||
if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) {
|
||||
if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() {
|
||||
warn!(
|
||||
"place_cargo failed at {:?} — tile occupied. \
|
||||
Cargo entity {:?} orphaned.",
|
||||
drop_pos, cargo_entity
|
||||
);
|
||||
} else if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity)
|
||||
{
|
||||
cargo.tile_pos = *drop_pos;
|
||||
}
|
||||
}
|
||||
@@ -340,8 +356,14 @@ pub fn task_executor_system(
|
||||
}
|
||||
DropStep::Dropping { drop_pos } => {
|
||||
if let Some(cargo_entity) = haul.release() {
|
||||
let _ = tilemap_mut.place_cargo(*drop_pos, cargo_entity);
|
||||
if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) {
|
||||
if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() {
|
||||
warn!(
|
||||
"place_cargo failed at {:?} — tile occupied. \
|
||||
Cargo entity {:?} orphaned.",
|
||||
drop_pos, cargo_entity
|
||||
);
|
||||
} else if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity)
|
||||
{
|
||||
cargo.tile_pos = *drop_pos;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod components;
|
||||
pub mod demo;
|
||||
pub mod events;
|
||||
pub mod executor;
|
||||
pub mod idle;
|
||||
|
||||
pub use components::{IdleState, Task, TaskQueue, TaskState};
|
||||
pub use demo::{demo_system, DemoState};
|
||||
pub use events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed};
|
||||
pub use executor::task_executor_system;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user