Compare commits

..
6 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
22 changed files with 1228 additions and 146 deletions

No files matched your search

+16 -2
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 # 551 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
```
@@ -209,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
@@ -254,6 +262,12 @@ ticks in milliseconds with no SceneTree.
`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`
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.
+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
+20 -2
View File
@@ -144,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. |
@@ -172,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. |
| `src/meta/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
@@ -190,13 +193,28 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/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. |
+107 -2
View File
@@ -108,8 +108,21 @@ stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps
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, as they always were —
a shot out of the black is still one you can dodge.
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.
@@ -530,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
+58 -16
View File
@@ -23,6 +23,23 @@ a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
the four stages that followed: characters and permadeath, inventory and loot,
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,19 +52,33 @@ candidates, none of which the user has chosen — **ask before starting one.**
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **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` has failed twice, **both times inside the pre-push hook**, and
then passed on every direct run after: four in a row the first time, eight the
second (six in a background loop plus two more, one of them the hook itself).
Neither failure's logs survived, because the hook runs smoke without `KEEP`.
The first suspicion was machine load, but the second failure had nothing else
running. What is different about the hook is its context — stdin is git's
pipe of ref lines, which every bot inherits. Unconfirmed. Push with
`KEEP=1 git push` so the logs of the next one survive.
`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.
---
@@ -58,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 |
@@ -67,10 +98,15 @@ What "everything passes" currently means. Numbers move; the shape does not.
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
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
@@ -94,6 +130,11 @@ against itself.
| 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 |
@@ -119,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.
+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
+21
View File
@@ -135,6 +135,8 @@ func _populate() -> void:
var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius):
continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
# After every room, so adding tunnel groups leaves each room's population
# exactly what that seed always produced.
@@ -189,6 +191,25 @@ func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
## Whether an enemy placed at [param at] would be awake the moment the party
## arrives: within its aggro range of the entrance, with nothing in the way --
## the same two questions SimWorld._aggro_target asks.
##
## Room 0 is left empty for exactly this reason, but that only ever covered room
## 0. A neighbouring room in sight of the entrance woke on arrival in one run in
## eight, and once tunnels were widened -- more open ground, more lines of sight
## -- in three runs in eight. Skipping these removes about one enemy in sixteen;
## clearing everything within aggro range instead would have removed nearly a
## third, which is a different dungeon rather than a fixed one.
##
## The rng draws for the skipped enemy have already been made, so every other
## enemy in the dungeon is exactly where that seed always put it.
func _would_see_the_entrance(def: EnemyDef, at: Vector2) -> bool:
if at.distance_to(world.spawn_point) > def.aggro_range:
return false
return world.map.has_line_of_sight(at, world.spawn_point)
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
## room by a tile, so a group is never standing in a doorway, and outside the
## boss arena.
+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
+19 -6
View File
@@ -274,12 +274,25 @@ func _bot_input() -> InputFrame:
# Downed bots ask for the hub, same as a player would -- otherwise a bot
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto a portal instead of orbiting, or interact never lands.
# Which one is picked from the account id, so a smoke run with several
# bots exercises every dungeon rather than only the first.
move = (bot_portal() - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.LOBBY:
# Straight to its own entrance, picked from the account id so a smoke
# run with several bots exercises every dungeon -- and interact pressed
# ONLY while standing on that one.
#
# It used to wander for 90 ticks in every 120 and then walk to its
# portal holding interact the whole way. The three entrances sit in a
# row, so a wander that ended near the middle one sent the bot bound for
# the Proving Grounds straight across the Choir Vault's ring with the
# key down, and in it went. Which dungeons opened depended on where a
# timing-dependent wander happened to stop: this was the smoke test's
# long-standing intermittent failure, and it only ever showed up as
# "the proving grounds opened" (or another entrance) going missing.
var target := bot_portal()
move = (target - predicted_pos).normalized() \
if predicted_pos.distance_to(target) > 6.0 else Vector2.ZERO
var here := portal_here()
if not here.is_empty() and (here["pos"] as Vector2).is_equal_approx(target):
buttons |= InputFrame.BTN_INTERACT
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item
+276 -64
View File
@@ -39,6 +39,23 @@ const LOW_HEALTH := 0.35
const HINT_HOLD := 6.0
const HINT_FADE := 1.5
## Health bar, taller than the others because it carries its own numbers.
const HP_H := 20.0
## Experience is a thin strip under health: it matters between fights, not in
## them.
const XP_H := 10.0
## How long the damage trail holds before draining, and how fast it drains (as
## a share of the bar per second). Long enough to read what the hit took.
const TRAIL_HOLD := 0.45
const TRAIL_DRAIN := 0.9
## Centre-screen banners: how long one stays, and its fade at each end.
const BANNER_LIFE := 2.4
const BANNER_FADE := 0.5
## The inside of every bar. The pack's bar sprite is light all the way through,
## so on its own an EMPTY bar was the brightest thing on screen and a player on
## a fifth of their health saw a bar that read as nearly full.
const COL_TRACK := Color(0.07, 0.07, 0.11)
const COL_TEXT := Color(0.86, 0.9, 0.97)
const COL_DIM := Color(0.58, 0.63, 0.74)
const COL_HEALTH := Color(0.35, 0.9, 0.6)
@@ -61,6 +78,25 @@ var _hit_flash: float = 0.0
var client: ClientRuntime = null
var _toasts: Array[Dictionary] = []
## The damage trail: where the health bar was before the last hit, held a
## moment and then drained down to where it is now.
var _hp_trail: float = 1.0
var _hp_last: float = 1.0
var _hp_hold: float = 0.0
## Experience just gained, shown beside the bar for a moment.
var _xp_last: int = -1
var _xp_gain: int = 0
var _xp_flash: float = 0.0
## The banner currently shown, or an empty title.
var _banner_title := ""
var _banner_sub := ""
var _banner_col := Color.WHITE
var _banner_born: float = -100.0
## The boss phase last announced, keyed like _boss_seen.
var _boss_phase_key := ""
var _boss_phase: int = -1
## Dark backing for every panel of text over the world. Built once.
var _panel_style: StyleBoxFlat = null
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
var _boss_seen := ""
## The level last drawn and whose it was, so a level-up can be announced the
@@ -113,6 +149,8 @@ func _process(delta: float) -> void:
_instance_seen = client.instance_id
_instance_since = now
_watch_level()
_watch_health(delta)
_watch_experience(delta)
_update_respawn_button()
_canvas.queue_redraw()
@@ -134,6 +172,43 @@ func flash_hit() -> void:
_hit_flash = 1.0
## A line across the middle of the screen for a moment: a level gained, a boss
## changing phase. Things that change what you should do next, which is why
## they are not in the message feed with the potions.
func banner(title: String, sub: String = "", colour: Color = COL_GOLD) -> void:
_banner_title = title
_banner_sub = sub
_banner_col = colour
_banner_born = _now()
## Keep the damage trail: when health drops, the trail stays where it was for
## TRAIL_HOLD and then drains to meet the bar. Healing moves both at once.
func _watch_health(delta: float) -> void:
var frac := clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
if frac < _hp_last - 0.0001:
_hp_trail = maxf(_hp_trail, _hp_last)
_hp_hold = TRAIL_HOLD
if frac >= _hp_trail:
_hp_trail = frac
if _hp_hold > 0.0:
_hp_hold -= delta
else:
_hp_trail = move_toward(_hp_trail, frac, TRAIL_DRAIN * delta)
_hp_last = frac
func _watch_experience(delta: float) -> void:
_xp_flash = maxf(_xp_flash - delta, 0.0)
var xp := client.my_total_xp
if _xp_last >= 0 and xp > _xp_last and String(_seen_character) != "":
# Summed while the flash is up, so a burst of kills reads as one total
# rather than as a number that flickers.
_xp_gain = (_xp_gain if _xp_flash > 0.0 else 0) + (xp - _xp_last)
_xp_flash = 1.4
_xp_last = xp
## Announce a level the moment the snapshot's experience crosses one. Watched
## rather than signalled because experience rides the snapshot, and the first
## sight of a character -- logging in, swapping -- must not read as a level-up.
@@ -146,11 +221,15 @@ func _watch_level() -> void:
if String(who["id"]) != _seen_character:
_seen_character = String(who["id"])
_seen_level = level
# A new character is not a hit, and its experience is not a gain.
_hp_trail = clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
_hp_last = _hp_trail
_xp_last = client.my_total_xp
return
if level > _seen_level:
var where := "" if client.instance_kind == Protocol.InstanceKind.LOBBY \
else " -- a choice waits at the quartermaster"
notify("LEVEL %d%s" % [level, where], COL_GOLD)
banner("LEVEL %d" % level,
"a choice waits at the quartermaster" if level < Progression.MAX_LEVEL
else "the highest there is", COL_GOLD)
_seen_level = level
@@ -243,11 +322,11 @@ func _draw_hud() -> void:
# Under everything, so the edge of the screen colours without dimming any of
# the text on top of it.
_draw_vignette()
_draw_party_markers()
var y := _draw_player_panel()
_draw_level_up_notice(y)
_draw_player_panel()
_draw_boss_bar()
_draw_arriving()
_draw_banner()
_draw_cleared_countdown()
_draw_others()
_draw_inventory()
@@ -260,13 +339,37 @@ func _draw_hud() -> void:
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
## Who you are, where you are, and how you are doing. Returns the y below the
## last thing drawn, so whatever comes next stacks rather than overlaps.
func _draw_player_panel() -> float:
## A dark, slightly translucent box behind a block of HUD text. The floor art is
## busy and mid-toned; text straight over it was readable on some tiles and not
## on others, and the shadow under each letter only went so far.
func _panel(rect: Rect2) -> void:
if _panel_style == null:
_panel_style = StyleBoxFlat.new()
_panel_style.bg_color = Color(0.05, 0.05, 0.09, 0.62)
_panel_style.border_color = Color(1.0, 1.0, 1.0, 0.07)
_panel_style.set_border_width_all(1)
_panel_style.set_corner_radius_all(4)
_canvas.draw_style_box(_panel_style, rect)
## Who you are, where you are, and how you are doing.
##
## Laid out in two passes -- measure the rows, draw the panel behind them, then
## draw them -- because the panel has to be under the text and its height
## depends on which rows are showing.
func _draw_player_panel() -> void:
var who := client.current_character()
var has_character := not who.is_empty()
var rows := 20.0 + HP_H + 6.0
if has_character:
rows += 22.0 + XP_H + 18.0
if client.upgrades_pending > 0:
rows += 20.0
_panel(Rect2(MARGIN - 10.0, MARGIN - 8.0, BAR_W + 20.0, rows + 12.0))
var x := MARGIN
var y := MARGIN
var who := client.current_character()
if not who.is_empty():
if has_character:
var name_text := String(who["name"])
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
@@ -288,41 +391,44 @@ func _draw_player_panel() -> float:
# Red when low, not a blend toward it: green-to-red through the middle is
# brown, which reads as a colour rather than as a warning.
var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
_bar(Vector2(x, y), frac, hp_tint)
_text(Vector2(x + BAR_W + 10.0, y + 13.0),
"%d / %d" % [client.my_hp, client.my_max_hp], 12, hp_tint)
y += BAR_H + 4.0
_bar(Vector2(x, y), frac, hp_tint, _hp_trail, Vector2(BAR_W, HP_H))
# Inside the bar, where the eye already is. Light on the dark track and on
# every fill colour, which a number beside the bar did not need to be.
_text(Vector2(x, y + HP_H - 5.0), "%d / %d" % [client.my_hp, client.my_max_hp],
12, COL_TEXT, HORIZONTAL_ALIGNMENT_CENTER, BAR_W)
y += HP_H + 6.0
if _draw_xp_bar(Vector2(x, y)):
y += BAR_H + 4.0
if has_character:
_draw_xp_bar(Vector2(x, y))
y += XP_H + 18.0
if client.my_escaping:
_bar(Vector2(x, y), client.my_escape, Color(0.5, 0.85, 1.0))
_text(Vector2(x + BAR_W + 10.0, y + 13.0), "ESCAPING", 12,
Color(0.5, 0.85, 1.0))
y += BAR_H + 4.0
return y
# No escape row here: the channel is a banner in the middle of the screen,
# where you are looking, and a second bar for the same second was noise.
_draw_level_up_notice(y)
## A thin bar under health: progress toward the next level, and the level
## itself. Drawn from the server's numbers, never recomputed locally. Returns
## whether it drew, since a player at the roster screen has no character.
func _draw_xp_bar(at: Vector2) -> bool:
if client.current_character().is_empty():
return false
## A thin bar under health: progress toward the next level. Drawn from the
## server's numbers, never recomputed locally.
func _draw_xp_bar(at: Vector2) -> void:
# Derived from the snapshot's live experience total rather than the roster's
# copy, which only arrives when the set of characters changes.
var level := Progression.level_for_xp(client.my_total_xp)
var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp)
var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
_bar(at, progress, tint)
# Brighter for a moment after a kill, so experience arriving is something
# you notice rather than something you check.
if _xp_flash > 0.0:
tint = tint.lerp(Color.WHITE, 0.45 * clampf(_xp_flash, 0.0, 1.0))
_bar(at, progress, tint, -1.0, Vector2(BAR_W, XP_H))
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_text(at + Vector2(BAR_W + 10.0, 13.0), text, 12, tint)
return true
_text(at + Vector2(0.0, XP_H + 13.0), text, 11, Color(tint, 0.9))
if _xp_flash > 0.0 and _xp_gain > 0:
_text(at + Vector2(0.0, XP_H + 13.0), "+%d xp" % _xp_gain, 11,
Color(COL_GOLD, clampf(_xp_flash, 0.0, 1.0)), HORIZONTAL_ALIGNMENT_RIGHT, BAR_W)
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
@@ -332,12 +438,12 @@ func _draw_level_up_notice(y: float) -> void:
if client.upgrades_pending <= 0:
return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
var where := " -- see the quartermaster in the hub" \
var where := " -- quartermaster, in the hub" \
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_text(Vector2(MARGIN, y + 16.0),
_text(Vector2(MARGIN, y + 12.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where],
13, Color(COL_GOLD, 0.6 + 0.4 * pulse))
12, Color(COL_GOLD, 0.6 + 0.4 * pulse))
## The boss's health, once you have seen the boss.
@@ -353,20 +459,12 @@ func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
if _boss_seen != seen_key:
if sight != null and sight.light_at(b["pos"]) <= 0.05:
return
_boss_seen = seen_key
if not track_boss(b):
return
var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
var rect := Rect2(pos, Vector2(BOSS_BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((BOSS_BAR_W - 6.0) * frac, BAR_H - 6.0))
if inner.size.x > 0.5:
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
Color(0.95, 0.35, 0.45))
_panel(Rect2(pos.x - 10.0, MARGIN - 8.0, BOSS_BAR_W + 20.0, BAR_H + 36.0))
_bar(pos, frac, Color(0.95, 0.35, 0.45), -1.0, Vector2(BOSS_BAR_W, BAR_H))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
@@ -377,16 +475,67 @@ func _draw_boss_bar() -> void:
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
## Arrival protection, as a banner rather than a line tucked beside the health
## bar: it is the one moment the rules are different, and both halves of it --
## you cannot be hurt, and you cannot shoot -- change what you should do.
func _draw_arriving() -> void:
if not client.my_spawn_grace:
return
## Whether the boss bar should be up, and the place a phase change is noticed.
## Returns false until the fog has lit the boss once; true from then on, for the
## rest of that fight.
##
## A phase change is announced because it is the moment the patterns you just
## learned stop being the ones coming at you. Not the phase the boss is in when
## first seen -- that is not a change, only where it started.
func track_boss(b: Dictionary) -> bool:
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
if _boss_seen != seen_key:
if sight != null and sight.light_at(b["pos"]) <= 0.05:
return false
_boss_seen = seen_key
var phase_index := int(b["phase"])
if _boss_phase_key != seen_key:
_boss_phase_key = seen_key
_boss_phase = phase_index
elif phase_index > _boss_phase:
_boss_phase = phase_index
var phase_name := ""
if client.boss_def != null and phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
banner(phase_name.to_upper(),
client.boss_def.display_name if client.boss_def != null else "",
Color(1.0, 0.55, 0.6))
return true
## One message across the middle of the screen, chosen by what matters most
## right now: leaving, then arriving, then whatever was announced last.
##
## Arrival protection is a banner rather than a line beside the health bar
## because it is the one moment the rules are different, and both halves of
## it -- you cannot be hurt, and you cannot shoot -- change what you should do.
func _draw_banner() -> void:
var y := _canvas.size.y * 0.27
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
var y := _canvas.size.y * 0.28
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
if client.my_escaping:
var blue := Color(0.55, 0.85, 1.0)
_centred(y, "LEAVING", 24, blue)
var w := 220.0
var at := Vector2((_canvas.size.x - w) * 0.5, y + 12.0)
_bar(at, client.my_escape, blue, -1.0, Vector2(w, 12.0))
_centred(y + 42.0, "keep holding %s" % key("emergency_escape"), 12, Color(blue, 0.8))
return
if client.my_spawn_grace:
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
return
if _banner_title.is_empty():
return
var age := _now() - _banner_born
if age > BANNER_LIFE:
return
var alpha := clampf(minf(age / 0.15, (BANNER_LIFE - age) / BANNER_FADE), 0.0, 1.0)
# A short settle on arrival rather than a pop, so it catches the eye without
# jolting it away from the bullets.
var settle := maxf(0.0, 0.2 - age) * 40.0
_centred(y - settle, _banner_title, 28, Color(_banner_col, alpha))
if not _banner_sub.is_empty():
_centred(y + 24.0 - settle, _banner_sub, 13, Color(_banner_col, 0.8 * alpha))
## Shown after the boss dies, so the victory lap has a visible clock on it.
@@ -414,6 +563,7 @@ func _draw_others() -> void:
func _draw_online() -> void:
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 28.0 + 18.0 * client.roster.size()))
_text(Vector2(x, y), "ONLINE", 13, COL_DIM)
y += 20.0
for entry in client.roster:
@@ -444,6 +594,7 @@ func _draw_party() -> void:
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 26.0 + 20.0 * members.size()))
_text(Vector2(x, y), "PARTY", 13, COL_DIM)
y += 10.0
var w := 140.0
@@ -474,6 +625,47 @@ func _draw_party() -> void:
y += 20.0
## A marker at the edge of sight for each party member you cannot see, pointing
## the way to them.
##
## The fog hides players behind walls and beyond sight like anything else, and
## in a dungeon the most common question about the rest of your party is where
## they went. Allies only, from data the client already holds -- a party member
## outside the interest radius is not in the snapshot, and gets no marker.
func _draw_party_markers() -> void:
if client.instance_kind == Protocol.InstanceKind.LOBBY or not client.my_alive:
return
var eye := client.predicted_pos
var centre := eye + client.camera_offset
var ring := SimConfig.FOG_VIEW_RADIUS - 40.0
var names := {}
for entry in client.roster:
names[int(entry["peer"])] = String(entry["name"])
var bounds := Rect2(Vector2(MARGIN, MARGIN), _canvas.size - Vector2(MARGIN, MARGIN) * 2.0)
for p in client.remote_players():
var pos: Vector2 = p["pos"]
var offset := pos - eye
var dist := offset.length()
if dist < 1.0:
continue
var lit := sight == null or sight.light_at(pos) > 0.3
if dist < ring and lit:
continue
var dir := offset / dist
var at := centre + dir * minf(ring, dist)
at = at.clamp(bounds.position, bounds.end)
var flags := int(p["flags"])
var col: Color = p["colour"]
if (flags & Protocol.F_ALIVE) == 0:
col = COL_DANGER
var tip := at + dir * 9.0
var side := dir.orthogonal() * 6.0
_canvas.draw_colored_polygon(PackedVector2Array([
tip, at - dir * 4.0 + side, at - dir * 4.0 - side]), Color(col, 0.9))
_text(at - dir * 12.0 + Vector2(-60.0, 4.0), String(names.get(int(p["peer"]), "")),
11, Color(col, 0.85), HORIZONTAL_ALIGNMENT_CENTER, 120.0)
## Four slots, always on screen. Deliberately not a panel you open: an
## inventory you have to stop and read is a menu, and a menu is a death in a
## game where the floor is bullets. Everything drawn here comes from the
@@ -494,7 +686,11 @@ func _draw_inventory() -> void:
# recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# An empty slot steps back. The pack's slot is a bright parchment
# square, and an empty one drew the eye as hard as a full one -- the
# blank box was the most prominent thing in the row.
var slot_tint := Color.WHITE if def != null else Color(1.0, 1.0, 1.0, 0.45)
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false, slot_tint)
# The key that uses the slot, because that is the only thing the player
# actually needs to know about it -- and it is whatever they bound.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
@@ -613,14 +809,30 @@ func _draw_vignette() -> void:
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
func _bar(pos: Vector2, frac: float, fill: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
## A bar in the pack's art: the frame stretched horizontally, a dark track
## inside it, and the fill tinted on top.
##
## [param trail], when it is above [param frac], is drawn first in a pale
## version of the fill -- the part of the bar the last hit took, before it
## drains away. That is what makes a hit readable as an amount rather than as a
## bar that is suddenly shorter.
func _bar(pos: Vector2, frac: float, fill: Color, trail: float = -1.0,
size: Vector2 = Vector2(BAR_W, BAR_H)) -> void:
var rect := Rect2(pos, size)
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
# The frame scales with the bar's height, so the inset has to as well: a
# fixed three pixels left a ten-pixel bar four pixels of track and six of
# white frame, and it read as an empty white bar whatever it held.
var inset := maxf(1.0, roundf(size.y * 0.15))
var track := Rect2(rect.position + Vector2(inset, inset), rect.size - Vector2(inset, inset) * 2.0)
_canvas.draw_rect(track, COL_TRACK)
var full := track.size.x
if trail > frac:
# Darker than the fill, not paler. The health number sits on top of
# this, and a pale trail was the one background it vanished against.
_canvas.draw_rect(Rect2(track.position, Vector2(full * clampf(trail, 0.0, 1.0), track.size.y)),
Color(fill.darkened(0.5), 0.95))
var inner := Rect2(track.position, Vector2(full * clampf(frac, 0.0, 1.0), track.size.y))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
+80 -6
View File
@@ -5,12 +5,20 @@ extends Control
signal join_requested(address: String, port: int)
signal host_requested(port: int)
signal settings_requested
## The licences two of the asset packs require, one click from the first
## screen rather than two screens deep in settings.
signal credits_requested
var _address: LineEdit
var _port: LineEdit
var _status: Label
var _join_button: Button
var _host_button: Button
## True while a connection attempt is in flight; see set_busy().
var _busy: bool = false
## The status as set, and whether it is progress rather than a complaint.
var _status_base := ""
var _working: bool = false
func _ready() -> void:
@@ -28,6 +36,10 @@ func _ready() -> void:
# A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual
# offset math to get wrong.
# Behind everything, so it is first: a child draws over its earlier
# siblings, and the panel has to be in front of the bullets.
add_child(preload("res://src/ui/menu_backdrop.gd").new())
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
@@ -43,8 +55,19 @@ func _ready() -> void:
var title := Label.new()
title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 34)
# Spaced out, which the default font will not do on its own: a word set
# tight at 38px reads as a heading, and this is a name.
var spaced := FontVariation.new()
spaced.base_font = ThemeDB.fallback_font
spaced.spacing_glyph = 5
title.add_theme_font_override("font", spaced)
title.add_theme_font_size_override("font_size", 38)
panel.add_child(title)
var accent := ColorRect.new()
accent.color = Color(1.0, 0.72, 0.35, 0.85)
accent.custom_minimum_size = Vector2(64.0, 3.0)
accent.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
panel.add_child(accent)
var subtitle := Label.new()
subtitle.text = "a co-operative bullet hell"
subtitle.add_theme_font_size_override("font_size", 13)
@@ -70,16 +93,20 @@ func _ready() -> void:
_address.placeholder_text = "address"
_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_address.text_submitted.connect(func(_t: String) -> void: _on_join())
_address.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_address)
_port = LineEdit.new()
_port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port)
_port.placeholder_text = "port"
_port.custom_minimum_size = Vector2(90.0, 0.0)
_port.text_submitted.connect(func(_t: String) -> void: _on_join())
_port.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_port)
_join_button = Button.new()
_join_button.text = "Join"
# Enter works from either field, and from the button itself, which has focus
# when the screen opens. Saying so costs one word.
_join_button.text = "Join (Enter)"
_join_button.pressed.connect(_on_join)
panel.add_child(_join_button)
@@ -98,12 +125,25 @@ func _ready() -> void:
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings.pressed.connect(func() -> void: settings_requested.emit())
row.add_child(settings)
var credits := Button.new()
credits.text = "Credits"
credits.size_flags_horizontal = Control.SIZE_EXPAND_FILL
credits.pressed.connect(func() -> void: credits_requested.emit())
row.add_child(credits)
var quit := Button.new()
quit.text = "Quit"
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
quit.pressed.connect(func() -> void: get_tree().quit())
row.add_child(quit)
var how := Label.new()
how.text = "Host and play runs a server on this machine. Others join it by your address."
how.add_theme_font_size_override("font_size", 11)
how.add_theme_color_override("font_color", Color(0.5, 0.55, 0.66))
how.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
how.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_child(how)
_status = Label.new()
_status.add_theme_font_size_override("font_size", 13)
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
@@ -119,6 +159,29 @@ func _ready() -> void:
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
panel.add_child(version)
_validate()
# So Enter joins straight away for a returning player, whose address is
# already filled in from last time.
_join_button.call_deferred("grab_focus")
## Whether what is typed could possibly connect, said before the button is
## pressed rather than after. A non-number in the port used to go through as
## port 0 and come back as a connection error that did not say why.
func _validate() -> void:
var port_text := _port.text.strip_edges()
var port := int(port_text) if port_text.is_valid_int() else 0
var port_ok := port >= 1 and port <= 65535
var address_ok := not _address.text.strip_edges().is_empty()
_join_button.disabled = _busy or not (port_ok and address_ok)
_host_button.disabled = _busy or not port_ok
if not port_ok:
set_status("the port is a number from 1 to 65535")
elif not address_ok:
set_status("which server? type its address")
elif _status.text.begins_with("the port") or _status.text.begins_with("which server"):
set_status("")
func _gap(height: float) -> Control:
var c := Control.new()
@@ -129,17 +192,28 @@ func _gap(height: float) -> Control:
## Shown under the buttons. Coloured by what it is: "connecting..." is not an
## error, and it used to be drawn in the same red as one.
func set_status(text: String) -> void:
_status_base = text
_status.text = text
var working := text.begins_with("connecting")
_working = text.begins_with("connecting")
_status.add_theme_color_override("font_color",
Color(0.65, 0.8, 1.0) if working else Color(1.0, 0.55, 0.5))
Color(0.65, 0.8, 1.0) if _working else Color(1.0, 0.55, 0.5))
## A connection attempt can take a few seconds, and a line of text that does not
## move while it does reads as frozen. The dots are the only thing animated.
func _process(_delta: float) -> void:
if not _working:
return
var base := _status_base.trim_suffix("...").trim_suffix(" ")
var dots := int(float(Time.get_ticks_msec()) / 400.0) % 4
_status.text = base + " " + ".".repeat(dots)
## Locks the connect buttons while an attempt is in flight, so a second click
## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void:
_join_button.disabled = busy
_host_button.disabled = busy
_busy = busy
_validate()
func _on_join() -> void:
+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
+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]
+5 -3
View File
@@ -57,7 +57,8 @@ 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()
@@ -70,8 +71,9 @@ func _process(delta: float) -> void:
## 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
## -- as they always were: a shot coming out of the black is a shot you can
## still dodge.
## -- 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
+23
View File
@@ -211,3 +211,26 @@ func test_boss_rush_leaves_the_tunnels_empty_too() -> void:
var run := Instance.make_dungeon(2, 4242, 3)
GameOpts.boss_rush = false
assert_eq(_tunnel_enemies(run).size(), 0)
func test_nothing_is_awake_and_watching_when_the_party_arrives() -> void:
# Room 0 is left empty so that arriving is not an ambush, but that only ever
# covered room 0. With tunnels widened, three runs in eight had an enemy in
# a neighbouring room within aggro range and in sight of the entrance.
var watching := 0
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
for e in run.world.enemies.values():
if e.pos.distance_to(run.world.spawn_point) <= e.def.aggro_range \
and run.world.map.has_line_of_sight(e.pos, run.world.spawn_point):
watching += 1
assert_eq(watching, 0, "%d enemies could see the entrance at the moment of arrival" % watching)
func test_clearing_the_entrance_leaves_the_rest_of_the_dungeon_alone() -> void:
# It skips the few enemies that would be awake on arrival and nothing else:
# the dungeon keeps nearly all of its population.
var total := 0
for s in range(1, 40):
total += Instance.make_dungeon(2, s * 7919, 1).world.enemies.size()
assert_gt(float(total) / 39.0, 9.0, "a depth-1 dungeon still has a dungeon's worth of enemies")
+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
+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
+57 -26
View File
@@ -24,29 +24,32 @@ const STORE := "user://screenshot_characters.json"
const ACCOUNT := 777001
## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const OPEN_ROSTER := 370
const AT_ROSTER := 400
const ENTER_DUNGEON := 410
const AT_DUNGEON := 480
const TO_TUNNEL := 490
const AT_TUNNEL := 550
const AT_TUNNEL_DEBUG := 560
const TO_BOSS := 570
const AT_BOSS := 640
const DONE := 650
const AT_MENU := 200
const ENTER_GAME := 204
const AT_GAME := 370
const OPEN_MENU := 380
const AT_INGAME_MENU := 410
const OPEN_SETTINGS := 420
const AT_SETTINGS := 450
const OPEN_CREDITS := 460
const AT_CREDITS := 490
const OPEN_UPGRADES := 500
const AT_UPGRADES := 530
const OPEN_ROSTER := 540
const AT_ROSTER := 570
const ENTER_DUNGEON := 580
const AT_DUNGEON := 650
const TO_TUNNEL := 660
const AT_TUNNEL := 720
const AT_TUNNEL_DEBUG := 730
const TO_BOSS := 740
const AT_BOSS := 810
const ESCAPE_START := 830
## A safety net only: the run normally ends the moment the escape shot is taken.
const DONE := 1710
var _frame: int = 0
var _left_shot: bool = false
var _game: Node = null
var _menu: Control = null
var _dir: String = "user://"
@@ -98,10 +101,32 @@ func _process(_delta: float) -> void:
# it every frame, so one assignment lasts exactly one frame.
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
_show_the_roster()
# Held low, so the low-health edge is in the picture -- and held at all,
# because the boss is firing and one landed ring would end the shot.
if _frame > TO_BOSS and _frame <= AT_BOSS:
_hold_health(0.22)
# Held, because the boss is firing and one landed ring would end the shot.
# High until just before the picture and low after, so the damage trail
# -- the part of the bar the drop took -- is still draining when it is taken.
if _frame > TO_BOSS and not _left_shot:
# 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")
@@ -266,11 +291,17 @@ func _stage_a_boss_fight() -> void:
friend.spawn_grace = 0
Net.server.peer_names[999] = "Wren"
Net.server.peer_instance[999] = inst.id
# And one out of sight, so the marker pointing the way to them shows.
var away := inst.world.add_player(998, "Lark")
away.pos = at + Vector2(-560.0, 260.0)
away.colour = Color(0.55, 0.85, 1.0)
away.spawn_grace = 0
Net.server.peer_names[998] = "Lark"
Net.server.peer_instance[998] = inst.id
Net.server._broadcast_roster()
var hud := _game.get_node("HUD")
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint)
hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint)
hud.notify("LEVEL 7 -- a choice waits at the quartermaster", Color(1.0, 0.85, 0.4))
## Move the player, on both sides, into the tunnel closest to where it spawned: