Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:26:25 +02:00
claudeandClaude Opus 5.5 855669a665 Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or
skipped the whole tile on the answer. Every shadow was a staircase, tiles
snapped in and out as you walked, and a wall beside you vanished whenever
its centre happened to sit behind its neighbour.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:51:19 +02:00
claudeandClaude Opus 5 a6084c1a8d Tell the roster the truth about a wounded character
ci / verify (push) Successful in 52s
Health survives a run now, so a character sitting on the select screen
can be nearly dead -- and the screen was still printing only the ceiling.
"level 11, 2000 hp" for a character on 160 is a lie told at the exact
moment it matters most, because the next thing that happens is the player
walks it into a dungeon under permadeath.

The roster message carries current hit points alongside the maximum, and
the screen shows a fraction only when it means something: a healthy
character still reads "1300 hp" rather than "1300/1300". The field is
inserted mid-record, which shifts every field after it, so the protocol
version goes to 10 -- a mismatched client would read the active flag out
of the middle of a health value.

tools/screenshot.gd had no capture for this screen, which is how a UI
change to it could have shipped unlooked-at. It now shows a made-up
roster of three characters -- whole, badly hurt, nearly dead -- for the
same reason the upgrade screen is fed a made-up offer: the point is the
layout, not a dungeon run. Getting there needs the player walked off the
quartermaster first, or the game scene re-opens the upgrade screen over
the top of it every frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:54:40 +02:00
claudeandClaude Opus 5 4aeb6d229d Health survives a door, and experience needs you to be there
Arriving anywhere set health to maximum, so a run cost nothing: walk in,
take what you could, walk out, walk back in whole. Current health now
lives on the character record for the same reason the inventory does --
SimWorld builds a blank player on every instance change, so anything that
must survive a door has to be written down -- and it is banked at every
transfer, every disconnect, and on every item transaction, since a potion
moves an item and hit points in one action.

The hub is where that damage is paid off, at 10% of maximum per second:
about ten seconds from nothing, fast enough not to be a chore and slow
enough to be a decision before walking back in. The dungeon rate is
unchanged, so waiting one out is still not a plan. Regeneration is a
property of the world now rather than a constant.

adopt() no longer touches current health. It is called mid-session by
on_choose_upgrade, and reading health from the record there would snap a
player back to whatever was last written to disk; recompute_max_hp still
clamps downwards so Glass Cannon takes the hit points above the new
ceiling with it. ServerRuntime._adopt_character stays the one place a
record becomes a live player.

Experience is still undivided, but now only among players within
XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so
anyone who watched it die is inside and someone parked in the entrance
room is not. The death events carry a position for this, and deliberately
do not put it on the wire: encode_events still writes the id alone.

diag_progression covers the whole chain end to end -- wounded in a
dungeon, escaped to the hub with the wound intact, healing there, and
walking back in still hurt -- because a unit test cannot transfer a peer.
Both halves were mutation-tested against it; the unit suite alone missed
the arrival heal entirely.

The Proving Grounds is recorded in the roadmap as a debugging tool that
does not ship: it pays full experience for a boss with 8% health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 21:48:20 +02:00
claudeandClaude Opus 5 54086b5604 Stop the rate limiter from being its own amplifier
The control-message pacing added two commits ago logged one WARN line
per refused packet, which is a thousand formatted strings and a thousand
stdout writes per second for a client sending a thousand -- the exact
cost the limiter was added to bound, moved from the disk write to the
log. One line per burst now, re-armed when the peer behaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:27:07 +02:00
claudeandClaude Opus 5 7b710471df Make the arenas' cover actually stop bullets
The player's muzzle was fixed last commit; every enemy and boss emitter
has one too, and theirs reach further. A ring sits 46px out, an arc
sweep's outer arms sit past 100px, and a wall-gap curtain spans the whole
arena -- most of a curtain is nowhere near the thing that fired it. The
pool samples geometry once per tick and never sees the jump, so all of
those were spawning bullets on the far side of whatever stood between.

That matters here more than it sounds, because both boss arenas are
authored out of cover. The hall has pillars; the vault has barricades,
and its own comment says they are there so that knowing where the Cantor
is does not hand you a firing lane to it. A barricade blocks bullets and
deliberately does NOT block sight -- so it is exactly the cover this bug
cancelled. Measured before fixing: the Warden's curtain put 13 shots
through a one-tile wall and its arc sweep 13 more; the Cantor's curtain,
16.

Two checks, not one. The path test skips its own starting tile so a
shooter in a doorway still resolves, and that skip let a telegraphed
burst whose centre and ring sit inside one wall tile through -- born in
the wall, announced to every client, dead on the next tick. The
destination is now asked separately, so a bullet never exists inside
geometry.

A telegraphed strike passes its own marker as the source rather than the
boss: that burst is born where the player was warned, and a pillar
between the boss and the spot has nothing to say about it. A strike
marked on cover is now simply a dud, which is cover working; pinned by a
test so it stays a known shape.

First attempt at this used a turret behind a barricade and proved
nothing -- a turret's 24px offset cannot clear its own 16px radius plus a
32px tile, so the test could not have failed. Replaced with the two
emitters that can reach, plus both real arenas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:24:34 +02:00
claudeandClaude Opus 5 20715e77cc Make the two hot decoders as careful as the six cold ones
Every colder message here checks get_available_bytes() before it reads,
and has a test named for it. Snapshots and events -- twenty and sixty
times a second, the two that actually matter -- did neither.

A snapshot's counts are a u8 and two u16s read straight off the wire and
then looped without a bounds check. A garbage enemy count is up to 65535
dictionaries built out of an empty buffer, and a garbage tick is fed
directly to the client's clock estimate and its input-lead resync. The
channel is unreliable and ENet's checksum is off, so a short snapshot is
a network event, not only an attack. It now answers valid:false and the
client drops it -- one dropped snapshot costs 50ms of interpolation.

Events were worse in kind. The decoder read a type byte and matched on
it, so a type it did not recognise consumed one byte and carried on,
reading every field after it at the wrong offset: one stray byte became a
stream of bullets spawned at invented positions, which the client then
applied. Lengths are now declared up front and an unknown type stops the
stream. The prefix is kept, because the channel is reliable and ordered
and throwing the packet away would strand every despawn in it.

Both size tables are pinned to their encoders by tests that encode one of
each record and compare, so they cannot drift -- and a new SimEvent.Type
with no declared length fails a test rather than shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:14:14 +02:00
claudeandClaude Opus 5 39d857c4d1 Bound what one packet can cost, and stop the hub eating its own loot
Selecting a character rewrote the entire store to disk, re-placed the
player, re-streamed its map chunks and broadcast a roster to everyone --
off one small reliable packet, at whatever rate a client cared to send.
Repeating a selection you already hold now answers yes and stops, and
select/create are paced per peer. Item moves and experience grants, the
two paths a client can trigger every tick, mark the store dirty and the
server flushes once per tick instead of once per event.

That flush also fixes experience quietly not being saved: grant_xp only
wrote when a level was crossed, and level is DERIVED from lifetime xp, so
the number nobody was keeping was the only number there is. A restart
rolled a character back to its last level-up.

The emergency escape ran in the hub, where completing it means
transferring into the instance you are already in: teleported to spawn,
healed to full, instanced loot dropped, every map chunk re-streamed, once
a second for as long as the key is held. The hub now opts out.

Ground loot evicts the oldest item when the floor fills. That is right
for a dungeon, which closes and takes its litter with it, and wrong for
the hub, which never closes -- a player dropping into a full floor
destroyed whatever someone else had left there longest, and drop is
edge-triggered per tick. A player's drop is refused instead; the world's
own drops still make room.

Also: a linkdead body is not in inst.peers, so closing a CLEARED dungeon
walked past it and left that peer's account and name in the dictionaries
forever -- with the new one-account guard, enough to lock someone out of
their own session. And several encoders wrote mini(size, 255) as a count
and then looped the whole array, which puts records after the count on
the wire; MAX_CLIENTS makes it unreachable, which is why it would have
survived to the day it was not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:09:43 +02:00
claudeandClaude Opus 5 ef28970654 Close three holes an unmodified client could walk through
One account is now one session. Two peers signed in as the same account
each got their own player built from the SAME character record, so both
held a copy of one bag and whichever wrote last won: drop the single
potion from both copies and two potions hit the floor. Reconciling two
live mirrors of one record cannot be made to work, so the handshake
refuses the second connection -- the newcomer, not the incumbent, or
claiming an account would be a way to kick anyone off the server.

A muzzle offset is a teleport and the bullet pool never sees it: it
samples geometry once per tick. Forward that is 19px, but Doubleshot
adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox
holding you off it. Six Doubleshots, stand flush against a wall, fire
along it, and live shots came out the far side. Shots now start from the
shooter when geometry stands in the way, which keeps the count honest.

Bullet-spawn events carry the firing actor's exact position, and they
were filtered by a flat 2900-unit radius against an 800-unit actor cull.
A depth-1 map's diagonal is about 2521, so that radius culled nothing:
every enemy that fired was pinpointed for every player, through fog, on
terrain the client was deliberately not streamed. The test is now
per bullet -- can THIS one reach ground you can see -- which keeps the
guarantee the wide radius existed for and drops the radar.

Found while fixing the above: a rejection never reached the client. The
RPC is queued and only leaves on the frame's flush, so kicking in the
same call threw the reason away, and _on_server_disconnected overwrote
last_error anyway. Both were true of protocol mismatch too, which is the
one message a player most needs. Smoke asserts the reason arrives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:04:05 +02:00
60 changed files with 4809 additions and 412 deletions

No files matched your search

