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 0000000..978a123 Binary files /dev/null and b/assets/tilesets/entities.png differ diff --git a/assets/tilesets/entities.tga b/assets/tilesets/entities.tga deleted file mode 100755 index 0e06725..0000000 Binary files a/assets/tilesets/entities.tga and /dev/null differ diff --git a/assets/tilesets/overworld.png b/assets/tilesets/overworld.png new file mode 100755 index 0000000..7ef34d7 Binary files /dev/null and b/assets/tilesets/overworld.png differ diff --git a/assets/tilesets/overworld.tga b/assets/tilesets/overworld.tga deleted file mode 100755 index 5158ce4..0000000 Binary files a/assets/tilesets/overworld.tga and /dev/null differ 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