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:
@@ -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(¤t).unwrap_or(&i32::MAX);
|
||||||
|
|
||||||
|
if let Some(&parent) = scratch.came_from.get(¤t) {
|
||||||
|
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(¤t).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(¤t).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) {
|
||||||
|
if scratch.came_from.contains_key(&neighbor_parent) {
|
||||||
|
let np_g = *scratch.g_scores.get(&neighbor_parent).unwrap_or(&i32::MAX);
|
||||||
|
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 {
|
||||||
|
position: neighbor_pos,
|
||||||
|
f_score: f,
|
||||||
|
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.came_from.insert(neighbor_pos, current);
|
||||||
scratch.g_scores.insert(neighbor_pos, new_g);
|
scratch.g_scores.insert(neighbor_pos, via_current_g);
|
||||||
let f = new_g + octile_distance_3d(neighbor_pos, goal);
|
let f = via_current_g + 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_current_g,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user