diff --git a/src/entities/cargo/log.rs b/src/entities/cargo/log.rs index 9fc9784..67703c5 100644 --- a/src/entities/cargo/log.rs +++ b/src/entities/cargo/log.rs @@ -7,8 +7,10 @@ //! placeholder until a dedicated ground-log sprite exists. 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::item::constants::ITEM_Z_FIGHTING_OFFSET; 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. 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 -/// nearest free tile via TileMap::find_nearest_free_cargo_tile. +/// `tile_pos` — world position of the trunk tile this log came from. +/// `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( commands: &mut Commands, tilemap: &mut TileMap, tile_pos: IVec3, + fall_direction: Vec2, log_sprite: Handle, + rng: &mut WyRand, ) -> Option { - // Find a free tile — the exact trunk position may be occupied - let drop_pos = tilemap.find_nearest_free_cargo_tile(tile_pos, 4)?; + // Distance along fall direction: 0–3 tiles + 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 .spawn(( diff --git a/src/entities/tasks/demo.rs b/src/entities/tasks/demo.rs new file mode 100644 index 0000000..06504cc --- /dev/null +++ b/src/entities/tasks/demo.rs @@ -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, + }, +} + +/// 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, + tilemap: Res, + chunk_map: Res, + tree_parts: Query<(Entity, &TreePart)>, + cargo_query: Query<(Entity, &Cargo), With>, + 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 = 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 = 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 { .. })) +} diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs index b4d0712..364e817 100644 --- a/src/entities/tasks/executor.rs +++ b/src/entities/tasks/executor.rs @@ -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; } } diff --git a/src/entities/tasks/mod.rs b/src/entities/tasks/mod.rs index b7fa3eb..536d0c9 100644 --- a/src/entities/tasks/mod.rs +++ b/src/entities/tasks/mod.rs @@ -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; diff --git a/src/plugins/tasks.rs b/src/plugins/tasks.rs index 4b8dcd7..63ee664 100644 --- a/src/plugins/tasks.rs +++ b/src/plugins/tasks.rs @@ -1,10 +1,12 @@ -//! TasksPlugin — registers task infrastructure: executor, events, idle logic. +//! TasksPlugin — registers task infrastructure: executor, events, idle logic, demo loop. //! //! Systems: -//! - task_executor_system (FixedUpdate, after pathfinding movement setup) +//! - task_executor_system (FixedUpdate) +//! - demo_system (FixedUpdate, after task_executor_system) 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::*; @@ -16,6 +18,11 @@ impl Plugin for TasksPlugin { .add_message::() .add_message::() .add_message::() - .add_message::(); + .add_message::() + .init_resource::() + .add_systems( + bevy::app::FixedUpdate, + demo_system.after(task_executor_system), + ); } }