feat: stage 2 collision, TileChangedEvent, path invalidation, rabbit digging

- Stage 2 collision: convoy skip when entities move same direction (dot>0.7),
  E/S yield rule for crossings, W/N right-of-way, head-on unchanged
- TileChangedEvent (Message) + PathfindingDirtyChunks (Resource) in new tile_changed module
- collect_pathfinding_dirty_chunks / invalidate_paths_on_tile_change /
  clear_pathfinding_dirty_chunks in PathfindingPlugin FixedUpdate chain
- TileMap::remove_floor clears HashMap + ChunkData bitsets + tile_ids
- RabbitDigTimer component: rabbits dig floor below every 5s, fires TileChangedEvent
  and TileOcclusionEvent for path invalidation + rendering
This commit is contained in:
2026-03-21 11:56:51 +00:00
parent d325aa6488
commit 5357a69930
9 changed files with 263 additions and 50 deletions
+155 -4
View File
@@ -64,6 +64,7 @@ use crate::constants::{
PATHFINDER_PROVISIONAL_NODE_LIMIT, PIXEL_RATIO, TILE_SIZE,
};
use crate::entities::shared_systems::occupancy::{rebuild_tile_occupancy, TileOccupancy};
use crate::world::tiles::tile_changed::{PathfindingDirtyChunks, TileChangedEvent};
use crate::world::tiles::TileMap;
use crate::world::{
chunks::CHUNK_SIZE,
@@ -217,10 +218,13 @@ impl Plugin for PathfindingPlugin {
.insert_resource(crate::entities::shared_components::PathRequestCounter::default())
.insert_resource(PathRequestQueue::default())
.init_resource::<TileOccupancy>()
.insert_resource(PathfindingDirtyChunks::default())
.add_systems(
FixedUpdate,
(
rebuild_tile_occupancy,
collect_pathfinding_dirty_chunks,
invalidate_paths_on_tile_change,
prepare_paths,
update_wandering_targets,
movement,
@@ -233,12 +237,58 @@ impl Plugin for PathfindingPlugin {
merge_benchmark_stats,
process_completed_paths,
process_path_queue,
clear_pathfinding_dirty_chunks,
),
)
.add_systems(Update, bench_report_system);
}
}
/// Collects chunk positions from TileChangedEvents into PathfindingDirtyChunks.
/// Runs once per frame, O(events). HashSet deduplicates — many tile changes
/// in the same chunk produce one entry.
pub fn collect_pathfinding_dirty_chunks(
mut events: MessageReader<TileChangedEvent>,
mut dirty: ResMut<PathfindingDirtyChunks>,
) {
for event in events.read() {
let chunk_pos = world_to_chunk(event.pos);
dirty.chunks.insert(chunk_pos);
}
}
/// Clears paths for entities whose upcoming steps pass through a changed chunk.
/// Checks only the next 8 steps (not the full path) for performance.
/// O(entities × 8) regardless of how many tiles changed.
/// Only runs when dirty chunks exist, which is rare in normal gameplay.
pub fn invalidate_paths_on_tile_change(
dirty: Res<PathfindingDirtyChunks>,
mut query: Query<&mut Ambulatory>,
) {
if dirty.chunks.is_empty() {
return;
}
for mut ambulatory in query.iter_mut() {
let Some(ref path) = ambulatory.current_path else {
continue;
};
let check_end = (ambulatory.path_index + 8).min(path.len());
let affected = path[ambulatory.path_index..check_end]
.iter()
.any(|p| dirty.chunks.contains(&world_to_chunk(p.as_ivec3())));
if affected {
ambulatory.current_path = None;
// Keep target — entity will recompute path to same destination
}
}
}
/// Clears PathfindingDirtyChunks at the end of the frame.
/// Must run AFTER invalidate_paths_on_tile_change.
pub fn clear_pathfinding_dirty_chunks(mut dirty: ResMut<PathfindingDirtyChunks>) {
dirty.chunks.clear();
}
pub fn prepare_paths(
mut commands: Commands,
mut queue: ResMut<PathRequestQueue>,
@@ -606,8 +656,29 @@ pub fn movement(
let advance_path: bool;
let move_dir = next_point - transform.translation;
if occupied && move_dir.length_squared() > 0.0 {
let forward_2d = Vec2::new(move_dir.x, move_dir.y).normalize();
let our_dir = Vec2::new(move_dir.x, move_dir.y).normalize();
let their_dir = if occupied {
occupancy.direction_at(next_point)
} else {
Vec2::ZERO
};
// Case 1: Convoy — same direction, treat as unoccupied
let convoy =
occupied && their_dir != Vec2::ZERO && their_dir.dot(our_dir) > 0.7;
// Case 2: Head-on (directly opposite directions)
let head_on = their_dir != Vec2::ZERO && their_dir.dot(our_dir) < -0.7;
let we_are_es = our_dir.x > 0.3 || our_dir.y < -0.3;
if !occupied || convoy {
// Normal movement
actual_move = next_point;
advance_path = true;
} else if occupied && head_on {
// Both yield left — existing sidestep chain
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;
@@ -664,18 +735,98 @@ pub fn movement(
advance_path = true;
let tile_weight =
get_tile_weight(&tilemap, transform.translation.as_ivec3());
let threshold = if ambulatory.walk_speed > 0. {
let excuse_threshold = if ambulatory.walk_speed > 0. {
(ambulatory.walk_speed as i32 * tile_weight as i32 / 50) as u32
} else {
0
};
if ambulatory.step_recovery == 0 {
ambulatory.step_recovery = threshold;
ambulatory.step_recovery = excuse_threshold;
}
}
} else if occupied && we_are_es {
// We yield — sidestep chain first, then excuse-me
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,
),
];
let sidestep = candidates.iter().copied().find(|&c| {
tilemap.is_standable(c.as_ivec3())
&& occupancy.count_at(c) <= current_count
});
if let Some(step) = sidestep {
actual_move = step;
advance_path = false;
} else {
// excuse-me: push through with delay
actual_move = next_point;
advance_path = true;
let tile_weight =
get_tile_weight(&tilemap, transform.translation.as_ivec3());
let excuse_threshold = if ambulatory.walk_speed > 0. {
(ambulatory.walk_speed as i32 * tile_weight as i32 / 50) as u32
} else {
0
};
if ambulatory.step_recovery == 0 {
ambulatory.step_recovery = excuse_threshold;
}
}
} else {
// occupied && we_are_wn — right of way, advance with excuse-me delay
actual_move = next_point;
advance_path = true;
let tile_weight =
get_tile_weight(&tilemap, transform.translation.as_ivec3());
let excuse_threshold = if ambulatory.walk_speed > 0. {
(ambulatory.walk_speed as i32 * tile_weight as i32 / 50) as u32
} else {
0
};
if ambulatory.step_recovery == 0 {
ambulatory.step_recovery = excuse_threshold;
}
}
let direction = (actual_move - transform.translation).normalize_or_zero();