Drop the chunk palette; chunks are plain u16 tile arrays

The 64-entry per-chunk palette was a wire-format artifact and capped a chunk
at 64 distinct tiles. A Chunk is now Box<[u16; 1024]> with flags derived from
the id on lookup — the same layout a binary on-disk format would use.

Map path becomes the first CLI argument; assets/map_large is a generated
256×256 stress map (loads in ~0.5 ms release). The snapshot test takes
SNAPSHOT_MAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 23:40:52 +02:00
co-authored by Claude Opus 5
parent 5a055c048d
commit 1d14076468
10 changed files with 317 additions and 104 deletions
+21 -70
View File
@@ -1,3 +1,4 @@
/// Gameplay properties of a tile, derived from its id (see `map::tile_flags`).
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileFlags(pub u8);
@@ -11,92 +12,42 @@ impl TileFlags {
pub fn liquid(self) -> bool { self.0 & Self::LIQUID != 0 }
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
pub struct TileDef { pub tile_id: u16, pub flags: TileFlags }
#[derive(Debug)]
pub struct PaletteFullError;
pub const CHUNK_SIZE: usize = 32;
pub const CHUNK_TILES: usize = CHUNK_SIZE * CHUNK_SIZE;
/// A 32×32 block of tile ids, row-major. This is the whole chunk: flags are derived from
/// the id on lookup, so the in-memory layout is already the plain `u16` array a binary
/// on-disk format would store.
pub struct Chunk {
pub palette: [TileDef; 64],
pub pal_count: u8,
pub tiles: Box<[u8; 1024]>,
pub version: u16,
pub tiles: Box<[u16; CHUNK_TILES]>,
}
impl Chunk {
/// Runtime use: all tiles empty, pal_count=1, version=1.
/// Clients with version=0 will always request this chunk.
/// All tiles id 0 (empty / solid border).
pub fn new() -> Self {
Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 1,
}
Self { tiles: Box::new([0; CHUNK_TILES]) }
}
/// Find existing palette entry or insert a new one.
/// Index 0 is always the null sentinel; this short-circuits for default TileDef.
/// Returns (palette_index, was_new). Does NOT bump version.
fn find_or_insert_raw(&mut self, def: TileDef) -> Result<(u8, bool), PaletteFullError> {
if def == TileDef::default() {
return Ok((0, false));
}
for i in 1..self.pal_count as usize {
if self.palette[i] == def {
return Ok((i as u8, false));
/// World gen path. Calls the closure for every local tile coordinate.
pub fn generate(f: impl Fn(u8, u8) -> u16) -> Self {
let mut chunk = Self::new();
for ly in 0..CHUNK_SIZE {
for lx in 0..CHUNK_SIZE {
chunk.tiles[ly * CHUNK_SIZE + lx] = f(lx as u8, ly as u8);
}
}
if self.pal_count >= 64 {
return Err(PaletteFullError);
}
let idx = self.pal_count;
self.palette[idx as usize] = def;
self.pal_count += 1;
Ok((idx, true))
chunk
}
/// World gen path. Calls the closure for every tile coordinate.
/// version=0 so the first StatePacket causes all clients to request it.
pub fn generate(f: impl Fn(u8, u8) -> TileDef) -> Result<Self, PaletteFullError> {
let mut chunk = Self {
palette: [TileDef::default(); 64],
pal_count: 1,
tiles: Box::new([0u8; 1024]),
version: 0,
};
for ly in 0u8..32 {
for lx in 0u8..32 {
let def = f(lx, ly);
let (idx, _) = chunk.find_or_insert_raw(def)?;
chunk.tiles[ly as usize * 32 + lx as usize] = idx;
}
}
chunk.version = 1;
Ok(chunk)
pub fn set_tile(&mut self, lx: u8, ly: u8, id: u16) {
self.tiles[ly as usize * CHUNK_SIZE + lx as usize] = id;
}
/// Set a tile at local coords. Bumps version if tile index or palette changed.
pub fn set_tile(&mut self, lx: u8, ly: u8, def: TileDef) -> Result<(), PaletteFullError> {
let (idx, was_new) = self.find_or_insert_raw(def)?;
let slot = &mut self.tiles[ly as usize * 32 + lx as usize];
let changed = *slot != idx;
*slot = idx;
if changed || was_new {
self.version = self.version.wrapping_add(1);
if self.version == 0 {
self.version = 1;
}
}
Ok(())
}
pub fn get_tile(&self, lx: u8, ly: u8) -> TileDef {
self.palette[self.tiles[ly as usize * 32 + lx as usize] as usize]
pub fn get_tile(&self, lx: u8, ly: u8) -> u16 {
self.tiles[ly as usize * CHUNK_SIZE + lx as usize]
}
pub fn tile_flags(&self, lx: u8, ly: u8) -> TileFlags {
self.get_tile(lx, ly).flags
crate::map::tile_flags(self.get_tile(lx, ly))
}
}
+5 -9
View File
@@ -9,7 +9,7 @@ mod sim;
pub use sim::Sim;
use chunk::{Chunk, TileDef, TileFlags};
use chunk::Chunk;
use map::TileMap;
use world::World;
@@ -99,8 +99,8 @@ pub fn tile_collidable(tile_id: u16) -> bool {
/// Build a world from a loaded tile map. The map's top-left tile sits at world (0, 0); the
/// map is sliced into 32×32 chunks. Tiles inside a loaded chunk but outside the authored map
/// become an invisible solid border (id 0, collidable), so the walkable world edge sits flush
/// with the visible map rim instead of with the larger chunk boundary.
/// get id 0 (solid, see `tile_collidable`), so the walkable world edge sits flush with the
/// visible map rim instead of with the larger chunk boundary.
pub fn load_world(map: &TileMap) -> World {
let mut w = World::new();
let max_cx = (map.width.saturating_sub(1) >> 5) as i16;
@@ -109,12 +109,8 @@ pub fn load_world(map: &TileMap) -> World {
for cx in 0..=max_cx {
let chunk = Chunk::generate(|lx, ly| {
let (wx, wy) = world::local_to_tile(cx, cy, lx, ly);
if wx < 0 || wy < 0 || wx >= map.width as i16 || wy >= map.height as i16 {
return TileDef { tile_id: 0, flags: TileFlags(TileFlags::COLLIDABLE) };
}
let id = map.tile(wx, wy);
TileDef { tile_id: id, flags: map::tile_flags(id) }
}).expect("palette overflow during world gen");
map.tile(wx, wy) // 0 outside the map
});
w.set_chunk(cx, cy, chunk);
}
}
+3 -3
View File
@@ -64,9 +64,9 @@ impl TileMap {
}
}
/// Gameplay flags for a global tile id. Collision comes from the shared movement
/// vocabulary (`crate::tile_collidable`) so the client can predict it identically;
/// other flags (opaque, …) stay sim-side. Tile ids index into `overworld.tga`.
/// Gameplay flags for a global tile id. Collision comes from the movement vocabulary
/// (`crate::tile_collidable`) so the pathfinder predicts it identically; other flags
/// (opaque, …) will join here. Tile ids index into `overworld.png`.
pub fn tile_flags(tile_id: u16) -> TileFlags {
let mut bits = 0u8;
if crate::tile_collidable(tile_id) {
+4 -6
View File
@@ -69,16 +69,14 @@ impl Sim {
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{Chunk, TileDef, TileFlags};
use crate::chunk::Chunk;
use crate::player_action;
/// Open floor (id 1) with a single wall tile at (5, 4) — id 146 is collidable in
/// the tile vocabulary.
fn open_world() -> World {
let mut w = World::new();
w.set_chunk(0, 0, Chunk::generate(|lx, ly| {
// A single wall tile at (5, 4).
let flags = if (lx, ly) == (5, 4) { TileFlags::COLLIDABLE } else { 0 };
TileDef { tile_id: 1, flags: TileFlags(flags) }
}).unwrap());
w.set_chunk(0, 0, Chunk::generate(|lx, ly| if (lx, ly) == (5, 4) { 146 } else { 1 }));
w
}
+3 -3
View File
@@ -1,5 +1,5 @@
use std::collections::HashMap;
use crate::chunk::{Chunk, TileDef, TileFlags, PaletteFullError};
use crate::chunk::{Chunk, TileFlags};
use crate::entity::Entity;
pub fn tile_to_chunk(tx: i16, ty: i16) -> (i16, i16) {
@@ -45,11 +45,11 @@ impl World {
self.chunks.get(&(cx, cy)).map(|c| c.tile_flags(lx, ly))
}
pub fn set_tile(&mut self, wx: i16, wy: i16, def: TileDef) -> Result<(), PaletteFullError> {
pub fn set_tile(&mut self, wx: i16, wy: i16, id: u16) {
let (cx, cy) = tile_to_chunk(wx, wy);
let (lx, ly) = tile_to_local(wx, wy);
let chunk = self.chunks.entry((cx, cy)).or_insert_with(Chunk::new);
chunk.set_tile(lx, ly, def)
chunk.set_tile(lx, ly, id);
}
pub fn spawn_entity(&mut self, type_id: u16, pos: (i16, i16), hp: u16) -> u32 {