Compare commits

...
10 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
claudeandClaude Opus 5.5 855669a665 Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or
skipped the whole tile on the answer. Every shadow was a staircase, tiles
snapped in and out as you walked, and a wall beside you vanished whenever
its centre happened to sit behind its neighbour.

Visibility casts rays from the eye instead: a fixed sweep, plus two aimed
either side of every wall corner, which is what makes each shadow edge a
straight line rather than a smear between rays. The view now draws terrain
and actors first and then darkness over whatever the rays did not reach,
so a shadow runs straight through a tile and an enemy rounding a corner
appears a slice at a time. Bullets are a child node and are still drawn
over the dark; telegraphs are drawn after it, as they must be.

"A bit less strict" is three specific things. A wall face you can see is
lit 26 units in, measured perpendicular to the face; shadows end in a
short fade rather than a line; and sight fades over its last 64 units
rather than stopping at a circle. Still hard fog -- nothing remembered.

The first version measured that depth along the ray, and a screenshot
caught what no test had: a wall you look along got a sliver, so the wall
beside you in a tunnel looked missing -- the old fog's own complaint.
Perpendicular depth fixes it, and light stops the instant a ray steps
back into the open, so it never rounds a corner, never reaches the far
side of a one-tile wall, and never leaves a stepped wall through its
other face. Two tiles touching only at a corner are closed.

About half a millisecond a frame. Measured against the old fog in the
same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's
own low rate predates this.

MapGrid gains a revision counter so the corner list is rebuilt only when
the map changes or the eye crosses a tile. screenshot.tscn now captures a
dungeon, a tunnel and the tunnel with F1's overlay, which draws where
each ray stopped -- and makes SHOT_DIR rather than failing every capture
when it does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:07:33 +02:00
claudeandClaude Opus 5.5 83c3b920fb Varied-width tunnels, and stop carving slits in the boss arenas
Every corridor was one tile wide, which in a bullet hell is a shooting
gallery: there is no side to step to, so anything fired down it lands.
Each leg of each L now rolls its own width between 2 and 4, so a tunnel
can open up or pinch at its bend rather than every connection being the
same pipe. Carving stops one tile short of the map's outer ring, which
keeps the world sealed however close to the edge a wide tunnel runs.

Found while doing it: 45% of generated arenas had their own walls carved
open. The last corridor aimed at the door tile, which sits ON the
arena's west wall, and an L that bent the other way ran its second leg
straight down that column -- 181 of 398 arenas, measured. It now aims at
the tile outside the door and always arrives along the door's own row,
and the arena is excluded from every carve, so a wide tunnel stops at
the wall instead of eating into it.

Three tests: no corridor tile sits in a passage one tile wide, every
width in the range turns up and none falls below two, and every arena
keeps every wall tile it was drawn with. The overshoot at a bend is
cosmetic -- the legs always overlap on the inner side, so removing it
bevels the outer corner and narrows nothing -- and the comment now says
so rather than claiming otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:51:19 +02:00
37 changed files with 3242 additions and 344 deletions

No files matched your search

