132646f6c3
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
450 lines
25 KiB
Markdown
450 lines
25 KiB
Markdown
# Status and roadmap
|
||
|
||
**Read this first when picking up work.** It maps every feature in the design
|
||
brief to its current state and the files that implement it, so you can find the
|
||
relevant code without re-reading the whole project.
|
||
|
||
Art and audio source packs, and their licence status, are recorded in
|
||
[ASSETS.md](ASSETS.md) — the packs themselves are not in git.
|
||
|
||
Design decisions that are already settled — and the reasoning behind them — live
|
||
in [DECISIONS.md](DECISIONS.md). Check there before asking the user something
|
||
that may already have an answer.
|
||
|
||
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
|
||
|
||
## Verification surface
|
||
|
||
What "everything passes" currently means. Numbers move; the shape does not.
|
||
|
||
| Gate | Covers | Runtime |
|
||
| --- | --- | --- |
|
||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||
| `tools/test.sh` | 460 GUT tests, no SceneTree | ~4s |
|
||
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
|
||
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
|
||
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
|
||
|
||
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
|
||
and it is the only thing in the project that can tell you the interface
|
||
actually rendered. Run it after touching `src/ui/`.
|
||
|
||
The four diagnostics exist because the smoke test structurally cannot reach
|
||
what they cover: bots are poor shots (so they neither level up, produce drops,
|
||
nor walk to the quartermaster), and a listen server cannot drift its own clock
|
||
against itself.
|
||
|
||
---
|
||
|
||
## Stage 1 — World and exploration · *done*
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| Tile grid: movement / bullet / sight blocking | done | [src/sim/map_grid.gd](../src/sim/map_grid.gd) |
|
||
| Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) |
|
||
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
|
||
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
|
||
| Enemies placed per room at creation | done | `Instance._populate()` |
|
||
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
|
||
| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` |
|
||
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
|
||
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
|
||
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
|
||
| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. |
|
||
| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. |
|
||
|
||
### The invariants that matter most here
|
||
|
||
**Maps are streamed per peer, and the generation seed is never sent.** See
|
||
[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must
|
||
stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against
|
||
terrain it does not have.
|
||
|
||
**Three radii, deliberately different, and each has a floor it must respect:**
|
||
|
||
| Radius | Value | Must exceed | Why |
|
||
| --- | --- | --- | --- |
|
||
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
|
||
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
|
||
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
|
||
| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. |
|
||
|
||
Fog is a *rendering* rule and defends nothing on its own — a modified client
|
||
draws whatever it holds. The defence is what the server declines to send.
|
||
|
||
---
|
||
|
||
## Dungeon kinds · *done*
|
||
|
||
Three entrances stand side by side in the hub, labelled with the fight each one
|
||
ends in.
|
||
|
||
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
|
||
| --- | --- | --- | --- | --- | --- |
|
||
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
|
||
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
|
||
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
|
||
|
||
**A dungeon names its arena, and the arena decides its boss.** Which boss you
|
||
are about to fight is the one thing a player chooses before walking into a
|
||
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
|
||
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
|
||
of minutes, so the one you want is one re-entry away.
|
||
|
||
The Proving Grounds is a **test harness you can walk into**: same generator,
|
||
same rooms, same enemies, but everything dies far faster and drops far more. A
|
||
manual pass over loot, the inventory, dropping and all four boss phases takes a
|
||
couple of minutes instead of a quarter of an hour, and because it is a portal
|
||
rather than a launch flag you can compare it to a real run without restarting
|
||
the server.
|
||
|
||
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
|
||
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
|
||
two have to stay in the same order.
|
||
|
||
Two more things worth knowing:
|
||
|
||
- **It is multipliers over the shared content, not a copy of it.** A duplicated
|
||
`Content` would drift the moment anything was tuned, and "identical but
|
||
easier" would quietly stop being true.
|
||
- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()`
|
||
resolves the server-side position; the `PORTAL_USED` event carries the answer.
|
||
No client message names a dungeon, which is what stops anyone picking the
|
||
generous loot table from a real run. `accepts_new_party_member` compares
|
||
dungeon ids too, so walking into one entrance can never drop you into
|
||
another's forming run on timing alone.
|
||
|
||
---
|
||
|
||
## Art and audio · *first pass done*
|
||
|
||
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
|
||
play off server events. Enough to prove the pipeline, not a finished look.
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| Atlas/sound table in one place | done | [src/view/art.gd](../src/view/art.gd) |
|
||
| Terrain, actors, boss from the 0x72 atlas | done | `WorldView._draw_tile` / `_draw_sprite` |
|
||
| Animated bullet sprites, one MultiMesh per kind | done | [src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd) |
|
||
| SFX pool driven by server events | done | [src/view/sfx.gd](../src/view/sfx.gd) |
|
||
| Rects validated without a display | done | `tests/unit/test_art.gd` |
|
||
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
|
||
| Directional sprites, hit flashes, screen shake | todo | |
|
||
| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) |
|
||
| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` |
|
||
| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` |
|
||
| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen |
|
||
| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. |
|
||
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
|
||
|
||
## Stage 2 — Characters, persistence, levels · *done*
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) |
|
||
| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) |
|
||
| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` |
|
||
| Last-played auto-selected on login | done | `CharacterStore.last_played` |
|
||
| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` |
|
||
| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) |
|
||
| Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) |
|
||
| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` |
|
||
| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints |
|
||
| Swap character from the hub | done | Esc menu → Change character; refused server-side in a dungeon |
|
||
| XP percentage to next level | done | `HUD._draw_xp_bar`, fed live from the snapshot |
|
||
| Dead characters hidden from the roster | done | `ServerRuntime._send_characters` sends living only |
|
||
| Suggested name when creating | done | `Character.random_name` |
|
||
| Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum |
|
||
|
||
Verified end to end by `tools/diag_progression.tscn`, which drives the real
|
||
server through kill → xp → level → health and death → retire → roster. That
|
||
path cannot be covered by the bot smoke test, because bots are poor shots.
|
||
|
||
### Still open in this area
|
||
|
||
- **The local identity provider is insecure by design.** Any client can claim
|
||
any account id. Fine for a LAN; must be replaced before the game is reachable
|
||
from the internet. Swapping in Steam is one `AuthProvider` subclass and no
|
||
schema change.
|
||
- `--account` and `--store` exist so several clients and test runs can coexist
|
||
on one machine. A real provider makes `--account` unnecessary.
|
||
|
||
## Stage 3 — Inventory and loot · *done*
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| 4 slots, permanently on screen | done | `SimConfig.INVENTORY_SLOTS`, `HUD._draw_inventory` |
|
||
| Items defined as data, not code | done | [src/content/items.gd](../src/content/items.gd), [src/actors/items/item_def.gd](../src/actors/items/item_def.gd) |
|
||
| Loot tables on enemies and bosses | done | `EnemyDef.loot` / `BossDef.loot`, rolled in `SimWorld._drop_loot` |
|
||
| Health potion, rare from trash | done | `Content.TRASH_POTION_CHANCE` = 0.08 |
|
||
| …guaranteed from the boss | done | `Content.warden()` loot table, chance 1.0 |
|
||
| World-shared loot | done | `SimLoot.owner_peer == 0` |
|
||
| Player-instanced loot | done | one `SimLoot` per living player, filtered per peer in `NetCodec.encode_snapshot` |
|
||
| Warden's Ration — useless, instanced | done | `Items.wardens_ration()` |
|
||
| Pick up, use, drop | done | `SimWorld._try_pickup` / `_use_slot` / `_drop_slot` |
|
||
| Inventories persist | done | stored on `Character`, written by `ServerRuntime._persist_inventory` |
|
||
| Ground loot drawn with a pickup prompt | done | `WorldView._draw_loot`, `HUD._draw_pickup_prompt` |
|
||
|
||
Controls: **E** picks up, **1–4** use a slot, **shift+1–4** drop one.
|
||
|
||
### The decisions worth knowing before touching this
|
||
|
||
**Item actions ride the input frame; they are not new messages.** `InputFrame`
|
||
gained `BTN_USE`, `BTN_DROP` and a slot byte. That buys the redundancy that
|
||
covers a dropped packet, the replay guard on `last_input_tick`, ordering against
|
||
movement on the same tick, and a natural rate limit of one action per tick — all
|
||
of which a separate reliable RPC would have needed bolted back on.
|
||
|
||
**Item actions are edge-triggered; movement and fire are not.** The client
|
||
repeats its last few frames every tick and a starved server coasts on the last
|
||
one it was given, so a level-triggered read empties the whole inventory in four
|
||
ticks. `SimPlayer.prev_buttons` and `prev_slot` hold the edge, and the *slot* is
|
||
part of it — tapping 2 while 1 is held is a second, distinct action.
|
||
|
||
**Instanced loot is enforced on the wire, not in the client.** A peer is never
|
||
told another player's copy exists. That makes it an interest-management rule of
|
||
the same kind as `ACTOR_INTEREST_RADIUS`, and it is why the ration is worth
|
||
having: every boss kill exercises the path.
|
||
|
||
**Anything dropped becomes world-shared, even if it arrived instanced.** That is
|
||
what makes dropping worth having — a trophy you do not want should be able to
|
||
reach someone who does.
|
||
|
||
**A potion at full health is refused rather than spent.** Nobody drinks one on
|
||
purpose at full health, so a mistimed keypress must not do it for them.
|
||
|
||
**A full bag leaves the item on the floor** and does not block the portal, which
|
||
shares the interact key.
|
||
|
||
### Known gaps
|
||
|
||
- **Nothing sells items.** Loot only comes from kills; the hub has no source.
|
||
The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap
|
||
rather than a decision.
|
||
- **No stacking.** Four potions take four slots. A count byte per slot is cheap
|
||
to add; nothing needed it yet, so the wire, the save record and the HUD all
|
||
stayed simpler for not having one.
|
||
- **Ground loot never expires**, it is only capped at
|
||
`SimConfig.MAX_LOOT_PER_INSTANCE` per world, oldest evicted. Dungeons close
|
||
and take their litter with them; only the hub can realistically reach the cap.
|
||
- **The ration draws as a gold flask.** The tileset has no food sprite. See
|
||
[ASSETS.md](ASSETS.md).
|
||
|
||
---
|
||
|
||
## The scale of health and damage
|
||
|
||
Health and damage are an order of magnitude larger than they started. A shot is
|
||
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
|
||
unchanged** — enemy health, boss health and every emitter's damage were scaled
|
||
together, so time to kill is exactly what it was.
|
||
|
||
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
|
||
carries computed to 6.3 and rounded straight back to 6, so a player's first
|
||
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
|
||
now has somewhere to land.
|
||
|
||
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
|
||
changes damage, and that shots-to-kill for each enemy is what it was before.
|
||
Do not tidy these numbers back down without scaling `content.gd` with them.
|
||
|
||
The hub's practice dummy stopped relying on a huge health pool at the same
|
||
time: it is `indestructible` by flag now. The old 100000 was already past the
|
||
u16 the snapshot sends enemy health in, and after the rescale a patient player
|
||
could have destroyed the hub's only practice target for everyone.
|
||
|
||
---
|
||
|
||
## Stage 4 — Upgrades · *done*
|
||
|
||
Every level gained banks one choice. Choices are spent at the **quartermaster**,
|
||
an NPC in the hub: walk to it, press E, pick one of three.
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| Upgrades as data, no engine branches | done | [src/content/upgrades.gd](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) |
|
||
| Per-player derived stats | done | [src/sim/player_stats.gd](../src/sim/player_stats.gd) |
|
||
| 3 weighted options per choice, held until spent | done | `Upgrades.roll_offer`, stored on `Character.offer` |
|
||
| Choices queue across levels | done | `Character.pending_choices` |
|
||
| Spent only at the NPC, enforced server-side | done | `SimWorld.at_upgrade_npc`, `ServerRuntime.on_choose_upgrade` |
|
||
| Choice screen showing the +5% and each effect | done | [src/ui/upgrade_screen.gd](../src/ui/upgrade_screen.gd) |
|
||
| List of upgrades already taken, plus resulting stats | done | lower half of the same panel |
|
||
| Upgrades persist, die with the character | done | `Character.upgrades`, permadeath retires the record |
|
||
|
||
### The seven upgrades
|
||
|
||
| Upgrade | Rarity | Effect |
|
||
| --- | --- | --- |
|
||
| Split Shot | common | On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks. |
|
||
| Glass Cannon | common | +100% damage, ×0.5 levelled max health. |
|
||
| Spread | common | +2 shots in a cone, −10% damage. |
|
||
| Sniper | common | ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed. |
|
||
| Doubleshot | uncommon | +1 parallel shot, −50% damage. |
|
||
| Poison | rare | Each hit deals another 50% of its damage over 10s. Doses stack independently. |
|
||
| Eraser | legendary | 1% chance per tick to delete an enemy projectile a shot is passing through. |
|
||
|
||
Draw weights: common 100, uncommon 45, rare 18, legendary 4.
|
||
|
||
### The decisions behind the numbers
|
||
|
||
All four settled with the user; the reasoning is in
|
||
[DECISIONS.md](DECISIONS.md).
|
||
|
||
- **Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
|
||
upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100%
|
||
pool additively; Sniper multiplies the result, which is why two Snipers is 4×
|
||
and not +200%.
|
||
- **Glass Cannon halves the LEVELLED maximum**, so the price does not fade to a
|
||
rounding error by level 15. Taken twice it multiplies again.
|
||
- **Poison doses are independent**, not refreshed. `PoisonTrack` makes that free:
|
||
every dose lasts the same number of ticks, so they expire in insertion order,
|
||
the pending expiries are a plain FIFO, and the per-tick cost is O(1) however
|
||
many are live.
|
||
- **Split Shot's children leave at ±45° from the original heading**, born just
|
||
past the target so they cannot be resolved against it again on the same tick.
|
||
|
||
### Constraints this collided with, and what it cost
|
||
|
||
- **Bullet speed now has a ceiling** (`SimConfig.MAX_BULLET_SPEED`, 1500 u/s).
|
||
Wall collision samples once per tick, so anything over one tile per tick
|
||
(1920 u/s) tunnels. Two Snipers would have asked for 2480.
|
||
- **`BULLET_INTEREST_RADIUS` rose to 2900**, because an upgraded player shot is
|
||
now the longest-travelling bullet in the game. `test_interest.gd` measures the
|
||
worst case from the ceiling rather than from static content, which is what it
|
||
used to do and what upgrades quietly invalidated.
|
||
- **Poison ticks do not emit `ENEMY_HIT`.** They land many times a second on a
|
||
reliable channel; the client learns hp from the snapshot. Death is still
|
||
announced, because the experience award is keyed on it.
|
||
- **The eraser's bullet-vs-bullet scan costs nothing until someone owns it** —
|
||
it runs only for shots carrying a non-zero chance.
|
||
|
||
### Known gaps
|
||
|
||
- **One panel, not two screens.** The brief asked for a separate screen listing
|
||
upgrades taken; it is the lower half of the choice panel instead, because the
|
||
two are read together.
|
||
- **No respec, and no way to see another player's build.**
|
||
|
||
---
|
||
|
||
## Stage 5 — Boss features and new bosses · *done*
|
||
|
||
| Feature | State | Where |
|
||
| --- | --- | --- |
|
||
| Stationary phases | done | every Warden phase |
|
||
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
|
||
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
|
||
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
|
||
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
|
||
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
|
||
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
|
||
|
||
### Boss movement
|
||
|
||
Movement is a property of the **phase**, not of the boss: a fight that stands
|
||
still and then starts hunting you is one boss with two phases. Four modes —
|
||
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
|
||
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
|
||
|
||
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
|
||
works in the Warden's hall and the Choir Vault alike.
|
||
- **CHASE holds a distance rather than closing.** A boss standing on top of you
|
||
is a boss whose bullets cannot be read.
|
||
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
|
||
destination and would otherwise snap onto its circle on the first tick.
|
||
- **Movement slides against geometry**, so a boss cannot walk through the
|
||
pillars its own arena was designed around.
|
||
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
|
||
still — which is exactly when an invariant is cheapest to establish. Boss
|
||
rooms deliberately do not lock, and that is only an escape if the boss cannot
|
||
follow you out.
|
||
|
||
### Telegraphed strikes
|
||
|
||
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
|
||
later. The moment in between is the whole feature: a burst that appears at your
|
||
feet is a coin flip, the same burst with a second of notice is a question.
|
||
|
||
- **The emitter is stateless**, like every other one — they are shared resources
|
||
and two bosses of the same kind must not stomp each other. Strike positions
|
||
are *derived* from the volley number, so the announcement and the strike
|
||
compute the same points with nothing stored between them.
|
||
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
|
||
besides spawn bullets.
|
||
- **Markers are drawn through fog and through walls.** Everything else in the
|
||
view respects line of sight; a warning you cannot see is not a warning. See
|
||
`WorldView._draw_telegraphs`.
|
||
|
||
### The Cantor of the Vault
|
||
|
||
| Phase | Movement | Idea |
|
||
| --- | --- | --- |
|
||
| Call to Prayer | static | Teaches the marker, and nothing else. |
|
||
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
|
||
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
|
||
| Final Cadence | orbit | Movement and telegraphs at once. |
|
||
|
||
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
|
||
from the arena its dungeon names, not from the seed and certainly not from
|
||
`--depth`, which nothing in play raises.
|
||
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
|
||
giving it a way in is a failing test rather than a boss nobody meets.
|
||
|
||
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
|
||
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
|
||
had already gone stale and silently stopped writing the second boss.
|
||
|
||
### Known gaps
|
||
|
||
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
|
||
newest snapshot with no interpolation — a visible step at 20 Hz — and every
|
||
test passed for as long as bosses stood still. There is still no automated
|
||
check that a moving actor renders smoothly; the ones added cover the
|
||
interpolation call, not the impression.
|
||
- **No boss-specific music, intro or death sequence.** A boss dies like an
|
||
enemy, only louder.
|
||
- **Telegraph markers are one shape.** A circle is the only warning the client
|
||
can draw; a lane or a cone would need another event field.
|
||
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
|
||
the generator's "boss arena on the right-hand side" assumption will be tested.
|
||
|
||
---
|
||
|
||
## Deliberate omissions
|
||
|
||
Not oversights — each was considered and rejected for now, with the reasoning in
|
||
[NETCODE.md](NETCODE.md) or [DECISIONS.md](DECISIONS.md):
|
||
|
||
- **Lag compensation.** Rewinding to a shooter's view means a player who dodged
|
||
still gets hit; wrong trade for this genre.
|
||
- **Snapshot delta compression.** Fine at current actor counts.
|
||
- **DTLS / encryption.** `ENetMultiplayerPeer` supports it. Required before any
|
||
public server, not before then.
|
||
- **Pattern-level bullet replication.** A real bandwidth win that couples the
|
||
client to emitter behaviour.
|
||
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape
|
||
for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||
|
||
---
|
||
|
||
## Open questions for the user
|
||
|
||
Genuinely unspecified. **Do not guess at these** — each changes the design, and
|
||
several have no obvious default.
|
||
|
||
### Blocking nothing yet
|
||
|
||
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
|
||
in play. Depth drives map size and could drive difficulty and rewards.
|
||
9. **Where do items come from outside a dungeon?** Loot only drops from kills.
|
||
If the hub should sell potions, that is the Stage 4 NPC's second job — and
|
||
it needs a currency, which the game does not have.
|
||
10. **Should items stack?** Four potions currently take four slots, which makes
|
||
a 4-slot bag small. Stacking is a count byte per slot plus a rule for
|
||
splitting one; neither is hard, but both change the UI.
|
||
11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are
|
||
non-redistributable and local-only, and there is no in-game credits screen
|
||
yet, which CC BY 4.0 requires for the audio.
|