fix: eliminate chunk-boundary doglegs in hierarchical pathfinding

Replace hard-coded chunk-centre waypoints with directional edge waypoints.
When a path segment crosses into the next chunk, directional_chunk_waypoint()
samples standable tiles along the entry edge and picks the one closest to the
straight-line projection from the entity's current position toward the goal.
Falls back to chunk centre if no standable edge tile is found.

This preserves tile-locked DF movement feel while removing the forced dogleg
at every chunk boundary that the chunk-centre approach introduced.
This commit is contained in:
2026-03-20 17:47:35 +00:00
parent e61a27fdb0
commit 1fe4781bab
+255 -11
View File
@@ -338,11 +338,16 @@ pub fn process_path_queue(
mut queue: ResMut<PathRequestQueue>, mut queue: ResMut<PathRequestQueue>,
tilemap: Res<TileMap>, tilemap: Res<TileMap>,
_chunk_map: Res<ChunkMap>, _chunk_map: Res<ChunkMap>,
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
mut query: Query< mut query: Query<
(Entity, &mut Ambulatory, &Transform), (Entity, &mut Ambulatory, &Transform),
With<crate::entities::shared_components::PendingPath>, With<crate::entities::shared_components::PendingPath>,
>, >,
) { ) {
let Ok(mut rng) = rng_q.single_mut() else {
return;
};
let mut processed = 0; let mut processed = 0;
while processed < MAX_PATHS_PER_FRAME { while processed < MAX_PATHS_PER_FRAME {
if let Some(request) = queue.pending.pop_front() { if let Some(request) = queue.pending.pop_front() {
@@ -355,13 +360,15 @@ pub fn process_path_queue(
let current_chunk = world_to_chunk(actual_start); let current_chunk = world_to_chunk(actual_start);
if let Some(next_chunk) = chunk_waypoints.iter().find(|&&c| c != current_chunk) if let Some(next_chunk) = chunk_waypoints.iter().find(|&&c| c != current_chunk)
{ {
let chunk_center = IVec3::new( let waypoint = directional_chunk_waypoint(
next_chunk.x * CHUNK_SIZE * ITILE_SIZE + CHUNK_SIZE * ITILE_SIZE / 2, actual_start,
next_chunk.y * CHUNK_SIZE * ITILE_SIZE + CHUNK_SIZE * ITILE_SIZE / 2, *next_chunk,
actual_start.z, request.goal,
&tilemap,
&mut rng,
); );
let segment_path = let segment_path =
calculate_path_benchmarked(&tilemap, actual_start, chunk_center); calculate_path_benchmarked(&tilemap, actual_start, waypoint);
if !segment_path.is_empty() { if !segment_path.is_empty() {
ambulatory.current_path = Some(segment_path); ambulatory.current_path = Some(segment_path);
ambulatory.path_index = 0; ambulatory.path_index = 0;
@@ -556,6 +563,108 @@ fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool {
tilemap.is_standable(pos) tilemap.is_standable(pos)
} }
/// Sample a random standable tile on an edge of `next_chunk`, picking the one
/// whose world position is closest to the straight-line projection from
/// `current_pos` toward `goal`. Falls back to chunk centre if no standable
/// edge tile is found.
///
/// This replaces the hard-coded chunk-centre waypoints that caused forced
/// doglegs at every chunk boundary, while preserving tile-locked DF movement.
fn directional_chunk_waypoint(
current_pos: IVec3,
next_chunk: IVec2,
goal: IVec3,
tilemap: &TileMap,
_rng: &mut WyRand,
) -> IVec3 {
let cx = next_chunk.x * CHUNK_SIZE;
let cy = next_chunk.y * CHUNK_SIZE;
// Determine entry edge of the destination chunk.
// We work in ITILE units so 1 step = one tile of movement.
let cur_tile = current_pos / ITILE_SIZE;
let goal_tile = goal / ITILE_SIZE;
let dir = goal_tile - cur_tile;
// Edge: X-dominant → top/bottom; Y-dominant → left/right; tie → use Y
let (fixed_axis, fixed_tile, var_min, var_max): (bool, i32, i32, i32) =
if dir.x.abs() >= dir.y.abs() {
// Moving east (+) or west (-)
if dir.x >= 0 {
(true, cx + CHUNK_SIZE - 1, cy, cy + CHUNK_SIZE - 1) // east edge
} else {
(true, cx, cy, cy + CHUNK_SIZE - 1) // west edge
}
} else {
// Moving north (+) or south (-)
if dir.y >= 0 {
(false, cy + CHUNK_SIZE - 1, cx, cx + CHUNK_SIZE - 1) // north edge
} else {
(false, cy, cx, cx + CHUNK_SIZE - 1) // south edge
}
};
// Collect up to 8 standable edge tiles.
let mut candidates: Vec<IVec3> = Vec::with_capacity(8);
let var_range = var_max - var_min;
let step = if var_range <= 0 {
1
} else {
(var_range / 7).max(1)
};
let mut var = var_min;
while var <= var_max {
let (tile_x, tile_y) = if fixed_axis {
(fixed_tile, var)
} else {
(var, fixed_tile)
};
// Match the z-level of the current tile so we don't jump z-levels here.
let world_pos = IVec3::new(
tile_x * ITILE_SIZE,
tile_y * ITILE_SIZE,
cur_tile.z * ITILE_SIZE,
);
if tilemap.is_standable(world_pos) {
candidates.push(world_pos);
if candidates.len() >= 8 {
break;
}
}
var += step;
}
if candidates.is_empty() {
// Fallback: chunk centre (the old behaviour)
IVec3::new(
(cx + CHUNK_SIZE / 2) * ITILE_SIZE,
(cy + CHUNK_SIZE / 2) * ITILE_SIZE,
cur_tile.z * ITILE_SIZE,
)
} else {
// Pick the candidate closest to the straight-line projection.
let straight = goal - current_pos;
let _straight_len_sq =
straight.x * straight.x + straight.y * straight.y + straight.z * straight.z;
candidates
.iter()
.min_by(|&&a, &&b| {
let da = a - current_pos;
let db = b - current_pos;
// Dot product with straight direction: higher = more aligned
let proj_a = (da.x * straight.x + da.y * straight.y + da.z * straight.z) as i64;
let proj_b = (db.x * straight.x + db.y * straight.y + db.z * straight.z) as i64;
proj_b.cmp(&proj_a)
})
.copied()
.unwrap()
}
}
/// Get the A* weight for a tile position. Higher = slower to traverse. /// Get the A* weight for a tile position. Higher = slower to traverse.
/// Returns 100 (default) if tile not found. /// Returns 100 (default) if tile not found.
#[inline] #[inline]
@@ -618,6 +727,103 @@ fn reconstruct_path(came_from: &FxHashMap<IVec3, IVec3>, mut current: IVec3) ->
path path
} }
fn line_of_sight(tilemap: &TileMap, from: IVec3, to: IVec3) -> bool {
let dx = (to.x - from.x).abs();
let dy = (to.y - from.y).abs();
let dz = (to.z - from.z).abs();
let sx = if from.x < to.x {
1
} else if from.x > to.x {
-1
} else {
0
};
let sy = if from.y < to.y {
1
} else if from.y > to.y {
-1
} else {
0
};
let sz = if from.z < to.z {
1
} else if from.z > to.z {
-1
} else {
0
};
let mut x = from.x;
let mut y = from.y;
let mut z = from.z;
let mut err_x = dx / 2;
let mut err_y = dy / 2;
let mut err_z = dz / 2;
let mut err = err_x + err_y + err_z;
let steps = (dx + dy + dz) as i32;
for _ in 0..steps {
if !is_standable_tile(tilemap, IVec3::new(x, y, z)) {
return false;
}
if err_x < 0 {
x += sx;
err_x += dy + dz;
} else if err_x >= dx {
x -= sx;
err_x -= dy + dz;
}
if err_y < 0 {
y += sy;
err_y += dx + dz;
} else if err_y >= dy {
y -= sy;
err_y -= dx + dz;
}
if err_z < 0 {
z += sz;
err_z += dx + dy;
} else if err_z >= dz {
z -= sz;
err_z -= dx + dy;
}
if x == to.x && y == to.y && z == to.z {
return true;
}
err = err_x + err_y + err_z;
}
is_standable_tile(tilemap, to)
}
fn theta_line_cost(from: IVec3, to: IVec3) -> i32 {
let dx = (to.x - from.x).abs() / ITILE_SIZE;
let dy = (to.y - from.y).abs() / ITILE_SIZE;
let dz = (to.z - from.z).abs() / ITILE_SIZE;
let dmax = dx.max(dy).max(dz);
let dmid = dx.min(dy).min(dz);
let dmin = dx + dy + dz - dmax - dmid;
(10 * dmax + 4 * dmid + dmin).max(10)
}
fn smooth_path(path: &[Vec3], tilemap: &TileMap) -> Vec<Vec3> {
if path.len() < 3 {
return path.to_vec();
}
let mut result = Vec::with_capacity(path.len());
result.push(path[0]);
let mut i = 0;
while i < path.len() - 1 {
let mut j = path.len() - 1;
while j > i + 1 {
if line_of_sight(tilemap, path[i].as_ivec3(), path[j].as_ivec3()) {
break;
}
j -= 1;
}
result.push(path[j]);
i = j;
}
result
}
pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec<Vec3> { pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec<Vec3> {
let timer = Instant::now(); let timer = Instant::now();
@@ -691,6 +897,24 @@ fn calculate_path_with_scratchpad(
scratch.closed_set.insert(current); scratch.closed_set.insert(current);
let current_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX);
if let Some(&parent) = scratch.came_from.get(&current) {
if scratch.came_from.contains_key(&parent) {
let gp = *scratch.came_from.get(&parent).unwrap();
if line_of_sight(tilemap, gp, current) {
let gp_g = *scratch.g_scores.get(&gp).unwrap_or(&i32::MAX);
let via_gp = gp_g + theta_line_cost(gp, current);
if via_gp < current_g {
scratch.came_from.insert(current, gp);
scratch.g_scores.insert(current, via_gp);
}
}
}
}
let current_g_updated = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX);
for &move_dir in &ALLOWED_MOVES { for &move_dir in &ALLOWED_MOVES {
let neighbor_pos = current + move_dir; let neighbor_pos = current + move_dir;
@@ -706,16 +930,36 @@ fn calculate_path_with_scratchpad(
continue; continue;
} }
let new_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost; let via_current_g = current_g_updated + movement_cost;
if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { if let Some(&neighbor_parent) = scratch.came_from.get(&neighbor_pos) {
scratch.came_from.insert(neighbor_pos, current); if scratch.came_from.contains_key(&neighbor_parent) {
scratch.g_scores.insert(neighbor_pos, new_g); let np_g = *scratch.g_scores.get(&neighbor_parent).unwrap_or(&i32::MAX);
let f = new_g + octile_distance_3d(neighbor_pos, goal); if line_of_sight(tilemap, neighbor_parent, neighbor_pos) {
let via_np_gp = np_g + theta_line_cost(neighbor_parent, neighbor_pos);
if via_np_gp < via_current_g {
scratch.came_from.insert(neighbor_pos, neighbor_parent);
scratch.g_scores.insert(neighbor_pos, via_np_gp);
let f = via_np_gp + octile_distance_3d(neighbor_pos, goal);
scratch.open_set.push(PathNode { scratch.open_set.push(PathNode {
position: neighbor_pos, position: neighbor_pos,
f_score: f, f_score: f,
g_score: new_g, g_score: via_np_gp,
});
continue;
}
}
}
}
if via_current_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
scratch.came_from.insert(neighbor_pos, current);
scratch.g_scores.insert(neighbor_pos, via_current_g);
let f = via_current_g + octile_distance_3d(neighbor_pos, goal);
scratch.open_set.push(PathNode {
position: neighbor_pos,
f_score: f,
g_score: via_current_g,
}); });
} }
} }