Files
dorf/git_diff_24h.txt
T
popertots e9ef5ebbcd fix: four interlocking pathfinding bugs causing panic, failure, and yo-yo teleportation
1. CSV Overflow Panic: Use saturating_sub to prevent underflow when total_failed_paths > n
2. Heuristic Scale Mismatch: octile_distance_3d now divides by ITILE_SIZE to match g-score units
3. Goal Z Offset: Correct target.as_ivec3() - ivec3(0,0,1) not ITILE_SIZE (target already has +1.0)
4. Gravity Yo-Yo: Clear current_path and target when entity falls to prevent teleportation loop
2026-03-18 17:49:07 +00:00

1901 lines
67 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff --git a/Cargo.lock b/Cargo.lock
index d58b8d3..440fc3a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,9 +400,9 @@ dependencies = [
[[package]]
name = "avif-serialize"
-version = "0.8.3"
+version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e"
+checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d"
dependencies = [
"arrayvec",
]
@@ -415,9 +415,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bevy"
-version = "0.18.0"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec689b5a79452b6f777b889bbff22d3216b82a8d2ab7814d4a0eb571e9938d97"
+checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e"
dependencies = [
"bevy_internal",
]
@@ -1175,9 +1175,9 @@ dependencies = [
[[package]]
name = "bevy_platform"
-version = "0.18.0"
+version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b29ea749a8e85f98186ab662f607b885b97c804bb14cdb0cdf838164496d474"
+checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b"
dependencies = [
"critical-section",
"foldhash 0.2.0",
@@ -1249,9 +1249,9 @@ checksum = "4f98cbc6d34bbdb58240b72ed1731931b4991a893b3a3238bb7c42ae054aa676"
[[package]]
name = "bevy_rand"
-version = "0.14.1"
+version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c76e5db2c274081fdff32a525609436bd7ac2c24b91c1a5d3bc04e43d7558ddf"
+checksum = "526ba09324b96fb64f275a0cb4f6112d36b6c916ce2f14e38d5a8739dd2ac7f9"
dependencies = [
"bevy_app",
"bevy_ecs",
@@ -2331,13 +2331,17 @@ dependencies = [
name = "dorf"
version = "0.1.0"
dependencies = [
+ "ahash",
"bevy",
"bevy_platform",
"bevy_rand",
+ "futures-lite",
"image",
+ "nohash-hasher",
"noise",
"rand 0.10.0",
"rayon",
+ "rustc-hash 2.1.1",
]
[[package]]
@@ -3023,9 +3027,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
-version = "0.25.9"
+version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
@@ -3041,8 +3045,8 @@ dependencies = [
"rayon",
"rgb",
"tiff",
- "zune-core 0.5.1",
- "zune-jpeg 0.5.12",
+ "zune-core",
+ "zune-jpeg",
]
[[package]]
@@ -3057,9 +3061,9 @@ dependencies = [
[[package]]
name = "imgref"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
+checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
[[package]]
name = "indexmap"
@@ -3431,9 +3435,9 @@ dependencies = [
[[package]]
name = "moxcms"
-version = "0.7.5"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
@@ -3566,6 +3570,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
[[package]]
name = "noise"
version = "0.9.0"
@@ -4415,9 +4425,9 @@ dependencies = [
[[package]]
name = "ravif"
-version = "0.12.0"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
@@ -4536,9 +4546,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rgb"
-version = "0.8.50"
+version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "rodio"
@@ -4993,16 +5003,16 @@ dependencies = [
[[package]]
name = "tiff"
-version = "0.10.3"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
- "zune-jpeg 0.4.21",
+ "zune-jpeg",
]
[[package]]
@@ -6468,12 +6478,6 @@ dependencies = [
"syn",
]
-[[package]]
-name = "zune-core"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
-
[[package]]
name = "zune-core"
version = "0.5.1"
@@ -6489,20 +6493,11 @@ dependencies = [
"simd-adler32",
]
-[[package]]
-name = "zune-jpeg"
-version = "0.4.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
-dependencies = [
- "zune-core 0.4.12",
-]
-
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
- "zune-core 0.5.1",
+ "zune-core",
]
diff --git a/Cargo.toml b/Cargo.toml
index ae71471..8522f33 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,13 +4,17 @@ version = "0.1.0"
edition = "2021"
[dependencies]
-bevy = { version = "0.18.0", features = ["wayland"] }
+bevy = { version = "0.18.1", features = ["wayland"] }
noise = "0.9.0"
rand = "0.10.0"
-bevy_rand = { version = "0.14.1", features = ["wyrand"] }
-image = "0.25.9"
-bevy_platform = "0.18.0"
+bevy_rand = { version = "0.14.2", features = ["wyrand"] }
+image = "0.25.10"
+bevy_platform = "0.18.1"
rayon = "1.11.0"
+rustc-hash = "2.1.1"
+ahash = "0.8.12"
+nohash-hasher = "0.2.0"
+futures-lite = "2.6"
# Enable max optimizations for dependencies, but not for our code:
[profile.dev.package."*"]
diff --git a/src/constants.rs b/src/constants.rs
index a0c34b2..7a22b57 100644
--- a/src/constants.rs
+++ b/src/constants.rs
@@ -3,3 +3,8 @@ pub const TILE_PIXELS: u32 = 16;
pub const TILE_SIZE: f32 = TILE_PIXELS as f32 * PIXEL_RATIO;
pub const ITILE_SIZE: i32 = TILE_SIZE as i32;
pub const SEED: u32 = 420;
+
+pub const PATHFINDER_SHORT_PATH_MAX_TILES: i32 = 64;
+pub const PATHFINDER_MAX_NODES: usize = 5000;
+pub const PATHFINDER_WAYPOINT_THRESHOLD_TILES: i32 = 100;
+pub const PATHFINDER_PROVISIONAL_NODE_LIMIT: usize = 64;
diff --git a/src/entities/item/prefabs/misc/misc_prefabs.rs b/src/entities/item/prefabs/misc/misc_prefabs.rs
index 39d9e16..f5d9706 100644
--- a/src/entities/item/prefabs/misc/misc_prefabs.rs
+++ b/src/entities/item/prefabs/misc/misc_prefabs.rs
@@ -41,15 +41,7 @@ pub fn spawn_prefab(
},
})
.id();
- let mut items = tilemap
- .item_tiles
- .get(&position.as_ivec3())
- .unwrap_or(&Vec::new())
- .clone();
- items.push(meat.index_u32());
- tilemap
- .item_tiles
- .insert(position.as_ivec3(), items.clone());
+ tilemap.insert_item(position.as_ivec3(), meat.index_u32());
return commands
.entity(meat)
.insert(VisibleGameEntity)
@@ -76,15 +68,7 @@ pub fn spawn_prefab(
},
})
.id();
- let mut items = tilemap
- .item_tiles
- .get(&position.as_ivec3())
- .unwrap_or(&Vec::new())
- .clone();
- items.push(coin.index_u32());
- tilemap
- .item_tiles
- .insert(position.as_ivec3(), items.clone());
+ tilemap.insert_item(position.as_ivec3(), coin.index_u32());
commands
.entity(coin)
.insert(VisibleGameEntity)
diff --git a/src/entities/item/systems.rs b/src/entities/item/systems.rs
index 5bc0eef..fc3f536 100644
--- a/src/entities/item/systems.rs
+++ b/src/entities/item/systems.rs
@@ -116,13 +116,13 @@ pub fn item_tile_management_system(
for position in positions {
let items = match tilemap.item_tiles.get(&position) {
- Some(i) => i,
+ Some(i) => i.clone(),
None => continue,
};
// Clean up empty tiles
if items.is_empty() {
- tilemap.item_tiles.remove(&position);
+ tilemap.remove_item(&position);
rotation_timer.current_indices.remove(&position);
continue;
}
@@ -146,8 +146,8 @@ pub fn item_tile_management_system(
.copied()
.unwrap_or(0);
- for &entity_id in items {
- if let Some(entity) = Entity::from_raw_u32(entity_id) {
+ for entity_id in &items {
+ if let Some(entity) = Entity::from_raw_u32(*entity_id) {
if let Ok(mut rotation_state) = item_query.get_mut(entity) {
rotation_state.should_be_visible = false;
}
diff --git a/src/entities/shared_components/ambulatory.rs b/src/entities/shared_components/ambulatory.rs
index 8c7ae65..5b41470 100644
--- a/src/entities/shared_components/ambulatory.rs
+++ b/src/entities/shared_components/ambulatory.rs
@@ -9,3 +9,21 @@ pub struct Ambulatory {
pub target: Option<Vec3>,
pub step_recovery: u32,
}
+
+#[derive(Component)]
+pub struct PendingPath {
+ pub start: IVec3,
+ pub goal: IVec3,
+ pub waypoint_path: Vec<Vec3>,
+ pub request_id: u64,
+}
+
+#[derive(Resource, Default)]
+pub struct PathRequestCounter {
+ pub next_id: u64,
+}
+
+#[derive(Resource, Default)]
+pub struct CompletedPaths {
+ pub paths: Vec<(u64, Vec<Vec3>)>,
+}
diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs
index ee8c897..36437a0 100644
--- a/src/entities/shared_systems/pathfinding.rs
+++ b/src/entities/shared_systems/pathfinding.rs
@@ -1,14 +1,91 @@
-use crate::constants::TILE_SIZE;
+use bevy::prelude::*;
+use rayon::prelude::*;
+use rustc_hash::FxHashMap;
+use rustc_hash::FxHashSet;
+use std::{cell::RefCell, collections::BinaryHeap, collections::VecDeque, time::Instant};
+
+use crate::constants::{
+ ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, TILE_SIZE,
+};
use crate::world::tiles::TileMap;
use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE};
use crate::{constants::*, entities::shared_components::Ambulatory};
-use bevy::{math::ivec3, prelude::*};
+use bevy::math::ivec3;
use bevy_rand::prelude::*;
use rand::RngExt;
-use std::{
- collections::{BinaryHeap, HashMap, HashSet},
- process::exit,
-};
+
+thread_local! {
+ static LOCAL_PATH_TIMES: RefCell<Vec<u128>> = const { RefCell::new(Vec::new()) };
+ static LOCAL_PATH_LENGTHS: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
+ static LOCAL_NODES_EXPANDED: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
+ static LOCAL_FAILED_PATHS: RefCell<u64> = const { RefCell::new(0) };
+}
+
+/// Single consolidated scratchpad for A* pathfinding.
+/// One RefCell borrow instead of multiple nested borrows.
+struct AStarScratchpad {
+ g_scores: FxHashMap<IVec3, i32>,
+ came_from: FxHashMap<IVec3, IVec3>,
+ closed_set: FxHashSet<IVec3>,
+ open_set: BinaryHeap<PathNode>,
+}
+
+impl Default for AStarScratchpad {
+ fn default() -> Self {
+ Self {
+ g_scores: FxHashMap::default(),
+ came_from: FxHashMap::default(),
+ closed_set: FxHashSet::default(),
+ open_set: BinaryHeap::new(),
+ }
+ }
+}
+
+impl AStarScratchpad {
+ fn clear_and_reserve(&mut self, capacity: usize) {
+ self.g_scores.clear();
+ self.came_from.clear();
+ self.closed_set.clear();
+ self.open_set.clear();
+
+ if self.g_scores.capacity() < capacity {
+ self.g_scores.reserve(capacity);
+ self.came_from.reserve(capacity);
+ self.closed_set.reserve(capacity);
+ }
+ }
+}
+
+thread_local! {
+ static SCRATCHPAD: RefCell<AStarScratchpad> = RefCell::new(AStarScratchpad::default());
+}
+
+const ALLOWED_MOVES: [IVec3; 24] = [
+ IVec3::new(-ITILE_SIZE, 0, 0),
+ IVec3::new(ITILE_SIZE, 0, 0),
+ IVec3::new(0, -ITILE_SIZE, 0),
+ IVec3::new(0, ITILE_SIZE, 0),
+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, 0),
+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0),
+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0),
+ IVec3::new(ITILE_SIZE, ITILE_SIZE, 0),
+ IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE),
+ IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, 0, ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, 0, -ITILE_SIZE),
+ IVec3::new(0, -ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(0, -ITILE_SIZE, -ITILE_SIZE),
+ IVec3::new(0, ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(0, ITILE_SIZE, -ITILE_SIZE),
+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE),
+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, ITILE_SIZE, ITILE_SIZE),
+ IVec3::new(ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE),
+];
#[derive(Clone, Eq, PartialEq, Debug)]
struct PathNode {
@@ -19,7 +96,10 @@ struct PathNode {
impl Ord for PathNode {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
- other.f_score.cmp(&self.f_score)
+ other
+ .f_score
+ .cmp(&self.f_score)
+ .then_with(|| other.g_score.cmp(&self.g_score))
}
}
@@ -29,58 +109,233 @@ impl PartialOrd for PathNode {
}
}
+#[derive(Resource, Default)]
+pub struct PathfindingBenchmark {
+ pub path_calc_times_us: Vec<u128>,
+ pub path_lengths: Vec<usize>,
+ pub nodes_expanded: Vec<usize>,
+ pub movement_system_times_us: Vec<u128>,
+ pub wander_system_times_us: Vec<u128>,
+ pub total_paths_calculated: u64,
+ pub total_failed_paths: u64,
+ pub report_every_n: u32,
+ pub sample_count: u32,
+}
+
+impl PathfindingBenchmark {
+ pub fn new(report_every_n: u32) -> Self {
+ Self {
+ report_every_n,
+ ..Default::default()
+ }
+ }
+}
+
+#[derive(Resource, Default)]
+pub struct PathRequestQueue {
+ pub pending: VecDeque<(Entity, IVec3, IVec3)>,
+}
+
+const MAX_PATHS_PER_FRAME: usize = 8;
+
pub struct PathfindingPlugin;
impl Plugin for PathfindingPlugin {
fn build(&self, app: &mut App) {
- app.add_systems(FixedUpdate, (update_wandering_targets, movement).chain());
+ app.insert_resource(PathfindingBenchmark::new(100))
+ .insert_resource(crate::entities::shared_components::CompletedPaths::default())
+ .insert_resource(crate::entities::shared_components::PathRequestCounter::default())
+ .insert_resource(PathRequestQueue::default())
+ .add_systems(
+ FixedUpdate,
+ (prepare_paths, update_wandering_targets, movement).chain(),
+ )
+ .add_systems(
+ PostUpdate,
+ (
+ merge_benchmark_stats,
+ process_completed_paths,
+ process_path_queue,
+ ),
+ )
+ .add_systems(Update, bench_report_system);
+ }
+}
+
+pub fn prepare_paths(
+ mut commands: Commands,
+ mut queue: ResMut<PathRequestQueue>,
+ mut query: Query<
+ (
+ Entity,
+ &mut crate::entities::shared_components::Ambulatory,
+ &Transform,
+ ),
+ Without<crate::entities::shared_components::PendingPath>,
+ >,
+ tilemap: Res<TileMap>,
+) {
+ for (entity, mut ambulatory, transform) in query.iter_mut() {
+ if ambulatory.current_path.is_some() || ambulatory.target.is_none() {
+ continue;
+ }
+ let Some(target) = ambulatory.target else {
+ continue;
+ };
+ let start = transform.translation.as_ivec3();
+ let goal = target.as_ivec3() - ivec3(0, 0, ITILE_SIZE);
+ let distance = octile_distance_3d(start, goal) / ITILE_SIZE;
+
+ if distance <= PATHFINDER_SHORT_PATH_MAX_TILES {
+ let path = calculate_path_benchmarked(&tilemap, start, goal);
+ ambulatory.current_path = Some(path);
+ ambulatory.path_index = 0;
+ } else {
+ let provisional = calculate_provisional_path(
+ &tilemap,
+ start,
+ goal,
+ PATHFINDER_PROVISIONAL_NODE_LIMIT,
+ );
+ if !provisional.is_empty() {
+ ambulatory.current_path = Some(provisional);
+ ambulatory.path_index = 0;
+
+ queue.pending.push_back((entity, start, goal));
+ commands
+ .entity(entity)
+ .insert(crate::entities::shared_components::PendingPath {
+ start,
+ goal,
+ waypoint_path: Vec::new(),
+ request_id: 0,
+ });
+ } else {
+ let path = calculate_path_benchmarked(&tilemap, start, goal);
+ ambulatory.current_path = Some(path);
+ ambulatory.path_index = 0;
+ }
+ }
+ }
+}
+
+pub fn process_path_queue(
+ mut commands: Commands,
+ mut queue: ResMut<PathRequestQueue>,
+ tilemap: Res<TileMap>,
+ mut query: Query<
+ (Entity, &mut Ambulatory, &Transform),
+ With<crate::entities::shared_components::PendingPath>,
+ >,
+) {
+ let mut processed = 0;
+ while processed < MAX_PATHS_PER_FRAME {
+ if let Some((entity, _old_start, goal)) = queue.pending.pop_front() {
+ processed += 1;
+
+ if let Ok((_, mut ambulatory, transform)) = query.get_mut(entity) {
+ let actual_start = transform.translation.as_ivec3();
+ let full_path = calculate_path_benchmarked(&tilemap, actual_start, goal);
+
+ if !full_path.is_empty() {
+ ambulatory.current_path = Some(full_path);
+ ambulatory.path_index = 0;
+ }
+ commands
+ .entity(entity)
+ .remove::<crate::entities::shared_components::PendingPath>();
+ }
+ } else {
+ break;
+ }
+ }
+}
+
+pub fn process_completed_paths(
+ mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
+ mut query: Query<(
+ Entity,
+ &mut crate::entities::shared_components::Ambulatory,
+ &mut crate::entities::shared_components::PendingPath,
+ )>,
+ mut commands: Commands,
+) {
+ for (request_id, path) in completed.paths.drain(..) {
+ for (entity, mut ambulatory, pending) in query.iter_mut() {
+ if pending.request_id == request_id {
+ ambulatory.current_path = Some(path.clone());
+ commands
+ .entity(entity)
+ .remove::<crate::entities::shared_components::PendingPath>();
+ }
+ }
}
}
+pub fn merge_benchmark_stats(mut bench: ResMut<PathfindingBenchmark>) {
+ LOCAL_PATH_TIMES.with(|t| {
+ let mut times = t.borrow_mut();
+ bench.path_calc_times_us.extend(times.iter());
+ bench.total_paths_calculated += times.len() as u64;
+ times.clear();
+ });
+ LOCAL_PATH_LENGTHS.with(|l| {
+ let mut lengths = l.borrow_mut();
+ bench.path_lengths.extend(lengths.iter());
+ lengths.clear();
+ });
+ LOCAL_NODES_EXPANDED.with(|n| {
+ let mut nodes = n.borrow_mut();
+ bench.nodes_expanded.extend(nodes.iter());
+ nodes.clear();
+ });
+ LOCAL_FAILED_PATHS.with(|f| {
+ let mut failed = f.borrow_mut();
+ bench.total_failed_paths += *failed;
+ *failed = 0;
+ });
+}
+
pub fn update_wandering_targets(
- mut query: Query<(&mut Ambulatory, &Transform)>, // add a 'with' here when behaviours are implemented
+ mut query: Query<(&mut Ambulatory, &Transform)>,
tilemap: Res<TileMap>,
chunk_map: Res<ChunkMap>,
mut rng_q: Query<&mut WyRand, With<GlobalRng>>,
) {
- if let Ok(mut rng) = rng_q.single_mut() {
- for (mut ambulatory, _) in query.iter_mut() {
- if ambulatory.target.is_none()
- || (ambulatory.current_path.is_some()
- && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len())
- {
- // Find a random loaded chunk
- let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect();
- if !loaded_chunks.is_empty() {
- let random_index = rng.random_range(0..loaded_chunks.len());
- if let Some(&chunk_pos) = loaded_chunks.get(random_index) {
- // Generate random position within chunk
- let chunk_x = chunk_pos.x * CHUNK_SIZE;
- let chunk_y = chunk_pos.y * CHUNK_SIZE;
-
- let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE);
- let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE);
-
- // Get height at position
- let surface_height = 0;
-
- // Find a valid z-level near the surface
- for z in (surface_height - 3)..=(surface_height + 4) {
- let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE;
- if let Some(_) = tilemap.floor_tiles.get(&target_pos) {
- target_pos.z += ITILE_SIZE;
- if let Some(_base_texture) = tilemap.floor_tiles.get(&target_pos) {
- if is_standable_tile(&tilemap, target_pos) {
- ambulatory.target = Some(Vec3::new(
- target_pos.x as f32,
- target_pos.y as f32,
- target_pos.z as f32 + 1.0,
- ));
- ambulatory.current_path = None;
- ambulatory.path_index = 0;
- break;
- }
- }
+ let Ok(mut rng) = rng_q.single_mut() else {
+ return;
+ };
+
+ for (mut ambulatory, _) in query.iter_mut() {
+ if ambulatory.target.is_none()
+ || (ambulatory.current_path.is_some()
+ && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len())
+ {
+ let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect();
+ if !loaded_chunks.is_empty() {
+ let random_index = rng.random_range(0..loaded_chunks.len());
+ if let Some(&chunk_pos) = loaded_chunks.get(random_index) {
+ let chunk_x = chunk_pos.x * CHUNK_SIZE;
+ let chunk_y = chunk_pos.y * CHUNK_SIZE;
+
+ let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE);
+ let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE);
+
+ for z in -3..=4 {
+ let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE;
+ if tilemap.floor_tiles.get(&target_pos).is_some() {
+ target_pos.z += ITILE_SIZE;
+ if tilemap.floor_tiles.get(&target_pos).is_some()
+ && is_standable_tile(&tilemap, target_pos)
+ {
+ ambulatory.target = Some(Vec3::new(
+ target_pos.x as f32,
+ target_pos.y as f32,
+ target_pos.z as f32 + 1.0,
+ ));
+ ambulatory.current_path = None;
+ ambulatory.path_index = 0;
+ break;
}
}
}
@@ -95,289 +350,435 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re
.par_iter_mut()
.for_each(|(mut ambulatory, mut transform)| {
let current_pos = transform.translation;
- // Apply gravity if in air
if !is_standable_tile(&tilemap, current_pos.as_ivec3()) {
transform.translation.z -= TILE_SIZE;
return;
}
- if let Some(target) = ambulatory.target {
- // Calculate path if needed
- if ambulatory.current_path.is_none() {
- ambulatory.current_path = Some(calculate_path(
- &tilemap,
- transform.translation.as_ivec3(),
- target.as_ivec3() - ivec3(0, 0, 1),
- ));
- ambulatory.path_index = 0;
- }
- if ambulatory.walk_speed > 0. {
- if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
- ambulatory.step_recovery += 1;
- return;
- } else {
- ambulatory.step_recovery = 0;
- }
- }
+ if ambulatory.current_path.is_none() {
+ return;
+ }
- // Follow the current path
- if let Some(path) = &ambulatory.current_path {
- if ambulatory.path_index < path.len() {
- let next_point = path[ambulatory.path_index];
+ if ambulatory.walk_speed > 0. {
+ if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
+ ambulatory.step_recovery += 1;
+ return;
+ } else {
+ ambulatory.step_recovery = 0;
+ }
+ }
- let direction = (next_point - transform.translation).normalize();
- transform.translation = next_point;
+ if let Some(path) = &ambulatory.current_path {
+ if ambulatory.path_index < path.len() {
+ let next_point = path[ambulatory.path_index];
+ let direction = (next_point - transform.translation).normalize();
+ transform.translation = next_point;
- // Update sprite direction (only for x movement)
- if direction.x > 0.0 {
- transform.scale.x = PIXEL_RATIO;
- } else if direction.x < 0.0 {
- transform.scale.x = -PIXEL_RATIO;
- }
+ if direction.x > 0.0 {
+ transform.scale.x = PIXEL_RATIO;
+ } else if direction.x < 0.0 {
+ transform.scale.x = -PIXEL_RATIO;
+ }
- // Check if we've reached the next point
- if transform.translation.distance(next_point) < TILE_SIZE {
- ambulatory.path_index += 1;
- }
- } else {
- ambulatory.current_path = None;
- ambulatory.target = None;
+ if transform.translation.distance(next_point) < TILE_SIZE {
+ ambulatory.path_index += 1;
}
+ } else {
+ ambulatory.current_path = None;
+ ambulatory.target = None;
}
}
});
}
fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool {
- let mut can_i_stand_in_tile: bool = false;
- let mut can_i_stand_on_tile_bellow: bool = false;
- let mut can_i_stand_in_fixture: bool = false;
- let mut can_i_stand_on_fixture_bellow: bool = false;
-
- // Check if current position has a blocking floor tile
- if let Some(current_floor_tile) = tilemap.floor_tiles.get(&pos) {
- can_i_stand_in_tile = current_floor_tile.1;
- }
- // Check if current position has a solid fixture tile (e.g., log)
- if let Some(current_fixture_tile) = tilemap.fixture_tiles.get(&pos) {
- can_i_stand_in_fixture = current_fixture_tile.1;
- }
-
- // Check if there's solid ground below (fixture or floor)
+ let can_stand_in_tile = tilemap
+ .floor_tiles
+ .get(&pos)
+ .map(|t| t.can_stand_in())
+ .unwrap_or(false);
+ let can_stand_in_fixture = tilemap
+ .fixture_tiles
+ .get(&pos)
+ .map(|t| t.can_stand_in())
+ .unwrap_or(false);
let pos_below = pos - IVec3::new(0, 0, ITILE_SIZE);
+ let can_stand_on_tile_below = tilemap
+ .floor_tiles
+ .get(&pos_below)
+ .map(|t| t.can_stand_on())
+ .unwrap_or(false);
+ let can_stand_on_fixture_below = tilemap
+ .fixture_tiles
+ .get(&pos_below)
+ .map(|t| t.can_stand_on())
+ .unwrap_or(false);
+
+ (can_stand_in_tile || can_stand_in_fixture)
+ && (can_stand_on_tile_below || can_stand_on_fixture_below)
+}
- if let Some(below_floor_tile) = tilemap.floor_tiles.get(&pos_below) {
- can_i_stand_on_tile_bellow = below_floor_tile.2;
+fn calculate_movement_cost(move_dir: IVec3) -> i32 {
+ match (
+ move_dir.x.abs() / ITILE_SIZE,
+ move_dir.y.abs() / ITILE_SIZE,
+ move_dir.z.abs() / ITILE_SIZE,
+ ) {
+ (1, 0, 0) | (0, 1, 0) => 10,
+ (1, 1, 0) => 14,
+ (1, 0, 1) | (0, 1, 1) => 42,
+ (1, 1, 1) => 56,
+ _ => 0,
}
+}
- if let Some(below_fixture_tile) = tilemap.fixture_tiles.get(&pos_below) {
- can_i_stand_on_fixture_bellow = below_fixture_tile.2;
- }
- return (can_i_stand_in_tile || can_i_stand_in_fixture)
- && (can_i_stand_on_tile_bellow || can_i_stand_on_fixture_bellow);
+fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 {
+ let dx = (a.x - b.x).abs();
+ let dy = (a.y - b.y).abs();
+ let dz = (a.z - b.z).abs();
+ let (dmax, dmid, dmin) = sorted_desc(dx, dy, dz);
+ 10 * dmax + 4 * dmid + dmin
}
-fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec<Vec3> {
- if !is_standable_tile(tilemap, start) {
- println!("Start pos invalid: {}", start);
- println!("Bugger (1)");
- exit(0);
+fn sorted_desc(a: i32, b: i32, c: i32) -> (i32, i32, i32) {
+ let mut arr = [a, b, c];
+ arr.sort_unstable_by(|x, y| y.cmp(x));
+ (arr[0], arr[1], arr[2])
+}
+
+fn reconstruct_path(came_from: &FxHashMap<IVec3, IVec3>, mut current: IVec3) -> Vec<Vec3> {
+ let mut path = vec![Vec3::new(
+ current.x as f32,
+ current.y as f32,
+ current.z as f32,
+ )];
+ while let Some(&prev) = came_from.get(&current) {
+ path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32));
+ current = prev;
}
- if !is_standable_tile(tilemap, goal) {
- println!("Goal pos invalid: {}", goal);
- println!("Bugger (2)");
- exit(0);
+ path.reverse();
+ path
+}
+
+pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec<Vec3> {
+ let timer = Instant::now();
+
+ if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) {
+ LOCAL_FAILED_PATHS.with(|f| {
+ *f.borrow_mut() += 1;
+ });
+ return Vec::new();
}
- let mut open_set = BinaryHeap::new();
- let mut came_from = HashMap::new();
- let mut g_scores = HashMap::new();
- let mut closed_set = HashSet::new();
- let mut in_open_set = HashSet::new();
+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
+ let (result, nodes_expanded) =
+ calculate_path_with_scratchpad(tilemap, start, goal, estimated_tiles);
+
+ let elapsed = timer.elapsed().as_micros();
+ LOCAL_PATH_TIMES.with(|t| {
+ t.borrow_mut().push(elapsed);
+ });
+ LOCAL_PATH_LENGTHS.with(|l| {
+ l.borrow_mut().push(result.len());
+ });
+ LOCAL_NODES_EXPANDED.with(|n| {
+ n.borrow_mut().push(nodes_expanded);
+ });
+
+ if result.is_empty() {
+ vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]
+ } else {
+ result
+ }
+}
- let start_node = PathNode {
- position: start,
- f_score: octile_distance_3d(start, goal),
- g_score: 0,
- };
+fn calculate_path_with_scratchpad(
+ tilemap: &TileMap,
+ start: IVec3,
+ goal: IVec3,
+ estimated_tiles: i32,
+) -> (Vec<Vec3>, usize) {
+ SCRATCHPAD.with(|s| {
+ let mut scratch = s.borrow_mut();
+ let capacity = ((estimated_tiles as usize).max(64)).min(4096);
+ scratch.clear_and_reserve(capacity);
+
+ let h = octile_distance_3d(start, goal);
+ scratch.open_set.push(PathNode {
+ position: start,
+ f_score: h,
+ g_score: 0,
+ });
+ scratch.g_scores.insert(start, 0);
- open_set.push(start_node);
- in_open_set.insert(start);
- g_scores.insert(start, 0);
-
- let allowed_moves = vec![
- // Orthogonal moves
- IVec3::new(-ITILE_SIZE, 0, 0),
- IVec3::new(ITILE_SIZE, 0, 0),
- IVec3::new(0, -ITILE_SIZE, 0),
- IVec3::new(0, ITILE_SIZE, 0),
- // Diagonal moves
- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, 0),
- IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0),
- IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0),
- IVec3::new(ITILE_SIZE, ITILE_SIZE, 0),
- // Diagonal with vertical moves (left/negative preference)
- IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE),
- IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE),
- IVec3::new(ITILE_SIZE, 0, ITILE_SIZE),
- IVec3::new(ITILE_SIZE, 0, -ITILE_SIZE),
- IVec3::new(0, -ITILE_SIZE, ITILE_SIZE),
- IVec3::new(0, -ITILE_SIZE, -ITILE_SIZE),
- IVec3::new(0, ITILE_SIZE, ITILE_SIZE),
- IVec3::new(0, ITILE_SIZE, -ITILE_SIZE),
- // Full 3D diagonal moves
- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE),
- IVec3::new(-ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE),
- IVec3::new(-ITILE_SIZE, ITILE_SIZE, ITILE_SIZE),
- IVec3::new(-ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE),
- IVec3::new(ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE),
- IVec3::new(ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE),
- IVec3::new(ITILE_SIZE, ITILE_SIZE, ITILE_SIZE),
- IVec3::new(ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE),
- ];
-
- while let Some(current_node) = open_set.pop() {
- let current = current_node.position;
-
- in_open_set.remove(&current);
-
- if current == goal {
- // println!("path found");
- return reconstruct_path(came_from, current);
- }
+ let mut nodes_expanded: usize = 0;
- closed_set.insert(current);
+ while let Some(current_node) = scratch.open_set.pop() {
+ let current = current_node.position;
+ nodes_expanded += 1;
- for &move_dir in &allowed_moves {
- let neighbor_pos = current + move_dir;
+ if nodes_expanded > PATHFINDER_MAX_NODES {
+ return (
+ reconstruct_path(&scratch.came_from, current),
+ nodes_expanded,
+ );
+ }
- if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) {
- continue;
+ if current == goal {
+ return (
+ reconstruct_path(&scratch.came_from, current),
+ nodes_expanded,
+ );
}
- // TODO: Add terrain-based cost modifiers
- // movement_cost = apply_terrain_modifier(movement_cost, neighbor_pos, tilemap);
- // Examples:
- // - Mud/sand: +50% cost
- // - Ice: +100% cost
- // - Designated high-traffic areas: -25% cost
- // - Designated restricted areas: +500% cost
- // - Etc
-
- let movement_cost = match (
- move_dir.x.abs() / ITILE_SIZE,
- move_dir.y.abs() / ITILE_SIZE,
- move_dir.z.abs() / ITILE_SIZE,
- ) {
- // 2D Movement (Dwarf Fortress style)
- (1, 0, 0) | (0, 1, 0) => 10, // Orthogonal movement
- (1, 1, 0) => 14, // Diagonal movement (~√2 × 10)
-
- // Vertical Movement (Raw climbing - very expensive)
- // (0, 0, 1) => 50, // Pure vertical climb/fall
-
- // 3D Movement (Climbing diagonally - even more expensive)
- (1, 0, 1) | (0, 1, 1) => 52, // Orthogonal + vertical climb
- (1, 1, 1) => 56, // Diagonal + vertical climb
-
- // TODO: Implement stairs and ramps for efficient vertical movement
- // Stairs would reduce vertical costs significantly:
- // (0, 0, 1) => 20 if has_stairs(current, neighbor_pos), // Stairs: 2× horizontal cost
- // (1, 0, 1) | (0, 1, 1) => 24 if has_stairs(current, neighbor_pos), // Stairs + horizontal
- // (1, 1, 1) => 28 if has_stairs(current, neighbor_pos), // Stairs + diagonal
-
- // TODO: Implement ramps for even smoother vertical movement
- // Ramps would be cheaper than stairs:
- // (0, 0, 1) => 15 if has_ramp(current, neighbor_pos), // Ramps: 1.5× horizontal cost
- // (1, 0, 1) | (0, 1, 1) => 18 if has_ramp(current, neighbor_pos), // Ramps + horizontal
- // (1, 1, 1) => 21 if has_ramp(current, neighbor_pos), // Ramps + diagonal
- _ => continue,
- };
-
- let new_g = g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost;
-
- if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
- came_from.insert(neighbor_pos, current);
- g_scores.insert(neighbor_pos, new_g);
- let h = octile_distance_3d(neighbor_pos, goal);
- let f = new_g + h;
-
- // Only add to open set if not already there
- if !in_open_set.contains(&neighbor_pos) {
- let neighbor_node = PathNode {
+ scratch.closed_set.insert(current);
+
+ 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)
+ {
+ continue;
+ }
+
+ let movement_cost = calculate_movement_cost(move_dir);
+ if movement_cost == 0 {
+ continue;
+ }
+
+ let new_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost;
+
+ if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
+ scratch.came_from.insert(neighbor_pos, current);
+ scratch.g_scores.insert(neighbor_pos, new_g);
+ let f = new_g + octile_distance_3d(neighbor_pos, goal);
+ scratch.open_set.push(PathNode {
position: neighbor_pos,
f_score: f,
g_score: new_g,
- };
- open_set.push(neighbor_node);
- in_open_set.insert(neighbor_pos);
- } else {
- let neighbor_node = PathNode {
+ });
+ }
+ }
+ }
+
+ (Vec::new(), nodes_expanded)
+ })
+}
+
+pub fn calculate_provisional_path(
+ tilemap: &TileMap,
+ start: IVec3,
+ goal: IVec3,
+ node_limit: usize,
+) -> Vec<Vec3> {
+ let timer = Instant::now();
+
+ if !is_standable_tile(tilemap, start) {
+ LOCAL_FAILED_PATHS.with(|f| {
+ *f.borrow_mut() += 1;
+ });
+ return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)];
+ }
+
+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
+
+ let result = SCRATCHPAD.with(|s| {
+ let mut scratch = s.borrow_mut();
+ let capacity = ((estimated_tiles as usize).max(64)).min(4096);
+ scratch.clear_and_reserve(capacity);
+
+ let initial_h = octile_distance_3d(start, goal);
+ scratch.open_set.push(PathNode {
+ position: start,
+ f_score: initial_h,
+ g_score: 0,
+ });
+ scratch.g_scores.insert(start, 0);
+
+ let mut nodes_expanded: usize = 0;
+ let mut best_node = start;
+ let mut best_h = initial_h;
+
+ while let Some(current_node) = scratch.open_set.pop() {
+ let current = current_node.position;
+ nodes_expanded += 1;
+
+ let h = octile_distance_3d(current, goal);
+ if h < best_h {
+ best_h = h;
+ best_node = current;
+ }
+
+ if current == goal {
+ return (
+ reconstruct_path(&scratch.came_from, current),
+ nodes_expanded,
+ );
+ }
+
+ if nodes_expanded >= node_limit {
+ return (
+ reconstruct_path(&scratch.came_from, best_node),
+ nodes_expanded,
+ );
+ }
+
+ scratch.closed_set.insert(current);
+
+ 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)
+ {
+ continue;
+ }
+
+ let movement_cost = calculate_movement_cost(move_dir);
+ if movement_cost == 0 {
+ continue;
+ }
+
+ let new_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost;
+
+ if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
+ scratch.came_from.insert(neighbor_pos, current);
+ scratch.g_scores.insert(neighbor_pos, new_g);
+ let f = new_g + octile_distance_3d(neighbor_pos, goal);
+ scratch.open_set.push(PathNode {
position: neighbor_pos,
f_score: f,
g_score: new_g,
- };
- open_set.push(neighbor_node);
+ });
}
}
}
- }
- Vec::new()
+ (
+ reconstruct_path(&scratch.came_from, best_node),
+ nodes_expanded,
+ )
+ });
+
+ let elapsed = timer.elapsed().as_micros();
+ LOCAL_PATH_TIMES.with(|t| {
+ t.borrow_mut().push(elapsed);
+ });
+ LOCAL_PATH_LENGTHS.with(|l| {
+ l.borrow_mut().push(result.0.len());
+ });
+ LOCAL_NODES_EXPANDED.with(|n| {
+ n.borrow_mut().push(result.1);
+ });
+
+ result.0
}
-fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 {
- let dx = (a.x - b.x).abs();
- let dy = (a.y - b.y).abs();
- let dz = (a.z - b.z).abs();
+pub fn bench_report_system(
+ keys: Res<ButtonInput<KeyCode>>,
+ mut bench: ResMut<PathfindingBenchmark>,
+) {
+ if keys.just_pressed(KeyCode::F8) {
+ println!("\n=== PATHFINDING BENCHMARK REPORT ===");
+ report_stat("path_calc", &bench.path_calc_times_us);
+ report_stat(
+ "path_length",
+ &bench
+ .path_lengths
+ .iter()
+ .map(|&l| l as u128)
+ .collect::<Vec<_>>(),
+ );
+ report_stat(
+ "nodes_expanded",
+ &bench
+ .nodes_expanded
+ .iter()
+ .map(|&n| n as u128)
+ .collect::<Vec<_>>(),
+ );
+
+ if !bench.movement_system_times_us.is_empty() {
+ report_stat("movement_system", &bench.movement_system_times_us);
+ }
+ if !bench.wander_system_times_us.is_empty() {
+ report_stat("wander_system", &bench.wander_system_times_us);
+ }
- // Dwarf Fortress style costs
- let cost_orthogonal = 10; // Horizontal orthogonal
- let cost_diagonal = 14; // Horizontal diagonal (~√2 × 10)
- let cost_climb = 50; // Raw vertical movement (climbing)
-
- let mut diffs = [dx, dy, dz];
- diffs.sort_unstable();
- let dmin = diffs[0];
- let dmax = diffs[2];
-
- if dz == 0 {
- // Pure 2D movement
- let diagonal_moves = dmin / ITILE_SIZE;
- let orthogonal_moves = (dmax - dmin) / ITILE_SIZE;
- cost_diagonal * diagonal_moves + cost_orthogonal * orthogonal_moves
- } else {
- // Movement involves Z - assume raw climbing for now
- // TODO: Modify this when stairs/ramps are implemented
- let z_moves = dz / ITILE_SIZE;
- let xy_distance = ((dx * dx + dy * dy) as f32).sqrt() as i32;
- let remaining_2d_diagonal = (xy_distance.min(dz)) / ITILE_SIZE;
- let remaining_2d_orthogonal =
- (xy_distance - remaining_2d_diagonal * ITILE_SIZE) / ITILE_SIZE;
-
- // Raw climbing cost + remaining 2D movement
- cost_climb * z_moves
- + cost_diagonal * remaining_2d_diagonal
- + cost_orthogonal * remaining_2d_orthogonal
+ let total = bench.total_paths_calculated;
+ let failed = bench.total_failed_paths;
+ println!(
+ "[BENCH] total_paths={} failed_paths={} success_rate={:.1}%",
+ total,
+ failed,
+ if total > 0 {
+ 100.0 * (total - failed) as f64 / total as f64
+ } else {
+ 100.0
+ }
+ );
+
+ if let Err(e) = write_benchmark_csv(&bench, "pathfinding_benchmark_current.csv") {
+ eprintln!("Failed to write benchmark CSV: {}", e);
+ }
+ println!("=====================================\n");
}
}
-fn reconstruct_path(came_from: HashMap<IVec3, IVec3>, mut current: IVec3) -> Vec<Vec3> {
- let mut path = vec![Vec3::new(
- current.x as f32,
- current.y as f32,
- current.z as f32,
- )];
+fn report_stat(label: &str, times: &[u128]) {
+ if times.is_empty() {
+ return;
+ }
+ let sum: u128 = times.iter().sum();
+ let avg = sum / times.len() as u128;
+ let min = *times.iter().min().unwrap();
+ let max = *times.iter().max().unwrap();
+ let mut sorted = times.to_vec();
+ sorted.sort_unstable();
+ let median = sorted[sorted.len() / 2];
+ let p95_idx = (sorted.len() as f64 * 0.95) as usize;
+ let p95 = sorted[p95_idx.min(sorted.len().saturating_sub(1))];
+
+ println!(
+ "[BENCH][{}] n={} avg={}µs median={}µs min={}µs max={}µs p95={}µs",
+ label,
+ times.len(),
+ avg,
+ median,
+ min,
+ max,
+ p95
+ );
+}
- while let Some(&previous) = came_from.get(&current) {
- path.push(Vec3::new(
- previous.x as f32,
- previous.y as f32,
- previous.z as f32,
- ));
- current = previous;
+fn write_benchmark_csv(bench: &PathfindingBenchmark, filename: &str) -> std::io::Result<()> {
+ use std::fs::File;
+ use std::io::Write;
+
+ let mut file = File::create(filename)?;
+ writeln!(
+ file,
+ "sample,path_duration_us,path_length,nodes_expanded,success"
+ )?;
+
+ let n = bench.path_calc_times_us.len();
+ for i in 0..n {
+ let duration = bench.path_calc_times_us.get(i).copied().unwrap_or(0);
+ let length = bench.path_lengths.get(i).copied().unwrap_or(0);
+ let nodes = bench.nodes_expanded.get(i).copied().unwrap_or(0);
+ let success = i < (n - bench.total_failed_paths as usize);
+ writeln!(file, "{},{},{},{},{}", i, duration, length, nodes, success)?;
}
- path.reverse();
- path
+ writeln!(file, "# Summary")?;
+ if !bench.path_calc_times_us.is_empty() {
+ let avg: u128 =
+ bench.path_calc_times_us.iter().sum::<u128>() / bench.path_calc_times_us.len() as u128;
+ writeln!(file, "# avg_duration_us,{}", avg)?;
+ }
+ writeln!(file, "# total_paths,{}", bench.total_paths_calculated)?;
+ writeln!(file, "# failed_paths,{}", bench.total_failed_paths)?;
+
+ Ok(())
}
diff --git a/src/world/chunks/management.rs b/src/world/chunks/management.rs
index f066456..300c4c4 100644
--- a/src/world/chunks/management.rs
+++ b/src/world/chunks/management.rs
@@ -6,7 +6,7 @@ use crate::world::{tiles::TileMap, CurrentWorldSpriteState, TerrainSpriteState};
pub const CHUNK_SIZE: i32 = 8;
-pub const Z_BELOW: f32 = 45.0;
+pub const Z_BELOW: f32 = 5.0;
pub const Z_ABOVE: f32 = 15.0;
pub const Z_TOTAL: f32 = Z_ABOVE + Z_BELOW;
diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs
index 9fc597c..4decbb3 100644
--- a/src/world/generation/forestry.rs
+++ b/src/world/generation/forestry.rs
@@ -11,8 +11,8 @@ use std::hash::{Hash, Hasher};
use crate::{
constants::{SEED, TILE_SIZE},
world::{
- tiles::TileMap, ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures,
- VisibleGameEntity,
+ tiles::{FixtureTileData, TileMap},
+ ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, VisibleGameEntity,
},
};
@@ -26,8 +26,7 @@ pub fn generate_chunk_forrestry(
let start = Instant::now();
let count = events.len();
- let collected_tilemap_updates: Mutex<Vec<(IVec3, (i32, bool, bool, [u32; 8]))>> =
- Mutex::new(Vec::<(IVec3, (i32, bool, bool, [u32; 8]))>::new());
+ let collected_tilemap_updates: Mutex<Vec<(IVec3, FixtureTileData)>> = Mutex::new(Vec::new());
events.par_read().for_each(|event| {
let floor_positions = &event.floor_tiles;
@@ -87,10 +86,10 @@ pub fn generate_chunk_forrestry(
}
}
- collected_tilemap_updates
- .lock()
- .unwrap()
- .push((trunk_ivec, (1, false, true, [0; 8])));
+ collected_tilemap_updates.lock().unwrap().push((
+ trunk_ivec,
+ FixtureTileData::new(1, false, true, [0; 8]),
+ ));
log_positions.insert(trunk_ivec);
}
@@ -144,10 +143,14 @@ pub fn generate_chunk_forrestry(
))
.id();
commands.entity(leaf).insert(VisibleGameEntity);
- collected_tilemap_updates
- .lock()
- .unwrap()
- .push((ivec, (5, false, true, [0; 8])));
+ collected_tilemap_updates.lock().unwrap().push(
+ (
+ ivec,
+ FixtureTileData::new(
+ 5, false, true, [0; 8],
+ ),
+ ),
+ );
}
}
}
@@ -164,7 +167,7 @@ pub fn generate_chunk_forrestry(
let collected_updates = collected_tilemap_updates.into_inner().unwrap();
for (ivec, data) in collected_updates {
- tilemap.fixture_tiles.insert(ivec, data);
+ tilemap.insert_fixture(ivec, data);
}
if count > 0 {
println!(
diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs
index fb46146..673ee7e 100644
--- a/src/world/generation/terrain.rs
+++ b/src/world/generation/terrain.rs
@@ -7,8 +7,9 @@ use noise::{NoiseFn, Perlin};
use crate::{
constants::{SEED, TILE_SIZE},
world::{
- tiles::TileMap, ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab,
- TileOcclusionEvent, CHUNK_SIZE, Z_ABOVE, Z_BELOW,
+ tiles::{FloorTileData, TileMap},
+ ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE,
+ Z_ABOVE, Z_BELOW,
},
};
@@ -50,8 +51,7 @@ pub fn generate_chunk_terrain(
let start_y = chunk_pos.y * CHUNK_SIZE;
let mut surface_positions: Vec<(Vec3, String)> = Vec::new();
- let mut local_tilemap_updates: HashMap<IVec3, (i32, bool, bool, bool, i32, [u32; 8])> =
- HashMap::new();
+ let mut local_tilemap_updates: HashMap<IVec3, FloorTileData> = HashMap::new();
// Generate tiles for this chunk
for local_y in 0..CHUNK_SIZE {
@@ -84,23 +84,26 @@ pub fn generate_chunk_terrain(
commands.command_scope(|mut cmd| {
FloorTilePrefab::air(position).spawn(&mut cmd);
});
- local_tilemap_updates
- .insert(pos_ivec, (0, true, false, true, 0, [0; 8]));
- // Air tile
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(0, true, false, true, 0, [0; 8]),
+ );
} else if cave_value < 0.8 {
commands.command_scope(|mut cmd| {
FloorTilePrefab::rock(position).spawn(&mut cmd);
});
- local_tilemap_updates
- .insert(pos_ivec, (2, false, true, false, 50, [0; 8]));
- // Rock tile
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(2, false, true, false, 50, [0; 8]),
+ );
} else {
commands.command_scope(|mut cmd| {
FloorTilePrefab::dirt(position).spawn(&mut cmd);
});
- local_tilemap_updates
- .insert(pos_ivec, (1, false, true, false, 85, [0; 8]));
- // Dirt tile
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(1, false, true, false, 85, [0; 8]),
+ );
}
} else if noise_position.z > position.z {
if (generate_surface_terrain(world_x, world_y) * TILE_SIZE).round()
@@ -109,23 +112,28 @@ pub fn generate_chunk_terrain(
commands.command_scope(|mut cmd| {
FloorTilePrefab::grass(position).spawn(&mut cmd);
});
- local_tilemap_updates
- .insert(pos_ivec, (1, false, true, false, 100, [0; 8])); // Dirt tile (grass)
- surface_positions.push((position, ("grass").to_string()));
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(1, false, true, false, 100, [0; 8]),
+ );
+ surface_positions.push((position, "grass".to_string()));
} else {
commands.command_scope(|mut cmd| {
FloorTilePrefab::dirt(position).spawn(&mut cmd);
});
- local_tilemap_updates
- .insert(pos_ivec, (1, false, true, false, 85, [0; 8]));
- // Dirt tile
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(1, false, true, false, 85, [0; 8]),
+ );
}
} else {
commands.command_scope(|mut cmd| {
FloorTilePrefab::air(position).spawn(&mut cmd);
});
- local_tilemap_updates.insert(pos_ivec, (0, true, false, true, 0, [0; 8]));
- // Air tile
+ local_tilemap_updates.insert(
+ pos_ivec,
+ FloorTileData::new(0, true, false, true, 0, [0; 8]),
+ );
}
}
}
@@ -151,7 +159,7 @@ pub fn generate_chunk_terrain(
.unwrap()
.into_iter()
.map(|(pos, data)| {
- tilemap.floor_tiles.insert(pos, data);
+ tilemap.insert_floor(pos, data);
pos
})
.collect();
diff --git a/src/world/mod.rs b/src/world/mod.rs
index a0cb751..d94211f 100644
--- a/src/world/mod.rs
+++ b/src/world/mod.rs
@@ -69,8 +69,8 @@ impl Plugin for WorldPlugin {
}
fn setup_initial_chunks(mut event_writer: MessageWriter<GenerateChunkEvent>) {
- for x in -5..=5 {
- for y in -5..=5 {
+ for x in -15..=15 {
+ for y in -15..=15 {
event_writer.write(GenerateChunkEvent {
chunk_position: IVec2::new(x, y),
});
diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs
index 8b3d811..028c005 100644
--- a/src/world/tiles/tilemap.rs
+++ b/src/world/tiles/tilemap.rs
@@ -1,9 +1,197 @@
use bevy::prelude::*;
-use bevy_platform::collections::hash_map::HashMap;
+use rustc_hash::FxHashMap;
-#[derive(Resource, Default, Clone)]
+use crate::constants::ITILE_SIZE;
+
+/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple.
+#[derive(Clone, Copy, Debug)]
+pub struct FloorTileData {
+ pub id: u8,
+ /// bit0=can_stand_in, bit1=can_stand_on, bit2=visibly_transparent
+ pub flags: u8,
+ pub astar_weight: u8,
+ pub visible_range: [u32; 8],
+}
+
+impl Default for FloorTileData {
+ fn default() -> Self {
+ Self {
+ id: 0,
+ flags: 0b001,
+ astar_weight: 0,
+ visible_range: [0; 8],
+ }
+ }
+}
+
+impl FloorTileData {
+ pub fn new(
+ id: u8,
+ can_stand_in: bool,
+ can_stand_on: bool,
+ visibly_transparent: bool,
+ astar_weight: u8,
+ visible_range: [u32; 8],
+ ) -> Self {
+ let mut flags = 0u8;
+ if can_stand_in {
+ flags |= 0b001;
+ }
+ if can_stand_on {
+ flags |= 0b010;
+ }
+ if visibly_transparent {
+ flags |= 0b100;
+ }
+ Self {
+ id,
+ flags,
+ astar_weight,
+ visible_range,
+ }
+ }
+
+ #[inline]
+ pub fn can_stand_in(&self) -> bool {
+ self.flags & 0b001 != 0
+ }
+ #[inline]
+ pub fn can_stand_on(&self) -> bool {
+ self.flags & 0b010 != 0
+ }
+ #[inline]
+ pub fn visibly_transparent(&self) -> bool {
+ self.flags & 0b100 != 0
+ }
+
+ #[inline]
+ pub fn set_can_stand_in(&mut self, value: bool) {
+ if value {
+ self.flags |= 0b001;
+ } else {
+ self.flags &= !0b001;
+ }
+ }
+
+ #[inline]
+ pub fn set_can_stand_on(&mut self, value: bool) {
+ if value {
+ self.flags |= 0b010;
+ } else {
+ self.flags &= !0b010;
+ }
+ }
+
+ #[inline]
+ pub fn set_visibly_transparent(&mut self, value: bool) {
+ if value {
+ self.flags |= 0b100;
+ } else {
+ self.flags &= !0b100;
+ }
+ }
+}
+
+/// Packed fixture tile data. ~18 bytes vs 48 bytes tuple.
+#[derive(Clone, Copy, Debug)]
+pub struct FixtureTileData {
+ pub id: u8,
+ /// bit0=can_stand_in, bit1=can_stand_on
+ pub flags: u8,
+ pub visible_range: [u32; 8],
+}
+
+impl Default for FixtureTileData {
+ fn default() -> Self {
+ Self {
+ id: 0,
+ flags: 0,
+ visible_range: [0; 8],
+ }
+ }
+}
+
+impl FixtureTileData {
+ pub fn new(id: u8, can_stand_in: bool, can_stand_on: bool, visible_range: [u32; 8]) -> Self {
+ let mut flags = 0u8;
+ if can_stand_in {
+ flags |= 0b001;
+ }
+ if can_stand_on {
+ flags |= 0b010;
+ }
+ Self {
+ id,
+ flags,
+ visible_range,
+ }
+ }
+
+ #[inline]
+ pub fn can_stand_in(&self) -> bool {
+ self.flags & 0b001 != 0
+ }
+ #[inline]
+ pub fn can_stand_on(&self) -> bool {
+ self.flags & 0b010 != 0
+ }
+}
+
+/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access.
+#[derive(Resource, Default)]
pub struct TileMap {
- pub floor_tiles: HashMap<IVec3, (i32, bool, bool, bool, i32, [u32; 8])>, //id, canStandIn, canStandOn, visiblyTransparent, astar_weight, visible_range
- pub fixture_tiles: HashMap<IVec3, (i32, bool, bool, [u32; 8])>, // id, canStandIn, canStandOn, visible_range
- pub item_tiles: HashMap<IVec3, Vec<u32>>, // Entity.id's of items on this tile
+ pub floor_tiles: FxHashMap<IVec3, FloorTileData>,
+ pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
+ pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
+}
+
+impl TileMap {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ #[inline]
+ pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> {
+ self.floor_tiles.get(pos)
+ }
+
+ #[inline]
+ pub fn get_fixture(&self, pos: &IVec3) -> Option<&FixtureTileData> {
+ self.fixture_tiles.get(pos)
+ }
+
+ #[inline]
+ pub fn has_floor(&self, pos: &IVec3) -> bool {
+ self.floor_tiles.contains_key(pos)
+ }
+
+ #[inline]
+ pub fn has_fixture(&self, pos: &IVec3) -> bool {
+ self.fixture_tiles.contains_key(pos)
+ }
+
+ #[inline]
+ pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) {
+ self.floor_tiles.insert(pos, tile);
+ }
+
+ #[inline]
+ pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) {
+ self.fixture_tiles.insert(pos, tile);
+ }
+
+ #[inline]
+ pub fn insert_item(&mut self, pos: IVec3, entity_id: u32) {
+ self.item_tiles.entry(pos).or_default().push(entity_id);
+ }
+
+ #[inline]
+ pub fn remove_item(&mut self, pos: &IVec3) -> Option<Vec<u32>> {
+ self.item_tiles.remove(pos)
+ }
+
+ #[inline]
+ pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> {
+ self.floor_tiles.get_mut(pos)
+ }
}
diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs
index 5c9a041..c56ce8b 100644
--- a/src/world/tiles/visibility.rs
+++ b/src/world/tiles/visibility.rs
@@ -100,8 +100,8 @@ pub fn handle_tile_occlusion_updates(
for (_, mut tile, pos) in floor_tiles.iter_mut() {
if let Some(visibility) = update_map.get(&pos.translation.as_ivec3()) {
tile.visible_range = *visibility;
- if let Some(tile_data) = tilemap.floor_tiles.get_mut(&pos.translation.as_ivec3()) {
- tile_data.5 = *visibility;
+ if let Some(tile_data) = tilemap.get_floor_mut(&pos.translation.as_ivec3()) {
+ tile_data.visible_range = *visibility;
}
}
}
@@ -134,7 +134,7 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] {
pos.z + z_offset * ITILE_SIZE,
);
match tilemap.floor_tiles.get(&neighbor_pos) {
- Some(&(id, _, _, _, _, _)) if id == 0 => break 'neighbor_check true,
+ Some(tile) if tile.id == 0 => break 'neighbor_check true,
None => {}
_ => {}
}
@@ -163,8 +163,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] {
let camera_z_index = (check_pos.z / ITILE_SIZE) + z_below;
if camera_z_index < 0 {
- if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) {
- occluded = !vt;
+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) {
+ occluded = !tile.visibly_transparent();
} else {
occluded = false;
}
@@ -180,8 +180,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] {
visible_range[z2 / 32] |= 1 << (z2 % 32);
}
- if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) {
- occluded = !vt;
+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) {
+ occluded = !tile.visibly_transparent();
} else {
occluded = false;
}
diff --git a/verify_bench.py b/verify_bench.py
new file mode 100644
index 0000000..fea2e64
--- /dev/null
+++ b/verify_bench.py
@@ -0,0 +1,36 @@
+import csv
+import sys
+
+def analyze(filename):
+ durations = []
+ failed = 0
+ total = 0
+ stutters = 0
+ try:
+ with open(filename, 'r') as f:
+ reader = csv.reader(f)
+ next(reader) # skip header
+ for row in reader:
+ if not row or row[0].startswith('#'): continue
+ total += 1
+ try:
+ d = int(row[1])
+ durations.append(d)
+ if d > 1000: stutters += 1
+ if row[4] == 'false': failed += 1
+ except:
+ pass
+
+ durations.sort()
+ if not durations: return
+ p50 = durations[len(durations)//2]
+ p90 = durations[int(len(durations)*0.90)]
+ p99 = durations[int(len(durations)*0.99)]
+ max_d = durations[-1]
+ print(f"[{filename}]")
+ print(f"P50={p50} P90={p90} P99={p99} Max={max_d} Stutters={stutters} Failed={failed}/{total}")
+ except Exception as e:
+ print(f"Error reading {filename}: {e}")
+
+analyze("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv")
+analyze("pathfinding_benchmark_current.csv")