Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e872e2cf5 | ||
|
|
3d1b5da72b | ||
|
|
99f0d2a783 |
No files matched your search
+12
-451
@@ -38,457 +38,18 @@ candidates, none of which the user has chosen — **ask before starting one.**
|
||||
Each stage below also carries its own **Known gaps** section — those are smaller
|
||||
and more specific than the table above.
|
||||
|
||||
### Known flake
|
||||
|
||||
`tools/smoke.sh` has failed twice, **both times inside the pre-push hook**, and
|
||||
then passed on every direct run after: four in a row the first time, eight the
|
||||
second (six in a background loop plus two more, one of them the hook itself).
|
||||
Neither failure's logs survived, because the hook runs smoke without `KEEP`.
|
||||
The first suspicion was machine load, but the second failure had nothing else
|
||||
running. What is different about the hook is its context — stdin is git's
|
||||
pipe of ref lines, which every bot inherits. Unconfirmed. Push with
|
||||
`KEEP=1 git push` so the logs of the next one survive.
|
||||
|
||||
---
|
||||
|
||||
## 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` | 468 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/`. It captures the main menu,
|
||||
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
|
||||
those by staging an offer, because earning one would make the shot depend on a
|
||||
dungeon run.
|
||||
|
||||
It has now caught four bugs that every other gate passed: the theme applying to
|
||||
nothing, the test suite rewriting the player's settings, an invisible slider
|
||||
track and an invisible scrollbar.
|
||||
|
||||
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()` |
|
||||
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
|
||||
| Prompts name the keys actually bound | done | `Settings.short_label()` |
|
||||
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
|
||||
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
|
||||
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
|
||||
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
|
||||
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
|
||||
| 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.
|
||||
|
||||
**It is a debugging tool and does not ship.** It pays exactly the same
|
||||
experience a real run does — `Progression.xp_for_boss` ignores its argument and
|
||||
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
|
||||
touch — while its boss has 8% of its health and its trash drops ten times as
|
||||
often. That makes it strictly the best place to level and to farm, which is
|
||||
correct for a harness and would be ruinous in a release. Remove it from
|
||||
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
|
||||
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
|
||||
|
||||
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/` |
|
||||
| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries |
|
||||
| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` |
|
||||
| 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 is covered by the darkness; 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).
|
||||
|
||||
---
|
||||
### The smoke flake (diagnosed and fixed)
|
||||
|
||||
`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose
|
||||
logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
|
||||
whose account picks the Proving Grounds, had gone into the Choir Vault. The
|
||||
bot wandered for 90 of every 120 ticks in the hub and then walked to its
|
||||
entrance holding interact the whole way; the three entrances sit in a row, so a
|
||||
wander that ended near the middle one carried it across the wrong ring with the
|
||||
key down. Which dungeons opened depended on where a timing-dependent wander
|
||||
stopped. The bot now walks straight to its own entrance and presses interact
|
||||
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with
|
||||
`KEEP=1 git push` so the logs survive.
|
||||
|
||||
## Open questions for the user
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ func _populate() -> void:
|
||||
var at := world.map.tile_centre(tx, ty)
|
||||
if world.map.circle_blocked(at, def.radius):
|
||||
continue
|
||||
if _would_see_the_entrance(def, at):
|
||||
continue
|
||||
world.spawn_enemy(def, at, rng.randi_range(0, 120))
|
||||
# After every room, so adding tunnel groups leaves each room's population
|
||||
# exactly what that seed always produced.
|
||||
@@ -189,6 +191,25 @@ func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
|
||||
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
|
||||
|
||||
|
||||
## Whether an enemy placed at [param at] would be awake the moment the party
|
||||
## arrives: within its aggro range of the entrance, with nothing in the way --
|
||||
## the same two questions SimWorld._aggro_target asks.
|
||||
##
|
||||
## Room 0 is left empty for exactly this reason, but that only ever covered room
|
||||
## 0. A neighbouring room in sight of the entrance woke on arrival in one run in
|
||||
## eight, and once tunnels were widened -- more open ground, more lines of sight
|
||||
## -- in three runs in eight. Skipping these removes about one enemy in sixteen;
|
||||
## clearing everything within aggro range instead would have removed nearly a
|
||||
## third, which is a different dungeon rather than a fixed one.
|
||||
##
|
||||
## The rng draws for the skipped enemy have already been made, so every other
|
||||
## enemy in the dungeon is exactly where that seed always put it.
|
||||
func _would_see_the_entrance(def: EnemyDef, at: Vector2) -> bool:
|
||||
if at.distance_to(world.spawn_point) > def.aggro_range:
|
||||
return false
|
||||
return world.map.has_line_of_sight(at, world.spawn_point)
|
||||
|
||||
|
||||
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
|
||||
## room by a tile, so a group is never standing in a doorway, and outside the
|
||||
## boss arena.
|
||||
|
||||
@@ -70,6 +70,7 @@ func _show_menu() -> void:
|
||||
_menu.join_requested.connect(_join)
|
||||
_menu.host_requested.connect(_host_and_play)
|
||||
_menu.settings_requested.connect(_open_settings)
|
||||
_menu.credits_requested.connect(_open_credits)
|
||||
add_child(_menu)
|
||||
# Unconditional, and the reason this is not an early-return when the menu
|
||||
# already exists: a failed connection returns here with the buttons still
|
||||
|
||||
@@ -274,12 +274,25 @@ func _bot_input() -> InputFrame:
|
||||
# Downed bots ask for the hub, same as a player would -- otherwise a bot
|
||||
# that dies mid-run just lies there and the smoke test stalls.
|
||||
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
|
||||
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
# Walk onto a portal instead of orbiting, or interact never lands.
|
||||
# Which one is picked from the account id, so a smoke run with several
|
||||
# bots exercises every dungeon rather than only the first.
|
||||
move = (bot_portal() - predicted_pos).normalized()
|
||||
if instance_kind == Protocol.InstanceKind.LOBBY:
|
||||
# Straight to its own entrance, picked from the account id so a smoke
|
||||
# run with several bots exercises every dungeon -- and interact pressed
|
||||
# ONLY while standing on that one.
|
||||
#
|
||||
# It used to wander for 90 ticks in every 120 and then walk to its
|
||||
# portal holding interact the whole way. The three entrances sit in a
|
||||
# row, so a wander that ended near the middle one sent the bot bound for
|
||||
# the Proving Grounds straight across the Choir Vault's ring with the
|
||||
# key down, and in it went. Which dungeons opened depended on where a
|
||||
# timing-dependent wander happened to stop: this was the smoke test's
|
||||
# long-standing intermittent failure, and it only ever showed up as
|
||||
# "the proving grounds opened" (or another entrance) going missing.
|
||||
var target := bot_portal()
|
||||
move = (target - predicted_pos).normalized() \
|
||||
if predicted_pos.distance_to(target) > 6.0 else Vector2.ZERO
|
||||
var here := portal_here()
|
||||
if not here.is_empty() and (here["pos"] as Vector2).is_equal_approx(target):
|
||||
buttons |= InputFrame.BTN_INTERACT
|
||||
var slot := 0
|
||||
if instance_kind == Protocol.InstanceKind.DUNGEON:
|
||||
# Grab at whatever is underfoot and occasionally drink, so the item
|
||||
|
||||
+276
-64
@@ -39,6 +39,23 @@ const LOW_HEALTH := 0.35
|
||||
const HINT_HOLD := 6.0
|
||||
const HINT_FADE := 1.5
|
||||
|
||||
## Health bar, taller than the others because it carries its own numbers.
|
||||
const HP_H := 20.0
|
||||
## Experience is a thin strip under health: it matters between fights, not in
|
||||
## them.
|
||||
const XP_H := 10.0
|
||||
## How long the damage trail holds before draining, and how fast it drains (as
|
||||
## a share of the bar per second). Long enough to read what the hit took.
|
||||
const TRAIL_HOLD := 0.45
|
||||
const TRAIL_DRAIN := 0.9
|
||||
## Centre-screen banners: how long one stays, and its fade at each end.
|
||||
const BANNER_LIFE := 2.4
|
||||
const BANNER_FADE := 0.5
|
||||
## The inside of every bar. The pack's bar sprite is light all the way through,
|
||||
## so on its own an EMPTY bar was the brightest thing on screen and a player on
|
||||
## a fifth of their health saw a bar that read as nearly full.
|
||||
const COL_TRACK := Color(0.07, 0.07, 0.11)
|
||||
|
||||
const COL_TEXT := Color(0.86, 0.9, 0.97)
|
||||
const COL_DIM := Color(0.58, 0.63, 0.74)
|
||||
const COL_HEALTH := Color(0.35, 0.9, 0.6)
|
||||
@@ -61,6 +78,25 @@ var _hit_flash: float = 0.0
|
||||
var client: ClientRuntime = null
|
||||
|
||||
var _toasts: Array[Dictionary] = []
|
||||
## The damage trail: where the health bar was before the last hit, held a
|
||||
## moment and then drained down to where it is now.
|
||||
var _hp_trail: float = 1.0
|
||||
var _hp_last: float = 1.0
|
||||
var _hp_hold: float = 0.0
|
||||
## Experience just gained, shown beside the bar for a moment.
|
||||
var _xp_last: int = -1
|
||||
var _xp_gain: int = 0
|
||||
var _xp_flash: float = 0.0
|
||||
## The banner currently shown, or an empty title.
|
||||
var _banner_title := ""
|
||||
var _banner_sub := ""
|
||||
var _banner_col := Color.WHITE
|
||||
var _banner_born: float = -100.0
|
||||
## The boss phase last announced, keyed like _boss_seen.
|
||||
var _boss_phase_key := ""
|
||||
var _boss_phase: int = -1
|
||||
## Dark backing for every panel of text over the world. Built once.
|
||||
var _panel_style: StyleBoxFlat = null
|
||||
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
|
||||
var _boss_seen := ""
|
||||
## The level last drawn and whose it was, so a level-up can be announced the
|
||||
@@ -113,6 +149,8 @@ func _process(delta: float) -> void:
|
||||
_instance_seen = client.instance_id
|
||||
_instance_since = now
|
||||
_watch_level()
|
||||
_watch_health(delta)
|
||||
_watch_experience(delta)
|
||||
_update_respawn_button()
|
||||
_canvas.queue_redraw()
|
||||
|
||||
@@ -134,6 +172,43 @@ func flash_hit() -> void:
|
||||
_hit_flash = 1.0
|
||||
|
||||
|
||||
## A line across the middle of the screen for a moment: a level gained, a boss
|
||||
## changing phase. Things that change what you should do next, which is why
|
||||
## they are not in the message feed with the potions.
|
||||
func banner(title: String, sub: String = "", colour: Color = COL_GOLD) -> void:
|
||||
_banner_title = title
|
||||
_banner_sub = sub
|
||||
_banner_col = colour
|
||||
_banner_born = _now()
|
||||
|
||||
|
||||
## Keep the damage trail: when health drops, the trail stays where it was for
|
||||
## TRAIL_HOLD and then drains to meet the bar. Healing moves both at once.
|
||||
func _watch_health(delta: float) -> void:
|
||||
var frac := clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
|
||||
if frac < _hp_last - 0.0001:
|
||||
_hp_trail = maxf(_hp_trail, _hp_last)
|
||||
_hp_hold = TRAIL_HOLD
|
||||
if frac >= _hp_trail:
|
||||
_hp_trail = frac
|
||||
if _hp_hold > 0.0:
|
||||
_hp_hold -= delta
|
||||
else:
|
||||
_hp_trail = move_toward(_hp_trail, frac, TRAIL_DRAIN * delta)
|
||||
_hp_last = frac
|
||||
|
||||
|
||||
func _watch_experience(delta: float) -> void:
|
||||
_xp_flash = maxf(_xp_flash - delta, 0.0)
|
||||
var xp := client.my_total_xp
|
||||
if _xp_last >= 0 and xp > _xp_last and String(_seen_character) != "":
|
||||
# Summed while the flash is up, so a burst of kills reads as one total
|
||||
# rather than as a number that flickers.
|
||||
_xp_gain = (_xp_gain if _xp_flash > 0.0 else 0) + (xp - _xp_last)
|
||||
_xp_flash = 1.4
|
||||
_xp_last = xp
|
||||
|
||||
|
||||
## Announce a level the moment the snapshot's experience crosses one. Watched
|
||||
## rather than signalled because experience rides the snapshot, and the first
|
||||
## sight of a character -- logging in, swapping -- must not read as a level-up.
|
||||
@@ -146,11 +221,15 @@ func _watch_level() -> void:
|
||||
if String(who["id"]) != _seen_character:
|
||||
_seen_character = String(who["id"])
|
||||
_seen_level = level
|
||||
# A new character is not a hit, and its experience is not a gain.
|
||||
_hp_trail = clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
|
||||
_hp_last = _hp_trail
|
||||
_xp_last = client.my_total_xp
|
||||
return
|
||||
if level > _seen_level:
|
||||
var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \
|
||||
else " -- a choice waits at the quartermaster"
|
||||
notify("LEVEL %d%s" % [level, where], COL_GOLD)
|
||||
banner("LEVEL %d" % level,
|
||||
"a choice waits at the quartermaster" if level < Progression.MAX_LEVEL
|
||||
else "the highest there is", COL_GOLD)
|
||||
_seen_level = level
|
||||
|
||||
|
||||
@@ -243,11 +322,11 @@ func _draw_hud() -> void:
|
||||
# Under everything, so the edge of the screen colours without dimming any of
|
||||
# the text on top of it.
|
||||
_draw_vignette()
|
||||
_draw_party_markers()
|
||||
|
||||
var y := _draw_player_panel()
|
||||
_draw_level_up_notice(y)
|
||||
_draw_player_panel()
|
||||
_draw_boss_bar()
|
||||
_draw_arriving()
|
||||
_draw_banner()
|
||||
_draw_cleared_countdown()
|
||||
_draw_others()
|
||||
_draw_inventory()
|
||||
@@ -260,13 +339,37 @@ func _draw_hud() -> void:
|
||||
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
|
||||
|
||||
|
||||
## Who you are, where you are, and how you are doing. Returns the y below the
|
||||
## last thing drawn, so whatever comes next stacks rather than overlaps.
|
||||
func _draw_player_panel() -> float:
|
||||
## A dark, slightly translucent box behind a block of HUD text. The floor art is
|
||||
## busy and mid-toned; text straight over it was readable on some tiles and not
|
||||
## on others, and the shadow under each letter only went so far.
|
||||
func _panel(rect: Rect2) -> void:
|
||||
if _panel_style == null:
|
||||
_panel_style = StyleBoxFlat.new()
|
||||
_panel_style.bg_color = Color(0.05, 0.05, 0.09, 0.62)
|
||||
_panel_style.border_color = Color(1.0, 1.0, 1.0, 0.07)
|
||||
_panel_style.set_border_width_all(1)
|
||||
_panel_style.set_corner_radius_all(4)
|
||||
_canvas.draw_style_box(_panel_style, rect)
|
||||
|
||||
|
||||
## Who you are, where you are, and how you are doing.
|
||||
##
|
||||
## Laid out in two passes -- measure the rows, draw the panel behind them, then
|
||||
## draw them -- because the panel has to be under the text and its height
|
||||
## depends on which rows are showing.
|
||||
func _draw_player_panel() -> void:
|
||||
var who := client.current_character()
|
||||
var has_character := not who.is_empty()
|
||||
var rows := 20.0 + HP_H + 6.0
|
||||
if has_character:
|
||||
rows += 22.0 + XP_H + 18.0
|
||||
if client.upgrades_pending > 0:
|
||||
rows += 20.0
|
||||
_panel(Rect2(MARGIN - 10.0, MARGIN - 8.0, BAR_W + 20.0, rows + 12.0))
|
||||
|
||||
var x := MARGIN
|
||||
var y := MARGIN
|
||||
var who := client.current_character()
|
||||
if not who.is_empty():
|
||||
if has_character:
|
||||
var name_text := String(who["name"])
|
||||
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
|
||||
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
|
||||
@@ -288,41 +391,44 @@ func _draw_player_panel() -> float:
|
||||
# Red when low, not a blend toward it: green-to-red through the middle is
|
||||
# brown, which reads as a colour rather than as a warning.
|
||||
var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
|
||||
_bar(Vector2(x, y), frac, hp_tint)
|
||||
_text(Vector2(x + BAR_W + 10.0, y + 13.0),
|
||||
"%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint)
|
||||
y += BAR_H + 4.0
|
||||
_bar(Vector2(x, y), frac, hp_tint, _hp_trail, Vector2(BAR_W, HP_H))
|
||||
# Inside the bar, where the eye already is. Light on the dark track and on
|
||||
# every fill colour, which a number beside the bar did not need to be.
|
||||
_text(Vector2(x, y + HP_H - 5.0), "%d / %d" % [client.my_hp, client.my_max_hp],
|
||||
12, COL_TEXT, HORIZONTAL_ALIGNMENT_CENTER, BAR_W)
|
||||
y += HP_H + 6.0
|
||||
|
||||
if _draw_xp_bar(Vector2(x, y)):
|
||||
y += BAR_H + 4.0
|
||||
if has_character:
|
||||
_draw_xp_bar(Vector2(x, y))
|
||||
y += XP_H + 18.0
|
||||
|
||||
if client.my_escaping:
|
||||
_bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0))
|
||||
_text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12,
|
||||
Color(0.5, 0.85, 1.0))
|
||||
y += BAR_H + 4.0
|
||||
return y
|
||||
# No escape row here: the channel is a banner in the middle of the screen,
|
||||
# where you are looking, and a second bar for the same second was noise.
|
||||
_draw_level_up_notice(y)
|
||||
|
||||
|
||||
## A thin bar under health: progress toward the next level, and the level
|
||||
## itself. Drawn from the server's numbers, never recomputed locally. Returns
|
||||
## whether it drew, since a player at the roster screen has no character.
|
||||
func _draw_xp_bar(at: Vector2) -> bool:
|
||||
if client.current_character().is_empty():
|
||||
return false
|
||||
## A thin bar under health: progress toward the next level. Drawn from the
|
||||
## server's numbers, never recomputed locally.
|
||||
func _draw_xp_bar(at: Vector2) -> void:
|
||||
# Derived from the snapshot's live experience total rather than the roster's
|
||||
# copy, which only arrives when the set of characters changes.
|
||||
var level := Progression.level_for_xp(client.my_total_xp)
|
||||
var capped := level >= Progression.MAX_LEVEL
|
||||
var progress := Progression.level_progress(client.my_total_xp)
|
||||
var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
|
||||
_bar(at, progress, tint)
|
||||
# Brighter for a moment after a kill, so experience arriving is something
|
||||
# you notice rather than something you check.
|
||||
if _xp_flash > 0.0:
|
||||
tint = tint.lerp(Color.WHITE, 0.45 * clampf(_xp_flash, 0.0, 1.0))
|
||||
_bar(at, progress, tint, -1.0, Vector2(BAR_W, XP_H))
|
||||
# The number as well as the bar: "how far to the next level" is a question
|
||||
# a bar answers vaguely and a percentage answers exactly.
|
||||
var text := "MAX" if capped else "%d%% to level %d" % [
|
||||
int(floor(progress * 100.0)), level + 1]
|
||||
_text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint)
|
||||
return true
|
||||
_text(at + Vector2(0.0, XP_H + 13.0), text, 11, Color(tint, 0.9))
|
||||
if _xp_flash > 0.0 and _xp_gain > 0:
|
||||
_text(at + Vector2(0.0, XP_H + 13.0), "+%d xp" % _xp_gain, 11,
|
||||
Color(COL_GOLD, clampf(_xp_flash, 0.0, 1.0)), HORIZONTAL_ALIGNMENT_RIGHT, BAR_W)
|
||||
|
||||
|
||||
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
|
||||
@@ -332,12 +438,12 @@ func _draw_level_up_notice(y: float) -> void:
|
||||
if client.upgrades_pending <= 0:
|
||||
return
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
|
||||
var where := " -- see the quartermaster in the hub" \
|
||||
var where := " -- quartermaster, in the hub" \
|
||||
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
|
||||
_text(Vector2(MARGIN, y + 16.0),
|
||||
_text(Vector2(MARGIN, y + 12.0),
|
||||
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
|
||||
"" if client.upgrades_pending == 1 else "S", where],
|
||||
13, Color(COL_GOLD, 0.6 + 0.4 * pulse))
|
||||
12, Color(COL_GOLD, 0.6 + 0.4 * pulse))
|
||||
|
||||
|
||||
## The boss's health, once you have seen the boss.
|
||||
@@ -353,20 +459,12 @@ func _draw_boss_bar() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty() or client.boss_def == null:
|
||||
return
|
||||
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
|
||||
if _boss_seen != seen_key:
|
||||
if sight != null and sight.light_at(b["pos"]) <= 0.05:
|
||||
return
|
||||
_boss_seen = seen_key
|
||||
if not track_boss(b):
|
||||
return
|
||||
var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0)
|
||||
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
|
||||
var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H))
|
||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
|
||||
Vector2((BOSS_BAR_W - 6.0) * frac, BAR_H - 6.0))
|
||||
if inner.size.x > 0.5:
|
||||
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
|
||||
Color(0.95, 0.35, 0.45))
|
||||
_panel(Rect2(pos.x - 10.0, MARGIN - 8.0, BOSS_BAR_W + 20.0, BAR_H + 36.0))
|
||||
_bar(pos, frac, Color(0.95, 0.35, 0.45), -1.0, Vector2(BOSS_BAR_W, BAR_H))
|
||||
var phase_index := int(b["phase"])
|
||||
var phase_name := ""
|
||||
if phase_index < client.boss_def.phases.size():
|
||||
@@ -377,16 +475,67 @@ func _draw_boss_bar() -> void:
|
||||
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
|
||||
|
||||
|
||||
## Arrival protection, as a banner rather than a line tucked beside the health
|
||||
## bar: it is the one moment the rules are different, and both halves of it --
|
||||
## you cannot be hurt, and you cannot shoot -- change what you should do.
|
||||
func _draw_arriving() -> void:
|
||||
if not client.my_spawn_grace:
|
||||
return
|
||||
## Whether the boss bar should be up, and the place a phase change is noticed.
|
||||
## Returns false until the fog has lit the boss once; true from then on, for the
|
||||
## rest of that fight.
|
||||
##
|
||||
## A phase change is announced because it is the moment the patterns you just
|
||||
## learned stop being the ones coming at you. Not the phase the boss is in when
|
||||
## first seen -- that is not a change, only where it started.
|
||||
func track_boss(b: Dictionary) -> bool:
|
||||
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
|
||||
if _boss_seen != seen_key:
|
||||
if sight != null and sight.light_at(b["pos"]) <= 0.05:
|
||||
return false
|
||||
_boss_seen = seen_key
|
||||
var phase_index := int(b["phase"])
|
||||
if _boss_phase_key != seen_key:
|
||||
_boss_phase_key = seen_key
|
||||
_boss_phase = phase_index
|
||||
elif phase_index > _boss_phase:
|
||||
_boss_phase = phase_index
|
||||
var phase_name := ""
|
||||
if client.boss_def != null and phase_index < client.boss_def.phases.size():
|
||||
phase_name = client.boss_def.phases[phase_index].name
|
||||
banner(phase_name.to_upper(),
|
||||
client.boss_def.display_name if client.boss_def != null else "",
|
||||
Color(1.0, 0.55, 0.6))
|
||||
return true
|
||||
|
||||
|
||||
## One message across the middle of the screen, chosen by what matters most
|
||||
## right now: leaving, then arriving, then whatever was announced last.
|
||||
##
|
||||
## Arrival protection is a banner rather than a line beside the health bar
|
||||
## because it is the one moment the rules are different, and both halves of
|
||||
## it -- you cannot be hurt, and you cannot shoot -- change what you should do.
|
||||
func _draw_banner() -> void:
|
||||
var y := _canvas.size.y * 0.27
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
|
||||
var y := _canvas.size.y * 0.28
|
||||
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
|
||||
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
|
||||
if client.my_escaping:
|
||||
var blue := Color(0.55, 0.85, 1.0)
|
||||
_centred(y, "LEAVING", 24, blue)
|
||||
var w := 220.0
|
||||
var at := Vector2((_canvas.size.x - w) * 0.5, y + 12.0)
|
||||
_bar(at, client.my_escape, blue, -1.0, Vector2(w, 12.0))
|
||||
_centred(y + 42.0, "keep holding %s" % key("emergency_escape"), 12, Color(blue, 0.8))
|
||||
return
|
||||
if client.my_spawn_grace:
|
||||
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
|
||||
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
|
||||
return
|
||||
if _banner_title.is_empty():
|
||||
return
|
||||
var age := _now() - _banner_born
|
||||
if age > BANNER_LIFE:
|
||||
return
|
||||
var alpha := clampf(minf(age / 0.15, (BANNER_LIFE - age) / BANNER_FADE), 0.0, 1.0)
|
||||
# A short settle on arrival rather than a pop, so it catches the eye without
|
||||
# jolting it away from the bullets.
|
||||
var settle := maxf(0.0, 0.2 - age) * 40.0
|
||||
_centred(y - settle, _banner_title, 28, Color(_banner_col, alpha))
|
||||
if not _banner_sub.is_empty():
|
||||
_centred(y + 24.0 - settle, _banner_sub, 13, Color(_banner_col, 0.8 * alpha))
|
||||
|
||||
|
||||
## Shown after the boss dies, so the victory lap has a visible clock on it.
|
||||
@@ -414,6 +563,7 @@ func _draw_others() -> void:
|
||||
func _draw_online() -> void:
|
||||
var x := _canvas.size.x - 240.0
|
||||
var y := MARGIN + 12.0
|
||||
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 28.0 + 18.0 * client.roster.size()))
|
||||
_text(Vector2(x, y), "ONLINE", 13, COL_DIM)
|
||||
y += 20.0
|
||||
for entry in client.roster:
|
||||
@@ -444,6 +594,7 @@ func _draw_party() -> void:
|
||||
return
|
||||
var x := _canvas.size.x - 240.0
|
||||
var y := MARGIN + 12.0
|
||||
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 26.0 + 20.0 * members.size()))
|
||||
_text(Vector2(x, y), "PARTY", 13, COL_DIM)
|
||||
y += 10.0
|
||||
var w := 140.0
|
||||
@@ -474,6 +625,47 @@ func _draw_party() -> void:
|
||||
y += 20.0
|
||||
|
||||
|
||||
## A marker at the edge of sight for each party member you cannot see, pointing
|
||||
## the way to them.
|
||||
##
|
||||
## The fog hides players behind walls and beyond sight like anything else, and
|
||||
## in a dungeon the most common question about the rest of your party is where
|
||||
## they went. Allies only, from data the client already holds -- a party member
|
||||
## outside the interest radius is not in the snapshot, and gets no marker.
|
||||
func _draw_party_markers() -> void:
|
||||
if client.instance_kind == Protocol.InstanceKind.LOBBY or not client.my_alive:
|
||||
return
|
||||
var eye := client.predicted_pos
|
||||
var centre := eye + client.camera_offset
|
||||
var ring := SimConfig.FOG_VIEW_RADIUS - 40.0
|
||||
var names := {}
|
||||
for entry in client.roster:
|
||||
names[int(entry["peer"])] = String(entry["name"])
|
||||
var bounds := Rect2(Vector2(MARGIN, MARGIN), _canvas.size - Vector2(MARGIN, MARGIN) * 2.0)
|
||||
for p in client.remote_players():
|
||||
var pos: Vector2 = p["pos"]
|
||||
var offset := pos - eye
|
||||
var dist := offset.length()
|
||||
if dist < 1.0:
|
||||
continue
|
||||
var lit := sight == null or sight.light_at(pos) > 0.3
|
||||
if dist < ring and lit:
|
||||
continue
|
||||
var dir := offset / dist
|
||||
var at := centre + dir * minf(ring, dist)
|
||||
at = at.clamp(bounds.position, bounds.end)
|
||||
var flags := int(p["flags"])
|
||||
var col: Color = p["colour"]
|
||||
if (flags & Protocol.F_ALIVE) == 0:
|
||||
col = COL_DANGER
|
||||
var tip := at + dir * 9.0
|
||||
var side := dir.orthogonal() * 6.0
|
||||
_canvas.draw_colored_polygon(PackedVector2Array([
|
||||
tip, at - dir * 4.0 + side, at - dir * 4.0 - side]), Color(col, 0.9))
|
||||
_text(at - dir * 12.0 + Vector2(-60.0, 4.0), String(names.get(int(p["peer"]), "")),
|
||||
11, Color(col, 0.85), HORIZONTAL_ALIGNMENT_CENTER, 120.0)
|
||||
|
||||
|
||||
## Four slots, always on screen. Deliberately not a panel you open: an
|
||||
## inventory you have to stop and read is a menu, and a menu is a death in a
|
||||
## game where the floor is bullets. Everything drawn here comes from the
|
||||
@@ -494,7 +686,11 @@ func _draw_inventory() -> void:
|
||||
# recoloured border, so "this is the one the key is on" reads the same
|
||||
# way every other pressed thing in the interface does.
|
||||
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
|
||||
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
|
||||
# An empty slot steps back. The pack's slot is a bright parchment
|
||||
# square, and an empty one drew the eye as hard as a full one -- the
|
||||
# blank box was the most prominent thing in the row.
|
||||
var slot_tint := Color.WHITE if def != null else Color(1.0, 1.0, 1.0, 0.45)
|
||||
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false, slot_tint)
|
||||
# The key that uses the slot, because that is the only thing the player
|
||||
# actually needs to know about it -- and it is whatever they bound.
|
||||
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
|
||||
@@ -613,14 +809,30 @@ func _draw_vignette() -> void:
|
||||
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
|
||||
|
||||
|
||||
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
|
||||
func _bar(pos: Vector2, frac: float, fill: Color) -> void:
|
||||
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
|
||||
## A bar in the pack's art: the frame stretched horizontally, a dark track
|
||||
## inside it, and the fill tinted on top.
|
||||
##
|
||||
## [param trail], when it is above [param frac], is drawn first in a pale
|
||||
## version of the fill -- the part of the bar the last hit took, before it
|
||||
## drains away. That is what makes a hit readable as an amount rather than as a
|
||||
## bar that is suddenly shorter.
|
||||
func _bar(pos: Vector2, frac: float, fill: Color, trail: float = -1.0,
|
||||
size: Vector2 = Vector2(BAR_W, BAR_H)) -> void:
|
||||
var rect := Rect2(pos, size)
|
||||
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
|
||||
var inset := 3.0
|
||||
var inner := Rect2(rect.position + Vector2(inset, inset),
|
||||
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
|
||||
rect.size.y - inset * 2.0))
|
||||
# The frame scales with the bar's height, so the inset has to as well: a
|
||||
# fixed three pixels left a ten-pixel bar four pixels of track and six of
|
||||
# white frame, and it read as an empty white bar whatever it held.
|
||||
var inset := maxf(1.0, roundf(size.y * 0.15))
|
||||
var track := Rect2(rect.position + Vector2(inset, inset), rect.size - Vector2(inset, inset) * 2.0)
|
||||
_canvas.draw_rect(track, COL_TRACK)
|
||||
var full := track.size.x
|
||||
if trail > frac:
|
||||
# Darker than the fill, not paler. The health number sits on top of
|
||||
# this, and a pale trail was the one background it vanished against.
|
||||
_canvas.draw_rect(Rect2(track.position, Vector2(full * clampf(trail, 0.0, 1.0), track.size.y)),
|
||||
Color(fill.darkened(0.5), 0.95))
|
||||
var inner := Rect2(track.position, Vector2(full * clampf(frac, 0.0, 1.0), track.size.y))
|
||||
if inner.size.x > 0.5:
|
||||
# The fill sprite is a flat strip, so tinting it is the whole palette --
|
||||
# one texture covers health, experience, the escape channel and the
|
||||
|
||||
+80
-6
@@ -5,12 +5,20 @@ extends Control
|
||||
signal join_requested(address: String, port: int)
|
||||
signal host_requested(port: int)
|
||||
signal settings_requested
|
||||
## The licences two of the asset packs require, one click from the first
|
||||
## screen rather than two screens deep in settings.
|
||||
signal credits_requested
|
||||
|
||||
var _address: LineEdit
|
||||
var _port: LineEdit
|
||||
var _status: Label
|
||||
var _join_button: Button
|
||||
var _host_button: Button
|
||||
## True while a connection attempt is in flight; see set_busy().
|
||||
var _busy: bool = false
|
||||
## The status as set, and whether it is progress rather than a complaint.
|
||||
var _status_base := ""
|
||||
var _working: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -28,6 +36,10 @@ func _ready() -> void:
|
||||
# A CenterContainer recomputes its child's centered position itself, every
|
||||
# time the child's minimum size or the window size changes -- no manual
|
||||
# offset math to get wrong.
|
||||
# Behind everything, so it is first: a child draws over its earlier
|
||||
# siblings, and the panel has to be in front of the bullets.
|
||||
add_child(preload("res://src/ui/menu_backdrop.gd").new())
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
@@ -43,8 +55,19 @@ func _ready() -> void:
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "TRANSCIENCE"
|
||||
title.add_theme_font_size_override("font_size", 34)
|
||||
# Spaced out, which the default font will not do on its own: a word set
|
||||
# tight at 38px reads as a heading, and this is a name.
|
||||
var spaced := FontVariation.new()
|
||||
spaced.base_font = ThemeDB.fallback_font
|
||||
spaced.spacing_glyph = 5
|
||||
title.add_theme_font_override("font", spaced)
|
||||
title.add_theme_font_size_override("font_size", 38)
|
||||
panel.add_child(title)
|
||||
var accent := ColorRect.new()
|
||||
accent.color = Color(1.0, 0.72, 0.35, 0.85)
|
||||
accent.custom_minimum_size = Vector2(64.0, 3.0)
|
||||
accent.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
panel.add_child(accent)
|
||||
var subtitle := Label.new()
|
||||
subtitle.text = "a co-operative bullet hell"
|
||||
subtitle.add_theme_font_size_override("font_size", 13)
|
||||
@@ -70,16 +93,20 @@ func _ready() -> void:
|
||||
_address.placeholder_text = "address"
|
||||
_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_address.text_submitted.connect(func(_t: String) -> void: _on_join())
|
||||
_address.text_changed.connect(func(_t: String) -> void: _validate())
|
||||
where.add_child(_address)
|
||||
_port = LineEdit.new()
|
||||
_port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port)
|
||||
_port.placeholder_text = "port"
|
||||
_port.custom_minimum_size = Vector2(90.0, 0.0)
|
||||
_port.text_submitted.connect(func(_t: String) -> void: _on_join())
|
||||
_port.text_changed.connect(func(_t: String) -> void: _validate())
|
||||
where.add_child(_port)
|
||||
|
||||
_join_button = Button.new()
|
||||
_join_button.text = "Join"
|
||||
# Enter works from either field, and from the button itself, which has focus
|
||||
# when the screen opens. Saying so costs one word.
|
||||
_join_button.text = "Join (Enter)"
|
||||
_join_button.pressed.connect(_on_join)
|
||||
panel.add_child(_join_button)
|
||||
|
||||
@@ -98,12 +125,25 @@ func _ready() -> void:
|
||||
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
settings.pressed.connect(func() -> void: settings_requested.emit())
|
||||
row.add_child(settings)
|
||||
var credits := Button.new()
|
||||
credits.text = "Credits"
|
||||
credits.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
credits.pressed.connect(func() -> void: credits_requested.emit())
|
||||
row.add_child(credits)
|
||||
var quit := Button.new()
|
||||
quit.text = "Quit"
|
||||
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
quit.pressed.connect(func() -> void: get_tree().quit())
|
||||
row.add_child(quit)
|
||||
|
||||
var how := Label.new()
|
||||
how.text = "Host and play runs a server on this machine. Others join it by your address."
|
||||
how.add_theme_font_size_override("font_size", 11)
|
||||
how.add_theme_color_override("font_color", Color(0.5, 0.55, 0.66))
|
||||
how.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
how.custom_minimum_size = Vector2(400.0, 0.0)
|
||||
panel.add_child(how)
|
||||
|
||||
_status = Label.new()
|
||||
_status.add_theme_font_size_override("font_size", 13)
|
||||
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
@@ -119,6 +159,29 @@ func _ready() -> void:
|
||||
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||
panel.add_child(version)
|
||||
|
||||
_validate()
|
||||
# So Enter joins straight away for a returning player, whose address is
|
||||
# already filled in from last time.
|
||||
_join_button.call_deferred("grab_focus")
|
||||
|
||||
|
||||
## Whether what is typed could possibly connect, said before the button is
|
||||
## pressed rather than after. A non-number in the port used to go through as
|
||||
## port 0 and come back as a connection error that did not say why.
|
||||
func _validate() -> void:
|
||||
var port_text := _port.text.strip_edges()
|
||||
var port := int(port_text) if port_text.is_valid_int() else 0
|
||||
var port_ok := port >= 1 and port <= 65535
|
||||
var address_ok := not _address.text.strip_edges().is_empty()
|
||||
_join_button.disabled = _busy or not (port_ok and address_ok)
|
||||
_host_button.disabled = _busy or not port_ok
|
||||
if not port_ok:
|
||||
set_status("the port is a number from 1 to 65535")
|
||||
elif not address_ok:
|
||||
set_status("which server? type its address")
|
||||
elif _status.text.begins_with("the port") or _status.text.begins_with("which server"):
|
||||
set_status("")
|
||||
|
||||
|
||||
func _gap(height: float) -> Control:
|
||||
var c := Control.new()
|
||||
@@ -129,17 +192,28 @@ func _gap(height: float) -> Control:
|
||||
## Shown under the buttons. Coloured by what it is: "connecting..." is not an
|
||||
## error, and it used to be drawn in the same red as one.
|
||||
func set_status(text: String) -> void:
|
||||
_status_base = text
|
||||
_status.text = text
|
||||
var working := text.begins_with("connecting")
|
||||
_working = text.begins_with("connecting")
|
||||
_status.add_theme_color_override("font_color",
|
||||
Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5))
|
||||
Color(0.65, 0.8, 1.0) if _working else Color(1.0, 0.55, 0.5))
|
||||
|
||||
|
||||
## A connection attempt can take a few seconds, and a line of text that does not
|
||||
## move while it does reads as frozen. The dots are the only thing animated.
|
||||
func _process(_delta: float) -> void:
|
||||
if not _working:
|
||||
return
|
||||
var base := _status_base.trim_suffix("...").trim_suffix(" ")
|
||||
var dots := int(float(Time.get_ticks_msec()) / 400.0) % 4
|
||||
_status.text = base + " " + ".".repeat(dots)
|
||||
|
||||
|
||||
## Locks the connect buttons while an attempt is in flight, so a second click
|
||||
## cannot start a competing connection over the top of the first.
|
||||
func set_busy(busy: bool) -> void:
|
||||
_join_button.disabled = busy
|
||||
_host_button.disabled = busy
|
||||
_busy = busy
|
||||
_validate()
|
||||
|
||||
|
||||
func _on_join() -> void:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
extends Node2D
|
||||
## The connect screen's background: a real boss pattern, played out behind the
|
||||
## panel, dimmed.
|
||||
##
|
||||
## Real in the sense that matters -- the boss's own emitters fired into a
|
||||
## [BulletPool] and drawn by the game's own bullet renderer, so what plays here
|
||||
## is what the fight actually looks like, and a pattern that changes in
|
||||
## content.gd changes here with it. Nothing is simulated beyond the bullets:
|
||||
## no world, no hits, no map. The first thing a player sees is the genre.
|
||||
|
||||
const BULLETS := preload("res://src/view/bullet_renderer.gd")
|
||||
|
||||
## How long each phase plays before the next one takes over, in ticks.
|
||||
const PHASE_TICKS := 60 * 9
|
||||
## How visible it is. Enough to read as a bullet hell, not enough to compete
|
||||
## with the panel in front of it.
|
||||
const DIM := 0.32
|
||||
## Run this many ticks before the first frame is drawn. Every pattern starts at
|
||||
## the centre of the screen, which is exactly where the panel is, so a backdrop
|
||||
## that started from nothing showed nothing at all for its first second or two
|
||||
## -- it was there, all of it, behind the buttons.
|
||||
const PRIME_TICKS := 60 * 5
|
||||
|
||||
var _pool := BulletPool.new()
|
||||
var _ctx := EmitContext.new()
|
||||
var _renderer: Node2D
|
||||
var _phases: Array = []
|
||||
var _tick: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
modulate = Color(1.0, 1.0, 1.0, DIM)
|
||||
_renderer = BULLETS.new()
|
||||
add_child(_renderer)
|
||||
# Every phase of every boss, in order -- the Warden's folds, then the
|
||||
# Cantor's verses -- so leaving the menu up shows the whole bestiary.
|
||||
for id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(id).phases:
|
||||
if not phase.emitters.is_empty():
|
||||
_phases.append(phase.emitters)
|
||||
_ctx.pool = _pool
|
||||
_ctx.rng = RandomNumberGenerator.new()
|
||||
_ctx.rng.seed = 1
|
||||
for _i in PRIME_TICKS:
|
||||
_step()
|
||||
_renderer.render_pool(_pool)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_step()
|
||||
_renderer.render_pool(_pool)
|
||||
|
||||
|
||||
func _step() -> void:
|
||||
if _phases.is_empty():
|
||||
return
|
||||
var centre := get_viewport_rect().size * 0.5
|
||||
var phase_index := (_tick / PHASE_TICKS) % _phases.size()
|
||||
var local := _tick % PHASE_TICKS
|
||||
_ctx.origin = centre
|
||||
# Aimed patterns need someone to aim at. A point drifting slowly round the
|
||||
# centre, so aimed volleys sweep rather than all going the same way.
|
||||
var t := float(_tick) * 0.006
|
||||
_ctx.target = centre + Vector2(cos(t), sin(t * 0.7)) * 260.0
|
||||
_ctx.has_target = true
|
||||
_ctx.bounds = get_viewport_rect()
|
||||
_ctx.local_tick = local
|
||||
# Telegraphed strikes announce through the context; there is nobody to warn.
|
||||
_ctx.events = []
|
||||
for e: BulletEmitter in _phases[phase_index]:
|
||||
if e == null or not e.should_fire(local):
|
||||
continue
|
||||
_ctx.shot_index = e.shot_index_at(local)
|
||||
e.fire(_ctx)
|
||||
_pool.step()
|
||||
_tick += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpbu6rmxpqg0e
|
||||
@@ -211,3 +211,26 @@ func test_boss_rush_leaves_the_tunnels_empty_too() -> void:
|
||||
var run := Instance.make_dungeon(2, 4242, 3)
|
||||
GameOpts.boss_rush = false
|
||||
assert_eq(_tunnel_enemies(run).size(), 0)
|
||||
|
||||
|
||||
func test_nothing_is_awake_and_watching_when_the_party_arrives() -> void:
|
||||
# Room 0 is left empty so that arriving is not an ambush, but that only ever
|
||||
# covered room 0. With tunnels widened, three runs in eight had an enemy in
|
||||
# a neighbouring room within aggro range and in sight of the entrance.
|
||||
var watching := 0
|
||||
for s in range(1, 120):
|
||||
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
|
||||
for e in run.world.enemies.values():
|
||||
if e.pos.distance_to(run.world.spawn_point) <= e.def.aggro_range \
|
||||
and run.world.map.has_line_of_sight(e.pos, run.world.spawn_point):
|
||||
watching += 1
|
||||
assert_eq(watching, 0, "%d enemies could see the entrance at the moment of arrival" % watching)
|
||||
|
||||
|
||||
func test_clearing_the_entrance_leaves_the_rest_of_the_dungeon_alone() -> void:
|
||||
# It skips the few enemies that would be awake on arrival and nothing else:
|
||||
# the dungeon keeps nearly all of its population.
|
||||
var total := 0
|
||||
for s in range(1, 40):
|
||||
total += Instance.make_dungeon(2, s * 7919, 1).world.enemies.size()
|
||||
assert_gt(float(total) / 39.0, 9.0, "a depth-1 dungeon still has a dungeon's worth of enemies")
|
||||
@@ -0,0 +1,213 @@
|
||||
extends GutTest
|
||||
## HUD and connect-screen behaviour that does not need a screen to check.
|
||||
## What things LOOK like is screenshot.tscn's job; this is what they DO.
|
||||
|
||||
const HUD := preload("res://src/ui/hud.gd")
|
||||
const MENU := preload("res://src/ui/main_menu.gd")
|
||||
const BACKDROP := preload("res://src/ui/menu_backdrop.gd")
|
||||
|
||||
|
||||
func _hud_with(hp: int, max_hp: int) -> Array:
|
||||
var hud: CanvasLayer = HUD.new()
|
||||
add_child_autofree(hud)
|
||||
var c: ClientRuntime = autofree(ClientRuntime.new())
|
||||
c.my_hp = hp
|
||||
c.my_max_hp = max_hp
|
||||
hud.client = c
|
||||
hud._hp_trail = float(hp) / float(max_hp)
|
||||
hud._hp_last = hud._hp_trail
|
||||
return [hud, c]
|
||||
|
||||
|
||||
# --- The damage trail -------------------------------------------------------
|
||||
|
||||
func test_a_hit_leaves_a_trail_where_the_bar_was() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 400
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "the trail holds at the old value")
|
||||
assert_almost_eq(hud._hp_last, 0.4, 0.001)
|
||||
|
||||
|
||||
func test_the_trail_holds_and_then_drains_to_meet_the_bar() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 400
|
||||
hud._watch_health(0.016)
|
||||
# Within the hold, nothing moves -- that pause is what makes the amount
|
||||
# readable.
|
||||
for _i in int(HUD.TRAIL_HOLD / 0.016) - 2:
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "still holding")
|
||||
for _i in 200:
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 0.4, 0.001, "and then drained all the way down")
|
||||
|
||||
|
||||
func test_healing_leaves_no_trail() -> void:
|
||||
var pair := _hud_with(400, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 900
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 0.9, 0.001,
|
||||
"a trail is for what was lost; on a heal the bar and trail move together")
|
||||
|
||||
|
||||
func test_a_second_hit_during_the_hold_keeps_the_original_top() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 700
|
||||
hud._watch_health(0.016)
|
||||
c.my_hp = 300
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001,
|
||||
"two quick hits read as one big one, measured from where it started")
|
||||
|
||||
|
||||
# --- Experience -------------------------------------------------------------
|
||||
|
||||
func test_experience_arriving_quickly_is_summed_into_one_number() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
hud._seen_character = "a"
|
||||
c.my_total_xp = 100
|
||||
hud._watch_experience(0.016)
|
||||
c.my_total_xp = 112
|
||||
hud._watch_experience(0.016)
|
||||
c.my_total_xp = 120
|
||||
hud._watch_experience(0.016)
|
||||
assert_eq(hud._xp_gain, 20, "+12 then +8 shows as +20, not as a flicker")
|
||||
|
||||
|
||||
func test_the_first_sight_of_a_character_is_not_a_gain() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_total_xp = 5000
|
||||
hud._watch_experience(0.016)
|
||||
assert_eq(hud._xp_flash, 0.0, "logging in with 5000 experience did not just earn it")
|
||||
|
||||
|
||||
# --- The connect screen -----------------------------------------------------
|
||||
|
||||
func _menu() -> Control:
|
||||
var m: Control = MENU.new()
|
||||
add_child_autofree(m)
|
||||
return m
|
||||
|
||||
|
||||
func test_a_port_that_cannot_connect_disables_join_and_says_why() -> void:
|
||||
var m := _menu()
|
||||
m._port.text = "twenty"
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled)
|
||||
assert_true(m._host_button.disabled)
|
||||
assert_string_contains(m._status.text, "port")
|
||||
m._port.text = "70000"
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled, "out of range is as useless as not a number")
|
||||
m._port.text = "27015"
|
||||
m._validate()
|
||||
assert_false(m._join_button.disabled)
|
||||
assert_eq(m._status.text, "", "and the complaint goes when the problem does")
|
||||
|
||||
|
||||
func test_no_address_disables_join_but_not_host() -> void:
|
||||
var m := _menu()
|
||||
m._address.text = " "
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled, "nowhere to join")
|
||||
assert_false(m._host_button.disabled, "hosting needs no address")
|
||||
|
||||
|
||||
func test_busy_wins_over_valid() -> void:
|
||||
var m := _menu()
|
||||
m.set_busy(true)
|
||||
assert_true(m._join_button.disabled, "a connection in flight is not a second invitation")
|
||||
m.set_busy(false)
|
||||
assert_false(m._join_button.disabled)
|
||||
|
||||
|
||||
func test_the_connect_screen_asks_for_no_name() -> void:
|
||||
# It used to, and the answer went nowhere a person could see -- it only named
|
||||
# the characters bots create.
|
||||
var m := _menu()
|
||||
for node in m.find_children("*", "LineEdit", true, false):
|
||||
assert_ne((node as LineEdit).placeholder_text, "name")
|
||||
assert_eq(m.find_children("*", "LineEdit", true, false).size(), 2,
|
||||
"an address and a port, nothing else")
|
||||
|
||||
|
||||
func test_the_backdrop_opens_on_a_pattern_already_in_flow() -> void:
|
||||
# Every pattern starts at the centre of the screen, behind the panel. Primed,
|
||||
# the first frame already has bullets well clear of it.
|
||||
var b: Node2D = BACKDROP.new()
|
||||
add_child_autofree(b)
|
||||
var centre := b.get_viewport_rect().size * 0.5
|
||||
var clear_of_panel := 0
|
||||
for i in b._pool.high_water:
|
||||
if b._pool.alive[i] != 0 and b._pool.pos[i].distance_to(centre) > 320.0:
|
||||
clear_of_panel += 1
|
||||
assert_gt(clear_of_panel, 10, "bullets beyond the panel on the very first frame")
|
||||
|
||||
|
||||
# --- The boss bar -----------------------------------------------------------
|
||||
|
||||
## A HUD whose fog sees from the left of a room, with a wall down the middle.
|
||||
func _hud_with_sight() -> Array:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
var g := MapGrid.new(40, 20, MapGrid.Kind.FLOOR)
|
||||
for ty in 20:
|
||||
g.set_tile(20, ty, MapGrid.Kind.WALL)
|
||||
var sight := Visibility.new()
|
||||
sight.compute(g, Vector2(10.5, 10.5) * MapGrid.TILE, 460.0)
|
||||
hud.sight = sight
|
||||
c.instance_id = 3
|
||||
c.boss_def = Content.boss(&"warden")
|
||||
return [hud, c]
|
||||
|
||||
|
||||
func _boss_at(x_tiles: float, phase: int = 0) -> Dictionary:
|
||||
return {"id": 1, "pos": Vector2(x_tiles, 10.5) * MapGrid.TILE, "phase": phase, "hp": 100}
|
||||
|
||||
|
||||
func test_the_bar_waits_until_the_boss_has_been_seen() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
# Behind the wall: in the snapshot -- the interest radius passes through
|
||||
# walls -- but not seen.
|
||||
assert_false(hud.track_boss(_boss_at(24.0)), "known about is not the same as seen")
|
||||
assert_true(hud.track_boss(_boss_at(16.0)), "in the light, it shows")
|
||||
|
||||
|
||||
func test_once_seen_it_stays_for_the_fight() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
hud.track_boss(_boss_at(16.0))
|
||||
assert_true(hud.track_boss(_boss_at(24.0)),
|
||||
"stepping behind a pillar must not take the bar away")
|
||||
|
||||
|
||||
func test_a_new_dungeon_has_to_be_seen_again() -> void:
|
||||
var pair := _hud_with_sight()
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
hud.track_boss(_boss_at(16.0))
|
||||
# Every dungeon's boss is actor 1, so the id alone would carry the last
|
||||
# fight's "seen" straight into the next one.
|
||||
c.instance_id = 4
|
||||
assert_false(hud.track_boss(_boss_at(24.0)))
|
||||
|
||||
|
||||
func test_a_phase_change_is_announced_and_the_first_phase_is_not() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
hud.track_boss(_boss_at(16.0, 1))
|
||||
assert_eq(hud._banner_title, "", "the phase it was in when found is not a change")
|
||||
hud.track_boss(_boss_at(16.0, 2))
|
||||
assert_eq(hud._banner_title, Content.boss(&"warden").phases[2].name.to_upper())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn1mu2io17h4i
|
||||
+55
-26
@@ -24,29 +24,32 @@ const STORE := "user://screenshot_characters.json"
|
||||
const ACCOUNT := 777001
|
||||
## Frame numbers, not seconds: the point is a deterministic sequence, and a
|
||||
## windowed client at 60fps gets there in about four seconds.
|
||||
const AT_MENU := 30
|
||||
const ENTER_GAME := 34
|
||||
const AT_GAME := 200
|
||||
const OPEN_MENU := 210
|
||||
const AT_INGAME_MENU := 240
|
||||
const OPEN_SETTINGS := 250
|
||||
const AT_SETTINGS := 280
|
||||
const OPEN_CREDITS := 290
|
||||
const AT_CREDITS := 320
|
||||
const OPEN_UPGRADES := 330
|
||||
const AT_UPGRADES := 360
|
||||
const OPEN_ROSTER := 370
|
||||
const AT_ROSTER := 400
|
||||
const ENTER_DUNGEON := 410
|
||||
const AT_DUNGEON := 480
|
||||
const TO_TUNNEL := 490
|
||||
const AT_TUNNEL := 550
|
||||
const AT_TUNNEL_DEBUG := 560
|
||||
const TO_BOSS := 570
|
||||
const AT_BOSS := 640
|
||||
const DONE := 650
|
||||
const AT_MENU := 200
|
||||
const ENTER_GAME := 204
|
||||
const AT_GAME := 370
|
||||
const OPEN_MENU := 380
|
||||
const AT_INGAME_MENU := 410
|
||||
const OPEN_SETTINGS := 420
|
||||
const AT_SETTINGS := 450
|
||||
const OPEN_CREDITS := 460
|
||||
const AT_CREDITS := 490
|
||||
const OPEN_UPGRADES := 500
|
||||
const AT_UPGRADES := 530
|
||||
const OPEN_ROSTER := 540
|
||||
const AT_ROSTER := 570
|
||||
const ENTER_DUNGEON := 580
|
||||
const AT_DUNGEON := 650
|
||||
const TO_TUNNEL := 660
|
||||
const AT_TUNNEL := 720
|
||||
const AT_TUNNEL_DEBUG := 730
|
||||
const TO_BOSS := 740
|
||||
const AT_BOSS := 810
|
||||
const ESCAPE_START := 830
|
||||
## A safety net only: the run normally ends the moment the escape shot is taken.
|
||||
const DONE := 1710
|
||||
|
||||
var _frame: int = 0
|
||||
var _left_shot: bool = false
|
||||
var _game: Node = null
|
||||
var _menu: Control = null
|
||||
var _dir: String = "user://"
|
||||
@@ -98,10 +101,30 @@ func _process(_delta: float) -> void:
|
||||
# it every frame, so one assignment lasts exactly one frame.
|
||||
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
|
||||
_show_the_roster()
|
||||
# Held low, so the low-health edge is in the picture -- and held at all,
|
||||
# because the boss is firing and one landed ring would end the shot.
|
||||
if _frame > TO_BOSS and _frame <= AT_BOSS:
|
||||
_hold_health(0.22)
|
||||
# Held, because the boss is firing and one landed ring would end the shot.
|
||||
# High until just before the picture and low after, so the damage trail
|
||||
# -- the part of the bar the drop took -- is still draining when it is taken.
|
||||
if _frame > TO_BOSS and not _left_shot:
|
||||
_hold_health(0.62 if _frame < AT_BOSS - 10 else 0.22)
|
||||
# Captured by progress rather than by frame count. The channel is one second
|
||||
# of server ticks and frames run as fast as the display allows, so a fixed
|
||||
# frame number lands anywhere from a quarter of the way in to after it has
|
||||
# completed -- and a completed escape teleports the player out mid-sequence.
|
||||
if _frame >= ESCAPE_START and not _left_shot:
|
||||
Net.client.request_escape = true
|
||||
if Net.client.my_escape >= 0.45:
|
||||
_grab("leaving")
|
||||
Net.client.request_escape = false
|
||||
_left_shot = true
|
||||
Net.shutdown()
|
||||
get_tree().quit(0)
|
||||
return
|
||||
if _frame == AT_BOSS - 30:
|
||||
Net.server._grant_xp(Net.LOCAL_PEER, 46)
|
||||
# Frames, not seconds, and this runs as fast as the display allows -- so
|
||||
# early enough that the banner's fade-in is over even at a high frame rate.
|
||||
if _frame == AT_BOSS - 60:
|
||||
_game.get_node("HUD").banner("LEVEL 7", "a choice waits at the quartermaster")
|
||||
match _frame:
|
||||
AT_MENU:
|
||||
_grab("menu")
|
||||
@@ -266,11 +289,17 @@ func _stage_a_boss_fight() -> void:
|
||||
friend.spawn_grace = 0
|
||||
Net.server.peer_names[999] = "Wren"
|
||||
Net.server.peer_instance[999] = inst.id
|
||||
# And one out of sight, so the marker pointing the way to them shows.
|
||||
var away := inst.world.add_player(998, "Lark")
|
||||
away.pos = at + Vector2(-560.0, 260.0)
|
||||
away.colour = Color(0.55, 0.85, 1.0)
|
||||
away.spawn_grace = 0
|
||||
Net.server.peer_names[998] = "Lark"
|
||||
Net.server.peer_instance[998] = inst.id
|
||||
Net.server._broadcast_roster()
|
||||
var hud := _game.get_node("HUD")
|
||||
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint)
|
||||
hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint)
|
||||
hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4))
|
||||
|
||||
|
||||
## Move the player, on both sides, into the tunnel closest to where it spawned:
|
||||
|
||||
Reference in new issue
Block a user