diff --git a/src/entities/mod.rs b/src/entities/mod.rs index cdfdc48..02441e2 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -4,5 +4,6 @@ pub mod livestock; pub mod sentient; pub mod shared_components; pub mod shared_systems; +pub mod tasks; pub use shared_systems::*; diff --git a/src/entities/sentient/dorf.rs b/src/entities/sentient/dorf.rs index 9dd44ec..f0f7e53 100644 --- a/src/entities/sentient/dorf.rs +++ b/src/entities/sentient/dorf.rs @@ -3,11 +3,13 @@ use crate::constants::TILE_SIZE; use crate::constants::*; use crate::entities::cargo::{CarryVisualState, HaulSlot}; use crate::entities::shared_components::Ambulatory; +use crate::entities::tasks::{Task, TaskQueue, TaskState}; use crate::game::SpawnDelay; use crate::world::VisibleGameEntity; use bevy::prelude::*; use bevy_rand::prelude::*; use rand::RngExt; +use smallvec::smallvec; #[derive(Bundle)] pub struct Dorf { @@ -17,6 +19,8 @@ pub struct Dorf { visibility: Visibility, haul_slot: HaulSlot, carry_visual: CarryVisualState, + task_queue: TaskQueue, + task_state: TaskState, } impl Dorf { @@ -25,6 +29,7 @@ impl Dorf { let run_speed = 6.; let normal_sprite = asset_server.load("dorf.png"); let carry_sprite = asset_server.load("dorf.png"); + let origin = (position / TILE_SIZE).as_ivec3(); Dorf { ambulatory: Ambulatory { @@ -47,6 +52,14 @@ impl Dorf { visibility: Visibility::Hidden, haul_slot: HaulSlot::default(), carry_visual: CarryVisualState::new(normal_sprite, carry_sprite), + task_queue: TaskQueue { + tasks: smallvec![Task::Idle { + target: origin, + wander_radius: 10, + origin, + }], + }, + task_state: TaskState::Pending, } } } diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index 90a6467..7dd8a1e 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -71,6 +71,7 @@ use crate::entities::shared_systems::constants::{ WALK_SPEED_DIVISOR, }; use crate::entities::shared_systems::occupancy::{rebuild_tile_occupancy, TileOccupancy}; +use crate::entities::tasks::task_executor_system; use crate::world::tiles::tile_changed::{PathfindingDirtyChunks, TileChangedEvent}; use crate::world::tiles::TileMap; use crate::world::{ @@ -236,8 +237,10 @@ impl Plugin for PathfindingPlugin { collect_pathfinding_dirty_chunks, invalidate_paths_on_tile_change, prepare_paths, + // DEPRECATED: Wandering now handled by Task::Idle in task executor update_wandering_targets, movement, + task_executor_system, ) .chain(), ) diff --git a/src/entities/tasks/components.rs b/src/entities/tasks/components.rs new file mode 100644 index 0000000..9778246 --- /dev/null +++ b/src/entities/tasks/components.rs @@ -0,0 +1,153 @@ +//! Task system components — the data backbone for entity behaviour. +//! +//! # Design +//! - Tasks are enum variants with associated data, not trait objects — zero-cost dispatch. +//! - TaskQueue holds a VecDeque of pending tasks; front() is current active task. +//! - TaskState tracks execution phase: Pending → Active → Completed/Failed. +//! - All task data is owned — no references to world state — for cache locality. +//! +//! # Performance +//! - SmallVec<[Task; 4]> for queue — avoids heap alloc for typical short queues. +//! - Task enum is #[repr(u8)] for compact storage and fast matching. +//! - No dynamic dispatch — match on Task variant directly in executor. + +use bevy::prelude::*; +use smallvec::SmallVec; + +/// A single task an entity can execute. +/// +/// Tasks are self-contained: they carry all data needed for execution. +/// The executor matches on the variant and drives behaviour accordingly. +/// +/// # Variant lifecycle +/// - Created by task-generating systems (e.g., "chop that tree") +/// - Pushed to TaskQueue +/// - Popped to Active by executor when ready +/// - Completed/Failed → removed, events fired +#[derive(Clone, Debug, PartialEq)] +#[repr(u8)] +pub enum Task { + /// Idle/wandering behaviour. Refactored from existing wandering system. + /// Target is a standable tile within wander_radius; entity moves toward it. + /// When reached, picks new random target. Never "completes" — cycles forever + /// until replaced by a higher-priority task. + Idle { + /// Current wander target tile (world position). + target: IVec3, + /// Max distance from origin to wander (Chebyshev). + wander_radius: i32, + /// Origin tile for wander bounds. + origin: IVec3, + }, + + /// Move to a tile within "close enough" threshold. + /// Completes when entity is within `threshold_tiles` of target. + /// Uses existing pathfinding infrastructure. + GoTo { + target: IVec3, + /// Completion threshold in tiles (Chebyshev distance). + threshold_tiles: i32, + }, + + /// Placeholder for Stage 3 implementations. + /// Executor should match and log "unimplemented" for now. + ChopTree { + target_fixture: IVec3, + }, + HaulObject { + target_cargo_tile: IVec3, + }, + DropHauled { + drop_tile: IVec3, + }, +} + +impl Task { + /// Returns true if this task is "terminal" — i.e., should be popped from + /// the queue when done, rather than cycled (like Idle). + #[inline] + pub fn is_terminal(&self) -> bool { + !matches!(self, Task::Idle { .. }) + } + + /// Human-readable name for debug logging. + #[inline] + pub fn name(&self) -> &'static str { + match self { + Task::Idle { .. } => "Idle", + Task::GoTo { .. } => "GoTo", + Task::ChopTree { .. } => "ChopTree", + Task::HaulObject { .. } => "HaulObject", + Task::DropHauled { .. } => "DropHauled", + } + } +} + +/// Queue of tasks for an entity. Front of queue is currently executing task. +/// +/// Tasks are pushed to the back; executor pops from front when complete. +/// Higher-priority tasks can be inserted at front via `push_front`. +/// +/// Uses SmallVec to avoid heap allocation for typical queue lengths (<4 tasks). +#[derive(Component, Debug, Default)] +pub struct TaskQueue { + pub tasks: SmallVec<[Task; 4]>, +} + +impl TaskQueue { + #[inline] + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + #[inline] + pub fn current(&self) -> Option<&Task> { + self.tasks.first() + } + + #[inline] + pub fn current_mut(&mut self) -> Option<&mut Task> { + self.tasks.first_mut() + } + + #[inline] + pub fn push(&mut self, task: Task) { + self.tasks.push(task); + } + + #[inline] + pub fn push_front(&mut self, task: Task) { + self.tasks.insert(0, task); + } + + #[inline] + pub fn pop(&mut self) -> Option { + if self.tasks.is_empty() { + None + } else { + Some(self.tasks.remove(0)) + } + } + + #[inline] + pub fn clear(&mut self) { + self.tasks.clear(); + } +} + +/// Tracks execution state of the current task. +/// +/// Allows systems to react to task phase changes without polling TaskQueue. +/// Updated by executor system each tick. +#[derive(Component, Debug, Default, PartialEq, Eq)] +pub enum TaskState { + /// Task is queued but not yet started. + #[default] + Pending, + /// Task is actively being executed this frame. + Active, + /// Task completed successfully — will be popped next tick. + Completed, + /// Task failed or was interrupted — will be popped next tick. + Failed, +} diff --git a/src/entities/tasks/events.rs b/src/entities/tasks/events.rs new file mode 100644 index 0000000..888335b --- /dev/null +++ b/src/entities/tasks/events.rs @@ -0,0 +1,40 @@ +//! Task lifecycle events — for decoupled reaction to task state changes. +//! +//! Events are fired by the executor when a task transitions state. +//! Systems can listen for these to trigger UI updates, logging, or +//! downstream task generation (e.g., "ChopTree completed" → spawn Cargo). + +use crate::entities::tasks::components::Task; +use bevy::prelude::*; + +#[derive(Message, Clone)] +pub struct TaskClaimed { + pub entity: Entity, + pub task: Task, +} + +#[derive(Message, Clone)] +pub struct TaskCompleted { + pub entity: Entity, + pub task: Task, +} + +#[derive(Message, Clone)] +pub struct TaskFailed { + pub entity: Entity, + pub task: Task, + pub reason: &'static str, +} + +#[derive(Message, Clone)] +pub struct TaskDropped { + pub entity: Entity, + pub task: Task, +} + +#[derive(Message, Clone)] +pub struct TaskBlocked { + pub entity: Entity, + pub task: Task, + pub blocker: &'static str, +} diff --git a/src/entities/tasks/executor.rs b/src/entities/tasks/executor.rs new file mode 100644 index 0000000..516f4de --- /dev/null +++ b/src/entities/tasks/executor.rs @@ -0,0 +1,141 @@ +//! Task executor system — the dispatcher that drives entity behaviour. +//! +//! Runs in FixedUpdate. For each entity with TaskQueue: +//! 1. If no active task, pop front of queue → set TaskState::Active +//! 2. Match on active Task variant, execute corresponding logic +//! 3. On completion/failure, update TaskState, fire event, pop task +//! 4. If queue empty after pop, assign default Task::Idle +//! +//! Uses Changed + Changed to minimize queries. + +use crate::constants::ITILE_SIZE; +use crate::entities::shared_components::Ambulatory; +use crate::entities::tasks::components::{Task, TaskQueue, TaskState}; +use crate::entities::tasks::events::{TaskClaimed, TaskCompleted, TaskFailed}; +use crate::entities::tasks::idle::execute_idle; +use crate::world::tiles::tilemap::TileMap; +use bevy::prelude::*; +use bevy_rand::prelude::*; + +/// Main task executor. Runs in FixedUpdate. +pub fn task_executor_system( + mut commands: Commands, + tilemap: Res, + mut rng_q: Query<&mut WyRand, With>, + mut query: Query<( + Entity, + &mut TaskQueue, + &mut TaskState, + &mut Ambulatory, + &Transform, + )>, + mut claimed_writer: MessageWriter, + mut completed_writer: MessageWriter, + mut failed_writer: MessageWriter, +) { + let Ok(mut rng) = rng_q.single_mut() else { + return; + }; + + for (entity, mut queue, mut state, mut ambulatory, transform) in query.iter_mut() { + // If queue empty, assign default Idle task + if queue.is_empty() { + let origin = (transform.translation / ITILE_SIZE as f32).as_ivec3(); + queue.push(Task::Idle { + target: origin, + wander_radius: 10, + origin, + }); + } + + // Promote Pending → Active if needed + if *state == TaskState::Pending && !queue.is_empty() { + *state = TaskState::Active; + if let Some(task) = queue.current() { + claimed_writer.write(TaskClaimed { + entity, + task: task.clone(), + }); + } + } + + // Execute current task if Active + if *state == TaskState::Active { + if let Some(current_task) = queue.current_mut() { + match current_task { + Task::Idle { .. } => { + execute_idle( + entity, + current_task, + transform, + &mut ambulatory, + &tilemap, + &mut rng, + ); + // Idle never completes - it cycles forever + } + Task::GoTo { + target, + threshold_tiles, + } => { + let entity_pos = (transform.translation / ITILE_SIZE as f32).as_ivec3(); + // Chebyshev distance (max of absolute differences) + let distance = (entity_pos.x - target.x) + .abs() + .max((entity_pos.y - target.y).abs()); + + if distance <= *threshold_tiles || !tilemap.is_standable(*target) { + *state = TaskState::Completed; + } else { + // Set target - pathfinding handles the rest + ambulatory.target = Some(Vec3::new( + target.x as f32, + target.y as f32, + transform.translation.z, + )); + ambulatory.current_path = None; + } + } + // Stage 3 placeholders - log and fail for now + Task::ChopTree { .. } | Task::HaulObject { .. } | Task::DropHauled { .. } => { + debug!( + "Task {} unimplemented for entity {:?}", + current_task.name(), + entity + ); + *state = TaskState::Failed; + } + } + } + } + + // Handle task completion/failure + if *state == TaskState::Completed || *state == TaskState::Failed { + if let Some(completed_task) = queue.pop() { + if *state == TaskState::Completed { + completed_writer.write(TaskCompleted { + entity, + task: completed_task.clone(), + }); + } else { + failed_writer.write(TaskFailed { + entity, + task: completed_task.clone(), + reason: "unimplemented", + }); + } + + // If terminal task completed, ensure Idle is queued + if completed_task.is_terminal() && queue.is_empty() { + let origin = (transform.translation / ITILE_SIZE as f32).as_ivec3(); + queue.push(Task::Idle { + target: origin, + wander_radius: 10, + origin, + }); + } + } + *state = TaskState::Pending; + } + } +} diff --git a/src/entities/tasks/idle.rs b/src/entities/tasks/idle.rs new file mode 100644 index 0000000..59dd2fc --- /dev/null +++ b/src/entities/tasks/idle.rs @@ -0,0 +1,80 @@ +//! Idle task implementation — wandering behaviour refactored here. +//! +//! This module contains the logic that was previously in the wandering +//! system. Now it's called by the task executor when Task::Idle is active. + +use crate::constants::ITILE_SIZE; +use crate::entities::shared_components::Ambulatory; +use crate::entities::tasks::components::Task; +use crate::world::tiles::tilemap::TileMap; +use bevy::prelude::*; +use bevy_rand::prelude::*; +use rand::RngExt; + +/// Execute Idle task: set target for wandering, let pathfinding handle movement. +pub(super) fn execute_idle( + entity: Entity, + task: &mut Task, + transform: &Transform, + ambulatory: &mut Ambulatory, + tilemap: &TileMap, + rng: &mut WyRand, +) { + let Task::Idle { + target, + wander_radius, + origin, + } = task + else { + return; + }; + + let entity_pos = (transform.translation / ITILE_SIZE as f32).as_ivec3(); + + // If reached target or target is no longer standable, pick new target + if entity_pos == *target || !tilemap.is_standable(*target) { + if let Some(new_target) = pick_wander_target(origin, *wander_radius, tilemap, rng) { + *target = new_target; + } + } + + // Set ambulatory target - pathfinding system will compute path + ambulatory.target = Some(Vec3::new( + target.x as f32, + target.y as f32, + transform.translation.z, + )); + ambulatory.current_path = None; + ambulatory.path_index = 0; +} + +/// Pick a random standable tile within wander radius of origin. +fn pick_wander_target( + origin: &IVec3, + radius: i32, + tilemap: &TileMap, + rng: &mut WyRand, +) -> Option { + let mut candidates = smallvec::SmallVec::<[IVec3; 16]>::new(); + + // Collect valid tiles within radius + for dx in -radius..=radius { + for dy in -radius..=radius { + let candidate = IVec3::new( + origin.x + dx * ITILE_SIZE, + origin.y + dy * ITILE_SIZE, + origin.z, + ); + if tilemap.is_standable(candidate) { + candidates.push(candidate); + } + } + } + + if candidates.is_empty() { + return None; + } + + let idx = rng.random_range(0..candidates.len()); + Some(candidates[idx]) +} diff --git a/src/entities/tasks/mod.rs b/src/entities/tasks/mod.rs new file mode 100644 index 0000000..b9c0277 --- /dev/null +++ b/src/entities/tasks/mod.rs @@ -0,0 +1,8 @@ +pub mod components; +pub mod events; +pub mod executor; +pub mod idle; + +pub use components::{Task, TaskQueue, TaskState}; +pub use events::{TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed}; +pub use executor::task_executor_system; diff --git a/src/main.rs b/src/main.rs index bd66266..bb518ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use crate::entities::{ cargo::any_hauling, item::{initialize_item_rotation_state, item_tile_management_system, ItemRotationTimer}, shared_systems::digging::dig_system, + tasks::{TaskClaimed, TaskCompleted, TaskFailed, TaskDropped, TaskBlocked}, }; use crate::game::SpawnDelay; @@ -47,6 +48,11 @@ fn main() { .insert_resource(ClearColor(Color::srgb(0., 0., 0.))) .add_plugins(world::WorldPlugin) .add_plugins(entities::pathfinding::PathfindingPlugin) + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() .add_systems( Startup, (camera::spawn_panning_camera, cursor::setup_cursor),