A step onto an occupied tile is now an attack: the geometry rule is checked against terrain only and the target tile decides between move and bump. Entities carry an attack stat; at zero hp they are removed. The sim reports Attacked/Died through an event queue the game drains after every movement window — the seam for sound, log, camera effects and spawns. Click-to-move treats an occupied goal as a target but detours around entities in the way; the player's death ends the game. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
9.2 KiB
Markdown
Executable File
173 lines
9.2 KiB
Markdown
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
|
||
|
||
- [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/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` → 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`),
|
||
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
|
||
(`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.
|
||
- [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.
|
||
- [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
|
||
- 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.
|
||
|
||
---
|
||
|
||
### Later — UI / HUD
|
||
|
||
- 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
|
||
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 `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)
|