+30 -5
View File
@@ -13,8 +13,8 @@ the obvious default.
```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # 468 GUT tests, headless (~4s)
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
tools/test.sh # 576 GUT tests, headless (~4s)
tools/smoke.sh # real server + 5 bot clients over ENet (~40s)
tools/server.sh # dedicated server
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 . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (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
@@ -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
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`
have all passed with the entire interface rendering unstyled, an invisible
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. |
| `--listen` | Skip the menu and host a listen server with a local player. |
| `--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. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
| `--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
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.
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()`
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
@@ -243,8 +257,19 @@ ticks in milliseconds with no SceneTree.
enemies and the boss all go through the same lerp. The boss did not for a
long time, which is invisible while bosses stand still and looks broken the
moment one moves.
- **The fog works by draw order, not by deciding per object.** `WorldView._draw`
draws terrain and every actor within sight range, then the darkness from
`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*
`_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`
ignores line of sight on purpose; everything else in the view respects 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.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
+6 -5
View File
@@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash
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.
@@ -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. |
| **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. |
| **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. |
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. |
| **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. |
| **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. |
| **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. |
@@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings.
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # 468 GUT tests, headless ~4s
tools/smoke.sh # server + 4 bot clients over ENet ~40s
tools/test.sh # 576 GUT tests, headless ~4s
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
+26 -5
View File
@@ -48,8 +48,10 @@ This project deliberately does not, for four reasons:
The trade is that there is no physics engine, so collision is hand-written.
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the
genre convention anyway.
for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre
convention anyway. The client's fog no longer shares that line test: it casts
exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so
shadows cut through tiles rather than snapping to them.
That was the right call rather than a compromise: the same grid answers
collision, sight, and per-peer interest management as array lookups. On arbitrary
@@ -142,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role |
| --- | --- |
| `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/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. |
@@ -170,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/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/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. |
### Network
@@ -186,14 +191,30 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role |
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `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/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/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/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/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/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `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. |
+127
View File
@@ -89,6 +89,41 @@ about as often as a good one.
**Hard fog.** *(User, this session.)* No remembered terrain — anything outside
current line of sight is not drawn, including ground already walked over.
**The fog is geometry, not a verdict per tile — and a little less strict.**
*(User asked: "no longer grid based ... able to light up cells partially", and
"a bit less strict". How strict was left to me.)* `Visibility` casts rays from
the eye — a fixed sweep plus two aimed either side of every wall corner, which
is what makes each shadow edge a straight line — and the view draws terrain and
actors first, then darkness over everything the rays did not reach. A shadow
edge runs straight through a tile; an enemy rounding a corner appears a slice
at a time instead of popping in whole.
"Less strict" is three specific things. The face of a wall you can see is lit
26 units deep, measured perpendicular to the face — along the ray it was a
sliver for any wall seen at a grazing angle, and the wall beside you vanished,
which is the complaint the old fog had. Shadows end in an 8-unit fade instead
of a hard line. And the edge of sight fades over its last 64 units instead of
stopping at a circle. Nothing is remembered: still hard fog.
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
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 — a shot out of the
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
flag; what raises depth in actual play is still open.
@@ -508,3 +543,95 @@ a different fight in a different room.
**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
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
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
@@ -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
lifetime expires.
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
actors, because the failure modes are not symmetric. An enemy appearing at the
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 +
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
from the live content, so adding a faster or longer-lived bullet fails a test
rather than producing bullets that wink into existence.
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
at its speed and acceleration over its remaining life, could reach ground the
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
withheld at spawn that later flies into view is invisible damage — so the rule
errs long.
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
@@ -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 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
same three seconds, but that is presentation. A client that ignores its own UI
and holds the bit down still waits, because the check lives in `SimWorld`.
The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
but note what reaches it. A connected player is unbound on the tick it dies and
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
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
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
One server process hosts the lobby hub plus every concurrent dungeon. An
+65 -15
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,
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
candidates, none of which the user has chosen — **ask before starting one.**
@@ -35,16 +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). |
| **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
and more specific than the table above.
### Known flake
### The smoke flake (diagnosed and fixed)
`tools/smoke.sh` failed once in a pre-push hook and then passed on four
consecutive runs with no stray processes. It was not reproduced and is not
diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets
and was competing with a full suite run — but that is a guess. If it recurs,
that is the first thing to check.
`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose
logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
whose account picks the Proving Grounds, had gone into the Choir Vault. The
bot wandered for 90 of every 120 ticks in the hub and then walked to its
entrance holding interact the whole way; the three entrances sit in a row, so a
wander that ended near the middle one carried it across the wrong ring with the
key down. Which dungeons opened depended on where a timing-dependent wander
stopped. The bot now walks straight to its own entrance and presses interact
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with
`KEEP=1 git push` so the logs survive.
---
@@ -55,8 +89,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
| Gate | Covers | Runtime |
| --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s |
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s |
| `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_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 |
@@ -64,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,
and it is the only thing in the project that can tell you the interface
actually rendered. Run it after touching `src/ui/`. It captures the main menu,
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
those by staging an offer, because earning one would make the shot depend on a
dungeon run.
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
the connect screen, the hub standing on a portal, the pause menu, settings,
credits, the upgrade choice, the roster after a death, a dungeon arrival, a
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
nothing, the test suite rewriting the player's settings, an invisible slider
@@ -89,8 +128,18 @@ against itself.
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` |
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
| Prompts name the keys actually bound | done | `Settings.short_label()` |
| 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) |
| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` |
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
@@ -111,7 +160,8 @@ terrain it does not have.
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. |
| `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
draws whatever it holds. The defence is what the server declines to send.
@@ -427,7 +477,7 @@ feet is a coin flip, the same burst with a second of notice is a question.
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the
view respects line of sight; a warning you cannot see is not a warning. See
view is covered by the darkness; a warning you cannot see is not a warning. See
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
+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
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
`.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 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().
static var bindings: Dictionary[String, Dictionary] = {}
@@ -64,6 +69,8 @@ static func load_and_apply() -> void:
if cfg.load(path) == OK:
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)
last_address = String(cfg.get_value("connection", "address", ""))
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
@@ -86,6 +93,9 @@ static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_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:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
@@ -93,6 +103,16 @@ static func save() -> void:
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 -------------------------------------------------------------------
## 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))
## 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:
match String(described.get("type", "")):
"key":
+119
View File
@@ -31,10 +31,26 @@ var stage_delay: int = 0
var boss_id: StringName = &""
## Generated layout, used to place enemies room by room.
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_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:
var inst := Instance.new()
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.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.tunnels = built["tunnels"]
inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"]
@@ -118,7 +135,109 @@ func _populate() -> void:
var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius):
continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
# After every room, so adding tunnel groups leaves each room's population
# exactly what that seed always produced.
_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:
+1
View File
@@ -70,6 +70,7 @@ func _show_menu() -> void:
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
+46 -5
View File
@@ -80,6 +80,12 @@ var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
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;
## the client never invents an entry.
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
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
if instance_kind == Protocol.InstanceKind.LOBBY:
# Straight to its own entrance, picked from the account id so a smoke
# run with several bots exercises every dungeon -- and interact pressed
# ONLY while standing on that one.
#
# It used to wander for 90 ticks in every 120 and then walk to its
# portal holding interact the whole way. The three entrances sit in a
# row, so a wander that ended near the middle one sent the bot bound for
# the Proving Grounds straight across the Choir Vault's ring with the
# key down, and in it went. Which dungeons opened depended on where a
# timing-dependent wander happened to stop: this was the smoke test's
# long-standing intermittent failure, and it only ever showed up as
# "the proving grounds opened" (or another entrance) going missing.
var target := bot_portal()
move = (target - predicted_pos).normalized() \
if predicted_pos.distance_to(target) > 6.0 else Vector2.ZERO
var here := portal_here()
if not here.is_empty() and (here["pos"] as Vector2).is_equal_approx(target):
buttons |= InputFrame.BTN_INTERACT
# Walk onto a portal instead of orbiting, or interact never lands.
# Which one is picked from the account id, so a smoke run with several
# bots exercises every dungeon rather than only the first.
move = (bot_portal() - predicted_pos).normalized()
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# 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:
if int(ev["peer"]) == my_peer:
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()
SimEvent.Type.PLAYER_RESPAWNED:
if int(ev["peer"]) == my_peer:
@@ -620,6 +647,20 @@ func loot_in_reach() -> Dictionary:
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
## nothing rather than looking broken.
func inventory_full() -> bool:
+80 -18
View File
@@ -7,6 +7,16 @@ extends RefCounted
## reproducible from its log line. It does NOT mean the client can rebuild the
## map -- see the note on build().
## Tunnel widths, in tiles, rolled per leg of every corridor.
##
## The floor is 2 because a one-tile tunnel in a bullet hell is a shooting
## gallery: there is no side to step to, so anything fired down it lands. Each
## leg of an L rolls its own width, so a tunnel can open up or pinch at its
## bend rather than every connection in the map being the same pipe.
const CORRIDOR_MIN_WIDTH := 2
const CORRIDOR_MAX_WIDTH := 4
## The single entry point. SERVER-SIDE ONLY.
##
## This used to be described as "both sides call it with the same three
@@ -140,13 +150,25 @@ 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,
# so every room is reachable by construction rather than by luck.
var widths := PackedInt32Array()
var legs: Array[Rect2i] = []
for i in range(1, rooms.size()):
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i]))
var from := _centre_of(rooms[i - 1])
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
(from.x + from.y) % 2 == 0, boss_room, legs))
if not rooms.is_empty():
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door)
# 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
# way ran its second leg straight down the arena's west wall -- 45% of
# generated arenas had a slit carved in an authored room. The arena is
# also kept out of every carve, so a wide tunnel stops at its wall
# rather than eating into it.
var outside := door + Vector2i(-1, 0)
widths.append_array(_carve_corridor(grid, rng,
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs))
# 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 - 1, door.y, MapGrid.Kind.FLOOR)
grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
_scatter_cover(grid, rooms, rng)
@@ -168,6 +190,14 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
# What every corridor leg was carved at. Not used by anything that
# builds a world -- it is here so a test can check the widths actually
# vary, which the tiles alone cannot say once tunnels overlap.
"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
# it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there.
@@ -181,23 +211,55 @@ static func _centre_of(r: Rect2i) -> Vector2i:
## L-shaped, with the corner order chosen by parity so corridors do not all
## bend the same way.
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void:
if (from.x + from.y) % 2 == 0:
_carve_h(grid, from.x, to.x, from.y)
_carve_v(grid, from.y, to.y, to.x)
## An L-shaped tunnel from [param from] to [param to], each leg its own width.
## Returns the two widths it rolled.
##
## [param horizontal_first] picks which way the L bends. [param keep_out] is
## never carved: it is the boss arena, which is authored, and keeps exactly the
## walls it was drawn with.
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
from: Vector2i, to: Vector2i, horizontal_first: bool,
keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array:
var first := 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)
# Each leg overshoots the bend by half the other's width, so the corner is
# carved square. Cosmetic, not structural: the two legs always overlap on
# 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
# notch rather than a turn.
legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out))
legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out))
return PackedInt32Array([first, second])
## One straight leg of [param width] tiles between two points sharing a row or
## a column, extended [param overshoot] tiles past both ends.
##
## Never touches the map's outermost ring: out-of-bounds reads as wall, and the
## 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.
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
overshoot: int, keep_out: Rect2i) -> Rect2i:
# 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.
var lo := -(width - 1) / 2
var hi := width / 2
var rect: Rect2i
if a.y == b.y:
var x0 := mini(a.x, b.x) - overshoot
var x1 := maxi(a.x, b.x) + overshoot
rect = Rect2i(x0, a.y + lo, x1 - x0 + 1, hi - lo + 1)
else:
_carve_v(grid, from.y, to.y, from.x)
_carve_h(grid, from.x, to.x, to.y)
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void:
for x in range(mini(x0, x1), maxi(x0, x1) + 1):
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
static func _carve_v(grid: MapGrid, y0: int, y1: int, x: int) -> void:
for y in range(mini(y0, y1), maxi(y0, y1) + 1):
var y0 := mini(a.y, b.y) - overshoot
var y1 := maxi(a.y, b.y) + overshoot
rect = Rect2i(a.x + lo, y0, hi - lo + 1, y1 - y0 + 1)
for y in range(maxi(rect.position.y, 1), mini(rect.end.y, grid.height - 1)):
for x in range(maxi(rect.position.x, 1), mini(rect.end.x, grid.width - 1)):
if keep_out.has_point(Vector2i(x, y)):
continue
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
return rect
## A few pillars and pits inside generated rooms, so open rooms still have
+17 -2
View File
@@ -54,6 +54,11 @@ var tiles := PackedByteArray()
## existing coordinate (spawn points, portal, boss placement) meaningful and
## avoids an all-positive coordinate space where "0" is a corner.
var origin := Vector2.ZERO
## Bumped by every write, so something that derives data from the tiles can tell
## whether it is stale without comparing them. The client's fog extracts wall
## corners from the map and would otherwise have to redo it every frame, or miss
## a chunk arriving.
var revision: int = 0
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
@@ -65,6 +70,7 @@ func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
height = maxi(h, 1)
tiles.resize(width * height)
tiles.fill(fill)
revision += 1
func in_bounds(tx: int, ty: int) -> bool:
@@ -82,6 +88,7 @@ func at(tx: int, ty: int) -> Kind:
func set_tile(tx: int, ty: int, kind: Kind) -> void:
if in_bounds(tx, ty):
tiles[ty * width + tx] = kind
revision += 1
func fill_rect(rect: Rect2i, kind: Kind) -> void:
@@ -111,6 +118,7 @@ func to_tile(world: Vector2) -> Vector2i:
## Centre the map on the world origin.
func centre_on_origin() -> void:
origin = -world_size() * 0.5
revision += 1
## World-space rectangle the map occupies.
@@ -175,8 +183,14 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
return out
## Bresenham-style sight test between two world points. Used for fog on the
## client and for aggro on the server, so it has to agree on both.
## Bresenham-style sight test between two world points, from tile to tile.
## Used for aggro on the server.
##
## It used to drive the client's fog as well, which is why the two once had to
## agree. The fog is exact geometry now (see Visibility), so this is the
## server's question alone -- and it is coarser than what the player sees: its
## diagonal steps pass between two tiles that touch only at a corner, which
## the fog treats as closed.
##
## [param include_end] is false here: the endpoints themselves never block, so
## standing in a doorway, or shooting at something embedded in a wall, still
@@ -303,3 +317,4 @@ func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
for tx in range(r.position.x, r.end.x):
tiles[ty * width + tx] = data[i]
i += 1
revision += 1
+17
View File
@@ -14,6 +14,7 @@ var _list: VBoxContainer
var _name_field: LineEdit
var _create_button: Button
var _status: Label
var _fallen: Label
var _title: Label
var _close_button: Button
var _known: Array[Dictionary] = []
@@ -51,6 +52,14 @@ func _ready() -> void:
_title.add_theme_font_size_override("font_size", 26)
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.add_theme_constant_override("separation", 4)
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
## 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:
_close_button.visible = can_close
+678 -188
View File
@@ -1,6 +1,13 @@
extends CanvasLayer
## 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.
##
## 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 BAR_W := 260.0
@@ -19,16 +26,87 @@ const SLOT_GAP := 8.0
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits.
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
## 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 _status: Label
var _hint: Label
var _respawn_button: Button
var _hit_flash: float = 0.0
var client: ClientRuntime = null
var _toasts: Array[Dictionary] = []
## The damage trail: where the health bar was before the last hit, held a
## moment and then drained down to where it is now.
var _hp_trail: float = 1.0
var _hp_last: float = 1.0
var _hp_hold: float = 0.0
## Experience just gained, shown beside the bar for a moment.
var _xp_last: int = -1
var _xp_gain: int = 0
var _xp_flash: float = 0.0
## The banner currently shown, or an empty title.
var _banner_title := ""
var _banner_sub := ""
var _banner_col := Color.WHITE
var _banner_born: float = -100.0
## The boss phase last announced, keyed like _boss_seen.
var _boss_phase_key := ""
var _boss_phase: int = -1
## Dark backing for every panel of text over the world. Built once.
var _panel_style: StyleBoxFlat = null
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
var _boss_seen := ""
## The level last drawn and whose it was, so a level-up can be announced the
## 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:
layer = 10
@@ -45,20 +123,6 @@ func _ready() -> void:
_canvas.draw.connect(_draw_hud)
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
# 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.
@@ -74,24 +138,101 @@ func _ready() -> void:
_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:
client = Net.client
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text()
_hint.text = _hint_text()
var now := _now()
_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()
_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:
var downed := client != null and not client.my_alive
_respawn_button.visible = downed
@@ -105,91 +246,424 @@ func _update_respawn_button() -> void:
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
func _status_text() -> String:
if client == null:
return "connecting..."
# Named rather than just "DUNGEON": there is more than one kind now, and
# knowing which one you are standing in is the whole point of having two.
var where := "LOBBY"
if client.instance_kind != Protocol.InstanceKind.LOBBY:
where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper()
var who := client.current_character()
var name_part := ""
if not who.is_empty():
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()]
# --- Text -------------------------------------------------------------------
## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain
## light text over it was readable on the dark parts of a tile and not on the
## light ones.
func _text(at: Vector2, s: String, size: int, colour: Color,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void:
var font := ThemeDB.fallback_font
_canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size,
Color(0.0, 0.0, 0.0, 0.7 * colour.a))
_canvas.draw_string(font, at, s, align, width, size, colour)
func _hint_text() -> String:
if client == null:
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 _text_width(s: String, size: int) -> float:
return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
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:
if client == null:
_text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM)
return
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
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))
# Under everything, so the edge of the screen colours without dimming any of
# the text on top of it.
_draw_vignette()
_draw_party_markers()
_draw_player_panel()
_draw_boss_bar()
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_banner()
_draw_cleared_countdown()
_draw_roster()
_draw_others()
_draw_inventory()
_draw_pickup_prompt()
_draw_level_up_notice()
var above := _draw_pickup_prompt()
_draw_toasts(above)
_draw_controls_hint()
_draw_debug_info()
if not client.my_alive:
# Centred on the canvas, which is only correct because _canvas actually
# 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))
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
## A dark, slightly translucent box behind a block of HUD text. The floor art is
## busy and mid-toned; text straight over it was readable on some tiles and not
## on others, and the shadow under each letter only went so far.
func _panel(rect: Rect2) -> void:
if _panel_style == null:
_panel_style = StyleBoxFlat.new()
_panel_style.bg_color = Color(0.05, 0.05, 0.09, 0.62)
_panel_style.border_color = Color(1.0, 1.0, 1.0, 0.07)
_panel_style.set_border_width_all(1)
_panel_style.set_corner_radius_all(4)
_canvas.draw_style_box(_panel_style, rect)
## Who you are, where you are, and how you are doing.
##
## Laid out in two passes -- measure the rows, draw the panel behind them, then
## draw them -- because the panel has to be under the text and its height
## depends on which rows are showing.
func _draw_player_panel() -> void:
var who := client.current_character()
var has_character := not who.is_empty()
var rows := 20.0 + HP_H + 6.0
if has_character:
rows += 22.0 + XP_H + 18.0
if client.upgrades_pending > 0:
rows += 20.0
_panel(Rect2(MARGIN - 10.0, MARGIN - 8.0, BAR_W + 20.0, rows + 12.0))
var x := MARGIN
var y := MARGIN
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
## 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.
func _draw_level_up_notice() -> void:
func _draw_level_up_notice(y: float) -> void:
if client.upgrades_pending <= 0:
return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
var where := " — see the quartermaster in the hub" \
var where := " -- quartermaster, in the hub" \
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(MARGIN, MARGIN + 74.0),
_text(Vector2(MARGIN, y + 12.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
12, Color(COL_GOLD, 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
@@ -212,133 +686,153 @@ func _draw_inventory() -> void:
# recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# The slot number, because the key that uses it is the only thing the
# player actually needs to know about a slot.
# An empty slot steps back. The pack's slot is a bright parchment
# square, and an empty one drew the eye as hard as a full one -- the
# blank box was the most prominent thing in the row.
var slot_tint := Color.WHITE if def != null else Color(1.0, 1.0, 1.0, 0.45)
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false, slot_tint)
# The key that uses the slot, because that is the only thing the player
# actually needs to know about it -- and it is whatever they bound.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
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:
continue
var icon := Art.item_icon(item)
var size := icon.size * Art.SCALE
_canvas.draw_texture_rect_region(Art.TILESET,
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(origin.x, origin.y + SLOT + 15.0),
"1-%d use shift+1-%d drop E pick up" % [count, count],
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
_text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11,
Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total)
## 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
## decision.
func _draw_pickup_prompt() -> void:
## decision. Returns the y the next thing up should stack from.
##
## 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()
if near.is_empty() or not client.my_alive:
return
if near.is_empty():
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 def := Items.get_def(item)
if def == null:
return
return above
var full := client.inventory_full()
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
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(_canvas.size.x * 0.5 - 150.0,
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
_centred(above, text, 14, tint)
return above - 22.0
## A thin bar under health: progress toward the next level, and the level
## itself. Drawn from the server's numbers, never recomputed locally.
func _draw_xp_bar(at: Vector2) -> void:
if client.current_character().is_empty():
## The message feed, newest nearest the inventory, each line fading out.
func _draw_toasts(bottom: float) -> void:
var now := _now()
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
# 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 := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.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:
var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY
var alpha := 1.0
if not in_hub:
var age := _now() - _instance_since
alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0)
if alpha <= 0.0:
return
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
_canvas.draw_string(ThemeDB.fallback_font,
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))
_text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12,
Color(COL_DIM, 0.9 * alpha))
## Who else is online, and whether they are already in a dungeon. Only useful in
## the hub, which is the one place you are deciding whether to go in.
func _draw_roster() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
func _draw_debug_info() -> void:
if not debug_info:
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 4.0
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
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
_text(Vector2(0.0, _canvas.size.y - 14.0),
"instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()],
12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN)
func _draw_boss_bar() -> void:
var b := client.boss_state()
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.
## Red at the edges of the screen: briefly on every hit, and pulsing while
## health is low.
##
## [param back] is no longer a colour -- the frame sprite is the background --
## but the signature keeps it so callers read the same and a future flat-bar
## fallback has somewhere to go.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
## At the edges, not across the middle. This used to tint the whole screen red
## on a hit, which in a bullet hell dims the bullets at exactly the moment you
## are among them -- the feedback made the next hit more likely.
func _draw_vignette() -> void:
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)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
# The frame scales with the bar's height, so the inset has to as well: a
# fixed three pixels left a ten-pixel bar four pixels of track and six of
# white frame, and it read as an empty white bar whatever it held.
var inset := maxf(1.0, roundf(size.y * 0.15))
var track := Rect2(rect.position + Vector2(inset, inset), rect.size - Vector2(inset, inset) * 2.0)
_canvas.draw_rect(track, COL_TRACK)
var full := track.size.x
if trail > frac:
# Darker than the fill, not paler. The health number sits on top of
# this, and a pale trail was the one background it vanished against.
_canvas.draw_rect(Rect2(track.position, Vector2(full * clampf(trail, 0.0, 1.0), track.size.y)),
Color(fill.darkened(0.5), 0.95))
var inner := Rect2(track.position, Vector2(full * clampf(frac, 0.0, 1.0), track.size.y))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
@@ -366,7 +860,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)),
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 host_requested(port: int)
signal settings_requested
## The licences two of the asset packs require, one click from the first
## screen rather than two screens deep in settings.
signal credits_requested
var _address: LineEdit
var _port: LineEdit
var _name: LineEdit
var _status: Label
var _join_button: Button
var _host_button: Button
## True while a connection attempt is in flight; see set_busy().
var _busy: bool = false
## The status as set, and whether it is progress rather than a complaint.
var _status_base := ""
var _working: bool = false
func _ready() -> void:
@@ -29,82 +36,194 @@ func _ready() -> void:
# A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual
# offset math to get wrong.
# Behind everything, so it is first: a child draws over its earlier
# siblings, and the panel has to be in front of the bullets.
add_child(preload("res://src/ui/menu_backdrop.gd").new())
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
# 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()
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)
center.add_child(panel)
frame.add_child(panel)
var title := Label.new()
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)
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)
_address = _field(panel, "server address", GameOpts.host_address)
_port = _field(panel, "port", str(GameOpts.port))
# No name field. There used to be one, and for a person it did nothing:
# characters are named on the roster screen, and this only ever fed
# 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.text = "Join"
# Enter works from either field, and from the button itself, which has focus
# when the screen opens. Saying so costs one word.
_join_button.text = "Join (Enter)"
_join_button.pressed.connect(_on_join)
panel.add_child(_join_button)
_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)
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()
settings.text = "Settings"
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
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()
quit.text = "Quit"
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
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.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)
# 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:
var l := Label.new()
l.text = label
l.add_theme_font_size_override("font_size", 12)
parent.add_child(l)
var e := LineEdit.new()
e.text = value
parent.add_child(e)
return e
_validate()
# So Enter joins straight away for a returning player, whose address is
# already filled in from last time.
_join_button.call_deferred("grab_focus")
## Whether what is typed could possibly connect, said before the button is
## pressed rather than after. A non-number in the port used to go through as
## port 0 and come back as a connection error that did not say why.
func _validate() -> void:
var port_text := _port.text.strip_edges()
var port := int(port_text) if port_text.is_valid_int() else 0
var port_ok := port >= 1 and port <= 65535
var address_ok := not _address.text.strip_edges().is_empty()
_join_button.disabled = _busy or not (port_ok and address_ok)
_host_button.disabled = _busy or not port_ok
if not port_ok:
set_status("the port is a number from 1 to 65535")
elif not address_ok:
set_status("which server? type its address")
elif _status.text.begins_with("the port") or _status.text.begins_with("which server"):
set_status("")
func _gap(height: float) -> Control:
var c := Control.new()
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:
_status_base = 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
## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void:
_join_button.disabled = busy
_host_button.disabled = busy
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
GameOpts.player_name = clean
_busy = busy
_validate()
func _on_join() -> void:
_commit_name()
join_requested.emit(_address.text.strip_edges(), int(_port.text))
if _join_button.disabled:
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:
_commit_name()
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 _summary: Label
var _status: Label
var _close: Button
func _ready() -> void:
@@ -84,11 +85,10 @@ func _ready() -> void:
_summary.custom_minimum_size = Vector2(740.0, 0.0)
panel.add_child(_summary)
var close := Button.new()
close.text = "Close (E)"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
panel.add_child(close)
_close = Button.new()
_close.custom_minimum_size = Vector2(160.0, 34.0)
_close.pressed.connect(func() -> void: closed.emit())
panel.add_child(_close)
func _rule() -> Control:
@@ -106,6 +106,9 @@ func set_status(text: String) -> void:
## screen can never show an offer that has already been spent.
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
_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:
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
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
## 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] = [
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
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
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()
counts.resize(_layers.size())
counts.fill(0)
var cull := eye.is_finite()
for i in pool.high_water:
if pool.alive[i] == 0:
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 n := counts[kind]
if n >= SimConfig.MAX_BULLETS:
@@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void:
+ float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE)
var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE
tint.a *= alpha
_layers[kind].multimesh.set_instance_color(n, tint)
counts[kind] = n + 1
for kind in _layers.size():
_layers[kind].multimesh.visible_instance_count = counts[kind]
+16
View File
@@ -20,6 +20,7 @@ const COL_PIT := Color(0.4, 0.5, 1.0, 0.5)
const COL_AGGRO := Color(1.0, 0.5, 0.2, 0.25)
const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9)
const COL_LOS := Color(0.5, 1.0, 0.8, 0.18)
const COL_SIGHT := Color(1.0, 0.95, 0.55, 0.55) # where each fog ray stopped
## Terrain: one outline per tile, coloured by which of the three flags it sets.
@@ -82,3 +83,18 @@ static func draw_bullets(c: CanvasItem, pool: BulletPool) -> void:
## A ring at the fog radius, so "why can I not see that" has a visible answer.
static func draw_ranges(c: CanvasItem, pos: Vector2) -> void:
c.draw_arc(pos, SimConfig.FOG_VIEW_RADIUS, 0.0, TAU, 64, COL_LOS, 1.0)
## The outline the fog's rays traced: where each one stopped. Drawn as the hit
## points rather than the soft edge, because what this answers is "why is THAT
## dark", and the answer is always which ray hit what.
static func draw_sight(c: CanvasItem, sight: Visibility) -> void:
var n := sight.angles.size()
if n < 2:
return
var outline := PackedVector2Array()
outline.resize(n + 1)
for i in n:
outline[i] = sight.eye + sight.dirs[i] * sight.hits[i]
outline[n] = outline[0]
c.draw_polyline(outline, COL_SIGHT, 1.0)
+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
# plays because a server event arrived, never because the client
# guessed a pickup succeeded.
_bound.item_picked_up.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0))
_bound.item_used.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0))
_bound.item_picked_up.connect(func(i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0)
_announce_item("+ %s", i))
_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.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades()
@@ -97,10 +101,19 @@ func _process(_delta: float) -> void:
_roster_open = false
characters.visible = forced or _roster_open
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():
_upgrades_open = false
upgrades.visible = _upgrades_open
_follow_camera()
hud.sight = world_view.sight
hud.debug_info = world_view.debug_hitboxes
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
@@ -147,6 +160,24 @@ func _on_respawn_pressed() -> void:
_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:
hud.flash_hit()
sfx.play(Art.SFX_PLAYER_HIT, -4.0)
+348
View File
@@ -0,0 +1,348 @@
class_name Visibility
extends RefCounted
## What the local player can see, as geometry rather than a verdict per tile.
##
## The fog this replaces asked one question per tile -- can I see its centre? --
## and drew or skipped the whole tile on the answer. Every shadow was a
## staircase, a wall beside you vanished whenever its centre happened to sit
## behind its neighbour, and tiles snapped in and out as you walked. This casts
## rays instead and lights the space they reach, so a shadow edge runs straight
## through a tile and half of one can be lit.
##
## Client-side and rendering-only, like the fog it replaces. It decides what is
## DRAWN; what the client is SENT is the server's business -- the actor interest
## radius and map streaming -- and nothing in here can widen that.
## Rays at fixed angles, on top of the ones aimed at corners. Corners alone give
## an exact outline wherever there is geometry to hit; the sweep covers open
## ground with nothing in it, and keeps the angle between neighbouring rays
## small enough that the straight chord joining them stays inside the fade.
const SWEEP_RAYS := 180
## How far either side of a corner its two rays are aimed, in radians: one grazes
## past and one hits. That pair is what makes the shadow leaving a corner a
## straight line rather than a smear across the gap to the next ray.
const CORNER_NUDGE := 0.0004
## How deep into a wall its lit face goes, measured straight in from the face.
##
## Without any, the wall you are looking at is exactly where the dark begins and
## is never drawn -- floor would end in a void. Measured perpendicular to the
## face rather than along the ray, because along the ray a wall you are looking
## ALONG gets a sliver: a fixed distance travelled at a grazing angle barely gets
## under the surface, so the wall right beside you was the one you could not
## see -- the very thing this replaced the old fog to fix.
##
## Under a tile, and light also stops the moment a ray leaves the wall it is
## in, so neither the far side of a one-tile wall nor the space round a corner
## is ever lit by it.
const WALL_DEPTH := 26.0
## The last part of that depth fades out instead of ending in a hard line.
const FEATHER := 8.0
## Soft edge at the limit of sight, in place of the old hard circle.
const FADE := 64.0
## One entry per ray, sorted by angle in [-PI, PI).
var angles := PackedFloat32Array()
var dirs := PackedVector2Array()
## Where each ray stopped: at the first tile that blocks sight, or at the radius.
var hits := PackedFloat32Array()
## Along each ray: fully lit up to [member starts], dark from [member ends].
var starts := PackedFloat32Array()
var ends := PackedFloat32Array()
var eye := Vector2.ZERO
var radius: float = 0.0
var _sweep := PackedFloat32Array()
## Every corner of sight-blocking geometry near the eye, in world space. Derived
## from the tiles alone, so it is only rebuilt when the map changes or the eye
## moves into another tile.
var _corners := PackedVector2Array()
var _corner_map: int = 0
var _corner_revision: int = -1
var _corner_tile := Vector2i(-99999, -99999)
var _corner_radius: float = -1.0
func _init() -> void:
_sweep.resize(SWEEP_RAYS)
for i in SWEEP_RAYS:
_sweep[i] = -PI + TAU * float(i) / float(SWEEP_RAYS)
## Recompute everything for an eye at [param at] seeing [param view_radius].
func compute(map: MapGrid, at: Vector2, view_radius: float) -> void:
eye = at
radius = view_radius
_refresh_corners(map)
var want := PackedFloat32Array(_sweep)
var reach := view_radius + MapGrid.TILE * 1.5
var reach_sq := reach * reach
for c in _corners:
var d := c - at
var dsq := d.length_squared()
if dsq > reach_sq or dsq < 0.0001:
continue
var a := d.angle()
want.append(wrapf(a - CORNER_NUDGE, -PI, PI))
want.append(wrapf(a + CORNER_NUDGE, -PI, PI))
want.sort()
var n := want.size()
angles = want
dirs.resize(n)
hits.resize(n)
starts.resize(n)
ends.resize(n)
var fade_from := view_radius - FADE
for i in n:
var dir := Vector2.from_angle(want[i])
dirs[i] = dir
var traced := trace(map, at, dir, view_radius, WALL_DEPTH, FEATHER)
hits[i] = traced.x
var s := fade_from
var e := view_radius
if traced.x < view_radius:
s = minf(traced.y, fade_from)
e = minf(traced.z, view_radius)
starts[i] = s
ends[i] = maxf(e, s + 0.5)
## How lit [param p] is, from 1 (fully) to 0 (dark). Matches what the shadow mesh
## draws: the outline between two neighbouring rays is the straight chord
## joining their ends, exactly as the triangles are laid out.
func light_at(p: Vector2) -> float:
var n := angles.size()
if n == 0:
return 1.0
var d := p - eye
var dist := d.length()
if dist < 0.001:
return 1.0
var u := d / dist
var a := wrapf(d.angle(), -PI, PI)
var j := angles.bsearch(a) % n
var i := (j - 1 + n) % n
var s := _along(u, dirs[i] * starts[i], dirs[j] * starts[j])
var e := _along(u, dirs[i] * ends[i], dirs[j] * ends[j])
if dist <= s:
return 1.0
if dist >= e:
return 0.0
return 1.0 - (dist - s) / maxf(e - s, 0.001)
## Darkness covering everything not lit, out to [param far], as one triangle
## list: { "points", "colors", "indices" }. Three vertices per ray -- where the
## fade starts (clear), where it is complete (dark) and the far edge (dark) --
## and four triangles per pair of neighbouring rays.
##
## One batch rather than a polygon per segment: there are several hundred
## segments a frame, and a draw call each was the expensive part.
func shadow_mesh(far: float, colour: Color) -> Dictionary:
var n := angles.size()
var points := PackedVector2Array()
var colors := PackedColorArray()
var indices := PackedInt32Array()
points.resize(n * 3)
colors.resize(n * 3)
indices.resize(n * 12)
var clear := Color(colour, 0.0)
var dark := Color(colour, 1.0)
for i in n:
var dir := dirs[i]
points[i * 3] = eye + dir * starts[i]
points[i * 3 + 1] = eye + dir * ends[i]
points[i * 3 + 2] = eye + dir * far
colors[i * 3] = clear
colors[i * 3 + 1] = dark
colors[i * 3 + 2] = dark
for i in n:
var j := (i + 1) % n
var si := i * 3
var sj := j * 3
var k := i * 12
# The fade: clear at the inner edge, dark at the outer.
indices[k] = si
indices[k + 1] = sj
indices[k + 2] = sj + 1
indices[k + 3] = si
indices[k + 4] = sj + 1
indices[k + 5] = si + 1
# Solid dark from there out to the far edge.
indices[k + 6] = si + 1
indices[k + 7] = sj + 1
indices[k + 8] = sj + 2
indices[k + 9] = si + 1
indices[k + 10] = sj + 2
indices[k + 11] = si + 2
return {"points": points, "colors": colors, "indices": indices}
## Distance along unit ray [param u] to the chord from [param a] to [param b],
## both relative to the eye. Falls back to the nearer end when the ray runs
## parallel to the chord, which only happens for a degenerate pair.
static func _along(u: Vector2, a: Vector2, b: Vector2) -> float:
var ab := b - a
var denom := u.cross(ab)
if absf(denom) < 0.000001:
return minf(a.length(), b.length())
return a.cross(b) / denom
## Distance from [param from] along unit [param dir] to the first tile that
## blocks sight, or [param max_dist] if there is none that close.
static func cast(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float) -> float:
return trace(map, from, dir, max_dist, 0.0, 0.0).x
## Follow one ray: where it hits, and how far its light carries into the wall.
## Returns (hit, fade_start, fade_end), all distances along the ray.
##
## Grid traversal (Amanatides and Woo), so it visits exactly the tiles the ray
## passes through and lands on the true edge of the one that stops it -- which
## is the whole difference from the old fog. A ray passing exactly through a
## corner shared by two blocking tiles is stopped there: two walls touching at
## a point leave no gap to see through.
##
## Past the hit, the light runs on to [param depth] measured straight in from the
## face it entered -- so along the ray that is depth / cos(angle of incidence),
## long for a grazing ray and short for a head-on one -- and the last
## [param feather] of it fades. It stops early, wherever it is, the moment the
## ray steps back out into a tile that does not block sight.
static func trace(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float,
depth: float, feather: float) -> Vector3:
var tile := MapGrid.TILE
var px := (from.x - map.origin.x) / tile
var py := (from.y - map.origin.y) / tile
var tx := floori(px)
var ty := floori(py)
var limit := max_dist / tile
var w := map.width
var h := map.height
var tiles := map.tiles
var sight: Array = MapGrid.BLOCKS_SIGHT
var step_x := 0
var step_y := 0
var delta_x := INF
var delta_y := INF
var next_x := INF
var next_y := INF
if dir.x > 0.0:
step_x = 1
delta_x = 1.0 / dir.x
next_x = (float(tx + 1) - px) * delta_x
elif dir.x < 0.0:
step_x = -1
delta_x = -1.0 / dir.x
next_x = (px - float(tx)) * delta_x
if dir.y > 0.0:
step_y = 1
delta_y = 1.0 / dir.y
next_y = (float(ty + 1) - py) * delta_y
elif dir.y < 0.0:
step_y = -1
delta_y = -1.0 / dir.y
next_y = (py - float(ty)) * delta_y
# Phase 1 walks open ground to the first blocker; phase 2 walks on through
# the blocker while there is light left to carry.
var hit := -1.0
var stop := limit
var fade := limit
while true:
var t: float
var crossed_x := false
var diagonal := false
if next_x < next_y - 0.000000001:
t = next_x
tx += step_x
next_x += delta_x
crossed_x = true
elif next_y < next_x - 0.000000001:
t = next_y
ty += step_y
next_y += delta_y
else:
t = next_x
diagonal = true
if t > stop:
break
if diagonal:
var side_x := tx + step_x
var side_y := ty + step_y
var across_x := side_x < 0 or ty < 0 or side_x >= w or ty >= h \
or bool(sight[tiles[ty * w + side_x]])
var across_y := tx < 0 or side_y < 0 or tx >= w or side_y >= h \
or bool(sight[tiles[side_y * w + tx]])
tx = side_x
ty = side_y
next_x += delta_x
next_y += delta_y
crossed_x = true
if hit < 0.0:
if across_x and across_y:
hit = t
stop = minf(limit, t + depth / tile / maxf(absf(dir.x), absf(dir.y)))
fade = minf(stop, t + (depth - feather) / tile / maxf(absf(dir.x), absf(dir.y)))
continue
elif not (across_x and across_y):
stop = t
break
# Off the map reads as wall, the same rule MapGrid.at() keeps.
var blocked := tx < 0 or ty < 0 or tx >= w or ty >= h \
or bool(sight[tiles[ty * w + tx]])
if hit < 0.0:
if blocked:
hit = t
# Incidence against the face just crossed: an x step crosses a
# vertical face, whose normal is along x.
var c := maxf(absf(dir.x) if crossed_x else absf(dir.y), 0.000001)
stop = minf(limit, t + depth / tile / c)
fade = minf(stop, t + (depth - feather) / tile / c)
elif not blocked:
# Back out into the open: the light ends at this face, wherever the
# depth had reached. This is what keeps it from rounding a corner.
stop = t
break
if hit < 0.0:
return Vector3(max_dist, max_dist, max_dist)
stop = minf(stop, limit)
fade = minf(fade, stop)
return Vector3(hit * tile, fade * tile, stop * tile)
## Rebuild the corner list if the map changed or the eye moved to another tile.
##
## A corner is any tile vertex where blocking and open ground meet other than
## along a straight edge: one or three of the four surrounding tiles blocking, or
## two blocking diagonally. Those are the only points a shadow edge can leave
## from, which is why aiming rays at them is enough to make every edge exact.
func _refresh_corners(map: MapGrid) -> void:
var t := map.to_tile(eye)
var id := map.get_instance_id()
if id == _corner_map and map.revision == _corner_revision \
and t == _corner_tile and radius == _corner_radius:
return
_corner_map = id
_corner_revision = map.revision
_corner_tile = t
_corner_radius = radius
_corners.clear()
var span := ceili(radius / MapGrid.TILE) + 2
var x0 := maxi(t.x - span, 0)
var x1 := mini(t.x + span + 1, map.width)
var y0 := maxi(t.y - span, 0)
var y1 := mini(t.y + span + 1, map.height)
for vy in range(y0, y1 + 1):
for vx in range(x0, x1 + 1):
var a := map.blocks_sight(vx - 1, vy - 1)
var b := map.blocks_sight(vx, vy - 1)
var c := map.blocks_sight(vx - 1, vy)
var d := map.blocks_sight(vx, vy)
var count := int(a) + int(b) + int(c) + int(d)
# Two blocking and a == d means the pair is diagonal (a+d or b+c).
if count == 1 or count == 3 or (count == 2 and a == d):
_corners.append(map.origin + Vector2(float(vx), float(vy)) * MapGrid.TILE)
+1
View File
@@ -0,0 +1 @@
uid://bikom644gux4e
+75 -31
View File
@@ -33,6 +33,17 @@ var client: ClientRuntime = null
var _anim_time: float = 0.0
## F1 overlay: draws what the simulation collides against, over what is drawn.
var debug_hitboxes: bool = false
## What the local player can see this frame. See [Visibility]. Read by the HUD
## 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
## is indistinguishable from the undrawn void beyond it and there is no seam.
var _dark: Color = ProjectSettings.get_setting(
"rendering/environment/defaults/default_clear_color", Color.BLACK)
## How far past the edge of sight the darkness is drawn. It has to cover every
## tile and sprite that is drawn at all -- a tile touching the circle of sight
## reaches 45 units past it, a boss sprite further.
const DARK_REACH := 128.0
func _unhandled_input(event: InputEvent) -> void:
@@ -46,32 +57,62 @@ func _process(delta: float) -> void:
_anim_time += delta
client = Net.client
if client != null:
bullets.render_pool(client.world.pool)
bullets.render_pool(client.world.pool, client.predicted_pos,
SimConfig.FOG_VIEW_RADIUS)
queue_redraw()
## Draw order is the fog's whole mechanism, so it is worth spelling out.
##
## Everything that fog may hide is drawn first, unconditionally within sight
## range; then the darkness goes over it. That is what lets a tile -- or an enemy
## stepping round a corner -- be half lit: nothing is decided per object, the
## shadow simply covers whatever is behind it. Telegraphs go on top of the
## 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
## bullets are a child node, so they are drawn after all of this, over the dark
## -- a shot coming out of the black is a shot you can still dodge. Out past the
## edge of sight they fade away, but only as far as is safe: see
## BulletRenderer.visibility().
func _draw() -> void:
if client == null:
return
var map: MapGrid = client.world.map
if map != null:
sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
_draw_terrain()
_draw_portals()
_draw_upgrade_npc()
for l in client.ground_loot():
if _visible(l["pos"]):
if _in_view(l["pos"]):
_draw_loot(l)
for e in client.enemies():
if _visible(e["pos"]):
if _in_view(e["pos"]):
_draw_enemy(e)
_draw_boss()
_draw_telegraphs()
for p in client.remote_players():
if _visible(p["pos"]):
if _in_view(p["pos"]):
_draw_remote_player(p)
if map != null:
_draw_darkness()
_draw_telegraphs()
_draw_local_player()
if debug_hitboxes:
_draw_debug()
## Everything the player cannot see, as one batch of triangles.
##
## Straight to the RenderingServer rather than through draw_polygon: a CanvasItem
## draw call is a thin wrapper around exactly this, but it takes one polygon,
## 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.
func _draw_darkness() -> void:
var mesh := sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
mesh["indices"], mesh["points"], mesh["colors"])
## Overlay showing what actually collides, on top of what is drawn. Deliberately
## ignores fog: the point is to check that art and simulation line up, and
## hiding half the evidence would defeat that.
@@ -91,6 +132,7 @@ func _draw_debug() -> void:
DebugDraw.draw_player(self, p["pos"], p["aim"])
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
DebugDraw.draw_ranges(self, client.predicted_pos)
DebugDraw.draw_sight(self, sight)
## Aggro radius by visual index. Read from the content definitions rather than
@@ -103,15 +145,16 @@ func _aggro_for(visual: int) -> float:
return 0.0
## Terrain, drawn only where the player can currently see it.
## Terrain within sight range, all of it. Whether it can actually be SEEN is
## decided afterwards, by the darkness drawn over it -- see _draw.
##
## Hard fog: nothing outside line of sight is drawn, including ground already
## walked over. Note what this is and is not -- it is a rendering rule, not a
## secrecy mechanism. The client holds real tile data because it simulates
## bullets, so a modified client could draw what is hidden here. What it cannot
## do is see terrain the server never streamed (only a radius around the
## player) or actors the server filtered out, which is where the line actually
## sits.
## Still hard fog: nothing is remembered, and ground you walked over is dark
## again once it is behind you. Note what this is and is not -- a rendering
## rule, not a secrecy mechanism. The client holds real tile data because it
## simulates bullets, so a modified client could draw what is hidden here. What
## it cannot do is see terrain the server never streamed (only a radius around
## the player) or actors the server filtered out, which is where the line
## actually sits.
func _draw_terrain() -> void:
var map: MapGrid = client.world.map
if map == null:
@@ -121,15 +164,18 @@ func _draw_terrain() -> void:
var lo := map.to_tile(eye - Vector2(view, view))
var hi := map.to_tile(eye + Vector2(view, view))
var view_sq := view * view
var half := Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5
for ty in range(lo.y, hi.y + 1):
for tx in range(lo.x, hi.x + 1):
var kind := map.at(tx, ty)
if kind == MapGrid.Kind.UNKNOWN:
continue
var centre := map.tile_centre(tx, ty)
if eye.distance_squared_to(centre) > view_sq:
continue
if not map.has_line_of_sight(eye, centre):
# The tile's nearest point, not its centre: a tile the circle of
# sight cuts through is partly inside it, and skipping it would put
# a notch in the edge.
var near := eye.clamp(centre - half, centre + half)
if eye.distance_squared_to(near) > view_sq:
continue
_draw_tile(kind, centre, tx, ty)
@@ -158,16 +204,14 @@ func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void:
draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
## Actors are only drawn where the player can actually see them. The server
## already withholds distant ones; this hides the ones behind walls.
func _visible(at: Vector2) -> bool:
var map: MapGrid = client.world.map
if map == null:
return true
var eye := client.predicted_pos
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
return false
return map.has_line_of_sight(eye, at)
## Whether an actor is near enough to be worth drawing. Distance only: whether
## it is behind a wall is the darkness's job, not this one's, and that is what
## lets something stepping round a corner be half visible instead of popping in
## whole the moment its centre clears the edge. The margin lets a large sprite
## standing just past the circle of sight show the part of it that is inside.
func _in_view(at: Vector2) -> bool:
var reach := SimConfig.FOG_VIEW_RADIUS + 48.0
return client.predicted_pos.distance_squared_to(at) <= reach * reach
## One ring per entrance, coloured and labelled by the dungeon it opens. The
@@ -192,7 +236,7 @@ func _draw_upgrade_npc() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY:
return
var at := client.upgrade_npc
if not _visible(at):
if not _in_view(at):
return
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
@@ -206,7 +250,7 @@ func _draw_upgrade_npc() -> void:
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
if client.at_upgrade_npc():
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))
# A pending choice pulses, so it is visible from across the hub.
if waiting > 0:
@@ -256,8 +300,8 @@ func _draw_enemy(e: Dictionary) -> void:
## Attacks that have been announced but not yet landed.
##
## Drawn through fog and through walls, deliberately. Everything else in this
## view respects line of sight, but a warning you cannot see is not a warning --
## Drawn over the darkness and through walls, deliberately. Everything else in
## this view is covered by it, but a warning you cannot see is not a warning --
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
## exist is to make the strike a decision. The marker is inside the boss arena
## you are already standing in, so this hides nothing a player could not walk
@@ -278,7 +322,7 @@ func _draw_telegraphs() -> void:
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty() or not _visible(b["pos"]):
if b.is_empty() or not _in_view(b["pos"]):
return
var pos: Vector2 = b["pos"]
var visual := client.boss_def.visual if client.boss_def != null else 0
+96
View File
@@ -138,3 +138,99 @@ func test_interacting_away_from_the_portal_does_nothing() -> void:
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
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
+82
View File
@@ -83,3 +83,85 @@ func test_rooms_do_not_overlap() -> void:
for j in range(i + 1, rooms.size()):
assert_false((rooms[i] as Rect2i).intersects(rooms[j]),
"rooms %d and %d overlap" % [i, j])
# --- Tunnels ----------------------------------------------------------------
func _inside_any(rooms: Array, x: int, y: int) -> bool:
for r: Rect2i in rooms:
if r.has_point(Vector2i(x, y)):
return true
return false
## Whether (x, y) is a corner of at least one 2x2 square of open floor. A
## passage one tile wide has no such square anywhere along it.
func _in_open_square(g: MapGrid, x: int, y: int) -> bool:
for dy in [-1, 0]:
for dx in [-1, 0]:
var ok := true
for oy in 2:
for ox in 2:
if g.at(x + dx + ox, y + dy + oy) != MapGrid.Kind.FLOOR:
ok = false
if ok:
return true
return false
func test_no_tunnel_is_a_single_file_squeeze() -> void:
# A one-tile corridor in a bullet hell is a shooting gallery: there is no
# side to step to. Every corridor tile has to sit in open ground at least
# two wide in some direction. Rooms and the authored arena are left out --
# the arena's door is drawn one tile wide on purpose.
for s in range(1, 40):
var m := MapGen.generate(s * 7919, 1 + (s % 5))
var g: MapGrid = m["grid"]
var rooms: Array = m["rooms"]
var arena: Rect2i = m["boss_room"]
var squeezes := 0
for y in g.height:
for x in g.width:
if g.at(x, y) != MapGrid.Kind.FLOOR:
continue
if _inside_any(rooms, x, y) or arena.has_point(Vector2i(x, y)):
continue
if not _in_open_square(g, x, y):
squeezes += 1
assert_eq(squeezes, 0,
"seed %d: %d corridor tile(s) in a passage one tile wide" % [s, squeezes])
func test_tunnel_widths_vary_and_never_go_below_two() -> void:
var seen := {}
for s in range(1, 40):
var widths: PackedInt32Array = MapGen.generate(s * 7919, 1 + (s % 5))["corridor_widths"]
assert_false(widths.is_empty(), "seed %d carved no tunnels at all" % s)
for w in widths:
assert_between(w, MapGen.CORRIDOR_MIN_WIDTH, MapGen.CORRIDOR_MAX_WIDTH)
seen[w] = true
assert_eq(seen.size(), MapGen.CORRIDOR_MAX_WIDTH - MapGen.CORRIDOR_MIN_WIDTH + 1,
"every width in the range turns up somewhere; got %s" % [seen.keys()])
func test_the_arena_keeps_every_wall_it_was_drawn_with() -> void:
# The corridor to the door used to aim at the door tile itself, which sits
# ON the arena's west wall. An L that bent the other way ran its second leg
# down that wall column, and 45% of generated arenas had a slit carved in
# an authored room. A wide tunnel makes the same mistake wider.
for arena in [&"warden_hall", &"choir_vault"]:
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
for s in range(1, 60):
var m := MapGen.generate(s * 7919, 1 + (s % 5), arena)
var g: MapGrid = m["grid"]
var room: Rect2i = m["boss_room"]
var carved := 0
for y in stamp.size():
for x in stamp[y].length():
if stamp[y][x] != "#":
continue
if g.at(room.position.x + x, room.position.y + y) != MapGrid.Kind.WALL:
carved += 1
assert_eq(carved, 0,
"seed %d %s: %d of the arena's own wall tiles were carved open"
% [s * 7919, arena, carved])
+86
View File
@@ -254,3 +254,89 @@ func test_saving_writes_to_the_configured_path() -> void:
var cfg := ConfigFile.new()
assert_eq(cfg.load(_scratch), OK)
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")
+305
View File
@@ -0,0 +1,305 @@
extends GutTest
## The fog's geometry. Nothing here can look at the screen -- screenshot.tscn is
## for that -- but every property that makes the fog fair or unfair is a
## question about which points are lit, and those can be asked directly.
const T := MapGrid.TILE
const VIEW := 460.0
var sight: Visibility
func before_each() -> void:
sight = Visibility.new()
## An open floor, walled at the border, with the origin at a tile corner so
## world positions are easy to reason about.
func _room(w: int = 40, h: int = 40) -> MapGrid:
var g := MapGrid.new(w, h, MapGrid.Kind.WALL)
g.fill_rect(Rect2i(1, 1, w - 2, h - 2), MapGrid.Kind.FLOOR)
return g
func _centre(tx: int, ty: int) -> Vector2:
return Vector2(float(tx) + 0.5, float(ty) + 0.5) * T
func test_open_ground_is_lit_and_beyond_sight_is_not() -> void:
var g := _room()
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(eye + Vector2(100.0, 0.0)), 1.0)
assert_eq(sight.light_at(eye + Vector2(0.0, -(VIEW - Visibility.FADE - 1.0))), 1.0,
"lit all the way to where the fade begins")
assert_eq(sight.light_at(eye + Vector2(VIEW + 5.0, 0.0)), 0.0,
"nothing past the radius")
var mid := sight.light_at(eye + Vector2(VIEW - Visibility.FADE * 0.5, 0.0))
assert_between(mid, 0.2, 0.8, "the edge of sight fades instead of cutting")
func test_a_wall_hides_what_is_behind_it() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(28, 20)), 0.0)
assert_eq(sight.light_at(_centre(22, 20)), 1.0, "and not what is in front")
## The feature asked for: a shadow edge that runs THROUGH a tile, so part of it
## is lit and part is not. The old fog could only light or hide a tile whole.
func test_one_tile_can_be_part_lit() -> void:
var g := _room()
g.set_tile(22, 20, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20) + Vector2(0.0, 7.0)
sight.compute(g, eye, VIEW)
# The pillar's shadow is the wedge between its top-left and bottom-left
# corners as seen from the eye: -25.6 to +10.6 degrees. Tile (25, 21) spans
# about +2.9 to +15.9, so the lower edge of the wedge cuts straight across
# it. Sample a grid inside it and require both answers.
var lit := 0
var dark := 0
for sy in 8:
for sx in 8:
var p := Vector2(25.0 * T + 2.0 + float(sx) * 4.0, 21.0 * T + 2.0 + float(sy) * 4.0)
var l := sight.light_at(p)
if l >= 0.99:
lit += 1
elif l <= 0.01:
dark += 1
assert_gt(lit, 0, "some of the tile is in the light")
assert_gt(dark, 0, "and some of the same tile is in shadow")
func test_a_shadow_leaves_a_corner_as_a_straight_line() -> void:
var g := _room()
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
# The pillar's lower-right corner, and the line from the eye through it.
# orthogonal() turns towards the pillar's other silhouette corner, which is
# the shadowed side; its negation points away from the pillar, into light.
var corner := Vector2(23.0, 19.0) * T
var away := (corner - eye).normalized()
var side := -away.orthogonal()
for dist in [140.0, 220.0, 300.0]:
var on_line: Vector2 = eye + away * dist
assert_eq(sight.light_at(on_line + side * 3.0), 1.0,
"%d units out, just on the open side of the edge" % dist)
assert_eq(sight.light_at(on_line - side * 3.0), 0.0,
"%d units out, just on the shadowed side" % dist)
## "A bit less strict": the face of the wall in front of you is lit, rather than
## being exactly where the dark begins and so never drawn at all.
func test_the_wall_you_are_facing_is_visible() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
var face := 24.0 * T
# A fixed depth, not one computed from the constant: a test that samples at
# WALL_DEPTH - 2 passes with the depth at zero, sampling the floor in front.
assert_eq(sight.light_at(Vector2(face + 12.0, eye.y)), 1.0,
"the first stretch into the wall is fully lit")
assert_lt(sight.light_at(Vector2(face + Visibility.WALL_DEPTH + 1.0, eye.y)),
0.01, "and it is dark again before the far side")
## The regression the first version of this shipped with, caught by a
## screenshot: light carried a fixed distance ALONG each ray, so a wall seen at a
## grazing angle -- the one right beside you in a tunnel -- got a sliver and
## looked missing. Depth is measured straight in from the face now.
func test_a_wall_you_are_looking_along_is_lit_its_whole_length() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := Vector2(24.0 * T - 40.0, 20.5 * T) # just over a tile from the face
sight.compute(g, eye, VIEW)
var dim := 0
for along in [0.0, 60.0, 120.0, 200.0, 280.0, 350.0]:
for side in [-1.0, 1.0]:
var p := Vector2(24.0 * T + 10.0, eye.y + along * side)
if sight.light_at(p) < 0.99:
dim += 1
assert_eq(dim, 0, "ten units into the wall, all the way along it, is lit")
func test_light_does_not_round_a_corner() -> void:
# A wall ending in a corner, seen at a grazing angle. A ray that enters the
# wall near its end would, if its light ran on regardless, come out through
# the end face into the open space round the corner. It must stop there.
var g := _room()
for ty in range(1, 21):
g.set_tile(24, ty, MapGrid.Kind.WALL) # ends at row 20
var eye := Vector2(24.0 * T - 40.0, 6.0 * T)
sight.compute(g, eye, VIEW)
# Round the end of the wall: past its far face, below its last tile.
for p in [Vector2(25.0 * T + 6.0, 21.0 * T + 6.0),
Vector2(26.0 * T, 21.0 * T + 20.0),
Vector2(25.0 * T + 20.0, 22.0 * T)]:
assert_eq(sight.light_at(p), 0.0, "round the corner at %s is dark" % p)
func test_light_never_reaches_the_far_side_of_a_one_tile_wall() -> void:
assert_lt(Visibility.WALL_DEPTH, T,
"the lit band inside a wall must stop inside a single tile of it")
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
# Just past the wall, at every height the eye can see the wall at -- steep
# and grazing alike.
var leaks := 0
for ty in range(2, 38):
for fx in [0.5, 4.0, 12.0]:
if sight.light_at(Vector2(25.0 * T + fx, float(ty) * T + 16.0)) > 0.0:
leaks += 1
assert_eq(leaks, 0, "nothing beyond a one-tile wall may be lit")
func test_a_staircase_wall_hides_its_far_side() -> void:
# A wall one tile thick that runs diagonally, as steps. A band of light
# measured straight in from the face it entered could leave a step through
# its other side -- which is the far side of the wall.
var g := _room()
for i in 14:
g.set_tile(18 + i, 6 + i, MapGrid.Kind.WALL)
g.set_tile(19 + i, 6 + i, MapGrid.Kind.WALL)
var eye := _centre(16, 16) # below-left of the stairs
sight.compute(g, eye, VIEW)
var leaks := 0
for i in range(1, 12):
# The tile immediately above-right of each step: the far side.
var far := _centre(20 + i, 5 + i)
if g.at(20 + i, 5 + i) == MapGrid.Kind.FLOOR and sight.light_at(far) > 0.0:
leaks += 1
assert_eq(leaks, 0, "the far side of a stepped wall stays dark")
func test_two_tiles_touching_at_a_corner_leave_no_gap_to_see_through() -> void:
var g := _room()
g.set_tile(22, 21, MapGrid.Kind.PILLAR)
g.set_tile(21, 22, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20) + Vector2(16.0, 16.0) # the shared corner's diagonal
sight.compute(g, eye, VIEW)
# Straight along the diagonal, through the point where the two pillars meet.
var through := eye + Vector2(1.0, 1.0).normalized() * 120.0
assert_eq(sight.light_at(through), 0.0,
"two walls meeting at a point are closed, not a crack")
# Vector2 is single precision and the cast is double, so "exactly" is to a
# thousandth of a unit.
assert_almost_eq(Visibility.cast(g, eye, Vector2(1.0, 1.0).normalized(), VIEW),
Vector2(22.0 * T, 22.0 * T).distance_to(eye), 0.001,
"the ray stops exactly at the shared corner")
func test_a_ray_stops_exactly_on_the_face_it_hits() -> void:
var g := _room()
g.set_tile(25, 20, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
assert_almost_eq(Visibility.cast(g, eye, Vector2.RIGHT, VIEW), 25.0 * T - eye.x, 0.0001)
assert_eq(Visibility.cast(g, eye, Vector2.UP, 100.0), 100.0,
"nothing within reach means the full reach")
func test_only_what_blocks_sight_casts_a_shadow() -> void:
var g := _room()
var eye := _centre(20, 20)
for kind in [MapGrid.Kind.PIT, MapGrid.Kind.BARRICADE]:
g.set_tile(23, 20, kind)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(26, 20)), 1.0,
"you can see across a %s" % MapGrid.Kind.keys()[kind])
g.set_tile(23, 20, MapGrid.Kind.PILLAR)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(26, 20)), 0.0, "but not past a pillar")
func test_a_streamed_wall_is_noticed() -> void:
# The corner list is cached between frames, so a chunk arriving with a new
# wall in it has to invalidate it. A stale list does not lose the shadow --
# rays read the tiles live -- it loses the EDGE: the new pillar's corners get
# no rays of their own, and its silhouette is drawn only as finely as the
# sweep, two degrees at a time. So the check is the edge, not the shadow.
var g := _room()
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
sight.compute(g, eye, VIEW)
var corner := Vector2(23.0, 19.0) * T
var away := (corner - eye).normalized()
var side := -away.orthogonal()
var on_line := eye + away * 300.0
assert_eq(sight.light_at(on_line + side * 2.0), 1.0,
"the new pillar's edge is as sharp as one that was always there")
assert_eq(sight.light_at(on_line - side * 2.0), 0.0)
func test_light_falls_off_monotonically_along_every_ray() -> void:
var g := _room()
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
g.set_tile(17, 23, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
for i in 64:
var dir := Vector2.from_angle(TAU * float(i) / 64.0)
var last := 1.0
var r := 4.0
while r < VIEW + 20.0:
var l := sight.light_at(eye + dir * r)
assert_lte(l, last + 0.0001, "light never comes back along a ray")
last = l
r += 6.0
func test_the_shadow_mesh_is_well_formed() -> void:
var g := _room()
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
sight.compute(g, _centre(20, 20), VIEW)
var mesh := sight.shadow_mesh(VIEW + 64.0, Color(0.05, 0.05, 0.08))
var points: PackedVector2Array = mesh["points"]
var colors: PackedColorArray = mesh["colors"]
var indices: PackedInt32Array = mesh["indices"]
assert_eq(points.size(), sight.angles.size() * 3)
assert_eq(colors.size(), points.size(), "a colour per vertex")
assert_eq(indices.size() % 3, 0, "whole triangles")
for k in indices:
assert_between(k, 0, points.size() - 1)
for c in colors:
assert_true(c.a == 0.0 or c.a == 1.0, "clear at the fade's start, dark beyond")
func test_neighbouring_rays_are_never_far_apart() -> void:
# The outline between two rays is a straight chord. If the gap were wide, the
# chord would cut inside the circle of sight on open ground, and a wedge of
# darkness would appear where nothing is in the way.
var g := _room()
sight.compute(g, _centre(20, 20), VIEW)
var widest := 0.0
var n := sight.angles.size()
for i in n:
var gap := wrapf(sight.angles[(i + 1) % n] - sight.angles[i], 0.0, TAU)
widest = maxf(widest, gap)
var dip := (VIEW - Visibility.FADE) * (1.0 - cos(widest * 0.5))
assert_lt(dip, 1.0,
"the chord dips %.2f units into open ground; under a pixel is invisible" % dip)
# And the behaviour that follows from it: halfway between two rays, right at
# the start of the fade, open ground is still fully lit.
var mid := (sight.angles[0] + sight.angles[1]) * 0.5
assert_eq(sight.light_at(_centre(20, 20)
+ Vector2.from_angle(mid) * (VIEW - Visibility.FADE - 1.0)), 1.0)
func test_a_real_dungeon_is_cheap_enough_to_do_every_frame() -> void:
# Not a benchmark -- the machine running the suite varies -- but a guard
# against the ray count exploding: corners are the variable part, and a
# map with thousands of them in view would be a regression worth knowing.
var inst := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
sight.compute(inst.world.map, inst.world.spawn_point, VIEW)
assert_lt(sight.angles.size(), 1200,
"%d rays for one frame of a depth-1 dungeon" % sight.angles.size())
+1
View File
@@ -0,0 +1 @@
uid://beivpa4pxiu3f
+206 -14
View File
@@ -14,24 +14,42 @@ extends Node
## touching anything under src/ui/ and open the files.
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
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const OPEN_ROSTER := 370
const AT_ROSTER := 400
const DONE := 410
const AT_MENU := 200
const ENTER_GAME := 204
const AT_GAME := 370
const OPEN_MENU := 380
const AT_INGAME_MENU := 410
const OPEN_SETTINGS := 420
const AT_SETTINGS := 450
const OPEN_CREDITS := 460
const AT_CREDITS := 490
const OPEN_UPGRADES := 500
const AT_UPGRADES := 530
const OPEN_ROSTER := 540
const AT_ROSTER := 570
const ENTER_DUNGEON := 580
const AT_DUNGEON := 650
const TO_TUNNEL := 660
const AT_TUNNEL := 720
const AT_TUNNEL_DEBUG := 730
const TO_BOSS := 740
const AT_BOSS := 810
const ESCAPE_START := 830
## A safety net only: the run normally ends the moment the escape shot is taken.
const DONE := 1710
var _frame: int = 0
var _left_shot: bool = false
var _game: Node = null
var _menu: Control = null
var _dir: String = "user://"
@@ -39,11 +57,18 @@ var _dir: String = "user://"
func _ready() -> void:
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
Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty():
_dir = from_env
# Made rather than assumed. The example in CLAUDE.md points at a fresh
# /tmp/shots, and a missing directory fails every capture one at a time
# with an error that names the file rather than the folder.
DirAccess.make_dir_recursive_absolute(_dir)
get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu)
@@ -69,16 +94,46 @@ func _process(_delta: float) -> void:
# Held until the shot is taken; one frame of it is not enough.
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
_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
# it every frame, so one assignment lasts exactly one frame.
if _frame > OPEN_ROSTER and _frame <= AT_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:
AT_MENU:
_grab("menu")
ENTER_GAME:
_menu.queue_free()
Net.host(27455)
_seed_character()
Net.start_local_client()
_game = GAME.instantiate()
add_child(_game)
@@ -118,6 +173,29 @@ func _process(_delta: float) -> void:
_show_the_roster()
AT_ROSTER:
_grab("roster")
ENTER_DUNGEON:
# 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.
_game.set("_roster_open", false)
_game.get_node("CharacterSelect").set_fallen("")
Net.server._send_to_dungeon(Net.LOCAL_PEER)
AT_DUNGEON:
_grab("dungeon")
TO_TUNNEL:
_stand_in_a_tunnel()
AT_TUNNEL:
_grab("tunnel")
# The same view with F1's overlay: the outline is where each fog ray
# stopped, which is the quickest way to tell a rendering mistake from
# a geometry one.
_game.get_node("WorldView").debug_hitboxes = true
AT_TUNNEL_DEBUG:
_grab("tunnel_debug")
TO_BOSS:
_game.get_node("WorldView").debug_hitboxes = false
_stage_a_boss_fight()
AT_BOSS:
_grab("boss_fight")
DONE:
Net.shutdown()
get_tree().quit(0)
@@ -144,6 +222,9 @@ func _show_the_roster() -> void:
p.pos = away
_game.set("_upgrades_open", false)
_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] = [
_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)),
@@ -152,6 +233,117 @@ func _show_the_roster() -> void:
_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:
## somewhere with walls close on two sides and a corner in view, which is where
## the old fog looked worst.
func _stand_in_a_tunnel() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null or inst.rooms.is_empty():
return
var map := inst.world.map
var start: Rect2i = inst.rooms[0]
var spawn := map.to_tile(inst.world.spawn_point)
var best := Vector2i(-1, -1)
var best_d := INF
for ty in map.height:
for tx in map.width:
if map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
var in_room := inst.boss_room.has_point(here)
for r: Rect2i in inst.rooms:
if r.grow(1).has_point(here):
in_room = true
if in_room:
continue
# A couple of tiles clear of the first room, so the tunnel's walls
# are on screen and not just the doorway.
if start.grow(3).has_point(here):
continue
var d := float((here - spawn).length_squared())
if d < best_d:
best_d = d
best = here
if best.x < 0:
return
var at := map.tile_centre(best.x, best.y)
Net.client.predicted_pos = at
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
if p != null:
p.pos = at
## One roster row in the shape decode_characters produces.
func _roster_entry(who: String, level: int, ceiling: int, current: int,
tint: Color) -> Dictionary: