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 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 23:17:41 +02:00
co-authored by Claude Opus 5
parent 64a6e1d42d
commit 5a055c048d
14 changed files with 212 additions and 96 deletions
+19 -12
View File
@@ -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<u16>, opaque: Vec<bool> }`. 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<Tile>`, 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<u8>` — done.
- `Image` struct: `width: u32`, `height: u32`, `pixels: Vec<u16>`, `opaque: Vec<bool>` — 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<u8>` 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<u16>` of RGB565 values, row-major, width × height.
- Future: consider embedding assets with `include_bytes!` to produce a single binary.
## Open questions
+6 -7
View File
@@ -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.
+3 -3
View File
@@ -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`.
---
+8 -5
View File
@@ -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<Tile>` 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