attempt 3
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use image::{GenericImageView, ImageBuffer, Rgba};
|
||||
use image::{ImageBuffer, Rgba};
|
||||
|
||||
const TILE_PX: u32 = 16;
|
||||
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@ initial_chunk_radius = 6
|
||||
vsync = "mailbox"
|
||||
|
||||
[spawn_counts]
|
||||
dorfs = 2
|
||||
dorfs = 5
|
||||
pigs = 0
|
||||
rabbits = 0
|
||||
+27
-33
@@ -20,20 +20,6 @@ use crate::world::VisibleGameEntity;
|
||||
/// Weight of a single log in kg. Enough to encumber a dorf carrying one.
|
||||
pub const LOG_WEIGHT_KG: u32 = 15;
|
||||
|
||||
/// Find the standable surface tile at world XY. Returns the floor tile
|
||||
/// where an entity can stand, or None if not found.
|
||||
/// The standable position IS the floor tile itself (has can_stand_in=true),
|
||||
/// not the air above it.
|
||||
fn find_surface_at(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option<IVec3> {
|
||||
for z in -3i32..=4i32 {
|
||||
let floor_pos = IVec3::new(world_x, world_y, z * ITILE_SIZE);
|
||||
if tilemap.floor_tiles.contains_key(&floor_pos) && tilemap.is_standable(floor_pos) {
|
||||
return Some(floor_pos);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Spawn a Cargo log entity with a directional fall bias.
|
||||
///
|
||||
/// `tile_pos` — world position of the trunk tile this log came from.
|
||||
@@ -56,7 +42,7 @@ pub fn spawn_log_cargo(
|
||||
fall_direction: Vec2,
|
||||
log_sprite: Handle<Image>,
|
||||
rng: &mut WyRand,
|
||||
) -> Option<Entity> {
|
||||
) -> Option<(Entity, IVec3)> {
|
||||
// Distance along fall direction: 0–3 tiles
|
||||
let fall_tiles = rng.random_range(0u32..=3) as f32;
|
||||
|
||||
@@ -65,26 +51,34 @@ pub fn spawn_log_cargo(
|
||||
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_xy_x = tile_pos.x + (offset.x * ITILE_SIZE as f32).round() as i32;
|
||||
let biased_xy_y = tile_pos.y + (offset.y * ITILE_SIZE as f32).round() as i32;
|
||||
|
||||
// Find the actual standable surface at this XY — logs land on the floor
|
||||
let biased_pos = find_surface_at(biased_xy_x, biased_xy_y, tilemap).unwrap_or(IVec3::new(
|
||||
biased_xy_x,
|
||||
biased_xy_y,
|
||||
tile_pos.z,
|
||||
));
|
||||
// Snap to grid
|
||||
let offset_tiles_x = offset.x.round() as i32;
|
||||
let offset_tiles_y = offset.y.round() as i32;
|
||||
let biased_xy_x = tile_pos.x + offset_tiles_x * ITILE_SIZE;
|
||||
let biased_xy_y = tile_pos.y + offset_tiles_y * ITILE_SIZE;
|
||||
|
||||
// Find nearest free tile from biased position (increased radius for scattered logs)
|
||||
// Find the actual standable surface at this XY — start search from tree's Z
|
||||
let mut biased_pos = IVec3::new(biased_xy_x, biased_xy_y, tile_pos.z);
|
||||
|
||||
// Search up and down for a standable tile (air above a solid floor)
|
||||
let start_z_idx = tile_pos.z / ITILE_SIZE;
|
||||
let mut found_surface = false;
|
||||
|
||||
// Search nearest Z first
|
||||
let z_search_order = [0, -1, 1, -2, 2, -3, 3, -4, 4];
|
||||
for dz in z_search_order {
|
||||
let check_pos = IVec3::new(biased_xy_x, biased_xy_y, (start_z_idx + dz) * ITILE_SIZE);
|
||||
if tilemap.is_standable(check_pos) {
|
||||
biased_pos = check_pos;
|
||||
found_surface = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find nearest free tile from biased position
|
||||
let drop_pos = tilemap.find_nearest_free_cargo_tile(biased_pos, 8, &[])?;
|
||||
|
||||
// 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((
|
||||
Cargo {
|
||||
@@ -115,12 +109,12 @@ pub fn spawn_log_cargo(
|
||||
"spawn_log_cargo: tile_pos={:?} drop_pos={:?} entity_z={}",
|
||||
tile_pos,
|
||||
drop_pos,
|
||||
drop_pos.z as f32 / 16.0 - 1.0
|
||||
drop_pos.z as f32 / 16.0
|
||||
);
|
||||
|
||||
tilemap
|
||||
.place_cargo(drop_pos, entity)
|
||||
.expect("place_cargo failed after find_nearest_free_cargo_tile succeeded");
|
||||
|
||||
Some(entity)
|
||||
Some((entity, drop_pos))
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ pub fn spawn_dorfs(
|
||||
let mut rng = WyRand::seed_from_u64(seed);
|
||||
|
||||
for _ in 0..config.spawn_counts.dorfs {
|
||||
let raw_x = rng.random_range(-8.0f32..8.0f32);
|
||||
let raw_y = rng.random_range(-8.0f32..8.0f32);
|
||||
let raw_x = rng.random_range(-512.0f32..512.0f32);
|
||||
let raw_y = rng.random_range(-512.0f32..512.0f32);
|
||||
let grid_x = (raw_x / TILE_SIZE).round() * TILE_SIZE;
|
||||
let grid_y = (raw_y / TILE_SIZE).round() * TILE_SIZE;
|
||||
let grid_z = 35.0 * TILE_SIZE;
|
||||
|
||||
@@ -314,14 +314,10 @@ pub fn prepare_paths(
|
||||
tilemap: Res<TileMap>,
|
||||
chunk_map: Res<ChunkMap>,
|
||||
) {
|
||||
let mut paths_needed = 0;
|
||||
let mut paths_computed = 0;
|
||||
|
||||
for (entity, mut ambulatory, transform) in query.iter_mut() {
|
||||
if ambulatory.current_path.is_some() || ambulatory.target.is_none() {
|
||||
continue;
|
||||
}
|
||||
paths_needed += 1;
|
||||
|
||||
let Some(target) = ambulatory.target else {
|
||||
continue;
|
||||
@@ -338,7 +334,6 @@ pub fn prepare_paths(
|
||||
let path = calculate_path_benchmarked(&tilemap, start, goal);
|
||||
ambulatory.current_path = Some(path);
|
||||
ambulatory.path_index = 0;
|
||||
paths_computed += 1;
|
||||
} else if chunk_distance > PATHFINDER_HIERARCHICAL_THRESHOLD_CHUNKS {
|
||||
let chunk_path = calculate_chunk_path(&chunk_map, start_chunk, goal_chunk);
|
||||
let provisional_goal = goal;
|
||||
@@ -624,6 +619,10 @@ pub fn movement(
|
||||
query
|
||||
.par_iter_mut()
|
||||
.for_each(|(mut ambulatory, mut transform)| {
|
||||
info!(
|
||||
"[PATH] Moving: target={:?} current={:?}",
|
||||
ambulatory.target, transform.translation
|
||||
);
|
||||
let current_pos = transform.translation;
|
||||
if !is_standable_tile(&tilemap, current_pos.as_ivec3()) {
|
||||
// Entities can spawn above the loaded world range (z > Z_ABOVE*TILE_SIZE).
|
||||
@@ -714,45 +713,24 @@ pub fn movement(
|
||||
let forward_2d = our_dir;
|
||||
let left_2d = Vec2::new(-forward_2d.y, forward_2d.x);
|
||||
let right_2d = Vec2::new(forward_2d.y, -forward_2d.x);
|
||||
let cur_z = transform.translation.z;
|
||||
|
||||
let candidates = [
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
(left_2d.x + forward_2d.x).signum() * TILE_SIZE,
|
||||
(left_2d.y + forward_2d.y).signum() * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
(right_2d.x + forward_2d.x).signum() * TILE_SIZE,
|
||||
(right_2d.y + forward_2d.y).signum() * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
right_2d.x * TILE_SIZE,
|
||||
right_2d.y * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
+ Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0),
|
||||
];
|
||||
|
||||
let sidestep = candidates.iter().copied().find(|&c| {
|
||||
@@ -783,45 +761,24 @@ pub fn movement(
|
||||
let forward_2d = our_dir;
|
||||
let left_2d = Vec2::new(-forward_2d.y, forward_2d.x);
|
||||
let right_2d = Vec2::new(forward_2d.y, -forward_2d.x);
|
||||
let cur_z = transform.translation.z;
|
||||
|
||||
let candidates = [
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
(left_2d.x + forward_2d.x).signum() * TILE_SIZE,
|
||||
(left_2d.y + forward_2d.y).signum() * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(left_2d.x * TILE_SIZE, left_2d.y * TILE_SIZE, 0.0),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
(right_2d.x + forward_2d.x).signum() * TILE_SIZE,
|
||||
(right_2d.y + forward_2d.y).signum() * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
snap_to_grid(
|
||||
transform.translation
|
||||
+ Vec3::new(
|
||||
right_2d.x * TILE_SIZE,
|
||||
right_2d.y * TILE_SIZE,
|
||||
0.0,
|
||||
),
|
||||
cur_z,
|
||||
TILE_SIZE,
|
||||
),
|
||||
+ Vec3::new(right_2d.x * TILE_SIZE, right_2d.y * TILE_SIZE, 0.0),
|
||||
];
|
||||
|
||||
let sidestep = candidates.iter().copied().find(|&c| {
|
||||
@@ -945,16 +902,25 @@ fn validate_next_steps(tilemap: &TileMap, path: &[Vec3], start_index: usize, ste
|
||||
}
|
||||
|
||||
fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool {
|
||||
tilemap.is_standable(pos)
|
||||
}
|
||||
let result = tilemap.is_standable(pos);
|
||||
if !result {
|
||||
let chunk_pos = crate::world::chunks::world_to_chunk(pos);
|
||||
let chunk_exists = tilemap.chunks.contains_key(&chunk_pos);
|
||||
let (local_x, local_y, z) = if let Some(c) = tilemap.chunks.get(&chunk_pos) {
|
||||
crate::world::tiles::chunk_data::ChunkData::world_to_local(pos)
|
||||
} else {
|
||||
(0, 0, 0)
|
||||
};
|
||||
|
||||
#[inline]
|
||||
fn snap_to_grid(raw: Vec3, preserve_z: f32, tile_size: f32) -> Vec3 {
|
||||
Vec3::new(
|
||||
(raw.x / tile_size).round() * tile_size,
|
||||
(raw.y / tile_size).round() * tile_size,
|
||||
preserve_z,
|
||||
)
|
||||
let floor_exists = tilemap.floor_tiles.contains_key(&pos);
|
||||
let fixture_exists = tilemap.fixture_tiles.contains_key(&pos);
|
||||
|
||||
info!(
|
||||
"[PATH] is_standable_tile=false: pos={:?} chunk={:?} chunk_loaded={} local=({},{},{}) floor={} fixture={}",
|
||||
pos, chunk_pos, chunk_exists, local_x, local_y, z, floor_exists, fixture_exists
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Sample a random standable tile on an edge of `next_chunk`, picking the one
|
||||
@@ -1103,7 +1069,20 @@ fn directional_chunk_waypoint(
|
||||
#[inline]
|
||||
fn get_tile_weight(tilemap: &TileMap, pos: IVec3) -> u8 {
|
||||
let floor_pos = IVec3::new(pos.x, pos.y, pos.z - ITILE_SIZE);
|
||||
tilemap.get_astar_weight(floor_pos)
|
||||
let weight = tilemap.get_astar_weight(floor_pos);
|
||||
if weight == 100 {
|
||||
let floor_tile = tilemap.floor_tiles.get(&floor_pos);
|
||||
let fixture_tile = tilemap.fixture_tiles.get(&floor_pos);
|
||||
let chunk_pos = crate::world::chunks::world_to_chunk(floor_pos);
|
||||
let chunk_exists = tilemap.chunks.contains_key(&chunk_pos);
|
||||
info!(
|
||||
"[PATH] get_tile_weight=100 (default): pos={:?} floor_pos={:?} chunk_loaded={} floor={:?} fixture={:?}",
|
||||
pos, floor_pos, chunk_exists,
|
||||
floor_tile.map(|f| f.id.clone()),
|
||||
fixture_tile.map(|f| f.id.clone())
|
||||
);
|
||||
}
|
||||
weight
|
||||
}
|
||||
|
||||
/// Calculate movement cost including tile weight.
|
||||
@@ -1119,7 +1098,10 @@ fn calculate_movement_cost(move_dir: IVec3, tile_weight: u8) -> i32 {
|
||||
(1, 1, 0) => 14, // Diagonal
|
||||
(1, 0, 1) | (0, 1, 1) => 42, // Vertical + cardinal
|
||||
(1, 1, 1) => 56, // Vertical + diagonal
|
||||
_ => 0,
|
||||
_ => {
|
||||
info!("[PATH] calculate_movement_cost=0: move_dir={:?}", move_dir);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
if base_cost == 0 {
|
||||
@@ -1274,13 +1256,40 @@ pub fn calculate_provisional_path(
|
||||
) -> Vec<Vec3> {
|
||||
let timer = Instant::now();
|
||||
|
||||
if !is_standable_tile(tilemap, start) {
|
||||
let start_standable = is_standable_tile(tilemap, start);
|
||||
if !start_standable {
|
||||
LOCAL_FAILED_PATHS.with(|f| {
|
||||
*f.borrow_mut() += 1;
|
||||
});
|
||||
info!(
|
||||
"[PATH] FAIL: start not standable start={:?} z_level={} floor={:?} fixture={:?}",
|
||||
start,
|
||||
start.z,
|
||||
tilemap.floor_tiles.get(&start).map(|f| f.id.clone()),
|
||||
tilemap.fixture_tiles.get(&start).map(|f| f.id.clone())
|
||||
);
|
||||
return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)];
|
||||
}
|
||||
|
||||
let goal_standable = is_standable_tile(tilemap, goal);
|
||||
if !goal_standable {
|
||||
info!(
|
||||
"[PATH] WARN: goal not standable goal={:?} z_level={} floor={:?} fixture={:?}",
|
||||
goal,
|
||||
goal.z,
|
||||
tilemap.floor_tiles.get(&goal).map(|f| f.id.clone()),
|
||||
tilemap.fixture_tiles.get(&goal).map(|f| f.id.clone())
|
||||
);
|
||||
}
|
||||
|
||||
if !is_standable_tile(tilemap, goal) {
|
||||
info!(
|
||||
"[PATH] WARN: is_standable check false: goal={:?}, is_standable={}",
|
||||
goal,
|
||||
tilemap.is_standable(goal)
|
||||
);
|
||||
}
|
||||
|
||||
let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
|
||||
|
||||
let result = SCRATCHPAD.with(|s| {
|
||||
@@ -1318,6 +1327,10 @@ pub fn calculate_provisional_path(
|
||||
}
|
||||
|
||||
if nodes_expanded >= node_limit {
|
||||
info!(
|
||||
"[PATH] FAIL: node_limit hit limit={} start={:?} goal={:?} expanded={}",
|
||||
node_limit, start, goal, nodes_expanded
|
||||
);
|
||||
return (Vec::new(), nodes_expanded);
|
||||
}
|
||||
|
||||
@@ -1326,9 +1339,10 @@ pub fn calculate_provisional_path(
|
||||
for &move_dir in &ALLOWED_MOVES {
|
||||
let neighbor_pos = current + move_dir;
|
||||
|
||||
if !is_standable_tile(tilemap, neighbor_pos)
|
||||
|| scratch.closed_set.contains(&neighbor_pos)
|
||||
{
|
||||
if !is_standable_tile(tilemap, neighbor_pos) {
|
||||
continue;
|
||||
}
|
||||
if scratch.closed_set.contains(&neighbor_pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1353,6 +1367,10 @@ pub fn calculate_provisional_path(
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"[PATH] FAIL: no_path_found start={:?} goal={:?} best_node={:?} best_h={}",
|
||||
start, goal, best_node, best_h
|
||||
);
|
||||
(
|
||||
reconstruct_path(&scratch.came_from, best_node),
|
||||
nodes_expanded,
|
||||
|
||||
+41
-386
@@ -1,150 +1,58 @@
|
||||
//! 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 smallvec::SmallVec;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::constants::ITILE_SIZE;
|
||||
use crate::entities::cargo::{Cargo, HaulSlot, Haulable};
|
||||
use crate::entities::tasks::components::{
|
||||
ChopStep, HaulStep, Task, TaskQueue, TaskState, CHOP_TICKS_DEFAULT,
|
||||
};
|
||||
use crate::entities::tasks::events::{LogsSpawned, TaskFailed};
|
||||
use crate::entities::behaviour::EntityType;
|
||||
use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
|
||||
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
|
||||
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. Working through the haul queue.
|
||||
///
|
||||
/// `unassigned` — logs not yet claimed, ordered nearest-to-origin first.
|
||||
/// `in_progress` — dorf entities currently executing a HaulCargo task for this tree.
|
||||
/// A dorf is removed when they return to idle (log dropped, task complete).
|
||||
Hauling {
|
||||
felled_trunk_pos: IVec3,
|
||||
/// Queue of (log_entity, log_tile_pos) not yet assigned.
|
||||
/// Front = highest priority (nearest to origin).
|
||||
unassigned: VecDeque<(Entity, IVec3)>,
|
||||
/// Dorf entities currently hauling a log for this tree.
|
||||
in_progress: 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: both unassigned and in_progress are empty
|
||||
///
|
||||
/// 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>,
|
||||
mut job_queue: ResMut<JobQueue>,
|
||||
tilemap: Res<TileMap>,
|
||||
chunk_map: Res<ChunkMap>,
|
||||
tree_parts: Query<(Entity, &TreePart)>,
|
||||
cargo_query: Query<(Entity, &Cargo), With<Haulable>>,
|
||||
haul_slot_query: Query<&HaulSlot>,
|
||||
mut dorf_query: Query<(Entity, &mut TaskQueue, &mut TaskState, &Transform)>,
|
||||
mut task_failed: MessageReader<TaskFailed>,
|
||||
mut logs_spawned: MessageReader<LogsSpawned>,
|
||||
dorf_query: Query<(&TaskQueue, &TaskState), With<EntityType>>,
|
||||
) {
|
||||
// Handle task failures that should reset the demo state for retry
|
||||
for event in task_failed.read() {
|
||||
if event.reason == "no adjacent standable tile to approach tree" {
|
||||
if let DemoState::Chopping { trunk_pos, chopper } = *demo_state {
|
||||
warn!(
|
||||
"[DEMO] ChopTree failed for tree at {:?} (chopper={:?}): {}, resetting to Idle",
|
||||
trunk_pos, chopper, event.reason
|
||||
);
|
||||
*demo_state = DemoState::Idle;
|
||||
let any_chopping = dorf_query.iter().any(|(queue, state)| {
|
||||
let is_active_or_completing = *state == TaskState::Active || *state == TaskState::Completed;
|
||||
if is_active_or_completing {
|
||||
if let Some(current) = queue.current() {
|
||||
return matches!(current, Task::ChopTree { .. });
|
||||
}
|
||||
}
|
||||
false
|
||||
});
|
||||
|
||||
if !job_queue.has_fell_tree() && !any_chopping {
|
||||
if let Some(trunk_pos) = find_tree_nearest_origin(&tilemap, &chunk_map, &tree_parts) {
|
||||
job_queue.push(JobKind::FellTree { trunk_pos });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle logs spawned from felling — transition to Hauling
|
||||
// Collect events first to avoid double-mutable borrow conflict with demo_state
|
||||
let pending_logs: Vec<_> = logs_spawned.read().collect();
|
||||
for event in pending_logs {
|
||||
if let DemoState::Chopping {
|
||||
trunk_pos,
|
||||
chopper: _,
|
||||
} = *demo_state
|
||||
{
|
||||
// Look up cargo positions from the newly spawned entities
|
||||
let mut unassigned: VecDeque<(Entity, IVec3)> = VecDeque::new();
|
||||
for &log_entity in event.log_entities.iter() {
|
||||
if let Ok((_, cargo)) = cargo_query.get(log_entity) {
|
||||
unassigned.push_back((log_entity, cargo.tile_pos));
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"[DEMO] → Hauling: {} logs spawned from tree at {:?}",
|
||||
unassigned.len(),
|
||||
trunk_pos
|
||||
);
|
||||
*demo_state = DemoState::Hauling {
|
||||
felled_trunk_pos: trunk_pos,
|
||||
unassigned,
|
||||
in_progress: Default::default(),
|
||||
};
|
||||
if !job_queue.is_empty() {
|
||||
let (fell, haul) = job_queue.debug_counts();
|
||||
info!("[QUEUE] FellTree: {}, HaulCargo: {}", fell, haul);
|
||||
}
|
||||
}
|
||||
|
||||
match &mut *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).
|
||||
fn find_tree_nearest_origin(
|
||||
tilemap: &TileMap,
|
||||
chunk_map: &ChunkMap,
|
||||
tree_parts: &Query<(Entity, &TreePart)>,
|
||||
) -> Option<IVec3> {
|
||||
let origin = IVec3::ZERO;
|
||||
|
||||
let mut best: Option<(IVec3, i32)> = None; // (trunk_pos, chebyshev_dist)
|
||||
let mut best_xy: Option<(IVec2, i32)> = None;
|
||||
|
||||
for (_, part) in tree_parts.iter() {
|
||||
if !part.is_trunk {
|
||||
continue;
|
||||
}
|
||||
if !tilemap.fixture_tiles.contains_key(&part.tile_pos) {
|
||||
continue; // already felled
|
||||
continue;
|
||||
}
|
||||
// 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))
|
||||
@@ -158,282 +66,29 @@ pub fn demo_system(
|
||||
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));
|
||||
if best_xy.map_or(true, |(_, best_dist)| dist < best_dist) {
|
||||
best_xy = Some((part.tile_pos.xy(), dist));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((trunk_pos, _)) = best else {
|
||||
// No trees found — nothing to do
|
||||
return;
|
||||
let Some((nearest_xy, _)) = best_xy else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Find the lowest trunk tile (minimum z) for this tree's XY column.
|
||||
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
|
||||
.filter_map(|(_, p)| {
|
||||
if p.is_trunk
|
||||
&& p.tile_pos.x == nearest_xy.x
|
||||
&& p.tile_pos.y == nearest_xy.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.
|
||||
// Don't interrupt a dorf still carrying cargo.
|
||||
let mut best_dorf: Option<(Entity, i32)> = None;
|
||||
let mut considered = 0u32;
|
||||
let mut rejected_busy = 0u32;
|
||||
let mut rejected_hauling = 0u32;
|
||||
|
||||
for (entity, queue, state, transform) in dorf_query.iter() {
|
||||
considered += 1;
|
||||
if !is_idle_dorf(&queue, &state) {
|
||||
rejected_busy += 1;
|
||||
continue;
|
||||
}
|
||||
// Skip dorfs still carrying cargo
|
||||
if haul_slot_query
|
||||
.get(entity)
|
||||
.map(|h| h.is_occupied())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
rejected_hauling += 1;
|
||||
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 {
|
||||
info!(
|
||||
"[DEMO] No idle dorf found for ChopTree (considered={} busy={} hauling={})",
|
||||
considered, rejected_busy, rejected_hauling
|
||||
);
|
||||
return; // no idle dorfs available
|
||||
};
|
||||
|
||||
// Assign ChopTree task
|
||||
if let Ok((_, mut queue, mut state, _)) = dorf_query.get_mut(chopper) {
|
||||
queue.clear();
|
||||
queue.push(Task::ChopTree {
|
||||
trunk_pos: lowest_trunk,
|
||||
chop_ticks: CHOP_TICKS_DEFAULT,
|
||||
step: ChopStep::MovingToTree { approach: None },
|
||||
});
|
||||
*state = TaskState::Pending;
|
||||
}
|
||||
|
||||
*demo_state = DemoState::Chopping {
|
||||
trunk_pos: lowest_trunk,
|
||||
chopper,
|
||||
};
|
||||
|
||||
info!(
|
||||
"[DEMO] → Chopping: chopper={:?} trunk={:?}",
|
||||
chopper, lowest_trunk
|
||||
);
|
||||
}
|
||||
|
||||
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, state, _)) = 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() {
|
||||
*demo_state = DemoState::Idle;
|
||||
warn!("[DEMO] chopper {:?} abandoned ChopTree at {:?} — queue={:?} state={:?}",
|
||||
chopper, trunk_pos,
|
||||
queue.current().map(|t| t.name()),
|
||||
state);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tree is felled — transition to Hauling
|
||||
info!("[DEMO] → Hauling: tree at {:?} felled", trunk_pos);
|
||||
|
||||
// Find all Cargo logs near the trunk position
|
||||
let search_world = LOG_SEARCH_RADIUS_TILES * ITILE_SIZE;
|
||||
let mut logs: SmallVec<[(Entity, IVec3, i32); 8]> = 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)| {
|
||||
// Sort key: Chebyshev distance from origin
|
||||
let dx = cargo.tile_pos.x.abs() / ITILE_SIZE;
|
||||
let dy = cargo.tile_pos.y.abs() / ITILE_SIZE;
|
||||
(e, cargo.tile_pos, dx.max(dy))
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Nearest to origin first — dorfs haul the closest logs first
|
||||
logs.sort_by_key(|(_, _, dist)| *dist);
|
||||
|
||||
if logs.is_empty() {
|
||||
warn!("[DEMO] no logs found after felling {:?}", trunk_pos);
|
||||
*demo_state = DemoState::Idle;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to VecDeque, dropping sort key
|
||||
let unassigned: VecDeque<(Entity, IVec3)> =
|
||||
logs.into_iter().map(|(e, pos, _)| (e, pos)).collect();
|
||||
|
||||
info!("[DEMO] → Hauling: {} logs queued", unassigned.len());
|
||||
|
||||
*demo_state = DemoState::Hauling {
|
||||
felled_trunk_pos: trunk_pos,
|
||||
unassigned,
|
||||
in_progress: FxHashSet::default(),
|
||||
};
|
||||
}
|
||||
|
||||
DemoState::Hauling {
|
||||
felled_trunk_pos,
|
||||
unassigned,
|
||||
in_progress,
|
||||
} => {
|
||||
let felled_trunk_pos = *felled_trunk_pos;
|
||||
|
||||
// Remove dorfs that have returned to idle — their haul is complete
|
||||
in_progress.retain(|&dorf_entity| {
|
||||
dorf_query
|
||||
.get(dorf_entity)
|
||||
.map(|(_, queue, state, _)| !is_idle_dorf(queue, state))
|
||||
.unwrap_or(false) // entity gone = treat as done
|
||||
});
|
||||
|
||||
// Assign idle dorfs to unassigned logs
|
||||
if !unassigned.is_empty() {
|
||||
// Collect idle dorfs sorted by proximity to front of log queue
|
||||
let next_log_pos = unassigned.front().map(|(_, p)| *p).unwrap_or(IVec3::ZERO);
|
||||
|
||||
let mut idle_dorfs: SmallVec<[(Entity, i32); 8]> = dorf_query
|
||||
.iter()
|
||||
.filter(|(_, queue, state, _)| is_idle_dorf(queue, state))
|
||||
.map(|(e, _, _, transform)| {
|
||||
let pos = transform.translation.as_ivec3();
|
||||
let dx = (pos.x - next_log_pos.x).abs() / ITILE_SIZE;
|
||||
let dy = (pos.y - next_log_pos.y).abs() / ITILE_SIZE;
|
||||
(e, dx.max(dy))
|
||||
})
|
||||
.collect();
|
||||
// Sort by distance — nearest dorf gets nearest log
|
||||
idle_dorfs.sort_by_key(|(_, d)| *d);
|
||||
// Drop distance, keep entity
|
||||
let idle_dorfs: SmallVec<[Entity; 8]> =
|
||||
idle_dorfs.into_iter().map(|(e, _)| e).collect();
|
||||
|
||||
// Track destinations reserved this tick to avoid assigning the same tile
|
||||
// to multiple dorfs before any have physically dropped their cargo.
|
||||
let mut reserved: SmallVec<[IVec3; 8]> = SmallVec::new();
|
||||
|
||||
for dorf_entity in idle_dorfs {
|
||||
// Compute haul destination fresh for each assignment,
|
||||
// excluding tiles already reserved this tick.
|
||||
let dest = tilemap
|
||||
.find_nearest_free_cargo_tile(
|
||||
IVec3::ZERO,
|
||||
HAUL_DEST_SEARCH_RADIUS,
|
||||
&reserved,
|
||||
)
|
||||
.unwrap_or(IVec3::ZERO);
|
||||
reserved.push(dest);
|
||||
|
||||
let Some((log_entity, log_pos)) = unassigned.pop_front() else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Verify log still exists and is in cargo_tiles before assigning
|
||||
if !tilemap.cargo_tiles.contains_key(&log_pos) {
|
||||
// Log already picked up by someone else — skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
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 { approach: None },
|
||||
});
|
||||
*state = TaskState::Pending;
|
||||
in_progress.insert(dorf_entity);
|
||||
info!(
|
||||
"[DEMO] assigned HaulCargo log={:?} → dorf={:?} dest={:?}",
|
||||
log_entity, dorf_entity, dest
|
||||
);
|
||||
Some(p.tile_pos)
|
||||
} else {
|
||||
// Couldn't assign — put log back at front of queue
|
||||
unassigned.push_front((log_entity, log_pos));
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
})
|
||||
.min_by_key(|pos| pos.z);
|
||||
|
||||
// Check if all work is done
|
||||
if unassigned.is_empty() && in_progress.is_empty() {
|
||||
info!("[DEMO] → Idle: all hauled, seeking next tree");
|
||||
*demo_state = DemoState::Idle;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 { .. }))
|
||||
}
|
||||
|
||||
/// Debug system — prints full task queue state whenever any TaskQueue changes.
|
||||
/// Only compiles in debug builds.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn debug_task_queues(query: Query<(Entity, &TaskQueue, &TaskState), Changed<TaskQueue>>) {
|
||||
for (entity, queue, state) in query.iter() {
|
||||
let current = queue
|
||||
.current()
|
||||
.map(|t| format!("{}[{:?}]", t.name(), state))
|
||||
.unwrap_or_else(|| format!("EMPTY[{:?}]", state));
|
||||
|
||||
let pending: Vec<&str> = queue.tasks.iter().skip(1).map(|t| t.name()).collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
info!("[TASK] {:?} → {}", entity, current);
|
||||
} else {
|
||||
info!(
|
||||
"[TASK] {:?} → {} pending:[{}]",
|
||||
entity,
|
||||
current,
|
||||
pending.join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
lowest_trunk
|
||||
}
|
||||
|
||||
+284
-54
@@ -8,6 +8,7 @@
|
||||
//!
|
||||
//! Uses Changed<TaskQueue> + Changed<TaskState> to minimise queries.
|
||||
|
||||
use crate::constants::ITILE_SIZE;
|
||||
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
|
||||
use crate::entities::cargo::{Cargo, HaulSlot};
|
||||
use crate::entities::shared_components::Ambulatory;
|
||||
@@ -15,7 +16,8 @@ use crate::entities::tasks::components::{
|
||||
ChopStep, DropStep, HaulStep, IdleState, Task, TaskQueue, TaskState,
|
||||
};
|
||||
use crate::entities::tasks::events::{LogsSpawned, TaskClaimed, TaskCompleted, TaskFailed};
|
||||
use crate::entities::tasks::idle::execute_idle;
|
||||
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
|
||||
use crate::entities::tasks::tasks::idle::execute_idle;
|
||||
use crate::world::chunks::ChunkMap;
|
||||
use crate::world::generation::forestry::{fell_tree, TreePart};
|
||||
use crate::world::tiles::tile_changed::TileChangedEvent;
|
||||
@@ -24,6 +26,7 @@ use crate::world::tiles::TileMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy_rand::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Main task executor. Runs in FixedUpdate.
|
||||
pub fn task_executor_system(
|
||||
@@ -45,6 +48,7 @@ pub fn task_executor_system(
|
||||
&EntityType,
|
||||
Option<&mut HaulSlot>,
|
||||
)>,
|
||||
mut job_queue: ResMut<JobQueue>,
|
||||
mut claimed_writer: MessageWriter<TaskClaimed>,
|
||||
mut completed_writer: MessageWriter<TaskCompleted>,
|
||||
mut failed_writer: MessageWriter<TaskFailed>,
|
||||
@@ -98,6 +102,8 @@ pub fn task_executor_system(
|
||||
// Execute current task if Active
|
||||
if *state == TaskState::Active {
|
||||
if let Some(current_task) = queue.current_mut() {
|
||||
let mut failed_reason: Option<&'static str> = None;
|
||||
|
||||
match current_task {
|
||||
Task::Idle { .. } => {
|
||||
execute_idle(
|
||||
@@ -139,6 +145,10 @@ pub fn task_executor_system(
|
||||
} => match step {
|
||||
ChopStep::MovingToTree { ref mut approach } => {
|
||||
if !tilemap_mut.fixture_tiles.contains_key(trunk_pos) {
|
||||
info!(
|
||||
"TASK FAILED: {:?} for {:?} - {}",
|
||||
current_task, entity, "tree already gone"
|
||||
);
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
task: current_task.clone(),
|
||||
@@ -187,10 +197,15 @@ pub fn task_executor_system(
|
||||
ambulatory.current_path = None;
|
||||
}
|
||||
None => {
|
||||
let reason = "no adjacent standable tile to approach tree";
|
||||
info!(
|
||||
"TASK FAILED: {:?} for {:?} - {}",
|
||||
current_task, entity, reason
|
||||
);
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
task: current_task.clone(),
|
||||
reason: "no adjacent standable tile to approach tree",
|
||||
reason,
|
||||
});
|
||||
*state = TaskState::Failed;
|
||||
continue;
|
||||
@@ -224,8 +239,9 @@ pub fn task_executor_system(
|
||||
// If target is Some, pathfinding is handling movement — nothing to do
|
||||
}
|
||||
ChopStep::Chopping { ticks_remaining } => {
|
||||
info!("[EXECUTOR] Chop tick: {:?}", ticks_remaining);
|
||||
if *ticks_remaining == 0 {
|
||||
let trunk_positions = fell_tree(
|
||||
let (trunk_position, trunk_count) = fell_tree(
|
||||
*trunk_pos,
|
||||
&tree_parts,
|
||||
&mut commands,
|
||||
@@ -243,9 +259,14 @@ pub fn task_executor_system(
|
||||
fall_dir = Vec2::new(1.0, 0.0); // default: fall east
|
||||
}
|
||||
let log_sprite: Handle<Image> = asset_server.load("log_cargo.png");
|
||||
let mut log_entities: SmallVec<[Entity; 8]> = SmallVec::new();
|
||||
for &pos in trunk_positions.iter() {
|
||||
if let Some(log_entity) =
|
||||
let mut log_entities: SmallVec<[(Entity, IVec3); 8]> =
|
||||
SmallVec::new();
|
||||
// Loop from 0 up to the number of trunk segments found
|
||||
for i in 0..trunk_count {
|
||||
// Calculate the position for this specific log by offseting Z
|
||||
let pos = trunk_position + IVec3::new(0, 0, i as i32);
|
||||
|
||||
if let Some((log_entity, drop_pos)) =
|
||||
crate::entities::cargo::spawn_log_cargo(
|
||||
&mut commands,
|
||||
&mut tilemap_mut,
|
||||
@@ -255,14 +276,42 @@ pub fn task_executor_system(
|
||||
&mut rng,
|
||||
)
|
||||
{
|
||||
log_entities.push(log_entity);
|
||||
log_entities.push((log_entity, drop_pos));
|
||||
}
|
||||
}
|
||||
// Emit event so demo can queue HaulCargo tasks for these logs
|
||||
if !log_entities.is_empty() {
|
||||
// Find surface Z at (0,0) - search for floor tile at different Z levels
|
||||
use crate::constants::ITILE_SIZE;
|
||||
let dest_z = (0..=4)
|
||||
.find_map(|z_idx| {
|
||||
let check_pos = IVec3::new(0, 0, z_idx * ITILE_SIZE);
|
||||
if tilemap_mut.floor_tiles.contains_key(&check_pos) {
|
||||
Some((z_idx + 1) * ITILE_SIZE)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(16); // Default to z=16 if no floor found
|
||||
|
||||
let dest = IVec3::new(0, 0, dest_z);
|
||||
|
||||
for (cargo_entity, actual_cargo_pos) in log_entities.iter() {
|
||||
job_queue.push(JobKind::HaulCargo {
|
||||
cargo_entity: *cargo_entity,
|
||||
cargo_pos: *actual_cargo_pos,
|
||||
dest,
|
||||
});
|
||||
info!(
|
||||
"[EXECUTOR] Added HaulCargo for cargo at {:?} -> {:?}",
|
||||
actual_cargo_pos, dest
|
||||
);
|
||||
}
|
||||
logs_spawned_writer.write(LogsSpawned {
|
||||
log_entities,
|
||||
dest: IVec3::ZERO,
|
||||
log_entities: log_entities
|
||||
.iter()
|
||||
.map(|(e, _)| *e)
|
||||
.collect(),
|
||||
dest,
|
||||
});
|
||||
}
|
||||
*step = ChopStep::Done;
|
||||
@@ -294,7 +343,14 @@ pub fn task_executor_system(
|
||||
HaulStep::MovingToCargo { approach } => {
|
||||
use crate::constants::ITILE_SIZE;
|
||||
|
||||
info!("[HAUL] {:?} MovingToCargo: cargo_pos={:?}, approach={:?}, target={:?}",
|
||||
entity, cargo_pos, approach, ambulatory.target);
|
||||
|
||||
if !tilemap_mut.cargo_tiles.contains_key(cargo_pos) {
|
||||
info!(
|
||||
"TASK FAILED: {:?} for {:?} - {}",
|
||||
current_task, entity, "cargo no longer exists"
|
||||
);
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
task: current_task.clone(),
|
||||
@@ -305,40 +361,107 @@ pub fn task_executor_system(
|
||||
}
|
||||
|
||||
if approach.is_none() {
|
||||
// Search for nearest standable tile to approach cargo.
|
||||
// Radius 0 = cargo tile itself (can stand in same tile as cargo).
|
||||
// Radius 1-2 = adjacent tiles if cargo tile is blocked.
|
||||
const NODE_CAP: usize = 1024;
|
||||
let mut frontier: VecDeque<IVec3> = VecDeque::new();
|
||||
let mut visited: std::collections::HashSet<IVec3> =
|
||||
std::collections::HashSet::new();
|
||||
|
||||
let cargo_z = cargo_pos.z;
|
||||
let approach_tile = (0..=2i32).find_map(|radius: i32| {
|
||||
for dx in -radius..=radius {
|
||||
for dy in -radius..=radius {
|
||||
if radius > 0
|
||||
&& dx.abs() != radius
|
||||
&& dy.abs() != radius
|
||||
{
|
||||
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = IVec3::new(
|
||||
let neighbor = IVec3::new(
|
||||
cargo_pos.x + dx * ITILE_SIZE,
|
||||
cargo_pos.y + dy * ITILE_SIZE,
|
||||
cargo_z,
|
||||
);
|
||||
if tilemap_mut.is_standable(candidate) {
|
||||
return Some(candidate);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
for dz in -1i32..=1 {
|
||||
if dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let above = IVec3::new(
|
||||
cargo_pos.x,
|
||||
cargo_pos.y,
|
||||
cargo_z + dz * ITILE_SIZE,
|
||||
);
|
||||
if visited.insert(above) {
|
||||
frontier.push_back(above);
|
||||
}
|
||||
}
|
||||
|
||||
let mut approach_tile: Option<IVec3> = None;
|
||||
|
||||
while let Some(tile) = frontier.pop_front() {
|
||||
if visited.len() > NODE_CAP {
|
||||
break;
|
||||
}
|
||||
|
||||
if !tilemap_mut.is_standable(tile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if tilemap_mut.cargo_tiles.contains_key(&tile) {
|
||||
for dz in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 && dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbor = IVec3::new(
|
||||
tile.x + dx * ITILE_SIZE,
|
||||
tile.y + dy * ITILE_SIZE,
|
||||
tile.z + dz * ITILE_SIZE,
|
||||
);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if tilemap_mut.claimed_tiles.contains_key(&tile) {
|
||||
for dz in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 && dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbor = IVec3::new(
|
||||
tile.x + dx * ITILE_SIZE,
|
||||
tile.y + dy * ITILE_SIZE,
|
||||
tile.z + dz * ITILE_SIZE,
|
||||
);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
approach_tile = Some(tile);
|
||||
break;
|
||||
}
|
||||
|
||||
match approach_tile {
|
||||
Some(tile) => {
|
||||
*approach = Some(tile);
|
||||
info!(
|
||||
"[HAUL] {:?} target set: cargo={:?} approach={:?}",
|
||||
entity, cargo_pos, tile
|
||||
"[HAUL] {:?} setting target: cargo={:?} approach={:?} target={:?}",
|
||||
entity, cargo_pos, tile, ambulatory.target
|
||||
);
|
||||
// +1.0 z-offset for entity standing height (same as trees)
|
||||
ambulatory.target = Some(Vec3::new(
|
||||
tile.x as f32,
|
||||
tile.y as f32,
|
||||
@@ -359,22 +482,26 @@ pub fn task_executor_system(
|
||||
}
|
||||
|
||||
// Check arrival at cargo tile
|
||||
if approach.is_some() && ambulatory.target.is_none() {
|
||||
let dx = transform.translation.x - cargo_pos.x as f32;
|
||||
let dy = transform.translation.y - cargo_pos.y as f32;
|
||||
// Use approach tile position for distance check, not target None
|
||||
if let Some(approach_tile) = *approach {
|
||||
let dx = transform.translation.x - approach_tile.x as f32;
|
||||
let dy = transform.translation.y - approach_tile.y as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
let pickup_range_sq =
|
||||
let arrive_sq =
|
||||
(ITILE_SIZE as f32 * 1.5) * (ITILE_SIZE as f32 * 1.5);
|
||||
if dist_sq <= pickup_range_sq {
|
||||
info!("[HAUL] {:?} arrival check: approach={:?} dist_sq={:.1} arrive_sq={:.1} transform={:?}",
|
||||
entity, approach_tile, dist_sq, arrive_sq, transform.translation.truncate());
|
||||
if dist_sq <= arrive_sq {
|
||||
info!(
|
||||
"[HAUL] {:?} arrived at cargo {:?}, picking up",
|
||||
entity, cargo_entity
|
||||
"[HAUL] {:?} arrived at approach {:?}, picking up cargo at {:?}",
|
||||
entity, approach_tile, cargo_pos
|
||||
);
|
||||
*step = HaulStep::PickingUp;
|
||||
}
|
||||
}
|
||||
}
|
||||
HaulStep::PickingUp => {
|
||||
info!("[HAUL] {:?} PickingUp: cargo_pos={:?}", entity, cargo_pos);
|
||||
if haul.is_occupied() {
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
@@ -390,7 +517,14 @@ pub fn task_executor_system(
|
||||
"[HAUL] {:?} picked up {:?} → hauling to {:?}",
|
||||
entity, cargo_entity, dest
|
||||
);
|
||||
*step = HaulStep::MovingToDest { chosen_drop: None };
|
||||
|
||||
let drop_target =
|
||||
find_drop_tile(&mut *tilemap_mut, *dest, entity);
|
||||
tilemap_mut.claimed_tiles.insert(drop_target, entity);
|
||||
|
||||
*step = HaulStep::MovingToDest {
|
||||
chosen_drop: Some(drop_target),
|
||||
};
|
||||
} else {
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
@@ -401,38 +535,43 @@ pub fn task_executor_system(
|
||||
}
|
||||
}
|
||||
HaulStep::MovingToDest { chosen_drop } => {
|
||||
if chosen_drop.is_none() {
|
||||
*chosen_drop = Some(
|
||||
tilemap_mut
|
||||
.find_nearest_free_cargo_tile(*dest, 8, &[])
|
||||
.unwrap_or(*dest),
|
||||
);
|
||||
}
|
||||
let drop_pos = chosen_drop.unwrap();
|
||||
let drop_pos = *chosen_drop;
|
||||
if let Some(drop) = drop_pos {
|
||||
if ambulatory.target.is_none() {
|
||||
info!("[HAUL] {:?} target set: drop at {:?}", entity, drop_pos);
|
||||
// +1.0 z-offset for entity standing height (same as approach)
|
||||
info!(
|
||||
"[HAUL] {:?} setting target to drop at {:?}",
|
||||
entity, drop
|
||||
);
|
||||
ambulatory.target = Some(Vec3::new(
|
||||
drop_pos.x as f32,
|
||||
drop_pos.y as f32,
|
||||
drop_pos.z as f32 + 1.0,
|
||||
drop.x as f32,
|
||||
drop.y as f32,
|
||||
drop.z as f32 + 1.0,
|
||||
));
|
||||
ambulatory.current_path = None;
|
||||
}
|
||||
|
||||
// Always check arrival distance, not gated by target status
|
||||
let dx = transform.translation.x - drop_pos.x as f32;
|
||||
let dy = transform.translation.y - drop_pos.y as f32;
|
||||
let dx = transform.translation.x - drop.x as f32;
|
||||
let dy = transform.translation.y - drop.y as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
let arrive_sq = (crate::constants::TILE_SIZE as f32 * 1.5)
|
||||
* (crate::constants::TILE_SIZE as f32 * 1.5);
|
||||
if dist_sq <= arrive_sq {
|
||||
ambulatory.target = None;
|
||||
tilemap_mut.claimed_tiles.remove(&drop);
|
||||
info!(
|
||||
"[HAUL] {:?} arrived at drop point {:?}",
|
||||
entity, drop_pos
|
||||
entity, drop
|
||||
);
|
||||
*step = HaulStep::Dropping { drop_pos };
|
||||
*step = HaulStep::Dropping { drop_pos: drop };
|
||||
}
|
||||
} else {
|
||||
failed_writer.write(TaskFailed {
|
||||
entity,
|
||||
task: current_task.clone(),
|
||||
reason: "drop target not set",
|
||||
});
|
||||
*state = TaskState::Failed;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
HaulStep::Dropping { drop_pos } => {
|
||||
@@ -551,3 +690,94 @@ pub fn task_executor_system(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_drop_tile(tilemap: &mut TileMap, dest: IVec3, exclude_entity: Entity) -> IVec3 {
|
||||
const SEARCH_RADIUS: i32 = 8;
|
||||
const NODE_CAP: usize = 1024;
|
||||
|
||||
let mut frontier: VecDeque<IVec3> = VecDeque::new();
|
||||
let mut visited: std::collections::HashSet<IVec3> = std::collections::HashSet::new();
|
||||
|
||||
let dest_z = dest.z;
|
||||
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbor = IVec3::new(dest.x + dx * ITILE_SIZE, dest.y + dy * ITILE_SIZE, dest_z);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for dz in -1i32..=1 {
|
||||
if dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let above = IVec3::new(dest.x, dest.y, dest_z + dz * ITILE_SIZE);
|
||||
if visited.insert(above) {
|
||||
frontier.push_back(above);
|
||||
}
|
||||
}
|
||||
|
||||
while let Some(tile) = frontier.pop_front() {
|
||||
if visited.len() > NODE_CAP {
|
||||
break;
|
||||
}
|
||||
|
||||
if !tilemap.is_standable(tile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if tilemap.cargo_tiles.contains_key(&tile) {
|
||||
for dz in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 && dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbor = IVec3::new(
|
||||
tile.x + dx * ITILE_SIZE,
|
||||
tile.y + dy * ITILE_SIZE,
|
||||
tile.z + dz * ITILE_SIZE,
|
||||
);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if tilemap.claimed_tiles.contains_key(&tile) {
|
||||
let claimant = tilemap.claimed_tiles.get(&tile).copied();
|
||||
if claimant != Some(exclude_entity) {
|
||||
for dz in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 && dz == 0 {
|
||||
continue;
|
||||
}
|
||||
let neighbor = IVec3::new(
|
||||
tile.x + dx * ITILE_SIZE,
|
||||
tile.y + dy * ITILE_SIZE,
|
||||
tile.z + dz * ITILE_SIZE,
|
||||
);
|
||||
if visited.insert(neighbor) {
|
||||
frontier.push_back(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
|
||||
dest
|
||||
}
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
use crate::constants::{ITILE_SIZE, TILE_SIZE};
|
||||
use crate::entities::behaviour::IdleBehaviour;
|
||||
use crate::entities::shared_components::Ambulatory;
|
||||
use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES};
|
||||
use crate::world::chunks::ChunkMap;
|
||||
use crate::world::chunks::CHUNK_SIZE;
|
||||
use crate::world::tiles::TileMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy_rand::prelude::*;
|
||||
use rand::RngExt;
|
||||
|
||||
pub(super) fn execute_idle(
|
||||
task: &mut Task,
|
||||
transform: &Transform,
|
||||
ambulatory: &mut Ambulatory,
|
||||
sprite: &mut Sprite,
|
||||
tilemap: &TileMap,
|
||||
chunk_map: &ChunkMap,
|
||||
behaviour: &IdleBehaviour,
|
||||
rng: &mut WyRand,
|
||||
current_tick: u32,
|
||||
) {
|
||||
let Task::Idle {
|
||||
origin,
|
||||
sigma_world,
|
||||
state,
|
||||
} = task
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
IdleState::Picking {
|
||||
retry_after_tick,
|
||||
retry_count,
|
||||
} => {
|
||||
if current_tick < *retry_after_tick {
|
||||
return;
|
||||
}
|
||||
|
||||
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
|
||||
Some(target) => {
|
||||
ambulatory.target = Some(Vec3::new(
|
||||
target.x as f32,
|
||||
target.y as f32,
|
||||
target.z as f32 + 1.0,
|
||||
));
|
||||
ambulatory.current_path = None;
|
||||
ambulatory.path_index = 0;
|
||||
*state = IdleState::Moving { target };
|
||||
}
|
||||
None => {
|
||||
*retry_count += 1;
|
||||
if *retry_count >= IDLE_MAX_RETRIES {
|
||||
panic!(
|
||||
"Entity stuck: pick_gaussian_target returned None {} times \
|
||||
consecutively. origin={:?} sigma_world={:.1} \
|
||||
loaded_chunks={} \
|
||||
— no standable tile found. Check tilemap state.",
|
||||
retry_count,
|
||||
origin,
|
||||
sigma_world,
|
||||
chunk_map.loaded_chunks.len(),
|
||||
);
|
||||
}
|
||||
*retry_after_tick = current_tick.saturating_add(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IdleState::Moving { target } => {
|
||||
let dx = transform.translation.x - target.x as f32;
|
||||
let dy = transform.translation.y - target.y as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
|
||||
|
||||
let arrived = dist_sq < arrive_threshold_sq;
|
||||
let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
|
||||
|
||||
if arrived || no_nav {
|
||||
ambulatory.target = None;
|
||||
ambulatory.current_path = None;
|
||||
|
||||
let loiter_roll: f32 = rng.random();
|
||||
if loiter_roll < behaviour.loiter_chance {
|
||||
let duration_range = behaviour.loiter_max_ticks - behaviour.loiter_min_ticks;
|
||||
let duration =
|
||||
behaviour.loiter_min_ticks + rng.random_range(0..=duration_range);
|
||||
|
||||
let flip1: f32 = rng.random();
|
||||
let flip2: f32 = rng.random();
|
||||
let flips_remaining = (flip1 < behaviour.flip_chance) as u8
|
||||
+ (flip2 < behaviour.flip_chance) as u8;
|
||||
|
||||
let next_flip_at = if flips_remaining > 0 {
|
||||
current_tick.saturating_add(rng.random_range(1..=duration / 2))
|
||||
} else {
|
||||
u32::MAX
|
||||
};
|
||||
|
||||
*state = IdleState::Loitering {
|
||||
ticks_remaining: duration,
|
||||
flips_remaining,
|
||||
next_flip_at,
|
||||
};
|
||||
} else {
|
||||
*state = IdleState::Picking {
|
||||
retry_after_tick: 0,
|
||||
retry_count: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IdleState::Loitering {
|
||||
ticks_remaining,
|
||||
flips_remaining,
|
||||
next_flip_at,
|
||||
} => {
|
||||
if *flips_remaining > 0 && current_tick >= *next_flip_at {
|
||||
sprite.flip_x = !sprite.flip_x;
|
||||
*flips_remaining -= 1;
|
||||
|
||||
if *flips_remaining > 0 && *ticks_remaining > 2 {
|
||||
*next_flip_at =
|
||||
current_tick.saturating_add(rng.random_range(1..=*ticks_remaining / 2));
|
||||
}
|
||||
}
|
||||
|
||||
if *ticks_remaining == 0 {
|
||||
sprite.flip_x = false;
|
||||
*state = IdleState::Picking {
|
||||
retry_after_tick: 0,
|
||||
retry_count: 0,
|
||||
};
|
||||
} else {
|
||||
*ticks_remaining -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_gaussian_target(
|
||||
origin: &IVec3,
|
||||
sigma_world: f32,
|
||||
tilemap: &TileMap,
|
||||
chunk_map: &ChunkMap,
|
||||
rng: &mut WyRand,
|
||||
) -> Option<IVec3> {
|
||||
let two_sigma_sq = 2.0 * sigma_world * sigma_world;
|
||||
let mut chosen: Option<IVec3> = None;
|
||||
let mut weight_sum = 0.0f32;
|
||||
|
||||
for &chunk_pos in chunk_map.loaded_chunks.keys() {
|
||||
let has_all_neighbours = chunk_map
|
||||
.loaded_chunks
|
||||
.contains_key(&(chunk_pos + IVec2::X))
|
||||
&& chunk_map
|
||||
.loaded_chunks
|
||||
.contains_key(&(chunk_pos - IVec2::X))
|
||||
&& chunk_map
|
||||
.loaded_chunks
|
||||
.contains_key(&(chunk_pos + IVec2::Y))
|
||||
&& chunk_map
|
||||
.loaded_chunks
|
||||
.contains_key(&(chunk_pos - IVec2::Y));
|
||||
if !has_all_neighbours {
|
||||
continue;
|
||||
}
|
||||
|
||||
const SAMPLES_PER_CHUNK: usize = 4;
|
||||
for _ in 0..SAMPLES_PER_CHUNK {
|
||||
let local_x = rng.random_range(0..CHUNK_SIZE);
|
||||
let local_y = rng.random_range(0..CHUNK_SIZE);
|
||||
let world_x = (chunk_pos.x * CHUNK_SIZE + local_x) * ITILE_SIZE;
|
||||
let world_y = (chunk_pos.y * CHUNK_SIZE + local_y) * ITILE_SIZE;
|
||||
|
||||
let Some(candidate) = find_surface(world_x, world_y, tilemap) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let dx = (candidate.x - origin.x) as f32;
|
||||
let dy = (candidate.y - origin.y) as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
let weight = (-dist_sq / two_sigma_sq).exp();
|
||||
|
||||
weight_sum += weight;
|
||||
let accept: f32 = rng.random();
|
||||
if accept < weight / weight_sum {
|
||||
chosen = Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chosen
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn find_surface(world_x: i32, world_y: i32, tilemap: &TileMap) -> Option<IVec3> {
|
||||
for z in -3i32..=4i32 {
|
||||
let floor_pos = IVec3::new(world_x, world_y, z * ITILE_SIZE);
|
||||
if tilemap.floor_tiles.contains_key(&floor_pos) {
|
||||
let above = IVec3::new(world_x, world_y, floor_pos.z + ITILE_SIZE);
|
||||
if tilemap.is_standable(above) {
|
||||
return Some(above);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::entities::behaviour::{EntityBehaviourRegistry, EntityType};
|
||||
use crate::entities::cargo::HaulSlot;
|
||||
use crate::entities::shared_components::Ambulatory;
|
||||
use crate::entities::tasks::components::{Task, TaskQueue, TaskState};
|
||||
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
|
||||
use crate::entities::tasks::jobs::{FellTreeJob, HaulCargoJob, IdleJob};
|
||||
use crate::world::tiles::TileMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub fn job_assignment_system(
|
||||
mut job_queue: ResMut<JobQueue>,
|
||||
tilemap: Res<TileMap>,
|
||||
mut dorf_query: Query<
|
||||
(
|
||||
&mut TaskQueue,
|
||||
&mut TaskState,
|
||||
&Transform,
|
||||
&HaulSlot,
|
||||
&mut Ambulatory,
|
||||
),
|
||||
With<EntityType>,
|
||||
>,
|
||||
) {
|
||||
for (mut queue, mut state, transform, haul_slot, mut ambulatory) in dorf_query.iter_mut() {
|
||||
let is_idle = queue.is_empty() || matches!(queue.current(), Some(Task::Idle { .. }));
|
||||
|
||||
if !is_idle {
|
||||
continue;
|
||||
}
|
||||
|
||||
if haul_slot.is_occupied() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dorf_pos_ivec = transform.translation.as_ivec3();
|
||||
let dorf_pos_2d = Vec2::new(transform.translation.x, transform.translation.y);
|
||||
|
||||
if let Some(job) = job_queue.pop_best_pathfinding(&tilemap, dorf_pos_ivec, dorf_pos_2d) {
|
||||
info!(
|
||||
"[ASSIGN] Job assigned to dorf at {:?}: {:?}",
|
||||
dorf_pos_ivec.xy(),
|
||||
job
|
||||
);
|
||||
|
||||
// Stop the dorf in its tracks so the new Task can take over movement.
|
||||
ambulatory.current_path = None;
|
||||
ambulatory.target = None;
|
||||
ambulatory.path_index = 0;
|
||||
|
||||
match job {
|
||||
JobKind::FellTree { trunk_pos } => {
|
||||
info!("[ASSIGN] Dorf assigned to FellTree at {:?}", trunk_pos);
|
||||
queue.clear();
|
||||
queue.push(FellTreeJob::start(trunk_pos));
|
||||
*state = TaskState::Pending;
|
||||
}
|
||||
JobKind::HaulCargo {
|
||||
cargo_entity,
|
||||
cargo_pos,
|
||||
dest,
|
||||
} => {
|
||||
info!("[ASSIGN] Dorf assigned to HaulCargo at {:?}", cargo_pos);
|
||||
queue.clear();
|
||||
queue.push(HaulCargoJob::start(cargo_entity, cargo_pos, dest));
|
||||
*state = TaskState::Pending;
|
||||
}
|
||||
}
|
||||
} else if queue.is_empty() {
|
||||
let behaviour = EntityBehaviourRegistry::global_get("dorf");
|
||||
let origin = transform.translation.as_ivec3();
|
||||
if queue.is_empty() {
|
||||
queue.push(IdleJob::start(origin, &behaviour.idle));
|
||||
*state = TaskState::Pending;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use crate::constants::ITILE_SIZE;
|
||||
use crate::entities::shared_systems::pathfinding::calculate_provisional_path;
|
||||
use bevy::prelude::*;
|
||||
use smallvec::SmallVec;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum JobKind {
|
||||
FellTree {
|
||||
trunk_pos: IVec3,
|
||||
},
|
||||
HaulCargo {
|
||||
cargo_entity: Entity,
|
||||
cargo_pos: IVec3,
|
||||
dest: IVec3,
|
||||
},
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
#[inline]
|
||||
pub fn priority(&self) -> u8 {
|
||||
match self {
|
||||
JobKind::FellTree { .. } => 2,
|
||||
JobKind::HaulCargo { .. } => 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn target(&self) -> IVec3 {
|
||||
match self {
|
||||
JobKind::FellTree { trunk_pos, .. } => *trunk_pos,
|
||||
JobKind::HaulCargo { cargo_pos, .. } => *cargo_pos,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_fell_tree(&self) -> bool {
|
||||
matches!(self, JobKind::FellTree { .. })
|
||||
}
|
||||
#[inline]
|
||||
pub fn is_haul_cargo(&self) -> bool {
|
||||
matches!(self, JobKind::HaulCargo { .. })
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
kind: JobKind,
|
||||
claimed: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct JobQueue {
|
||||
jobs: VecDeque<Entry>,
|
||||
}
|
||||
|
||||
impl JobQueue {
|
||||
#[inline]
|
||||
pub fn push(&mut self, kind: JobKind) {
|
||||
self.jobs.push_back(Entry {
|
||||
kind,
|
||||
claimed: false,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pop_best_pathfinding(
|
||||
&mut self,
|
||||
tilemap: &crate::world::tiles::TileMap,
|
||||
dorf_pos: IVec3,
|
||||
_dorf_pos_2d: Vec2,
|
||||
) -> Option<JobKind> {
|
||||
if self.jobs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1. Gather all unclaimed jobs and score them for sorting.
|
||||
// We store: (Queue Index, Priority, Rough Distance)
|
||||
let mut candidates: Vec<(usize, u8, i32)> = self
|
||||
.jobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| !entry.claimed)
|
||||
.map(|(i, entry)| {
|
||||
let target = entry.kind.target();
|
||||
// Manhattan distance is a cheap heuristic for sorting
|
||||
let dist = (target.x - dorf_pos.x).abs()
|
||||
+ (target.y - dorf_pos.y).abs()
|
||||
+ (target.z - dorf_pos.z).abs();
|
||||
(i, entry.kind.priority(), dist)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 2. Sort: Highest Priority first. If tied, Shortest Distance first.
|
||||
candidates.sort_by(|a, b| {
|
||||
b.1.cmp(&a.1) // Descending priority
|
||||
.then(a.2.cmp(&b.2)) // Ascending distance
|
||||
});
|
||||
|
||||
let max_path_dist = 1024;
|
||||
|
||||
// 3. Evaluate the sorted jobs with actual pathfinding
|
||||
for (idx, _pri, _dist) in candidates {
|
||||
let entry = &self.jobs[idx];
|
||||
|
||||
let best_path_target = match &entry.kind {
|
||||
JobKind::FellTree { trunk_pos } => {
|
||||
let standable_tiles = Self::find_all_standable_adjacent(trunk_pos, tilemap);
|
||||
|
||||
let mut best_target = None;
|
||||
let mut shortest_path_len = usize::MAX;
|
||||
|
||||
// Pathfind to EVERY standable adjacent tile to find the absolute closest one
|
||||
for tile in standable_tiles {
|
||||
let path =
|
||||
calculate_provisional_path(tilemap, dorf_pos, tile, max_path_dist);
|
||||
if !path.is_empty() && path.len() < shortest_path_len {
|
||||
shortest_path_len = path.len();
|
||||
best_target = Some(tile);
|
||||
}
|
||||
}
|
||||
best_target
|
||||
}
|
||||
JobKind::HaulCargo { cargo_pos, .. } => {
|
||||
let path =
|
||||
calculate_provisional_path(tilemap, dorf_pos, *cargo_pos, max_path_dist);
|
||||
if !path.is_empty() {
|
||||
Some(*cargo_pos)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 4. If we found a valid path to this job, claim it and return it
|
||||
if let Some(target_pos) = best_path_target {
|
||||
info!(
|
||||
"[QUEUE] Job Claimed: kind={:?} target={:?} dorf={:?}",
|
||||
entry.kind, target_pos, dorf_pos
|
||||
);
|
||||
|
||||
self.jobs[idx].claimed = true;
|
||||
// Note: JobKind needs `#[derive(Clone)]` if it doesn't have it already
|
||||
return Some(self.jobs[idx].kind.clone());
|
||||
}
|
||||
}
|
||||
|
||||
info!("[QUEUE] No reachable jobs found for dorf at {:?}", dorf_pos);
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns ALL standable tiles immediately adjacent to the tree on the same Z level.
|
||||
// job_queue.rs
|
||||
|
||||
fn find_all_standable_adjacent(
|
||||
trunk_pos: &IVec3,
|
||||
tilemap: &crate::world::tiles::TileMap,
|
||||
) -> SmallVec<[IVec3; 8]> {
|
||||
let mut tiles = SmallVec::new();
|
||||
|
||||
// The standing position is the SAME Z as the trunk.
|
||||
// If trunk is at 16, dorf stands at 16 (in the air).
|
||||
let standing_z = trunk_pos.z;
|
||||
|
||||
for dx in -1i32..=1 {
|
||||
for dy in -1i32..=1 {
|
||||
if dx == 0 && dy == 0 {
|
||||
continue;
|
||||
}
|
||||
let candidate = IVec3::new(
|
||||
trunk_pos.x + dx * ITILE_SIZE,
|
||||
trunk_pos.y + dy * ITILE_SIZE,
|
||||
standing_z,
|
||||
);
|
||||
|
||||
if tilemap.is_standable(candidate) {
|
||||
tiles.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
tiles
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.jobs.is_empty()
|
||||
}
|
||||
#[inline]
|
||||
pub fn len(&self) -> usize {
|
||||
self.jobs.len()
|
||||
}
|
||||
#[inline]
|
||||
pub fn has_fell_tree(&self) -> bool {
|
||||
self.jobs.iter().any(|e| e.kind.is_fell_tree())
|
||||
}
|
||||
|
||||
pub fn debug_counts(&self) -> (usize, usize) {
|
||||
let fell = self.jobs.iter().filter(|e| e.kind.is_fell_tree()).count();
|
||||
let haul = self.jobs.iter().filter(|e| e.kind.is_haul_cargo()).count();
|
||||
(fell, haul)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &JobKind> {
|
||||
self.jobs.iter().map(|e| &e.kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::entities::behaviour::IdleBehaviour;
|
||||
use crate::entities::tasks::components::{ChopStep, HaulStep, IdleState, Task, CHOP_TICKS_DEFAULT};
|
||||
use bevy::prelude::{Entity, IVec3};
|
||||
|
||||
pub struct FellTreeJob;
|
||||
|
||||
impl FellTreeJob {
|
||||
pub fn start(trunk_pos: IVec3) -> Task {
|
||||
Task::ChopTree {
|
||||
trunk_pos,
|
||||
chop_ticks: CHOP_TICKS_DEFAULT,
|
||||
step: ChopStep::MovingToTree { approach: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HaulCargoJob;
|
||||
|
||||
impl HaulCargoJob {
|
||||
pub fn start(cargo_entity: Entity, cargo_pos: IVec3, dest: IVec3) -> Task {
|
||||
Task::HaulCargo {
|
||||
cargo_entity,
|
||||
cargo_pos,
|
||||
dest,
|
||||
step: HaulStep::MovingToCargo { approach: None },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IdleJob;
|
||||
|
||||
impl IdleJob {
|
||||
pub fn start(origin: IVec3, behaviour: &IdleBehaviour) -> Task {
|
||||
Task::Idle {
|
||||
origin,
|
||||
sigma_world: behaviour.sigma_world,
|
||||
state: IdleState::Picking {
|
||||
retry_after_tick: 0,
|
||||
retry_count: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@ pub mod components;
|
||||
pub mod demo;
|
||||
pub mod events;
|
||||
pub mod executor;
|
||||
pub mod idle;
|
||||
pub mod job_assignment;
|
||||
pub mod job_queue;
|
||||
pub mod jobs;
|
||||
pub mod queue_debug;
|
||||
pub mod tasks;
|
||||
|
||||
pub use components::{IdleState, Task, TaskQueue, TaskState};
|
||||
#[cfg(debug_assertions)]
|
||||
pub use demo::debug_task_queues;
|
||||
pub use demo::{demo_system, DemoState};
|
||||
pub use demo::demo_system;
|
||||
pub use events::{LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped, TaskFailed};
|
||||
pub use executor::task_executor_system;
|
||||
pub use job_assignment::job_assignment_system;
|
||||
pub use job_queue::{JobKind, JobQueue};
|
||||
pub use queue_debug::queue_debug_system;
|
||||
|
||||
pub use crate::plugins::tasks::TasksPlugin;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::entities::behaviour::EntityType;
|
||||
use crate::entities::tasks::components::{TaskQueue, TaskState};
|
||||
use crate::entities::tasks::job_queue::{JobKind, JobQueue};
|
||||
use bevy::prelude::*;
|
||||
|
||||
pub fn queue_debug_system(
|
||||
job_queue: Res<JobQueue>,
|
||||
dorf_query: Query<(&TaskQueue, &TaskState, &Transform), With<EntityType>>,
|
||||
) {
|
||||
let (fell_count, haul_count) = job_queue.debug_counts();
|
||||
let total = job_queue.len();
|
||||
|
||||
info!(
|
||||
"=== QUEUE === fell:{}, haul:{}, total:{}",
|
||||
fell_count, haul_count, total
|
||||
);
|
||||
|
||||
for (i, kind) in job_queue.iter().enumerate() {
|
||||
match kind {
|
||||
JobKind::FellTree { trunk_pos } => {
|
||||
info!(" [{}] FellTree at {:?}", i, trunk_pos);
|
||||
}
|
||||
JobKind::HaulCargo {
|
||||
cargo_entity,
|
||||
cargo_pos,
|
||||
dest,
|
||||
} => {
|
||||
info!(
|
||||
" [{}] HaulCargo entity:{:?} from {:?} to {:?}",
|
||||
i, cargo_entity, cargo_pos, dest
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("=== DORFS ===");
|
||||
for (queue, state, transform) in dorf_query.iter() {
|
||||
let pos = transform.translation.truncate();
|
||||
let current_task = queue.current().map(|t| t.name()).unwrap_or("EMPTY");
|
||||
let state_str = match *state {
|
||||
TaskState::Pending => "PENDING",
|
||||
TaskState::Active => "ACTIVE",
|
||||
TaskState::Completed => "COMPLETED",
|
||||
TaskState::Failed => "FAILED",
|
||||
};
|
||||
info!(
|
||||
" Dorf at {:?}: task={} state={} queue_len={}",
|
||||
pos,
|
||||
current_task,
|
||||
state_str,
|
||||
queue.tasks.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::TaskResult;
|
||||
use crate::constants::{ITILE_SIZE, TILE_SIZE};
|
||||
use crate::entities::behaviour::IdleBehaviour;
|
||||
use crate::entities::shared_components::Ambulatory;
|
||||
use crate::entities::tasks::components::{IdleState, Task, IDLE_MAX_RETRIES};
|
||||
use crate::world::chunks::ChunkMap;
|
||||
use crate::world::chunks::CHUNK_SIZE;
|
||||
use crate::world::tiles::TileMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy_rand::prelude::*;
|
||||
use rand::RngExt;
|
||||
|
||||
pub fn execute_idle(
|
||||
task: &mut Task,
|
||||
transform: &Transform,
|
||||
ambulatory: &mut Ambulatory,
|
||||
sprite: &mut Sprite,
|
||||
tilemap: &TileMap,
|
||||
chunk_map: &ChunkMap,
|
||||
behaviour: &IdleBehaviour,
|
||||
rng: &mut WyRand,
|
||||
current_tick: u32,
|
||||
) -> TaskResult {
|
||||
let Task::Idle {
|
||||
origin,
|
||||
sigma_world,
|
||||
state,
|
||||
} = task
|
||||
else {
|
||||
return TaskResult::Failed("not an idle task");
|
||||
};
|
||||
|
||||
match state.clone() {
|
||||
IdleState::Picking {
|
||||
retry_after_tick,
|
||||
retry_count,
|
||||
} => {
|
||||
if current_tick < retry_after_tick {
|
||||
return TaskResult::Continue(0);
|
||||
}
|
||||
|
||||
match pick_gaussian_target(origin, *sigma_world, tilemap, chunk_map, rng) {
|
||||
Some(floor_pos) => {
|
||||
// 1. The PATHFINDER needs to target the air ABOVE the floor
|
||||
let standable_target = floor_pos + IVec3::new(0, 0, ITILE_SIZE);
|
||||
|
||||
// 2. The AMBULATORY system (floats) needs the target + 1.0 offset
|
||||
ambulatory.target = Some(Vec3::new(
|
||||
standable_target.x as f32,
|
||||
standable_target.y as f32,
|
||||
standable_target.z as f32 + 1.0,
|
||||
));
|
||||
|
||||
// 3. Reset movement state
|
||||
ambulatory.current_path = None;
|
||||
ambulatory.path_index = 0;
|
||||
ambulatory.move_direction = Vec2::ZERO;
|
||||
|
||||
// 4. Update the Task State with the STANDABLE target
|
||||
if let Task::Idle {
|
||||
state: ref mut s, ..
|
||||
} = task
|
||||
{
|
||||
*s = IdleState::Moving {
|
||||
target: standable_target,
|
||||
};
|
||||
}
|
||||
|
||||
// Return the required TaskResult
|
||||
TaskResult::Continue(0)
|
||||
}
|
||||
None => {
|
||||
// If no target found, retry later
|
||||
if let Task::Idle {
|
||||
state: ref mut s, ..
|
||||
} = task
|
||||
{
|
||||
*s = IdleState::Picking {
|
||||
retry_after_tick: current_tick + 30,
|
||||
retry_count: retry_count + 1,
|
||||
};
|
||||
}
|
||||
TaskResult::Continue(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IdleState::Moving { target } => {
|
||||
info!(
|
||||
"[IDLE] Moving: target={:?} current={:?}",
|
||||
target, transform.translation
|
||||
);
|
||||
let dx = transform.translation.x - target.x as f32;
|
||||
let dy = transform.translation.y - target.y as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
let arrive_threshold_sq = (TILE_SIZE * 1.5) * (TILE_SIZE * 1.5);
|
||||
|
||||
let arrived = dist_sq < arrive_threshold_sq;
|
||||
let no_nav = ambulatory.target.is_none() && ambulatory.current_path.is_none();
|
||||
|
||||
info!("[IDLE] Moving: arrived={} no_nav={}", arrived, no_nav);
|
||||
if arrived || no_nav {
|
||||
ambulatory.target = None;
|
||||
ambulatory.current_path = None;
|
||||
|
||||
let loiter_roll: f32 = rng.random();
|
||||
if loiter_roll < behaviour.loiter_chance {
|
||||
let duration_range = behaviour.loiter_max_ticks - behaviour.loiter_min_ticks;
|
||||
let duration =
|
||||
behaviour.loiter_min_ticks + rng.random_range(0..=duration_range);
|
||||
|
||||
let flip1: f32 = rng.random();
|
||||
let flip2: f32 = rng.random();
|
||||
let flips_remaining = (flip1 < behaviour.flip_chance) as u8
|
||||
+ (flip2 < behaviour.flip_chance) as u8;
|
||||
|
||||
let next_flip_at = if flips_remaining > 0 {
|
||||
current_tick.saturating_add(rng.random_range(1..=duration / 2))
|
||||
} else {
|
||||
u32::MAX
|
||||
};
|
||||
|
||||
if let Task::Idle {
|
||||
state: ref mut s, ..
|
||||
} = task
|
||||
{
|
||||
*s = IdleState::Loitering {
|
||||
ticks_remaining: duration,
|
||||
flips_remaining,
|
||||
next_flip_at,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if let Task::Idle {
|
||||
state: ref mut s, ..
|
||||
} = task
|
||||
{
|
||||
*s = IdleState::Picking {
|
||||
retry_after_tick: 0,
|
||||
retry_count: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskResult::Continue(0)
|
||||
}
|
||||
|
||||
IdleState::Loitering {
|
||||
ticks_remaining,
|
||||
flips_remaining,
|
||||
next_flip_at,
|
||||
} => {
|
||||
if flips_remaining > 0 && current_tick >= next_flip_at {
|
||||
sprite.flip_x = !sprite.flip_x;
|
||||
}
|
||||
|
||||
if ticks_remaining == 0 {
|
||||
sprite.flip_x = false;
|
||||
if let Task::Idle {
|
||||
state: ref mut s, ..
|
||||
} = task
|
||||
{
|
||||
*s = IdleState::Picking {
|
||||
retry_after_tick: 0,
|
||||
retry_count: 0,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if let Task::Idle {
|
||||
state:
|
||||
IdleState::Loitering {
|
||||
ref mut ticks_remaining,
|
||||
..
|
||||
},
|
||||
..
|
||||
} = task
|
||||
{
|
||||
*ticks_remaining -= 1;
|
||||
}
|
||||
}
|
||||
TaskResult::Continue(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_gaussian_target(
|
||||
origin: &IVec3,
|
||||
sigma_world: f32,
|
||||
tilemap: &TileMap,
|
||||
_chunk_map: &ChunkMap, // No longer needed for O(1) lookups
|
||||
rng: &mut WyRand,
|
||||
) -> Option<IVec3> {
|
||||
if tilemap.surface_positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let two_sigma_sq = 2.0 * sigma_world * sigma_world;
|
||||
let mut chosen: Option<IVec3> = None;
|
||||
let mut weight_sum = 0.0f32;
|
||||
|
||||
// We sample a fixed number of times globally from known surface tiles.
|
||||
// This is O(SAMPLES) instead of O(CHUNKS * SAMPLES * TOTAL_TILES).
|
||||
const TOTAL_SAMPLES: usize = 32;
|
||||
|
||||
for _ in 0..TOTAL_SAMPLES {
|
||||
// 1. Pick a random surface tile directly from the pre-populated list
|
||||
let idx = rng.random_range(0..tilemap.surface_positions.len());
|
||||
let candidate = tilemap.surface_positions[idx];
|
||||
|
||||
// 2. Calculate Gaussian weight based on 2D distance
|
||||
// (Allows dorfs to choose targets on different Z-levels if they are surface tiles)
|
||||
let dx = (candidate.x - origin.x) as f32;
|
||||
let dy = (candidate.y - origin.y) as f32;
|
||||
let dist_sq = dx * dx + dy * dy;
|
||||
|
||||
// Use a small epsilon to prevent exp(0) issues
|
||||
let weight = (-(dist_sq / two_sigma_sq)).exp().max(0.0001);
|
||||
|
||||
// 3. Reservoir Sampling: Update the chosen target based on relative weight
|
||||
weight_sum += weight;
|
||||
if rng.random_range(0.0..1.0) < (weight / weight_sum) {
|
||||
chosen = Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pos) = chosen {
|
||||
info!(
|
||||
"[IDLE] Target picked at {:?} (Weight Sum: {:.4})",
|
||||
pos, weight_sum
|
||||
);
|
||||
}
|
||||
|
||||
chosen
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod idle;
|
||||
|
||||
pub enum TaskResult {
|
||||
Complete,
|
||||
Continue(u32),
|
||||
Failed(&'static str),
|
||||
}
|
||||
+11
-19
@@ -1,13 +1,7 @@
|
||||
//! TasksPlugin — registers task infrastructure: executor, events, idle logic, demo loop.
|
||||
//!
|
||||
//! Systems:
|
||||
//! - task_executor_system (FixedUpdate)
|
||||
//! - demo_system (FixedUpdate, after task_executor_system)
|
||||
//! - debug_task_queues (FixedUpdate, after demo_system, debug only)
|
||||
|
||||
use crate::entities::tasks::{
|
||||
demo_system, task_executor_system, DemoState, LogsSpawned, TaskBlocked, TaskClaimed,
|
||||
TaskCompleted, TaskDropped, TaskFailed,
|
||||
demo_system, job_assignment_system, job_queue::JobQueue, queue_debug_system,
|
||||
task_executor_system, LogsSpawned, TaskBlocked, TaskClaimed, TaskCompleted, TaskDropped,
|
||||
TaskFailed,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -21,17 +15,15 @@ impl Plugin for TasksPlugin {
|
||||
.add_message::<TaskDropped>()
|
||||
.add_message::<TaskBlocked>()
|
||||
.add_message::<LogsSpawned>()
|
||||
.init_resource::<DemoState>()
|
||||
.init_resource::<JobQueue>()
|
||||
.add_systems(
|
||||
bevy::app::FixedUpdate,
|
||||
demo_system.after(task_executor_system),
|
||||
FixedUpdate,
|
||||
(
|
||||
demo_system,
|
||||
job_assignment_system,
|
||||
task_executor_system,
|
||||
queue_debug_system,
|
||||
),
|
||||
);
|
||||
|
||||
// Debug only — compiles away in release
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
use crate::entities::tasks::debug_task_queues;
|
||||
app.add_systems(bevy::app::FixedUpdate, debug_task_queues.after(demo_system));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +292,8 @@ pub fn fell_tree(
|
||||
tilemap: &mut TileMap,
|
||||
tile_changed: &mut MessageWriter<TileChangedEvent>,
|
||||
occlusion: &mut MessageWriter<TileOcclusionEvent>,
|
||||
) -> SmallVec<[IVec3; 8]> {
|
||||
) -> (IVec3, usize) {
|
||||
// Changed return type
|
||||
let target_chunk = world_to_chunk(trunk_pos);
|
||||
let trunk_x = trunk_pos.x;
|
||||
let trunk_y = trunk_pos.y;
|
||||
@@ -333,5 +334,19 @@ pub fn fell_tree(
|
||||
for pos in dirty_columns {
|
||||
occlusion.write(TileOcclusionEvent { tile_position: pos });
|
||||
}
|
||||
trunk_positions
|
||||
|
||||
let trunk_count = trunk_positions.len();
|
||||
// Default to the input trunk_pos if for some reason no trunk parts were found
|
||||
let lowest_trunk = trunk_positions
|
||||
.iter()
|
||||
.min_by_key(|p| p.z)
|
||||
.cloned()
|
||||
.unwrap_or(trunk_pos);
|
||||
|
||||
info!(
|
||||
"[FORESTRY] fell_tree: trunk_pos={:?} lowest={:?} count={}",
|
||||
trunk_pos, lowest_trunk, trunk_count
|
||||
);
|
||||
|
||||
(lowest_trunk, trunk_count)
|
||||
}
|
||||
|
||||
@@ -329,6 +329,10 @@ pub fn apply_terrain_blobs(
|
||||
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
|
||||
}
|
||||
|
||||
for surface in blob.surface_positions.iter() {
|
||||
tilemap.surface_positions.push(surface.0.as_ivec3());
|
||||
}
|
||||
|
||||
forrestry_event_writer.write(ChunkForrestryEvent {
|
||||
chunk_position: blob.chunk_pos,
|
||||
floor_tiles: blob.surface_positions,
|
||||
|
||||
@@ -96,14 +96,11 @@ impl ChunkData {
|
||||
/// - (can_stand_in_floor OR can_stand_in_fixture) at (x, y, z)
|
||||
/// - AND (can_stand_on_floor OR can_stand_on_fixture) at (x, y, z-1)
|
||||
#[inline]
|
||||
pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool {
|
||||
// Bounds check: z must be within -Z_BELOW..Z_ABOVE
|
||||
if z < -(Z_BELOW as i32) || z > (Z_ABOVE as i32) {
|
||||
return false;
|
||||
}
|
||||
// chunkdata.rs
|
||||
|
||||
// Bounds check: local coords must be within chunk
|
||||
if local_x < 0 || local_x >= CHUNK_SIZE || local_y < 0 || local_y >= CHUNK_SIZE {
|
||||
pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool {
|
||||
// 1. Bounds check (z is an index here, e.g., 0 to 64)
|
||||
if z < 0 || z >= (Z_BELOW + Z_ABOVE) as i32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -112,15 +109,15 @@ impl ChunkData {
|
||||
let bit = idx % 32;
|
||||
let mask = 1u32 << bit;
|
||||
|
||||
// Am I in Air?
|
||||
let in_floor = (self.stand_in_floor[word] & mask) != 0;
|
||||
let in_fixture = (self.stand_in_fixture[word] & mask) != 0;
|
||||
|
||||
// Can't stand at the very bottom of the world
|
||||
if z <= -(Z_BELOW as i32) {
|
||||
// 2. Check the tile immediately below (z - 1)
|
||||
if z <= 0 {
|
||||
return false;
|
||||
}
|
||||
} // Bottom of the world
|
||||
|
||||
// Check tile below for "stand on"
|
||||
let below_idx = Self::pos_to_index(local_x, local_y, z - 1);
|
||||
let below_word = below_idx / 32;
|
||||
let below_bit = below_idx % 32;
|
||||
@@ -129,6 +126,7 @@ impl ChunkData {
|
||||
let on_floor = (self.stand_on_floor[below_word] & below_mask) != 0;
|
||||
let on_fixture = (self.stand_on_fixture[below_word] & below_mask) != 0;
|
||||
|
||||
// Logic: Current tile is passable AND tile below is solid
|
||||
(in_floor || in_fixture) && (on_floor || on_fixture)
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,12 @@ pub struct TileMap {
|
||||
/// One Cargo entity per tile position. Enforces single-occupancy.
|
||||
/// Cargo does not affect standability — purely for lookup and placement validation.
|
||||
pub cargo_tiles: FxHashMap<IVec3, Entity>,
|
||||
/// Tiles claimed by dorfs for drop destinations. Prevents multiple dorfs
|
||||
/// from targeting the same drop tile. Maps tile position -> entity doing the claim.
|
||||
pub claimed_tiles: FxHashMap<IVec3, Entity>,
|
||||
/// Surface tile positions for idle pathfinding and valid spawn targets.
|
||||
/// Populated from TerrainBlob during terrain processing.
|
||||
pub surface_positions: Vec<IVec3>,
|
||||
}
|
||||
|
||||
impl TileMap {
|
||||
|
||||
Reference in New Issue
Block a user