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>
7.4 KiB
Executable File
Roadmap
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, 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
server, thin client with prediction). The scope was cut to single player in September 2026;
the sim kept its authoritative shape, everything network-related was removed. The sim
crate's tick/set_action interface is the seam a server could be wrapped around again.
Achieved milestones
- PNG loader:
Image::from_png(via thepngcrate, quantized to RGB565 on load, alpha mask),to_tileset→Vec<Tile>ingame/src/assets.rs. Framebuffer is 16-bit truecolor (pbiobranchtruecolor16) - Pixel helpers:
set_pixel,extract,blit,blit_tileingame/src/game/pixelhelper.rs— signedi32offsets, clip against all four framebuffer edges, per-tile opacity mask - Input system:
GameAction,InputMap,InputStateingame/src/input.rs(overpbio::Key) - World model:
Worldof 32×32 palette-compressedChunks (64-entryTileDefpalette, 6-bit tile indices), entities indexed per chunk,entities_in_viewport - Map loader: Tiled-CSV parser in
sim/src/map.rs(flip-flag masking, content-derived dimensions),load_worldslices it into chunks, invisible solid border at the map rim. Seenotes/07-map-loader.md - Tile rendering: chunk-based world rendering straight from
World(render_viewport) - Sprite-based entity rendering: entity
type_id→ tile inentities.png, blitted with transparency - Hybrid tick loop: 24 Hz base tick, movement in
entity_tickevery 4th tick (6 Hz); the game loop drives the sim with a fixed-timestep accumulator. Seenotes/06-hybrid-tick-loop.md - Intent model: one scheduled action per entity per movement window (
Sim::set_action), replaced on re-set, consumed on execution. Headless tests insim/src/sim.rs - 8-directional movement in chessboard geometry: world physics use the Chebyshev
metric — diagonal and cardinal steps are the same distance, a "circle" is a square
of tiles, matching the square viewport. The single-step rule lives in
sim::step_allowed(king move onto a free tile, diagonals additionally need both orthogonal neighbors free — no corner cutting) and is the one function used by the sim and the A*. Two held keys walk diagonally. - Collision vocabulary seeded with test values (
sim::tile_collidable): id 146 (trees/rocks) and id 0 (empty / the invisible world border). A proper tile-data file format replaces this table later. - Click-to-move: framebuffer click → world tile, A* over the world
(
game/src/game/pathfind.rs, 8-connected, Chebyshev heuristic), route fed into the sim one step per movement window. Keyboard input cancels the route; each step is re-validated at scheduling time and a blocked step replans toward the persistent goal. Click-and-hold steers continuously: while the button is held the route keeps replanning toward the tile under the cursor (only when that tile changes — cursor or camera movement), sweeping across blocked tiles keeps the current route. - Smooth camera follow: the camera is a float pixel position (
camingame.rs) that tracks the player linearly at ~64 px/s, snapping only to whole pixels at render time (and outright on teleport-sized corrections). The viewport renders with sub-tile offsets (31×31 tile pass + right-strip clip). - Entity interpolation: per-id lerp table (
EntityLerpingame.rs) — previous/current tile plus a clock, rendered as a pixel lerp over one movement interval. Jumps of more than one tile (Chebyshev) snap. Purely cosmetic; game logic keeps using the sim's tile positions.
Upcoming work
06 — Hybrid tick loop (partially done)
Design of record: notes/06-hybrid-tick-loop.md.
- 24 Hz base loop driven from the game loop
- Stride layout settled:
entity_tickat%4(6 Hz, sets movement rate) - Action-point model (
energy/speedper entity, act whenenergy >= ACTION_COST). Removed fromentity.rsfor now as premature — reintroduce when NPCs (08) actually need it. Thenentity_tickcan move to%2(12 Hz) andspeedsets the effective movement rate.
07 — Tiled map loader (done — see Achieved / notes/07-map-loader.md)
Remaining follow-ups when the need is concrete:
- Collision vocabulary: seeded with test values (0, 146) in
sim::tile_collidable. Decide on a proper file format for tile data (collision, opacity, …) instead of a hardcoded match. - Tile flipping: orientation is discarded on load; revisit with the sprite pass (09).
- Multiple / object layers (spawns, triggers) — not yet parsed. The player currently spawns at (0, 0).
08 — Basic NPC entity + AI budget
One dumb wandering enemy. Validates the simulation architecture before complexity accumulates.
Depends on the action-point model from 06 being reintroduced. NPCs schedule intents through
the same Sim::set_action the player uses.
- Add
EntityKind::Npcwith athink() -> u32method returning budget cost - Per sim-tick: distribute a fixed
think_budgetacross entities ordered by player proximity - Complex entities consume more budget; simple ones less. Loop breaks at zero — natural load shedding.
- No framework. No trait objects yet. A match on
EntityKindis fine.
let mut budget: u32 = BASE_BUDGET;
for entity in entities_by_player_proximity() {
if budget == 0 { break; }
budget = budget.saturating_sub(entity.think(&mut world));
}
- Entity-vs-entity blocking:
step_allowedonly checks tiles. Once NPCs exist, occupied tiles need to block too — in the sim and in the pathfinder'sblockedclosure.
09 — Client camera (done — see Achieved)
Sprite rendering, camera follow and entity interpolation have all landed.
Later — UI / HUD
- Bitmap font renderer: 8×8 glyph sheet PNG,
draw_text(frame, font, x, y, color, text)— seenotes/04 - UI primitives in
game/src/ui.rs:fill_rect,draw_rect,draw_hbar— seenotes/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 two distinct regions, the abstraction earns its keep. Not before.
Later — Distribution
- Asset embedding:
include_bytes!for single-binary distribution (assets are currently read relative to the working directory) - Save / load: the world is a plain
Simvalue — serialize it. Roguelike convention is a single save slot deleted on load
Later — World depth
Once the simulation architecture is stable and a real level exists:
- Doors, interactive objects
- Combat: melee range check, HP, death, permadeath
- Inventory
- Day/night and weather on the slow tick (
% 192) — affect visibility, spawns - Procedural dungeon generation (BSP or cellular automata feeding into the same tile format)