From 5a055c048ddccb5b0e58f34f6d26eb8dda318f2f Mon Sep 17 00:00:00 2001 From: irrlicht Date: Fri, 18 Sep 2026 23:17:41 +0200 Subject: [PATCH] Switch to 16-bit truecolor framebuffer and PNG assets pbio moves to git.bnd.wtf/irrlicht/pbio.git branch truecolor16: the framebuffer is now RGB565 u16, no palette. Assets are loaded from PNG via the png crate and quantized to RGB565 on load; transparency comes from the alpha channel as a per-tile opacity mask, so any color (black included) can be opaque. The TGA loader and the palette-order fragility are gone. overworld.png is the unmodified Mini-Medieval Overworld sheet (same 24-column layout as the old TGA, so map tile ids are unchanged); entities.png holds the player sprite extracted from the old entities.tga. Adds an ignored snapshot test that renders the first frame headlessly to a PNG for eyeballing the renderer without a window. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 83 +++++++++++++++++++++++++++++++++- assets/tilesets/entities.png | Bin 0 -> 127 bytes assets/tilesets/entities.tga | Bin 4100 -> 0 bytes assets/tilesets/overworld.png | Bin 0 -> 20992 bytes assets/tilesets/overworld.tga | Bin 47559 -> 0 bytes game/Cargo.toml | 3 +- game/src/assets.rs | 81 ++++++++++++++++----------------- game/src/game.rs | 51 ++++++++++++++++----- game/src/game/pixelhelper.rs | 26 ++++++----- game/src/main.rs | 1 - notes/02-asset_loader.md | 31 ++++++++----- notes/04-text_renderer.md | 13 +++--- notes/07-map-loader.md | 6 +-- notes/roadmap.md | 13 ++++-- 14 files changed, 212 insertions(+), 96 deletions(-) create mode 100644 assets/tilesets/entities.png delete mode 100755 assets/tilesets/entities.tga create mode 100755 assets/tilesets/overworld.png delete mode 100755 assets/tilesets/overworld.tga diff --git a/Cargo.lock b/Cargo.lock index 01e5b36..4db68d3 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -319,6 +325,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -389,12 +404,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -439,6 +474,7 @@ name = "game" version = "0.1.0" dependencies = [ "pbio", + "png", "sim", ] @@ -797,6 +833,26 @@ dependencies = [ "paste", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "naga" version = "27.0.3" @@ -1163,7 +1219,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pbio" version = "0.1.0" -source = "git+https://git.bnd.wtf/irrlicht/rust-pbio.git#2d814718200a5585216f90766693bca1582b3c0e" +source = "git+https://git.bnd.wtf/irrlicht/pbio.git?branch=truecolor16#8c647d3700fe3196d0fd511e80bc3c60b0b8f1ae" dependencies = [ "bytemuck", "pollster", @@ -1215,6 +1271,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "polling" version = "3.11.0" @@ -1476,6 +1545,12 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "sim" version = "0.1.0" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.1.1" @@ -2405,3 +2480,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/assets/tilesets/entities.png b/assets/tilesets/entities.png new file mode 100644 index 0000000000000000000000000000000000000000..978a123666c753abfff157db3bd5b06540f81260 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqZk{fVAr-fh6AmzL^X2;6uUJtX z{i0sr*vbe0|A+o}lvvF)fq^Gk!(kfBL(iQH3no8sdtTLc$k2ml3CB^c87w`=7z|`z aSTUYH>oQX{kYN|lLVuK6ot_{sbCi=XkiyY`XvMsU|+d67O*G#Q$r$*d`wESjRpswtXm znxff%P80@BzUUK$ale|P$$en!+9S+kV@Z>|cWu zbv~Z`1X&?zCOR5`&(#VpI_eolI-jA+&*Dnv)_F8^DVjWe9vueeog-_ zAD=F7a(n-DnS8yU?jHBYPITbX9!590-;dl>@GjN=N;|v^U q;5Y-v892_saR!bv@So1W{(t>``}qEJ^=W(<@7})r@_M|vdX>KpMyRy_ diff --git a/assets/tilesets/overworld.png b/assets/tilesets/overworld.png new file mode 100755 index 0000000000000000000000000000000000000000..7ef34d7dd1f82b68194e8cbf6d25c46057f11852 GIT binary patch literal 20992 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≤wn@b$13mlkP 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 X-_`%7{#XC9{~!K&{V)FbpM}2z#C>#| diff --git a/game/Cargo.toml b/game/Cargo.toml index 618001d..9274d10 100755 --- a/game/Cargo.toml +++ b/game/Cargo.toml @@ -5,4 +5,5 @@ edition = "2024" [dependencies] sim = { path = "../sim" } -pbio = { git = "https://git.bnd.wtf/irrlicht/rust-pbio.git" } +pbio = { git = "https://git.bnd.wtf/irrlicht/pbio.git", branch = "truecolor16" } +png = "0.18.1" diff --git a/game/src/assets.rs b/game/src/assets.rs index a4facea..5e8487a 100755 --- a/game/src/assets.rs +++ b/game/src/assets.rs @@ -1,49 +1,62 @@ use std::fs::File; -use std::io::Read; +use std::io::BufReader; +/// A decoded image in the framebuffer's pixel format: RGB565 plus a 1-bit alpha +/// mask. Truecolor sources are quantized on load (8 bit → 5/6/5 by truncation), so an +/// asset can never be in the "wrong" palette — there is none. pub struct Image { pub width: u32, pub height: u32, - pub pixels: Vec, + pub pixels: Vec, + /// One flag per pixel: `true` = opaque. Alpha ≥ 128 counts as opaque. + pub opaque: Vec, +} + +/// An 8×8 sprite: RGB565 pixels row-major plus an opacity bitmask (bit `y*8+x`). +#[derive(Clone, Copy)] +pub struct Tile { + pub px: [u16; 64], + pub mask: u64, } impl Image { - /// Load an 8-bit indexed TGA (type 1 or type 9) from disk. - pub fn from_tga(path: &str) -> Self { - let mut file = File::open(path).unwrap(); - let mut data = Vec::new(); - file.read_to_end(&mut data).unwrap(); + /// Load a PNG from disk. Any color type / bit depth the `png` crate can expand to + /// 8-bit RGBA is accepted. + pub fn from_png(path: &str) -> Self { + let file = File::open(path).unwrap_or_else(|e| panic!("failed to open {path}: {e}")); + let mut decoder = png::Decoder::new(BufReader::new(file)); + decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA); + let mut reader = decoder.read_info().unwrap_or_else(|e| panic!("bad PNG {path}: {e}")); + let mut buf = vec![0u8; reader.output_buffer_size().expect("PNG too large")]; + let info = reader.next_frame(&mut buf).unwrap_or_else(|e| panic!("bad PNG {path}: {e}")); + assert_eq!(info.color_type, png::ColorType::Rgba, "{path}: expected RGBA after expansion"); - let image_type = data[2]; - let width = u16::from_le_bytes([data[12], data[13]]) as u32; - let height = u16::from_le_bytes([data[14], data[15]]) as u32; - let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize; - let colormap_entry_bytes = data[7] as usize / 8; - let pixel_offset = 18 + colormap_entries * colormap_entry_bytes; - - let pixel_count = (width * height) as usize; - let mut pixels = vec![0u8; pixel_count]; - match image_type { - 1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]), - 9 => decode_rle(&data[pixel_offset..], &mut pixels), - _ => panic!("unsupported TGA image type: {}", image_type), + let count = (info.width * info.height) as usize; + let mut pixels = Vec::with_capacity(count); + let mut opaque = Vec::with_capacity(count); + for p in buf[..count * 4].chunks_exact(4) { + pixels.push(pbio::rgb565(p[0], p[1], p[2])); + opaque.push(p[3] >= 128); } - - Image { width, height, pixels } + Image { width: info.width, height: info.height, pixels, opaque } } /// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec. - pub fn to_tileset(&self) -> Vec<[u8; 64]> { + pub fn to_tileset(&self) -> Vec { let w = self.width as usize; let cols = w / 8; let rows = self.height as usize / 8; let mut tiles = Vec::with_capacity(rows * cols); for row in 0..rows { for col in 0..cols { - let mut tile = [0u8; 64]; + let mut tile = Tile { px: [0; 64], mask: 0 }; for tr in 0..8 { for tc in 0..8 { - tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc]; + let i = w * (row * 8 + tr) + col * 8 + tc; + tile.px[tr * 8 + tc] = self.pixels[i]; + if self.opaque[i] { + tile.mask |= 1 << (tr * 8 + tc); + } } } tiles.push(tile); @@ -52,21 +65,3 @@ impl Image { tiles } } - -fn decode_rle(src: &[u8], dst: &mut [u8]) { - let mut si = 0; - let mut di = 0; - while di < dst.len() { - let header = src[si]; - si += 1; - let count = (header & 0x7f) as usize + 1; - if header & 0x80 != 0 { - dst[di..di + count].fill(src[si]); - si += 1; - } else { - dst[di..di + count].copy_from_slice(&src[si..si + count]); - si += count; - } - di += count; - } -} diff --git a/game/src/game.rs b/game/src/game.rs index 4c7add1..e7b235b 100755 --- a/game/src/game.rs +++ b/game/src/game.rs @@ -3,7 +3,7 @@ mod pixelhelper; use std::collections::{HashMap, VecDeque}; -use crate::assets::Image; +use crate::assets::{Image, Tile}; use crate::input::{GameAction, InputState}; use sim::entity::Entity; use sim::map::TileMap; @@ -25,8 +25,11 @@ const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize; /// drag) is dropped instead of spiralling into a burst of ticks. const MAX_FRAME_MS: f32 = 250.0; -/// Palette index for the planned route. Dim blue in RGB332 (r=0, g=0, b=2). -const ROUTE_COLOR: u8 = 0b000_000_10; +/// Color of the planned route: dim blue. +const ROUTE_COLOR: u16 = pbio::rgb565(0, 0, 170); + +/// Fill for tiles outside the tileset / world. +const VOID_COLOR: u16 = pbio::rgb565(0, 0, 0); /// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer. const VIEW_TILES: i32 = 30; @@ -50,8 +53,8 @@ struct EntityLerp { } pub struct Game { - tileset: Vec<[u8; 64]>, - entity_tileset: Vec<[u8; 64]>, + tileset: Vec, + entity_tileset: Vec, sim: Sim, player_id: u32, /// Frame time not yet consumed by whole ticks. @@ -74,8 +77,8 @@ pub struct Game { impl Game { pub fn start() -> Self { - let tileset = Image::from_tga("assets/tilesets/overworld.tga").to_tileset(); - let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); + let tileset = Image::from_png("assets/tilesets/overworld.png").to_tileset(); + let entity_tileset = Image::from_png("assets/tilesets/entities.png").to_tileset(); let map = TileMap::from_csv("assets/map_test"); println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len()); @@ -109,7 +112,7 @@ impl Game { self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable()) } - pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState) + pub fn update(&mut self, render_frame: &mut [u16], dt: usize, input: &InputState) -> Option { if input.mouse_clicked() || input.mouse_held() { @@ -315,8 +318,7 @@ impl Game { } } - fn render_viewport(&self, frame: &mut [u8]) { - const WALL: u8 = 0x00; + fn render_viewport(&self, frame: &mut [u16]) { const W: usize = 320; frame.fill(0); @@ -345,7 +347,7 @@ impl Game { } else { for dy in 0..8 { for dx in 0..8 { - pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL); + pixelhelper::set_pixel(frame, W, px + dx, py + dy, VOID_COLOR); } } } @@ -383,3 +385,30 @@ impl Game { } } } + +#[cfg(test)] +mod snapshot { + /// Renders the first frame headlessly to the PNG named by `$SNAPSHOT` — a way to + /// eyeball the renderer without a window: + /// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored` + #[test] + #[ignore] + fn dump_frame() { + std::env::set_current_dir("..").unwrap(); + let mut g = super::Game::start(); + let input = crate::input::InputState::new(); + let mut frame = vec![0u16; 320 * 240]; + g.update(&mut frame, 0, &input); + let out = std::env::var("SNAPSHOT").unwrap(); + let mut rgb = Vec::with_capacity(320 * 240 * 3); + for p in &frame { + rgb.push(((p >> 11) as u8) << 3); + rgb.push((((p >> 5) & 63) as u8) << 2); + rgb.push(((p & 31) as u8) << 3); + } + let f = std::fs::File::create(out).unwrap(); + let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 320, 240); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&rgb).unwrap(); + } +} diff --git a/game/src/game/pixelhelper.rs b/game/src/game/pixelhelper.rs index 9ed81c8..f591221 100755 --- a/game/src/game/pixelhelper.rs +++ b/game/src/game/pixelhelper.rs @@ -1,6 +1,7 @@ -/// Write a single palette index. Out-of-bounds coordinates are silently ignored. -#[allow(dead_code)] -pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) { +use crate::assets::Tile; + +/// Write a single RGB565 pixel. Out-of-bounds coordinates are silently ignored. +pub fn set_pixel(dst: &mut [u16], dst_w: usize, x: i32, y: i32, color: u16) { let dst_h = (dst.len() / dst_w) as i32; if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h { return; @@ -10,8 +11,8 @@ pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) { /// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs). #[allow(dead_code)] -pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec { - let mut out = vec![0u8; w * h]; +pub fn extract(src: &[u16], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec { + let mut out = vec![0u16; w * h]; for row in 0..h { let s = src_w * (y + row) + x; let d = w * row; @@ -21,9 +22,10 @@ pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) } /// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy). -/// Clips against all four edges of `dst`; the destination position may be negative. +/// Opaque copy, no transparency. Clips against all four edges of `dst`; the +/// destination position may be negative. #[allow(dead_code)] -pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) { +pub fn blit(dst: &mut [u16], dst_w: usize, dx: i32, dy: i32, src: &[u16], src_w: usize) { let dst_w_i = dst_w as i32; let dst_h = (dst.len() / dst_w) as i32; let src_h = (src.len() / src_w) as i32; @@ -46,9 +48,9 @@ pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: u } } -/// Blit an 8×8 tile with transparency: index 0 is skipped. +/// Blit an 8×8 tile with transparency: pixels whose mask bit is clear are skipped. /// Clips against all four edges of `dst`; the destination position may be negative. -pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) { +pub fn blit_tile(dst: &mut [u16], dst_w: usize, dx: i32, dy: i32, tile: &Tile) { let dst_w_i = dst_w as i32; let dst_h = (dst.len() / dst_w) as i32; @@ -62,9 +64,9 @@ pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64] if x < 0 || x >= dst_w_i { continue; } - let px = tile[(row * 8 + col) as usize]; - if px != 0 { - dst[dst_w * y as usize + x as usize] = px; + let i = (row * 8 + col) as usize; + if tile.mask & (1 << i) != 0 { + dst[dst_w * y as usize + x as usize] = tile.px[i]; } } } diff --git a/game/src/main.rs b/game/src/main.rs index c2d8af8..045c7bf 100755 --- a/game/src/main.rs +++ b/game/src/main.rs @@ -11,7 +11,6 @@ fn main() { window_size: (800, 600), framebuffer_size: (320, 240), aspect_ratio: Some(4.0 / 3.0), - palette: pbio::palette::RGB332, vsync: true, mouse_capture: false, mouse_visible: true, diff --git a/notes/02-asset_loader.md b/notes/02-asset_loader.md index 86beac9..e3478be 100755 --- a/notes/02-asset_loader.md +++ b/notes/02-asset_loader.md @@ -2,15 +2,22 @@ ## Current state -TGA loading is implemented in `game/src/assets.rs`. `Image::from_tga(path)` reads the file, -parses the 18-byte header, skips the colormap block, and returns an `Image` — supporting -type 1 (uncompressed) and type 9 (RLE) via the private `decode_rle()` function in the same -module. `game.rs` uses it directly: `Image::from_tga("assets/tilesets/overworld.tga")`. +PNG loading is implemented in `game/src/assets.rs`. `Image::from_png(path)` decodes via the +`png` crate (any color type, expanded to 8-bit RGBA) and quantizes on load to the +framebuffer's RGB565 format with `pbio::rgb565`; alpha ≥ 128 counts as opaque. The result +is an `Image { width, height, pixels: Vec, opaque: Vec }`. Artists work in +truecolor; there is no palette an asset could be "in the wrong order" for. `game.rs` uses it +directly: `Image::from_png("assets/tilesets/overworld.png")`. -`Image` also provides `to_tileset() -> Vec<[u8; 64]>`, which splits the image into 8×8 -tiles row-major. Every tile is emitted; tile ID is the flat row-major index into the Vec, -so tiles are never skipped or reordered. (Transparency is a per-pixel concern handled at -blit time by `blit_tile`, not by the splitter.) +`Image` also provides `to_tileset() -> Vec`, which splits the image into 8×8 tiles +row-major. A `Tile` is 64 RGB565 pixels plus a `u64` opacity bitmask. Every tile is +emitted; tile ID is the flat row-major index into the Vec, so tiles are never skipped or +reordered. `overworld.png` is the unmodified Mini-Medieval `Overworld.png` sheet (24 +columns), so Tiled tile ids line up with the Vec index. + +History: the first iteration used 8-bit indexed TGAs on a palette framebuffer (RGB332, +`pbio` master branch). The colormap-ordering fragility and the index-0-is-transparent +convention are gone with the switch to `pbio`'s `truecolor16` branch. `AssetStore` (a named registry) is **not yet implemented**. Assets are currently loaded inline in `Game::start()`. @@ -22,12 +29,12 @@ files directly at the call site. ## Design notes -- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec` — done. +- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec`, `opaque: Vec` — done. - A simple `AssetStore` could be a `HashMap<&'static str, Image>` loaded at startup. - No streaming needed — the whole game is small enough to load everything upfront in `start()`. -- Palette index 0 is transparent when blitting sprites. This convention is enforced by - `blit_tile()` in `pixelhelper.rs`; the loader itself does not need to handle it. -- Pixel assets are stored as flat `Vec` of palette indices, row-major, width × height bytes. +- Transparency is the tile's mask bit, checked by `blit_tile()` in `pixelhelper.rs`. Any + color — black included — can be an opaque pixel. +- Pixel assets are stored as flat `Vec` of RGB565 values, row-major, width × height. - Future: consider embedding assets with `include_bytes!` to produce a single binary. ## Open questions diff --git a/notes/04-text_renderer.md b/notes/04-text_renderer.md index 4a2dbc9..849a9a2 100755 --- a/notes/04-text_renderer.md +++ b/notes/04-text_renderer.md @@ -6,20 +6,19 @@ No text rendering exists yet. No font loading or glyph blitting code exists. ## Goal -Draw ASCII text into the `u8` framebuffer using a bitmap font, respecting the palette-index -color model (one u8 per pixel, no RGBA). +Draw ASCII text into the RGB565 framebuffer using a bitmap font. ## Design notes -- A bitmap font is the right fit: each glyph is a small tile of palette indices, blitted +- A bitmap font is the right fit: each glyph is a small masked tile, blitted directly into the framebuffer using the existing blit primitive in `pixelhelper.rs`. -- Likely approach: a fixed 8×8 or similar glyph grid packed into a TGA or raw binary, +- Likely approach: a fixed 8×8 or similar glyph grid packed into a PNG, one glyph per ASCII codepoint starting at 0x20 (space). -- The font image itself should be indexed: foreground pixels carry a nonzero palette index - (caller supplies the color), background pixels are index 0 (transparent/skip). +- The font image only needs its alpha mask: glyph pixels are opaque, background transparent; + the caller supplies the color and `draw_text` paints the mask with it. - API sketch: ```rust - fn draw_text(frame: &mut [u8], font: &Font, x: i32, y: i32, color: u8, text: &str); + fn draw_text(frame: &mut [u16], font: &Font, x: i32, y: i32, color: u16, text: &str); ``` - `Font` holds the glyph sheet as an `Image` plus glyph width/height. diff --git a/notes/07-map-loader.md b/notes/07-map-loader.md index 267e303..9ded346 100644 --- a/notes/07-map-loader.md +++ b/notes/07-map-loader.md @@ -48,10 +48,10 @@ Tile ids are used directly: - **Sim** — `tile_flags(id)` (in `map.rs`) maps an id to gameplay `TileFlags` (collidable / opaque) via the `sim::tile_collidable` vocabulary. Hand-maintained; test values only. -- **Renderer** — 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. +- **Renderer** — the id indexes straight into `overworld.png` (`game.rs`). The tileset's tile + order *is* the id space; keep the Tiled tileset and `overworld.png` in lockstep. -No `firstgid` subtraction: the authored ids already line up with `overworld.tga`. +No `firstgid` subtraction: the authored ids already line up with `overworld.png`. --- diff --git a/notes/roadmap.md b/notes/roadmap.md index e3fa937..7d08e5b 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -5,7 +5,8 @@ Local single-player roguelike with a continuously simulated, tick-based world. Workspace layout: `sim/` (headless world simulation — tiles, entities, movement rules, tick loop; no platform dependencies, unit-testable) and `game/` (the executable — window, rendering, input, pathfinding, camera; owns a `Sim` and drives it). The platform layer — -window, GPU, input, RGB332 palette — lives in the external `pbio` crate (git dependency), +window, GPU, input, RGB565 framebuffer — lives in the external `pbio` crate (git dependency, +branch `truecolor16`), not in this repo. History: this fork started from a client/server multiplayer architecture (authoritative UDP @@ -17,9 +18,11 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou ## Achieved milestones -- [x] TGA loader: `Image`, `from_tga`, `to_tileset`, `decode_rle` in `game/src/assets.rs` +- [x] PNG loader: `Image::from_png` (via the `png` crate, quantized to RGB565 on load, alpha + mask), `to_tileset` → `Vec` in `game/src/assets.rs`. Framebuffer is 16-bit + truecolor (`pbio` branch `truecolor16`) - [x] Pixel helpers: `set_pixel`, `extract`, `blit`, `blit_tile` in `game/src/game/pixelhelper.rs` - — signed `i32` offsets, clip against all four framebuffer edges, index 0 = transparent + — signed `i32` offsets, clip against all four framebuffer edges, per-tile opacity mask - [x] Input system: `GameAction`, `InputMap`, `InputState` in `game/src/input.rs` (over `pbio::Key`) - [x] World model: `World` of 32×32 palette-compressed `Chunk`s (64-entry `TileDef` palette, 6-bit tile indices), entities indexed per chunk, `entities_in_viewport` @@ -27,7 +30,7 @@ crate's `tick`/`set_action` interface is the seam a server could be wrapped arou dimensions), `load_world` slices it into chunks, invisible solid border at the map rim. See `notes/07-map-loader.md` - [x] Tile rendering: chunk-based world rendering straight from `World` (`render_viewport`) -- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.tga`, blitted with transparency +- [x] Sprite-based entity rendering: entity `type_id` → tile in `entities.png`, blitted with transparency - [x] Hybrid tick loop: 24 Hz base tick, movement in `entity_tick` every 4th tick (6 Hz); the game loop drives the sim with a fixed-timestep accumulator. See `notes/06-hybrid-tick-loop.md` - [x] Intent model: one scheduled action per entity per movement window (`Sim::set_action`), @@ -118,7 +121,7 @@ Sprite rendering, camera follow and entity interpolation have all landed. ### Later — UI / HUD -- Bitmap font renderer: 8×8 glyph sheet TGA, `draw_text(frame, font, x, y, color, text)` — see `notes/04` +- Bitmap font renderer: 8×8 glyph sheet PNG, `draw_text(frame, font, x, y, color, text)` — see `notes/04` - UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar` — see `notes/05` - HUD layout (320×240): HP bar + player name bottom 16px, message log right panel - A real clip-rect on the blit primitives belongs here — once the viewport and HUD panels are