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:
co-authored by
Claude Opus 5
parent
64a6e1d42d
commit
5a055c048d
+38
-43
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user