Clearing stand_in_floor when removing a floor tile made the dug position impassable. An entity falling into the dug slot found (false || false) && true = false and kept falling. Now only stand_on_floor is cleared — the space reverts to air (passable), and only the tile above loses its platform.
351 lines
12 KiB
Rust
351 lines
12 KiB
Rust
//! 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::world::chunks::{world_to_chunk, CHUNK_SIZE, Z_ABOVE, Z_BELOW};
|
|
|
|
/// 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 {
|
|
/// O(1) standability lookups via bitsets (~2KB per chunk).
|
|
pub chunks: FxHashMap<IVec2, ChunkData>,
|
|
/// Primary tile storage for pathfinding (fallback for standability).
|
|
pub floor_tiles: FxHashMap<IVec3, FloorTileData>,
|
|
/// Secondary tile storage (fixtures like trees can be standable).
|
|
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
|
|
/// Entity references per tile position.
|
|
pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
|
|
}
|
|
|
|
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) {
|
|
self.floor_tiles.insert(pos, tile);
|
|
}
|
|
|
|
#[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<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)
|
|
}
|
|
|
|
/// 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(100)
|
|
}
|
|
|
|
/// 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<FixtureTileData> {
|
|
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<FloorTileData> {
|
|
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);
|
|
}
|
|
}
|