Compare commits

..
8 Commits
Author SHA1 Message Date
claudeandClaude Opus 5.5 08f58af1a8 Documentation pass before a compact
ci / verify (push) Successful in 53s
Audited rather than appended. What had drifted, now corrected:

- ROADMAP's opening still described the project before this session: no
  word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a
  screenshot list half its current size. It now summarises the session,
  lists seven open items raised with the user and not yet decided (the
  boss-kill experience edge, the hub frame rate, aggro through corner
  cracks, the Warden-hall alcoves, one-tile arena doors, the Proving
  Grounds before release, hit feedback), and quotes current numbers.
  Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull
  at 2200; it is a 2900 ceiling over a per-bullet check.
- NETCODE described the flat bullet radius as the design, claimed the
  respawn lockout gates connected players (they are unbound on death),
  and called actor scoping "next stage". All corrected, and a new section
  covers what the handshake and server now refuse: duplicate sessions,
  reasons that now arrive, paced control messages, coalesced store
  writes, bounds-checked hot decoders.
- DECISIONS gains the audit's rules, the Proving Grounds' status, the
  tunnel and entrance rules, and the interface calls -- each flagged as
  the user's or as a judgement call.
- ARCHITECTURE's file map lacked the actor classes, the dev auth
  provider, the menu backdrop and any tools section.
- README told people to start a client with --name, which now only names
  bot characters, and its feature list predated fog, tunnels and carried
  health. CLAUDE.md gains three invariants (health on the record, one
  session per account, tools that host use scratch data); WORKFLOW gains
  the four traps this session fell into or nearly did.

Every doc was checked for unintended deletions against the session's
start, and every relative link resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:05:56 +02:00
claudeandClaude Opus 5.5 94ad6b3429 Stop drawing bullets past the edge of sight -- but never sooner than is safe
ci / verify (push) Successful in 54s
Bullets were drawn everywhere on screen, including out in the black past
the edge of sight: shots that could not reach you, streaking across the
dark. They now fade out beyond sight, under one condition that bounds the
whole change: no enemy bullet may hit you without having been on screen,
at full strength, for at least WARNING_TIME (0.75s) first.

So each enemy bullet is drawn out to the edge of sight or to however far
it could close in that window with you running straight at it -- its own
speed, plus acceleration over the window, plus yours -- whichever is
further, and fades over 80 units beyond. With today's content the edge of
sight always wins: the fastest enemy bullet is 300 u/s, so head-on it is
on screen 0.83s before it lands. The speed term is what keeps that true
when something faster is added. Player shots fade at the edge of sight
however fast they are, since they cannot hurt you; a Sniper's 1500 u/s
shot was the commonest thing crossing the dark.

Shadow does not hide a bullet. One behind a wall within sight range is
still drawn over the darkness, because you can step round the corner into
it, and hiding it would be exactly the invisible hit the condition rules
out.

The guarantee is tested the hard way: every emitter in the content, and
bullets far faster than any of them, simulated head-on against a player
running at them, measuring how long each was fully drawn before it
landed. The rule's weakenings -- no speed term, forgetting the player's
own approach, ignoring acceleration -- each fail it. The connect screen's
backdrop passes no eye and draws everything, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:57:50 +02:00
claudeandClaude Opus 5.5 170eb409dd Restore the 451 lines of the roadmap the flake note deleted
ci / verify (push) Successful in 53s
Commit 3d1b5da replaced the "Known flake" note by slicing the file from
that heading to "## Open questions for the user", on the assumption the
two were adjacent. They are not: between them sit the verification
surface and every stage section -- what each one built, its decisions and
its known gaps. All of it went, and it was pushed.

Restored from the commit before, with only the two intended changes
applied on top: the flake section replaced with its diagnosis, now ending
at the rule that actually closes it, and the HUD and connect-screen rows
the second UI pass meant to add. Every other document was checked for the
same kind of loss against the start of the session; none had any.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:14:58 +02:00
claudeandClaude Opus 5.5 6e872e2cf5 HUD and connect screen, second pass
ci / verify (push) Successful in 53s
At 331 of 1500 health the bar read as nearly full. The pack's bar sprite
is light all the way through, so the EMPTY part was the brightest thing
on it. Every bar now has a dark track inside its frame, scaled with the
bar's height -- a fixed inset left the thin experience bar four pixels of
track and six of white -- so empty reads as empty.

Health carries its number inside the bar, and a damage trail: the part
the last hit took stays in a darker red for a moment and then drains, so
a hit reads as an amount rather than as a bar that is suddenly shorter.
Two quick hits read as one, measured from where the first began. The
trail was pale at first and the number vanished against it; darker fixed
that. Experience flashes and shows "+N xp" on a kill, summed across a
burst so it does not flicker. Every block of HUD text sits on a dark
panel, since the floor art is busy enough to beat a text shadow.

Centre banners for the moments that change what you should do: a level
gained, a boss changing phase (not the phase it was found in), and the
escape channel -- which was a second bar in the corner and is now one
bar where you are looking, with the key to keep holding. Party members
you cannot see get a marker at the edge of sight pointing the way to
them. Empty inventory slots step back, so the blank parchment square is
no longer the most prominent thing in the row.

The connect screen opens on a real boss pattern behind its panel -- the
bosses' own emitters, fired into a pool and drawn by the game's own bullet
renderer, dimmed. It is primed with five seconds of ticks: every pattern
starts at the centre of the screen, which is where the panel is, so
unprimed it showed nothing for its first second or two. The title is
spaced and underlined, Credits is on the screen itself, Join says Enter
works and has focus, the port is validated before it can fail, and
"connecting" animates so a slow attempt does not look frozen.

The boss-bar logic is pulled out into track_boss() so it can be tested:
it waits for the fog to light the boss, stays for the fight, has to be
seen again in the next dungeon -- every dungeon's boss is actor 1, so the
id alone would have carried "seen" across -- and announces only real
phase changes. screenshot.tscn captures the escape banner by progress
rather than by frame count, because the channel is server ticks and a
completed escape teleports the player out mid-sequence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:13:11 +02:00
claudeandClaude Opus 5.5 3d1b5da72b Fix the smoke test's intermittent failure: a bot taking the wrong portal
The long-standing smoke flake -- failing now and then, always first in a
pre-push hook whose logs were discarded -- finally failed with its logs
kept. bot1, whose account picks the Proving Grounds, had gone into the
Choir Vault, so "the proving grounds opened" never matched.

The bot wandered a full-speed Lissajous for 90 of every 120 ticks in the
hub, then walked toward its entrance for 30 holding interact the entire
way. The three entrances sit in a row, so a wander that happened to end
near the middle one sent the bot bound for the right-hand portal across
the Choir Vault's ring with the key down, and in it went. Where the
wander stopped depended on network timing, which is why it was
intermittent and why it never reproduced on demand.

The bot now walks straight to its own entrance and presses interact only
while standing on that one. Six consecutive smoke runs pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:12:43 +02:00
claudeandClaude Opus 5.5 99f0d2a783 Nothing is awake and watching when the party arrives
Room 0 is left empty so that arriving is not an ambush, but that only
ever covered room 0. An enemy in a neighbouring room within aggro range
and in sight of the entrance woke the moment you arrived -- in one run
in eight before the tunnels were widened, and in three runs in eight
after, because wider tunnels mean more open ground and more lines of
sight. Measured over 200 runs: 25 before, 75 after. A screenshot of an
arrival with enemies visibly in range is what prompted counting.

Room enemies that would be awake on arrival -- within their aggro range
of the entrance with nothing in the way, the same two questions aggro
asks -- are now skipped. That is about one enemy in sixteen. Clearing
everything within aggro range regardless of sight would have removed
nearly a third, which is a different dungeon rather than a fixed one.
The skipped enemy's dice have already been rolled, so every other enemy
stands exactly where that seed always put it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:03:22 +02:00
claudeandClaude Opus 5.5 19d2295bec A UI pass: prompts that tell the truth, and a HUD that does not collide
ci / verify (push) Successful in 53s
Prompts named the default keys whatever was bound. Every action is
rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4
use", the quartermaster said "E talk" and the upgrade screen "Close (E)".
All of them now go through Settings.short_label(), and a test rebinds
fire, escape, interact and a slot key and checks each prompt follows.

The HUD's top-left was a developer's status line -- name, instance id,
health as text, frame rate -- with the boss bar drawn over its right end
and the level-up notice sharing a row with the escape bar. It is now
name and level, where you are, health with its numbers, experience,
escape and level-ups, stacked by a running cursor so nothing has a fixed
row to collide on. Instance and fps moved behind F1, which is who they
were for. Arrival protection is a centred banner rather than a note
beside the health bar, because it is the one moment the rules change.

A hit used to tint the whole screen red, which in a bullet hell dims the
bullets at exactly the moment you are among them. It is an edge now, and
the same edge pulses below a third of your health. New: a message feed
above the inventory for what the server just confirmed -- items taken,
used and dropped, levels gained -- and party health top-right in a
dungeon, where the hub shows who is online. The pickup prompt now also
names the dungeon under your feet when you stand on its ring, in the
same order the server resolves that key: loot first, then the entrance.

The boss bar used to announce the boss to anyone within the actor
interest radius -- which passes through walls -- so a player in the
entrance room was told the Warden was near. It waits until the fog has
actually lit the boss once, then stays for the fight.

Permadeath was announced only by the roster screen appearing. The client
records its own death from the server's event and the roster says who
fell, at what level, and where. The connect screen lost a name field
that did nothing for a person -- it only named the characters bots
create -- gained a proper panel, remembers the last server, and shows
the protocol number a mismatch refusal will quote.

And screenshot.tscn ran as the player's real account against their real
character store, walking their character into dungeons for pictures --
with health now carried between runs, a stray hit would have stuck. It
has its own store and account now, like every diagnostic, and a seeded
character. The real store was checked: nothing it carries changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:41:04 +02:00
claudeandClaude Opus 5.5 069abdb3fa Now and then, a group of enemies in a tunnel
Tunnels are wide enough to fight in now, so occasionally one is occupied:
each tunnel leg rolls a 10% chance of a group of two or three of one kind,
standing within two tiles of each other. Most legs are too short or too
near the entrance to qualify, so in practice it is about one depth-1 run
in three (60 of 200 measured), rising with depth as tunnels get longer.

Rare on purpose -- tunnels are where you go between fights, and a tunnel
that is usually empty is what makes the one that is not a surprise.

Never within aggro range of the entrance, checked per member rather than
per group: members stand up to two tiles off the group's centre, so a
centre that clears the entrance does not mean everyone does. The first
version checked the centre, and the nearest member landed 15 units
outside aggro range by luck. Never a lone straggler: a leg that cannot
fit two gets nobody. Placed after every room, so each room's population
is exactly what its seed always produced, and every leg rolls the same
dice whether or not it is used, so one tunnel's luck never depends on
another's shape. Scaled by the dungeon like every other spawn, each
member from its own fresh definition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:25 +02:00
30 changed files with 2247 additions and 313 deletions

No files matched your search