+30 -5
View File
@@ -13,8 +13,8 @@ the obvious default.
```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # 468 GUT tests, headless (~4s)
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
tools/test.sh # 576 GUT tests, headless (~4s)
tools/smoke.sh # real server + 5 bot clients over ENet (~40s)
tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu
```
@@ -29,7 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (needs a display)
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # every screen, the fog, a staged boss fight (needs a display; own store and account)
```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
@@ -37,6 +37,11 @@ test cannot cover any of them: bots are poor shots, so they rarely kill
anything, which means they neither earn levels, produce drops, nor ever reach
the quartermaster.
**Every key the game names to the player goes through `Settings.short_label()`.**
All of them are rebindable, and a prompt that names the default is wrong for
anyone who changed it — the HUD said "LMB fire" and the upgrade screen said
"Close (E)" whatever those were bound to.
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
have all passed with the entire interface rendering unstyled, an invisible
slider and an invisible scrollbar. After touching anything under `src/ui/` or
@@ -51,7 +56,8 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--join` | Skip the menu and connect. |
| `--listen` | Skip the menu and host a listen server with a local player. |
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
| `--host`, `--port`, `--name` | Connection details. |
| `--host`, `--port` | Connection details. |
| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. |
| `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. |
| `--depth N` | Server-side: depth of new dungeons, which drives map size. |
@@ -203,6 +209,14 @@ ticks in milliseconds with no SceneTree.
from `_place`.** `SimWorld` knows nothing about characters and builds a blank
player on every instance change, so any new path that puts someone in a world
must go through `_place` or it hands them a level-1 body with an empty bag.
Current health comes from the record too (`Character.hp`), and is banked by
`_bank_health` wherever a player leaves a world — miss one and that door
becomes a free heal.
- **One account, one session.** `on_hello` refuses a peer whose account is
already playing; two live players on one character duplicate items.
- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and
`GameOpts.account_override` first.** The default is the player's real store and
account; `screenshot.tscn` once played their character into dungeons.
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
recomputes them from the character's upgrade ids every time, so a saved stat
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
@@ -243,8 +257,19 @@ ticks in milliseconds with no SceneTree.
enemies and the boss all go through the same lerp. The boss did not for a
long time, which is invisible while bosses stand still and looks broken the
moment one moves.
- **The fog works by draw order, not by deciding per object.** `WorldView._draw`
draws terrain and every actor within sight range, then the darkness from
`Visibility` over them, then whatever must stay visible. That is what lets a
tile or an enemy be half lit. Anything new that fog may hide goes *before*
`_draw_darkness`; anything it must not goes after.
- **No enemy bullet may land without having been drawn first.** Bullets fade
beyond the edge of sight, but `BulletRenderer.visibility()` stretches each
enemy bullet's draw distance with its speed so it is always fully on screen
`WARNING_TIME` before it could reach you. A faster bullet in new content is
covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide
bullets by shadow alone — you can walk round a corner into one.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
ignores line of sight on purpose; everything else in the view respects it. A
is drawn after the darkness on purpose; everything else in the view is under it. A
warning you cannot see is an unavoidable hit with extra steps.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
+6 -5
View File
@@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash
tools/server.sh # dedicated server on :27015
tools/client.sh --join --name ada # connect a client
tools/client.sh --join # connect a client; you name a character on the roster screen
```
Or run one client and press **Host and play** for a listen server.
@@ -25,8 +25,9 @@ hitbox overlay. Every one of those is rebindable in Settings.
| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. |
| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. |
| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. |
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. |
| **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. |
| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
@@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings.
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # 468 GUT tests, headless ~4s
tools/smoke.sh # server + 4 bot clients over ENet ~40s
tools/test.sh # 576 GUT tests, headless ~4s
tools/smoke.sh # server + 5 bot clients over ENet ~40s
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
+26 -5
View File
@@ -48,8 +48,10 @@ This project deliberately does not, for four reasons:
The trade is that there is no physics engine, so collision is hand-written.
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the
genre convention anyway.
for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre
convention anyway. The client's fog no longer shares that line test: it casts
exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so
shadows cut through tiles rather than snapping to them.
That was the right call rather than a compromise: the same grid answers
collision, sight, and per-peer interest management as array lookups. On arbitrary
@@ -142,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role |
| --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. |
| `src/sim/input_frame.gd` | The only thing a client may assert about itself. |
| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
@@ -170,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. |
| `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. |
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network
@@ -186,14 +191,30 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
| File | Role |
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. |
| `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |
+157 -10
View File
@@ -89,6 +89,41 @@ about as often as a good one.
**Hard fog.** *(User, this session.)* No remembered terrain — anything outside
current line of sight is not drawn, including ground already walked over.
**The fog is geometry, not a verdict per tile — and a little less strict.**
*(User asked: "no longer grid based ... able to light up cells partially", and
"a bit less strict". How strict was left to me.)* `Visibility` casts rays from
the eye — a fixed sweep plus two aimed either side of every wall corner, which
is what makes each shadow edge a straight line — and the view draws terrain and
actors first, then darkness over everything the rays did not reach. A shadow
edge runs straight through a tile; an enemy rounding a corner appears a slice
at a time instead of popping in whole.
"Less strict" is three specific things. The face of a wall you can see is lit
26 units deep, measured perpendicular to the face — along the ray it was a
sliver for any wall seen at a grazing angle, and the wall beside you vanished,
which is the complaint the old fog had. Shadows end in an 8-unit fade instead
of a hard line. And the edge of sight fades over its last 64 units instead of
stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps
back out of the wall it was in, so it never rounds a corner or reaches the far
side of a one-tile wall; and two blocking tiles that touch only at a corner are
closed, not a crack. Bullets are drawn over the darkness — a shot out of the
black is still one you can dodge.
**Bullets fade out past the edge of sight, but never sooner than is safe.**
*(User asked to stop drawing bullets beyond sight, with the condition that
nobody is ever hit by a bullet they could not see.)* Every enemy bullet is
drawn at full strength out to the edge of sight or to however far it could close
in `BulletRenderer.WARNING_TIME` (0.75s) with you running straight at it,
whichever is further, then fades over 80 units. Today the edge of sight is always
further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen
0.83s before it lands. `test_bullet_draw.gd` checks that for every emitter in the
content, and for bullets far faster. Player shots fade at the edge of sight
regardless, since they cannot hurt you. Bullets in shadow *within* sight range
are still drawn: you can step round a corner into one, and hiding it would be
exactly the invisible hit the condition rules out.
**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
flag; what raises depth in actual play is still open.
@@ -147,10 +182,19 @@ removed from the instance and left at the roster screen. The one exception is a
linkdead player, which has nobody to show a roster to, so its body is left for
the escape channel to resolve as before.
**Experience is shared across the party, undivided.** Everyone alive in the
instance receives the full amount for a kill. Splitting it would make bringing a
friend cost you progress, which is the opposite of what the hub roster exists to
encourage.
**Experience is shared across the party, undivided — among everyone who was
there.** Everyone alive receives the full amount for a kill. Splitting it would
make bringing a friend cost you progress, which is the opposite of what the hub
roster exists to encourage.
Presence is a separate question from the split, and it has a radius:
`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who
watched something die is inside it; a player parked in the entrance room while
the party clears the dungeon is not, and used to collect the whole run. Ten
percent past sight rather than exactly on it, so the edge of the fog is
comfortably inside and nobody loses a kill they actually saw. One consequence
worth knowing: an upgraded shot outranges this several times over, so a kill
made somewhere you cannot see pays nothing.
**A level-up heals by the amount it added.** Gaining a level mid-fight should
feel like relief, not like the bar you were watching got further from full.
@@ -168,12 +212,23 @@ than the one-second escape channel, and it would make that channel pointless.
The menu greys the button out so the rule is visible, but the server refuses
regardless of what any client's UI allows.
**Health regenerates at 0.5% of MAXIMUM per second, with no out-of-combat
gate.** A percentage rather than a flat rate, so it does not become irrelevant
at level 15 — a capped character regains 1.2 hp/s against a level 1's 0.5, and
both take about 200 seconds to heal from nothing. No combat gate because at this
rate it cannot out-heal anything actually shooting at you, and a trickle that
never stops is easier to reason about than a timer players have to learn.
**Health regenerates at 0.5% of MAXIMUM per second in a dungeon, with no
out-of-combat gate.** A percentage rather than a flat rate, so it does not become
irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's
0.5. No combat gate because at this rate it cannot out-heal anything actually
shooting at you, and a trickle that never stops is easier to reason about than a
timer players have to learn.
**The hub regenerates at 10% per second, and health is carried through a door.**
Arriving anywhere used to set health to maximum, which meant a run cost nothing:
walk in, take what you can, walk out, walk back in whole. Current health now
lives on the character record — the same reason the inventory does, since
`SimWorld` builds a blank player on every instance change — and the hub is where
it is paid off, over about ten seconds. Fast enough that recovering is not a
chore, slow enough that it is a decision to make before walking back in. The
dungeon rate is unchanged, so waiting inside one is still not a plan.
More friction is expected here later; this is the floor, not the design.
**A dead character is gone, as far as the player is concerned.** Retirement is
the server's own bookkeeping for archival and troubleshooting; the roster the
@@ -488,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
+74 -15
View File
@@ -23,6 +23,23 @@ a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
the four stages that followed: characters and permadeath, inventory and loot,
upgrades, and a second boss that moves.
Since then, in one long session:
- **An anti-cheat audit, and its fixes.** Item duplication through two sessions
on one account; shots through walls from muzzle offsets (the player's and every
boss's); every firing enemy located by the flat bullet-interest radius;
unbounded store writes from small packets; the hub's escape channel; hub loot
griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under
"The anti-cheat audit".
- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes
only to players within `XP_AWARD_RADIUS` of the kill.
- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)),
and **bullets fade past the edge of sight** without ever arriving unseen.
- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the
entrance on arrival, and arenas that keep their walls.
- **Two interface passes** — see the Interface section of DECISIONS.md.
- **The smoke flake is fixed** (below).
So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.**
@@ -35,16 +52,33 @@ candidates, none of which the user has chosen — **ask before starting one.**
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
And smaller things raised with the user and not yet answered or chosen:
| Open item | What is known |
| --- | --- |
| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. |
| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. |
| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. |
| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. |
| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. |
| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. |
| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). |
Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above.
### Known flake
### The smoke flake (diagnosed and fixed)
`tools/smoke.sh` failed once in a pre-push hook and then passed on four
consecutive runs with no stray processes. It was not reproduced and is not
diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets
and was competing with a full suite run — but that is a guess. If it recurs,
that is the first thing to check.
`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose
logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
whose account picks the Proving Grounds, had gone into the Choir Vault. The
bot wandered for 90 of every 120 ticks in the hub and then walked to its
entrance holding interact the whole way; the three entrances sit in a row, so a
wander that ended near the middle one carried it across the wrong ring with the
key down. Which dungeons opened depended on where a timing-dependent wander
stopped. The bot now walks straight to its own entrance and presses interact
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with
`KEEP=1 git push` so the logs survive.
---
@@ -55,8 +89,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
| Gate | Covers | Runtime |
| --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s |
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~40s |
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
@@ -64,10 +98,15 @@ What "everything passes" currently means. Numbers move; the shape does not.
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
and it is the only thing in the project that can tell you the interface
actually rendered. Run it after touching `src/ui/`. It captures the main menu,
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
those by staging an offer, because earning one would make the shot depend on a
dungeon run.
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
the connect screen, the hub standing on a portal, the pause menu, settings,
credits, the upgrade choice, the roster after a death, a dungeon arrival, a
tunnel (with and without F1), a staged boss fight, and the escape banner. It runs
on **its own character store and account** — it used to host on the player's
real ones — and stages what it cannot earn (an offer, a party, a death).
Timings are frame counts and frames run as fast as the display allows, so
anything tied to server ticks is captured by condition instead (see the escape
shot).
It has now caught four bugs that every other gate passed: the theme applying to
nothing, the test suite rewriting the player's settings, an invisible slider
@@ -89,8 +128,18 @@ against itself.
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` |
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
| Prompts name the keys actually bound | done | `Settings.short_label()` |
| Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` |
| Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` |
| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land |
| Markers pointing to party members out of sight | done | `Hud._draw_party_markers` |
| Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) |
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
| Hard fog of war | done | `WorldView._draw_terrain` / `_visible` |
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
@@ -111,7 +160,8 @@ terrain it does not have.
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. |
| `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. |
| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. |
Fog is a *rendering* rule and defends nothing on its own — a modified client
draws whatever it holds. The defence is what the server declines to send.
@@ -142,6 +192,15 @@ couple of minutes instead of a quarter of an hour, and because it is a portal
rather than a launch flag you can compare it to a real run without restarting
the server.
**It is a debugging tool and does not ship.** It pays exactly the same
experience a real run does — `Progression.xp_for_boss` ignores its argument and
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
touch — while its boss has 8% of its health and its trash drops ten times as
often. That makes it strictly the best place to level and to farm, which is
correct for a harness and would be ruinous in a release. Remove it from
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
two have to stay in the same order.
@@ -418,7 +477,7 @@ feet is a coin flip, the same burst with a second of notice is a question.
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the
view respects line of sight; a warning you cannot see is not a warning. See
view is covered by the darkness; a warning you cannot see is not a warning. See
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
+34
View File
@@ -224,6 +224,40 @@ character decoder, which then read a field its encoder never wrote. Both are
codecs and both had the same trailing lines. Check the match count when patching
by pattern, not by line.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
+35 -1
View File
@@ -13,6 +13,11 @@ signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## How long a refused peer is left connected so its rejection can actually go
## out. Long enough to survive a frame's flush and the round trip, short enough
## that nobody notices; see [method reject_and_kick].
const REJECT_LINGER := 0.5
## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1
@@ -97,8 +102,31 @@ func shutdown() -> void:
_set_state(State.OFFLINE)
## Tell a peer why it is not welcome, then close the socket.
##
## The kick is DEFERRED, which is the whole point of this existing. An RPC is
## queued and only leaves on the frame's flush, so disconnecting in the same
## call throws the reason away with the packet -- a client turned away for a
## protocol mismatch was told only that the server closed the connection. The
## peer can do nothing during the wait: it has no account bound, and every
## message that needs one already refuses it.
func reject_and_kick(peer_id: int, reason: String) -> void:
send_reject(peer_id, reason)
if _is_local(peer_id):
return
get_tree().create_timer(REJECT_LINGER).timeout.connect(
func() -> void: kick(peer_id))
func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
if multiplayer.multiplayer_peer is not ENetMultiplayerPeer:
return
# The peer may have hung up first. A rejected client closes its own socket
# as soon as it reads the reason, and reject_and_kick's timer lands after
# that -- asking ENet to disconnect someone already gone is an engine error,
# not a quiet no-op.
if not multiplayer.get_peers().has(peer_id):
return
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
@@ -130,6 +158,12 @@ func _on_connect_failed() -> void:
func _on_server_disconnected() -> void:
# A rejection lands immediately before the socket closes, and it is the
# useful half of the pair: "protocol mismatch: server 9, client 8" is
# actionable and "server closed the connection" is not. Overwriting it here
# made every reject reason in the game unreachable -- the reason was sent,
# decoded, stored, and then clobbered a frame later by this line.
if state != State.FAILED:
last_error = "server closed the connection"
GameLog.warn("net", last_error)
shutdown()
+12
View File
@@ -37,6 +37,18 @@ static func outside_map(p: Vector2, map: MapGrid) -> bool:
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
## The furthest a bullet with these parameters could travel before it expires.
##
## Used to decide whether a spawn is worth telling a client about. Deliberately
## an OVER-estimate: acceleration is integrated at face value while a negative
## one is ignored, and a turning bullet curves, so the straight-line answer is
## always at least the real path. Over-estimating costs a packet; under-
## estimating is a bullet that arrives invisible.
static func bullet_reach(speed: float, accel: float, life_ticks: int) -> float:
var t := float(maxi(life_ticks, 0)) * SimConfig.TICK_DELTA
return maxf(speed, 0.0) * t + 0.5 * maxf(accel, 0.0) * t * t
## Outside the map, or stopped by geometry.
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
if map == null:
+34
View File
@@ -45,6 +45,11 @@ const SFX_BUS := "SFX"
static var master_volume: float = 0.8
static var sfx_volume: float = 0.8
## The server last joined, so the connect screen does not ask for the same
## address every launch. Empty until the first join. Client-local like
## everything else here -- it is where you play, not who you are.
static var last_address: String = ""
static var last_port: int = 0
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
@@ -64,6 +69,8 @@ static func load_and_apply() -> void:
if cfg.load(path) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
last_address = String(cfg.get_value("connection", "address", ""))
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
@@ -86,6 +93,9 @@ static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume)
if not last_address.is_empty():
cfg.set_value("connection", "address", last_address)
cfg.set_value("connection", "port", last_port)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
@@ -93,6 +103,16 @@ static func save() -> void:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
## Remember where the player just tried to connect.
static func remember_server(address: String, port: int) -> void:
var clean := address.strip_edges()
if clean.is_empty() or port <= 0 or port > 65535:
return
last_address = clean
last_port = port
save()
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
@@ -191,6 +211,20 @@ static func binding_label(action: String) -> String:
return describe_label(current_binding(action))
## The binding as short as it can be said, for prompts on screen: "LMB" rather
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
## player goes through this, because every one of them is rebindable and a
## prompt that names the default is wrong for anyone who changed it.
static func short_label(action: String) -> String:
var label := binding_label(action)
match label:
"Mouse Left": return "LMB"
"Mouse Right": return "RMB"
"Mouse Middle": return "MMB"
"Escape": return "Esc"
return label
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
+27 -4
View File
@@ -64,11 +64,24 @@ const SPAWN_GRACE_TICKS := 120 # 2 seconds
## simpler to reason about than a timer players have to learn, and at this rate
## it cannot outpace anything that is actually shooting at you.
const HP_REGEN_PERCENT_PER_SEC := 0.5
## What the hub heals at instead. Arriving anywhere used to snap you to full,
## which made health free: enter a dungeon, take what you could, leave, walk
## back in whole. Recovering it takes ten seconds of standing in the hub now,
## so the run you just left cost you something -- and the cost is time in the
## one place nothing is shooting at you, not a resource to manage.
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
## A downed player cannot leave for the hub until this has elapsed. Enforced
## here rather than only by grey-ing out the button, because a disabled button
## is a suggestion -- the server is the only thing a modified client cannot
## argue with.
## A downed player cannot leave for the hub until this has elapsed. Enforced in
## the simulation rather than only by grey-ing out the button, because a
## disabled button is a suggestion.
##
## Worth knowing what actually reaches it: a player whose peer is still
## connected never waits, because ServerRuntime unbinds it on the same tick it
## dies and leaves it at the roster screen -- that is a settled decision, not an
## oversight (docs/DECISIONS.md, "Death unbinds the character entirely"). What
## the lockout governs is the LINKDEAD body, which has nobody to show a roster
## to and asks for itself; the countdown is what stops it vanishing out of a
## fight the instant it is killed.
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
# --- Input timing -----------------------------------------------------------
@@ -186,6 +199,16 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
const FOG_VIEW_RADIUS := 460.0
# --- Interest management ----------------------------------------------------
## How near a kill you have to be to be paid for it. Ten percent beyond what a
## player can see, so someone who watched it die is always inside and someone
## parked at the entrance never is. Experience stays undivided among everyone
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
## each of them gets.
##
## Worth knowing: an upgraded shot outranges this several times over, so a kill
## made far enough away that you cannot see it pays nothing.
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
## Actors further than this from a player are not sent to that player at all.
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
## sight, but far short of a dungeon, so a modified client cannot read enemy
+126
View File
@@ -31,10 +31,26 @@ var stage_delay: int = 0
var boss_id: StringName = &""
## Generated layout, used to place enemies room by room.
var rooms: Array[Rect2i] = []
## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the
## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel.
var tunnels: Array[Rect2i] = []
var boss_spawn := Vector2.ZERO
var boss_room := Rect2i()
## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg,
## and most legs are too short or too near the entrance to qualify anyway, so a
## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose:
## tunnels are where you go between fights, and a tunnel that is usually empty
## is what makes the one that is not a surprise.
const TUNNEL_GROUP_CHANCE := 0.1
const TUNNEL_GROUP_MIN := 2
const TUNNEL_GROUP_MAX := 3
## Members stand within this many tiles of the group's centre, so a group reads
## as one encounter rather than a tunnel's worth of strays.
const TUNNEL_GROUP_SPREAD := 2
static func make_lobby(instance_id: int) -> Instance:
var inst := Instance.new()
inst.id = instance_id
@@ -47,6 +63,13 @@ static func make_lobby(instance_id: int) -> Instance:
inst.world.upgrade_npc = built["npc"]
inst.world.has_upgrade_npc = true
inst.world.spawn_point = built["spawn"]
# Nothing here to escape from, and completing the channel in the hub means
# transferring to the hub -- see SimWorld.escape_enabled.
inst.world.escape_enabled = false
# The hub is where a run's damage is paid off. Arriving used to heal you to
# full, which made health free between runs; ten seconds of standing here
# does the same job at a price.
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before
# committing to a run.
@@ -71,6 +94,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
inst.depth, flavour.arena)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.tunnels = built["tunnels"]
inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"]
@@ -111,7 +135,109 @@ func _populate() -> void:
var at := world.map.tile_centre(tx, ty)
if world.map.circle_blocked(at, def.radius):
continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
# After every room, so adding tunnel groups leaves each room's population
# exactly what that seed always produced.
_populate_tunnels(rng, flavour)
## Now and then, a group of enemies in a tunnel.
##
## Never within aggro range of the entrance, by the same reasoning room 0 is
## left empty: arriving into a fight you cannot see yet is not a decision, it
## is an ambush. And never a lone straggler -- if a leg cannot fit at least
## TUNNEL_GROUP_MIN, it gets nobody.
func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0
for leg in tunnels:
# Every leg rolls the same three numbers whether or not it is used, so
# one tunnel's dice never depend on another tunnel's shape.
var roll := rng.randf()
var pick := rng.randi()
var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX)
if roll >= TUNNEL_GROUP_CHANCE:
continue
var open := _tunnel_tiles(leg)
if open.size() < TUNNEL_GROUP_MAX * 2:
continue
var centre: Vector2i = open[pick % open.size()]
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var spots: Array[Vector2] = []
for t in open:
if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \
or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD:
continue
var at := world.map.tile_centre(t.x, t.y)
# Per member, not per group: members stand up to two tiles off the
# centre, so a centre that clears the entrance does not mean every
# member does.
if at.distance_to(world.spawn_point) < clear_of_spawn:
continue
if not world.map.circle_blocked(at, def.radius):
spots.append(at)
if spots.size() < TUNNEL_GROUP_MIN:
continue
for i in range(spots.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var held := spots[i]
spots[i] = spots[j]
spots[j] = held
for i in mini(size, spots.size()):
# A fresh definition per member: apply_to_enemy mutates in place,
# and sharing one object would scale it once per member.
var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id))
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
## Whether an enemy placed at [param at] would be awake the moment the party
## arrives: within its aggro range of the entrance, with nothing in the way --
## the same two questions SimWorld._aggro_target asks.
##
## Room 0 is left empty for exactly this reason, but that only ever covered room
## 0. A neighbouring room in sight of the entrance woke on arrival in one run in
## eight, and once tunnels were widened -- more open ground, more lines of sight
## -- in three runs in eight. Skipping these removes about one enemy in sixteen;
## clearing everything within aggro range instead would have removed nearly a
## third, which is a different dungeon rather than a fixed one.
##
## The rng draws for the skipped enemy have already been made, so every other
## enemy in the dungeon is exactly where that seed always put it.
func _would_see_the_entrance(def: EnemyDef, at: Vector2) -> bool:
if at.distance_to(world.spawn_point) > def.aggro_range:
return false
return world.map.has_line_of_sight(at, world.spawn_point)
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
## room by a tile, so a group is never standing in a doorway, and outside the
## boss arena.
func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]:
var out: Array[Vector2i] = []
for ty in range(leg.position.y, leg.end.y):
for tx in range(leg.position.x, leg.end.x):
if world.map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
if boss_room.has_point(here):
continue
var in_room := false
for r in rooms:
if r.grow(1).has_point(here):
in_room = true
break
if not in_room:
out.append(here)
return out
## The furthest any enemy in the game notices a player from.
static func _widest_aggro() -> float:
var widest := 0.0
for id in Content.ALL_ENEMIES:
widest = maxf(widest, Content.enemy(id).aggro_range)
return widest
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
+1
View File
@@ -70,6 +70,7 @@ func _show_menu() -> void:
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
+17
View File
@@ -28,6 +28,17 @@ var died_unix: int = 0
## bags, and so a server restart does not quietly confiscate everyone's potions.
var inventory: Array[StringName] = []
## Current health, or [constant HP_FULL] for "however much this character can
## have". Stored for the same reason the inventory is: SimWorld builds a blank
## player on every instance change, so anything that must survive walking
## through a door has to live on the record. Without it, entering and leaving
## was a free heal, and health cost nothing between runs.
##
## HP_FULL rather than a number so a save written before this existed, and a
## character that has never been hurt, both load as whole rather than as dead.
const HP_FULL := -1
var hp: int = HP_FULL
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
## two Split Shots really is two splits -- so this is a list, not a set.
##
@@ -140,6 +151,7 @@ func to_dict() -> Dictionary:
# Items.ORDER being appended to, and a human editing it should be able
# to tell what a character is carrying.
"inventory": _inventory_ids(),
"hp": hp,
"upgrades": _name_list(upgrades),
"pending_choices": pending_choices,
"offer": _name_list(offer),
@@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character:
var carried_id := StringName(String(raw))
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
c.set_inventory(carried)
# Anything unreadable, negative or absent means full. The failure that
# matters is a save loading as a character with no health left, which under
# permadeath is not something a player can recover from.
var saved_hp := int(d.get("hp", HP_FULL))
c.hp = saved_hp if saved_hp > 0 else HP_FULL
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
# save from a build with an upgrade this one lacks still produces coherent
# stats instead of a phantom that counts toward the +5% and does nothing.
+47 -3
View File
@@ -20,6 +20,9 @@ const MAX_ACTIVE := 5
var _accounts: Dictionary[int, Dictionary] = {}
var _path: String = SAVE_PATH
var _rng := RandomNumberGenerator.new()
## Set by [method save_deferred]; cleared by the next write. See that method for
## why one path defers and every other one does not.
var _dirty: bool = false
func _init(path: String = SAVE_PATH) -> void:
@@ -108,14 +111,37 @@ func retire_character(account_id: int, character_id: String) -> void:
## Write a character's carried items back to disk. Separate from grant_xp
## rather than folded into a general "save this character", so the one caller
## reads as what it is.
func set_inventory(account_id: int, character_id: String, items: Array[StringName]) -> void:
##
## [param defer] coalesces the write to the end of the server tick. Item events
## are the one persistence path a client can trigger at the tick rate -- the
## slot byte counts toward the edge, so alternating it with drop held produces
## an action every tick -- and each one otherwise re-serialised every account on
## the server to disk. Deferring bounds that at one write per tick however many
## players are doing it. The risk the immediate write guards against is a crash
## between moving an item and recording it; a tick is 16ms of that, and the
## direction of the loss is an item that stays in your bag, never a duplicate.
func set_inventory(account_id: int, character_id: String, items: Array[StringName],
defer: bool = false) -> void:
var c := get_character(account_id, character_id)
if c == null:
return
c.set_inventory(items)
if defer:
save_deferred()
else:
save()
## Bank a character's current health. Deferred like the inventory: it changes
## every tick a player is regenerating, and the server flushes once a tick.
func set_health(account_id: int, character_id: String, hp: int) -> void:
var c := get_character(account_id, character_id)
if c == null:
return
c.hp = maxi(hp, 1)
save_deferred()
## Award level-up choices and, if nothing is on the table yet, roll one.
##
## The offer is rolled HERE and stored, not generated on demand when the screen
@@ -162,14 +188,32 @@ func grant_xp(account_id: int, character_id: String, amount: int) -> int:
if c == null:
return 0
var gained := c.grant_xp(amount)
if gained > 0:
save()
# Every grant is written, not only the ones that cross a level boundary.
# Saving on gained > 0 alone meant a character could kill its way to one
# point short of a level and lose all of it to a restart, because the level
# is DERIVED from lifetime experience -- the number that was not being kept
# is the only number there is. Deferred so a busy fight still costs one
# write per tick rather than one per kill.
save_deferred()
return gained
# --- Persistence ------------------------------------------------------------
## Mark the store as needing a write, without doing it now. The owner calls
## [method flush] once per tick; see [method set_inventory].
func save_deferred() -> void:
_dirty = true
## Write only if something asked for one since the last write.
func flush() -> void:
if _dirty:
save()
func save() -> void:
_dirty = false
var accounts := {}
for account_id in _accounts:
var entry: Dictionary = _accounts[account_id]
+52 -5
View File
@@ -80,6 +80,12 @@ var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = []
## The character this player just lost, as { "name", "level", "where" }, or
## empty. Death is permanent and the server unbinds you on the same tick, so
## without this the only sign anything happened was that the roster screen
## appeared. Recorded from the server's own PLAYER_DIED event, never guessed.
var last_fallen: Dictionary = {}
## This account's characters, and which one is being played. Server-pushed;
## the client never invents an entry.
var characters: Array[Dictionary] = []
@@ -268,12 +274,25 @@ func _bot_input() -> InputFrame:
# Downed bots ask for the hub, same as a player would -- otherwise a bot
# that dies mid-run just lies there and the smoke test stalls.
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
if instance_kind == Protocol.InstanceKind.LOBBY:
# Straight to its own entrance, picked from the account id so a smoke
# run with several bots exercises every dungeon -- and interact pressed
# ONLY while standing on that one.
#
# It used to wander for 90 ticks in every 120 and then walk to its
# portal holding interact the whole way. The three entrances sit in a
# row, so a wander that ended near the middle one sent the bot bound for
# the Proving Grounds straight across the Choir Vault's ring with the
# key down, and in it went. Which dungeons opened depended on where a
# timing-dependent wander happened to stop: this was the smoke test's
# long-standing intermittent failure, and it only ever showed up as
# "the proving grounds opened" (or another entrance) going missing.
var target := bot_portal()
move = (target - predicted_pos).normalized() \
if predicted_pos.distance_to(target) > 6.0 else Vector2.ZERO
var here := portal_here()
if not here.is_empty() and (here["pos"] as Vector2).is_equal_approx(target):
buttons |= InputFrame.BTN_INTERACT
# Walk onto a portal instead of orbiting, or interact never lands.
# Which one is picked from the account id, so a smoke run with several
# bots exercises every dungeon rather than only the first.
move = (bot_portal() - predicted_pos).normalized()
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item
@@ -450,6 +469,12 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
func on_snapshot(data: PackedByteArray) -> void:
var snap := NetCodec.decode_snapshot(data)
if not snap["valid"]:
# Unreliable channel, no checksum: a truncated snapshot is a network
# event, not only an attack. Dropping one costs 50ms of interpolation;
# applying half of one moves the clock estimate and the input lead on
# numbers read out of the end of a buffer.
return
if not snap_curr.is_empty() and int(snap["tick"]) <= int(snap_curr["tick"]):
return # stale or duplicate; unreliable channel, newest wins
cleared_countdown = int(snap["cleared_countdown"])
@@ -540,6 +565,14 @@ func on_events(data: PackedByteArray) -> void:
SimEvent.Type.PLAYER_DIED:
if int(ev["peer"]) == my_peer:
my_alive = false
var who := current_character()
if not who.is_empty():
last_fallen = {
"name": String(who["name"]),
"level": Progression.level_for_xp(my_total_xp),
"where": Dungeons.get_or_default(dungeon_id).display_name
if instance_kind == Protocol.InstanceKind.DUNGEON else "",
}
hud_dirty.emit()
SimEvent.Type.PLAYER_RESPAWNED:
if int(ev["peer"]) == my_peer:
@@ -614,6 +647,20 @@ func loot_in_reach() -> Dictionary:
return best
## The entrance this player is standing on, as { "pos", "dungeon" }, or empty.
## Purely for the prompt, like loot_in_reach: nearest wins, the same tie-break
## SimWorld.portal_at() uses, and the server decides for itself regardless.
func portal_here() -> Dictionary:
var best := {}
var best_d := SimConfig.PORTAL_RADIUS * SimConfig.PORTAL_RADIUS
for p: Dictionary in portals:
var d: float = predicted_pos.distance_squared_to(p["pos"])
if d <= best_d:
best_d = d
best = p
return best
## True when every slot is taken, so the HUD can explain why a pickup did
## nothing rather than looking broken.
func inventory_full() -> bool:
+101 -11
View File
@@ -38,7 +38,13 @@ static func encode_snapshot(world: SimWorld,
or eye.distance_squared_to(p.pos) <= cull_sq:
visible_players.append(p)
b.put_u8(mini(visible_players.size(), 255))
# Sliced, not just clamped. Writing mini(size, 255) as the header and then
# looping the full array puts records after the count on the wire, and the
# decoder reads the enemy count out of the middle of a player. MAX_CLIENTS
# keeps it unreachable today, which is exactly why it would survive to the
# day it is not.
visible_players = visible_players.slice(0, 255)
b.put_u8(visible_players.size())
for p in visible_players:
b.put_u32(p.peer_id)
b.put_float(p.pos.x)
@@ -81,7 +87,8 @@ static func encode_snapshot(world: SimWorld,
if observer != null and eye.distance_squared_to(e.pos) > cull_sq:
continue
live_enemies.append(e)
b.put_u16(mini(live_enemies.size(), 65535))
live_enemies = live_enemies.slice(0, 65535)
b.put_u16(live_enemies.size())
for e in live_enemies:
b.put_u32(e.id)
b.put_float(e.pos.x)
@@ -123,7 +130,8 @@ static func encode_snapshot(world: SimWorld,
if eye.distance_squared_to(l.pos) > cull_sq:
continue
visible_loot.append(l)
b.put_u16(mini(visible_loot.size(), 65535))
visible_loot = visible_loot.slice(0, 65535)
b.put_u16(visible_loot.size())
for l in visible_loot:
b.put_u32(l.id)
b.put_float(l.pos.x)
@@ -132,18 +140,41 @@ static func encode_snapshot(world: SimWorld,
return b.data_array
# Fixed record sizes, mirroring what encode_snapshot writes. Pinned against the
# encoder by test_net_codec so the two cannot drift.
const SNAPSHOT_HEADER := 5 ## u32 tick + u8 countdown
const SNAPSHOT_PLAYER := 33
const SNAPSHOT_ENEMY := 16
const SNAPSHOT_BOSS := 17
const SNAPSHOT_LOOT := 13
## Decode one snapshot. The returned dictionary carries "valid": a short or
## corrupt packet answers false and the caller drops it.
##
## Snapshots ride an unreliable channel with no checksum, so a truncated one is
## a network event rather than only an attack -- and this decoder used to read
## straight through the end of the buffer. A garbage enemy count is up to 65535
## dictionaries built out of nothing, twenty times a second, and a garbage tick
## poisons the client's clock estimate and its input lead. Every colder message
## here already checks; the two hottest did not.
static func decode_snapshot(data: PackedByteArray) -> Dictionary:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var snap := {
"tick": b.get_u32(),
"cleared_countdown": b.get_u8(),
"tick": 0, "cleared_countdown": Protocol.COUNTDOWN_NONE,
"players": [], "enemies": [], "boss": null,
"loot": [], "inventory": [],
"loot": [], "inventory": [], "valid": false,
}
if b.get_available_bytes() < SNAPSHOT_HEADER + 1:
return snap
snap["tick"] = b.get_u32()
snap["cleared_countdown"] = b.get_u8()
var pcount := b.get_u8()
if b.get_available_bytes() < pcount * SNAPSHOT_PLAYER:
return snap
for _i in pcount:
snap["players"].append({
"peer": b.get_u32(),
@@ -159,7 +190,11 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"total_xp": b.get_u32(),
})
if b.get_available_bytes() < 2:
return snap
var ecount := b.get_u16()
if b.get_available_bytes() < ecount * SNAPSHOT_ENEMY:
return snap
for _i in ecount:
snap["enemies"].append({
"id": b.get_u32(),
@@ -169,7 +204,12 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"visual": b.get_u8(),
})
if b.get_u8() == 1:
if b.get_available_bytes() < 1:
return snap
var has_boss := b.get_u8() == 1
if has_boss and b.get_available_bytes() < SNAPSHOT_BOSS:
return snap
if has_boss:
snap["boss"] = {
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
@@ -177,18 +217,23 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"phase": b.get_u8(),
}
if b.get_available_bytes() < SimConfig.INVENTORY_SLOTS + 2:
return snap
var inventory: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
inventory.append(b.get_u8())
snap["inventory"] = inventory
var lcount := b.get_u16()
if b.get_available_bytes() < lcount * SNAPSHOT_LOOT:
return snap
for _i in lcount:
snap["loot"].append({
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"item": b.get_u8(),
})
snap["valid"] = true
return snap
@@ -271,16 +316,53 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
return b.data_array
## Bytes an event of [param t] writes after its type byte, or -1 for a type this
## build cannot parse.
##
## The decoder needs this BEFORE the match, because an event whose length it
## does not know is not a single bad record: every field after it is read at the
## wrong offset, so one stray byte becomes a stream of invented bullets at
## invented positions. Pinned against encode_events by test_net_codec.
static func event_payload_size(t: int) -> int:
match t:
SimEvent.Type.BULLET_SPAWN: return 36
SimEvent.Type.BULLET_DESPAWN: return 4
SimEvent.Type.PLAYER_HIT: return 8
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED: return 4
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED: return 5
SimEvent.Type.PLAYER_RESPAWNED: return 12
SimEvent.Type.ENEMY_HIT: return 10
SimEvent.Type.ENEMY_DIED: return 4
SimEvent.Type.BOSS_PHASE: return 1
SimEvent.Type.TELEGRAPH: return 14
SimEvent.Type.BOSS_DIED: return 0
return -1
## Returns { "tick": int, "events": Array[Dictionary] }.
##
## Decoding stops at the first record that does not fit or that names a type
## this build has no length for. The stream is reliable and ordered, so the
## prefix decoded before that point is sound and is kept -- unlike a snapshot,
## dropping the whole packet would strand every bullet it was about to despawn.
static func decode_events(data: PackedByteArray) -> Dictionary:
var out: Array[Dictionary] = []
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
if b.get_available_bytes() < 6:
return {"tick": 0, "events": out}
var server_tick := b.get_u32()
var count := b.get_u16()
for _i in count:
if b.get_available_bytes() < 1:
break
var t := b.get_u8()
var payload := event_payload_size(t)
if payload < 0 or b.get_available_bytes() < payload:
break
var ev := {"t": t}
match t:
SimEvent.Type.BULLET_SPAWN:
@@ -361,7 +443,8 @@ static func decode_inputs(data: PackedByteArray) -> Array[InputFrame]:
static func encode_roster(entries: Array[Dictionary]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(entries.size(), 255))
entries = entries.slice(0, 255)
b.put_u8(entries.size())
for e in entries:
b.put_u32(int(e["peer"]))
b.put_utf8_string(String(e["name"]))
@@ -535,7 +618,13 @@ static func encode_characters(chars: Array[Character], selected: String) -> Pack
b.put_u8(clampi(c.level, 1, 255))
b.put_u32(maxi(c.total_xp, 0))
b.put_u8(clampi(roundi(c.xp_progress() * 255.0), 0, 255))
b.put_u16(clampi(c.max_hp(), 1, 65535))
var ceiling := c.max_hp()
b.put_u16(clampi(ceiling, 1, 65535))
# Current health as well as the ceiling. Health survives a run now, so a
# character on the roster can be badly hurt, and a screen that showed
# only the maximum would advertise a wounded character as whole -- which
# under permadeath is the worst possible thing to be wrong about.
b.put_u16(clampi(ceiling if c.hp == Character.HP_FULL else c.hp, 1, 65535))
b.put_u8(1 if c.active else 0)
b.put_u32(c.colour.to_rgba32())
for i in SimConfig.INVENTORY_SLOTS:
@@ -574,8 +663,8 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
return {"selected": selected, "characters": out}
var count := b.get_u8()
# Bytes each entry needs after its two strings: level, xp, progress,
# max_hp, active, colour, then one byte per inventory slot.
var fixed := 1 + 4 + 1 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
# max_hp, hp, active, colour, then one byte per inventory slot.
var fixed := 1 + 4 + 1 + 2 + 2 + 1 + 4 + SimConfig.INVENTORY_SLOTS
for _i in count:
var id := _safe_utf8(b)
var display := _safe_utf8(b)
@@ -588,6 +677,7 @@ static func decode_characters(data: PackedByteArray) -> Dictionary:
"xp": b.get_u32(),
"progress": float(b.get_u8()) / 255.0,
"max_hp": b.get_u16(),
"hp": b.get_u16(),
"active": b.get_u8() == 1,
"colour": Color.hex(b.get_u32()),
"inventory": _read_inventory(b),
+5 -1
View File
@@ -22,7 +22,11 @@ extends RefCounted
## 8: upgrades. A new server -> client upgrade-state message, a new
## client -> server choice message, and two more SelectResult values.
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
const VERSION := 9
## 10: health survives a run, so the character roster carries current hit
## points as well as the ceiling. Inserted mid-record, which shifts every
## field after it -- a mismatched client would read the active flag out of
## the middle of a health value.
const VERSION := 10
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
+193 -23
View File
@@ -24,8 +24,25 @@ var store: CharacterStore = null
var peer_chunks: Dictionary[int, Dictionary] = {}
var lobby: Instance
## Ticks a peer must wait between control messages. Selecting or creating a
## character rewrites the whole store to disk, re-places the player (which wipes
## its map-chunk record and re-streams the neighbourhood), and broadcasts a
## roster to every peer on the server -- all off one small reliable packet.
## Unrated, one client could hold the server in a synchronous JSON write. A
## quarter of a second is far under anything a person clicks deliberately.
const CONTROL_COOLDOWN_TICKS := 15
var _next_instance_id: int = SimConfig.LOBBY_INSTANCE_ID
var _snapshot_phase: int = 0
## Server clock, in physics ticks. Only used to pace the control messages above.
var _tick: int = 0
## Tick each peer was last allowed a control message on.
var _peer_control_tick: Dictionary[int, int] = {}
## Peers already told off for going too fast. Without this the refusal logs one
## line per packet, which turns the limiter into the amplification it exists to
## prevent -- a client sending a thousand a second would get a thousand lines
## formatted and written. One line per burst, cleared when the peer behaves.
var _peer_control_warned: Dictionary[int, bool] = {}
func _ready() -> void:
@@ -49,6 +66,7 @@ func _take_instance_id() -> int:
func _physics_process(_delta: float) -> void:
_tick += 1
_snapshot_phase += 1
var send_snapshot := _snapshot_phase % SimConfig.SNAPSHOT_INTERVAL == 0
var closing: Array[int] = []
@@ -79,6 +97,12 @@ func _physics_process(_delta: float) -> void:
for id in closing:
_close_dungeon(id)
# One write per tick at most, however many item pickups and experience
# grants the tick produced. Everything that defers is bounded here; the
# paths that matter more (a character created, retired, or levelled) still
# write the moment they happen.
store.flush()
## Split the tick's events into the ones clients need and the ones only the
## server acts on (escape completion, portal use, respawn requests).
@@ -95,9 +119,11 @@ func _dispatch_events(inst: Instance) -> void:
for ev in events:
match int(ev["t"]):
SimEvent.Type.ENEMY_DIED:
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.BOSS_DIED:
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
# The simulation moved items between the ground and a bag; the
@@ -174,6 +200,7 @@ func on_peer_disconnected(peer_id: int) -> void:
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
_broadcast_roster()
return
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
_forget_peer(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
@@ -185,6 +212,8 @@ func _forget_peer(peer_id: int) -> void:
peer_chunks.erase(peer_id)
peer_accounts.erase(peer_id)
peer_characters.erase(peer_id)
_peer_control_tick.erase(peer_id)
_peer_control_warned.erase(peer_id)
_broadcast_roster()
@@ -192,6 +221,9 @@ func _forget_peer(peer_id: int) -> void:
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
## the same outcome, so it is simply removed.
func _release_linkdead(peer_id: int, inst: Instance) -> void:
# Whatever the channel left it on. Pulling the plug at one hit point must
# not be a way to come back whole.
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
GameLog.info("server", "peer %d released from instance %d after drop"
% [peer_id, inst.id])
@@ -206,14 +238,29 @@ func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void:
return # a second hello from the same peer is either a bug or an attack
if version != Protocol.VERSION:
GameLog.warn("server", "peer %d protocol %d != %d, rejecting" % [peer_id, version, Protocol.VERSION])
Net.send_reject(peer_id, "protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
Net.kick(peer_id)
Net.reject_and_kick(peer_id,
"protocol mismatch: server %d, client %d" % [Protocol.VERSION, version])
return
var account := Net.auth.validate(ticket)
if account == AuthProvider.NO_ACCOUNT:
GameLog.warn("server", "peer %d failed authentication" % peer_id)
Net.send_reject(peer_id, "authentication failed")
Net.kick(peer_id)
Net.reject_and_kick(peer_id, "authentication failed")
return
# One account, one session. Two peers on one account each get their own
# SimPlayer built from the SAME character record, so both hold a copy of the
# same bag and whichever writes last wins -- drop the one potion from both
# copies and two potions hit the floor. Refusing the second connection is the
# only fix that closes it; reconciling two live mirrors of one record after
# the fact cannot be made to work.
#
# The NEW peer is refused rather than the old one kicked, deliberately: with
# an auth provider anyone can spoof, "kick whoever holds this account" would
# be a way to disconnect any player on the server at will.
var held_by := peer_for_account(account)
if held_by != 0:
GameLog.warn("server", "peer %d claimed account %d, already held by peer %d"
% [peer_id, account, held_by])
Net.reject_and_kick(peer_id, "that account is already playing")
return
peer_accounts[peer_id] = account
Net.send_welcome(peer_id)
@@ -255,6 +302,16 @@ func on_select_character(peer_id: int, character_id: String) -> void:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
return
# Already playing this one, and already where a swap would put you. Answering
# yes and stopping is not just an optimisation: the full path rewrites the
# store, re-places the player and re-streams its map, so a client that simply
# repeats its own selection was free work for the asking.
if peer_characters.get(peer_id, "") == character_id and here != null:
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
_send_characters(peer_id)
return
if not _accept_control(peer_id):
return
var c := store.get_character(account, character_id)
if c == null:
Net.send_select_result(peer_id, Protocol.SelectResult.NO_SUCH_CHARACTER, "no such character")
@@ -278,6 +335,8 @@ func on_create_character(peer_id: int, character_name: String) -> void:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
return
if not _accept_control(peer_id):
return
var c := store.create_character(account, character_name)
if c == null:
Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED,
@@ -295,6 +354,7 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
var account: int = peer_accounts[peer_id]
var previous := instance_of(peer_id)
if previous != null:
_bank_health(previous, peer_id)
previous.remove_peer(peer_id)
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
@@ -374,6 +434,39 @@ func instance_of(peer_id: int) -> Instance:
return instances.get(id)
## Whether [param peer_id] may spend a control message this tick, recording it
## if so. Silent when it refuses: there is no SelectResult for "too fast", and a
## dropped message at this rate can only come from a client that is not a menu.
func _accept_control(peer_id: int) -> bool:
var last: int = _peer_control_tick.get(peer_id, -CONTROL_COOLDOWN_TICKS)
if _tick - last < CONTROL_COOLDOWN_TICKS:
if not _peer_control_warned.get(peer_id, false):
_peer_control_warned[peer_id] = true
GameLog.warn("server",
"peer %d is sending control messages faster than %d ticks apart"
% [peer_id, CONTROL_COOLDOWN_TICKS])
return false
_peer_control_tick[peer_id] = _tick
_peer_control_warned.erase(peer_id)
return true
## The peer currently signed in as [param account], or 0 for none.
##
## A peer that dropped inside a dungeon still counts while its linkdead body is
## channelling out, which is the point: reconnecting during that second would
## put the same character in the world twice -- once as the body still being
## shot at, once fresh in the hub -- and a death on the abandoned body would
## then retire the character being played.
func peer_for_account(account: int) -> int:
if account == AuthProvider.NO_ACCOUNT:
return 0
for peer in peer_accounts:
if peer_accounts[peer] == account:
return peer
return 0
# --- Transfers --------------------------------------------------------------
func _place(peer_id: int, inst: Instance) -> void:
@@ -419,15 +512,19 @@ func _adopt_character(peer_id: int, inst: Instance) -> void:
if c == null or p == null:
return
p.adopt(c)
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
# new ceiling, which would leave a character whose upgrades RAISED it
# arriving wounded for no reason.
p.hp = p.max_hp
# Health comes off the record, not from a full bar. Arriving used to heal
# you outright, which meant a run cost nothing: walk in, take what you can,
# walk out, walk back in whole. It is paid off in the hub instead, which
# regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not
# instantly. HP_FULL covers a new character and a save written before health
# was kept; the clamp covers upgrades that moved the ceiling either way.
p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp)
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
_bank_health(from, peer_id)
from.remove_peer(peer_id)
_place(peer_id, to)
_broadcast_roster()
@@ -490,6 +587,17 @@ func _close_dungeon(id: int) -> void:
return
for peer in inst.peers.duplicate():
_transfer(peer, lobby)
# A linkdead body is not in inst.peers, so the loop above walks straight past
# it. The empty-instance path checks has_linkdead() and waits; the CLEARED
# path does not, so a party that killed the boss with one member disconnected
# left that peer's account, name and instance id in the dictionaries with no
# instance to resolve -- invisible, permanent, and enough to keep the account
# locked out of its own session by the one-account guard.
for p in inst.world.players.values():
if p.linkdead:
GameLog.info("server", "peer %d released with instance %d as it closed"
% [p.peer_id, id])
_forget_peer(p.peer_id)
instances.erase(id)
GameLog.info("server", "closed dungeon instance %d" % id)
@@ -521,16 +629,26 @@ func _stream_map(peer_id: int, inst: Instance) -> void:
NetCodec.encode_map_chunks(inst.world.map, batch))
## Experience is shared by everyone alive in the instance, undivided. Splitting
## it would make bringing a friend cost you progress, which is the opposite of
## what a co-op game wants; the hub roster exists to help people group up.
func _award_kill(inst: Instance, amount: int) -> void:
## Experience is shared, undivided, by everyone alive and near enough to
## [param at] to have been part of it.
##
## Undivided is the settled decision: splitting it would make bringing a friend
## cost you progress, which is the opposite of what a co-op game wants. The
## radius is a separate question -- who was there -- and it exists because
## without one a player parked at the entrance collected full boss experience
## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a
## player can see, so anyone who watched it die is inside it and anyone who
## could not possibly have known is not.
func _award_kill(inst: Instance, amount: int, at: Vector2) -> void:
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
return
var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS
for peer in inst.peers:
var p: SimPlayer = inst.world.players.get(peer)
if p == null or not p.alive:
continue
if p.pos.distance_squared_to(at) > reach_sq:
continue
_grant_xp(peer, amount)
@@ -540,6 +658,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
## point of persisting an inventory is that a crash between "picked it up" and
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
## duplicate one.
## Copy a player's current health back onto its character record.
##
## Called wherever the [SimPlayer] is about to stop existing -- every transfer,
## every disconnect -- because SimWorld builds a blank one on the other side and
## whatever was not written down is gone. Also called on item transactions, so
## drinking a potion is banked with the potion that was spent.
func _bank_health(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null or not p.alive:
return # a dead character is retired; its health is not a thing anymore
store.set_health(account, character_id, p.hp)
func _persist_inventory(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
@@ -548,7 +683,14 @@ func _persist_inventory(inst: Instance, peer_id: int) -> void:
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return
store.set_inventory(account, character_id, p.inventory)
# Deferred: a client can produce an item action every tick, and each one used
# to re-serialise every account on the server. ServerRuntime flushes once at
# the end of the tick.
store.set_inventory(account, character_id, p.inventory, true)
# A potion moves an item and hit points in the same action; banking one
# without the other would let a restart hand the potion back.
if p.alive:
store.set_health(account, character_id, p.hp)
func _grant_xp(peer_id: int, amount: int) -> void:
@@ -655,30 +797,58 @@ func _broadcast_roster() -> void:
Net.send_roster(peer, payload)
## Bullet spawns within this peer's interest radius. See
## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the
## one used for actors.
## Bullet spawns this peer could actually be reached by.
##
## Tested per bullet rather than against a flat radius, and that distinction is
## the whole point. A spawn event carries the firing actor's exact position, so
## a radius wide enough for the fastest shot in the game hands a modified client
## the location of every enemy inside it -- 2900 units, against an 800-unit
## actor cull and a 900-unit map stream. At the depth the game actually runs at
## the map's own diagonal is about 2521, so the flat radius culled NOTHING: an
## enemy firing anywhere on the floor was pinpointed for everyone, through the
## fog, on terrain the client had deliberately not been streamed.
##
## Asking instead whether THIS bullet can reach ground this player can see keeps
## the guarantee the wide radius existed for -- nothing ever arrives invisible --
## while a slow, short-lived shot across the map stops being a free radar ping.
func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]:
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return spawns
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
var out: Array[Dictionary] = []
for ev in spawns:
if p.pos.distance_squared_to(ev["pos"]) <= cull_sq:
if _bullet_concerns(p.pos, ev["pos"], ev["vel"], ev["accel"], ev["life"]):
out.append(ev)
return out
## Whether a bullet born at [param at] could reach ground visible from
## [param eye] before it expires. BULLET_INTEREST_RADIUS stays on as a hard
## ceiling: test_interest.gd proves no bullet in the game needs more than it, so
## it can only ever agree with the per-bullet answer -- but if some future
## content outruns the computation, the failure is a culled bullet rather than
## an unbounded broadcast.
func _bullet_concerns(eye: Vector2, at: Vector2, vel: Vector2, accel: float,
life: int) -> bool:
var horizon := minf(
Movement.bullet_reach(vel.length(), accel, life) + SimConfig.FOG_VIEW_RADIUS,
SimConfig.BULLET_INTEREST_RADIUS)
return eye.distance_squared_to(at) <= horizon * horizon
func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]:
var out: Array[Dictionary] = []
var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null
var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS
for i in world.pool.high_water:
if world.pool.alive[i] == 0:
continue
if observer != null \
and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq:
# Same per-bullet question as _spawns_near. The backlog a joiner is sent
# is a snapshot of every live bullet, so a flat radius here leaked the
# position of every firing enemy in the instance the moment you walked
# in -- the one moment you have seen none of the map yet.
if observer != null and not _bullet_concerns(observer.pos,
world.pool.pos[i], world.pool.vel[i], world.pool.accel[i],
world.pool.life[i]):
continue
out.append({
"t": SimEvent.Type.BULLET_SPAWN,
+80 -18
View File
@@ -7,6 +7,16 @@ extends RefCounted
## reproducible from its log line. It does NOT mean the client can rebuild the
## map -- see the note on build().
## Tunnel widths, in tiles, rolled per leg of every corridor.
##
## The floor is 2 because a one-tile tunnel in a bullet hell is a shooting
## gallery: there is no side to step to, so anything fired down it lands. Each
## leg of an L rolls its own width, so a tunnel can open up or pinch at its
## bend rather than every connection in the map being the same pipe.
const CORRIDOR_MIN_WIDTH := 2
const CORRIDOR_MAX_WIDTH := 4
## The single entry point. SERVER-SIDE ONLY.
##
## This used to be described as "both sides call it with the same three
@@ -140,13 +150,25 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# Connect each room to the previous one, then the last to the arena door,
# so every room is reachable by construction rather than by luck.
var widths := PackedInt32Array()
var legs: Array[Rect2i] = []
for i in range(1, rooms.size()):
_carve_corridor(grid, _centre_of(rooms[i - 1]), _centre_of(rooms[i]))
var from := _centre_of(rooms[i - 1])
widths.append_array(_carve_corridor(grid, rng, from, _centre_of(rooms[i]),
(from.x + from.y) % 2 == 0, boss_room, legs))
if not rooms.is_empty():
_carve_corridor(grid, _centre_of(rooms[rooms.size() - 1]), door)
# Aimed at the tile OUTSIDE the door, and always arriving along the
# door's own row. Aiming at the door itself, an L that bent the other
# way ran its second leg straight down the arena's west wall -- 45% of
# generated arenas had a slit carved in an authored room. The arena is
# also kept out of every carve, so a wide tunnel stops at its wall
# rather than eating into it.
var outside := door + Vector2i(-1, 0)
widths.append_array(_carve_corridor(grid, rng,
_centre_of(rooms[rooms.size() - 1]), outside, false, boss_room, legs))
# The door tile itself, and the tile outside it, must be floor.
grid.set_tile(door.x, door.y, MapGrid.Kind.FLOOR)
grid.set_tile(door.x - 1, door.y, MapGrid.Kind.FLOOR)
grid.set_tile(outside.x, outside.y, MapGrid.Kind.FLOOR)
_scatter_cover(grid, rooms, rng)
@@ -168,6 +190,14 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
# What every corridor leg was carved at. Not used by anything that
# builds a world -- it is here so a test can check the widths actually
# vary, which the tiles alone cannot say once tunnels overlap.
"corridor_widths": widths,
# The rectangle each tunnel leg was carved over. Overlaps the rooms it
# joins at its ends, so whoever uses it decides what counts as tunnel --
# Instance._populate_tunnels keeps only floor clear of every room.
"tunnels": legs,
# Which boss lives here follows from WHICH arena was stamped. Choosing
# it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there.
@@ -181,23 +211,55 @@ static func _centre_of(r: Rect2i) -> Vector2i:
## L-shaped, with the corner order chosen by parity so corridors do not all
## bend the same way.
static func _carve_corridor(grid: MapGrid, from: Vector2i, to: Vector2i) -> void:
if (from.x + from.y) % 2 == 0:
_carve_h(grid, from.x, to.x, from.y)
_carve_v(grid, from.y, to.y, to.x)
## An L-shaped tunnel from [param from] to [param to], each leg its own width.
## Returns the two widths it rolled.
##
## [param horizontal_first] picks which way the L bends. [param keep_out] is
## never carved: it is the boss arena, which is authored, and keeps exactly the
## walls it was drawn with.
static func _carve_corridor(grid: MapGrid, rng: RandomNumberGenerator,
from: Vector2i, to: Vector2i, horizontal_first: bool,
keep_out: Rect2i, legs: Array[Rect2i]) -> PackedInt32Array:
var first := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var second := rng.randi_range(CORRIDOR_MIN_WIDTH, CORRIDOR_MAX_WIDTH)
var bend := Vector2i(to.x, from.y) if horizontal_first else Vector2i(from.x, to.y)
# Each leg overshoots the bend by half the other's width, so the corner is
# carved square. Cosmetic, not structural: the two legs always overlap on
# the INNER side of the bend, so leaving this out never narrows a tunnel
# below its width -- it just bevels the outer corner, which reads as a
# notch rather than a turn.
legs.append(_carve_leg(grid, from, bend, first, second / 2, keep_out))
legs.append(_carve_leg(grid, bend, to, second, first / 2, keep_out))
return PackedInt32Array([first, second])
## One straight leg of [param width] tiles between two points sharing a row or
## a column, extended [param overshoot] tiles past both ends.
##
## Never touches the map's outermost ring: out-of-bounds reads as wall, and the
## world is sealed by construction, so a wide tunnel that hugs the edge must
## stop one tile short of it rather than open the map to nothing.
static func _carve_leg(grid: MapGrid, a: Vector2i, b: Vector2i, width: int,
overshoot: int, keep_out: Rect2i) -> Rect2i:
# Width is laid across the leg, centred on its line: two tiles is the line
# and the one after it, three is one either side, four leans one way.
var lo := -(width - 1) / 2
var hi := width / 2
var rect: Rect2i
if a.y == b.y:
var x0 := mini(a.x, b.x) - overshoot
var x1 := maxi(a.x, b.x) + overshoot
rect = Rect2i(x0, a.y + lo, x1 - x0 + 1, hi - lo + 1)
else:
_carve_v(grid, from.y, to.y, from.x)
_carve_h(grid, from.x, to.x, to.y)
static func _carve_h(grid: MapGrid, x0: int, x1: int, y: int) -> void:
for x in range(mini(x0, x1), maxi(x0, x1) + 1):
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
static func _carve_v(grid: MapGrid, y0: int, y1: int, x: int) -> void:
for y in range(mini(y0, y1), maxi(y0, y1) + 1):
var y0 := mini(a.y, b.y) - overshoot
var y1 := maxi(a.y, b.y) + overshoot
rect = Rect2i(a.x + lo, y0, hi - lo + 1, y1 - y0 + 1)
for y in range(maxi(rect.position.y, 1), mini(rect.end.y, grid.height - 1)):
for x in range(maxi(rect.position.x, 1), mini(rect.end.x, grid.width - 1)):
if keep_out.has_point(Vector2i(x, y)):
continue
grid.set_tile(x, y, MapGrid.Kind.FLOOR)
return rect
## A few pillars and pits inside generated rooms, so open rooms still have
+46 -6
View File
@@ -54,6 +54,11 @@ var tiles := PackedByteArray()
## existing coordinate (spawn points, portal, boss placement) meaningful and
## avoids an all-positive coordinate space where "0" is a corner.
var origin := Vector2.ZERO
## Bumped by every write, so something that derives data from the tiles can tell
## whether it is stale without comparing them. The client's fog extracts wall
## corners from the map and would otherwise have to redo it every frame, or miss
## a chunk arriving.
var revision: int = 0
func _init(w: int = 1, h: int = 1, fill: Kind = Kind.WALL) -> void:
@@ -65,6 +70,7 @@ func resize(w: int, h: int, fill: Kind = Kind.WALL) -> void:
height = maxi(h, 1)
tiles.resize(width * height)
tiles.fill(fill)
revision += 1
func in_bounds(tx: int, ty: int) -> bool:
@@ -82,6 +88,7 @@ func at(tx: int, ty: int) -> Kind:
func set_tile(tx: int, ty: int, kind: Kind) -> void:
if in_bounds(tx, ty):
tiles[ty * width + tx] = kind
revision += 1
func fill_rect(rect: Rect2i, kind: Kind) -> void:
@@ -111,6 +118,7 @@ func to_tile(world: Vector2) -> Vector2i:
## Centre the map on the world origin.
func centre_on_origin() -> void:
origin = -world_size() * 0.5
revision += 1
## World-space rectangle the map occupies.
@@ -175,9 +183,39 @@ func slide_circle(pos: Vector2, delta: Vector2, radius: float) -> Vector2:
return out
## Bresenham-style sight test between two world points. Used for fog on the
## client and for aggro on the server, so it has to agree on both.
## Bresenham-style sight test between two world points, from tile to tile.
## Used for aggro on the server.
##
## It used to drive the client's fog as well, which is why the two once had to
## agree. The fog is exact geometry now (see Visibility), so this is the
## server's question alone -- and it is coarser than what the player sees: its
## diagonal steps pass between two tiles that touch only at a corner, which
## the fog treats as closed.
##
## [param include_end] is false here: the endpoints themselves never block, so
## standing in a doorway, or shooting at something embedded in a wall, still
## resolves.
func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
return _walk_clear(from, to, BLOCKS_SIGHT, false)
## Whether a bullet could travel from [param from] to [param to] without
## crossing geometry.
##
## Needed because a muzzle offset is a teleport: [BulletPool] samples position
## once per tick and never sees the jump from a player's centre out to where the
## shot is actually born, so anything that places a bullet away from its shooter
## has to ask this first. Unlike sight, the DESTINATION counts -- a bullet born
## inside a wall is exactly the case being ruled out.
func bullet_path_clear(from: Vector2, to: Vector2) -> bool:
return _walk_clear(from, to, BLOCKS_BULLET, true)
## Shared Bresenham walk behind both tests above, so the two can never disagree
## about which tiles a straight line crosses. [param flags] is one of the
## BLOCKS_* tables; [param include_end] decides whether the destination tile is
## allowed to stop the walk.
func _walk_clear(from: Vector2, to: Vector2, flags: Array, include_end: bool) -> bool:
var a := to_tile(from)
var b := to_tile(to)
var dx := absi(b.x - a.x)
@@ -192,12 +230,13 @@ func has_line_of_sight(from: Vector2, to: Vector2) -> bool:
var limit := width + height + 4
while steps < limit:
steps += 1
if x == b.x and y == b.y:
var at_end := x == b.x and y == b.y
if at_end and not include_end:
return true
# The endpoints themselves never block: standing in a doorway, or
# shooting at something embedded in a wall, must still resolve.
if not (x == a.x and y == a.y) and blocks_sight(x, y):
if not (x == a.x and y == a.y) and bool(flags[at(x, y)]):
return false
if at_end:
return true
var e2 := 2 * err
if e2 >= dy:
err += dy
@@ -278,3 +317,4 @@ func apply_chunk(chunk_id: int, data: PackedByteArray) -> void:
for tx in range(r.position.x, r.end.x):
tiles[ty * width + tx] = data[i]
i += 1
revision += 1
+38 -1
View File
@@ -54,7 +54,44 @@ func shot_index_at(local_tick: int) -> int:
## Shared spawn path so every emitter produces bullets with identical physics.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void:
##
## [param from] is where the shot appears, which for nearly every emitter is a
## muzzle offset away from the shooter -- up to 46px here, and an arc sweep's
## outer arms further still. That offset is a teleport: [BulletPool] samples
## geometry once per tick and never sees the jump, so a shot could be born on
## the far side of a 32px wall and fly away down the other side of it. A
## barricade makes it worse than a curiosity, because a barricade stops bullets
## while deliberately NOT stopping sight -- so an enemy behind one sees you,
## aggros, fires, and hits you through the cover that exists to prevent exactly
## that. Shots that cannot reach their own muzzle are dropped, which is what
## would have happened a tick later anyway if they had had to travel there.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2,
speed_scale: float = 1.0) -> void:
emit_shot_from(ctx, angle, from, ctx.origin, speed_scale)
## As [method emit_shot], for a burst that is not travelling out of the shooter.
## [param travels_from] is the point the shot is treated as leaving -- a
## telegraphed strike passes its own marker, because that burst is born at the
## spot the player was warned about and has nothing to do with where the boss is
## standing.
func emit_shot_from(ctx: EmitContext, angle: float, from: Vector2,
travels_from: Vector2, speed_scale: float = 1.0) -> void:
# ctx.pool.map is null when a test fires a pattern into a bare pool, which
# keeps EmitContext as narrow as its own comment promises.
var map: MapGrid = ctx.pool.map
if map != null:
# Two questions, not one. The path check skips its own starting tile, so
# a shooter standing in a doorway still resolves -- but that skip also
# lets a burst whose centre and whose ring both sit inside one wall tile
# through. Those bullets are born in the wall, announced to every client,
# and die on the next tick: a flicker of shots inside a barricade. A
# bullet must never exist inside geometry, so the destination is asked
# separately.
if map.bullet_blocked(from):
return
if not map.bullet_path_clear(travels_from, from):
return
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
accel, deg_to_rad(turn_deg))
@@ -59,7 +59,10 @@ func _burst(ctx: EmitContext, at: Vector2) -> void:
return
for i in burst_count:
var a := TAU * (float(i) / float(burst_count))
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
# From the marker, not from the boss: this burst is born where the
# player was warned it would be, and a pillar between the boss and the
# spot has nothing to say about it.
emit_shot_from(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25), at)
## Where volley [param volley]'s strike [param index] lands, inside
+20 -5
View File
@@ -118,6 +118,16 @@ func can_request_respawn() -> bool:
## Adopt a character's stats. Called when a player picks or switches character.
## Inventory comes along: it is stored on the character, so swapping in the hub
## swaps bags too rather than handing one character another's potions.
## Adopt a character's stats. Called when a player picks or switches character,
## and again whenever an upgrade changes the numbers.
##
## Deliberately does NOT set current health. It is called mid-session by
## on_choose_upgrade, and reading health from the record there would snap a
## player back to whatever was last written to disk. recompute_max_hp() still
## clamps downwards, because Glass Cannon halving your ceiling has to take the
## hit points above it with them. Where current health comes from on arrival is
## ServerRuntime._adopt_character, which is the one place a record becomes a
## live player.
func adopt(c: Character) -> void:
character_id = c.id
display_name = c.display_name
@@ -126,7 +136,6 @@ func adopt(c: Character) -> void:
colour = c.colour
stats = PlayerStats.build(c.upgrades)
recompute_max_hp()
hp = mini(hp, max_hp)
set_inventory(c.inventory)
@@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int:
return hp - before
## One tick of passive healing. Returns true if the visible hit points changed,
## so the caller can decide whether anything needs announcing.
func regenerate() -> bool:
## One tick of passive healing at [param percent_per_sec] of maximum health.
## Returns true if the visible hit points changed, so the caller can decide
## whether anything needs announcing.
##
## The rate is a property of where you are standing rather than a constant: the
## hub heals far faster than a dungeon does, because recovering between runs
## should take a moment rather than a resource, and recovering DURING one
## should not be possible at all by waiting.
func regenerate(percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC) -> bool:
if not alive or hp >= max_hp:
regen_carry = 0.0
return false
regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
regen_carry += float(max_hp) * (percent_per_sec / 100.0) \
* SimConfig.TICK_DELTA
if regen_carry < 1.0:
return false
+42 -4
View File
@@ -46,6 +46,17 @@ var spawn_point := Vector2(0.0, 240.0)
## Arrival protection granted to players entering this world. 0 in the hub,
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
var spawn_grace_ticks: int = 0
## Whether the emergency escape does anything here. False only in the hub: the
## escape's completion is routed to "send this peer to the lobby", and in the
## lobby that is a transfer to the instance you are already in -- which re-places
## the player, teleporting it to the spawn point, healing it to full, dropping
## its instanced loot and re-streaming every map chunk around it, once a second
## for as long as the key is held. A world you can leave is the default; the hub
## is the one that opts out.
var escape_enabled: bool = true
## Passive healing here, as a percentage of maximum health per second. The hub
## overrides it -- see SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC.
var regen_percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC
var _next_actor_id: int = 1
var _ctx := EmitContext.new()
@@ -209,7 +220,7 @@ func step() -> void:
func _step_players() -> void:
for p in players.values():
p.regenerate()
p.regenerate(regen_percent_per_sec)
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0:
@@ -317,9 +328,21 @@ func _fire_player_shot(p: SimPlayer) -> void:
forward)
## Place one shot. [param at] is where the muzzle wants it; if geometry stands
## between the shooter and that point the bullet is born on the shooter instead.
##
## A muzzle offset is a teleport, and [BulletPool] never sees it: it samples
## geometry once per tick, so a bullet that starts on the far side of a wall
## simply flies away down the other side. Forward that is only 19px, but
## Doubleshot adds up to 45px SIDEWAYS -- more than a 32px tile is thick, plus
## the 6px hitbox that keeps you off the wall. Six Doubleshots and standing
## flush against a wall was a wallhack reachable with a stock client. Falling
## back to p.pos rather than dropping the shot keeps the shot count honest:
## you fired what the upgrade says you fired, it just left from your centre.
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
var from := at if map.bullet_path_clear(p.pos, at) else p.pos
var slot := pool.spawn(
at,
from,
dir * p.stats.bullet_speed,
SimConfig.PLAYER_BULLET_RADIUS,
SimConfig.PLAYER_BULLET_LIFETIME,
@@ -459,6 +482,13 @@ func _drop_slot(p: SimPlayer, slot_index: int) -> void:
return
if p.inventory[slot_index] == Items.NONE:
return
# Refused rather than made room for. spawn_loot evicts the oldest item when
# the floor is full, which is right for a dungeon's own drops and wrong for a
# player's: the hub never closes, so a player dropping into a full floor was
# destroying whatever someone else had left there longest -- and the drop key
# can be pressed every tick. Your item stays in your bag instead.
if loot.size() >= SimConfig.MAX_LOOT_PER_INSTANCE:
return
var item := p.take_slot(slot_index)
# Anything dropped becomes world-shared, even if it arrived as an instanced
# drop. That is what makes dropping worth having: an item you do not want
@@ -468,6 +498,8 @@ func _drop_slot(p: SimPlayer, slot_index: int) -> void:
func _step_escape(p: SimPlayer, frame: InputFrame) -> void:
if not escape_enabled:
return
# A dropped connection is treated as holding the button down. Pulling the
# plug then costs exactly what pressing escape costs -- one second of
# standing there, still killable -- instead of an instant, safe exit.
@@ -833,7 +865,12 @@ func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
_drop_loot(e.def.loot, e.pos)
# The def id rides along so the instance layer can score it without
# looking up an actor that is about to stop existing.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
# "pos" is read by ServerRuntime to decide who was close enough to be
# paid, and is deliberately not in the wire format -- encode_events
# writes the id and nothing else. A client has no use for it, and every
# field that does not go out is one that cannot be mined.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id,
"def": String(e.def.id), "pos": e.pos})
## Returns the damage actually applied, after the phase's armour multiplier, so
@@ -851,7 +888,8 @@ func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
if boss.hp <= 0:
boss.alive = false
_drop_loot(boss.def.loot, boss.pos)
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id),
"pos": boss.pos})
return applied
+28 -2
View File
@@ -14,6 +14,7 @@ var _list: VBoxContainer
var _name_field: LineEdit
var _create_button: Button
var _status: Label
var _fallen: Label
var _title: Label
var _close_button: Button
var _known: Array[Dictionary] = []
@@ -51,6 +52,14 @@ func _ready() -> void:
_title.add_theme_font_size_override("font_size", 26)
panel.add_child(_title)
_fallen = Label.new()
_fallen.add_theme_font_size_override("font_size", 14)
_fallen.add_theme_color_override("font_color", Color(0.95, 0.55, 0.55))
_fallen.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_fallen.custom_minimum_size = Vector2(460.0, 0.0)
_fallen.visible = false
panel.add_child(_fallen)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 4)
panel.add_child(_list)
@@ -140,8 +149,17 @@ func _row_for(c: Dictionary) -> Control:
var playing := String(c["id"]) == _selected
var label := Label.new()
label.text = " %s level %d %d hp%s" % [
c["name"], int(c["level"]), int(c["max_hp"]),
# Current health is shown only when it is not full. Health carries between
# runs now, so "1300 hp" on a character sitting on 160 would be a lie told
# at the exact moment it matters most -- but printing "1300/1300" on every
# healthy character is noise, so the fraction appears only when it means
# something.
var ceiling := int(c["max_hp"])
var current := int(c.get("hp", ceiling))
var health := "%d hp" % ceiling if current >= ceiling \
else "%d/%d hp" % [current, ceiling]
label.text = " %s level %d %s%s" % [
c["name"], int(c["level"]), health,
" <- playing" if playing else ""]
label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
@@ -157,5 +175,13 @@ func _row_for(c: Dictionary) -> Control:
## Whether the player may dismiss this screen. False when they have nothing to
## play, in which case there is nowhere to dismiss it to.
## A line for the character that was just lost, above the list of the ones
## that are left. Separate from the status line, which is for refusals: a death
## is not an error, and the status line clears the moment anything else is said.
func set_fallen(text: String) -> void:
_fallen.text = text
_fallen.visible = not text.is_empty()
func set_dismissible(can_close: bool) -> void:
_close_button.visible = can_close
+678 -188
View File
@@ -1,6 +1,13 @@
extends CanvasLayer
## Heads-up display. Built in code rather than as a scene because every element
## is data-driven -- there is no layout here a designer would want to drag.
##
## Layout, because several things here used to land on top of each other:
## top-left is you (name, where, health, experience, escape, level-ups), stacked
## by a running cursor so nothing has a fixed row to collide on; top-centre is
## the boss; top-right is everyone else; the bottom centre is the inventory
## with the pickup prompt and the message feed stacked above it; the controls
## hint has the bottom-left corner to itself.
const MARGIN := 24.0
const BAR_W := 260.0
@@ -19,16 +26,87 @@ const SLOT_GAP := 8.0
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0
const BOSS_BAR_W := 480.0
## The message feed: how long a message stays, and how many at once.
const TOAST_LIFE := 2.6
const TOAST_FADE := 0.8
const TOAST_MAX := 4
## Health below this share of maximum pulses the screen edge.
const LOW_HEALTH := 0.35
## How long the controls hint stays up after arriving in a dungeon. In the hub
## it stays: that is where you are learning the keys, and nothing is shooting.
const HINT_HOLD := 6.0
const HINT_FADE := 1.5
## Health bar, taller than the others because it carries its own numbers.
const HP_H := 20.0
## Experience is a thin strip under health: it matters between fights, not in
## them.
const XP_H := 10.0
## How long the damage trail holds before draining, and how fast it drains (as
## a share of the bar per second). Long enough to read what the hit took.
const TRAIL_HOLD := 0.45
const TRAIL_DRAIN := 0.9
## Centre-screen banners: how long one stays, and its fade at each end.
const BANNER_LIFE := 2.4
const BANNER_FADE := 0.5
## The inside of every bar. The pack's bar sprite is light all the way through,
## so on its own an EMPTY bar was the brightest thing on screen and a player on
## a fifth of their health saw a bar that read as nearly full.
const COL_TRACK := Color(0.07, 0.07, 0.11)
const COL_TEXT := Color(0.86, 0.9, 0.97)
const COL_DIM := Color(0.58, 0.63, 0.74)
const COL_HEALTH := Color(0.35, 0.9, 0.6)
const COL_GOLD := Color(1.0, 0.85, 0.4)
const COL_DANGER := Color(1.0, 0.25, 0.3)
signal respawn_pressed
## What the world view could see this frame, handed over by the game scene so
## the boss bar can wait until the boss has actually been seen. See
## _draw_boss_bar.
var sight: Visibility = null
## Mirrors the F1 overlay. The instance number and frame rate are for whoever
## is debugging, not for whoever is playing, so they live behind it.
var debug_info: bool = false
var _canvas: Control
var _status: Label
var _hint: Label
var _respawn_button: Button
var _hit_flash: float = 0.0
var client: ClientRuntime = null
var _toasts: Array[Dictionary] = []
## The damage trail: where the health bar was before the last hit, held a
## moment and then drained down to where it is now.
var _hp_trail: float = 1.0
var _hp_last: float = 1.0
var _hp_hold: float = 0.0
## Experience just gained, shown beside the bar for a moment.
var _xp_last: int = -1
var _xp_gain: int = 0
var _xp_flash: float = 0.0
## The banner currently shown, or an empty title.
var _banner_title := ""
var _banner_sub := ""
var _banner_col := Color.WHITE
var _banner_born: float = -100.0
## The boss phase last announced, keyed like _boss_seen.
var _boss_phase_key := ""
var _boss_phase: int = -1
## Dark backing for every panel of text over the world. Built once.
var _panel_style: StyleBoxFlat = null
## Which boss, in which instance, has been seen -- see _draw_boss_bar.
var _boss_seen := ""
## The level last drawn and whose it was, so a level-up can be announced the
## moment it happens and a character switch is not mistaken for one.
var _seen_level: int = -1
var _seen_character := ""
## When the current instance was entered, for fading the controls hint.
var _instance_seen: int = -1
var _instance_since: float = 0.0
func _ready() -> void:
layer = 10
@@ -45,20 +123,6 @@ func _ready() -> void:
_canvas.draw.connect(_draw_hud)
add_child(_canvas)
_status = _make_label(Vector2(MARGIN, MARGIN))
_hint = _make_label(Vector2(MARGIN, 0.0))
_hint.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
# `.position` assigns an ABSOLUTE coordinate regardless of anchors (that's
# what stranded this label off-screen despite the anchor being correct).
# offset_left/offset_top are the anchor-relative ones -- negative offset_top
# here means "56px above the bottom anchor", which is what "-56" was meant
# to say in the first place.
_hint.offset_left = MARGIN
# Above the inventory row, not through it. Derived from the row's own
# geometry rather than written down, because the slots grew from 46px to 64
# and a hardcoded -56 put the control hints straight across them.
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
# A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so
# death is not exited by whatever key happened to be under a finger.
@@ -74,24 +138,101 @@ func _ready() -> void:
_canvas.add_child(_respawn_button)
func _make_label(pos: Vector2) -> Label:
var l := Label.new()
l.position = pos
l.add_theme_font_size_override("font_size", 14)
l.add_theme_color_override("font_color", Color(0.8, 0.85, 0.95))
_canvas.add_child(l)
return l
func _process(delta: float) -> void:
client = Net.client
_hit_flash = maxf(_hit_flash - delta * 2.5, 0.0)
_status.text = _status_text()
_hint.text = _hint_text()
var now := _now()
_toasts = _toasts.filter(func(t: Dictionary) -> bool:
return now - float(t["born"]) < TOAST_LIFE)
if client != null:
if client.instance_id != _instance_seen:
_instance_seen = client.instance_id
_instance_since = now
_watch_level()
_watch_health(delta)
_watch_experience(delta)
_update_respawn_button()
_canvas.queue_redraw()
func _now() -> float:
return float(Time.get_ticks_msec()) / 1000.0
## A short line in the message feed above the inventory. Called for things the
## server just confirmed -- an item taken, used or dropped, a level gained -- so
## the feed is a record of what happened, never of what the client hoped.
func notify(text: String, colour: Color = COL_TEXT) -> void:
_toasts.append({"text": text, "colour": colour, "born": _now()})
while _toasts.size() > TOAST_MAX:
_toasts.pop_front()
func flash_hit() -> void:
_hit_flash = 1.0
## A line across the middle of the screen for a moment: a level gained, a boss
## changing phase. Things that change what you should do next, which is why
## they are not in the message feed with the potions.
func banner(title: String, sub: String = "", colour: Color = COL_GOLD) -> void:
_banner_title = title
_banner_sub = sub
_banner_col = colour
_banner_born = _now()
## Keep the damage trail: when health drops, the trail stays where it was for
## TRAIL_HOLD and then drains to meet the bar. Healing moves both at once.
func _watch_health(delta: float) -> void:
var frac := clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
if frac < _hp_last - 0.0001:
_hp_trail = maxf(_hp_trail, _hp_last)
_hp_hold = TRAIL_HOLD
if frac >= _hp_trail:
_hp_trail = frac
if _hp_hold > 0.0:
_hp_hold -= delta
else:
_hp_trail = move_toward(_hp_trail, frac, TRAIL_DRAIN * delta)
_hp_last = frac
func _watch_experience(delta: float) -> void:
_xp_flash = maxf(_xp_flash - delta, 0.0)
var xp := client.my_total_xp
if _xp_last >= 0 and xp > _xp_last and String(_seen_character) != "":
# Summed while the flash is up, so a burst of kills reads as one total
# rather than as a number that flickers.
_xp_gain = (_xp_gain if _xp_flash > 0.0 else 0) + (xp - _xp_last)
_xp_flash = 1.4
_xp_last = xp
## Announce a level the moment the snapshot's experience crosses one. Watched
## rather than signalled because experience rides the snapshot, and the first
## sight of a character -- logging in, swapping -- must not read as a level-up.
func _watch_level() -> void:
var who := client.current_character()
if who.is_empty():
_seen_character = ""
return
var level := Progression.level_for_xp(client.my_total_xp)
if String(who["id"]) != _seen_character:
_seen_character = String(who["id"])
_seen_level = level
# A new character is not a hit, and its experience is not a gain.
_hp_trail = clampf(float(client.my_hp) / float(maxi(client.my_max_hp, 1)), 0.0, 1.0)
_hp_last = _hp_trail
_xp_last = client.my_total_xp
return
if level > _seen_level:
banner("LEVEL %d" % level,
"a choice waits at the quartermaster" if level < Progression.MAX_LEVEL
else "the highest there is", COL_GOLD)
_seen_level = level
func _update_respawn_button() -> void:
var downed := client != null and not client.my_alive
_respawn_button.visible = downed
@@ -105,91 +246,424 @@ func _update_respawn_button() -> void:
else "Return to hub (%.0fs)" % ceil(client.my_respawn_wait)
func _status_text() -> String:
if client == null:
return "connecting..."
# Named rather than just "DUNGEON": there is more than one kind now, and
# knowing which one you are standing in is the whole point of having two.
var where := "LOBBY"
if client.instance_kind != Protocol.InstanceKind.LOBBY:
where = Dungeons.get_or_default(client.dungeon_id).display_name.to_upper()
var who := client.current_character()
var name_part := ""
if not who.is_empty():
name_part = "%s lv %d " % [
who["name"], Progression.level_for_xp(client.my_total_xp)]
return "%s%s instance %d hp %d/%d %d fps" % [
name_part, where, client.instance_id, client.my_hp, client.my_max_hp,
Engine.get_frames_per_second()]
# --- Text -------------------------------------------------------------------
## Text with a one-pixel shadow. The floor art is busy and mid-toned, and plain
## light text over it was readable on the dark parts of a tile and not on the
## light ones.
func _text(at: Vector2, s: String, size: int, colour: Color,
align: HorizontalAlignment = HORIZONTAL_ALIGNMENT_LEFT, width: float = -1.0) -> void:
var font := ThemeDB.fallback_font
_canvas.draw_string(font, at + Vector2(1.0, 1.0), s, align, width, size,
Color(0.0, 0.0, 0.0, 0.7 * colour.a))
_canvas.draw_string(font, at, s, align, width, size, colour)
func _hint_text() -> String:
if client == null:
return ""
if not client.my_alive:
return "DOWN"
if client.instance_kind == Protocol.InstanceKind.LOBBY:
return "WASD move mouse aim LMB fire E on a ring to enter that dungeon Esc menu F1 hitboxes"
return "WASD move mouse aim LMB fire hold F to return to the hub Esc menu F1 hitboxes"
func _text_width(s: String, size: int) -> float:
return ThemeDB.fallback_font.get_string_size(s, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
func _centred(y: float, s: String, size: int, colour: Color) -> void:
_text(Vector2(0.0, y), s, size, colour, HORIZONTAL_ALIGNMENT_CENTER, _canvas.size.x)
# --- Key names --------------------------------------------------------------
## What the player has [param action] bound to. Read from Settings every time
## rather than written into the strings: every one of these is rebindable, and
## the hint used to say "LMB fire" whatever fire was actually on.
static func key(action: String) -> String:
return Settings.short_label(action)
## The four movement keys as one word when they are single characters -- WASD,
## ZQSD, ESDF -- and spelled out when they are not, like the arrow keys.
static func move_keys() -> String:
var parts: Array[String] = []
for action in ["move_up", "move_left", "move_down", "move_right"]:
parts.append(key(action))
for p in parts:
if p.length() != 1:
return "/".join(parts)
return "".join(parts)
## The slot keys, as a range when they run 1-2-3-4 and listed when they do not.
static func slot_keys(count: int) -> String:
var parts: Array[String] = []
for i in count:
parts.append(key("use_slot_%d" % (i + 1)))
var plain := true
for i in count:
if parts[i] != str(i + 1):
plain = false
return "1-%d" % count if plain else " ".join(parts)
static func controls_hint(in_hub: bool) -> String:
if in_hub:
return "%s move mouse aim %s fire %s on a ring to enter it %s menu" % [
move_keys(), key("fire"), key("interact"), key("system_menu")]
return "%s move %s fire hold %s to return to the hub %s menu" % [
move_keys(), key("fire"), key("emergency_escape"), key("system_menu")]
static func inventory_hint(count: int) -> String:
return "%s use shift+%s drop %s pick up" % [
slot_keys(count), slot_keys(count), key("interact")]
# --- Drawing ----------------------------------------------------------------
func _draw_hud() -> void:
if client == null:
_text(Vector2(MARGIN, MARGIN + 14.0), "connecting...", 14, COL_DIM)
return
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
if client.my_escaping:
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
# Under everything, so the edge of the screen colours without dimming any of
# the text on top of it.
_draw_vignette()
_draw_party_markers()
_draw_player_panel()
_draw_boss_bar()
if client.my_spawn_grace:
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H),
"ARRIVING -- invulnerable, weapons cold",
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.6, 0.9, 1.0))
_draw_banner()
_draw_cleared_countdown()
_draw_roster()
_draw_others()
_draw_inventory()
_draw_pickup_prompt()
_draw_level_up_notice()
var above := _draw_pickup_prompt()
_draw_toasts(above)
_draw_controls_hint()
_draw_debug_info()
if not client.my_alive:
# Centred on the canvas, which is only correct because _canvas actually
# has the viewport's size now -- see the anchor note in _ready().
var centre := _canvas.size * 0.5
_canvas.draw_string(ThemeDB.fallback_font, centre - Vector2(52.0, 8.0),
"DOWN", HORIZONTAL_ALIGNMENT_LEFT, -1, 34, Color(1.0, 0.4, 0.4))
_centred(_canvas.size.y * 0.5, "DOWN", 34, Color(1.0, 0.4, 0.4))
if _hit_flash > 0.0:
_canvas.draw_rect(Rect2(Vector2.ZERO, _canvas.size),
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
## A dark, slightly translucent box behind a block of HUD text. The floor art is
## busy and mid-toned; text straight over it was readable on some tiles and not
## on others, and the shadow under each letter only went so far.
func _panel(rect: Rect2) -> void:
if _panel_style == null:
_panel_style = StyleBoxFlat.new()
_panel_style.bg_color = Color(0.05, 0.05, 0.09, 0.62)
_panel_style.border_color = Color(1.0, 1.0, 1.0, 0.07)
_panel_style.set_border_width_all(1)
_panel_style.set_corner_radius_all(4)
_canvas.draw_style_box(_panel_style, rect)
## Who you are, where you are, and how you are doing.
##
## Laid out in two passes -- measure the rows, draw the panel behind them, then
## draw them -- because the panel has to be under the text and its height
## depends on which rows are showing.
func _draw_player_panel() -> void:
var who := client.current_character()
var has_character := not who.is_empty()
var rows := 20.0 + HP_H + 6.0
if has_character:
rows += 22.0 + XP_H + 18.0
if client.upgrades_pending > 0:
rows += 20.0
_panel(Rect2(MARGIN - 10.0, MARGIN - 8.0, BAR_W + 20.0, rows + 12.0))
var x := MARGIN
var y := MARGIN
if has_character:
var name_text := String(who["name"])
_text(Vector2(x, y + 15.0), name_text, 17, COL_TEXT)
_text(Vector2(x + _text_width(name_text, 17) + 10.0, y + 15.0),
"level %d" % Progression.level_for_xp(client.my_total_xp), 13, COL_DIM)
y += 22.0
# Named rather than just "DUNGEON": there is more than one kind, and knowing
# which one you are standing in is the whole point of having more than one.
var where := "THE HUB"
var tint := COL_DIM
if client.instance_kind != Protocol.InstanceKind.LOBBY:
var def := Dungeons.get_or_default(client.dungeon_id)
where = def.display_name.to_upper()
tint = def.tint
_text(Vector2(x, y + 12.0), where, 12, tint)
y += 20.0
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
# Red when low, not a blend toward it: green-to-red through the middle is
# brown, which reads as a colour rather than as a warning.
var hp_tint := COL_HEALTH if frac >= LOW_HEALTH else Color(1.0, 0.38, 0.36)
_bar(Vector2(x, y), frac, hp_tint, _hp_trail, Vector2(BAR_W, HP_H))
# Inside the bar, where the eye already is. Light on the dark track and on
# every fill colour, which a number beside the bar did not need to be.
_text(Vector2(x, y + HP_H - 5.0), "%d / %d" % [client.my_hp, client.my_max_hp],
12, COL_TEXT, HORIZONTAL_ALIGNMENT_CENTER, BAR_W)
y += HP_H + 6.0
if has_character:
_draw_xp_bar(Vector2(x, y))
y += XP_H + 18.0
# No escape row here: the channel is a banner in the middle of the screen,
# where you are looking, and a second bar for the same second was noise.
_draw_level_up_notice(y)
## A thin bar under health: progress toward the next level. Drawn from the
## server's numbers, never recomputed locally.
func _draw_xp_bar(at: Vector2) -> void:
# Derived from the snapshot's live experience total rather than the roster's
# copy, which only arrives when the set of characters changes.
var level := Progression.level_for_xp(client.my_total_xp)
var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp)
var tint := COL_GOLD if capped else Color(0.6, 0.55, 1.0)
# Brighter for a moment after a kill, so experience arriving is something
# you notice rather than something you check.
if _xp_flash > 0.0:
tint = tint.lerp(Color.WHITE, 0.45 * clampf(_xp_flash, 0.0, 1.0))
_bar(at, progress, tint, -1.0, Vector2(BAR_W, XP_H))
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_text(at + Vector2(0.0, XP_H + 13.0), text, 11, Color(tint, 0.9))
if _xp_flash > 0.0 and _xp_gain > 0:
_text(at + Vector2(0.0, XP_H + 13.0), "+%d xp" % _xp_gain, 11,
Color(COL_GOLD, clampf(_xp_flash, 0.0, 1.0)), HORIZONTAL_ALIGNMENT_RIGHT, BAR_W)
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
## would have to already know to go and look.
func _draw_level_up_notice() -> void:
func _draw_level_up_notice(y: float) -> void:
if client.upgrades_pending <= 0:
return
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
var where := " — see the quartermaster in the hub" \
var where := " -- quartermaster, in the hub" \
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(MARGIN, MARGIN + 74.0),
_text(Vector2(MARGIN, y + 12.0),
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
"" if client.upgrades_pending == 1 else "S", where],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
12, Color(COL_GOLD, 0.6 + 0.4 * pulse))
## The boss's health, once you have seen the boss.
##
## The server sends the boss to anyone within the actor interest radius, which
## is deliberately wider than sight and passes through walls -- so the bar used
## to announce "Warden of the Fold" to a player still standing in the entrance
## room, which says exactly where the boss is to someone who has not found it.
## It now waits until the fog has actually lit the boss once, and then stays for
## the rest of the fight: losing the bar every time the boss stepped behind a
## pillar would be worse than the leak.
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
if not track_boss(b):
return
var pos := Vector2((_canvas.size.x - BOSS_BAR_W) * 0.5, MARGIN + 20.0)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_panel(Rect2(pos.x - 10.0, MARGIN - 8.0, BOSS_BAR_W + 20.0, BAR_H + 36.0))
_bar(pos, frac, Color(0.95, 0.35, 0.45), -1.0, Vector2(BOSS_BAR_W, BAR_H))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
_text(Vector2(pos.x, pos.y - 6.0), client.boss_def.display_name, 15,
Color(1.0, 0.75, 0.8))
_text(Vector2(pos.x, pos.y - 6.0), phase_name, 12, Color(1.0, 0.6, 0.65, 0.85),
HORIZONTAL_ALIGNMENT_RIGHT, BOSS_BAR_W)
## Whether the boss bar should be up, and the place a phase change is noticed.
## Returns false until the fog has lit the boss once; true from then on, for the
## rest of that fight.
##
## A phase change is announced because it is the moment the patterns you just
## learned stop being the ones coming at you. Not the phase the boss is in when
## first seen -- that is not a change, only where it started.
func track_boss(b: Dictionary) -> bool:
var seen_key := "%d:%d" % [client.instance_id, int(b["id"])]
if _boss_seen != seen_key:
if sight != null and sight.light_at(b["pos"]) <= 0.05:
return false
_boss_seen = seen_key
var phase_index := int(b["phase"])
if _boss_phase_key != seen_key:
_boss_phase_key = seen_key
_boss_phase = phase_index
elif phase_index > _boss_phase:
_boss_phase = phase_index
var phase_name := ""
if client.boss_def != null and phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
banner(phase_name.to_upper(),
client.boss_def.display_name if client.boss_def != null else "",
Color(1.0, 0.55, 0.6))
return true
## One message across the middle of the screen, chosen by what matters most
## right now: leaving, then arriving, then whatever was announced last.
##
## Arrival protection is a banner rather than a line beside the health bar
## because it is the one moment the rules are different, and both halves of
## it -- you cannot be hurt, and you cannot shoot -- change what you should do.
func _draw_banner() -> void:
var y := _canvas.size.y * 0.27
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.008)
if client.my_escaping:
var blue := Color(0.55, 0.85, 1.0)
_centred(y, "LEAVING", 24, blue)
var w := 220.0
var at := Vector2((_canvas.size.x - w) * 0.5, y + 12.0)
_bar(at, client.my_escape, blue, -1.0, Vector2(w, 12.0))
_centred(y + 42.0, "keep holding %s" % key("emergency_escape"), 12, Color(blue, 0.8))
return
if client.my_spawn_grace:
_centred(y, "ARRIVING", 24, Color(0.6, 0.9, 1.0, 0.75 + 0.25 * pulse))
_centred(y + 22.0, "invulnerable · weapons cold", 13, Color(0.6, 0.9, 1.0, 0.8))
return
if _banner_title.is_empty():
return
var age := _now() - _banner_born
if age > BANNER_LIFE:
return
var alpha := clampf(minf(age / 0.15, (BANNER_LIFE - age) / BANNER_FADE), 0.0, 1.0)
# A short settle on arrival rather than a pop, so it catches the eye without
# jolting it away from the bullets.
var settle := maxf(0.0, 0.2 - age) * 40.0
_centred(y - settle, _banner_title, 28, Color(_banner_col, alpha))
if not _banner_sub.is_empty():
_centred(y + 24.0 - settle, _banner_sub, 13, Color(_banner_col, 0.8 * alpha))
## Shown after the boss dies, so the victory lap has a visible clock on it.
func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
return
var y := _canvas.size.y * 0.5 - 60.0
_centred(y, "DUNGEON CLEARED", 24, Color(0.6, 1.0, 0.75))
_centred(y + 22.0, "returning to the hub in %ds" % client.cleared_countdown, 14,
Color(0.6, 1.0, 0.75, 0.85))
## Top right: in the hub, who is online and where; in a dungeon, the party you
## are in and how each of them is doing. Different questions -- in the hub you
## are deciding whether to go in, in a dungeon you are deciding who needs help.
func _draw_others() -> void:
if client.roster.is_empty():
return
if client.instance_kind == Protocol.InstanceKind.LOBBY:
_draw_online()
else:
_draw_party()
func _draw_online() -> void:
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 28.0 + 18.0 * client.roster.size()))
_text(Vector2(x, y), "ONLINE", 13, COL_DIM)
y += 20.0
for entry in client.roster:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "in a dungeon" if in_dungeon else "in the hub"
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
if not entry["alive"]:
col = Color(0.85, 0.4, 0.4)
where += ", down"
var me := int(entry["peer"]) == client.my_peer
_text(Vector2(x, y), "%s%s" % [entry["name"], " (you)" if me else ""], 13, col)
_text(Vector2(x, y), where, 11, Color(col, 0.7), HORIZONTAL_ALIGNMENT_RIGHT, 216.0)
y += 18.0
## Everyone else in this dungeon, with their health. The snapshot only carries
## players inside the interest radius, so a party member further away than that
## is listed without a bar -- the client genuinely does not know.
func _draw_party() -> void:
var near := {}
for p in client.remote_players():
near[int(p["peer"])] = p
var members: Array[Dictionary] = []
for entry in client.roster:
if int(entry["instance"]) == client.instance_id and int(entry["peer"]) != client.my_peer:
members.append(entry)
if members.is_empty():
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 12.0
_panel(Rect2(x - 10.0, MARGIN - 8.0, 236.0, 26.0 + 20.0 * members.size()))
_text(Vector2(x, y), "PARTY", 13, COL_DIM)
y += 10.0
var w := 140.0
for entry in members:
var peer := int(entry["peer"])
_text(Vector2(x, y + 13.0), String(entry["name"]), 13, COL_TEXT)
var rec: Dictionary = near.get(peer, {})
var bar_at := Vector2(x + 76.0, y + 3.0)
if rec.is_empty():
_text(Vector2(bar_at.x, y + 13.0), "out of range", 11, COL_DIM)
else:
var flags := int(rec["flags"])
var frac := float(rec["hp"]) / float(maxi(int(rec["max_hp"]), 1))
var col: Color = rec["colour"]
if (flags & Protocol.F_ALIVE) == 0:
_text(Vector2(bar_at.x, y + 13.0), "down", 12, COL_DANGER)
else:
var rect := Rect2(bar_at, Vector2(w, 12.0))
_canvas.draw_rect(rect, Color(0.0, 0.0, 0.0, 0.55))
_canvas.draw_rect(Rect2(rect.position + Vector2(2.0, 2.0),
Vector2((w - 4.0) * clampf(frac, 0.0, 1.0), 8.0)),
col if frac >= LOW_HEALTH else COL_DANGER)
if (flags & Protocol.F_LINKDEAD) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "dropped", 11, COL_DIM)
elif (flags & Protocol.F_ESCAPING) != 0:
_text(Vector2(bar_at.x + w + 6.0, y + 13.0), "leaving", 11,
Color(0.5, 0.85, 1.0))
y += 20.0
## A marker at the edge of sight for each party member you cannot see, pointing
## the way to them.
##
## The fog hides players behind walls and beyond sight like anything else, and
## in a dungeon the most common question about the rest of your party is where
## they went. Allies only, from data the client already holds -- a party member
## outside the interest radius is not in the snapshot, and gets no marker.
func _draw_party_markers() -> void:
if client.instance_kind == Protocol.InstanceKind.LOBBY or not client.my_alive:
return
var eye := client.predicted_pos
var centre := eye + client.camera_offset
var ring := SimConfig.FOG_VIEW_RADIUS - 40.0
var names := {}
for entry in client.roster:
names[int(entry["peer"])] = String(entry["name"])
var bounds := Rect2(Vector2(MARGIN, MARGIN), _canvas.size - Vector2(MARGIN, MARGIN) * 2.0)
for p in client.remote_players():
var pos: Vector2 = p["pos"]
var offset := pos - eye
var dist := offset.length()
if dist < 1.0:
continue
var lit := sight == null or sight.light_at(pos) > 0.3
if dist < ring and lit:
continue
var dir := offset / dist
var at := centre + dir * minf(ring, dist)
at = at.clamp(bounds.position, bounds.end)
var flags := int(p["flags"])
var col: Color = p["colour"]
if (flags & Protocol.F_ALIVE) == 0:
col = COL_DANGER
var tip := at + dir * 9.0
var side := dir.orthogonal() * 6.0
_canvas.draw_colored_polygon(PackedVector2Array([
tip, at - dir * 4.0 + side, at - dir * 4.0 - side]), Color(col, 0.9))
_text(at - dir * 12.0 + Vector2(-60.0, 4.0), String(names.get(int(p["peer"]), "")),
11, Color(col, 0.85), HORIZONTAL_ALIGNMENT_CENTER, 120.0)
## Four slots, always on screen. Deliberately not a panel you open: an
@@ -212,133 +686,153 @@ func _draw_inventory() -> void:
# recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# The slot number, because the key that uses it is the only thing the
# player actually needs to know about a slot.
# An empty slot steps back. The pack's slot is a bright parchment
# square, and an empty one drew the eye as hard as a full one -- the
# blank box was the most prominent thing in the row.
var slot_tint := Color.WHITE if def != null else Color(1.0, 1.0, 1.0, 0.45)
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false, slot_tint)
# The key that uses the slot, because that is the only thing the player
# actually needs to know about it -- and it is whatever they bound.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
key("use_slot_%d" % (i + 1)), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
if def == null:
continue
var icon := Art.item_icon(item)
var size := icon.size * Art.SCALE
_canvas.draw_texture_rect_region(Art.TILESET,
Rect2(at + (Vector2(SLOT, SLOT) - size) * 0.5, size), icon)
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(origin.x, origin.y + SLOT + 15.0),
"1-%d use shift+1-%d drop E pick up" % [count, count],
HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Color(0.5, 0.55, 0.66))
_text(Vector2(origin.x, origin.y + SLOT + 15.0), inventory_hint(count), 11,
Color(COL_DIM, 0.85), HORIZONTAL_ALIGNMENT_CENTER, total)
## What pressing interact right now would do. The server runs the same search
## for itself and does not care what this concluded -- this is a label, not a
## decision.
func _draw_pickup_prompt() -> void:
## decision. Returns the y the next thing up should stack from.
##
## Loot first, then an entrance, which is the order the server resolves the
## same key in: an item in reach is taken, and only a press that took nothing
## goes through the portal underneath it.
func _draw_pickup_prompt() -> float:
var above := _canvas.size.y - SLOT_BOTTOM - SLOT - 14.0
if not client.my_alive:
return above
var near := client.loot_in_reach()
if near.is_empty() or not client.my_alive:
return
if near.is_empty():
var portal := client.portal_here()
if portal.is_empty() or client.instance_kind != Protocol.InstanceKind.LOBBY:
return above
var def := Dungeons.get_or_default(portal["dungeon"])
_centred(above, "%s enter %s" % [key("interact"), def.display_name], 15, def.tint)
return above - 22.0
var item := Items.by_index(int(near["item"]))
var def := Items.get_def(item)
if def == null:
return
return above
var full := client.inventory_full()
var text := "inventory full -- %s stays where it is" % def.display_name \
if full else "E take %s" % def.display_name
if full else "%s take %s" % [key("interact"), def.display_name]
var tint := Color(0.85, 0.5, 0.45) if full else def.tint
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(_canvas.size.x * 0.5 - 150.0,
_canvas.size.y - SLOT_BOTTOM - SLOT - 22.0),
text, HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
_centred(above, text, 14, tint)
return above - 22.0
## A thin bar under health: progress toward the next level, and the level
## itself. Drawn from the server's numbers, never recomputed locally.
func _draw_xp_bar(at: Vector2) -> void:
if client.current_character().is_empty():
## The message feed, newest nearest the inventory, each line fading out.
func _draw_toasts(bottom: float) -> void:
var now := _now()
var y := bottom
for i in range(_toasts.size() - 1, -1, -1):
var t: Dictionary = _toasts[i]
var age := now - float(t["born"])
var alpha := clampf((TOAST_LIFE - age) / TOAST_FADE, 0.0, 1.0)
var col: Color = t["colour"]
# A short rise on arrival, so a new line catches the eye without a sound.
var rise := maxf(0.0, 0.25 - age) * 24.0
_centred(y + rise, String(t["text"]), 14, Color(col, col.a * alpha))
y -= 20.0
func _draw_controls_hint() -> void:
if not client.my_alive:
return
# Derived from the snapshot's live experience total rather than the roster's
# copy, which only arrives when the set of characters changes.
var level := Progression.level_for_xp(client.my_total_xp)
var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp)
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
_bar(at, progress, tint, Color.BLACK)
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
## Shown after the boss dies, so the victory lap has a visible clock on it.
func _draw_cleared_countdown() -> void:
if client.cleared_countdown >= Protocol.COUNTDOWN_NONE:
var in_hub := client.instance_kind == Protocol.InstanceKind.LOBBY
var alpha := 1.0
if not in_hub:
var age := _now() - _instance_since
alpha = clampf(1.0 - (age - HINT_HOLD) / HINT_FADE, 0.0, 1.0)
if alpha <= 0.0:
return
var text := "DUNGEON CLEARED -- returning to the hub in %ds" % client.cleared_countdown
_canvas.draw_string(ThemeDB.fallback_font,
Vector2(_canvas.size.x * 0.5 - 190.0, _canvas.size.y * 0.5 - 60.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 18, Color(0.6, 1.0, 0.75))
_text(Vector2(MARGIN, _canvas.size.y - 14.0), controls_hint(in_hub), 12,
Color(COL_DIM, 0.9 * alpha))
## Who else is online, and whether they are already in a dungeon. Only useful in
## the hub, which is the one place you are deciding whether to go in.
func _draw_roster() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY or client.roster.is_empty():
func _draw_debug_info() -> void:
if not debug_info:
return
var x := _canvas.size.x - 240.0
var y := MARGIN + 4.0
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y), "ONLINE",
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, Color(0.65, 0.7, 0.8))
y += 20.0
for entry in client.roster:
var in_dungeon: bool = int(entry["kind"]) == Protocol.InstanceKind.DUNGEON
var where := "dungeon %d" % int(entry["instance"]) if in_dungeon else "hub"
var col := Color(1.0, 0.7, 0.45) if in_dungeon else Color(0.7, 0.8, 0.9)
if not entry["alive"]:
col = Color(0.85, 0.4, 0.4)
where += " (down)"
var me := " <- you" if int(entry["peer"]) == client.my_peer else ""
_canvas.draw_string(ThemeDB.fallback_font, Vector2(x, y),
"%s -- %s%s" % [entry["name"], where, me],
HORIZONTAL_ALIGNMENT_LEFT, -1, 13, col)
y += 18.0
_text(Vector2(0.0, _canvas.size.y - 14.0),
"instance %d %d fps" % [client.instance_id, Engine.get_frames_per_second()],
12, COL_DIM, HORIZONTAL_ALIGNMENT_RIGHT, _canvas.size.x - MARGIN)
func _draw_boss_bar() -> void:
var b := client.boss_state()
if b.is_empty() or client.boss_def == null:
return
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
var rect := Rect2(pos, Vector2(w, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((w - 6.0) * frac, BAR_H - 6.0))
if inner.size.x > 0.5:
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
phase_name = client.boss_def.phases[phase_index].name
_canvas.draw_string(ThemeDB.fallback_font, pos + Vector2(0.0, -6.0),
"%s -- %s" % [client.boss_def.display_name, phase_name],
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
## Red at the edges of the screen: briefly on every hit, and pulsing while
## health is low.
##
## [param back] is no longer a colour -- the frame sprite is the background --
## but the signature keeps it so callers read the same and a future flat-bar
## fallback has somewhere to go.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
## At the edges, not across the middle. This used to tint the whole screen red
## on a hit, which in a bullet hell dims the bullets at exactly the moment you
## are among them -- the feedback made the next hit more likely.
func _draw_vignette() -> void:
var strength := _hit_flash * 0.55
if client.my_alive:
var frac := float(client.my_hp) / float(maxi(client.my_max_hp, 1))
if frac < LOW_HEALTH:
var low := (LOW_HEALTH - frac) / LOW_HEALTH
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.006)
strength = maxf(strength, (0.2 + 0.35 * low) * (0.65 + 0.35 * pulse))
if strength <= 0.01:
return
var s := _canvas.size
var depth := minf(s.x, s.y) * 0.16
var edge := Color(COL_DANGER, strength)
var clear := Color(COL_DANGER, 0.0)
var sides := [
[Vector2(0, 0), Vector2(s.x, 0), Vector2(s.x, depth), Vector2(0, depth)],
[Vector2(0, s.y), Vector2(0, s.y - depth), Vector2(s.x, s.y - depth), Vector2(s.x, s.y)],
[Vector2(0, 0), Vector2(depth, 0), Vector2(depth, s.y), Vector2(0, s.y)],
[Vector2(s.x, 0), Vector2(s.x, s.y), Vector2(s.x - depth, s.y), Vector2(s.x - depth, 0)],
]
var cols := [
[edge, edge, clear, clear],
[edge, clear, clear, edge],
[edge, clear, clear, edge],
[edge, edge, clear, clear],
]
for i in 4:
_canvas.draw_polygon(PackedVector2Array(sides[i]), PackedColorArray(cols[i]))
## A bar in the pack's art: the frame stretched horizontally, a dark track
## inside it, and the fill tinted on top.
##
## [param trail], when it is above [param frac], is drawn first in a pale
## version of the fill -- the part of the bar the last hit took, before it
## drains away. That is what makes a hit readable as an amount rather than as a
## bar that is suddenly shorter.
func _bar(pos: Vector2, frac: float, fill: Color, trail: float = -1.0,
size: Vector2 = Vector2(BAR_W, BAR_H)) -> void:
var rect := Rect2(pos, size)
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
# The frame scales with the bar's height, so the inset has to as well: a
# fixed three pixels left a ten-pixel bar four pixels of track and six of
# white frame, and it read as an empty white bar whatever it held.
var inset := maxf(1.0, roundf(size.y * 0.15))
var track := Rect2(rect.position + Vector2(inset, inset), rect.size - Vector2(inset, inset) * 2.0)
_canvas.draw_rect(track, COL_TRACK)
var full := track.size.x
if trail > frac:
# Darker than the fill, not paler. The health number sits on top of
# this, and a pale trail was the one background it vanished against.
_canvas.draw_rect(Rect2(track.position, Vector2(full * clampf(trail, 0.0, 1.0), track.size.y)),
Color(fill.darkened(0.5), 0.95))
var inner := Rect2(track.position, Vector2(full * clampf(frac, 0.0, 1.0), track.size.y))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
@@ -366,7 +860,3 @@ func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)),
Rect2(src.x - cap, 0.0, cap, src.y), tint)
func flash_hit() -> void:
_hit_flash = 1.0
+152 -33
View File
@@ -5,13 +5,20 @@ extends Control
signal join_requested(address: String, port: int)
signal host_requested(port: int)
signal settings_requested
## The licences two of the asset packs require, one click from the first
## screen rather than two screens deep in settings.
signal credits_requested
var _address: LineEdit
var _port: LineEdit
var _name: LineEdit
var _status: Label
var _join_button: Button
var _host_button: Button
## True while a connection attempt is in flight; see set_busy().
var _busy: bool = false
## The status as set, and whether it is progress rather than a complaint.
var _status_base := ""
var _working: bool = false
func _ready() -> void:
@@ -29,82 +36,194 @@ func _ready() -> void:
# A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual
# offset math to get wrong.
# Behind everything, so it is first: a child draws over its earlier
# siblings, and the panel has to be in front of the bullets.
add_child(preload("res://src/ui/menu_backdrop.gd").new())
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
# The same dark panel every other screen sits in, rather than controls
# floating on the clear colour: this is the first screen anyone sees.
var frame := UiTheme.dialog_panel()
center.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(360.0, 0.0)
panel.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_theme_constant_override("separation", 8)
center.add_child(panel)
frame.add_child(panel)
var title := Label.new()
title.text = "TRANSCIENCE"
title.add_theme_font_size_override("font_size", 32)
# Spaced out, which the default font will not do on its own: a word set
# tight at 38px reads as a heading, and this is a name.
var spaced := FontVariation.new()
spaced.base_font = ThemeDB.fallback_font
spaced.spacing_glyph = 5
title.add_theme_font_override("font", spaced)
title.add_theme_font_size_override("font_size", 38)
panel.add_child(title)
var accent := ColorRect.new()
accent.color = Color(1.0, 0.72, 0.35, 0.85)
accent.custom_minimum_size = Vector2(64.0, 3.0)
accent.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
panel.add_child(accent)
var subtitle := Label.new()
subtitle.text = "a co-operative bullet hell"
subtitle.add_theme_font_size_override("font_size", 13)
subtitle.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
panel.add_child(subtitle)
panel.add_child(_gap(10.0))
_name = _field(panel, "name", GameOpts.player_name)
_address = _field(panel, "server address", GameOpts.host_address)
_port = _field(panel, "port", str(GameOpts.port))
# No name field. There used to be one, and for a person it did nothing:
# characters are named on the roster screen, and this only ever fed
# GameOpts.player_name, which names the characters BOTS create. Asking for
# a name the game then ignores is worse than not asking.
var heading := Label.new()
heading.text = "server"
heading.add_theme_font_size_override("font_size", 12)
panel.add_child(heading)
var where := HBoxContainer.new()
where.add_theme_constant_override("separation", 8)
panel.add_child(where)
# Remembered from the last join, so a returning player does not retype it.
_address = LineEdit.new()
_address.text = Settings.last_address if not Settings.last_address.is_empty() \
else GameOpts.host_address
_address.placeholder_text = "address"
_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_address.text_submitted.connect(func(_t: String) -> void: _on_join())
_address.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_address)
_port = LineEdit.new()
_port.text = str(Settings.last_port if Settings.last_port > 0 else GameOpts.port)
_port.placeholder_text = "port"
_port.custom_minimum_size = Vector2(90.0, 0.0)
_port.text_submitted.connect(func(_t: String) -> void: _on_join())
_port.text_changed.connect(func(_t: String) -> void: _validate())
where.add_child(_port)
_join_button = Button.new()
_join_button.text = "Join"
# Enter works from either field, and from the button itself, which has focus
# when the screen opens. Saying so costs one word.
_join_button.text = "Join (Enter)"
_join_button.pressed.connect(_on_join)
panel.add_child(_join_button)
_host_button = Button.new()
_host_button.text = "Host and play (listen server)"
_host_button.text = "Host and play"
_host_button.tooltip_text = "Run a server on this machine and join it -- others can join you"
_host_button.pressed.connect(_on_host)
panel.add_child(_host_button)
panel.add_child(_gap(6.0))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
panel.add_child(row)
var settings := Button.new()
settings.text = "Settings"
settings.size_flags_horizontal = Control.SIZE_EXPAND_FILL
settings.pressed.connect(func() -> void: settings_requested.emit())
panel.add_child(settings)
row.add_child(settings)
var credits := Button.new()
credits.text = "Credits"
credits.size_flags_horizontal = Control.SIZE_EXPAND_FILL
credits.pressed.connect(func() -> void: credits_requested.emit())
row.add_child(credits)
var quit := Button.new()
quit.text = "Quit"
quit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
quit.pressed.connect(func() -> void: get_tree().quit())
panel.add_child(quit)
row.add_child(quit)
var how := Label.new()
how.text = "Host and play runs a server on this machine. Others join it by your address."
how.add_theme_font_size_override("font_size", 11)
how.add_theme_color_override("font_color", Color(0.5, 0.55, 0.66))
how.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
how.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_child(how)
_status = Label.new()
_status.add_theme_color_override("font_color", Color(1.0, 0.55, 0.5))
_status.add_theme_font_size_override("font_size", 13)
_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_status.custom_minimum_size = Vector2(400.0, 0.0)
panel.add_child(_status)
# Which protocol this build speaks. A "protocol mismatch" refusal names two
# numbers, and this is where a player finds out which one is theirs.
var version := Label.new()
version.text = "protocol %d" % Protocol.VERSION
version.add_theme_font_size_override("font_size", 11)
version.add_theme_color_override("font_color", Color(0.45, 0.5, 0.6))
version.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
panel.add_child(version)
func _field(parent: Control, label: String, value: String) -> LineEdit:
var l := Label.new()
l.text = label
l.add_theme_font_size_override("font_size", 12)
parent.add_child(l)
var e := LineEdit.new()
e.text = value
parent.add_child(e)
return e
_validate()
# So Enter joins straight away for a returning player, whose address is
# already filled in from last time.
_join_button.call_deferred("grab_focus")
## Whether what is typed could possibly connect, said before the button is
## pressed rather than after. A non-number in the port used to go through as
## port 0 and come back as a connection error that did not say why.
func _validate() -> void:
var port_text := _port.text.strip_edges()
var port := int(port_text) if port_text.is_valid_int() else 0
var port_ok := port >= 1 and port <= 65535
var address_ok := not _address.text.strip_edges().is_empty()
_join_button.disabled = _busy or not (port_ok and address_ok)
_host_button.disabled = _busy or not port_ok
if not port_ok:
set_status("the port is a number from 1 to 65535")
elif not address_ok:
set_status("which server? type its address")
elif _status.text.begins_with("the port") or _status.text.begins_with("which server"):
set_status("")
func _gap(height: float) -> Control:
var c := Control.new()
c.custom_minimum_size = Vector2(0.0, height)
return c
## Shown under the buttons. Coloured by what it is: "connecting..." is not an
## error, and it used to be drawn in the same red as one.
func set_status(text: String) -> void:
_status_base = text
_status.text = text
_working = text.begins_with("connecting")
_status.add_theme_color_override("font_color",
Color(0.65, 0.8, 1.0) if _working else Color(1.0, 0.55, 0.5))
## A connection attempt can take a few seconds, and a line of text that does not
## move while it does reads as frozen. The dots are the only thing animated.
func _process(_delta: float) -> void:
if not _working:
return
var base := _status_base.trim_suffix("...").trim_suffix(" ")
var dots := int(float(Time.get_ticks_msec()) / 400.0) % 4
_status.text = base + " " + ".".repeat(dots)
## Locks the connect buttons while an attempt is in flight, so a second click
## cannot start a competing connection over the top of the first.
func set_busy(busy: bool) -> void:
_join_button.disabled = busy
_host_button.disabled = busy
func _commit_name() -> void:
var clean := _name.text.strip_edges()
if not clean.is_empty():
GameOpts.player_name = clean
_busy = busy
_validate()
func _on_join() -> void:
_commit_name()
join_requested.emit(_address.text.strip_edges(), int(_port.text))
if _join_button.disabled:
return
var address := _address.text.strip_edges()
var port := int(_port.text)
Settings.remember_server(address, port)
join_requested.emit(address, port)
func _on_host() -> void:
_commit_name()
host_requested.emit(int(_port.text))
+76
View File
@@ -0,0 +1,76 @@
extends Node2D
## The connect screen's background: a real boss pattern, played out behind the
## panel, dimmed.
##
## Real in the sense that matters -- the boss's own emitters fired into a
## [BulletPool] and drawn by the game's own bullet renderer, so what plays here
## is what the fight actually looks like, and a pattern that changes in
## content.gd changes here with it. Nothing is simulated beyond the bullets:
## no world, no hits, no map. The first thing a player sees is the genre.
const BULLETS := preload("res://src/view/bullet_renderer.gd")
## How long each phase plays before the next one takes over, in ticks.
const PHASE_TICKS := 60 * 9
## How visible it is. Enough to read as a bullet hell, not enough to compete
## with the panel in front of it.
const DIM := 0.32
## Run this many ticks before the first frame is drawn. Every pattern starts at
## the centre of the screen, which is exactly where the panel is, so a backdrop
## that started from nothing showed nothing at all for its first second or two
## -- it was there, all of it, behind the buttons.
const PRIME_TICKS := 60 * 5
var _pool := BulletPool.new()
var _ctx := EmitContext.new()
var _renderer: Node2D
var _phases: Array = []
var _tick: int = 0
func _ready() -> void:
modulate = Color(1.0, 1.0, 1.0, DIM)
_renderer = BULLETS.new()
add_child(_renderer)
# Every phase of every boss, in order -- the Warden's folds, then the
# Cantor's verses -- so leaving the menu up shows the whole bestiary.
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
if not phase.emitters.is_empty():
_phases.append(phase.emitters)
_ctx.pool = _pool
_ctx.rng = RandomNumberGenerator.new()
_ctx.rng.seed = 1
for _i in PRIME_TICKS:
_step()
_renderer.render_pool(_pool)
func _physics_process(_delta: float) -> void:
_step()
_renderer.render_pool(_pool)
func _step() -> void:
if _phases.is_empty():
return
var centre := get_viewport_rect().size * 0.5
var phase_index := (_tick / PHASE_TICKS) % _phases.size()
var local := _tick % PHASE_TICKS
_ctx.origin = centre
# Aimed patterns need someone to aim at. A point drifting slowly round the
# centre, so aimed volleys sweep rather than all going the same way.
var t := float(_tick) * 0.006
_ctx.target = centre + Vector2(cos(t), sin(t * 0.7)) * 260.0
_ctx.has_target = true
_ctx.bounds = get_viewport_rect()
_ctx.local_tick = local
# Telegraphed strikes announce through the context; there is nobody to warn.
_ctx.events = []
for e: BulletEmitter in _phases[phase_index]:
if e == null or not e.should_fire(local):
continue
_ctx.shot_index = e.shot_index_at(local)
e.fire(_ctx)
_pool.step()
_tick += 1
+1
View File
@@ -0,0 +1 @@
uid://cpbu6rmxpqg0e
+8 -5
View File
@@ -26,6 +26,7 @@ var _cards: HBoxContainer
var _taken: Label
var _summary: Label
var _status: Label
var _close: Button
func _ready() -> void:
@@ -84,11 +85,10 @@ func _ready() -> void:
_summary.custom_minimum_size = Vector2(740.0, 0.0)
panel.add_child(_summary)
var close := Button.new()
close.text = "Close (E)"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
panel.add_child(close)
_close = Button.new()
_close.custom_minimum_size = Vector2(160.0, 34.0)
_close.pressed.connect(func() -> void: closed.emit())
panel.add_child(_close)
func _rule() -> Control:
@@ -106,6 +106,9 @@ func set_status(text: String) -> void:
## screen can never show an offer that has already been spent.
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
_status.text = ""
# Named on every refresh rather than once: the same key closes the screen
# that opened it, and it is whatever interact is bound to now.
_close.text = "Close (%s)" % Settings.short_label("interact")
if pending > 0:
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
else:
+53 -3
View File
@@ -17,6 +17,21 @@ extends Node2D
## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind.
## How long an enemy bullet is guaranteed to be drawn at full strength before
## it could possibly reach you. The whole point of fading bullets with distance
## is to stop drawing shots that cannot matter -- and the one thing that must
## never come of it is a hit from a bullet you were not shown.
##
## So each enemy bullet is drawn out to however far it, plus you running straight
## at it, could close in this long -- or to the edge of sight, whichever is
## further. With today's content the edge of sight is always the further: the
## fastest enemy bullet does 300 u/s, so even head-on it is on screen for 0.83s
## before it lands. The speed term is what keeps that true for anything faster.
const WARNING_TIME := 0.75
## Past the distance a bullet is drawn to, it fades out over this much more
## rather than vanishing on a line -- a hard edge reads as a rendering fault.
const FADE_OUT := 80.0
const _FALLBACK_COLOURS: Array[Color] = [
Color(0.45, 0.95, 1.0), # KIND_PLAYER_SHOT
Color(1.0, 0.35, 0.75), # KIND_ORB
@@ -99,13 +114,47 @@ func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
return out
func render_pool(pool: BulletPool) -> void:
## How strongly to draw a bullet [param dist] from the eye: 1 is fully, 0 is not
## at all. See [constant WARNING_TIME] for why the distance depends on the bullet.
##
## Shots by players fade at the edge of sight and no further, however fast they
## are: they cannot hurt you, so there is nothing to warn about, and a Sniper's
## 1500 u/s shot was the most common thing streaking across the dark.
##
## Distance only -- not shadow. A bullet behind a wall within sight range is
## still drawn, over the darkness, because you can step round the corner into
## it; hiding it would be the invisible hit this is careful not to cause.
static func visibility(dist: float, speed: float, accel: float, team: int,
sight_radius: float) -> float:
var reach := sight_radius
if team == SimConfig.TEAM_ENEMY:
# Top speed over the warning window, times the window: an over-estimate
# of how far it can come, which is the safe direction to be wrong in.
var top := speed + maxf(accel, 0.0) * WARNING_TIME
reach = maxf(sight_radius, (top + SimConfig.PLAYER_SPEED) * WARNING_TIME)
if dist <= reach:
return 1.0
return clampf(1.0 - (dist - reach) / FADE_OUT, 0.0, 1.0)
## Push the pool into the MultiMeshes. With an [param eye], bullets far beyond
## [param sight_radius] are left out; without one -- the connect screen's
## backdrop -- everything is drawn.
func render_pool(pool: BulletPool, eye: Vector2 = Vector2.INF,
sight_radius: float = 0.0) -> void:
var counts := PackedInt32Array()
counts.resize(_layers.size())
counts.fill(0)
var cull := eye.is_finite()
for i in pool.high_water:
if pool.alive[i] == 0:
continue
var alpha := 1.0
if cull:
alpha = visibility((pool.pos[i] as Vector2).distance_to(eye),
(pool.vel[i] as Vector2).length(), pool.accel[i], pool.team[i], sight_radius)
if alpha <= 0.0:
continue
var kind := clampi(pool.kind[i], 0, _layers.size() - 1)
var n := counts[kind]
if n >= SimConfig.MAX_BULLETS:
@@ -121,8 +170,9 @@ func render_pool(pool: BulletPool) -> void:
+ float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,
_FALLBACK_COLOURS[kind] if _fallback else Color.WHITE)
var tint: Color = _FALLBACK_COLOURS[kind] if _fallback else Color.WHITE
tint.a *= alpha
_layers[kind].multimesh.set_instance_color(n, tint)
counts[kind] = n + 1
for kind in _layers.size():
_layers[kind].multimesh.visible_instance_count = counts[kind]
+16
View File
@@ -20,6 +20,7 @@ const COL_PIT := Color(0.4, 0.5, 1.0, 0.5)
const COL_AGGRO := Color(1.0, 0.5, 0.2, 0.25)
const COL_MUZZLE := Color(0.4, 1.0, 0.6, 0.9)
const COL_LOS := Color(0.5, 1.0, 0.8, 0.18)
const COL_SIGHT := Color(1.0, 0.95, 0.55, 0.55) # where each fog ray stopped
## Terrain: one outline per tile, coloured by which of the three flags it sets.
@@ -82,3 +83,18 @@ static func draw_bullets(c: CanvasItem, pool: BulletPool) -> void:
## A ring at the fog radius, so "why can I not see that" has a visible answer.
static func draw_ranges(c: CanvasItem, pos: Vector2) -> void:
c.draw_arc(pos, SimConfig.FOG_VIEW_RADIUS, 0.0, TAU, 64, COL_LOS, 1.0)
## The outline the fog's rays traced: where each one stopped. Drawn as the hit
## points rather than the soft edge, because what this answers is "why is THAT
## dark", and the answer is always which ray hit what.
static func draw_sight(c: CanvasItem, sight: Visibility) -> void:
var n := sight.angles.size()
if n < 2:
return
var outline := PackedVector2Array()
outline.resize(n + 1)
for i in n:
outline[i] = sight.eye + sight.dirs[i] * sight.hits[i]
outline[n] = outline[0]
c.draw_polyline(outline, COL_SIGHT, 1.0)
+35 -4
View File
@@ -75,10 +75,14 @@ func _process(_delta: float) -> void:
# Item feedback rides the same rule as every other sound here: it
# plays because a server event arrived, never because the client
# guessed a pickup succeeded.
_bound.item_picked_up.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0))
_bound.item_used.connect(func(_i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0))
_bound.item_picked_up.connect(func(i: StringName) -> void:
sfx.play(Art.SFX_ENEMY_DEATH, -16.0)
_announce_item("+ %s", i))
_bound.item_used.connect(func(i: StringName) -> void:
sfx.play(Art.SFX_SHOOT, -10.0)
_announce_item("%s used", i))
_bound.item_dropped.connect(func(i: StringName) -> void:
_announce_item("dropped %s", i))
_bound.characters_changed.connect(_refresh_characters)
_bound.upgrades_changed.connect(_refresh_upgrades)
_refresh_upgrades()
@@ -97,10 +101,19 @@ func _process(_delta: float) -> void:
_roster_open = false
characters.visible = forced or _roster_open
characters.set_dismissible(not forced)
# Said once, on the screen death sends you to, and gone as soon as the
# player has picked who comes next.
if forced and not _bound.last_fallen.is_empty():
characters.set_fallen(fallen_line(_bound.last_fallen))
elif not forced and not _bound.last_fallen.is_empty():
_bound.last_fallen = {}
characters.set_fallen("")
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
_upgrades_open = false
upgrades.visible = _upgrades_open
_follow_camera()
hud.sight = world_view.sight
hud.debug_info = world_view.debug_hitboxes
menu.set_in_dungeon(_bound != null
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
@@ -147,6 +160,24 @@ func _on_respawn_pressed() -> void:
_bound.request_respawn = true
## Permadeath, said plainly: who, how far they got, and where. A death used to
## be announced only by the roster screen appearing.
static func fallen_line(fallen: Dictionary) -> String:
var where := String(fallen.get("where", ""))
return "%s fell at level %d%s. They are gone -- choose who goes next." % [
fallen["name"], int(fallen["level"]),
" in the %s" % where.trim_prefix("The ") if not where.is_empty() else ""]
## A line in the HUD's message feed for an item the server just moved. The
## events are this player's own -- ClientRuntime filters them by peer -- so a
## party member's pickup does not read as yours.
func _announce_item(pattern: String, item: StringName) -> void:
var def := Items.get_def(item)
if def != null:
hud.notify(pattern % def.display_name, def.tint)
func _on_local_hit(_damage: int) -> void:
hud.flash_hit()
sfx.play(Art.SFX_PLAYER_HIT, -4.0)
+348
View File
@@ -0,0 +1,348 @@
class_name Visibility
extends RefCounted
## What the local player can see, as geometry rather than a verdict per tile.
##
## The fog this replaces asked one question per tile -- can I see its centre? --
## and drew or skipped the whole tile on the answer. Every shadow was a
## staircase, a wall beside you vanished whenever its centre happened to sit
## behind its neighbour, and tiles snapped in and out as you walked. This casts
## rays instead and lights the space they reach, so a shadow edge runs straight
## through a tile and half of one can be lit.
##
## Client-side and rendering-only, like the fog it replaces. It decides what is
## DRAWN; what the client is SENT is the server's business -- the actor interest
## radius and map streaming -- and nothing in here can widen that.
## Rays at fixed angles, on top of the ones aimed at corners. Corners alone give
## an exact outline wherever there is geometry to hit; the sweep covers open
## ground with nothing in it, and keeps the angle between neighbouring rays
## small enough that the straight chord joining them stays inside the fade.
const SWEEP_RAYS := 180
## How far either side of a corner its two rays are aimed, in radians: one grazes
## past and one hits. That pair is what makes the shadow leaving a corner a
## straight line rather than a smear across the gap to the next ray.
const CORNER_NUDGE := 0.0004
## How deep into a wall its lit face goes, measured straight in from the face.
##
## Without any, the wall you are looking at is exactly where the dark begins and
## is never drawn -- floor would end in a void. Measured perpendicular to the
## face rather than along the ray, because along the ray a wall you are looking
## ALONG gets a sliver: a fixed distance travelled at a grazing angle barely gets
## under the surface, so the wall right beside you was the one you could not
## see -- the very thing this replaced the old fog to fix.
##
## Under a tile, and light also stops the moment a ray leaves the wall it is
## in, so neither the far side of a one-tile wall nor the space round a corner
## is ever lit by it.
const WALL_DEPTH := 26.0
## The last part of that depth fades out instead of ending in a hard line.
const FEATHER := 8.0
## Soft edge at the limit of sight, in place of the old hard circle.
const FADE := 64.0
## One entry per ray, sorted by angle in [-PI, PI).
var angles := PackedFloat32Array()
var dirs := PackedVector2Array()
## Where each ray stopped: at the first tile that blocks sight, or at the radius.
var hits := PackedFloat32Array()
## Along each ray: fully lit up to [member starts], dark from [member ends].
var starts := PackedFloat32Array()
var ends := PackedFloat32Array()
var eye := Vector2.ZERO
var radius: float = 0.0
var _sweep := PackedFloat32Array()
## Every corner of sight-blocking geometry near the eye, in world space. Derived
## from the tiles alone, so it is only rebuilt when the map changes or the eye
## moves into another tile.
var _corners := PackedVector2Array()
var _corner_map: int = 0
var _corner_revision: int = -1
var _corner_tile := Vector2i(-99999, -99999)
var _corner_radius: float = -1.0
func _init() -> void:
_sweep.resize(SWEEP_RAYS)
for i in SWEEP_RAYS:
_sweep[i] = -PI + TAU * float(i) / float(SWEEP_RAYS)
## Recompute everything for an eye at [param at] seeing [param view_radius].
func compute(map: MapGrid, at: Vector2, view_radius: float) -> void:
eye = at
radius = view_radius
_refresh_corners(map)
var want := PackedFloat32Array(_sweep)
var reach := view_radius + MapGrid.TILE * 1.5
var reach_sq := reach * reach
for c in _corners:
var d := c - at
var dsq := d.length_squared()
if dsq > reach_sq or dsq < 0.0001:
continue
var a := d.angle()
want.append(wrapf(a - CORNER_NUDGE, -PI, PI))
want.append(wrapf(a + CORNER_NUDGE, -PI, PI))
want.sort()
var n := want.size()
angles = want
dirs.resize(n)
hits.resize(n)
starts.resize(n)
ends.resize(n)
var fade_from := view_radius - FADE
for i in n:
var dir := Vector2.from_angle(want[i])
dirs[i] = dir
var traced := trace(map, at, dir, view_radius, WALL_DEPTH, FEATHER)
hits[i] = traced.x
var s := fade_from
var e := view_radius
if traced.x < view_radius:
s = minf(traced.y, fade_from)
e = minf(traced.z, view_radius)
starts[i] = s
ends[i] = maxf(e, s + 0.5)
## How lit [param p] is, from 1 (fully) to 0 (dark). Matches what the shadow mesh
## draws: the outline between two neighbouring rays is the straight chord
## joining their ends, exactly as the triangles are laid out.
func light_at(p: Vector2) -> float:
var n := angles.size()
if n == 0:
return 1.0
var d := p - eye
var dist := d.length()
if dist < 0.001:
return 1.0
var u := d / dist
var a := wrapf(d.angle(), -PI, PI)
var j := angles.bsearch(a) % n
var i := (j - 1 + n) % n
var s := _along(u, dirs[i] * starts[i], dirs[j] * starts[j])
var e := _along(u, dirs[i] * ends[i], dirs[j] * ends[j])
if dist <= s:
return 1.0
if dist >= e:
return 0.0
return 1.0 - (dist - s) / maxf(e - s, 0.001)
## Darkness covering everything not lit, out to [param far], as one triangle
## list: { "points", "colors", "indices" }. Three vertices per ray -- where the
## fade starts (clear), where it is complete (dark) and the far edge (dark) --
## and four triangles per pair of neighbouring rays.
##
## One batch rather than a polygon per segment: there are several hundred
## segments a frame, and a draw call each was the expensive part.
func shadow_mesh(far: float, colour: Color) -> Dictionary:
var n := angles.size()
var points := PackedVector2Array()
var colors := PackedColorArray()
var indices := PackedInt32Array()
points.resize(n * 3)
colors.resize(n * 3)
indices.resize(n * 12)
var clear := Color(colour, 0.0)
var dark := Color(colour, 1.0)
for i in n:
var dir := dirs[i]
points[i * 3] = eye + dir * starts[i]
points[i * 3 + 1] = eye + dir * ends[i]
points[i * 3 + 2] = eye + dir * far
colors[i * 3] = clear
colors[i * 3 + 1] = dark
colors[i * 3 + 2] = dark
for i in n:
var j := (i + 1) % n
var si := i * 3
var sj := j * 3
var k := i * 12
# The fade: clear at the inner edge, dark at the outer.
indices[k] = si
indices[k + 1] = sj
indices[k + 2] = sj + 1
indices[k + 3] = si
indices[k + 4] = sj + 1
indices[k + 5] = si + 1
# Solid dark from there out to the far edge.
indices[k + 6] = si + 1
indices[k + 7] = sj + 1
indices[k + 8] = sj + 2
indices[k + 9] = si + 1
indices[k + 10] = sj + 2
indices[k + 11] = si + 2
return {"points": points, "colors": colors, "indices": indices}
## Distance along unit ray [param u] to the chord from [param a] to [param b],
## both relative to the eye. Falls back to the nearer end when the ray runs
## parallel to the chord, which only happens for a degenerate pair.
static func _along(u: Vector2, a: Vector2, b: Vector2) -> float:
var ab := b - a
var denom := u.cross(ab)
if absf(denom) < 0.000001:
return minf(a.length(), b.length())
return a.cross(b) / denom
## Distance from [param from] along unit [param dir] to the first tile that
## blocks sight, or [param max_dist] if there is none that close.
static func cast(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float) -> float:
return trace(map, from, dir, max_dist, 0.0, 0.0).x
## Follow one ray: where it hits, and how far its light carries into the wall.
## Returns (hit, fade_start, fade_end), all distances along the ray.
##
## Grid traversal (Amanatides and Woo), so it visits exactly the tiles the ray
## passes through and lands on the true edge of the one that stops it -- which
## is the whole difference from the old fog. A ray passing exactly through a
## corner shared by two blocking tiles is stopped there: two walls touching at
## a point leave no gap to see through.
##
## Past the hit, the light runs on to [param depth] measured straight in from the
## face it entered -- so along the ray that is depth / cos(angle of incidence),
## long for a grazing ray and short for a head-on one -- and the last
## [param feather] of it fades. It stops early, wherever it is, the moment the
## ray steps back out into a tile that does not block sight.
static func trace(map: MapGrid, from: Vector2, dir: Vector2, max_dist: float,
depth: float, feather: float) -> Vector3:
var tile := MapGrid.TILE
var px := (from.x - map.origin.x) / tile
var py := (from.y - map.origin.y) / tile
var tx := floori(px)
var ty := floori(py)
var limit := max_dist / tile
var w := map.width
var h := map.height
var tiles := map.tiles
var sight: Array = MapGrid.BLOCKS_SIGHT
var step_x := 0
var step_y := 0
var delta_x := INF
var delta_y := INF
var next_x := INF
var next_y := INF
if dir.x > 0.0:
step_x = 1
delta_x = 1.0 / dir.x
next_x = (float(tx + 1) - px) * delta_x
elif dir.x < 0.0:
step_x = -1
delta_x = -1.0 / dir.x
next_x = (px - float(tx)) * delta_x
if dir.y > 0.0:
step_y = 1
delta_y = 1.0 / dir.y
next_y = (float(ty + 1) - py) * delta_y
elif dir.y < 0.0:
step_y = -1
delta_y = -1.0 / dir.y
next_y = (py - float(ty)) * delta_y
# Phase 1 walks open ground to the first blocker; phase 2 walks on through
# the blocker while there is light left to carry.
var hit := -1.0
var stop := limit
var fade := limit
while true:
var t: float
var crossed_x := false
var diagonal := false
if next_x < next_y - 0.000000001:
t = next_x
tx += step_x
next_x += delta_x
crossed_x = true
elif next_y < next_x - 0.000000001:
t = next_y
ty += step_y
next_y += delta_y
else:
t = next_x
diagonal = true
if t > stop:
break
if diagonal:
var side_x := tx + step_x
var side_y := ty + step_y
var across_x := side_x < 0 or ty < 0 or side_x >= w or ty >= h \
or bool(sight[tiles[ty * w + side_x]])
var across_y := tx < 0 or side_y < 0 or tx >= w or side_y >= h \
or bool(sight[tiles[side_y * w + tx]])
tx = side_x
ty = side_y
next_x += delta_x
next_y += delta_y
crossed_x = true
if hit < 0.0:
if across_x and across_y:
hit = t
stop = minf(limit, t + depth / tile / maxf(absf(dir.x), absf(dir.y)))
fade = minf(stop, t + (depth - feather) / tile / maxf(absf(dir.x), absf(dir.y)))
continue
elif not (across_x and across_y):
stop = t
break
# Off the map reads as wall, the same rule MapGrid.at() keeps.
var blocked := tx < 0 or ty < 0 or tx >= w or ty >= h \
or bool(sight[tiles[ty * w + tx]])
if hit < 0.0:
if blocked:
hit = t
# Incidence against the face just crossed: an x step crosses a
# vertical face, whose normal is along x.
var c := maxf(absf(dir.x) if crossed_x else absf(dir.y), 0.000001)
stop = minf(limit, t + depth / tile / c)
fade = minf(stop, t + (depth - feather) / tile / c)
elif not blocked:
# Back out into the open: the light ends at this face, wherever the
# depth had reached. This is what keeps it from rounding a corner.
stop = t
break
if hit < 0.0:
return Vector3(max_dist, max_dist, max_dist)
stop = minf(stop, limit)
fade = minf(fade, stop)
return Vector3(hit * tile, fade * tile, stop * tile)
## Rebuild the corner list if the map changed or the eye moved to another tile.
##
## A corner is any tile vertex where blocking and open ground meet other than
## along a straight edge: one or three of the four surrounding tiles blocking, or
## two blocking diagonally. Those are the only points a shadow edge can leave
## from, which is why aiming rays at them is enough to make every edge exact.
func _refresh_corners(map: MapGrid) -> void:
var t := map.to_tile(eye)
var id := map.get_instance_id()
if id == _corner_map and map.revision == _corner_revision \
and t == _corner_tile and radius == _corner_radius:
return
_corner_map = id
_corner_revision = map.revision
_corner_tile = t
_corner_radius = radius
_corners.clear()
var span := ceili(radius / MapGrid.TILE) + 2
var x0 := maxi(t.x - span, 0)
var x1 := mini(t.x + span + 1, map.width)
var y0 := maxi(t.y - span, 0)
var y1 := mini(t.y + span + 1, map.height)
for vy in range(y0, y1 + 1):
for vx in range(x0, x1 + 1):
var a := map.blocks_sight(vx - 1, vy - 1)
var b := map.blocks_sight(vx, vy - 1)
var c := map.blocks_sight(vx - 1, vy)
var d := map.blocks_sight(vx, vy)
var count := int(a) + int(b) + int(c) + int(d)
# Two blocking and a == d means the pair is diagonal (a+d or b+c).
if count == 1 or count == 3 or (count == 2 and a == d):
_corners.append(map.origin + Vector2(float(vx), float(vy)) * MapGrid.TILE)
+1
View File
@@ -0,0 +1 @@
uid://bikom644gux4e
+75 -31
View File
@@ -33,6 +33,17 @@ var client: ClientRuntime = null
var _anim_time: float = 0.0
## F1 overlay: draws what the simulation collides against, over what is drawn.
var debug_hitboxes: bool = false
## What the local player can see this frame. See [Visibility]. Read by the HUD
## too, which waits for the boss to have been seen before showing its bar.
var sight := Visibility.new()
## The darkness is the window's clear colour, exactly, so where it is complete it
## is indistinguishable from the undrawn void beyond it and there is no seam.
var _dark: Color = ProjectSettings.get_setting(
"rendering/environment/defaults/default_clear_color", Color.BLACK)
## How far past the edge of sight the darkness is drawn. It has to cover every
## tile and sprite that is drawn at all -- a tile touching the circle of sight
## reaches 45 units past it, a boss sprite further.
const DARK_REACH := 128.0
func _unhandled_input(event: InputEvent) -> void:
@@ -46,32 +57,62 @@ func _process(delta: float) -> void:
_anim_time += delta
client = Net.client
if client != null:
bullets.render_pool(client.world.pool)
bullets.render_pool(client.world.pool, client.predicted_pos,
SimConfig.FOG_VIEW_RADIUS)
queue_redraw()
## Draw order is the fog's whole mechanism, so it is worth spelling out.
##
## Everything that fog may hide is drawn first, unconditionally within sight
## range; then the darkness goes over it. That is what lets a tile -- or an enemy
## stepping round a corner -- be half lit: nothing is decided per object, the
## shadow simply covers whatever is behind it. Telegraphs go on top of the
## darkness because a warning you cannot see is no warning (see
## _draw_telegraphs). The local player goes on top because it is the eye. And
## bullets are a child node, so they are drawn after all of this, over the dark
## -- a shot coming out of the black is a shot you can still dodge. Out past the
## edge of sight they fade away, but only as far as is safe: see
## BulletRenderer.visibility().
func _draw() -> void:
if client == null:
return
var map: MapGrid = client.world.map
if map != null:
sight.compute(map, client.predicted_pos, SimConfig.FOG_VIEW_RADIUS)
_draw_terrain()
_draw_portals()
_draw_upgrade_npc()
for l in client.ground_loot():
if _visible(l["pos"]):
if _in_view(l["pos"]):
_draw_loot(l)
for e in client.enemies():
if _visible(e["pos"]):
if _in_view(e["pos"]):
_draw_enemy(e)
_draw_boss()
_draw_telegraphs()
for p in client.remote_players():
if _visible(p["pos"]):
if _in_view(p["pos"]):
_draw_remote_player(p)
if map != null:
_draw_darkness()
_draw_telegraphs()
_draw_local_player()
if debug_hitboxes:
_draw_debug()
## Everything the player cannot see, as one batch of triangles.
##
## Straight to the RenderingServer rather than through draw_polygon: a CanvasItem
## draw call is a thin wrapper around exactly this, but it takes one polygon,
## and the shadow is several hundred. Issued from inside _draw on this item's own
## canvas, so it lands in order between what was drawn before and after it.
func _draw_darkness() -> void:
var mesh := sight.shadow_mesh(SimConfig.FOG_VIEW_RADIUS + DARK_REACH, _dark)
RenderingServer.canvas_item_add_triangle_array(get_canvas_item(),
mesh["indices"], mesh["points"], mesh["colors"])
## Overlay showing what actually collides, on top of what is drawn. Deliberately
## ignores fog: the point is to check that art and simulation line up, and
## hiding half the evidence would defeat that.
@@ -91,6 +132,7 @@ func _draw_debug() -> void:
DebugDraw.draw_player(self, p["pos"], p["aim"])
DebugDraw.draw_player(self, client.predicted_pos, client.aim)
DebugDraw.draw_ranges(self, client.predicted_pos)
DebugDraw.draw_sight(self, sight)
## Aggro radius by visual index. Read from the content definitions rather than
@@ -103,15 +145,16 @@ func _aggro_for(visual: int) -> float:
return 0.0
## Terrain, drawn only where the player can currently see it.
## Terrain within sight range, all of it. Whether it can actually be SEEN is
## decided afterwards, by the darkness drawn over it -- see _draw.
##
## Hard fog: nothing outside line of sight is drawn, including ground already
## walked over. Note what this is and is not -- it is a rendering rule, not a
## secrecy mechanism. The client holds real tile data because it simulates
## bullets, so a modified client could draw what is hidden here. What it cannot
## do is see terrain the server never streamed (only a radius around the
## player) or actors the server filtered out, which is where the line actually
## sits.
## Still hard fog: nothing is remembered, and ground you walked over is dark
## again once it is behind you. Note what this is and is not -- a rendering
## rule, not a secrecy mechanism. The client holds real tile data because it
## simulates bullets, so a modified client could draw what is hidden here. What
## it cannot do is see terrain the server never streamed (only a radius around
## the player) or actors the server filtered out, which is where the line
## actually sits.
func _draw_terrain() -> void:
var map: MapGrid = client.world.map
if map == null:
@@ -121,15 +164,18 @@ func _draw_terrain() -> void:
var lo := map.to_tile(eye - Vector2(view, view))
var hi := map.to_tile(eye + Vector2(view, view))
var view_sq := view * view
var half := Vector2(MapGrid.TILE, MapGrid.TILE) * 0.5
for ty in range(lo.y, hi.y + 1):
for tx in range(lo.x, hi.x + 1):
var kind := map.at(tx, ty)
if kind == MapGrid.Kind.UNKNOWN:
continue
var centre := map.tile_centre(tx, ty)
if eye.distance_squared_to(centre) > view_sq:
continue
if not map.has_line_of_sight(eye, centre):
# The tile's nearest point, not its centre: a tile the circle of
# sight cuts through is partly inside it, and skipping it would put
# a notch in the edge.
var near := eye.clamp(centre - half, centre + half)
if eye.distance_squared_to(near) > view_sq:
continue
_draw_tile(kind, centre, tx, ty)
@@ -158,16 +204,14 @@ func _draw_tile(kind: MapGrid.Kind, centre: Vector2, tx: int, ty: int) -> void:
draw_texture_rect_region(Art.TILESET, dest, Art.BARRICADE)
## Actors are only drawn where the player can actually see them. The server
## already withholds distant ones; this hides the ones behind walls.
func _visible(at: Vector2) -> bool:
var map: MapGrid = client.world.map
if map == null:
return true
var eye := client.predicted_pos
if eye.distance_squared_to(at) > SimConfig.FOG_VIEW_RADIUS * SimConfig.FOG_VIEW_RADIUS:
return false
return map.has_line_of_sight(eye, at)
## Whether an actor is near enough to be worth drawing. Distance only: whether
## it is behind a wall is the darkness's job, not this one's, and that is what
## lets something stepping round a corner be half visible instead of popping in
## whole the moment its centre clears the edge. The margin lets a large sprite
## standing just past the circle of sight show the part of it that is inside.
func _in_view(at: Vector2) -> bool:
var reach := SimConfig.FOG_VIEW_RADIUS + 48.0
return client.predicted_pos.distance_squared_to(at) <= reach * reach
## One ring per entrance, coloured and labelled by the dungeon it opens. The
@@ -192,7 +236,7 @@ func _draw_upgrade_npc() -> void:
if client.instance_kind != Protocol.InstanceKind.LOBBY:
return
var at := client.upgrade_npc
if not _visible(at):
if not _in_view(at):
return
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
@@ -206,7 +250,7 @@ func _draw_upgrade_npc() -> void:
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
if client.at_upgrade_npc():
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
"%s talk" % Settings.short_label("interact"), HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
Color(0.8, 0.85, 0.95))
# A pending choice pulses, so it is visible from across the hub.
if waiting > 0:
@@ -256,8 +300,8 @@ func _draw_enemy(e: Dictionary) -> void:
## Attacks that have been announced but not yet landed.
##
## Drawn through fog and through walls, deliberately. Everything else in this
## view respects line of sight, but a warning you cannot see is not a warning --
## Drawn over the darkness and through walls, deliberately. Everything else in
## this view is covered by it, but a warning you cannot see is not a warning --
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
## exist is to make the strike a decision. The marker is inside the boss arena
## you are already standing in, so this hides nothing a player could not walk
@@ -278,7 +322,7 @@ func _draw_telegraphs() -> void:
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty() or not _visible(b["pos"]):
if b.is_empty() or not _in_view(b["pos"]):
return
var pos: Vector2 = b["pos"]
var visual := client.boss_def.visual if client.boss_def != null else 0
+96
View File
@@ -138,3 +138,99 @@ func test_interacting_away_from_the_portal_does_nothing() -> void:
var used := lobby.world.events.filter(
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.PORTAL_USED)
assert_eq(used.size(), 0)
# --- Tunnel groups ----------------------------------------------------------
func _tunnel_enemies(run: Instance) -> Array[SimEnemy]:
var out: Array[SimEnemy] = []
for e in run.world.enemies.values():
var t := run.world.map.to_tile(e.pos)
if run.boss_room.has_point(t):
continue
var in_room := false
for r in run.rooms:
if r.grow(1).has_point(t):
in_room = true
if not in_room:
out.append(e)
return out
func test_tunnels_are_occasionally_occupied_and_usually_not() -> void:
# "Rarely" in numbers: across many depth-1 runs, some have a group in a
# tunnel and most do not.
var occupied := 0
var runs := 80
for s in range(1, runs + 1):
if not _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1)).is_empty():
occupied += 1
assert_gt(occupied, runs / 10, "tunnel groups exist (%d of %d runs)" % [occupied, runs])
assert_lt(occupied, runs / 2, "but a run with one is the exception (%d of %d)" % [occupied, runs])
func test_a_tunnel_group_is_never_awake_when_you_arrive() -> void:
var widest := Instance._widest_aggro()
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
for e in _tunnel_enemies(run):
assert_gt(e.pos.distance_to(run.world.spawn_point), widest,
"seed %d: a tunnel %s stands within aggro range of the entrance"
% [s * 7919, e.def.id])
func test_a_tunnel_group_is_a_group() -> void:
# Never a lone straggler, and never spread down the whole tunnel: every
# member has another within a few tiles.
var near := MapGrid.TILE * (Instance.TUNNEL_GROUP_SPREAD * 2 + 1)
var seen := 0
for s in range(1, 120):
var found := _tunnel_enemies(Instance.make_dungeon(2, s * 7919, 1 + (s % 3)))
for e in found:
seen += 1
var has_company := false
for other in found:
if other != e and other.pos.distance_to(e.pos) <= near:
has_company = true
assert_true(has_company, "seed %d: a tunnel %s on its own" % [s * 7919, e.def.id])
assert_gt(seen, 0, "the sample produced at least one group to check")
func test_a_tunnel_group_is_scaled_like_everything_else() -> void:
# The Proving Grounds scales every enemy it spawns; a tunnel group must not
# be the one thing that arrives at full health in the easy harness.
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 2, Dungeons.PROVING)
for e in _tunnel_enemies(run):
assert_lt(e.hp, Content.enemy(e.def.id).max_hp,
"seed %d: a tunnel %s skipped the dungeon's scaling" % [s * 7919, e.def.id])
func test_boss_rush_leaves_the_tunnels_empty_too() -> void:
GameOpts.boss_rush = true
var run := Instance.make_dungeon(2, 4242, 3)
GameOpts.boss_rush = false
assert_eq(_tunnel_enemies(run).size(), 0)
func test_nothing_is_awake_and_watching_when_the_party_arrives() -> void:
# Room 0 is left empty so that arriving is not an ambush, but that only ever
# covered room 0. With tunnels widened, three runs in eight had an enemy in
# a neighbouring room within aggro range and in sight of the entrance.
var watching := 0
for s in range(1, 120):
var run := Instance.make_dungeon(2, s * 7919, 1 + (s % 3))
for e in run.world.enemies.values():
if e.pos.distance_to(run.world.spawn_point) <= e.def.aggro_range \
and run.world.map.has_line_of_sight(e.pos, run.world.spawn_point):
watching += 1
assert_eq(watching, 0, "%d enemies could see the entrance at the moment of arrival" % watching)
func test_clearing_the_entrance_leaves_the_rest_of_the_dungeon_alone() -> void:
# It skips the few enemies that would be awake on arrival and nothing else:
# the dungeon keeps nearly all of its population.
var total := 0
for s in range(1, 40):
total += Instance.make_dungeon(2, s * 7919, 1).world.enemies.size()
assert_gt(float(total) / 39.0, 9.0, "a depth-1 dungeon still has a dungeon's worth of enemies")
+112
View File
@@ -0,0 +1,112 @@
extends GutTest
## Which bullets are drawn, and the promise that bounds it: no enemy bullet can
## hit you without having been on screen, at full strength, for at least
## BulletRenderer.WARNING_TIME first. Fading distant bullets is only worth doing
## if that holds for every bullet the game can fire.
const RENDERER := preload("res://src/view/bullet_renderer.gd")
const SIGHT := SimConfig.FOG_VIEW_RADIUS
## The worst case for warning: the bullet coming straight at you and you running
## straight at it. Stepped the way BulletPool integrates, from far enough out
## that it starts undrawn. Returns seconds it was fully drawn before it hit.
func _warning(speed: float, accel: float) -> float:
var dt := SimConfig.TICK_DELTA
var gap := 6000.0
var v := speed
var t := 0
var first_full := -1
var reach := SimConfig.PLAYER_RADIUS + 7.0
while gap > reach and t < 60 * 120:
if first_full < 0 and RENDERER.visibility(gap, v, accel,
SimConfig.TEAM_ENEMY, SIGHT) >= 1.0:
first_full = t
v = maxf(v + accel * dt, 0.0)
gap -= (v + SimConfig.PLAYER_SPEED) * dt
t += 1
assert_gte(first_full, 0, "the bullet was drawn at all before it arrived")
return float(t - first_full) * dt
func test_every_enemy_bullet_in_the_game_is_seen_in_time() -> void:
var emitters: Array[BulletEmitter] = []
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
emitters.append_array(phase.emitters)
assert_gt(emitters.size(), 0)
for e in emitters:
var warned := _warning(e.speed, e.accel)
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"a %.0f u/s bullet was on screen %.2fs before it could land" % [e.speed, warned])
func test_a_bullet_far_faster_than_anything_today_is_still_seen_in_time() -> void:
# The sight radius covers today's content on its own. This is the case the
# speed term exists for: content added later, faster than the radius allows.
for case in [[1200.0, 0.0], [600.0, 900.0], [2000.0, 0.0]]:
var warned := _warning(case[0], case[1])
assert_gte(warned, RENDERER.WARNING_TIME - SimConfig.TICK_DELTA,
"%.0f u/s, accelerating %.0f: on screen %.2fs" % [case[0], case[1], warned])
func test_inside_sight_everything_is_drawn_fully() -> void:
for team in [SimConfig.TEAM_ENEMY, SimConfig.TEAM_PLAYER]:
assert_eq(RENDERER.visibility(SIGHT - 1.0, 130.0, 0.0, team, SIGHT), 1.0)
func test_a_slow_shot_far_beyond_sight_is_not_drawn() -> void:
assert_eq(RENDERER.visibility(SIGHT + 400.0, 130.0, 0.0, SimConfig.TEAM_ENEMY, SIGHT),
0.0, "out in the black and nowhere near you")
func test_the_edge_fades_rather_than_cuts() -> void:
var mid := RENDERER.visibility(SIGHT + RENDERER.FADE_OUT * 0.5, 130.0, 0.0,
SimConfig.TEAM_ENEMY, SIGHT)
assert_between(mid, 0.3, 0.7)
func test_player_shots_fade_at_the_edge_of_sight_however_fast() -> void:
# They cannot hurt you, so there is nothing to warn about -- and a Sniper's
# shot streaking across the dark was the commonest case of all.
assert_eq(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_PLAYER, SIGHT), 0.0)
assert_gt(RENDERER.visibility(SIGHT + 200.0, SimConfig.MAX_BULLET_SPEED, 0.0,
SimConfig.TEAM_ENEMY, SIGHT), 0.0,
"an enemy bullet that fast is drawn further out, because it could reach you")
# --- The renderer actually applies it ---------------------------------------
func _drawn(r: Node2D) -> int:
var n := 0
for layer in r._layers:
n += (layer as MultiMeshInstance2D).multimesh.visible_instance_count
return n
func test_the_renderer_leaves_out_what_is_too_far_to_matter() -> void:
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
var eye := Vector2(100.0, 100.0)
pool.spawn(eye + Vector2(200.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(eye + Vector2(SIGHT + 500.0, 0.0), Vector2(-130.0, 0.0), 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool, eye, SIGHT)
assert_eq(_drawn(r), 1, "the near bullet only")
func test_with_no_eye_everything_is_drawn() -> void:
# The connect screen's backdrop has no player and wants the whole pattern.
var r: Node2D = RENDERER.new()
add_child_autofree(r)
var pool := BulletPool.new()
pool.spawn(Vector2.ZERO, Vector2.RIGHT, 7.0, 300, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
pool.spawn(Vector2(5000.0, 0.0), Vector2.RIGHT, 7.0, 300, 10,
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
r.render_pool(pool)
assert_eq(_drawn(r), 2)
+1
View File
@@ -0,0 +1 @@
uid://noaqo4x7so77
+91
View File
@@ -312,3 +312,94 @@ func test_an_unknown_upgrade_in_a_save_is_dropped() -> void:
assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName])
assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName])
assert_eq(restored.pending_choices, 2)
# --- Deferred writes --------------------------------------------------------
# Two paths a client can trigger at the tick rate -- item moves and experience
# grants -- mark the store dirty instead of writing, and the server flushes once
# per tick. Everything else still writes the moment it happens.
func test_experience_below_a_level_boundary_is_still_written() -> void:
var c := store.create_character(ACC, "Grinder")
assert_eq(store.grant_xp(ACC, c.id, 5), 0, "nowhere near a level")
store.flush()
var reloaded := CharacterStore.new(store._path)
assert_true(reloaded.load_from_disk())
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 5,
"experience that has not crossed a level is the only record of it: " +
"level is derived from lifetime xp, so losing the xp loses the level")
func test_a_deferred_inventory_write_lands_on_the_flush() -> void:
var c := store.create_character(ACC, "Carrier") # writes immediately
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.NONE, Items.NONE, Items.NONE]
store.set_inventory(ACC, c.id, bag, true)
var before := CharacterStore.new(store._path)
before.load_from_disk()
assert_eq(before.get_character(ACC, c.id).inventory[0], Items.NONE,
"deferred means deferred; the file still holds the older bag")
store.flush()
var after := CharacterStore.new(store._path)
after.load_from_disk()
assert_eq(after.get_character(ACC, c.id).inventory[0], Items.HEALTH_POTION)
func test_flushing_a_clean_store_writes_nothing() -> void:
store.create_character(ACC, "Idle")
var stamp := FileAccess.get_modified_time(store._path)
store.flush()
assert_eq(FileAccess.get_modified_time(store._path), stamp,
"a tick in which nothing happened must not cost a disk write")
func test_an_immediate_write_clears_the_pending_one() -> void:
var c := store.create_character(ACC, "Mixed")
store.grant_xp(ACC, c.id, 7) # dirty, not written
store.set_last_played(ACC, c.id) # writes now, and takes the xp with it
var reloaded := CharacterStore.new(store._path)
reloaded.load_from_disk()
assert_eq(reloaded.get_character(ACC, c.id).total_xp, 7)
# --- Health on the record ---------------------------------------------------
# SimWorld builds a blank player on every instance change, so anything that must
# survive walking through a door lives here, exactly like the inventory.
func test_health_survives_a_save_and_reload() -> void:
var c := store.create_character(ACC, "Wounded")
store.set_health(ACC, c.id, 137)
store.flush()
var reloaded := CharacterStore.new(store._path)
assert_true(reloaded.load_from_disk())
assert_eq(reloaded.get_character(ACC, c.id).hp, 137)
func test_a_fresh_character_is_recorded_as_whole_rather_than_as_a_number() -> void:
var c := store.create_character(ACC, "New")
assert_eq(c.hp, Character.HP_FULL,
"a character who has never been hurt has no health number to keep")
func test_a_save_from_before_health_was_kept_loads_as_whole() -> void:
var d := Character.create("Old", RandomNumberGenerator.new()).to_dict()
d.erase("hp")
assert_eq(Character.from_dict(d).hp, Character.HP_FULL)
func test_a_nonsense_health_in_a_save_loads_as_whole_not_as_dead() -> void:
# Under permadeath, a character that loads at zero health is unrecoverable,
# so every unreadable value has to fail in the generous direction.
for bad in [0, -5, "banana"]:
var d := Character.create("Odd", RandomNumberGenerator.new()).to_dict()
d["hp"] = bad
assert_eq(Character.from_dict(d).hp, Character.HP_FULL,
"hp %s must load as whole" % bad)
func test_health_is_never_banked_at_zero() -> void:
var c := store.create_character(ACC, "Edge")
store.set_health(ACC, c.id, 0)
assert_eq(c.hp, 1, "the store floors it; a live character is never on zero")
+30
View File
@@ -112,3 +112,33 @@ func test_escape_completion_never_reaches_the_client_directly() -> void:
for ev: Dictionary in packet["events"]:
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_COMPLETED,
"the transfer is the server's decision; clients only see the outcome")
## The hub has nothing to escape from, and completing the channel there routes
## to "send this peer to the lobby" -- a transfer into the instance you are
## already in, which re-places the player: teleported to spawn, healed to full,
## instanced loot dropped, every map chunk re-streamed. Once a second, for as
## long as the key is held.
func test_the_hub_has_no_escape_to_channel() -> void:
var hub := Instance.make_lobby(1)
assert_false(hub.world.escape_enabled,
"the lobby is the one world that opts out")
hub.add_peer(99, "tester")
for i in SimConfig.ESCAPE_CHANNEL_TICKS * 2:
var frames: Array[InputFrame] = [InputFrame.make(hub.world.tick + 1,
Vector2.ZERO, 0.0, InputFrame.BTN_ESCAPE)]
hub.world.queue_input(99, frames)
hub.step()
var p: SimPlayer = hub.world.players[99]
assert_eq(p.escape_ticks, 0, "nothing should have been channelling")
for ev in hub.world.events:
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_STARTED,
"holding escape in the hub must not start a channel")
assert_ne(int(ev["t"]), SimEvent.Type.ESCAPE_COMPLETED,
"and must certainly never complete one")
func test_a_dungeon_still_lets_you_out() -> void:
# The guard above must not be a way to disable the escape everywhere.
var run := Instance.make_dungeon(2, 1234)
assert_true(run.world.escape_enabled)
+127
View File
@@ -145,3 +145,130 @@ func test_arc_sweep_angle_changes_over_the_phase() -> void:
ctx.local_tick = 30
e.fire(ctx)
assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001))
# --- Muzzles against geometry -----------------------------------------------
# An emitter places bullets away from its shooter, and BulletPool samples
# geometry once per tick, so it never sees that jump. A curtain spans the whole
# arena and an arc sweep's outer arms sit 100px out -- both were spawning shots
# on the far side of cover. Both boss arenas are built out of cover: the vault's
# barricades exist precisely so there is no clean firing lane, and the hall has
# pillars.
func _walled_world(kind: MapGrid.Kind) -> SimWorld:
var g := MapGrid.new(30, 20, MapGrid.Kind.FLOOR)
for ty in 20:
g.set_tile(15, ty, kind)
var w := SimWorld.new(1)
w.set_map(g)
return w
func _ctx_for(w: SimWorld, at: Vector2) -> EmitContext:
var ctx := EmitContext.new()
ctx.pool = w.pool
ctx.origin = at
ctx.target = at + Vector2(0.0, 200.0)
ctx.has_target = true
ctx.rng = w.rng
ctx.bounds = Rect2(at - Vector2(320.0, 320.0), Vector2(640.0, 640.0))
return ctx
## Bullets born strictly beyond the barrier column, which spans one tile.
func _past_the_barrier(w: SimWorld) -> int:
var beyond: float = w.map.origin.x + 16.0 * MapGrid.TILE
var n := 0
for i in w.pool.high_water:
if w.pool.alive[i] != 0 and w.pool.pos[i].x >= beyond:
n += 1
return n
func _fire_once(w: SimWorld, em: BulletEmitter, at: Vector2) -> void:
var ctx := _ctx_for(w, at)
ctx.local_tick = 0
ctx.shot_index = 0
em.fire(ctx)
func test_a_curtain_does_not_spawn_through_a_barricade() -> void:
# A curtain spans its bounds, so most of it is nowhere near the shooter --
# which is exactly how it was reaching the far side of the room.
var em := WallGapEmitter.new()
em.count = 16
em.speed = 100.0
var open_world := _walled_world(MapGrid.Kind.FLOOR)
var at := Vector2(open_world.map.origin.x + 14.0 * MapGrid.TILE,
open_world.map.tile_centre(14, 10).y)
_fire_once(open_world, em, at)
assert_gt(_past_the_barrier(open_world), 0,
"with nothing in the way the curtain reaches across the room")
var blocked := _walled_world(MapGrid.Kind.BARRICADE)
_fire_once(blocked, em, at)
assert_eq(_past_the_barrier(blocked), 0,
"a barricade stops bullets, so it has to stop them being born past it")
assert_gt(blocked.pool.live_count, 0,
"and the near half of the curtain still fires")
func test_an_arc_sweeps_outer_arms_do_not_reach_through_a_wall() -> void:
var em := ArcSweepEmitter.new()
em.arms = 4
em.arm_spacing = 26.0
em.muzzle_offset = 26.0
em.speed = 100.0
var at_open := _walled_world(MapGrid.Kind.FLOOR)
# Flush against the barrier, sweeping into it.
var at := Vector2(at_open.map.origin.x + 15.0 * MapGrid.TILE - 20.0,
at_open.map.tile_centre(10, 10).y)
_fire_once(at_open, em, at)
var reach_open := _past_the_barrier(at_open)
var blocked := _walled_world(MapGrid.Kind.WALL)
_fire_once(blocked, em, at)
assert_gt(reach_open, 0, "the arms do reach past a tile when nothing stops them")
assert_eq(_past_the_barrier(blocked), 0, "and must not when something does")
func test_no_boss_ever_spawns_a_bullet_inside_its_own_arena_s_cover() -> void:
# The gameplay statement, against the real arenas. Both are authored around
# cover -- the vault's barricades exist so that knowing where the Cantor is
# does not hand you a firing lane, and the hall has pillars -- and a spawner
# that ignores geometry quietly cancels all of it.
for dungeon in [Dungeons.STANDARD, Dungeons.VAULT]:
var inst := Instance.make_dungeon(2, 4242, 1, dungeon)
var w := inst.world
var bait := w.add_player(1, "bait")
bait.pos = inst.boss_spawn + Vector2(90.0, 0.0)
var offenders := 0
for _t in 900:
w.step()
for slot in w.pool.spawn_log:
if w.map.bullet_blocked(w.pool.pos[slot]):
offenders += 1
assert_eq(offenders, 0,
"%s spawned %d bullet(s) inside its own arena's cover"
% [dungeon, offenders])
## The Cantor's strike_point avoids the room's EDGE and knows nothing about the
## barricades and pillars inside it, so a volley can be marked on top of cover.
## What lands there is nothing at all -- which is cover doing its job, and is
## pinned here so it stays a known shape rather than a surprise.
func test_a_strike_marked_on_cover_is_a_dud_rather_than_a_shot_through_it() -> void:
var g := MapGrid.new(20, 20, MapGrid.Kind.FLOOR)
g.set_tile(10, 10, MapGrid.Kind.BARRICADE)
var w := SimWorld.new(1)
w.set_map(g)
var em := TelegraphedStrikeEmitter.new()
em.burst_count = 8
em.blast_radius = 40.0
em.speed = 100.0
var ctx := _ctx_for(w, g.tile_centre(2, 2))
# Burst centred on the barricade: nothing may come out of it, and nothing
# may appear on the far side of it either.
em._burst(ctx, g.tile_centre(10, 10))
assert_eq(w.pool.live_count, 0,
"a burst inside cover stays inside it, and dies there")
+45
View File
@@ -0,0 +1,45 @@
extends GutTest
## Permadeath is announced. The server unbinds a character on the tick it dies,
## so before this the only sign anything had happened was the roster appearing.
const GAME_SCENE := preload("res://src/view/game_scene.gd")
func test_the_line_names_who_fell_how_far_they_got_and_where() -> void:
var line: String = GAME_SCENE.fallen_line(
{"name": "Bramcreek", "level": 7, "where": "Warden's Descent"})
assert_string_contains(line, "Bramcreek")
assert_string_contains(line, "level 7")
assert_string_contains(line, "in the Warden's Descent")
func test_a_dungeon_already_called_the_something_is_not_the_the_something() -> void:
var line: String = GAME_SCENE.fallen_line(
{"name": "Wren", "level": 3, "where": "The Choir Vault"})
assert_string_contains(line, "in the Choir Vault")
assert_false(line.contains("the The"))
func test_the_client_records_its_own_death_from_the_server_event() -> void:
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_peer = 5
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
c.selected_character = "a"
c.instance_kind = Protocol.InstanceKind.DUNGEON
c.dungeon_id = Dungeons.VAULT
c.my_total_xp = Progression.total_xp_for_level(4)
c.on_events(NetCodec.encode_events(10,
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 5}] as Array[Dictionary]))
assert_eq(c.last_fallen.get("name", ""), "Ash")
assert_eq(int(c.last_fallen.get("level", 0)), 4)
assert_eq(String(c.last_fallen.get("where", "")), "The Choir Vault")
func test_someone_elses_death_is_not_yours() -> void:
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_peer = 5
c.characters = [{"id": "a", "name": "Ash", "active": true}] as Array[Dictionary]
c.selected_character = "a"
c.on_events(NetCodec.encode_events(10,
[{"t": SimEvent.Type.PLAYER_DIED, "peer": 6}] as Array[Dictionary]))
assert_true(c.last_fallen.is_empty())
+1
View File
@@ -0,0 +1 @@
uid://chmutr64rrmsh
+213
View File
@@ -0,0 +1,213 @@
extends GutTest
## HUD and connect-screen behaviour that does not need a screen to check.
## What things LOOK like is screenshot.tscn's job; this is what they DO.
const HUD := preload("res://src/ui/hud.gd")
const MENU := preload("res://src/ui/main_menu.gd")
const BACKDROP := preload("res://src/ui/menu_backdrop.gd")
func _hud_with(hp: int, max_hp: int) -> Array:
var hud: CanvasLayer = HUD.new()
add_child_autofree(hud)
var c: ClientRuntime = autofree(ClientRuntime.new())
c.my_hp = hp
c.my_max_hp = max_hp
hud.client = c
hud._hp_trail = float(hp) / float(max_hp)
hud._hp_last = hud._hp_trail
return [hud, c]
# --- The damage trail -------------------------------------------------------
func test_a_hit_leaves_a_trail_where_the_bar_was() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 400
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "the trail holds at the old value")
assert_almost_eq(hud._hp_last, 0.4, 0.001)
func test_the_trail_holds_and_then_drains_to_meet_the_bar() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 400
hud._watch_health(0.016)
# Within the hold, nothing moves -- that pause is what makes the amount
# readable.
for _i in int(HUD.TRAIL_HOLD / 0.016) - 2:
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "still holding")
for _i in 200:
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 0.4, 0.001, "and then drained all the way down")
func test_healing_leaves_no_trail() -> void:
var pair := _hud_with(400, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 900
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 0.9, 0.001,
"a trail is for what was lost; on a heal the bar and trail move together")
func test_a_second_hit_during_the_hold_keeps_the_original_top() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_hp = 700
hud._watch_health(0.016)
c.my_hp = 300
hud._watch_health(0.016)
assert_almost_eq(hud._hp_trail, 1.0, 0.001,
"two quick hits read as one big one, measured from where it started")
# --- Experience -------------------------------------------------------------
func test_experience_arriving_quickly_is_summed_into_one_number() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
hud._seen_character = "a"
c.my_total_xp = 100
hud._watch_experience(0.016)
c.my_total_xp = 112
hud._watch_experience(0.016)
c.my_total_xp = 120
hud._watch_experience(0.016)
assert_eq(hud._xp_gain, 20, "+12 then +8 shows as +20, not as a flicker")
func test_the_first_sight_of_a_character_is_not_a_gain() -> void:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
c.my_total_xp = 5000
hud._watch_experience(0.016)
assert_eq(hud._xp_flash, 0.0, "logging in with 5000 experience did not just earn it")
# --- The connect screen -----------------------------------------------------
func _menu() -> Control:
var m: Control = MENU.new()
add_child_autofree(m)
return m
func test_a_port_that_cannot_connect_disables_join_and_says_why() -> void:
var m := _menu()
m._port.text = "twenty"
m._validate()
assert_true(m._join_button.disabled)
assert_true(m._host_button.disabled)
assert_string_contains(m._status.text, "port")
m._port.text = "70000"
m._validate()
assert_true(m._join_button.disabled, "out of range is as useless as not a number")
m._port.text = "27015"
m._validate()
assert_false(m._join_button.disabled)
assert_eq(m._status.text, "", "and the complaint goes when the problem does")
func test_no_address_disables_join_but_not_host() -> void:
var m := _menu()
m._address.text = " "
m._validate()
assert_true(m._join_button.disabled, "nowhere to join")
assert_false(m._host_button.disabled, "hosting needs no address")
func test_busy_wins_over_valid() -> void:
var m := _menu()
m.set_busy(true)
assert_true(m._join_button.disabled, "a connection in flight is not a second invitation")
m.set_busy(false)
assert_false(m._join_button.disabled)
func test_the_connect_screen_asks_for_no_name() -> void:
# It used to, and the answer went nowhere a person could see -- it only named
# the characters bots create.
var m := _menu()
for node in m.find_children("*", "LineEdit", true, false):
assert_ne((node as LineEdit).placeholder_text, "name")
assert_eq(m.find_children("*", "LineEdit", true, false).size(), 2,
"an address and a port, nothing else")
func test_the_backdrop_opens_on_a_pattern_already_in_flow() -> void:
# Every pattern starts at the centre of the screen, behind the panel. Primed,
# the first frame already has bullets well clear of it.
var b: Node2D = BACKDROP.new()
add_child_autofree(b)
var centre := b.get_viewport_rect().size * 0.5
var clear_of_panel := 0
for i in b._pool.high_water:
if b._pool.alive[i] != 0 and b._pool.pos[i].distance_to(centre) > 320.0:
clear_of_panel += 1
assert_gt(clear_of_panel, 10, "bullets beyond the panel on the very first frame")
# --- The boss bar -----------------------------------------------------------
## A HUD whose fog sees from the left of a room, with a wall down the middle.
func _hud_with_sight() -> Array:
var pair := _hud_with(1000, 1000)
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
var g := MapGrid.new(40, 20, MapGrid.Kind.FLOOR)
for ty in 20:
g.set_tile(20, ty, MapGrid.Kind.WALL)
var sight := Visibility.new()
sight.compute(g, Vector2(10.5, 10.5) * MapGrid.TILE, 460.0)
hud.sight = sight
c.instance_id = 3
c.boss_def = Content.boss(&"warden")
return [hud, c]
func _boss_at(x_tiles: float, phase: int = 0) -> Dictionary:
return {"id": 1, "pos": Vector2(x_tiles, 10.5) * MapGrid.TILE, "phase": phase, "hp": 100}
func test_the_bar_waits_until_the_boss_has_been_seen() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
# Behind the wall: in the snapshot -- the interest radius passes through
# walls -- but not seen.
assert_false(hud.track_boss(_boss_at(24.0)), "known about is not the same as seen")
assert_true(hud.track_boss(_boss_at(16.0)), "in the light, it shows")
func test_once_seen_it_stays_for_the_fight() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
hud.track_boss(_boss_at(16.0))
assert_true(hud.track_boss(_boss_at(24.0)),
"stepping behind a pillar must not take the bar away")
func test_a_new_dungeon_has_to_be_seen_again() -> void:
var pair := _hud_with_sight()
var hud: CanvasLayer = pair[0]
var c: ClientRuntime = pair[1]
hud.track_boss(_boss_at(16.0))
# Every dungeon's boss is actor 1, so the id alone would carry the last
# fight's "seen" straight into the next one.
c.instance_id = 4
assert_false(hud.track_boss(_boss_at(24.0)))
func test_a_phase_change_is_announced_and_the_first_phase_is_not() -> void:
var hud: CanvasLayer = _hud_with_sight()[0]
hud.track_boss(_boss_at(16.0, 1))
assert_eq(hud._banner_title, "", "the phase it was in when found is not a change")
hud.track_boss(_boss_at(16.0, 2))
assert_eq(hud._banner_title, Content.boss(&"warden").phases[2].name.to_upper())
+1
View File
@@ -0,0 +1 @@
uid://cn1mu2io17h4i
+72
View File
@@ -112,3 +112,75 @@ func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
var travel := PlayerStats.build(ids).bullet_speed \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)
# --- Per-bullet interest ----------------------------------------------------
# The radius tests above pin the CEILING: no bullet in the game may need more
# than BULLET_INTEREST_RADIUS. These pin the rule that actually runs, which asks
# whether a particular bullet can reach ground this player can see. The two are
# different questions, and only the second one closes the leak: a spawn event
# carries the firing actor's exact position, so anything sent on the strength of
# a flat radius is an enemy located for a modified client.
func _server() -> ServerRuntime:
# Not added to the tree on purpose: _ready() would build a lobby and load a
# character store, and none of that is involved in an interest decision.
return autofree(ServerRuntime.new()) as ServerRuntime
func test_a_slow_bullet_fired_across_the_map_is_not_announced() -> void:
var srv := _server()
var slow := Vector2(120.0, 0.0) # 120 u/s for 60 ticks reaches 120 units
assert_false(srv._bullet_concerns(Vector2.ZERO, Vector2(2000.0, 0.0),
slow, 0.0, 60),
"it expires 1400 units short of anything this player can see")
func test_a_bullet_that_can_reach_visible_ground_is_announced() -> void:
var srv := _server()
var fast := Vector2(900.0, 0.0) # 900 u/s for 90 ticks reaches 1350
assert_true(srv._bullet_concerns(Vector2.ZERO, Vector2(1500.0, 0.0),
fast, 0.0, 90),
"1350 of travel plus the fog radius covers 1500, so it must be sent")
func test_the_horizon_is_travel_plus_what_the_player_can_see() -> void:
var srv := _server()
# A bullet whose entire travel lands exactly on the edge of sight.
var speed := 600.0
var life := 60
var reach := Movement.bullet_reach(speed, 0.0, life)
var vel := Vector2(speed, 0.0)
assert_true(srv._bullet_concerns(Vector2.ZERO,
Vector2(reach + SimConfig.FOG_VIEW_RADIUS - 1.0, 0.0), vel, 0.0, life))
assert_false(srv._bullet_concerns(Vector2.ZERO,
Vector2(reach + SimConfig.FOG_VIEW_RADIUS + 1.0, 0.0), vel, 0.0, life))
func test_acceleration_counts_toward_the_reach() -> void:
var srv := _server()
var at := Vector2(1200.0, 0.0)
var vel := Vector2(200.0, 0.0)
assert_false(srv._bullet_concerns(Vector2.ZERO, at, vel, 0.0, 90),
"200 u/s for 1.5s is 300 units; nowhere near")
assert_true(srv._bullet_concerns(Vector2.ZERO, at, vel, 900.0, 90),
"the same shot accelerating gets there, so it has to be announced")
func test_no_enemy_in_the_content_leaks_its_position_across_a_dungeon() -> void:
# The regression this all exists for. A depth-1 map is 2112x1376 units, so
# its diagonal is about 2521 -- less than BULLET_INTEREST_RADIUS. Under the
# flat radius every firing enemy anywhere on the floor was announced to
# everyone, whatever the fog or the actor cull said.
var srv := _server()
var diagonal := Vector2(66.0, 43.0).length() * MapGrid.TILE
assert_lt(diagonal, SimConfig.BULLET_INTEREST_RADIUS,
"if this stops being true the flat radius was not the bug described here")
var leaked: Array[String] = []
for id in Content.ALL_ENEMIES:
for e in Content.enemy(id).emitters:
if srv._bullet_concerns(Vector2.ZERO, Vector2(diagonal, 0.0),
Vector2(e.speed, 0.0), 0.0, e.lifetime):
leaked.append("%s (%.0f u/s x %d ticks)" % [id, e.speed, e.lifetime])
assert_eq(leaked, [] as Array[String],
"an enemy firing at the far corner of the map must not be announced")
+32
View File
@@ -418,3 +418,35 @@ func test_the_same_tick_tie_break_is_join_order_not_distance() -> void:
assert_eq(world.players[ME].inventory[0], Items.HEALTH_POTION,
"the player who joined first wins, despite standing further away")
assert_eq(them.inventory[0], Items.NONE)
## Ground loot evicts the oldest item when the floor fills up, which is the
## right answer for a dungeon's own drops and the wrong one for a player's: the
## hub never closes, so dropping into a full floor destroyed whatever someone
## else had left there longest -- and drop is edge-triggered per tick, so a
## client could clear the hub floor in seconds.
func test_a_player_drop_never_destroys_someone_elses_item() -> void:
var w := SimWorld.new(1)
var mine := w.add_player(1, "me")
mine.pos = Vector2.ZERO
mine.inventory[0] = Items.HEALTH_POTION
var theirs: Array[int] = []
for i in SimConfig.MAX_LOOT_PER_INSTANCE:
theirs.append(w.spawn_loot(Items.WARDENS_RATION, Vector2(500.0, float(i))).id)
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE, "the floor is full")
w._drop_slot(mine, 0)
assert_eq(mine.inventory[0], Items.HEALTH_POTION,
"the drop is refused, so the item stays in the bag")
for id in theirs:
assert_true(w.loot.has(id), "nothing already on the floor may be evicted")
func test_the_cap_still_bites_for_drops_the_world_generates_itself() -> void:
# A dungeon's own drops keep the old behaviour: they must never fail to
# appear, and the instance closes and takes the litter with it anyway.
var w := SimWorld.new(1)
for i in SimConfig.MAX_LOOT_PER_INSTANCE + 5:
w.spawn_loot(Items.WARDENS_RATION, Vector2(float(i), 0.0))
assert_eq(w.loot.size(), SimConfig.MAX_LOOT_PER_INSTANCE)
+82
View File
@@ -83,3 +83,85 @@ func test_rooms_do_not_overlap() -> void:
for j in range(i + 1, rooms.size()):
assert_false((rooms[i] as Rect2i).intersects(rooms[j]),
"rooms %d and %d overlap" % [i, j])
# --- Tunnels ----------------------------------------------------------------
func _inside_any(rooms: Array, x: int, y: int) -> bool:
for r: Rect2i in rooms:
if r.has_point(Vector2i(x, y)):
return true
return false
## Whether (x, y) is a corner of at least one 2x2 square of open floor. A
## passage one tile wide has no such square anywhere along it.
func _in_open_square(g: MapGrid, x: int, y: int) -> bool:
for dy in [-1, 0]:
for dx in [-1, 0]:
var ok := true
for oy in 2:
for ox in 2:
if g.at(x + dx + ox, y + dy + oy) != MapGrid.Kind.FLOOR:
ok = false
if ok:
return true
return false
func test_no_tunnel_is_a_single_file_squeeze() -> void:
# A one-tile corridor in a bullet hell is a shooting gallery: there is no
# side to step to. Every corridor tile has to sit in open ground at least
# two wide in some direction. Rooms and the authored arena are left out --
# the arena's door is drawn one tile wide on purpose.
for s in range(1, 40):
var m := MapGen.generate(s * 7919, 1 + (s % 5))
var g: MapGrid = m["grid"]
var rooms: Array = m["rooms"]
var arena: Rect2i = m["boss_room"]
var squeezes := 0
for y in g.height:
for x in g.width:
if g.at(x, y) != MapGrid.Kind.FLOOR:
continue
if _inside_any(rooms, x, y) or arena.has_point(Vector2i(x, y)):
continue
if not _in_open_square(g, x, y):
squeezes += 1
assert_eq(squeezes, 0,
"seed %d: %d corridor tile(s) in a passage one tile wide" % [s, squeezes])
func test_tunnel_widths_vary_and_never_go_below_two() -> void:
var seen := {}
for s in range(1, 40):
var widths: PackedInt32Array = MapGen.generate(s * 7919, 1 + (s % 5))["corridor_widths"]
assert_false(widths.is_empty(), "seed %d carved no tunnels at all" % s)
for w in widths:
assert_between(w, MapGen.CORRIDOR_MIN_WIDTH, MapGen.CORRIDOR_MAX_WIDTH)
seen[w] = true
assert_eq(seen.size(), MapGen.CORRIDOR_MAX_WIDTH - MapGen.CORRIDOR_MIN_WIDTH + 1,
"every width in the range turns up somewhere; got %s" % [seen.keys()])
func test_the_arena_keeps_every_wall_it_was_drawn_with() -> void:
# The corridor to the door used to aim at the door tile itself, which sits
# ON the arena's west wall. An L that bent the other way ran its second leg
# down that wall column, and 45% of generated arenas had a slit carved in
# an authored room. A wide tunnel makes the same mistake wider.
for arena in [&"warden_hall", &"choir_vault"]:
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
for s in range(1, 60):
var m := MapGen.generate(s * 7919, 1 + (s % 5), arena)
var g: MapGrid = m["grid"]
var room: Rect2i = m["boss_room"]
var carved := 0
for y in stamp.size():
for x in stamp[y].length():
if stamp[y][x] != "#":
continue
if g.at(room.position.x + x, room.position.y + y) != MapGrid.Kind.WALL:
carved += 1
assert_eq(carved, 0,
"seed %d %s: %d of the arena's own wall tiles were carved open"
% [s * 7919, arena, carved])
+30
View File
@@ -102,3 +102,33 @@ func test_standing_inside_a_wall_can_still_see_out() -> void:
var inside := grid.tile_centre(0, 0)
var outside := grid.tile_centre(1, 1)
assert_true(grid.has_line_of_sight(inside, outside))
## bullet_path_clear differs from has_line_of_sight in two ways that matter, and
## both are easy to get wrong: it consults BLOCKS_BULLET (so a barricade stops
## it and a pit does not), and the destination tile counts.
func test_a_bullet_path_is_blocked_by_what_stops_bullets_not_by_what_stops_sight() -> void:
var a := grid.tile_centre(1, 4)
var b := grid.tile_centre(8, 4)
assert_true(grid.bullet_path_clear(a, b), "clear floor between them")
grid.set_tile(4, 4, MapGrid.Kind.PIT)
assert_true(grid.bullet_path_clear(a, b), "bullets fly over a pit")
grid.set_tile(4, 4, MapGrid.Kind.BARRICADE)
assert_false(grid.bullet_path_clear(a, b),
"you can SEE over a barricade, which is exactly why sight is the wrong test")
assert_true(grid.has_line_of_sight(a, b), "and sight still passes it")
grid.set_tile(4, 4, MapGrid.Kind.WALL)
assert_false(grid.bullet_path_clear(a, b), "a wall stops both")
func test_a_bullet_path_ending_inside_a_wall_is_refused() -> void:
var from := grid.tile_centre(1, 4)
grid.set_tile(2, 4, MapGrid.Kind.WALL)
assert_false(grid.bullet_path_clear(from, grid.tile_centre(2, 4)),
"the destination counts: a muzzle inside a wall is the case this rules out")
# Sight deliberately does the opposite -- shooting at something embedded in
# a wall must still resolve -- so the two must not be collapsed into one.
assert_true(grid.has_line_of_sight(from, grid.tile_centre(2, 4)))
+167
View File
@@ -511,3 +511,170 @@ func test_events_after_a_telegraph_still_decode() -> void:
assert_eq(int(out[1]["id"]), 77)
assert_eq(int(out[1]["hp"]), 900)
assert_eq(int(out[2]["phase"]), 2)
# --- Truncation on the two hot streams --------------------------------------
# Every cold message here already refuses to read past the end of its buffer.
# Snapshots and events -- the two that arrive twenty and sixty times a second --
# did not, and the snapshot channel is unreliable and unchecksummed, so a short
# packet is a network event rather than only an attack.
func _populated_world() -> SimWorld:
var w := SimWorld.new(1)
w.add_player(1, "a")
w.add_player(2, "b")
w.spawn_enemy(Content.turret(), Vector2(40.0, 0.0))
w.spawn_boss(Content.boss(&"warden"))
w.spawn_loot(Items.HEALTH_POTION, Vector2(10.0, 10.0))
return w
func test_the_snapshot_record_sizes_match_what_the_encoder_writes() -> void:
# The guards are arithmetic on these constants, so if they drift from the
# encoder the decoder starts refusing good packets or accepting short ones.
var empty := SimWorld.new(1)
var base := NetCodec.encode_snapshot(empty).size()
assert_eq(base, NetCodec.SNAPSHOT_HEADER + 1 + 2 + 1 + SimConfig.INVENTORY_SLOTS + 2,
"an empty world is header + three counts + the boss flag + the bag")
var one_player := SimWorld.new(1)
one_player.add_player(1, "a")
assert_eq(NetCodec.encode_snapshot(one_player).size() - base,
NetCodec.SNAPSHOT_PLAYER)
var one_enemy := SimWorld.new(1)
one_enemy.spawn_enemy(Content.turret(), Vector2.ZERO)
assert_eq(NetCodec.encode_snapshot(one_enemy).size() - base,
NetCodec.SNAPSHOT_ENEMY)
var one_boss := SimWorld.new(1)
one_boss.spawn_boss(Content.boss(&"warden"))
assert_eq(NetCodec.encode_snapshot(one_boss).size() - base,
NetCodec.SNAPSHOT_BOSS)
var one_item := SimWorld.new(1)
one_item.spawn_loot(Items.HEALTH_POTION, Vector2.ZERO)
assert_eq(NetCodec.encode_snapshot(one_item).size() - base,
NetCodec.SNAPSHOT_LOOT)
func test_a_truncated_snapshot_is_refused_rather_than_read_past_the_end() -> void:
var full := NetCodec.encode_snapshot(_populated_world())
assert_true(NetCodec.decode_snapshot(full)["valid"], "the whole packet is fine")
# Every prefix short of the whole thing must be refused, not half-applied.
for cut in range(0, full.size()):
var snap := NetCodec.decode_snapshot(full.slice(0, cut))
assert_false(snap["valid"], "a %d-byte prefix must not decode" % cut)
func test_a_snapshot_claiming_more_actors_than_it_carries_is_refused() -> void:
var data := NetCodec.encode_snapshot(_populated_world())
# Byte 5 is the player count; claim 255 of them.
data[NetCodec.SNAPSHOT_HEADER] = 255
var snap := NetCodec.decode_snapshot(data)
assert_false(snap["valid"],
"a hostile count is 255 records read out of a buffer that has none")
assert_eq((snap["players"] as Array).size(), 0)
func test_an_invalid_snapshot_carries_a_usable_empty_shape() -> void:
# The caller drops it, but it must not be a shape that errors on the way out.
var snap := NetCodec.decode_snapshot(PackedByteArray())
assert_false(snap["valid"])
assert_eq(snap["tick"], 0)
assert_eq((snap["players"] as Array).size(), 0)
assert_null(snap["boss"])
func test_every_event_type_declares_the_length_the_encoder_writes() -> void:
# The decoder needs a length BEFORE it parses, because an event of unknown
# size does not spoil one record -- it unaligns every byte after it.
var samples := {
SimEvent.Type.BULLET_SPAWN: {"uid": 1, "pos": Vector2.ONE, "vel": Vector2.ONE,
"r": 4.0, "life": 60, "kind": 0, "team": 0, "accel": 0.0, "turn": 0.0},
SimEvent.Type.BULLET_DESPAWN: {"uid": 1},
SimEvent.Type.PLAYER_HIT: {"peer": 1, "dmg": 5, "hp": 10},
SimEvent.Type.PLAYER_DIED: {"peer": 1},
SimEvent.Type.ESCAPE_STARTED: {"peer": 1},
SimEvent.Type.ESCAPE_CANCELLED: {"peer": 1},
SimEvent.Type.PLAYER_FIRED: {"peer": 1},
SimEvent.Type.PLAYER_RESPAWNED: {"peer": 1, "pos": Vector2.ONE},
SimEvent.Type.ITEM_PICKED_UP: {"peer": 1, "item": Items.HEALTH_POTION},
SimEvent.Type.ITEM_USED: {"peer": 1, "item": Items.HEALTH_POTION},
SimEvent.Type.ITEM_DROPPED: {"peer": 1, "item": Items.HEALTH_POTION},
SimEvent.Type.ENEMY_HIT: {"id": 1, "dmg": 5, "hp": 10},
SimEvent.Type.ENEMY_DIED: {"id": 1},
SimEvent.Type.BOSS_PHASE: {"phase": 1},
SimEvent.Type.BOSS_DIED: {},
SimEvent.Type.TELEGRAPH: {"pos": Vector2.ONE, "r": 8.0, "ticks": 30},
}
var header := 6 # u32 tick + u16 count
for t: int in SimEvent.Type.values():
if NetCodec.SERVER_ONLY.has(t):
assert_eq(NetCodec.event_payload_size(t), -1,
"a server-only event is never on the wire, so it has no length")
continue
assert_true(samples.has(t),
"SimEvent.Type %d is new: give it a sample and a payload size" % t)
var ev: Dictionary = (samples[t] as Dictionary).duplicate()
ev["t"] = t
var wrote := NetCodec.encode_events(0, [ev] as Array[Dictionary]).size()
assert_eq(wrote - header - 1, NetCodec.event_payload_size(t),
"declared length for SimEvent.Type %d does not match the encoder" % t)
func test_a_truncated_event_packet_stops_instead_of_inventing_bullets() -> void:
var evs: Array[Dictionary] = [
{"t": SimEvent.Type.BULLET_SPAWN, "uid": 7, "pos": Vector2(1.0, 2.0),
"vel": Vector2(3.0, 4.0), "r": 4.0, "life": 60, "kind": 0, "team": 1,
"accel": 0.0, "turn": 0.0},
{"t": SimEvent.Type.ENEMY_DIED, "id": 9},
]
var full := NetCodec.encode_events(11, evs)
assert_eq((NetCodec.decode_events(full)["events"] as Array).size(), 2)
# Lop off the last event's payload: the first must still arrive, the second
# must not be conjured from whatever is left.
var short := full.slice(0, full.size() - 2)
var got: Array = NetCodec.decode_events(short)["events"]
assert_eq(got.size(), 1, "the sound prefix is kept; the torn record is not")
assert_eq(int(got[0]["t"]), SimEvent.Type.BULLET_SPAWN)
func test_an_unknown_event_type_stops_the_stream_rather_than_desyncing_it() -> void:
var evs: Array[Dictionary] = [{"t": SimEvent.Type.ENEMY_DIED, "id": 9}]
var data := NetCodec.encode_events(3, evs)
# Claim there are two events and give the second an unparseable type.
data[4] = 2
data.append(200)
data.append_array([1, 2, 3, 4, 5, 6, 7, 8])
var got: Array = NetCodec.decode_events(data)["events"]
assert_eq(got.size(), 1,
"an event whose length is unknown unaligns everything after it, " +
"so the only safe answer is to stop")
func test_the_roster_carries_current_health_as_well_as_the_ceiling() -> void:
# Health survives a run now, so a character on the select screen can be
# badly hurt. Sending only the maximum would advertise it as whole, which
# under permadeath is the worst thing on that screen to be wrong about.
var rng := RandomNumberGenerator.new()
var whole := Character.create("Whole", rng)
var hurt := Character.create("Hurt", rng)
hurt.hp = 42
var got: Array = NetCodec.decode_characters(
NetCodec.encode_characters([whole, hurt] as Array[Character], ""))["characters"]
assert_eq(got.size(), 2)
assert_eq(int(got[0]["hp"]), int(got[0]["max_hp"]),
"a character that has never been hurt reads as full")
assert_eq(int(got[1]["hp"]), 42)
assert_gt(int(got[1]["max_hp"]), 42, "and the ceiling is still its own number")
func test_roster_health_is_never_sent_as_zero() -> void:
# A zero on this screen reads as a dead character the player is being
# offered, which cannot happen: the roster is living characters only.
var c := Character.create("Edge", RandomNumberGenerator.new())
c.hp = 0
var got: Array = NetCodec.decode_characters(
NetCodec.encode_characters([c] as Array[Character], ""))["characters"]
assert_gte(int(got[0]["hp"]), 1)
+47
View File
@@ -96,3 +96,50 @@ func test_it_cannot_outpace_incoming_fire() -> void:
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
world.step()
assert_lt(p.hp, start, "standing in fire must still lose health")
# --- Where you are decides how fast you heal ---------------------------------
# Arriving anywhere used to set health to maximum, which made a run free: walk
# in, take what you can, walk out, walk back in whole. The hub pays that off
# over about ten seconds instead.
func test_the_hub_heals_far_faster_than_a_dungeon() -> void:
var hub := Instance.make_lobby(1)
assert_eq(hub.world.regen_percent_per_sec,
SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC)
var run := Instance.make_dungeon(2, 99)
assert_eq(run.world.regen_percent_per_sec, SimConfig.HP_REGEN_PERCENT_PER_SEC)
assert_gt(hub.world.regen_percent_per_sec, run.world.regen_percent_per_sec,
"recovering is what the hub is for; a dungeon must not be a rest stop")
func test_a_full_bar_takes_about_ten_seconds_in_the_hub() -> void:
var hub := Instance.make_lobby(1)
var p := hub.world.add_player(1, "hurt")
p.hp = 1
for _t in SimConfig.TICK_RATE * 10:
hub.step()
assert_eq(p.hp, p.max_hp, "ten seconds of standing in the hub is a full bar")
func test_the_hub_still_takes_a_noticeable_moment() -> void:
# Fast, but not the instant heal this replaced: half a second must not do it.
var hub := Instance.make_lobby(1)
var p := hub.world.add_player(1, "hurt")
p.hp = 1
for _t in SimConfig.TICK_RATE / 2:
hub.step()
assert_lt(p.hp, p.max_hp, "arriving in the hub is not a heal button")
assert_gt(p.hp, 1, "but it is visibly happening")
func test_a_dungeon_cannot_be_waited_out() -> void:
var run := Instance.make_dungeon(2, 99)
var p := run.world.add_player(1, "hurt")
p.hp = 1
p.spawn_grace = 0
var before := p.hp
for _t in SimConfig.TICK_RATE * 10:
p.regenerate(run.world.regen_percent_per_sec)
assert_lt(p.hp - before, p.max_hp / 10,
"ten seconds in a dungeon must not undo a fight")
+178
View File
@@ -251,3 +251,181 @@ func test_spawn_grace_expires() -> void:
assert_false(p.invulnerable())
_drive(1, Vector2.ZERO, InputFrame.BTN_FIRE)
assert_eq(world.pool.live_count, 1, "the gun comes back once grace ends")
## A muzzle offset moves a bullet's birthplace away from its shooter, and the
## bullet pool samples geometry once per tick so it never sees that jump. With
## six Doubleshots the sideways offset reaches 45px -- wider than a 32px wall
## plus the 6px hitbox that holds you off it -- so standing flush against a wall
## and firing along it put live shots down the far side. No modified client
## needed, which is what made it worth a test rather than a comment.
func _wall_at_column(column: int) -> SimWorld:
var g := MapGrid.new(10, 6, MapGrid.Kind.FLOOR)
for ty in 6:
g.set_tile(column, ty, MapGrid.Kind.WALL)
var w := SimWorld.new(1)
w.set_map(g)
return w
func _fire_flush_against_the_wall(doubleshots: int) -> Dictionary:
var w := _wall_at_column(4)
var p := w.add_player(PEER, "tester")
var face: float = w.map.origin.x + 4.0 * MapGrid.TILE
p.pos = Vector2(face - SimConfig.PLAYER_RADIUS, w.map.tile_centre(3, 3).y)
p.aim = -PI / 2.0 # along the wall, not into it
p.spawn_grace = 0
p.fire_cooldown = 0
var ids: Array[StringName] = []
for _i in doubleshots:
ids.append(Upgrades.DOUBLESHOT)
p.stats = PlayerStats.build(ids)
w._fire_player_shot(p)
var fired := 0
var past := 0
for i in w.pool.high_water:
if w.pool.alive[i] == 0:
continue
fired += 1
if w.pool.pos[i].x > face:
past += 1
return {"fired": fired, "past_the_face": past}
func test_no_stack_of_upgrades_can_put_a_shot_through_a_wall() -> void:
for doubleshots in [0, 5, 6, 10]:
var r := _fire_flush_against_the_wall(doubleshots)
assert_eq(int(r["past_the_face"]), 0,
"%d Doubleshot(s) put %d shot(s) on the far side of the wall"
% [doubleshots, r["past_the_face"]])
func test_clamping_the_muzzle_does_not_swallow_the_shot() -> void:
# The fix must not silently reduce your firepower for standing near cover:
# a blocked muzzle falls back to the shooter's centre, it does not cancel.
assert_eq(int(_fire_flush_against_the_wall(6)["fired"]), 7,
"six Doubleshots is seven projectiles, wall or no wall")
# --- Control-message pacing -------------------------------------------------
# Selecting or creating a character rewrites the whole store to disk, re-places
# the player and broadcasts a roster to everyone, all off one small reliable
# packet. The pacing below is what stops a client turning that into a synchronous
# disk write it controls the rate of.
func _runtime() -> ServerRuntime:
# Not parented: _ready() would open a lobby and load a store, and neither is
# involved in deciding whether a message arrived too soon.
return autofree(ServerRuntime.new()) as ServerRuntime
func test_control_messages_are_paced_per_peer() -> void:
var srv := _runtime()
assert_true(srv._accept_control(PEER), "the first one is always allowed")
assert_false(srv._accept_control(PEER), "the second on the same tick is not")
srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS - 1
assert_false(srv._accept_control(PEER), "one tick short is still too soon")
srv._tick += 1
assert_true(srv._accept_control(PEER), "and the cooldown does expire")
func test_one_peer_flooding_does_not_gag_another() -> void:
var srv := _runtime()
assert_true(srv._accept_control(1))
assert_false(srv._accept_control(1))
assert_true(srv._accept_control(2), "pacing is per peer, not global")
func test_a_refused_flood_is_logged_once_and_not_once_per_packet() -> void:
# The limiter must not become the amplification it exists to prevent: a
# client sending a thousand a second would otherwise have the server format
# and write a thousand lines.
var srv := _runtime()
assert_true(srv._accept_control(PEER))
for _i in 500:
assert_false(srv._accept_control(PEER))
assert_true(srv._peer_control_warned.get(PEER, false),
"the first refusal of a burst is worth saying")
srv._tick += ServerRuntime.CONTROL_COOLDOWN_TICKS
assert_true(srv._accept_control(PEER))
assert_false(srv._peer_control_warned.has(PEER),
"behaving again re-arms the warning for the next burst")
# --- Who gets paid for a kill -----------------------------------------------
# Experience is undivided among everyone who was there, which is settled. Who
# counts as "there" was not: without a radius, a player parked in the entrance
# room collected the full experience for a boss they never saw.
func _paying_server(where: Instance) -> ServerRuntime:
var srv := _runtime()
srv.store = CharacterStore.new("user://test_award_%d.json" % randi())
srv.store.load_from_disk()
srv.instances[where.id] = where
return srv
func _seat(srv: ServerRuntime, where: Instance, peer: int, at: Vector2) -> SimPlayer:
var c := srv.store.create_character(peer, "p%d" % peer)
srv.peer_accounts[peer] = peer
srv.peer_characters[peer] = c.id
srv.peer_instance[peer] = where.id
where.add_peer(peer, c.display_name)
var p: SimPlayer = where.world.players[peer]
p.pos = at
return p
func _xp_of(srv: ServerRuntime, peer: int) -> int:
return srv.store.get_character(peer, srv.peer_characters[peer]).total_xp
func test_only_players_near_the_kill_are_paid() -> void:
var run := Instance.make_dungeon(9, 4242)
var srv := _paying_server(run)
var kill := Vector2(500.0, 500.0)
_seat(srv, run, 11, kill + Vector2(50.0, 0.0))
_seat(srv, run, 22, kill + Vector2(SimConfig.XP_AWARD_RADIUS + 200.0, 0.0))
srv._award_kill(run, 50, kill)
assert_eq(_xp_of(srv, 11), 50, "standing over it counts")
assert_eq(_xp_of(srv, 22), 0, "parked across the map does not")
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
func test_the_radius_reaches_past_what_a_player_can_see() -> void:
# Anyone who watched it die must be paid, so the boundary sits outside the
# fog rather than on it.
var run := Instance.make_dungeon(9, 4242)
var srv := _paying_server(run)
var kill := Vector2.ZERO
_seat(srv, run, 11, Vector2(SimConfig.FOG_VIEW_RADIUS, 0.0))
srv._award_kill(run, 50, kill)
assert_eq(_xp_of(srv, 11), 50,
"at the very edge of sight you still saw it happen")
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
func test_a_kill_still_pays_the_whole_party_that_was_there() -> void:
# The radius decides who was present; it must not turn into a split.
var run := Instance.make_dungeon(9, 4242)
var srv := _paying_server(run)
var kill := Vector2(100.0, 100.0)
for peer in [11, 22, 33]:
_seat(srv, run, peer, kill + Vector2(40.0, 0.0))
srv._award_kill(run, 50, kill)
for peer in [11, 22, 33]:
assert_eq(_xp_of(srv, peer), 50, "undivided, for everyone present")
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
func test_the_dead_are_not_paid_however_close_they_are_lying() -> void:
var run := Instance.make_dungeon(9, 4242)
var srv := _paying_server(run)
var kill := Vector2.ZERO
var corpse := _seat(srv, run, 11, kill)
corpse.alive = false
srv._award_kill(run, 50, kill)
assert_eq(_xp_of(srv, 11), 0)
DirAccess.remove_absolute(ProjectSettings.globalize_path(srv.store._path))
+86
View File
@@ -254,3 +254,89 @@ func test_saving_writes_to_the_configured_path() -> void:
var cfg := ConfigFile.new()
assert_eq(cfg.load(_scratch), OK)
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
# --- The connect screen remembers ---------------------------------------------
func test_the_last_server_survives_a_save_and_reload() -> void:
var saved_address := Settings.last_address
var saved_port := Settings.last_port
Settings.remember_server(" play.example.net ", 27020)
Settings.last_address = ""
Settings.last_port = 0
Settings.load_and_apply()
assert_eq(Settings.last_address, "play.example.net", "trimmed, and back again")
assert_eq(Settings.last_port, 27020)
Settings.last_address = saved_address
Settings.last_port = saved_port
func test_nonsense_is_not_remembered() -> void:
var saved_address := Settings.last_address
Settings.last_address = "kept.example.net"
Settings.remember_server(" ", 27020)
Settings.remember_server("x.example.net", 0)
Settings.remember_server("x.example.net", 70000)
assert_eq(Settings.last_address, "kept.example.net",
"a blank address or an impossible port must not replace a good one")
Settings.last_address = saved_address
# --- The HUD says what the keys actually are ----------------------------------
# Every hint on screen used to be a literal -- "WASD move", "LMB fire", "hold F"
# -- while every one of those actions is rebindable. Rebind fire and the HUD
# kept telling you to click.
const HUD := preload("res://src/ui/hud.gd")
func test_the_hints_name_the_default_keys() -> void:
assert_eq(HUD.move_keys(), "WASD")
assert_eq(HUD.key("fire"), "LMB")
assert_eq(HUD.slot_keys(4), "1-4")
assert_string_contains(HUD.controls_hint(false), "hold F")
assert_string_contains(HUD.controls_hint(false), "Esc menu",
"spelled the way a keycap spells it, not the way the OS names it")
func test_the_hints_follow_a_rebind() -> void:
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_RIGHT
click.device = -1
assert_eq(Settings.rebind("fire", click), "")
assert_eq(Settings.rebind("emergency_escape", _key_event(KEY_G)), "")
var hint := HUD.controls_hint(false)
assert_string_contains(hint, "RMB fire")
assert_string_contains(hint, "hold G")
assert_false(hint.contains("LMB"), "the old key is not mentioned anywhere")
func test_movement_spelled_out_when_it_is_not_four_letters() -> void:
assert_eq(Settings.rebind("move_up", _key_event(KEY_UP)), "")
var keys := HUD.move_keys()
assert_true(keys.begins_with("Up/"), "arrow keys read as words, got %s" % keys)
func test_slot_keys_are_listed_when_they_are_not_one_to_four() -> void:
assert_eq(Settings.rebind("use_slot_3", _key_event(KEY_Z)), "")
assert_eq(HUD.slot_keys(4), "1 2 Z 4")
func test_the_upgrade_screen_names_the_key_that_closes_it() -> void:
assert_eq(Settings.rebind("interact", _key_event(KEY_G)), "")
var screen: CanvasLayer = preload("res://src/ui/upgrade_screen.gd").new()
add_child_autofree(screen)
var none: Array[StringName] = []
screen.refresh(0, none, none)
assert_eq(screen._close.text, "Close (G)",
"it used to say (E) whatever interact was bound to")
func test_short_labels_are_what_a_keycap_says() -> void:
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
click.device = -1
assert_eq(Settings.rebind("interact", click), "fire",
"left click is fire's, and the clash is named")
assert_eq(Settings.short_label("fire"), "LMB")
assert_eq(Settings.short_label("system_menu"), "Esc")
+305
View File
@@ -0,0 +1,305 @@
extends GutTest
## The fog's geometry. Nothing here can look at the screen -- screenshot.tscn is
## for that -- but every property that makes the fog fair or unfair is a
## question about which points are lit, and those can be asked directly.
const T := MapGrid.TILE
const VIEW := 460.0
var sight: Visibility
func before_each() -> void:
sight = Visibility.new()
## An open floor, walled at the border, with the origin at a tile corner so
## world positions are easy to reason about.
func _room(w: int = 40, h: int = 40) -> MapGrid:
var g := MapGrid.new(w, h, MapGrid.Kind.WALL)
g.fill_rect(Rect2i(1, 1, w - 2, h - 2), MapGrid.Kind.FLOOR)
return g
func _centre(tx: int, ty: int) -> Vector2:
return Vector2(float(tx) + 0.5, float(ty) + 0.5) * T
func test_open_ground_is_lit_and_beyond_sight_is_not() -> void:
var g := _room()
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(eye + Vector2(100.0, 0.0)), 1.0)
assert_eq(sight.light_at(eye + Vector2(0.0, -(VIEW - Visibility.FADE - 1.0))), 1.0,
"lit all the way to where the fade begins")
assert_eq(sight.light_at(eye + Vector2(VIEW + 5.0, 0.0)), 0.0,
"nothing past the radius")
var mid := sight.light_at(eye + Vector2(VIEW - Visibility.FADE * 0.5, 0.0))
assert_between(mid, 0.2, 0.8, "the edge of sight fades instead of cutting")
func test_a_wall_hides_what_is_behind_it() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(28, 20)), 0.0)
assert_eq(sight.light_at(_centre(22, 20)), 1.0, "and not what is in front")
## The feature asked for: a shadow edge that runs THROUGH a tile, so part of it
## is lit and part is not. The old fog could only light or hide a tile whole.
func test_one_tile_can_be_part_lit() -> void:
var g := _room()
g.set_tile(22, 20, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20) + Vector2(0.0, 7.0)
sight.compute(g, eye, VIEW)
# The pillar's shadow is the wedge between its top-left and bottom-left
# corners as seen from the eye: -25.6 to +10.6 degrees. Tile (25, 21) spans
# about +2.9 to +15.9, so the lower edge of the wedge cuts straight across
# it. Sample a grid inside it and require both answers.
var lit := 0
var dark := 0
for sy in 8:
for sx in 8:
var p := Vector2(25.0 * T + 2.0 + float(sx) * 4.0, 21.0 * T + 2.0 + float(sy) * 4.0)
var l := sight.light_at(p)
if l >= 0.99:
lit += 1
elif l <= 0.01:
dark += 1
assert_gt(lit, 0, "some of the tile is in the light")
assert_gt(dark, 0, "and some of the same tile is in shadow")
func test_a_shadow_leaves_a_corner_as_a_straight_line() -> void:
var g := _room()
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
# The pillar's lower-right corner, and the line from the eye through it.
# orthogonal() turns towards the pillar's other silhouette corner, which is
# the shadowed side; its negation points away from the pillar, into light.
var corner := Vector2(23.0, 19.0) * T
var away := (corner - eye).normalized()
var side := -away.orthogonal()
for dist in [140.0, 220.0, 300.0]:
var on_line: Vector2 = eye + away * dist
assert_eq(sight.light_at(on_line + side * 3.0), 1.0,
"%d units out, just on the open side of the edge" % dist)
assert_eq(sight.light_at(on_line - side * 3.0), 0.0,
"%d units out, just on the shadowed side" % dist)
## "A bit less strict": the face of the wall in front of you is lit, rather than
## being exactly where the dark begins and so never drawn at all.
func test_the_wall_you_are_facing_is_visible() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
var face := 24.0 * T
# A fixed depth, not one computed from the constant: a test that samples at
# WALL_DEPTH - 2 passes with the depth at zero, sampling the floor in front.
assert_eq(sight.light_at(Vector2(face + 12.0, eye.y)), 1.0,
"the first stretch into the wall is fully lit")
assert_lt(sight.light_at(Vector2(face + Visibility.WALL_DEPTH + 1.0, eye.y)),
0.01, "and it is dark again before the far side")
## The regression the first version of this shipped with, caught by a
## screenshot: light carried a fixed distance ALONG each ray, so a wall seen at a
## grazing angle -- the one right beside you in a tunnel -- got a sliver and
## looked missing. Depth is measured straight in from the face now.
func test_a_wall_you_are_looking_along_is_lit_its_whole_length() -> void:
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := Vector2(24.0 * T - 40.0, 20.5 * T) # just over a tile from the face
sight.compute(g, eye, VIEW)
var dim := 0
for along in [0.0, 60.0, 120.0, 200.0, 280.0, 350.0]:
for side in [-1.0, 1.0]:
var p := Vector2(24.0 * T + 10.0, eye.y + along * side)
if sight.light_at(p) < 0.99:
dim += 1
assert_eq(dim, 0, "ten units into the wall, all the way along it, is lit")
func test_light_does_not_round_a_corner() -> void:
# A wall ending in a corner, seen at a grazing angle. A ray that enters the
# wall near its end would, if its light ran on regardless, come out through
# the end face into the open space round the corner. It must stop there.
var g := _room()
for ty in range(1, 21):
g.set_tile(24, ty, MapGrid.Kind.WALL) # ends at row 20
var eye := Vector2(24.0 * T - 40.0, 6.0 * T)
sight.compute(g, eye, VIEW)
# Round the end of the wall: past its far face, below its last tile.
for p in [Vector2(25.0 * T + 6.0, 21.0 * T + 6.0),
Vector2(26.0 * T, 21.0 * T + 20.0),
Vector2(25.0 * T + 20.0, 22.0 * T)]:
assert_eq(sight.light_at(p), 0.0, "round the corner at %s is dark" % p)
func test_light_never_reaches_the_far_side_of_a_one_tile_wall() -> void:
assert_lt(Visibility.WALL_DEPTH, T,
"the lit band inside a wall must stop inside a single tile of it")
var g := _room()
for ty in range(1, 39):
g.set_tile(24, ty, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
# Just past the wall, at every height the eye can see the wall at -- steep
# and grazing alike.
var leaks := 0
for ty in range(2, 38):
for fx in [0.5, 4.0, 12.0]:
if sight.light_at(Vector2(25.0 * T + fx, float(ty) * T + 16.0)) > 0.0:
leaks += 1
assert_eq(leaks, 0, "nothing beyond a one-tile wall may be lit")
func test_a_staircase_wall_hides_its_far_side() -> void:
# A wall one tile thick that runs diagonally, as steps. A band of light
# measured straight in from the face it entered could leave a step through
# its other side -- which is the far side of the wall.
var g := _room()
for i in 14:
g.set_tile(18 + i, 6 + i, MapGrid.Kind.WALL)
g.set_tile(19 + i, 6 + i, MapGrid.Kind.WALL)
var eye := _centre(16, 16) # below-left of the stairs
sight.compute(g, eye, VIEW)
var leaks := 0
for i in range(1, 12):
# The tile immediately above-right of each step: the far side.
var far := _centre(20 + i, 5 + i)
if g.at(20 + i, 5 + i) == MapGrid.Kind.FLOOR and sight.light_at(far) > 0.0:
leaks += 1
assert_eq(leaks, 0, "the far side of a stepped wall stays dark")
func test_two_tiles_touching_at_a_corner_leave_no_gap_to_see_through() -> void:
var g := _room()
g.set_tile(22, 21, MapGrid.Kind.PILLAR)
g.set_tile(21, 22, MapGrid.Kind.PILLAR)
var eye := _centre(20, 20) + Vector2(16.0, 16.0) # the shared corner's diagonal
sight.compute(g, eye, VIEW)
# Straight along the diagonal, through the point where the two pillars meet.
var through := eye + Vector2(1.0, 1.0).normalized() * 120.0
assert_eq(sight.light_at(through), 0.0,
"two walls meeting at a point are closed, not a crack")
# Vector2 is single precision and the cast is double, so "exactly" is to a
# thousandth of a unit.
assert_almost_eq(Visibility.cast(g, eye, Vector2(1.0, 1.0).normalized(), VIEW),
Vector2(22.0 * T, 22.0 * T).distance_to(eye), 0.001,
"the ray stops exactly at the shared corner")
func test_a_ray_stops_exactly_on_the_face_it_hits() -> void:
var g := _room()
g.set_tile(25, 20, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
assert_almost_eq(Visibility.cast(g, eye, Vector2.RIGHT, VIEW), 25.0 * T - eye.x, 0.0001)
assert_eq(Visibility.cast(g, eye, Vector2.UP, 100.0), 100.0,
"nothing within reach means the full reach")
func test_only_what_blocks_sight_casts_a_shadow() -> void:
var g := _room()
var eye := _centre(20, 20)
for kind in [MapGrid.Kind.PIT, MapGrid.Kind.BARRICADE]:
g.set_tile(23, 20, kind)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(26, 20)), 1.0,
"you can see across a %s" % MapGrid.Kind.keys()[kind])
g.set_tile(23, 20, MapGrid.Kind.PILLAR)
sight.compute(g, eye, VIEW)
assert_eq(sight.light_at(_centre(26, 20)), 0.0, "but not past a pillar")
func test_a_streamed_wall_is_noticed() -> void:
# The corner list is cached between frames, so a chunk arriving with a new
# wall in it has to invalidate it. A stale list does not lose the shadow --
# rays read the tiles live -- it loses the EDGE: the new pillar's corners get
# no rays of their own, and its silhouette is drawn only as finely as the
# sweep, two degrees at a time. So the check is the edge, not the shadow.
var g := _room()
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
g.set_tile(22, 18, MapGrid.Kind.PILLAR)
sight.compute(g, eye, VIEW)
var corner := Vector2(23.0, 19.0) * T
var away := (corner - eye).normalized()
var side := -away.orthogonal()
var on_line := eye + away * 300.0
assert_eq(sight.light_at(on_line + side * 2.0), 1.0,
"the new pillar's edge is as sharp as one that was always there")
assert_eq(sight.light_at(on_line - side * 2.0), 0.0)
func test_light_falls_off_monotonically_along_every_ray() -> void:
var g := _room()
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
g.set_tile(17, 23, MapGrid.Kind.WALL)
var eye := _centre(20, 20)
sight.compute(g, eye, VIEW)
for i in 64:
var dir := Vector2.from_angle(TAU * float(i) / 64.0)
var last := 1.0
var r := 4.0
while r < VIEW + 20.0:
var l := sight.light_at(eye + dir * r)
assert_lte(l, last + 0.0001, "light never comes back along a ray")
last = l
r += 6.0
func test_the_shadow_mesh_is_well_formed() -> void:
var g := _room()
g.set_tile(23, 18, MapGrid.Kind.PILLAR)
sight.compute(g, _centre(20, 20), VIEW)
var mesh := sight.shadow_mesh(VIEW + 64.0, Color(0.05, 0.05, 0.08))
var points: PackedVector2Array = mesh["points"]
var colors: PackedColorArray = mesh["colors"]
var indices: PackedInt32Array = mesh["indices"]
assert_eq(points.size(), sight.angles.size() * 3)
assert_eq(colors.size(), points.size(), "a colour per vertex")
assert_eq(indices.size() % 3, 0, "whole triangles")
for k in indices:
assert_between(k, 0, points.size() - 1)
for c in colors:
assert_true(c.a == 0.0 or c.a == 1.0, "clear at the fade's start, dark beyond")
func test_neighbouring_rays_are_never_far_apart() -> void:
# The outline between two rays is a straight chord. If the gap were wide, the
# chord would cut inside the circle of sight on open ground, and a wedge of
# darkness would appear where nothing is in the way.
var g := _room()
sight.compute(g, _centre(20, 20), VIEW)
var widest := 0.0
var n := sight.angles.size()
for i in n:
var gap := wrapf(sight.angles[(i + 1) % n] - sight.angles[i], 0.0, TAU)
widest = maxf(widest, gap)
var dip := (VIEW - Visibility.FADE) * (1.0 - cos(widest * 0.5))
assert_lt(dip, 1.0,
"the chord dips %.2f units into open ground; under a pixel is invisible" % dip)
# And the behaviour that follows from it: halfway between two rays, right at
# the start of the fade, open ground is still fully lit.
var mid := (sight.angles[0] + sight.angles[1]) * 0.5
assert_eq(sight.light_at(_centre(20, 20)
+ Vector2.from_angle(mid) * (VIEW - Visibility.FADE - 1.0)), 1.0)
func test_a_real_dungeon_is_cheap_enough_to_do_every_frame() -> void:
# Not a benchmark -- the machine running the suite varies -- but a guard
# against the ray count exploding: corners are the variable part, and a
# map with thousands of them in view would be a regression worth knowing.
var inst := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
sight.compute(inst.world.map, inst.world.spawn_point, VIEW)
assert_lt(sight.angles.size(), 1200,
"%d rays for one frame of a depth-1 dungeon" % sight.angles.size())
+1
View File
@@ -0,0 +1 @@
uid://beivpa4pxiu3f
+69
View File
@@ -18,6 +18,8 @@ var _step: int = 0
var _srv: ServerRuntime
var _account: int = 424242
var _character: Character
var _wounded: int = 0
var _hub_arrival: int = 0
func _ready() -> void:
@@ -77,6 +79,11 @@ func _physics_process(_delta: float) -> void:
_check(false, "the dungeon had a living enemy to kill")
_finish()
return
# Stand over it. Experience now has a radius, so a kill the player is
# nowhere near pays nothing -- which would make this check depend on
# where the generator happened to put the enemy.
var mine_now: SimPlayer = inst.world.players[Net.LOCAL_PEER]
mine_now.pos = victim.pos
victim.hp = 1
inst.world._damage_enemy(victim, 100)
_srv._dispatch_events(inst)
@@ -116,6 +123,68 @@ func _physics_process(_delta: float) -> void:
_check(_srv.store.characters_for(_account).size() == before_count,
"creating a character inside a dungeon is refused too")
elif _step == 65:
# A kill nobody was near pays nobody. The radius exists because without
# it a player parked in the entrance room collected the full experience
# for a boss they never saw.
var inst := _srv.instance_of(Net.LOCAL_PEER)
var far: SimEnemy = null
for e in inst.world.enemies.values():
if e.alive:
far = e
break
if far == null:
_check(false, "a second enemy to kill out of sight")
else:
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
p.pos = far.pos + Vector2(SimConfig.XP_AWARD_RADIUS + 400.0, 0.0)
var before: int = _srv.store.get_character(_account, _character.id).total_xp
far.hp = 1
inst.world._damage_enemy(far, 100)
_srv._dispatch_events(inst)
var after: int = _srv.store.get_character(_account, _character.id).total_xp
_check(after == before,
"a kill out of range awards nothing (%d -> %d)" % [before, after])
elif _step == 82:
# Health is carried, not reset. Arriving anywhere used to set it to
# maximum, which made a run free: walk in, take what you can, walk out,
# walk back in whole.
var inst := _srv.instance_of(Net.LOCAL_PEER)
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
p.spawn_grace = 0
p.hp = int(float(p.max_hp) * 0.4)
_wounded = p.hp
_check(_wounded > 0 and _wounded < p.max_hp, "the player is wounded")
elif _step == 84:
_srv._send_to_lobby(Net.LOCAL_PEER)
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY,
"escaped to the hub")
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp < p.max_hp, "the wound came with them (%d/%d)" % [p.hp, p.max_hp])
_check(absi(p.hp - _wounded) <= 2,
"and it is the same wound, not a new number (%d vs %d)" % [p.hp, _wounded])
_hub_arrival = p.hp
elif _step == 88:
var inst := _srv.instance_of(Net.LOCAL_PEER)
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp > _hub_arrival,
"the hub is healing it back (%d -> %d)" % [_hub_arrival, p.hp])
_check(p.hp < p.max_hp, "but has not finished in four ticks")
_srv._send_to_dungeon(Net.LOCAL_PEER)
elif _step == 89:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
"back into a dungeon")
var p: SimPlayer = inst.world.players[Net.LOCAL_PEER]
_check(p.hp < p.max_hp,
"and walked in still hurt (%d/%d) -- entering is not a heal"
% [p.hp, p.max_hp])
elif _step == 90:
# Stop behaving like a bot before the kill. A bot auto-creates a
# replacement the instant it sees an empty roster -- correct in play,
+260 -12
View File
@@ -14,22 +14,42 @@ extends Node
## touching anything under src/ui/ and open the files.
const GAME := preload("res://src/game.tscn")
const GAME_SCENE := preload("res://src/view/game_scene.gd")
## Its own store and its own account, like every diagnostic. Without these the
## tool hosted a listen server on the player's REAL character store as the
## player's REAL account, and walked their character into dungeons to take
## pictures -- with health carried between runs, a stray hit would have stuck,
## and a death would have retired someone's character for a screenshot.
const STORE := "user://screenshot_characters.json"
const ACCOUNT := 777001
## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const DONE := 370
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://"
@@ -37,11 +57,18 @@ var _dir: String = "user://"
func _ready() -> void:
GameOpts.parse()
GameOpts.store_path = STORE
GameOpts.account_override = ACCOUNT
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE))
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty():
_dir = from_env
# Made rather than assumed. The example in CLAUDE.md points at a fresh
# /tmp/shots, and a missing directory fails every capture one at a time
# with an error that names the file rather than the folder.
DirAccess.make_dir_recursive_absolute(_dir)
get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu)
@@ -67,12 +94,46 @@ func _process(_delta: float) -> void:
# Held until the shot is taken; one frame of it is not enough.
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
_stand_at_the_npc()
# On an entrance for the hub shot, so its prompt is in the picture.
if _frame > AT_GAME - 40 and _frame <= AT_GAME:
_stand_on_a_portal()
# Same reason: the game scene owns this screen's visibility too and decides
# it every frame, so one assignment lasts exactly one frame.
if _frame > OPEN_ROSTER and _frame <= AT_ROSTER:
_show_the_roster()
# Held, because the boss is firing and one landed ring would end the shot.
# High until just before the picture and low after, so the damage trail
# -- the part of the bar the drop took -- is still draining when it is taken.
if _frame > TO_BOSS and not _left_shot:
# Forty frames, not ten: at a high frame rate ten is shorter than one
# snapshot, and the picture showed the health from before the drop.
_hold_health(0.62 if _frame < AT_BOSS - 40 else 0.22)
# Captured by progress rather than by frame count. The channel is one second
# of server ticks and frames run as fast as the display allows, so a fixed
# frame number lands anywhere from a quarter of the way in to after it has
# completed -- and a completed escape teleports the player out mid-sequence.
if _frame >= ESCAPE_START and not _left_shot:
Net.client.request_escape = true
if Net.client.my_escape >= 0.45:
_grab("leaving")
Net.client.request_escape = false
_left_shot = true
Net.shutdown()
get_tree().quit(0)
return
if _frame == AT_BOSS - 30:
Net.server._grant_xp(Net.LOCAL_PEER, 46)
# Frames, not seconds, and this runs as fast as the display allows -- so
# early enough that the banner's fade-in is over even at a high frame rate.
if _frame == AT_BOSS - 60:
_game.get_node("HUD").banner("LEVEL 7", "a choice waits at the quartermaster")
match _frame:
AT_MENU:
_grab("menu")
ENTER_GAME:
_menu.queue_free()
Net.host(27455)
_seed_character()
Net.start_local_client()
_game = GAME.instantiate()
add_child(_game)
@@ -108,11 +169,198 @@ func _process(_delta: float) -> void:
_game.get_node("UpgradeScreen").refresh(2, offer, taken)
AT_UPGRADES:
_grab("upgrades")
OPEN_ROSTER:
_show_the_roster()
AT_ROSTER:
_grab("roster")
ENTER_DUNGEON:
# The fog is what these two are for, and the hub is the worst place to
# judge it: one big room with little in it to cast a shadow.
_game.set("_roster_open", false)
_game.get_node("CharacterSelect").set_fallen("")
Net.server._send_to_dungeon(Net.LOCAL_PEER)
AT_DUNGEON:
_grab("dungeon")
TO_TUNNEL:
_stand_in_a_tunnel()
AT_TUNNEL:
_grab("tunnel")
# The same view with F1's overlay: the outline is where each fog ray
# stopped, which is the quickest way to tell a rendering mistake from
# a geometry one.
_game.get_node("WorldView").debug_hitboxes = true
AT_TUNNEL_DEBUG:
_grab("tunnel_debug")
TO_BOSS:
_game.get_node("WorldView").debug_hitboxes = false
_stage_a_boss_fight()
AT_BOSS:
_grab("boss_fight")
DONE:
Net.shutdown()
get_tree().quit(0)
## Put the roster on screen with a made-up set of characters.
##
## Made up for the same reason the upgrade offer is: the point is the layout,
## and earning three characters at three different health levels first would
## make the shot depend on a dungeon run. One whole, one badly hurt, one barely
## alive -- health survives a run now, so the wounded rows are the reason this
## screen is worth a picture at all.
##
## Walking off the quartermaster first is not optional: the game scene re-opens
## the upgrade screen every frame the player is standing on the NPC, and it
## draws over this one.
func _show_the_roster() -> void:
var away: Vector2 = Net.client.upgrade_npc + Vector2(600.0, 0.0)
Net.client.predicted_pos = away
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst != null:
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
if p != null:
p.pos = away
_game.set("_upgrades_open", false)
_game.set("_roster_open", true)
# A death, as it is announced on the screen a death sends you to.
_game.get_node("CharacterSelect").set_fallen(GAME_SCENE.fallen_line(
{"name": "Quillfell", "level": 9, "where": "The Choir Vault"}))
var roster: Array[Dictionary] = [
_roster_entry("Whole", 4, 1300, 1300, Color(0.5, 0.9, 1.0)),
_roster_entry("Bruised", 7, 1600, 940, Color(1.0, 0.8, 0.4)),
_roster_entry("Nearly", 11, 2000, 160, Color(1.0, 0.5, 0.5)),
]
_game.get_node("CharacterSelect").refresh(roster, "c4")
## A character worth photographing: levelled, carrying things, so the HUD has
## something in every slot and a bar that is not empty. Made in the scratch
## store before the local client says hello, so it is who the client resumes.
func _seed_character() -> void:
var store := Net.server.store
var c := store.create_character(ACCOUNT, "Ash")
store.grant_xp(ACCOUNT, c.id, Progression.total_xp_for_level(6) + 260)
var bag: Array[StringName] = [Items.HEALTH_POTION, Items.WARDENS_RATION,
Items.HEALTH_POTION, Items.NONE]
store.set_inventory(ACCOUNT, c.id, bag)
store.flush()
func _stand_on_a_portal() -> void:
if Net.client == null or Net.client.portals.is_empty():
return
var at: Vector2 = Net.client.portals[0]["pos"]
Net.client.predicted_pos = at
var p := _player()
if p != null:
p.pos = at
func _player() -> SimPlayer:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null
func _hold_health(share: float) -> void:
var p := _player()
if p != null:
p.hp = maxi(1, int(float(p.max_hp) * share))
## Into the boss arena, low on health, with a party member beside you and a
## few messages in the feed: everything on the HUD that only exists mid-fight,
## in one picture.
##
## The party member is a world player with no socket behind it -- added to the
## simulation and the roster, never to the instance's send list, so the server
## never tries to talk to it.
func _stage_a_boss_fight() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null:
return
var at := inst.boss_spawn + Vector2(-200.0, 60.0)
Net.client.predicted_pos = at
var me := _player()
if me != null:
me.pos = at
me.spawn_grace = 0
var friend := inst.world.add_player(999, "Wren")
friend.pos = at + Vector2(-40.0, -70.0)
friend.colour = Color(1.0, 0.75, 0.45)
friend.hp = int(float(friend.max_hp) * 0.6)
friend.spawn_grace = 0
Net.server.peer_names[999] = "Wren"
Net.server.peer_instance[999] = inst.id
# And one out of sight, so the marker pointing the way to them shows.
var away := inst.world.add_player(998, "Lark")
away.pos = at + Vector2(-560.0, 260.0)
away.colour = Color(0.55, 0.85, 1.0)
away.spawn_grace = 0
Net.server.peer_names[998] = "Lark"
Net.server.peer_instance[998] = inst.id
Net.server._broadcast_roster()
var hud := _game.get_node("HUD")
hud.notify("+ Health Potion", Items.get_def(Items.HEALTH_POTION).tint)
hud.notify("Warden's Ration used", Items.get_def(Items.WARDENS_RATION).tint)
## Move the player, on both sides, into the tunnel closest to where it spawned:
## somewhere with walls close on two sides and a corner in view, which is where
## the old fog looked worst.
func _stand_in_a_tunnel() -> void:
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst == null or inst.rooms.is_empty():
return
var map := inst.world.map
var start: Rect2i = inst.rooms[0]
var spawn := map.to_tile(inst.world.spawn_point)
var best := Vector2i(-1, -1)
var best_d := INF
for ty in map.height:
for tx in map.width:
if map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
var in_room := inst.boss_room.has_point(here)
for r: Rect2i in inst.rooms:
if r.grow(1).has_point(here):
in_room = true
if in_room:
continue
# A couple of tiles clear of the first room, so the tunnel's walls
# are on screen and not just the doorway.
if start.grow(3).has_point(here):
continue
var d := float((here - spawn).length_squared())
if d < best_d:
best_d = d
best = here
if best.x < 0:
return
var at := map.tile_centre(best.x, best.y)
Net.client.predicted_pos = at
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
if p != null:
p.pos = at
## One roster row in the shape decode_characters produces.
func _roster_entry(who: String, level: int, ceiling: int, current: int,
tint: Color) -> Dictionary:
return {
"id": "c%d" % level,
"name": who,
"level": level,
"xp": 0,
"progress": 0.4,
"max_hp": ceiling,
"hp": current,
"active": true,
"colour": tint,
"inventory": [0, 0, 0, 0] as Array[int],
}
func _grab(shot_name: String) -> void:
var path := "%s/shot_%s.png" % [_dir, shot_name]
var err := get_viewport().get_texture().get_image().save_png(path)
+37
View File
@@ -55,6 +55,22 @@ for n in 1 2; do
sleep 0.4
done
# A bot that claims an account another bot is already playing. One account is
# one session: two peers on one account each get their own player built from the
# SAME character record, so both hold a copy of one bag and whichever writes last
# wins -- drop the single potion from both copies and two potions hit the floor.
# The handshake has to refuse it, and it has to refuse the NEWCOMER, or claiming
# someone's account becomes a way to kick them off the server.
for _ in $(seq 1 60); do
grep -q "authenticated as account 7001" "$OUT/server.log" 2>/dev/null && break
sleep 0.25
done
"$GODOT" --headless --path . -- --bot --host 127.0.0.1 --port "$PORT" \
--name "twinbot" --account 7001 --autoquit 300 \
> "$OUT/twinbot.log" 2>&1 &
PIDS+=($!)
sleep 0.4
# A fourth bot that leaves *politely* -- the same Net.shutdown() the in-game
# menu's "Disconnect" button calls. The escape channel is keyed on the socket
# closing, not on how it closed, so a clean exit must be caught exactly like the
@@ -118,6 +134,17 @@ no_engine_errors() { # no_engine_errors <label> <file>
fi
}
count() { # count <label> <file> <pattern> <expected>
local hits
hits=$(grep -cE "$3" "$2" || true)
if [[ "$hits" == "$4" ]]; then
echo " ok $1"
else
echo " FAIL $1 (/$3/ matched $hits time(s), expected $4, in $(basename "$2"))"
fails=$((fails + 1))
fi
}
refute() { # refute <label> <file> <pattern>
local hits
hits=$(grep -cE "$3" "$2" || true)
@@ -166,6 +193,16 @@ check "a polite disconnect is also channelled" \
# assert nothing at all. The upgrade-state message DOES cross the wire on every
# login, so a malformed one shows up in the script-error refutations below --
# and tools/diag_upgrades.tscn covers the loop itself.
check "a duplicate account is refused" "$OUT/server.log" \
"claimed account 7001, already held by peer"
check "and the newcomer is the one told no" "$OUT/twinbot.log" \
"that account is already playing"
count "the account was only ever seated once" "$OUT/server.log" \
"authenticated as account 7001" 1
# The victim keeps playing: refusing the second connection must not disturb the
# session that already held the account.
check "the original session survives it" "$OUT/bot1.log" "entered instance .*LOBBY"
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
no_engine_errors "no engine errors on the server" "$OUT/server.log"