//! Tile map storage for pathfinding and rendering. //! //! # Design Choices //! //! ## FxHashMap over HashMap //! Uses `rustc_hash::FxHashMap` instead of std HashMap. FxHash is 30-50% faster //! for integer keys (IVec3) because it uses a simpler hash function optimized //! for hashable-by-bit patterns. ~1.3M tiles are stored, so lookup speed matters. //! //! ## Packed Tile Data //! FloorTileData is ~35 bytes vs 76 bytes for a naive tuple. FixtureTileData is //! ~18 bytes vs 48 bytes. Bit-packing flags (can_stand_in/on, visibly_transparent) //! reduces memory footprint and improves cache locality. //! //! ## ChunkData for O(1) Standability //! Each chunk stores bit-packed standability data. The `is_standable()` method //! checks chunk data first (4 bit-checks) before falling back to HashMap lookups. //! This replaces 4 HashMap lookups with O(1) bit operations. //! //! ## Single-Threaded Access //! No Arc wrapper because pathfinding runs on the main thread using thread-local //! scratchpads. Async pathfinding was attempted but snapshot copying overhead //! exceeded the benefit given current P99 (~357µs). //! //! ## Memory Layout //! - chunks: O(1) standability lookups via bitsets (~2KB per chunk) //! - floor_tiles: Primary pathfinding data (standability checks) //! - fixture_tiles: Secondary checks (fixtures can be standable) //! - item_tiles: Entity references per tile position use bevy::prelude::*; use rustc_hash::FxHashMap; use super::chunk_data::ChunkData; use crate::constants::ITILE_SIZE; use crate::entities::item::drop_table::DropTable; use crate::world::chunks::{world_to_chunk, CHUNK_SIZE, Z_ABOVE, Z_BELOW}; use crate::world::tiles::magic_numbers::ASTAR_DEFAULT_WEIGHT; /// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple. #[derive(Clone, 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], pub drop_table: DropTable, } impl Default for FloorTileData { fn default() -> Self { Self { id: 0, flags: 0b001, astar_weight: 0, visible_range: [0; 8], drop_table: DropTable::default(), } } } 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], drop_table: DropTable, ) -> 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, drop_table, } } #[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 { /// O(1) standability lookups via bitsets (~2KB per chunk). pub chunks: FxHashMap, /// Primary tile storage for pathfinding (fallback for standability). pub floor_tiles: FxHashMap, /// Secondary tile storage (fixtures like trees can be standable). pub fixture_tiles: FxHashMap, /// Entity references per tile position. pub item_tiles: FxHashMap>, } impl TileMap { #[inline] pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> { self.floor_tiles.get(pos) } #[inline] pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) { let chunk_pos = world_to_chunk(pos); if let Some(chunk) = self.chunks.get_mut(&chunk_pos) { let (lx, ly, z) = ChunkData::world_to_local(pos); if z >= -(Z_BELOW as i32) && z <= Z_ABOVE as i32 && lx >= 0 && lx < CHUNK_SIZE && ly >= 0 && ly < CHUNK_SIZE { chunk.set_floor_tile( lx, ly, z, tile.id, tile.can_stand_in(), tile.can_stand_on(), tile.astar_weight, ); } } self.floor_tiles.insert(pos, tile); } pub fn dig_floor(&mut self, pos: &IVec3) -> Option { let removed = self.remove_floor(pos); if removed.is_some() { let air = crate::config::TileRegistry::global().floor("air"); self.insert_floor( *pos, FloorTileData::new( air.id, air.can_stand_in, air.can_stand_on, air.transparent, air.astar_weight, [0; 8], DropTable::default(), ), ); } removed } #[inline] pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) { let chunk_pos = world_to_chunk(pos); if let Some(chunk) = self.chunks.get_mut(&chunk_pos) { let (lx, ly, z) = ChunkData::world_to_local(pos); chunk.set_fixture_tile(lx, ly, z, tile.can_stand_in(), tile.can_stand_on()); // If fixture blocks entry, also clear the floor's stand_in bit at this position. // Air floor tiles exist at above-ground positions — without this, air's // can_stand_in=true would override the fixture block via the OR check. if !tile.can_stand_in() { let idx = ChunkData::pos_to_index(lx, ly, z); let word = idx / 32; let mask = !(1u32 << (idx % 32)); chunk.stand_in_floor[word] &= mask; } } 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) } /// O(1) standability check using bit-packed chunk data. /// Returns false if chunk is not loaded (unloaded chunks have no valid tiles). pub fn is_standable(&self, world_pos: IVec3) -> bool { let chunk_pos = world_to_chunk(world_pos); let Some(chunk) = self.chunks.get(&chunk_pos) else { return false; }; let (local_x, local_y, z) = ChunkData::world_to_local(world_pos); chunk.is_standable(local_x, local_y, z) } /// Get A* pathfinding weight for a tile position. /// Returns 100 (default) if tile not found. Lower is better. pub fn get_astar_weight(&self, world_pos: IVec3) -> u8 { let chunk_pos = world_to_chunk(world_pos); if let Some(chunk) = self.chunks.get(&chunk_pos) { let (local_x, local_y, z) = ChunkData::world_to_local(world_pos); return chunk.get_astar_weight(local_x, local_y, z); } self.floor_tiles .get(&world_pos) .map(|t| t.astar_weight) .unwrap_or(ASTAR_DEFAULT_WEIGHT) } /// Remove a fixture tile, clearing both the HashMap entry and ChunkData bitsets. /// BOTH must be cleared — leaving ChunkData stale causes is_standable bugs. pub fn remove_fixture(&mut self, pos: &IVec3) -> Option { let removed = self.fixture_tiles.remove(pos); let chunk_pos = world_to_chunk(*pos); if let Some(chunk) = self.chunks.get_mut(&chunk_pos) { let (lx, ly, z) = ChunkData::world_to_local(*pos); // Guard bounds before pos_to_index — out-of-range z panics with overflow. assert!( z >= -(Z_BELOW as i32) && z <= Z_ABOVE as i32, "remove_fixture: z={} out of bounds [{}, {}] at pos={:?}", z, -Z_BELOW, Z_ABOVE, pos ); assert!( (0..CHUNK_SIZE).contains(&lx) && (0..CHUNK_SIZE).contains(&ly), "remove_fixture: local coords ({}, {}) out of bounds [0, {}) at pos={:?}", lx, ly, CHUNK_SIZE, pos ); let idx = ChunkData::pos_to_index(lx, ly, z); let word = idx / 32; let clear_mask = !(1u32 << (idx % 32)); chunk.stand_in_fixture[word] &= clear_mask; chunk.stand_on_fixture[word] &= clear_mask; } removed } /// Remove a floor tile, clearing the HashMap entry, ChunkData bitsets, and tile_ids. /// All three must be cleared — leaving ChunkData stale causes is_standable bugs, /// and leaving tile_ids non-zero causes the renderer to keep drawing the tile. pub fn remove_floor(&mut self, pos: &IVec3) -> Option { let removed = self.floor_tiles.remove(pos); let chunk_pos = world_to_chunk(*pos); if let Some(chunk) = self.chunks.get_mut(&chunk_pos) { let (lx, ly, z) = ChunkData::world_to_local(*pos); // Guard bounds before pos_to_index — out-of-range z panics with overflow. // This fires when a rabbit digs the floor below the world minimum z, // leaving no standable tile and causing the entity to fall past the floor. assert!( z >= -(Z_BELOW as i32) && z <= Z_ABOVE as i32, "remove_floor: z={} out of bounds [{}, {}] at pos={:?}", z, -Z_BELOW, Z_ABOVE, pos ); assert!( (0..CHUNK_SIZE).contains(&lx) && (0..CHUNK_SIZE).contains(&ly), "remove_floor: local coords ({}, {}) out of bounds [0, {}) at pos={:?}", lx, ly, CHUNK_SIZE, pos ); let idx = ChunkData::pos_to_index(lx, ly, z); let word = idx / 32; let clear_mask = !(1u32 << (idx % 32)); // Only clear stand_on_floor. Removing a floor tile means the space becomes // air — stand_in_floor should stay true so entities can pass through. // Only stand_on_floor needs clearing: the tile above can no longer stand on // a tile that doesn't exist. Clearing stand_in_floor made dug positions // impassable, causing entities to fall through multiple levels. chunk.stand_on_floor[word] &= clear_mask; chunk.tile_ids[idx] = 0; } removed } /// Remove all tile data for a specific chunk from the TileMap. /// Iterates all positions in the chunk volume and removes from HashMaps. /// Used during chunk unloading to clean up tile data. pub fn remove_chunk_data(&mut self, chunk_pos: IVec2) { for local_x in 0..CHUNK_SIZE { for local_y in 0..CHUNK_SIZE { for z in -Z_BELOW as i32..=Z_ABOVE as i32 { let pos = IVec3::new( chunk_pos.x * CHUNK_SIZE + local_x, chunk_pos.y * CHUNK_SIZE + local_y, z, ) * ITILE_SIZE; self.floor_tiles.remove(&pos); self.fixture_tiles.remove(&pos); self.item_tiles.remove(&pos); } } } self.chunks.remove(&chunk_pos); } }