+24 -4
View File
@@ -13,8 +13,8 @@ the obvious default.
```bash ```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # 534 GUT tests, headless (~4s) tools/test.sh # 576 GUT tests, headless (~4s)
tools/smoke.sh # real server + 4 bot clients over ENet (~40s) tools/smoke.sh # real server + 5 bot clients over ENet (~40s)
tools/server.sh # dedicated server tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu tools/client.sh --listen # host and play, no menu
``` ```
@@ -29,7 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # menus, HUD, roster, and the fog in a dungeon and a tunnel (needs a display) SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # every screen, the fog, a staged boss fight (needs a display; own store and account)
``` ```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke `diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
@@ -37,6 +37,11 @@ test cannot cover any of them: bots are poor shots, so they rarely kill
anything, which means they neither earn levels, produce drops, nor ever reach anything, which means they neither earn levels, produce drops, nor ever reach
the quartermaster. the quartermaster.
**Every key the game names to the player goes through `Settings.short_label()`.**
All of them are rebindable, and a prompt that names the default is wrong for
anyone who changed it — the HUD said "LMB fire" and the upgrade screen said
"Close (E)" whatever those were bound to.
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh` **Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
have all passed with the entire interface rendering unstyled, an invisible have all passed with the entire interface rendering unstyled, an invisible
slider and an invisible scrollbar. After touching anything under `src/ui/` or slider and an invisible scrollbar. After touching anything under `src/ui/` or
@@ -51,7 +56,8 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--join` | Skip the menu and connect. | | `--join` | Skip the menu and connect. |
| `--listen` | Skip the menu and host a listen server with a local player. | | `--listen` | Skip the menu and host a listen server with a local player. |
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. | | `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
| `--host`, `--port`, `--name` | Connection details. | | `--host`, `--port` | Connection details. |
| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. |
| `--autoquit N` | Quit after N physics ticks. | | `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. | | `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
| `--depth N` | Server-side: depth of new dungeons, which drives map size. | | `--depth N` | Server-side: depth of new dungeons, which drives map size. |
@@ -203,6 +209,14 @@ ticks in milliseconds with no SceneTree.
from `_place`.** `SimWorld` knows nothing about characters and builds a blank from `_place`.** `SimWorld` knows nothing about characters and builds a blank
player on every instance change, so any new path that puts someone in a world player on every instance change, so any new path that puts someone in a world
must go through `_place` or it hands them a level-1 body with an empty bag. must go through `_place` or it hands them a level-1 body with an empty bag.
Current health comes from the record too (`Character.hp`), and is banked by
`_bank_health` wherever a player leaves a world — miss one and that door
becomes a free heal.
- **One account, one session.** `on_hello` refuses a peer whose account is
already playing; two live players on one character duplicate items.
- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and
`GameOpts.account_override` first.** The default is the player's real store and
account; `screenshot.tscn` once played their character into dungeons.
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()` - **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
recomputes them from the character's upgrade ids every time, so a saved stat recomputes them from the character's upgrade ids every time, so a saved stat
cannot disagree with the upgrades that produced it. Upgrade *riders* (split cannot disagree with the upgrades that produced it. Upgrade *riders* (split
@@ -248,6 +262,12 @@ ticks in milliseconds with no SceneTree.
`Visibility` over them, then whatever must stay visible. That is what lets a `Visibility` over them, then whatever must stay visible. That is what lets a
tile or an enemy be half lit. Anything new that fog may hide goes *before* tile or an enemy be half lit. Anything new that fog may hide goes *before*
`_draw_darkness`; anything it must not goes after. `_draw_darkness`; anything it must not goes after.
- **No enemy bullet may land without having been drawn first.** Bullets fade
beyond the edge of sight, but `BulletRenderer.visibility()` stretches each
enemy bullet's draw distance with its speed so it is always fully on screen
`WARNING_TIME` before it could reach you. A faster bullet in new content is
covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide
bullets by shadow alone — you can walk round a corner into one.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs` - **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
is drawn after the darkness on purpose; everything else in the view is under it. A is drawn after the darkness on purpose; everything else in the view is under it. A
warning you cannot see is an unavoidable hit with extra steps. warning you cannot see is an unavoidable hit with extra steps.
+6 -5
View File
@@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash ```bash
tools/server.sh # dedicated server on :27015 tools/server.sh # dedicated server on :27015
tools/client.sh --join --name ada # connect a client tools/client.sh --join # connect a client; you name a character on the roster screen
``` ```
Or run one client and press **Host and play** for a listen server. Or run one client and press **Host and play** for a listen server.
@@ -25,8 +25,9 @@ hitbox overlay. Every one of those is rebindable in Settings.
| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. | | **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. |
| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. | | **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. |
| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. | | **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. | | **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. |
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. | | **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. |
| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. | | **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. | | **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. | | **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
@@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings.
```bash ```bash
tools/check.sh # parse-check every script ~5s tools/check.sh # parse-check every script ~5s
tools/test.sh # 468 GUT tests, headless ~4s tools/test.sh # 576 GUT tests, headless ~4s
tools/smoke.sh # server + 4 bot clients over ENet ~40s tools/smoke.sh # server + 5 bot clients over ENet ~40s
``` ```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
+20 -2
View File
@@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role | | File | Role |
| --- | --- | | --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. | | `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. | | `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. | | `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. | | `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
@@ -172,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. | | `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. |
| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. | | `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. | | `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. | | `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network ### Network
@@ -190,13 +193,28 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| --- | --- | | --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. | | `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. | | `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. | | `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. | | `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. | | `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. | | `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. | | `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. | | `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. | | `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `src/ui/character_select.gd` | Roster screen: pick or create. | | `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. | | `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. | | `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |
+107 -2
View File
@@ -108,8 +108,21 @@ stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps Two lines this deliberately does not cross: light stops the moment a ray steps
back out of the wall it was in, so it never rounds a corner or reaches the far back out of the wall it was in, so it never rounds a corner or reaches the far
side of a one-tile wall; and two blocking tiles that touch only at a corner are side of a one-tile wall; and two blocking tiles that touch only at a corner are
closed, not a crack. Bullets are drawn over the darkness, as they always were — closed, not a crack. Bullets are drawn over the darkness — a shot out of the
a shot out of the black is still one you can dodge. black is still one you can dodge.
**Bullets fade out past the edge of sight, but never sooner than is safe.**
*(User asked to stop drawing bullets beyond sight, with the condition that
nobody is ever hit by a bullet they could not see.)* Every enemy bullet is
drawn at full strength out to the edge of sight or to however far it could close
in `BulletRenderer.WARNING_TIME` (0.75s) with you running straight at it,
whichever is further, then fades over 80 units. Today the edge of sight is always
further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen
0.83s before it lands. `test_bullet_draw.gd` checks that for every emitter in the
content, and for bullets far faster. Player shots fade at the edge of sight
regardless, since they cannot hurt you. Bullets in shadow *within* sight range
are still drawn: you can step round a corner into one, and hiding it would be
exactly the invisible hit the condition rules out.
**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev **Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
flag; what raises depth in actual play is still open. flag; what raises depth in actual play is still open.
@@ -530,3 +543,95 @@ a different fight in a different room.
**A CHASE boss has a dead band around its preferred distance.** The correction **A CHASE boss has a dead band around its preferred distance.** The correction
is signed, so without one the sign flips every tick at the standoff and the boss is signed, so without one the sign flips every tick at the standoff and the boss
vibrates on the spot at the tick rate — a couple of pixels, and unmistakable. vibrates on the spot at the tick rate — a couple of pixels, and unmistakable.
---
## The anti-cheat audit, and the rules it left behind
*(User asked for an audit for design flaws and cheats, then "fix these".)* Each
of these was found by reading the server's code paths and then proving the
problem with a probe before fixing it.
**One account, one session — and the newcomer is refused, not the incumbent.**
Two peers on one account each got a player built from the same character record,
so both held a copy of one bag and whichever wrote last won: drop the one potion
from both copies and two potions hit the floor. Refusing the second connection is
the only fix that closes it. The *new* peer is the one turned away because, with
an auth provider anyone can spoof, "kick whoever holds this account" would let
anyone disconnect anyone. A dropped player's linkdead body still holds the
account until its escape channel resolves, which is deliberate: reconnecting
during that second would put the character in the world twice.
**A rejection must reach the client.** `Net.reject_and_kick` defers the kick,
because an RPC only leaves on the frame's flush and kicking in the same call threw
the reason away — a protocol mismatch used to arrive as "server closed the
connection".
**Bullets are never born on the far side of geometry.** A muzzle offset is a
teleport the pool never sees. The player's shot falls back to the shooter's
centre when blocked (six Doubleshots used to put shots through a one-tile wall);
an enemy's shot is dropped (boss curtains were spawning through the arenas' own
pillars and barricades). A telegraphed burst is checked from its marker, not from
the boss.
**Bullet spawns are culled per bullet, not by a flat radius.** Each spawn event
carries the shooter's exact position, and the flat 2900-unit radius exceeded the
whole depth-1 map, so every firing enemy was located for every player.
`ServerRuntime._bullet_concerns` asks whether *this* bullet can reach ground the
player can see.
**No packet buys unbounded work.** Selecting a character you already hold is a
no-op; select and create are paced per peer (`CONTROL_COOLDOWN_TICKS`), and the
pacing logs once per burst, not once per packet. Item moves and experience grants
mark the store dirty and it is written once per tick at most.
**The hub has no escape channel.** Completing it there meant transferring into
the instance you were already in — teleport, full heal, map re-streamed.
**A player's drop is refused when the floor is full.** The cap evicts the oldest
item, which is right for a dungeon's own drops and was wrong for the hub, where it
let a player destroy whatever someone else had left there longest.
## Health, experience and a debug dungeon
**The Proving Grounds is a debugging tool and does not ship.** *(User.)* It pays
full experience for a boss with 8% of its health. Remove it from `Dungeons.ORDER`
and its `P` marker before a release — see the roadmap.
## Tunnels and what is in them
**Tunnels are 2 to 4 tiles wide, rolled per leg.** *(User: "a varied width with a
minimum of 2".)* A one-tile corridor in a bullet hell is a shooting gallery. The
width is per leg of each L, not varying along a leg; that was the simpler reading
and the user has not asked for the other. The arena is excluded from every carve —
an L that bent the wrong way used to slit 45% of arenas' west walls open.
**Now and then a tunnel has a group in it.** *(User: "rarely".)* A 10% chance per
leg of two or three of one kind; about one depth-1 run in three gets one. Never
within aggro range of the entrance, checked per member.
**Nothing can be watching the entrance when you arrive.** Room 0 was always left
empty for this, but only room 0 — and wider tunnels tripled how often a
neighbouring room could see the entrance (25 to 75 runs in 200). Enemies that
would be awake on arrival are skipped: about one in sixteen. Clearing everything
within aggro range would have removed nearly a third, which is a balance change
the user has not asked for.
## Interface
**Every key a prompt names is the one actually bound.** All actions are
rebindable; prompts go through `Settings.short_label()`.
**The boss bar waits until the boss has been seen.** The server sends the boss
through walls from 800 units, and the bar used to announce it to a player still in
the entrance room. It appears once the fog has lit the boss and stays for the
fight. A judgement call, flagged to the user as reversible.
**The controls hint fades six seconds into a dungeon** and stays in the hub. Also
a judgement call; one constant (`Hud.HINT_HOLD`).
**The connect screen asks for no name.** The field it had only named the
characters bots create; people name characters on the roster screen.
**Getting hit reddens the screen's edge, not the whole screen** — a full-screen
tint dimmed the bullets at the moment you were among them.
+48 -11
View File
@@ -165,7 +165,7 @@ Two consequences fall out of partial knowledge:
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
lives in what the server declines to send: terrain outside the stream radius, lives in what the server declines to send: terrain outside the stream radius,
and (next stage) actors outside it. and actors outside the interest radius (below).
## Actors are scoped per peer ## Actors are scoped per peer
@@ -188,13 +188,24 @@ Two rules fall out of it:
about a bullet must always be told it died, or it keeps a phantom until the about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires. lifetime expires.
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
actors, because the failure modes are not symmetric. An enemy appearing at the at its speed and acceleration over its remaining life, could reach ground the
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 + symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that withheld at spawn that later flies into view is invisible damage — so the rule
from the live content, so adding a faster or longer-lived bullet fails a test errs long.
rather than producing bullets that wink into existence.
It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the
longest-travelling shot in the game. That was a leak: every spawn event carries
the shooter's exact position, and at 2900 units the radius exceeded the entire
depth-1 map, so every firing enemy anywhere on the floor was located for every
player — past the actor cull, past the map stream, through the fog. The constant
survives only as a hard ceiling, and `test_interest.gd` checks both: that no real
enemy's bullet is announced across a dungeon, and that the ceiling still covers
the longest shot in the game.
The same rule decides what is sent to a player arriving mid-fight
(`_live_bullet_events`), which is the moment they have seen least of the map.
## Loot has two visibilities, and one of them is an interest rule ## Loot has two visibilities, and one of them is an interest rule
@@ -318,9 +329,11 @@ transition, not a combat mechanic.
| Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position | | Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position |
| Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses | | Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses |
The respawn lockout is worth calling out: the HUD disables its button for the The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
same three seconds, but that is presentation. A client that ignores its own UI but note what reaches it. A connected player is unbound on the tick it dies and
and holds the bit down still waits, because the check lives in `SimWorld`. sent to the roster screen (a settled decision; see DECISIONS.md), so it never
waits at all. The lockout governs the *linkdead* body, which has nobody to show a
roster to and asks for itself.
Beyond that, the wire format itself constrains the cheat surface: the move Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()` vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
@@ -411,6 +424,30 @@ the snapshot stream:
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected `Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later. with a reason, rather than desyncing later.
## What the handshake and the server refuse
- **One account, one session.** A second connection claiming an account that is
already playing is refused — the newcomer, not the incumbent, or a spoofed
claim would kick anyone off. Two peers on one account each held a copy of one
character's bag, which duplicated items. A linkdead body still holds its
account until its escape channel resolves.
- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks
after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in
the same call discarded it, and the client's disconnect handler overwrote it
anyway. Both are fixed; smoke asserts a refused client reads its reason.
- **No small packet buys large work.** Character select and create rewrite the
whole store, re-place the player and broadcast a roster, so they are paced per
peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character
you hold is a no-op. Item moves and experience grants — the two persistence
paths a client can trigger every tick — mark the store dirty, and it is written
once per tick at most (`CharacterStore.save_deferred` / `flush`).
- **The two hot decoders check their bounds.** A short or hostile snapshot
decodes as `valid: false` and is dropped; the snapshot channel is unreliable
with ENet's checksum off, so this is a network event, not only an attack. Event
lengths are declared per type (`NetCodec.event_payload_size`), and an unknown
type stops the stream rather than unaligning everything after it. Both size
tables are pinned to their encoders by tests.
## Instances ## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An One server process hosts the lobby hub plus every concurrent dungeon. An
+63 -16
View File
@@ -23,6 +23,23 @@ a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
the four stages that followed: characters and permadeath, inventory and loot, the four stages that followed: characters and permadeath, inventory and loot,
upgrades, and a second boss that moves. upgrades, and a second boss that moves.
Since then, in one long session:
- **An anti-cheat audit, and its fixes.** Item duplication through two sessions
on one account; shots through walls from muzzle offsets (the player's and every
boss's); every firing enemy located by the flat bullet-interest radius;
unbounded store writes from small packets; the hub's escape channel; hub loot
griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under
"The anti-cheat audit".
- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes
only to players within `XP_AWARD_RADIUS` of the kill.
- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)),
and **bullets fade past the edge of sight** without ever arriving unseen.
- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the
entrance on arrival, and arenas that keep their walls.
- **Two interface passes** — see the Interface section of DECISIONS.md.
- **The smoke flake is fixed** (below).
So there is no obvious "next ticket". What follows is the honest list of So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.** candidates, none of which the user has chosen — **ask before starting one.**
@@ -35,19 +52,33 @@ candidates, none of which the user has chosen — **ask before starting one.**
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). | | **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. | | **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
And smaller things raised with the user and not yet answered or chosen:
| Open item | What is known |
| --- | --- |
| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. |
| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. |
| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. |
| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. |
| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. |
| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. |
| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). |
Each stage below also carries its own **Known gaps** section — those are smaller Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above. and more specific than the table above.
### Known flake ### The smoke flake (diagnosed and fixed)
`tools/smoke.sh` has failed twice, **both times inside the pre-push hook**, and `tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose
then passed on every direct run after: four in a row the first time, eight the logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
second (six in a background loop plus two more, one of them the hook itself). whose account picks the Proving Grounds, had gone into the Choir Vault. The
Neither failure's logs survived, because the hook runs smoke without `KEEP`. bot wandered for 90 of every 120 ticks in the hub and then walked to its
The first suspicion was machine load, but the second failure had nothing else entrance holding interact the whole way; the three entrances sit in a row, so a
running. What is different about the hook is its context — stdin is git's wander that ended near the middle one carried it across the wrong ring with the
pipe of ref lines, which every bot inherits. Unconfirmed. Push with key down. Which dungeons opened depended on where a timing-dependent wander
`KEEP=1 git push` so the logs of the next one survive. 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.
--- ---
@@ -58,8 +89,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
| Gate | Covers | Runtime | | Gate | Covers | Runtime |
| --- | --- | --- | | --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s | | `tools/check.sh` | every script parses and type-checks | ~5s |
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s | | `tools/test.sh` | 576 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 | | `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~40s |
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `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_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_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
@@ -67,10 +98,15 @@ What "everything passes" currently means. Numbers move; the shape does not.
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display, `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 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, actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
the HUD, the pause menu, settings, credits and the upgrade choice — the last of the connect screen, the hub standing on a portal, the pause menu, settings,
those by staging an offer, because earning one would make the shot depend on a credits, the upgrade choice, the roster after a death, a dungeon arrival, a
dungeon run. tunnel (with and without F1), a staged boss fight, and the escape banner. It runs
on **its own character store and account** — it used to host on the player's
real ones — and stages what it cannot earn (an offer, a party, a death).
Timings are frame counts and frames run as fast as the display allows, so
anything tied to server ticks is captured by condition instead (see the escape
shot).
It has now caught four bugs that every other gate passed: the theme applying to 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 nothing, the test suite rewriting the player's settings, an invisible slider
@@ -92,6 +128,16 @@ against itself.
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.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 | | Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` | | 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()` |
| Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` |
| Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` |
| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land |
| Markers pointing to party members out of sight | done | `Hud._draw_party_markers` |
| Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) |
| 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) | | 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` | | 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` | | Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
@@ -114,7 +160,8 @@ terrain it does not have.
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. | | `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. | | `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. | | `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. | | `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. |
| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. |
Fog is a *rendering* rule and defends nothing on its own — a modified client 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. draws whatever it holds. The defence is what the server declines to send.
+34
View File
@@ -224,6 +224,40 @@ character decoder, which then read a field its encoder never wrote. Both are
codecs and both had the same trailing lines. Check the match count when patching codecs and both had the same trailing lines. Check the match count when patching
by pattern, not by line. by pattern, not by line.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## CI ## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with `.github/workflows/ci.yml` runs check → test → smoke on a container image with
+34
View File
@@ -45,6 +45,11 @@ const SFX_BUS := "SFX"
static var master_volume: float = 0.8 static var master_volume: float = 0.8
static var sfx_volume: float = 0.8 static var sfx_volume: float = 0.8
## The server last joined, so the connect screen does not ask for the same
## address every launch. Empty until the first join. Client-local like
## everything else here -- it is where you play, not who you are.
static var last_address: String = ""
static var last_port: int = 0
## action -> a serialisable description of its event. See _describe(). ## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {} static var bindings: Dictionary[String, Dictionary] = {}
@@ -64,6 +69,8 @@ static func load_and_apply() -> void:
if cfg.load(path) == OK: if cfg.load(path) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0) master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0) sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
last_address = String(cfg.get_value("connection", "address", ""))
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
bindings.clear() bindings.clear()
for action in REBINDABLE: for action in REBINDABLE:
var key := String(action[0]) var key := String(action[0])
@@ -86,6 +93,9 @@ static func save() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume) cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume) cfg.set_value("audio", "sfx", sfx_volume)
if not last_address.is_empty():
cfg.set_value("connection", "address", last_address)
cfg.set_value("connection", "port", last_port)
for action in bindings: for action in bindings:
cfg.set_value("input", action, bindings[action]) cfg.set_value("input", action, bindings[action])
var err := cfg.save(path) var err := cfg.save(path)
@@ -93,6 +103,16 @@ static func save() -> void:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err]) GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
## Remember where the player just tried to connect.
static func remember_server(address: String, port: int) -> void:
var clean := address.strip_edges()
if clean.is_empty() or port <= 0 or port > 65535:
return
last_address = clean
last_port = port
save()
# --- Audio ------------------------------------------------------------------- # --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer. ## Ensure the SFX bus exists and push both volumes onto the mixer.
@@ -191,6 +211,20 @@ static func binding_label(action: String) -> String:
return describe_label(current_binding(action)) return describe_label(current_binding(action))
## The binding as short as it can be said, for prompts on screen: "LMB" rather
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
## player goes through this, because every one of them is rebindable and a
## prompt that names the default is wrong for anyone who changed it.
static func short_label(action: String) -> String:
var label := binding_label(action)
match label:
"Mouse Left": return "LMB"
"Mouse Right": return "RMB"
"Mouse Middle": return "MMB"
"Escape": return "Esc"
return label
static func describe_label(described: Dictionary) -> String: static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")): match String(described.get("type", "")):
"key": "key":
+119
View File
@@ -31,10 +31,26 @@ var stage_delay: int = 0
var boss_id: StringName = &"" var boss_id: StringName = &""
## Generated layout, used to place enemies room by room. ## Generated layout, used to place enemies room by room.
var rooms: Array[Rect2i] = [] var rooms: Array[Rect2i] = []
## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the
## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel.
var tunnels: Array[Rect2i] = []
var boss_spawn := Vector2.ZERO var boss_spawn := Vector2.ZERO
var boss_room := Rect2i() var boss_room := Rect2i()
## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg,
## and most legs are too short or too near the entrance to qualify anyway, so a
## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose:
## tunnels are where you go between fights, and a tunnel that is usually empty
## is what makes the one that is not a surprise.
const TUNNEL_GROUP_CHANCE := 0.1
const TUNNEL_GROUP_MIN := 2
const TUNNEL_GROUP_MAX := 3
## Members stand within this many tiles of the group's centre, so a group reads
## as one encounter rather than a tunnel's worth of strays.
const TUNNEL_GROUP_SPREAD := 2
static func make_lobby(instance_id: int) -> Instance: static func make_lobby(instance_id: int) -> Instance:
var inst := Instance.new() var inst := Instance.new()
inst.id = instance_id inst.id = instance_id
@@ -78,6 +94,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
inst.depth, flavour.arena) inst.depth, flavour.arena)
inst.world.set_map(built["grid"]) inst.world.set_map(built["grid"])
inst.rooms = built["rooms"] inst.rooms = built["rooms"]
inst.tunnels = built["tunnels"]
inst.boss_spawn = built["boss_pos"] inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"] inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"] inst.world.spawn_point = built["spawn"]
@@ -118,7 +135,109 @@ func _populate() -> void:
var at := world.map.tile_centre(tx, ty) var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius): if world.map.circle_blocked(at, def.radius):
continue continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120)) 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.
_populate_tunnels(rng, flavour)
## Now and then, a group of enemies in a tunnel.
##
## Never within aggro range of the entrance, by the same reasoning room 0 is
## left empty: arriving into a fight you cannot see yet is not a decision, it
## is an ambush. And never a lone straggler -- if a leg cannot fit at least
## TUNNEL_GROUP_MIN, it gets nobody.
func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0
for leg in tunnels:
# Every leg rolls the same three numbers whether or not it is used, so
# one tunnel's dice never depend on another tunnel's shape.
var roll := rng.randf()
var pick := rng.randi()
var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX)
if roll >= TUNNEL_GROUP_CHANCE:
continue
var open := _tunnel_tiles(leg)
if open.size() < TUNNEL_GROUP_MAX * 2:
continue
var centre: Vector2i = open[pick % open.size()]
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var spots: Array[Vector2] = []
for t in open:
if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \
or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD:
continue
var at := world.map.tile_centre(t.x, t.y)
# Per member, not per group: members stand up to two tiles off the
# centre, so a centre that clears the entrance does not mean every
# member does.
if at.distance_to(world.spawn_point) < clear_of_spawn:
continue
if not world.map.circle_blocked(at, def.radius):
spots.append(at)
if spots.size() < TUNNEL_GROUP_MIN:
continue
for i in range(spots.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var held := spots[i]
spots[i] = spots[j]
spots[j] = held
for i in mini(size, spots.size()):
# A fresh definition per member: apply_to_enemy mutates in place,
# and sharing one object would scale it once per member.
var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id))
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.
func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]:
var out: Array[Vector2i] = []
for ty in range(leg.position.y, leg.end.y):
for tx in range(leg.position.x, leg.end.x):
if world.map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
if boss_room.has_point(here):
continue
var in_room := false
for r in rooms:
if r.grow(1).has_point(here):
in_room = true
break
if not in_room:
out.append(here)
return out
## The furthest any enemy in the game notices a player from.
static func _widest_aggro() -> float:
var widest := 0.0
for id in Content.ALL_ENEMIES:
widest = maxf(widest, Content.enemy(id).aggro_range)
return widest
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef: func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
+1
View File
@@ -70,6 +70,7 @@ func _show_menu() -> void:
_menu.join_requested.connect(_join) _menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play) _menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings) _menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu) add_child(_menu)
# Unconditional, and the reason this is not an early-return when the 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 # already exists: a failed connection returns here with the buttons still
+47 -6
View File
@@ -80,6 +80,12 @@ var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed. ## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = [] var roster: Array[Dictionary] = []
## The character this player just lost, as { "name", "level", "where" }, or
## empty. Death is permanent and the server unbinds you on the same tick, so
## without this the only sign anything happened was that the roster screen
## appeared. Recorded from the server's own PLAYER_DIED event, never guessed.
var last_fallen: Dictionary = {}
## This account's characters, and which one is being played. Server-pushed; ## This account's characters, and which one is being played. Server-pushed;
## the client never invents an entry. ## the client never invents an entry.
var characters: Array[Dictionary] = [] var characters: Array[Dictionary] = []
@@ -268,12 +274,25 @@ func _bot_input() -> InputFrame:
# Downed bots ask for the hub, same as a player would -- otherwise a bot # 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. # that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT) return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30: if instance_kind == Protocol.InstanceKind.LOBBY:
buttons |= InputFrame.BTN_INTERACT # Straight to its own entrance, picked from the account id so a smoke
# Walk onto a portal instead of orbiting, or interact never lands. # run with several bots exercises every dungeon -- and interact pressed
# Which one is picked from the account id, so a smoke run with several # ONLY while standing on that one.
# bots exercises every dungeon rather than only the first. #
move = (bot_portal() - predicted_pos).normalized() # 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 var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON: if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item # Grab at whatever is underfoot and occasionally drink, so the item
@@ -546,6 +565,14 @@ func on_events(data: PackedByteArray) -> void:
SimEvent.Type.PLAYER_DIED: SimEvent.Type.PLAYER_DIED:
if int(ev["peer"]) == my_peer: if int(ev["peer"]) == my_peer:
my_alive = false my_alive = false
var who := current_character()
if not who.is_empty():
last_fallen = {
"name": String(who["name"]),
"level": Progression.level_for_xp(my_total_xp),
"where": Dungeons.get_or_default(dungeon_id).display_name
if instance_kind == Protocol.InstanceKind.DUNGEON else "",
}
hud_dirty.emit() hud_dirty.emit()
SimEvent.Type.PLAYER_RESPAWNED: SimEvent.Type.PLAYER_RESPAWNED:
if int(ev["peer"]) == my_peer: if int(ev["peer"]) == my_peer:
@@ -620,6 +647,20 @@ func loot_in_reach() -> Dictionary:
return best return best
## The entrance this player is standing on, as { "pos", "dungeon" }, or empty.
## Purely for the prompt, like loot_in_reach: nearest wins, the same tie-break
## SimWorld.portal_at() uses, and the server decides for itself regardless.
func portal_here() -> Dictionary:
var best := {}
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
for p: Dictionary in portals:
var d: float = predicted_pos.distance_squared_to(p["pos"])
if d <= best_d:
best_d = d
best = p
return best
## True when every slot is taken, so the HUD can explain why a pickup did ## True when every slot is taken, so the HUD can explain why a pickup did
## nothing rather than looking broken. ## nothing rather than looking broken.
func inventory_full() -> bool: func inventory_full() -> bool:
+12 -6
View File
@@ -151,10 +151,11 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# Connect each room to the previous one, then the last to the arena door, # Connect each room to the previous one, then the last to the arena door,
# so every room is reachable by construction rather than by luck. # so every room is reachable by construction rather than by luck.
var widths := PackedInt32Array() var widths := PackedInt32Array()
var legs: Array[Rect2i] = []
for i in range(1, rooms.size()): for i in range(1, rooms.size()):
var from := _centre_of(rooms[i - 1]) var from := _centre_of(rooms[i - 1])
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]), widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
(from.x + from.y) % 2 == 0, boss_room)) (from.x + from.y) % 2 == 0, boss_room, legs))
if not rooms.is_empty(): if not rooms.is_empty():
# Aimed at the tile OUTSIDE the door, and always arriving along the # Aimed at the tile OUTSIDE the door, and always arriving along the
# door's own row. Aiming at the door itself, an L that bent the other # door's own row. Aiming at the door itself, an L that bent the other
@@ -164,7 +165,7 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# rather than eating into it. # rather than eating into it.
var outside := door + Vector2i(-1, 0) var outside := door + Vector2i(-1, 0)
widths.append_array(_carve_corridor(grid, rng, widths.append_array(_carve_corridor(grid, rng,
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room)) _centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs))
# The door tile itself, and the tile outside it, must be floor. # The door tile itself, and the tile outside it, must be floor.
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR) grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR) grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
@@ -193,6 +194,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# builds a world -- it is here so a test can check the widths actually # builds a world -- it is here so a test can check the widths actually
# vary, which the tiles alone cannot say once tunnels overlap. # vary, which the tiles alone cannot say once tunnels overlap.
"corridor_widths": widths, "corridor_widths": widths,
# The rectangle each tunnel leg was carved over. Overlaps the rooms it
# joins at its ends, so whoever uses it decides what counts as tunnel --
# Instance._populate_tunnels keeps only floor clear of every room.
"tunnels": legs,
# Which boss lives here follows from WHICH arena was stamped. Choosing # Which boss lives here follows from WHICH arena was stamped. Choosing
# it anywhere else would let a dungeon put the Cantor in the Warden's # it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there. # hall, where its patterns assume geometry that is not there.
@@ -214,7 +219,7 @@ static func _centre_of(r: Rect2i) -> Vector2i:
## walls it was drawn with. ## walls it was drawn with.
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator, static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
from: Vector2i, to: Vector2i, horizontal_first: bool, from: Vector2i, to: Vector2i, horizontal_first: bool,
keep_out: Rect2i) -> PackedInt32Array: keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array:
var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH) var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y) var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y)
@@ -223,8 +228,8 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
# the INNER side of the bend, so leaving this out never narrows a tunnel # the INNER side of the bend, so leaving this out never narrows a tunnel
# below its width -- it just bevels the outer corner, which reads as a # below its width -- it just bevels the outer corner, which reads as a
# notch rather than a turn. # notch rather than a turn.
_carve_leg(grid, from, bend, first, second / 2, keep_out) legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out))
_carve_leg(grid, bend, to, second, first / 2, keep_out) legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out))
return PackedInt32Array([first, second]) return PackedInt32Array([first, second])
@@ -235,7 +240,7 @@ static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
## world is sealed by construction, so a wide tunnel that hugs the edge must ## world is sealed by construction, so a wide tunnel that hugs the edge must
## stop one tile short of it rather than open the map to nothing. ## stop one tile short of it rather than open the map to nothing.
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int, static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
overshoot: int, keep_out: Rect2i) -> void: overshoot: int, keep_out: Rect2i) -> Rect2i:
# Width is laid across the leg, centred on its line: two tiles is the line # Width is laid across the leg, centred on its line: two tiles is the line
# and the one after it, three is one either side, four leans one way. # and the one after it, three is one either side, four leans one way.
var lo := -(width - 1) / 2 var lo := -(width - 1) / 2
@@ -254,6 +259,7 @@ static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
if keep_out.has_point(Vector2i(x, y)): if keep_out.has_point(Vector2i(x, y)):
continue continue
grid.set_tile(x, y, MapGrid.Kind.FLOOR) grid.set_tile(x, y, MapGrid.Kind.FLOOR)
return rect
## A few pillars and pits inside generated rooms, so open rooms still have ## A few pillars and pits inside generated rooms, so open rooms still have
+17
View File
@@ -14,6 +14,7 @@ var _list: VBoxContainer
var _name_field: LineEdit var _name_field: LineEdit
var _create_button: Button var _create_button: Button
var _status: Label var _status: Label
var _fallen: Label
var _title: Label var _title: Label
var _close_button: Button var _close_button: Button
var _known: Array[Dictionary] = [] var _known: Array[Dictionary] = []
@@ -51,6 +52,14 @@ func _ready() -> void:
_title.add_theme_font_size_override("font_size", 26) _title.add_theme_font_size_override("font_size", 26)
panel.add_child(_title) panel.add_child(_title)
_fallen = Label.new()
_fallen.add_theme_font_size_override("font_size", 14)
_fallen.add_theme_color_override("font_color", Color(0.95, 0.55, 0.55))
_fallen.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_fallen.custom_minimum_size = Vector2(460.0, 0.0)
_fallen.visible = false
panel.add_child(_fallen)
_list = VBoxContainer.new() _list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4) _list.add_theme_constant_override("separation", 4)
panel.add_child(_list) panel.add_child(_list)
@@ -166,5 +175,13 @@ func _row_for(c: Dictionary) -> Control:
## Whether the player may dismiss this screen. False when they have nothing to ## Whether the player may dismiss this screen. False when they have nothing to
## play, in which case there is nowhere to dismiss it to. ## play, in which case there is nowhere to dismiss it to.
## A line for the character that was just lost, above the list of the ones
## that are left. Separate from the status line, which is for refusals: a death
## is not an error, and the status line clears the moment anything else is said.
func set_fallen(text: String) -> void:
_fallen.text = text
_fallen.visible = not text.is_empty()
func set_dismissible(can_close: bool) -> void: func set_dismissible(can_close: bool) -> void:
_close_button.visible = can_close _close_button.visible = can_close
+678 -188
View File
@@ -1,6 +1,13 @@
extends CanvasLayer extends CanvasLayer
## Heads-up display. Built in code rather than as a scene because every element ## Heads-up display. Built in code rather than as a scene because every element
## is data-driven -- there is no layout here a designer would want to drag. ## is data-driven -- there is no layout here a designer would want to drag.
##
## Layout, because several things here used to land on top of each other:
## top-left is you (name, where, health, experience, escape, level-ups), stacked
## by a running cursor so nothing has a fixed row to collide on; top-centre is
## the boss; top-right is everyone else; the bottom centre is the inventory
## with the pickup prompt and the message feed stacked above it; the controls
## hint has the bottom-left corner to itself.
const MARGIN := 24.0 const MARGIN := 24.0
const BAR_W := 260.0 const BAR_W := 260.0
@@ -19,16 +26,87 @@ const SLOT_GAP := 8.0
const BAR_CAP := 6.0 const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits. ## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0 const SLOT_BOTTOM := 26.0
const BOSS_BAR_W := 480.0
## The message feed: how long a message stays, and how many at once.
const TOAST_LIFE := 2.6
const TOAST_FADE := 0.8
const TOAST_MAX := 4
## Health below this share of maximum pulses the screen edge.
const LOW_HEALTH := 0.35
## How long the controls hint stays up after arriving in a dungeon. In the hub
## it stays: that is where you are learning the keys, and nothing is shooting.
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)
const COL_GOLD := Color(1.0, 0.85, 0.4)
const COL_DANGER := Color(1.0, 0.25, 0.3)
signal respawn_pressed signal respawn_pressed
## What the world view could see this frame, handed over by the game scene so
## the boss bar can wait until the boss has actually been seen. See
## _draw_boss_bar.
var sight: Visibility = null
## Mirrors the F1 overlay. The instance number and frame rate are for whoever
## is debugging, not for whoever is playing, so they live behind it.
var debug_info: bool = false
var _canvas: Control var _canvas: Control
var _status: Label
var _hint: Label
var _respawn_button: Button var _respawn_button: Button
var _hit_flash: float = 0.0 var _hit_flash: float = 0.0
var client: ClientRuntime = null 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
## moment it happens and a character switch is not mistaken for one.
var _seen_level: int = -1
var _seen_character := ""
## When the current instance was entered, for fading the controls hint.
var _instance_seen: int = -1
var _instance_since: float = 0.0
func _ready() -> void: func _ready() -> void:
layer = 10 layer = 10
@@ -45,20 +123,6 @@ func _ready() -> void:
_canvas.draw.connect(_draw_hud) _canvas.draw.connect(_draw_hud)
add_child(_canvas) add_child(_canvas)
_status = _make_label(Vector2(MARGIN, MARGIN))
_hint = _make_label(Vector2(MARGIN, 0.0))
_hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
# `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's
# what stranded this label off-screen despite the anchor being correct).
# offset_left/offset_top are the anchor-relative ones -- negative offset_top
# here means "56px above the bottom anchor", which is what "-56" was meant
# to say in the first place.
_hint.offset_left = MARGIN
# Above the inventory row, not through it. Derived from the row's own
# geometry rather than written down, because the slots grew from 46px to 64
# and a hardcoded -56 put the control hints straight across them.
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
# A real Button rather than something drawn in _draw_hud: this is the one # A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so # HUD element the player has to actually click, and it starts disabled so
# death is not exited by whatever key happened to be under a finger. # death is not exited by whatever key happened to be under a finger.
@@ -74,24 +138,101 @@ func _ready() -> void:
_canvas.add_child(_respawn_button) _canvas.add_child(_respawn_button)
func _make_label(pos: Vector2) -> Label:
var l := Label.new()
l.position = pos
l.add_theme_font_size_override("font_size", 14)
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
_canvas.add_child(l)
return l
func _process(delta: float) -> void: func _process(delta: float) -> void:
client = Net.client client = Net.client
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0) _hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text() var now := _now()
_hint.text = _hint_text() _toasts = _toasts.filter(func(t: Dictionary) -> bool:
return now - float(t["born"]) < TOAST_LIFE)
if client != null:
if client.instance_id != _instance_seen:
_instance_seen = client.instance_id
_instance_since = now
_watch_level()
_watch_health(delta)
_watch_experience(delta)
_update_respawn_button() _update_respawn_button()
_canvas.queue_redraw() _canvas.queue_redraw()
func _now() -> float:
return float(Time.get_ticks_msec()) / 1000.0
## A short line in the message feed above the inventory. Called for things the
## server just confirmed -- an item taken, used or dropped, a level gained -- so
## the feed is a record of what happened, never of what the client hoped.
func notify(text: String, colour: Color = COL_TEXT) -> void:
_toasts.append({"text": text, "colour": colour, "born": _now()})
while _toasts.size() > TOAST_MAX:
_toasts.pop_front()
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.
func _watch_level() -> void:
var who := client.current_character()
if who.is_empty():
_seen_character = ""
return
var level := Progression.level_for_xp(client.my_total_xp)
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:
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
func _update_respawn_button() -> void: func _update_respawn_button() -> void:
var downed := client != null and not client.my_alive var downed := client != null and not client.my_alive
_respawn_button.visible = downed _respawn_button.visible = downed
@@ -105,91 +246,424 @@ func _update_respawn_button() -> void:
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait) else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
func _status_text() -> String: # --- Text -------------------------------------------------------------------
if client == null:
return "connecting..." ## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain
# Named rather than just "DUNGEON": there is more than one kind now, and ## light text over it was readable on the dark parts of a tile and not on the
# knowing which one you are standing in is the whole point of having two. ## light ones.
var where := "LOBBY" func _text(at: Vector2, s: String, size: int, colour: Color,
if client.instance_kind != Protocol.InstanceKind.LOBBY: align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void:
where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper() var font := ThemeDB.fallback_font
var who := client.current_character() _canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size,
var name_part := "" Color(0.0, 0.0, 0.0, 0.7 * colour.a))
if not who.is_empty(): _canvas.draw_string(font, at, s, align, width, size, colour)
name_part = "%s lv %d " % [
who["name"], Progression.level_for_xp(client.my_total_xp)]
return "%s%s instance %d hp %d/%d %d fps" % [
name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
Engine.get_frames_per_second()]
func _hint_text() -> String: func _text_width(s: String, size: int) -> float:
if client == null: return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
return ""
if not client.my_alive:
return "DOWN"
if client.instance_kind == Protocol.InstanceKind.LOBBY:
return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes"
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes"
func _centred(y: float, s: String, size: int, colour: Color) -> void:
_text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x)
# --- Key names --------------------------------------------------------------
## What the player has [param action] bound to. Read from Settings every time
## rather than written into the strings: every one of these is rebindable, and
## the hint used to say "LMB fire" whatever fire was actually on.
static func key(action: String) -> String:
return Settings.short_label(action)
## The four movement keys as one word when they are single characters -- WASD,
## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys.
static func move_keys() -> String:
var parts: Array[String] = []
for action in ["move_up", "move_left", "move_down", "move_right"]:
parts.append(key(action))
for p in parts:
if p.length() != 1:
return "/".join(parts)
return "".join(parts)
## The slot keys, as a range when they run 1-2-3-4 and listed when they do not.
static func slot_keys(count: int) -> String:
var parts: Array[String] = []
for i in count:
parts.append(key("use_slot_%d" % (i + 1)))
var plain := true
for i in count:
if parts[i] != str(i + 1):
plain = false
return "1-%d" % count if plain else " ".join(parts)
static func controls_hint(in_hub: bool) -> String:
if in_hub:
return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [
move_keys(), key("fire"), key("interact"), key("system_menu")]
return "%s move %s fire hold %s to return to the hub %s menu" % [
move_keys(), key("fire"), key("emergency_escape"), key("system_menu")]
static func inventory_hint(count: int) -> String:
return "%s use shift+%s drop %s pick up" % [
slot_keys(count), slot_keys(count), key("interact")]
# --- Drawing ----------------------------------------------------------------
func _draw_hud() -> void: func _draw_hud() -> void:
if client == null: if client == null:
_text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM)
return return
var origin := Vector2(MARGIN, MARGIN + 28.0) # Under everything, so the edge of the screen colours without dimming any of
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)), # the text on top of it.
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18)) _draw_vignette()
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0)) _draw_party_markers()
if client.my_escaping:
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_player_panel()
_draw_boss_bar() _draw_boss_bar()
_draw_banner()
if client.my_spawn_grace:
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H),
"ARRIVING -- invulnerable, weapons cold",
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
_draw_cleared_countdown() _draw_cleared_countdown()
_draw_roster() _draw_others()
_draw_inventory() _draw_inventory()
_draw_pickup_prompt() var above := _draw_pickup_prompt()
_draw_level_up_notice() _draw_toasts(above)
_draw_controls_hint()
_draw_debug_info()
if not client.my_alive: if not client.my_alive:
# Centred on the canvas, which is only correct because _canvas actually _centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
# has the viewport's size now -- see the anchor note in _ready().
var centre := _canvas.size * 0.5
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
if _hit_flash > 0.0: ## A dark, slightly translucent box behind a block of HUD text. The floor art is
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size), ## busy and mid-toned; text straight over it was readable on some tiles and not
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash)) ## 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
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),
"level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM)
y += 22.0
# Named rather than just "DUNGEON": there is more than one kind, and knowing
# which one you are standing in is the whole point of having more than one.
var where := "THE HUB"
var tint := COL_DIM
if client.instance_kind != Protocol.InstanceKind.LOBBY:
var def := Dungeons.get_or_default(client.dungeon_id)
where = def.display_name.to_upper()
tint = def.tint
_text(Vector2(x, y + 12.0), where, 12, tint)
y += 20.0
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
# 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, _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 has_character:
_draw_xp_bar(Vector2(x, y))
y += XP_H + 18.0
# 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. 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)
# 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(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 ## Unspent level-ups. Shown wherever you are, because the choice is banked in a
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player ## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
## would have to already know to go and look. ## would have to already know to go and look.
func _draw_level_up_notice() -> void: func _draw_level_up_notice(y: float) -> void:
if client.upgrades_pending <= 0: if client.upgrades_pending <= 0:
return return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004) 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 "" if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_canvas.draw_string(ThemeDB.fallback_font, _text(Vector2(MARGIN, y + 12.0),
Vector2(MARGIN, MARGIN + 74.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending, "%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where], "" if client.upgrades_pending == 1 else "S", where],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, 12, Color(COL_GOLD, 0.6 + 0.4 * pulse))
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
## The boss's health, once you have seen the boss.
##
## The server sends the boss to anyone within the actor interest radius, which
## is deliberately wider than sight and passes through walls -- so the bar used
## to announce "Warden of the Fold" to a player still standing in the entrance
## room, which says exactly where the boss is to someone who has not found it.
## It now waits until the fog has actually lit the boss once, and then stays for
## the rest of the fight: losing the bar every time the boss stepped behind a
## pillar would be worse than the leak.
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
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)
_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():
phase_name = client.boss_def.phases[phase_index].name
_text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15,
Color(1.0, 0.75, 0.8))
_text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85),
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
## 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)
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.
func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
return
var y := _canvas.size.y * 0.5 - 60.0
_centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75))
_centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14,
Color(0.6, 1.0, 0.75, 0.85))
## Top right: in the hub, who is online and where; in a dungeon, the party you
## are in and how each of them is doing. Different questions -- in the hub you
## are deciding whether to go in, in a dungeon you are deciding who needs help.
func _draw_others() -> void:
if client.roster.is_empty():
return
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_online()
else:
_draw_party()
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:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "in a dungeon" if in_dungeon else "in the hub"
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
if not entry["alive"]:
col = Color(0.85, 0.4, 0.4)
where += ", down"
var me := int(entry["peer"]) == client.my_peer
_text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col)
_text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0)
y += 18.0
## Everyone else in this dungeon, with their health. The snapshot only carries
## players inside the interest radius, so a party member further away than that
## is listed without a bar -- the client genuinely does not know.
func _draw_party() -> void:
var near := {}
for p in client.remote_players():
near[int(p["peer"])] = p
var members: Array[Dictionary] = []
for entry in client.roster:
if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer:
members.append(entry)
if members.is_empty():
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
for entry in members:
var peer := int(entry["peer"])
_text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT)
var rec: Dictionary = near.get(peer, {})
var bar_at := Vector2(x + 76.0, y + 3.0)
if rec.is_empty():
_text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM)
else:
var flags := int(rec["flags"])
var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1))
var col: Color = rec["colour"]
if (flags & Protocol.F_ALIVE) == 0:
_text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER)
else:
var rect := Rect2(bar_at, Vector2(w, 12.0))
_canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55))
_canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0),
Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)),
col if frac >= LOW_HEALTH else COL_DANGER)
if (flags & Protocol.F_LINKDEAD) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM)
elif (flags & Protocol.F_ESCAPING) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11,
Color(0.5, 0.85, 1.0))
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 ## Four slots, always on screen. Deliberately not a panel you open: an
@@ -212,133 +686,153 @@ func _draw_inventory() -> void:
# recoloured border, so "this is the one the key is on" reads the same # recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does. # way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot") 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
# The slot number, because the key that uses it is the only thing the # square, and an empty one drew the eye as hard as a full one -- the
# player actually needs to know about a slot. # 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), _canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM) key("use_slot_%d" % (i + 1)), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
if def == null: if def == null:
continue continue
var icon := Art.item_icon(item) var icon := Art.item_icon(item)
var size := icon.size * Art.SCALE var size := icon.size * Art.SCALE
_canvas.draw_texture_rect_region(Art.TILESET, _canvas.draw_texture_rect_region(Art.TILESET,
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon) Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
_canvas.draw_string(ThemeDB.fallback_font, _text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11,
Vector2(origin.x, origin.y + SLOT + 15.0), Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total)
"1-%d use shift+1-%d drop E pick up" % [count, count],
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
## What pressing interact right now would do. The server runs the same search ## What pressing interact right now would do. The server runs the same search
## for itself and does not care what this concluded -- this is a label, not a ## for itself and does not care what this concluded -- this is a label, not a
## decision. ## decision. Returns the y the next thing up should stack from.
func _draw_pickup_prompt() -> void: ##
## Loot first, then an entrance, which is the order the server resolves the
## same key in: an item in reach is taken, and only a press that took nothing
## goes through the portal underneath it.
func _draw_pickup_prompt() -> float:
var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0
if not client.my_alive:
return above
var near := client.loot_in_reach() var near := client.loot_in_reach()
if near.is_empty() or not client.my_alive: if near.is_empty():
return var portal := client.portal_here()
if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY:
return above
var def := Dungeons.get_or_default(portal["dungeon"])
_centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint)
return above - 22.0
var item := Items.by_index(int(near["item"])) var item := Items.by_index(int(near["item"]))
var def := Items.get_def(item) var def := Items.get_def(item)
if def == null: if def == null:
return return above
var full := client.inventory_full() var full := client.inventory_full()
var text := "inventory full -- %s stays where it is" % def.display_name \ var text := "inventory full -- %s stays where it is" % def.display_name \
if full else "E take %s" % def.display_name if full else "%s take %s" % [key("interact"), def.display_name]
var tint := Color(0.85, 0.5, 0.45) if full else def.tint var tint := Color(0.85, 0.5, 0.45) if full else def.tint
_canvas.draw_string(ThemeDB.fallback_font, _centred(above, text, 14, tint)
Vector2(_canvas.size.x * 0.5 - 150.0, return above - 22.0
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
## A thin bar under health: progress toward the next level, and the level ## The message feed, newest nearest the inventory, each line fading out.
## itself. Drawn from the server's numbers, never recomputed locally. func _draw_toasts(bottom: float) -> void:
func _draw_xp_bar(at: Vector2) -> void: var now := _now()
if client.current_character().is_empty(): var y := bottom
for i in range(_toasts.size() - 1, -1, -1):
var t: Dictionary = _toasts[i]
var age := now - float(t["born"])
var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0)
var col: Color = t["colour"]
# A short rise on arrival, so a new line catches the eye without a sound.
var rise := maxf(0.0, 0.25 - age) * 24.0
_centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha))
y -= 20.0
func _draw_controls_hint() -> void:
if not client.my_alive:
return return
# Derived from the snapshot's live experience total rather than the roster's var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY
# copy, which only arrives when the set of characters changes. var alpha := 1.0
var level := Progression.level_for_xp(client.my_total_xp) if not in_hub:
var capped := level >= Progression.MAX_LEVEL var age := _now() - _instance_since
var progress := Progression.level_progress(client.my_total_xp) alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0)
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0) if alpha <= 0.0:
_bar(at, progress, tint, Color.BLACK)
# 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]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
## Shown after the boss dies, so the victory lap has a visible clock on it.
func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
return return
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown _text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12,
_canvas.draw_string(ThemeDB.fallback_font, Color(COL_DIM, 0.9 * alpha))
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
## Who else is online, and whether they are already in a dungeon. Only useful in func _draw_debug_info() -> void:
## the hub, which is the one place you are deciding whether to go in. if not debug_info:
func _draw_roster() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
return return
var x := _canvas.size.x - 240.0 _text(Vector2(0.0, _canvas.size.y - 14.0),
var y := MARGIN + 4.0 "instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()],
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE", 12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN)
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
y += 20.0
for entry in client.roster:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
if not entry["alive"]:
col = Color(0.85, 0.4, 0.4)
where += " (down)"
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
"%s -- %s%s" % [entry["name"], where, me],
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
y += 18.0
func _draw_boss_bar() -> void: ## Red at the edges of the screen: briefly on every hit, and pulsing while
var b := client.boss_state() ## health is low.
if b.is_empty() or client.boss_def == null:
return
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
var rect := Rect2(pos, Vector2(w, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((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))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
"%s -- %s" % [client.boss_def.display_name, phase_name],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
## ##
## [param back] is no longer a colour -- the frame sprite is the background -- ## At the edges, not across the middle. This used to tint the whole screen red
## but the signature keeps it so callers read the same and a future flat-bar ## on a hit, which in a bullet hell dims the bullets at exactly the moment you
## fallback has somewhere to go. ## are among them -- the feedback made the next hit more likely.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void: func _draw_vignette() -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H)) var strength := _hit_flash * 0.55
if client.my_alive:
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
if frac < LOW_HEALTH:
var low := (LOW_HEALTH - frac) / LOW_HEALTH
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006)
strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse))
if strength <= 0.01:
return
var s := _canvas.size
var depth := minf(s.x, s.y) * 0.16
var edge := Color(COL_DANGER, strength)
var clear := Color(COL_DANGER, 0.0)
var sides := [
[Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)],
[Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)],
[Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)],
[Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)],
]
var cols := [
[edge, edge, clear, clear],
[edge, clear, clear, edge],
[edge, clear, clear, edge],
[edge, edge, clear, clear],
]
for i in 4:
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
## 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) _hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0 # The frame scales with the bar's height, so the inset has to as well: a
var inner := Rect2(rect.position + Vector2(inset, inset), # fixed three pixels left a ten-pixel bar four pixels of track and six of
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0), # white frame, and it read as an empty white bar whatever it held.
rect.size.y - inset * 2.0)) 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: if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette -- # The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the # one texture covers health, experience, the escape channel and the
@@ -366,7 +860,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0), Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)), Vector2(draw_cap, rect.size.y)),
Rect2(src.x - cap, 0.0, cap, src.y), tint) Rect2(src.x - cap, 0.0, cap, src.y), tint)
func flash_hit() -> void:
_hit_flash = 1.0
+152 -33
View File
@@ -5,13 +5,20 @@ extends Control
signal join_requested(address: String, port: int) signal join_requested(address: String, port: int)
signal host_requested(port: int) signal host_requested(port: int)
signal settings_requested 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 _address: LineEdit
var _port: LineEdit var _port: LineEdit
var _name: LineEdit
var _status: Label var _status: Label
var _join_button: Button var _join_button: Button
var _host_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: func _ready() -> void:
@@ -29,82 +36,194 @@ func _ready() -> void:
# A CenterContainer recomputes its child's centered position itself, every # A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual # time the child's minimum size or the window size changes -- no manual
# offset math to get wrong. # 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() var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center) add_child(center)
# The same dark panel every other screen sits in, rather than controls
# floating on the clear colour: this is the first screen anyone sees.
var frame := UiTheme.dialog_panel()
center.add_child(frame)
var panel := VBoxContainer.new() var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(360.0, 0.0) panel.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_theme_constant_override("separation", 8) panel.add_theme_constant_override("separation", 8)
center.add_child(panel) frame.add_child(panel)
var title := Label.new() var title := Label.new()
title.text = "TRANSCIENCE" title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 32) # 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) 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)
subtitle.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
panel.add_child(subtitle)
panel.add_child(_gap(10.0))
_name = _field(panel, "name", GameOpts.player_name) # No name field. There used to be one, and for a person it did nothing:
_address = _field(panel, "server address", GameOpts.host_address) # characters are named on the roster screen, and this only ever fed
_port = _field(panel, "port", str(GameOpts.port)) # GameOpts.player_name, which names the characters BOTS create. Asking for
# a name the game then ignores is worse than not asking.
var heading := Label.new()
heading.text = "server"
heading.add_theme_font_size_override("font_size", 12)
panel.add_child(heading)
var where := HBoxContainer.new()
where.add_theme_constant_override("separation", 8)
panel.add_child(where)
# Remembered from the last join, so a returning player does not retype it.
_address = LineEdit.new()
_address.text = Settings.last_address if not Settings.last_address.is_empty() \
else GameOpts.host_address
_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 = 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) _join_button.pressed.connect(_on_join)
panel.add_child(_join_button) panel.add_child(_join_button)
_host_button = Button.new() _host_button = Button.new()
_host_button.text = "Host and play (listen server)" _host_button.text = "Host and play"
_host_button.tooltip_text = "Run a server on this machine and join it -- others can join you"
_host_button.pressed.connect(_on_host) _host_button.pressed.connect(_on_host)
panel.add_child(_host_button) panel.add_child(_host_button)
panel.add_child(_gap(6.0))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
panel.add_child(row)
var settings := Button.new() var settings := Button.new()
settings.text = "Settings" settings.text = "Settings"
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings.pressed.connect(func() -> void: settings_requested.emit()) settings.pressed.connect(func() -> void: settings_requested.emit())
panel.add_child(settings) 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() var quit := Button.new()
quit.text = "Quit" quit.text = "Quit"
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
quit.pressed.connect(func() -> void: get_tree().quit()) quit.pressed.connect(func() -> void: get_tree().quit())
panel.add_child(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 = Label.new()
_status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5)) _status.add_theme_font_size_override("font_size", 13)
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_child(_status) panel.add_child(_status)
# Which protocol this build speaks. A "protocol mismatch" refusal names two
# numbers, and this is where a player finds out which one is theirs.
var version := Label.new()
version.text = "protocol %d" % Protocol.VERSION
version.add_theme_font_size_override("font_size", 11)
version.add_theme_color_override("font_color", Color(0.45, 0.5, 0.6))
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
panel.add_child(version)
func _field(parent: Control, label: String, value: String) -> LineEdit: _validate()
var l := Label.new() # So Enter joins straight away for a returning player, whose address is
l.text = label # already filled in from last time.
l.add_theme_font_size_override("font_size", 12) _join_button.call_deferred("grab_focus")
parent.add_child(l)
var e := LineEdit.new()
e.text = value
parent.add_child(e)
return e
## 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()
c.custom_minimum_size = Vector2(0.0, height)
return c
## 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: func set_status(text: String) -> void:
_status_base = text
_status.text = text _status.text = text
_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))
## 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 ## 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. ## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void: func set_busy(busy: bool) -> void:
_join_button.disabled = busy _busy = busy
_host_button.disabled = busy _validate()
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
GameOpts.player_name = clean
func _on_join() -> void: func _on_join() -> void:
_commit_name() if _join_button.disabled:
join_requested.emit(_address.text.strip_edges(), int(_port.text)) return
var address := _address.text.strip_edges()
var port := int(_port.text)
Settings.remember_server(address, port)
join_requested.emit(address, port)
func _on_host() -> void: func _on_host() -> void:
_commit_name()
host_requested.emit(int(_port.text)) host_requested.emit(int(_port.text))
+76
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://cpbu6rmxpqg0e
+8 -5
View File
@@ -26,6 +26,7 @@ var _cards: HBoxContainer
var _taken: Label var _taken: Label
var _summary: Label var _summary: Label
var _status: Label var _status: Label
var _close: Button
func _ready() -> void: func _ready() -> void:
@@ -84,11 +85,10 @@ func _ready() -> void:
_summary.custom_minimum_size = Vector2(740.0, 0.0) _summary.custom_minimum_size = Vector2(740.0, 0.0)
panel.add_child(_summary) panel.add_child(_summary)
var close := Button.new() _close = Button.new()
close.text = "Close (E)" _close.custom_minimum_size = Vector2(160.0, 34.0)
close.custom_minimum_size = Vector2(160.0, 34.0) _close.pressed.connect(func() -> void: closed.emit())
close.pressed.connect(func() -> void: closed.emit()) panel.add_child(_close)
panel.add_child(close)
func _rule() -> Control: func _rule() -> Control:
@@ -106,6 +106,9 @@ func set_status(text: String) -> void:
## screen can never show an offer that has already been spent. ## screen can never show an offer that has already been spent.
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void: func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
_status.text = "" _status.text = ""
# Named on every refresh rather than once: the same key closes the screen
# that opened it, and it is whatever interact is bound to now.
_close.text = "Close (%s)" % Settings.short_label("interact")
if pending > 0: if pending > 0:
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending _title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
else: else:
+53 -3
View File
@@ -17,6 +17,21 @@ extends Node2D
## Used only in fallback mode, where every bullet is the same generated dot and ## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind. ## colour is the only thing distinguishing a kind.
## How long an enemy bullet is guaranteed to be drawn at full strength before
## it could possibly reach you. The whole point of fading bullets with distance
## is to stop drawing shots that cannot matter -- and the one thing that must
## never come of it is a hit from a bullet you were not shown.
##
## So each enemy bullet is drawn out to however far it, plus you running straight
## at it, could close in this long -- or to the edge of sight, whichever is
## further. With today's content the edge of sight is always the further: the
## fastest enemy bullet does 300 u/s, so even head-on it is on screen for 0.83s
## before it lands. The speed term is what keeps that true for anything faster.
const WARNING_TIME := 0.75
## Past the distance a bullet is drawn to, it fades out over this much more
## rather than vanishing on a line -- a hard edge reads as a rendering fault.
const FADE_OUT := 80.0
const _FALLBACK_COLOURS: Array[Color] = [ const _FALLBACK_COLOURS: Array[Color] = [
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
Color(1.0, 0.35, 0.75), # KIND_ORB Color(1.0, 0.35, 0.75), # KIND_ORB
@@ -99,13 +114,47 @@ func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
return out return out
func render_pool(pool: BulletPool) -> void: ## How strongly to draw a bullet [param dist] from the eye: 1 is fully, 0 is not
## at all. See [constant WARNING_TIME] for why the distance depends on the bullet.
##
## Shots by players fade at the edge of sight and no further, however fast they
## are: they cannot hurt you, so there is nothing to warn about, and a Sniper's
## 1500 u/s shot was the most common thing streaking across the dark.
##
## Distance only -- not shadow. A bullet behind a wall within sight range is
## still drawn, over the darkness, because you can step round the corner into
## it; hiding it would be the invisible hit this is careful not to cause.
static func visibility(dist: float, speed: float, accel: float, team: int,
sight_radius: float) -> float:
var reach := sight_radius
if team == SimConfig.TEAM_ENEMY:
# Top speed over the warning window, times the window: an over-estimate
# of how far it can come, which is the safe direction to be wrong in.
var top := speed + maxf(accel, 0.0) * WARNING_TIME
reach = maxf(sight_radius, (top + SimConfig.PLAYER_SPEED) * WARNING_TIME)
if dist <= reach:
return 1.0
return clampf(1.0 - (dist - reach) / FADE_OUT, 0.0, 1.0)
## Push the pool into the MultiMeshes. With an [param eye], bullets far beyond
## [param sight_radius] are left out; without one -- the connect screen's
## backdrop -- everything is drawn.
func render_pool(pool: BulletPool, eye: Vector2 = Vector2.INF,
sight_radius: float = 0.0) -> void:
var counts := PackedInt32Array() var counts := PackedInt32Array()
counts.resize(_layers.size()) counts.resize(_layers.size())
counts.fill(0) counts.fill(0)
var cull := eye.is_finite()
for i in pool.high_water: for i in pool.high_water:
if pool.alive[i] == 0: if pool.alive[i] == 0:
continue continue
var alpha := 1.0
if cull:
alpha = visibility((pool.pos[i] as Vector2).distance_to(eye),
(pool.vel[i] as Vector2).length(), pool.accel[i], pool.team[i], sight_radius)
if alpha <= 0.0:
continue
var kind := clampi(pool.kind[i], 0, _layers.size() - 1) var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
var n := counts[kind] var n := counts[kind]
if n >= SimConfig.MAX_BULLETS: if n >= SimConfig.MAX_BULLETS:
@@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void:
+ float(pool.uid[i] % 64) * (TAU / 64.0) + float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d( _layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i])) n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n, var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE) tint.a *= alpha
_layers[kind].multimesh.set_instance_color(n, tint)
counts[kind] = n + 1 counts[kind] = n + 1
for kind in _layers.size(): for kind in _layers.size():
_layers[kind].multimesh.visible_instance_count = counts[kind] _layers[kind].multimesh.visible_instance_count = counts[kind]
+35 -4
View File
@@ -75,10 +75,14 @@ func _process(_delta: float) -> void:
# Item feedback rides the same rule as every other sound here: it # Item feedback rides the same rule as every other sound here: it
# plays because a server event arrived, never because the client # plays because a server event arrived, never because the client
# guessed a pickup succeeded. # guessed a pickup succeeded.
_bound.item_picked_up.connect(func(_i: StringName) -> void: _bound.item_picked_up.connect(func(i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0)) sfx.play(Art.SFX_ENEMY_DEATH, -16.0)
_bound.item_used.connect(func(_i: StringName) -> void: _announce_item("+ %s", i))
sfx.play(Art.SFX_SHOOT, -10.0)) _bound.item_used.connect(func(i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0)
_announce_item("%s used", i))
_bound.item_dropped.connect(func(i: StringName) -> void:
_announce_item("dropped %s", i))
_bound.characters_changed.connect(_refresh_characters) _bound.characters_changed.connect(_refresh_characters)
_bound.upgrades_changed.connect(_refresh_upgrades) _bound.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades() _refresh_upgrades()
@@ -97,10 +101,19 @@ func _process(_delta: float) -> void:
_roster_open = false _roster_open = false
characters.visible = forced or _roster_open characters.visible = forced or _roster_open
characters.set_dismissible(not forced) characters.set_dismissible(not forced)
# Said once, on the screen death sends you to, and gone as soon as the
# player has picked who comes next.
if forced and not _bound.last_fallen.is_empty():
characters.set_fallen(fallen_line(_bound.last_fallen))
elif not forced and not _bound.last_fallen.is_empty():
_bound.last_fallen = {}
characters.set_fallen("")
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc(): if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
_upgrades_open = false _upgrades_open = false
upgrades.visible = _upgrades_open upgrades.visible = _upgrades_open
_follow_camera() _follow_camera()
hud.sight = world_view.sight
hud.debug_info = world_view.debug_hitboxes
menu.set_in_dungeon(_bound != null menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
@@ -147,6 +160,24 @@ func _on_respawn_pressed() -> void:
_bound.request_respawn = true _bound.request_respawn = true
## Permadeath, said plainly: who, how far they got, and where. A death used to
## be announced only by the roster screen appearing.
static func fallen_line(fallen: Dictionary) -> String:
var where := String(fallen.get("where", ""))
return "%s fell at level %d%s. They are gone -- choose who goes next." % [
fallen["name"], int(fallen["level"]),
" in the %s" % where.trim_prefix("The ") if not where.is_empty() else ""]
## A line in the HUD's message feed for an item the server just moved. The
## events are this player's own -- ClientRuntime filters them by peer -- so a
## party member's pickup does not read as yours.
func _announce_item(pattern: String, item: StringName) -> void:
var def := Items.get_def(item)
if def != null:
hud.notify(pattern % def.display_name, def.tint)
func _on_local_hit(_damage: int) -> void: func _on_local_hit(_damage: int) -> void:
hud.flash_hit() hud.flash_hit()
sfx.play(Art.SFX_PLAYER_HIT, -4.0) sfx.play(Art.SFX_PLAYER_HIT, -4.0)
+12 -9
View File
@@ -33,8 +33,9 @@ var client: ClientRuntime = null
var _anim_time: float = 0.0 var _anim_time: float = 0.0
## F1 overlay: draws what the simulation collides against, over what is drawn. ## F1 overlay: draws what the simulation collides against, over what is drawn.
var debug_hitboxes: bool = false var debug_hitboxes: bool = false
## What the local player can see this frame. See [Visibility]. ## What the local player can see this frame. See [Visibility]. Read by the HUD
var _sight := Visibility.new() ## too, which waits for the boss to have been seen before showing its bar.
var sight := Visibility.new()
## The darkness is the window's clear colour, exactly, so where it is complete it ## The darkness is the window's clear colour, exactly, so where it is complete it
## is indistinguishable from the undrawn void beyond it and there is no seam. ## is indistinguishable from the undrawn void beyond it and there is no seam.
var _dark: Color = ProjectSettings.get_setting( var _dark: Color = ProjectSettings.get_setting(
@@ -56,7 +57,8 @@ func _process(delta: float) -> void:
_anim_time += delta _anim_time += delta
client = Net.client client = Net.client
if client != null: if client != null:
bullets.render_pool(client.world.pool) bullets.render_pool(client.world.pool, client.predicted_pos,
SimConfig.FOG_VIEW_RADIUS)
queue_redraw() queue_redraw()
@@ -69,14 +71,15 @@ func _process(delta: float) -> void:
## darkness because a warning you cannot see is no warning (see ## darkness because a warning you cannot see is no warning (see
## _draw_telegraphs). The local player goes on top because it is the eye. And ## _draw_telegraphs). The local player goes on top because it is the eye. And
## bullets are a child node, so they are drawn after all of this, over the dark ## bullets are a child node, so they are drawn after all of this, over the dark
## -- as they always were: a shot coming out of the black is a shot you can ## -- a shot coming out of the black is a shot you can still dodge. Out past the
## still dodge. ## edge of sight they fade away, but only as far as is safe: see
## BulletRenderer.visibility().
func _draw() -> void: func _draw() -> void:
if client == null: if client == null:
return return
var map: MapGrid = client.world.map var map: MapGrid = client.world.map
if map != null: if map != null:
_sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS) sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
_draw_terrain() _draw_terrain()
_draw_portals() _draw_portals()
_draw_upgrade_npc() _draw_upgrade_npc()
@@ -105,7 +108,7 @@ func _draw() -> void:
## and the shadow is several hundred. Issued from inside _draw on this item's own ## and the shadow is several hundred. Issued from inside _draw on this item's own
## canvas, so it lands in order between what was drawn before and after it. ## canvas, so it lands in order between what was drawn before and after it.
func _draw_darkness() -> void: func _draw_darkness() -> void:
var mesh := _sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark) var mesh := sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(), RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
mesh["indices"], mesh["points"], mesh["colors"]) mesh["indices"], mesh["points"], mesh["colors"])
@@ -129,7 +132,7 @@ func _draw_debug() -> void:
DebugDraw.draw_player(self, p["pos"], p["aim"]) DebugDraw.draw_player(self, p["pos"], p["aim"])
DebugDraw.draw_player(self, client.predicted_pos, client.aim) DebugDraw.draw_player(self, client.predicted_pos, client.aim)
DebugDraw.draw_ranges(self, client.predicted_pos) DebugDraw.draw_ranges(self, client.predicted_pos)
DebugDraw.draw_sight(self, _sight) DebugDraw.draw_sight(self, sight)
## Aggro radius by visual index. Read from the content definitions rather than ## Aggro radius by visual index. Read from the content definitions rather than
@@ -247,7 +250,7 @@ func _draw_upgrade_npc() -> void:
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint) HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
if client.at_upgrade_npc(): if client.at_upgrade_npc():
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0), draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13, "%s talk" % Settings.short_label("interact"), HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
Color(0.8, 0.85, 0.95)) Color(0.8, 0.85, 0.95))
# A pending choice pulses, so it is visible from across the hub. # A pending choice pulses, so it is visible from across the hub.
if waiting > 0: if waiting > 0:
+96
View File
@@ -138,3 +138,99 @@ func test_interacting_away_from_the_portal_does_nothing() -> void:
var used := lobby.world.events.filter( var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED) func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 0) assert_eq(used.size(), 0)
# --- Tunnel groups ----------------------------------------------------------
func _tunnel_enemies(run: Instance) -> Array[SimEnemy]:
var out: Array[SimEnemy] = []
for e in run.world.enemies.values():
var t := run.world.map.to_tile(e.pos)
if run.boss_room.has_point(t):
continue
var in_room := false
for r in run.rooms:
if r.grow(1).has_point(t):
in_room = true
if not in_room:
out.append(e)
return out
func test_tunnels_are_occasionally_occupied_and_usually_not() -> void:
# "Rarely" in numbers: across many depth-1 runs, some have a group in a
# tunnel and most do not.
var occupied := 0
var runs := 80
for s in range(1, runs + 1):
if not _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1)).is_empty():
occupied += 1
assert_gt(occupied, runs / 10, "tunnel groups exist (%d of %d runs)" % [occupied, runs])
assert_lt(occupied, runs / 2, "but a run with one is the exception (%d of %d)" % [occupied, runs])
func test_a_tunnel_group_is_never_awake_when_you_arrive() -> void:
var widest := Instance._widest_aggro()
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
for e in _tunnel_enemies(run):
assert_gt(e.pos.distance_to(run.world.spawn_point), widest,
"seed %d: a tunnel %s stands within aggro range of the entrance"
% [s * 7919, e.def.id])
func test_a_tunnel_group_is_a_group() -> void:
# Never a lone straggler, and never spread down the whole tunnel: every
# member has another within a few tiles.
var near := MapGrid.TILE * (Instance.TUNNEL_GROUP_SPREAD * 2 + 1)
var seen := 0
for s in range(1, 120):
var found := _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1 + (s % 3)))
for e in found:
seen += 1
var has_company := false
for other in found:
if other != e and other.pos.distance_to(e.pos) <= near:
has_company = true
assert_true(has_company, "seed %d: a tunnel %s on its own" % [s * 7919, e.def.id])
assert_gt(seen, 0, "the sample produced at least one group to check")
func test_a_tunnel_group_is_scaled_like_everything_else() -> void:
# The Proving Grounds scales every enemy it spawns; a tunnel group must not
# be the one thing that arrives at full health in the easy harness.
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 2, Dungeons.PROVING)
for e in _tunnel_enemies(run):
assert_lt(e.hp, Content.enemy(e.def.id).max_hp,
"seed %d: a tunnel %s skipped the dungeon's scaling" % [s * 7919, e.def.id])
func test_boss_rush_leaves_the_tunnels_empty_too() -> void:
GameOpts.boss_rush = true
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")
+112
View File
@@ -0,0 +1,112 @@
extends GutTest
## Which bullets are drawn, and the promise that bounds it: no enemy bullet can
## hit you without having been on screen, at full strength, for at least
## BulletRenderer.WARNING_TIME first. Fading distant bullets is only worth doing
## if that holds for every bullet the game can fire.
const RENDERER := preload("res://src/view/bullet_renderer.gd")
const SIGHT := SimConfig.FOG_VIEW_RADIUS
## The worst case for warning: the bullet coming straight at you and you running
## straight at it. Stepped the way BulletPool integrates, from far enough out
## that it starts undrawn. Returns seconds it was fully drawn before it hit.
func _warning(speed: float, accel: float) -> float:
var dt := SimConfig.TICK_DELTA
var gap := 6000.0
var v := speed
var t := 0
var first_full := -1
var reach := SimConfig.PLAYER_RADIUS + 7.0
while gap > reach and t < 60 * 120:
if first_full < 0 and RENDERER.visibility(gap, v, accel,
SimConfig.TEAM_ENEMY, SIGHT) >= 1.0:
first_full = t
v = maxf(v + accel * dt, 0.0)
gap -= (v + SimConfig.PLAYER_SPEED) * dt
t += 1
assert_gte(first_full, 0, "the bullet was drawn at all before it arrived")
return float(t - first_full) * dt
func test_every_enemy_bullet_in_the_game_is_seen_in_time() -> void:
var emitters: Array[BulletEmitter] = []
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
emitters.append_array(phase.emitters)
assert_gt(emitters.size(), 0)
for e in emitters:
var warned := _warning(e.speed, e.accel)
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"a %.0f u/s bullet was on screen %.2fs before it could land" % [e.speed, warned])
func test_a_bullet_far_faster_than_anything_today_is_still_seen_in_time() -> void:
# The sight radius covers today's content on its own. This is the case the
# speed term exists for: content added later, faster than the radius allows.
for case in [[1200.0, 0.0], [600.0, 900.0], [2000.0, 0.0]]:
var warned := _warning(case[0], case[1])
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"%.0f u/s, accelerating %.0f: on screen %.2fs" % [case[0], case[1], warned])
func test_inside_sight_everything_is_drawn_fully() -> void:
for team in [SimConfig.TEAM_ENEMY, SimConfig.TEAM_PLAYER]:
assert_eq(RENDERER.visibility(SIGHT - 1.0, 130.0, 0.0, team, SIGHT), 1.0)
func test_a_slow_shot_far_beyond_sight_is_not_drawn() -> void:
assert_eq(RENDERER.visibility(SIGHT + 400.0, 130.0, 0.0, SimConfig.TEAM_ENEMY, SIGHT),
0.0, "out in the black and nowhere near you")
func test_the_edge_fades_rather_than_cuts() -> void:
var mid := RENDERER.visibility(SIGHT + RENDERER.FADE_OUT * 0.5, 130.0, 0.0,
SimConfig.TEAM_ENEMY, SIGHT)
assert_between(mid, 0.3, 0.7)
func test_player_shots_fade_at_the_edge_of_sight_however_fast() -> void:
# They cannot hurt you, so there is nothing to warn about -- and a Sniper's
# shot streaking across the dark was the commonest case of all.
assert_eq(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_PLAYER, SIGHT), 0.0)
assert_gt(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_ENEMY, SIGHT), 0.0,
"an enemy bullet that fast is drawn further out, because it could reach you")
# --- The renderer actually applies it ---------------------------------------
func _drawn(r: Node2D) -> int:
var n := 0
for layer in r._layers:
n += (layer as MultiMeshInstance2D).multimesh.visible_instance_count
return n
func test_the_renderer_leaves_out_what_is_too_far_to_matter() -> void:
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
var eye := Vector2(100.0, 100.0)
pool.spawn(eye + Vector2(200.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(eye + Vector2(SIGHT + 500.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool, eye, SIGHT)
assert_eq(_drawn(r), 1, "the near bullet only")
func test_with_no_eye_everything_is_drawn() -> void:
# The connect screen's backdrop has no player and wants the whole pattern.
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
pool.spawn(Vector2.ZERO, Vector2.RIGHT, 7.0, 300, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(Vector2(5000.0, 0.0), Vector2.RIGHT, 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool)
assert_eq(_drawn(r), 2)
+1
View File
@@ -0,0 +1 @@
uid://noaqo4x7so77
+45
View File
@@ -0,0 +1,45 @@
extends GutTest
## Permadeath is announced. The server unbinds a character on the tick it dies,
## so before this the only sign anything had happened was the roster appearing.
const GAME_SCENE := preload("res://src/view/game_scene.gd")
func test_the_line_names_who_fell_how_far_they_got_and_where() -> void:
var line: String = GAME_SCENE.fallen_line(
{"name": "Bramcreek", "level": 7, "where": "Warden's Descent"})
assert_string_contains(line, "Bramcreek")
assert_string_contains(line, "level 7")
assert_string_contains(line, "in the Warden's Descent")
func test_a_dungeon_already_called_the_something_is_not_the_the_something() -> void:
var line: String = GAME_SCENE.fallen_line(
{"name": "Wren", "level": 3, "where": "The Choir Vault"})
assert_string_contains(line, "in the Choir Vault")
assert_false(line.contains("the The"))
func test_the_client_records_its_own_death_from_the_server_event() -> void:
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_peer = 5
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
c.selected_character = "a"
c.instance_kind = Protocol.InstanceKind.DUNGEON
c.dungeon_id = Dungeons.VAULT
c.my_total_xp = Progression.total_xp_for_level(4)
c.on_events(NetCodec.encode_events(10,
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 5}] as Array[Dictionary]))
assert_eq(c.last_fallen.get("name", ""), "Ash")
assert_eq(int(c.last_fallen.get("level", 0)), 4)
assert_eq(String(c.last_fallen.get("where", "")), "The Choir Vault")
func test_someone_elses_death_is_not_yours() -> void:
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_peer = 5
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
c.selected_character = "a"
c.on_events(NetCodec.encode_events(10,
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 6}] as Array[Dictionary]))
assert_true(c.last_fallen.is_empty())
+1
View File
@@ -0,0 +1 @@
uid://chmutr64rrmsh
+213
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://cn1mu2io17h4i
+86
View File
@@ -254,3 +254,89 @@ func test_saving_writes_to_the_configured_path() -> void:
var cfg := ConfigFile.new() var cfg := ConfigFile.new()
assert_eq(cfg.load(_scratch), OK) assert_eq(cfg.load(_scratch), OK)
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001) assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
# --- The connect screen remembers ---------------------------------------------
func test_the_last_server_survives_a_save_and_reload() -> void:
var saved_address := Settings.last_address
var saved_port := Settings.last_port
Settings.remember_server(" play.example.net ", 27020)
Settings.last_address = ""
Settings.last_port = 0
Settings.load_and_apply()
assert_eq(Settings.last_address, "play.example.net", "trimmed, and back again")
assert_eq(Settings.last_port, 27020)
Settings.last_address = saved_address
Settings.last_port = saved_port
func test_nonsense_is_not_remembered() -> void:
var saved_address := Settings.last_address
Settings.last_address = "kept.example.net"
Settings.remember_server(" ", 27020)
Settings.remember_server("x.example.net", 0)
Settings.remember_server("x.example.net", 70000)
assert_eq(Settings.last_address, "kept.example.net",
"a blank address or an impossible port must not replace a good one")
Settings.last_address = saved_address
# --- The HUD says what the keys actually are ----------------------------------
# Every hint on screen used to be a literal -- "WASD move", "LMB fire", "hold F"
# -- while every one of those actions is rebindable. Rebind fire and the HUD
# kept telling you to click.
const HUD := preload("res://src/ui/hud.gd")
func test_the_hints_name_the_default_keys() -> void:
assert_eq(HUD.move_keys(), "WASD")
assert_eq(HUD.key("fire"), "LMB")
assert_eq(HUD.slot_keys(4), "1-4")
assert_string_contains(HUD.controls_hint(false), "hold F")
assert_string_contains(HUD.controls_hint(false), "Esc menu",
"spelled the way a keycap spells it, not the way the OS names it")
func test_the_hints_follow_a_rebind() -> void:
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_RIGHT
click.device = -1
assert_eq(Settings.rebind("fire", click), "")
assert_eq(Settings.rebind("emergency_escape", _key_event(KEY_G)), "")
var hint := HUD.controls_hint(false)
assert_string_contains(hint, "RMB fire")
assert_string_contains(hint, "hold G")
assert_false(hint.contains("LMB"), "the old key is not mentioned anywhere")
func test_movement_spelled_out_when_it_is_not_four_letters() -> void:
assert_eq(Settings.rebind("move_up", _key_event(KEY_UP)), "")
var keys := HUD.move_keys()
assert_true(keys.begins_with("Up/"), "arrow keys read as words, got %s" % keys)
func test_slot_keys_are_listed_when_they_are_not_one_to_four() -> void:
assert_eq(Settings.rebind("use_slot_3", _key_event(KEY_Z)), "")
assert_eq(HUD.slot_keys(4), "1 2 Z 4")
func test_the_upgrade_screen_names_the_key_that_closes_it() -> void:
assert_eq(Settings.rebind("interact", _key_event(KEY_G)), "")
var screen: CanvasLayer = preload("res://src/ui/upgrade_screen.gd").new()
add_child_autofree(screen)
var none: Array[StringName] = []
screen.refresh(0, none, none)
assert_eq(screen._close.text, "Close (G)",
"it used to say (E) whatever interact was bound to")
func test_short_labels_are_what_a_keycap_says() -> void:
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.device = -1
assert_eq(Settings.rebind("interact", click), "fire",
"left click is fire's, and the clash is named")
assert_eq(Settings.short_label("fire"), "LMB")
assert_eq(Settings.short_label("system_menu"), "Esc")
+145 -19
View File
@@ -14,29 +14,42 @@ extends Node
## touching anything under src/ui/ and open the files. ## touching anything under src/ui/ and open the files.
const GAME := preload("res://src/game.tscn") const GAME := preload("res://src/game.tscn")
const GAME_SCENE := preload("res://src/view/game_scene.gd")
## Its own store and its own account, like every diagnostic. Without these the
## tool hosted a listen server on the player's REAL character store as the
## player's REAL account, and walked their character into dungeons to take
## pictures -- with health carried between runs, a stray hit would have stuck,
## and a death would have retired someone's character for a screenshot.
const STORE := "user://screenshot_characters.json"
const ACCOUNT := 777001
## Frame numbers, not seconds: the point is a deterministic sequence, and a ## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds. ## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30 const AT_MENU := 200
const ENTER_GAME := 34 const ENTER_GAME := 204
const AT_GAME := 200 const AT_GAME := 370
const OPEN_MENU := 210 const OPEN_MENU := 380
const AT_INGAME_MENU := 240 const AT_INGAME_MENU := 410
const OPEN_SETTINGS := 250 const OPEN_SETTINGS := 420
const AT_SETTINGS := 280 const AT_SETTINGS := 450
const OPEN_CREDITS := 290 const OPEN_CREDITS := 460
const AT_CREDITS := 320 const AT_CREDITS := 490
const OPEN_UPGRADES := 330 const OPEN_UPGRADES := 500
const AT_UPGRADES := 360 const AT_UPGRADES := 530
const OPEN_ROSTER := 370 const OPEN_ROSTER := 540
const AT_ROSTER := 400 const AT_ROSTER := 570
const ENTER_DUNGEON := 410 const ENTER_DUNGEON := 580
const AT_DUNGEON := 480 const AT_DUNGEON := 650
const TO_TUNNEL := 490 const TO_TUNNEL := 660
const AT_TUNNEL := 550 const AT_TUNNEL := 720
const AT_TUNNEL_DEBUG := 560 const AT_TUNNEL_DEBUG := 730
const DONE := 570 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 _frame: int = 0
var _left_shot: bool = false
var _game: Node = null var _game: Node = null
var _menu: Control = null var _menu: Control = null
var _dir: String = "user://" var _dir: String = "user://"
@@ -44,6 +57,9 @@ var _dir: String = "user://"
func _ready() -> void: func _ready() -> void:
GameOpts.parse() GameOpts.parse()
GameOpts.store_path = STORE
GameOpts.account_override = ACCOUNT
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE))
Engine.physics_ticks_per_second = SimConfig.TICK_RATE Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Settings.load_and_apply() Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR") var from_env := OS.get_environment("SHOT_DIR")
@@ -78,16 +94,46 @@ func _process(_delta: float) -> void:
# Held until the shot is taken; one frame of it is not enough. # Held until the shot is taken; one frame of it is not enough.
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES: if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
_stand_at_the_npc() _stand_at_the_npc()
# On an entrance for the hub shot, so its prompt is in the picture.
if _frame > AT_GAME - 40 and _frame <= AT_GAME:
_stand_on_a_portal()
# Same reason: the game scene owns this screen's visibility too and decides # Same reason: the game scene owns this screen's visibility too and decides
# it every frame, so one assignment lasts exactly one frame. # it every frame, so one assignment lasts exactly one frame.
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER: if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
_show_the_roster() _show_the_roster()
# 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:
# Forty frames, not ten: at a high frame rate ten is shorter than one
# snapshot, and the picture showed the health from before the drop.
_hold_health(0.62 if _frame < AT_BOSS - 40 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: match _frame:
AT_MENU: AT_MENU:
_grab("menu") _grab("menu")
ENTER_GAME: ENTER_GAME:
_menu.queue_free() _menu.queue_free()
Net.host(27455) Net.host(27455)
_seed_character()
Net.start_local_client() Net.start_local_client()
_game = GAME.instantiate() _game = GAME.instantiate()
add_child(_game) add_child(_game)
@@ -131,6 +177,7 @@ func _process(_delta: float) -> void:
# The fog is what these two are for, and the hub is the worst place to # The fog is what these two are for, and the hub is the worst place to
# judge it: one big room with little in it to cast a shadow. # judge it: one big room with little in it to cast a shadow.
_game.set("_roster_open", false) _game.set("_roster_open", false)
_game.get_node("CharacterSelect").set_fallen("")
Net.server._send_to_dungeon(Net.LOCAL_PEER) Net.server._send_to_dungeon(Net.LOCAL_PEER)
AT_DUNGEON: AT_DUNGEON:
_grab("dungeon") _grab("dungeon")
@@ -144,6 +191,11 @@ func _process(_delta: float) -> void:
_game.get_node("WorldView").debug_hitboxes = true _game.get_node("WorldView").debug_hitboxes = true
AT_TUNNEL_DEBUG: AT_TUNNEL_DEBUG:
_grab("tunnel_debug") _grab("tunnel_debug")
TO_BOSS:
_game.get_node("WorldView").debug_hitboxes = false
_stage_a_boss_fight()
AT_BOSS:
_grab("boss_fight")
DONE: DONE:
Net.shutdown() Net.shutdown()
get_tree().quit(0) get_tree().quit(0)
@@ -170,6 +222,9 @@ func _show_the_roster() -> void:
p.pos = away p.pos = away
_game.set("_upgrades_open", false) _game.set("_upgrades_open", false)
_game.set("_roster_open", true) _game.set("_roster_open", true)
# A death, as it is announced on the screen a death sends you to.
_game.get_node("CharacterSelect").set_fallen(GAME_SCENE.fallen_line(
{"name": "Quillfell", "level": 9, "where": "The Choir Vault"}))
var roster: Array[Dictionary] = [ var roster: Array[Dictionary] = [
_roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)), _roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)),
_roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)), _roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)),
@@ -178,6 +233,77 @@ func _show_the_roster() -> void:
_game.get_node("CharacterSelect").refresh(roster, "c4") _game.get_node("CharacterSelect").refresh(roster, "c4")
## A character worth photographing: levelled, carrying things, so the HUD has
## something in every slot and a bar that is not empty. Made in the scratch
## store before the local client says hello, so it is who the client resumes.
func _seed_character() -> void:
var store := Net.server.store
var c := store.create_character(ACCOUNT, "Ash")
store.grant_xp(ACCOUNT, c.id, Progression.total_xp_for_level(6) + 260)
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.WARDENS_RATION,
Items.HEALTH_POTION, Items.NONE]
store.set_inventory(ACCOUNT, c.id, bag)
store.flush()
func _stand_on_a_portal() -> void:
if Net.client == null or Net.client.portals.is_empty():
return
var at: Vector2 = Net.client.portals[0]["pos"]
Net.client.predicted_pos = at
var p := _player()
if p != null:
p.pos = at
func _player() -> SimPlayer:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null
func _hold_health(share: float) -> void:
var p := _player()
if p != null:
p.hp = maxi(1, int(float(p.max_hp) * share))
## Into the boss arena, low on health, with a party member beside you and a
## few messages in the feed: everything on the HUD that only exists mid-fight,
## in one picture.
##
## The party member is a world player with no socket behind it -- added to the
## simulation and the roster, never to the instance's send list, so the server
## never tries to talk to it.
func _stage_a_boss_fight() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null:
return
var at := inst.boss_spawn + Vector2(-200.0, 60.0)
Net.client.predicted_pos = at
var me := _player()
if me != null:
me.pos = at
me.spawn_grace = 0
var friend := inst.world.add_player(999, "Wren")
friend.pos = at + Vector2(-40.0, -70.0)
friend.colour = Color(1.0, 0.75, 0.45)
friend.hp = int(float(friend.max_hp) * 0.6)
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)
## Move the player, on both sides, into the tunnel closest to where it spawned: ## Move the player, on both sides, into the tunnel closest to where it spawned:
## somewhere with walls close on two sides and a corner in view, which is where ## somewhere with walls close on two sides and a corner in view, which is where
## the old fog looked worst. ## the old fog looked worst.