Task Infrastructure — Task Enum, Queue, Events, Executor

This commit is contained in:
2026-03-22 00:35:22 +00:00
parent 458068fb87
commit d61baaf1ba
9 changed files with 445 additions and 0 deletions
+1
View File
@@ -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::*;
+13
View File
@@ -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,
}
}
}
@@ -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(),
)
+153
View File
@@ -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<Task> {
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,
}
+40
View File
@@ -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,
}
+141
View File
@@ -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<TaskQueue> + Changed<TaskState> 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<TileMap>,
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
mut query: Query<(
Entity,
&mut TaskQueue,
&mut TaskState,
&mut Ambulatory,
&Transform,
)>,
mut claimed_writer: MessageWriter<TaskClaimed>,
mut completed_writer: MessageWriter<TaskCompleted>,
mut failed_writer: MessageWriter<TaskFailed>,
) {
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;
}
}
}
+80
View File
@@ -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<IVec3> {
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])
}
+8
View File
@@ -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;
+6
View File
@@ -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::<TaskClaimed>()
.add_message::<TaskCompleted>()
.add_message::<TaskFailed>()
.add_message::<TaskDropped>()
.add_message::<TaskBlocked>()
.add_systems(
Startup,
(camera::spawn_panning_camera, cursor::setup_cursor),