diff --git a/assets/map_test b/assets/map_test new file mode 100644 index 0000000..28de41f --- /dev/null +++ b/assets/map_test @@ -0,0 +1,30 @@ +25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25 +146,146,146,25,25,25,25,75,75,75,75,25,25,25,25,146,25,25,25,146,146,146,146,25,25,25,146,146,146,25 +25,146,146,25,75,75,75,75,75,75,146,146,146,146,146,25,25,25,25,25,25,25,25,25,25,25,25,146,146,25 +25,146,25,25,25,75,75,25,25,146,146,146,146,25,25,25,25,75,75,75,25,25,25,25,25,25,146,146,25,25 +25,146,146,25,25,25,75,75,75,75,146,146,146,146,25,25,25,75,75,75,75,75,25,25,25,25,146,25,25,25 +25,25,25,25,25,25,75,25,25,25,25,146,146,-2147483574,-2147483574,-2147483574,25,25,75,25,25,75,75,25,25,25,25,146,25,25 +25,25,25,25,25,25,75,25,25,25,25,-2147483574,-2147483574,146,25,-2147483574,-2147483574,25,75,25,25,25,75,25,25,25,25,146,25,25 +25,25,25,25,75,75,75,25,25,25,-2147483574,-2147483574,25,146,146,25,-2147483574,-2147483574,75,25,25,25,25,75,25,25,25,25,25,25 +25,25,25,75,75,75,25,25,25,-2147483574,-2147483574,25,25,25,146,146,25,-2147483574,-2147483574,75,25,25,25,75,75,25,25,25,25,25 +-2147483574,25,25,75,25,25,25,25,-2147483574,-2147483574,25,25,25,25,25,25,146,75,75,75,75,25,25,25,75,75,25,25,25,25 +25,-2147483574,-2147483574,75,25,25,25,25,-2147483574,25,25,25,25,25,25,25,25,146,75,75,75,75,25,25,25,75,25,25,25,25 +-2147483574,-2147483574,25,25,-2147483574,-2147483574,25,25,-2147483574,25,25,25,25,25,25,25,25,146,75,-2147483574,146,75,25,25,25,25,25,25,25,25 +25,25,-2147483574,25,25,25,-2147483574,-2147483574,-2147483574,-2147483574,25,25,25,25,25,75,75,75,146,75,146,146,146,25,25,25,25,25,25,25 +25,25,25,-2147483574,25,25,25,25,-2147483574,25,-2147483574,-2147483574,-2147483574,25,25,25,25,25,146,25,-2147483574,-2147483574,146,146,146,25,25,25,25,25 +25,25,25,25,-2147483574,-2147483574,25,25,-2147483574,-2147483574,-2147483574,25,25,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,25,146,-2147483574,-2147483574,-2147483574,25,25,146,25,25,25,25 +25,25,25,25,25,25,-2147483574,25,25,25,25,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,146,-2147483574,-2147483574,25,25,146,25,25,25,25 +25,25,25,25,25,25,25,-2147483574,25,25,25,25,25,25,25,25,-2147483574,-2147483574,25,25,146,-2147483574,-2147483574,25,146,146,25,25,25,25 +25,25,25,25,25,25,25,25,-2147483574,25,25,25,25,25,25,25,25,-2147483574,25,25,25,146,25,-2147483574,146,25,25,25,25,25 +25,25,25,25,25,25,25,25,-2147483574,25,25,25,25,25,25,25,25,-2147483574,25,25,25,25,146,146,146,-2147483574,-2147483574,25,25,25 +25,25,25,25,25,25,25,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,25,25,25,25,25,146,25,25,-2147483574,25,25,25 +25,25,25,25,75,25,25,25,25,25,-2147483574,-2147483574,25,25,25,25,25,25,25,25,25,25,25,146,25,25,25,25,25,25 +25,25,25,25,25,75,25,25,25,25,25,-2147483574,-2147483574,25,25,25,25,25,25,25,25,25,25,146,25,25,25,25,25,25 +25,25,25,25,25,25,75,25,25,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,-2147483574,25,25,25,25,25,25,25,25,25,146,146,25,25,25,25 +25,25,25,25,25,25,25,75,25,25,-2147483574,25,25,-2147483574,25,-2147483574,-2147483574,25,25,25,25,25,25,25,146,146,146,25,25,25 +25,25,146,25,25,25,25,25,75,25,25,-2147483574,-2147483574,25,-2147483574,-2147483574,-2147483574,25,25,25,25,25,25,146,146,146,146,25,25,25 +25,25,146,146,146,25,25,25,75,25,25,25,25,-2147483574,-2147483574,-2147483574,25,25,25,25,25,25,25,25,25,146,146,146,25,25 +25,25,146,146,146,146,25,25,25,75,25,75,75,75,75,75,75,75,75,25,25,25,25,25,25,25,25,146,146,25 +25,146,146,146,146,146,25,25,75,75,75,75,25,25,25,25,25,25,75,75,75,25,25,25,25,25,25,25,25,25 +25,25,146,25,25,25,25,75,75,75,75,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25 +25,25,25,25,25,25,25,25,25,75,75,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25,25 diff --git a/notes/07-map-loader.md b/notes/07-map-loader.md new file mode 100644 index 0000000..9b65971 --- /dev/null +++ b/notes/07-map-loader.md @@ -0,0 +1,77 @@ +# Map Loader + +Loads level data from a Tiled CSV export, replacing the old hardcoded `build_world` wall loop. +Implemented in `server/src/map.rs`; wired into the world in `server/src/main.rs::load_world`. + +--- + +## Format + +A bare comma-separated tile grid — Tiled's "CSV" layer export, stripped of the surrounding +TMX/XML. No header, no dimensions. The loader derives them from the content: + +- **width** = number of fields in the first row +- **height** = number of rows + +The current test map lives at `assets/map_test` (30×30, tile ids 25 / 74 / 75 / 146). +A trailing comma per row is tolerated; ragged rows panic (they indicate a corrupt export). + +--- + +## Tile flip flags — the "negative numbers" + +Tiled packs per-tile flip/rotation into the **top 3 bits** of each 32-bit GID. A flipped tile +therefore shows up in the CSV as a large *negative* decimal when read as `i32`. Example: + +``` +-2147483574 as u32 = 0x8000004A + │ └── 0x4A = 74 ← the real tile id + └────────── 0x80000000 = horizontal flip +``` + +| Bit | Mask | Meaning | +|-----|--------------|---------------------| +| 31 | `0x80000000` | horizontal flip | +| 30 | `0x40000000` | vertical flip | +| 29 | `0x20000000` | diagonal flip (rot) | + +The loader parses each field as `i64`, reinterprets the low 32 bits as `u32`, and strips the +flip flags with `& 0x1FFF_FFFF`, leaving the bare tile id. **Flip orientation is discarded for +now** — flipped tiles render unflipped. Real flipping would need a flip-aware `blit_tile` on +the client plus the flags carried through the protocol; deferred to the camera/sprite pass (09). + +--- + +## Tile id ↔ tileset + +Tile ids are used directly: + +- **Server** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags` + (collidable / opaque). Hand-maintained vocabulary; currently everything is walkable floor. +- **Client** — the id indexes straight into `overworld.tga` (`game.rs`). The tileset's tile + order *is* the id space; keep the Tiled tileset and `overworld.tga` in lockstep. + +No `firstgid` subtraction: the authored ids already line up with `overworld.tga`. + +--- + +## World assembly + +`load_world` slices the map into 32×32 chunks via `Chunk::generate`, which builds each chunk's +6-bit palette automatically. The map's top-left tile sits at world (0, 0). + +**Edge border:** a 30×30 map only fills part of chunk (0,0) (which spans tiles 0–31). Tiles +inside a loaded chunk but outside the authored map are emitted as an *invisible solid border* +(id 0, `COLLIDABLE`) so the walkable edge sits flush with the visible map rim rather than two +tiles past it at the chunk boundary. Beyond any loaded chunk, movement is blocked anyway by +`sim.rs`'s `map_or(true)` (no chunk = world edge). + +--- + +## Not done yet + +- Multiple layers (only a single tile layer is read) +- Tile properties from Tiled (collision comes from the hand-maintained `tile_flags`, not the file) +- Object layers (spawns, triggers) +- Tile flipping (orientation discarded — see above) + diff --git a/notes/roadmap.md b/notes/roadmap.md index db34575..34a1153 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -28,6 +28,9 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i - [x] Ping/Pong RTT measurement (`PingPacket` / `PongPacket`) - [x] Connection timeout: client emits `Disconnected` after 10 s without a `StatePacket`; server evicts clients unseen for 10 s +- [x] Map loader: Tiled-CSV parser in `server/src/map.rs` (flip-flag masking, content-derived + dimensions), `load_world` slices it into chunks, invisible solid border at the map rim. + Replaces the hardcoded `build_world`. See `notes/07-map-loader.md` --- @@ -48,14 +51,14 @@ This supersedes the original 20/10/1 Hz three-tier sketch. --- -### 07 — Tiled map loader +### 07 — Tiled map loader *(done — see Achieved / `notes/07-map-loader.md`)* -Replace the hardcoded `build_world` wall loop with real level data. +Remaining follow-ups when the need is concrete: -- Parse Tiled TMX (XML) or JSON export — only the subset actually used -- Define the tile vocabulary: `TileKind` variants map 1:1 to Tiled tile IDs -- Load a hand-authored starting area as the first real level -- Procedural generation comes later; hand-authored first +- Collision vocabulary: `tile_flags` in `map.rs` is currently empty (all walkable). Fill in + collidable / opaque tile ids as the level design requires. +- Tile flipping: orientation is discarded on load; revisit with the sprite pass (09). +- Multiple / object layers (spawns, triggers) — not yet parsed. --- diff --git a/server/src/main.rs b/server/src/main.rs index 40e566b..8dcd7ec 100755 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3,6 +3,7 @@ mod entity; mod world; mod sim; mod net; +mod map; use std::thread; use std::time::{Duration, Instant}; @@ -11,22 +12,28 @@ use chunk::{Chunk, TileDef, TileFlags}; use world::World; use sim::Sim; use net::Net; +use map::TileMap; const TICK_HZ: u32 = 24; const TICK_DURATION: Duration = Duration::from_micros(1_000_000 / TICK_HZ as u64); -fn build_world() -> World { +/// 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. +fn load_world(map: &TileMap) -> World { let mut w = World::new(); - for cy in -1i16..=1 { - for cx in -1i16..=1 { + let max_cx = (map.width.saturating_sub(1) >> 5) as i16; + let max_cy = (map.height.saturating_sub(1) >> 5) as i16; + for cy in 0..=max_cy { + for cx in 0..=max_cx { let chunk = Chunk::generate(|lx, ly| { let (wx, wy) = world::local_to_tile(cx, cy, lx, ly); - let border = wx == -32 || wx == 31 || wy == -32 || wy == 31; - if border { - TileDef { tile_id: 1, flags: TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE) } - } else { - TileDef::default() + 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"); w.set_chunk(cx, cy, chunk); } @@ -35,7 +42,9 @@ fn build_world() -> World { } fn main() { - let world = build_world(); + let map = TileMap::from_csv("assets/map_test"); + println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len()); + let world = load_world(&map); let mut sim = Sim::new(world); let mut net = Net::new("127.0.0.1:7777").expect("failed to bind UDP socket"); diff --git a/server/src/map.rs b/server/src/map.rs new file mode 100644 index 0000000..6d436be --- /dev/null +++ b/server/src/map.rs @@ -0,0 +1,77 @@ +//! Minimal Tiled-CSV map loader. +//! +//! Reads a bare comma-separated tile grid (Tiled's "CSV" layer export, stripped of the +//! surrounding TMX/XML). Dimensions are derived from the content: columns from the first +//! row, height from the row count. Tiled encodes per-tile flip/rotation in the top bits of +//! each 32-bit GID, which makes flipped tiles show up as large negative decimals — those +//! bits are masked off here, leaving the bare tile_id. Flip orientation is discarded for now. + +use crate::chunk::TileFlags; + +/// Top three bits hold horizontal / vertical / diagonal flip flags; the rest is the tile id. +const GID_FLIP_MASK: u32 = 0x1FFF_FFFF; + +pub struct TileMap { + pub width: u16, + pub height: u16, + pub tiles: Vec, // row-major, len == width * height +} + +impl TileMap { + pub fn from_csv(path: &str) -> Self { + let text = std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("failed to read map {path}: {e}")); + + let mut tiles = Vec::new(); + let mut width = 0usize; + let mut height = 0usize; + + for line in text.lines() { + if line.trim().is_empty() { + continue; + } + let mut cols = 0usize; + for field in line.split(',') { + let field = field.trim(); + if field.is_empty() { + continue; // tolerate a trailing comma + } + // Parse as i64 so the "negative" flipped GIDs fit, then reinterpret the low + // 32 bits as u32 and strip the flip flags. + let raw = field.parse::() + .unwrap_or_else(|_| panic!("invalid tile value {field:?} in {path}")); + let gid = (raw as u32) & GID_FLIP_MASK; + tiles.push(gid as u16); + cols += 1; + } + if width == 0 { + width = cols; + } else if cols != width { + panic!("ragged map row in {path}: expected {width} cols, got {cols}"); + } + height += 1; + } + + TileMap { width: width as u16, height: height as u16, tiles } + } + + /// Tile id at map coordinates, or 0 (empty) when outside the map bounds. + pub fn tile(&self, x: i16, y: i16) -> u16 { + if x < 0 || y < 0 || x >= self.width as i16 || y >= self.height as i16 { + return 0; + } + self.tiles[y as usize * self.width as usize + x as usize] + } +} + +/// Gameplay flags for a global tile id. Hand-maintained vocabulary — extend as the +/// tileset grows. Unknown ids default to a plain walkable floor tile. +/// +/// Tile ids correspond to indices into `overworld.tga` on the client side. +pub fn tile_flags(tile_id: u16) -> TileFlags { + match tile_id { + // TODO: fill in collidable / opaque tiles from overworld.tga, e.g. + // 146 => TileFlags(TileFlags::COLLIDABLE | TileFlags::OPAQUE), + _ => TileFlags::default(), + } +}