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
Generated
+82 -1
View File
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.
+2 -1
View File
@@ -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"
+38 -43
View File
@@ -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<u8>,
pub pixels: Vec<u16>,
/// One flag per pixel: `true` = opaque. Alpha ≥ 128 counts as opaque.
pub opaque: Vec<bool>,
}
/// 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<Tile> {
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;
}
}
+40 -11
View File
@@ -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<Tile>,
entity_tileset: Vec<Tile>,
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<GameSignal>
{
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();
}
}
+14 -12
View File
@@ -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<u8> {
let mut out = vec![0u8; w * h];
pub fn extract(src: &[u16], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u16> {
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];
}
}
}
-1
View File
@@ -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,
+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