Implement async job pathfinding system

- Add calculate_traversal_distance() - full A* that returns actual distance
- Add batch_calculate_traversals() for efficient batch processing
- Add Calculating state to JobState enum for dorf locking
- Add approach_target and retry_count to job Entry
- Add helper methods: is_dorf_locked, get_locked_dorfs, get_scope_for_job
- Add job_assignment config section with max_dorfs_per_job
- Create job_pathfinding.rs with main pathfinding system:
  - Throttled to MAX_JOBS_PER_TICK (5) per frame
  - Expanding scope: 5 -> 20 -> 200 dorfs based on retry count
  - Uses full pathfinding, not provisional
  - Pre-computes approach_target for assigned dorfs
- Simplify job_assignment.rs to only handle idle fallback
- Update has_fell_tree to check state (Unclaimed, Calculating, Claimed)
This commit is contained in:
2026-03-29 13:13:53 +01:00
parent 3c74a68d4d
commit 053fe415bf
8 changed files with 506 additions and 131 deletions
+232
View File
@@ -0,0 +1,232 @@
use bevy::prelude::*;
use smallvec::SmallVec;
use crate::config::GameConfig;
use crate::entities::behaviour::EntityType;
use crate::entities::shared_components::Ambulatory;
use crate::entities::shared_systems::pathfinding::calculate_traversal_distance;
use crate::entities::tasks::components::{ChopStep, HaulStep, Task, TaskQueue, TaskState};
use crate::entities::tasks::job_queue::{JobId, JobKind, JobQueue, JobState};
use crate::world::tiles::TileMap;
const MAX_JOBS_PER_TICK: usize = 5;
pub fn job_pathfinding_system(
mut job_queue: ResMut<JobQueue>,
config: Res<GameConfig>,
tilemap: Res<TileMap>,
mut dorf_query: Query<
(
Entity,
&mut TaskQueue,
&mut TaskState,
&Transform,
&mut Ambulatory,
),
With<EntityType>,
>,
) {
let mut unclaimed: Vec<usize> = job_queue.iter_unclaimed().map(|(idx, _)| idx).collect();
if unclaimed.is_empty() {
return;
}
unclaimed.sort_by(|&a, &b| {
let pri_a = job_queue.get_job_priority(a).unwrap_or(0);
let pri_b = job_queue.get_job_priority(b).unwrap_or(0);
pri_b.cmp(&pri_a)
});
let locked_dorfs = job_queue.get_locked_dorfs();
for job_idx in unclaimed.into_iter().take(MAX_JOBS_PER_TICK) {
let (kind, state, _) = match job_queue.get_job_at(job_idx) {
Some(k) => k,
None => continue,
};
if !matches!(state, JobState::Unclaimed) {
continue;
}
let kind = kind.clone();
let target_pos = kind.target();
let scope = job_queue.get_scope_for_job(job_idx);
let mut candidate_dorfs: Vec<(Entity, IVec3)> = dorf_query
.iter_mut()
.filter(|(entity, queue, state, transform, _)| {
if locked_dorfs.contains(entity) {
return false;
}
let pos = transform.translation.as_ivec3();
if !tilemap.is_standable(pos) {
return false;
}
if !queue.is_empty() {
return false;
}
let state_val: &TaskState = &*state;
if matches!(state_val, TaskState::Active) {
return false;
}
true
})
.map(|(entity, _, _, transform, _)| (entity, transform.translation.as_ivec3()))
.collect();
candidate_dorfs.sort_by_key(|(_, pos)| {
(pos.x - target_pos.x).abs()
+ (pos.y - target_pos.y).abs()
+ (pos.z - target_pos.z).abs()
});
let dorfs_to_try: Vec<_> = candidate_dorfs.into_iter().take(scope as usize).collect();
if dorfs_to_try.is_empty() {
continue;
}
let dorf_entities: Vec<Entity> = dorfs_to_try.iter().map(|(e, _)| *e).collect();
if !job_queue.set_job_calculating(job_idx, dorf_entities.clone()) {
continue;
}
let mut best_result: Option<(Entity, IVec3, i32)> = None;
match kind {
JobKind::FellTree { trunk_pos } => {
let approach_tiles = find_all_standable_adjacent(&trunk_pos, &tilemap);
if approach_tiles.is_empty() {
job_queue.suspend_job(job_idx);
continue;
}
for (dorf_entity, dorf_pos) in &dorfs_to_try {
let mut best_for_dorf: Option<(IVec3, i32)> = None;
for approach_tile in &approach_tiles {
if let Ok(distance) =
calculate_traversal_distance(&tilemap, *dorf_pos, *approach_tile)
{
match best_for_dorf {
None => best_for_dorf = Some((*approach_tile, distance)),
Some((_, best_dist)) if distance < best_dist => {
best_for_dorf = Some((*approach_tile, distance));
}
_ => {}
}
}
}
if let Some((approach, dist)) = best_for_dorf {
match best_result {
None => best_result = Some((*dorf_entity, approach, dist)),
Some((_, _, best_dist)) if dist < best_dist => {
best_result = Some((*dorf_entity, approach, dist));
}
_ => {}
}
}
}
}
JobKind::HaulCargo { cargo_pos, .. } => {
for (dorf_entity, dorf_pos) in &dorfs_to_try {
if let Ok(distance) =
calculate_traversal_distance(&tilemap, *dorf_pos, cargo_pos)
{
match best_result {
None => best_result = Some((*dorf_entity, cargo_pos, distance)),
Some((_, _, best_dist)) if distance < best_dist => {
best_result = Some((*dorf_entity, cargo_pos, distance));
}
_ => {}
}
}
}
}
}
if let Some((dorf_entity, approach_target, _)) = best_result {
if let Some(job_id) = job_queue.assign_job(job_idx, dorf_entity, approach_target) {
if let Ok((_, mut queue, mut state, _, mut ambulatory)) =
dorf_query.get_mut(dorf_entity)
{
let job_kind = match job_queue.get_job_kind_at(job_idx) {
Some(k) => k,
None => continue,
};
let task = match job_kind {
JobKind::FellTree { trunk_pos } => Task::ChopTree {
job_id,
trunk_pos,
chop_ticks: 120,
step: ChopStep::MovingToTree {
approach: Some(approach_target),
},
},
JobKind::HaulCargo {
cargo_entity,
cargo_pos,
dest,
} => Task::HaulCargo {
job_id,
cargo_entity,
cargo_pos,
dest,
step: HaulStep::MovingToCargo {
approach: Some(approach_target),
},
},
};
queue.clear();
queue.push(task);
*state = TaskState::Pending;
ambulatory.current_path = None;
ambulatory.target = None;
ambulatory.path_index = 0;
info!(
"[PATHFIND] Assigned job {:?} to dorf {:?} with approach {:?}",
job_id, dorf_entity, approach_target
);
}
}
} else {
job_queue.increment_retry(job_idx);
if !dorf_entities.is_empty() {
job_queue.unclaim_jobs_for_entity(dorf_entities[0]);
}
let retry_count = job_queue.get_job_retry_count(job_idx).unwrap_or(0);
if retry_count >= 10 {
job_queue.suspend_job(job_idx);
}
}
}
}
fn find_all_standable_adjacent(trunk_pos: &IVec3, tilemap: &TileMap) -> SmallVec<[IVec3; 8]> {
use crate::constants::ITILE_SIZE;
let mut tiles = SmallVec::new();
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
}