Files
forgotten_caves/notes/roadmap.md
T
irrlichtandClaude Opus 5 4b4e779b3e Realign pack sheets into the entities layout and fill the sheet
tools/realign_sheet.py strips the blank spacer rows from a documented
Animals/Monsters sheet, drops the dark-box preview copies and moves the
light frames into the manifest's slot columns, so a sheet pastes into the
template as one block. Both sheets are pasted; the manifest gains the
fifth foal/horse pair the Animals sheet actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:46:02 +02:00

222 lines
13 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
Assets: `assets/packs/` holds the source packs untouched (Mini Medieval family by
VEXED, CC-BY — "documented" sheets: labels and state columns are pixel text in the
PNGs, readable by eye, not by script); `assets/tilesets/` is what the game actually
loads; `assets/fonts/` the font sources and repacked atlases (Micro Chat for the HUD,
CGA 8×8 kept for menus/titles); `assets/maps/` the Tiled CSV maps.
Entity sprites: `tilesets/entities.png` is assembled by hand from the packs into a
fixed grid described by the sidecar `entities.toml` — one row per entity, one column
slot per state (idle 2, sit 2, walk 4, action1 4, action2 2, hit 2, dead 2 frames
max). The manifest only maps `name → row` and the slot layout; which states and how
many frames a row has, the loader (`game/src/sprites.rs`) reads off the pixels
(leading non-empty cells; empty slot → falls back to idle). `tools/mktemplate.py`
renders the paste-in template `entities-template.png` (labels, header, slot guides at
alpha 48 — invisible to the loader) and carries existing sprites over, so adding rows
is: edit the manifest, rerun the tool, paste, save as `entities.png`.
`tools/realign_sheet.py <pack sheet>` turns a documented Animals/Monsters sheet into
`assets/work/<Name>-aligned.png`: blank spacer rows removed, only the light frames,
already in the slot columns — one block paste per sheet (Monsters → rows 1–9, Animals
→ rows 10–83; the manifest order follows the sheets, including five foal/horse
pairs). Game code refers to sprites by name (`sprites.id("hen")`); the id doubles as
the entity's `type_id`.
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
- [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, 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 `Chunk`s (plain `[u16; 1024]` tile ids, flags derived
on lookup — the network-era 64-entry palette is gone), entities indexed per chunk,
`entities_in_viewport`. Map path is the first CLI argument; `assets/maps/map_large` is a
generated 256×256 stress map
- [x] Map loader: Tiled-CSV parser in `sim/src/map.rs` (flip-flag masking, content-derived
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` → sprite row in `entities.png`
(via the `entities.toml` manifest, see Assets above), idle frame blitted with
transparency. Animation (walk while the lerp runs, hit/dead from events) is the
next step — the sheet layout and loader already carry the frames.
- [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`),
replaced on re-set, consumed on execution. Headless tests in `sim/src/sim.rs`
- [x] 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.
- [x] 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.
- [x] Click-to-move: framebuffer click → world tile, A* over the world
(`sim/src/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.
- [x] Smooth camera follow: the camera is a float pixel position (`cam` in `game.rs`)
that eases toward the player's *interpolated* render position (exponential
approach, τ = 120 ms), 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).
- [x] Entity blocking: `World::solid` (collidable tile ∨ out of world) and
`World::blocked` (solid ∨ occupied). The sim checks the geometry rule against
`solid` and resolves the target tile itself; the pathfinder plans with `blocked`.
Intents execute in id order, so the player (id 1) wins a contested tile
deterministically.
- [x] First NPC: `EntityKind { Player, Npc }` on `Entity`; `Sim::think` runs before
every movement window and schedules a random king move for each NPC one window
in four (seeded xorshift in `Sim`). Sprite = tile 1 in `entities.png` (a hen from
the Mini-Medieval pack). One is spawned at (6, 6) in `Game::start`.
- [x] Bump-to-attack and death: the geometry rule is checked against terrain only
(`World::solid`); an entity on the target tile turns the step into an attack
(`Entity::attack` damage, `hp` saturating). At 0 hp the entity is removed. The
sim reports both through its event queue — `sim::Event { Attacked, Died }`,
drained by the game with `Sim::take_events` after every window — which is the
seam for sound, message log, camera shake, spawns. Player death quits for now.
Click-to-move treats an occupied goal as a target (route ends in a bump) but
walks around entities in the way; keyboard steps always bump. A bump into the
same `type_id` (species) is a refused move, not a fight.
- [x] NPC brains: `Brain { Idle, Wander, Hunt(Prey) }` as a field on `Entity` — strategy
is data, independent of species and swappable at runtime. `Sim::think` matches on
it. `Hunt` takes the first step of an A* route (the pathfinder moved into `sim/`
for this) to the nearest prey within `SIGHT_RANGE` (8), replanned every window;
out of sight or unreachable it wanders. A greedy step-toward-target was tried
first and oscillated at any wall between hunter and prey. Six foxes
(`Hunt(Kind(hen))`, 8 hp / 2 dmg) clear 150 wandering hens off `map_test` in
about a minute; `cargo test -p game population -- --ignored --nocapture` watches
it headlessly.
- [x] Entity interpolation: per-id lerp table (`EntityLerp` in `game.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`**.
- [x] 24 Hz base loop driven from the game loop
- [x] Stride layout settled: `entity_tick` at `%4` (6 Hz, sets movement rate)
- [ ] Action-point model (`energy` / `speed` per entity, act when `energy >= ACTION_COST`).
Removed from `entity.rs` for now as premature — reintroduce when NPCs (08) actually need it.
Then `entity_tick` can move to `%2` (12 Hz) and `speed` sets 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 *(wanderer done — see Achieved)*
The wandering NPC and entity blocking have landed; `Sim::think` is the hook where the
budget model goes once there are enough NPCs to need it. The action-point model from
06 is still pending and only matters once NPC speeds should differ.
- [x] `EntityKind::Npc`, `Sim::think` scheduling intents through the same intent table
- [x] Bump-to-attack, death, `Event` queue
- [x] `Brain` enum, `Hunt` via A*
- A* per hunter per window is fine for six foxes; the think budget below becomes
necessary once hunters number in the dozens (or `hunt` caches its route and replans
only when the target moved).
- Hunters don't defend themselves: a fox bitten by a hen does not retaliate unless
the hen happens to be its nearest prey. `Brain::Hunt` could switch target on `Attacked`.
- Stats beyond `hp`/`attack` (defense, speed) as plain fields; a `Stats` substruct once
they crowd `Entity`. No ECS until entities actually differ in *which* components they
carry — see the discussion of 2026-09-19.
- NPC reactions (flee when hit, aggro on the player) — `think` would need to see the
previous window's events; today the game drains them right after `step`
- Per sim-tick: distribute a fixed `think_budget` across 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 `EntityKind` is fine.
```rust
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));
}
```
---
### 09 — Client camera *(done — see Achieved)*
Sprite rendering, camera follow and entity interpolation have all landed.
---
### UI / HUD *(first pass done)*
- [x] Bitmap font: `game/src/font.rs`, CP437 atlas (32×8 cells, codepoint = index),
proportional advance from measured ink bounds, luminance-keyed mask, `wrap`.
Ported from `wds`. The atlas is `assets/fonts/micro-chat-8x9.png`, repacked by
`tools/mkfont.py` from VEXED's Micro Chat sheet (5 px font, 8 px line height).
See `notes/04-text_renderer.md`
- [x] UI primitives in `game/src/ui.rs`: `fill_rect`, `draw_rect`, `draw_hbar`
- [x] HUD in the 80 px strip right of the viewport (`Game::render_hud`): name, HP bar,
population, and a message log fed from the sim events (only fights involving the
player; newest at the bottom, wrapped to the panel)
- A real clip-rect on the blit primitives — the HUD currently relies on being drawn
*after* the viewport, which overwrites the right-edge bleed. Fine until a panel
overlaps the world.
- Player name is a constant; a character/stat screen comes with inventory.
---
### 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 `Sim` value — 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)