diff --git a/git_diff_24h.txt b/git_diff_24h.txt new file mode 100644 index 0000000..d704679 --- /dev/null +++ b/git_diff_24h.txt @@ -0,0 +1,1900 @@ +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, + pub step_recovery: u32, + } ++ ++#[derive(Component)] ++pub struct PendingPath { ++ pub start: IVec3, ++ pub goal: IVec3, ++ pub waypoint_path: Vec, ++ 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)>, ++} +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> = const { RefCell::new(Vec::new()) }; ++ static LOCAL_PATH_LENGTHS: RefCell> = const { RefCell::new(Vec::new()) }; ++ static LOCAL_NODES_EXPANDED: RefCell> = const { RefCell::new(Vec::new()) }; ++ static LOCAL_FAILED_PATHS: RefCell = const { RefCell::new(0) }; ++} ++ ++/// Single consolidated scratchpad for A* pathfinding. ++/// One RefCell borrow instead of multiple nested borrows. ++struct AStarScratchpad { ++ g_scores: FxHashMap, ++ came_from: FxHashMap, ++ closed_set: FxHashSet, ++ open_set: BinaryHeap, ++} ++ ++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 = 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, ++ pub path_lengths: Vec, ++ pub nodes_expanded: Vec, ++ pub movement_system_times_us: Vec, ++ pub wander_system_times_us: Vec, ++ 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, ++ mut query: Query< ++ ( ++ Entity, ++ &mut crate::entities::shared_components::Ambulatory, ++ &Transform, ++ ), ++ Without, ++ >, ++ tilemap: Res, ++) { ++ 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, ++ tilemap: Res, ++ mut query: Query< ++ (Entity, &mut Ambulatory, &Transform), ++ With, ++ >, ++) { ++ 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::(); ++ } ++ } else { ++ break; ++ } ++ } ++} ++ ++pub fn process_completed_paths( ++ mut completed: ResMut, ++ 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::(); ++ } ++ } + } + } + ++pub fn merge_benchmark_stats(mut bench: ResMut) { ++ 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, + chunk_map: Res, + mut rng_q: Query<&mut WyRand, With>, + ) { +- 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 { +- 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, mut current: IVec3) -> Vec { ++ 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(¤t) { ++ 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 { ++ 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, 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(¤t); +- +- 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(¤t).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(¤t).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 { ++ 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(¤t).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>, ++ mut bench: ResMut, ++) { ++ 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::>(), ++ ); ++ report_stat( ++ "nodes_expanded", ++ &bench ++ .nodes_expanded ++ .iter() ++ .map(|&n| n as u128) ++ .collect::>(), ++ ); ++ ++ 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, mut current: IVec3) -> Vec { +- 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(¤t) { +- 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::() / 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> = +- Mutex::new(Vec::<(IVec3, (i32, bool, bool, [u32; 8]))>::new()); ++ let collected_tilemap_updates: Mutex> = 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 = +- HashMap::new(); ++ let mut local_tilemap_updates: HashMap = 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) { +- 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, //id, canStandIn, canStandOn, visiblyTransparent, astar_weight, visible_range +- pub fixture_tiles: HashMap, // id, canStandIn, canStandOn, visible_range +- pub item_tiles: HashMap>, // Entity.id's of items on this tile ++ pub floor_tiles: FxHashMap, ++ pub fixture_tiles: FxHashMap, ++ pub item_tiles: FxHashMap>, ++} ++ ++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> { ++ 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") diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index 36437a0..f9f92d3 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -183,8 +183,8 @@ pub fn prepare_paths( 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; + let goal = target.as_ivec3() - ivec3(0, 0, 1); + let distance = octile_distance_3d(start, goal); if distance <= PATHFINDER_SHORT_PATH_MAX_TILES { let path = calculate_path_benchmarked(&tilemap, start, goal); @@ -352,6 +352,8 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re let current_pos = transform.translation; if !is_standable_tile(&tilemap, current_pos.as_ivec3()) { transform.translation.z -= TILE_SIZE; + ambulatory.current_path = None; + ambulatory.target = None; return; } @@ -433,9 +435,9 @@ fn calculate_movement_cost(move_dir: IVec3) -> i32 { } 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 dx = (a.x - b.x).abs() / ITILE_SIZE; + let dy = (a.y - b.y).abs() / ITILE_SIZE; + let dz = (a.z - b.z).abs() / ITILE_SIZE; let (dmax, dmid, dmin) = sorted_desc(dx, dy, dz); 10 * dmax + 4 * dmid + dmin } @@ -767,7 +769,7 @@ fn write_benchmark_csv(bench: &PathfindingBenchmark, filename: &str) -> std::io: 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); + let success = i < n.saturating_sub(bench.total_failed_paths as usize); writeln!(file, "{},{},{},{},{}", i, duration, length, nodes, success)?; }