demo part 2

This commit is contained in:
2026-03-22 14:57:59 +00:00
parent 54eabe85e2
commit 5a830f46f9
5 changed files with 388 additions and 15 deletions
+41 -7
View File
@@ -7,8 +7,10 @@
//! placeholder until a dedicated ground-log sprite exists. //! placeholder until a dedicated ground-log sprite exists.
use bevy::prelude::*; use bevy::prelude::*;
use bevy_rand::prelude::*;
use rand::RngExt;
use crate::constants::PIXEL_RATIO; use crate::constants::{ITILE_SIZE, PIXEL_RATIO};
use crate::entities::cargo::{Cargo, Haulable}; use crate::entities::cargo::{Cargo, Haulable};
use crate::entities::item::constants::ITEM_Z_FIGHTING_OFFSET; use crate::entities::item::constants::ITEM_Z_FIGHTING_OFFSET;
use crate::entities::item::inventory::constants::SIZE_LARGE; use crate::entities::item::inventory::constants::SIZE_LARGE;
@@ -17,20 +19,52 @@ use crate::world::tiles::TileMap;
/// 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;
/// Spawn a Cargo log entity at `tile_pos` and register it in cargo_tiles. /// Spawn a Cargo log entity with a directional fall bias.
/// ///
/// If the tile is already occupied (another log landed here), finds the /// `tile_pos` — world position of the trunk tile this log came from.
/// nearest free tile via TileMap::find_nearest_free_cargo_tile. /// `fall_direction` — unit-ish vector (XY only) from chopper toward trunk.
/// Used to bias the landing position away from the chopper.
/// `log_sprite` — sprite handle (texture ID 500004).
/// `rng` — caller-provided RNG for reproducible scatter.
/// ///
/// Returns the spawned Entity, or None if no free tile found within radius. /// Landing position:
/// base = tile_pos + fall_direction * random(0..=3 tiles)
/// jitter = random lateral offset of ±1 tile perpendicular to fall_direction
/// actual = find_nearest_free_cargo_tile(base + jitter, 4)
///
/// If the chosen tile is occupied by a living entity, TODO: fire an OuchEvent.
/// For now: log a debug message and place the cargo on the nearest free tile.
pub fn spawn_log_cargo( pub fn spawn_log_cargo(
commands: &mut Commands, commands: &mut Commands,
tilemap: &mut TileMap, tilemap: &mut TileMap,
tile_pos: IVec3, tile_pos: IVec3,
fall_direction: Vec2,
log_sprite: Handle<Image>, log_sprite: Handle<Image>,
rng: &mut WyRand,
) -> Option<Entity> { ) -> Option<Entity> {
// Find a free tile — the exact trunk position may be occupied // Distance along fall direction: 03 tiles
let drop_pos = tilemap.find_nearest_free_cargo_tile(tile_pos, 4)?; let fall_tiles = rng.random_range(0u32..=3) as f32;
// Perpendicular jitter: ±1 tile lateral to fall direction
let perp = Vec2::new(-fall_direction.y, fall_direction.x);
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 biased_pos = IVec3::new(
tile_pos.x + (offset.x * ITILE_SIZE as f32).round() as i32,
tile_pos.y + (offset.y * ITILE_SIZE as f32).round() as i32,
tile_pos.z,
);
// Find nearest free tile from biased position
let drop_pos = tilemap.find_nearest_free_cargo_tile(biased_pos, 4)?;
// 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((
+308
View File
@@ -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 { .. }))
}
+26 -4
View File
@@ -176,13 +176,23 @@ pub fn task_executor_system(
&mut tile_changed, &mut tile_changed,
&mut occlusion, &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"); let log_sprite = asset_server.load("tree_trunk.png");
for &pos in trunk_positions.iter() { for &pos in trunk_positions.iter() {
crate::entities::cargo::spawn_log_cargo( crate::entities::cargo::spawn_log_cargo(
&mut commands, &mut commands,
&mut tilemap_mut, &mut tilemap_mut,
pos, pos,
fall_dir,
log_sprite.clone(), log_sprite.clone(),
&mut rng,
); );
} }
*step = ChopStep::Done; *step = ChopStep::Done;
@@ -291,8 +301,14 @@ pub fn task_executor_system(
} }
HaulStep::Dropping { drop_pos } => { HaulStep::Dropping { drop_pos } => {
if let Some(cargo_entity) = haul.release() { if let Some(cargo_entity) = haul.release() {
let _ = tilemap_mut.place_cargo(*drop_pos, cargo_entity); if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() {
if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) { 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; cargo.tile_pos = *drop_pos;
} }
} }
@@ -340,8 +356,14 @@ pub fn task_executor_system(
} }
DropStep::Dropping { drop_pos } => { DropStep::Dropping { drop_pos } => {
if let Some(cargo_entity) = haul.release() { if let Some(cargo_entity) = haul.release() {
let _ = tilemap_mut.place_cargo(*drop_pos, cargo_entity); if tilemap_mut.place_cargo(*drop_pos, cargo_entity).is_err() {
if let Ok(mut cargo) = cargo_query.get_mut(cargo_entity) { 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; cargo.tile_pos = *drop_pos;
} }
} }
+2
View File
@@ -1,9 +1,11 @@
pub mod components; pub mod components;
pub mod demo;
pub mod events; pub mod events;
pub mod executor; pub mod executor;
pub mod idle; pub mod idle;
pub use components::{IdleState, Task, TaskQueue, TaskState}; pub use components::{IdleState, Task, TaskQueue, TaskState};
pub use demo::{demo_system, DemoState};
pub use events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed}; pub use events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed};
pub use executor::task_executor_system; pub use executor::task_executor_system;
+11 -4
View File
@@ -1,10 +1,12 @@
//! TasksPlugin — registers task infrastructure: executor, events, idle logic. //! TasksPlugin — registers task infrastructure: executor, events, idle logic, demo loop.
//! //!
//! Systems: //! Systems:
//! - task_executor_system (FixedUpdate, after pathfinding movement setup) //! - task_executor_system (FixedUpdate)
//! - demo_system (FixedUpdate, after task_executor_system)
use crate::entities::tasks::{ use crate::entities::tasks::{
task_executor_system, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed, demo_system, task_executor_system, DemoState, TaskBlocked, TaskClaimed, TaskCompleted,
TaskDropped, TaskFailed,
}; };
use bevy::prelude::*; use bevy::prelude::*;
@@ -16,6 +18,11 @@ impl Plugin for TasksPlugin {
.add_message::<TaskCompleted>() .add_message::<TaskCompleted>()
.add_message::<TaskFailed>() .add_message::<TaskFailed>()
.add_message::<TaskDropped>() .add_message::<TaskDropped>()
.add_message::<TaskBlocked>(); .add_message::<TaskBlocked>()
.init_resource::<DemoState>()
.add_systems(
bevy::app::FixedUpdate,
demo_system.after(task_executor_system),
);
} }
} }