Compare commits
17
Commits
b8332b697d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f58af1a8 | ||
|
|
94ad6b3429 | ||
|
|
170eb409dd | ||
|
|
6e872e2cf5 | ||
|
|
3d1b5da72b | ||
|
|
99f0d2a783 | ||
|
|
19d2295bec | ||
|
|
069abdb3fa | ||
|
|
855669a665 | ||
|
|
83c3b920fb | ||
|
|
a6084c1a8d | ||
|
|
4aeb6d229d | ||
|
|
54086b5604 | ||
|
|
7b710471df | ||
|
|
20715e77cc | ||
|
|
39d857c4d1 | ||
|
|
ef28970654 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
@@ -0,0 +1,76 @@
|
||||
extends Node2D
|
||||
## The connect screen's background: a real boss pattern, played out behind the
|
||||
## panel, dimmed.
|
||||
##
|
||||
## Real in the sense that matters -- the boss's own emitters fired into a
|
||||
## [BulletPool] and drawn by the game's own bullet renderer, so what plays here
|
||||
## is what the fight actually looks like, and a pattern that changes in
|
||||
## content.gd changes here with it. Nothing is simulated beyond the bullets:
|
||||
## no world, no hits, no map. The first thing a player sees is the genre.
|
||||
|
||||
const BULLETS := preload("res://src/view/bullet_renderer.gd")
|
||||
|
||||
## How long each phase plays before the next one takes over, in ticks.
|
||||
const PHASE_TICKS := 60 * 9
|
||||
## How visible it is. Enough to read as a bullet hell, not enough to compete
|
||||
## with the panel in front of it.
|
||||
const DIM := 0.32
|
||||
## Run this many ticks before the first frame is drawn. Every pattern starts at
|
||||
## the centre of the screen, which is exactly where the panel is, so a backdrop
|
||||
## that started from nothing showed nothing at all for its first second or two
|
||||
## -- it was there, all of it, behind the buttons.
|
||||
const PRIME_TICKS := 60 * 5
|
||||
|
||||
var _pool := BulletPool.new()
|
||||
var _ctx := EmitContext.new()
|
||||
var _renderer: Node2D
|
||||
var _phases: Array = []
|
||||
var _tick: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
modulate = Color(1.0, 1.0, 1.0, DIM)
|
||||
_renderer = BULLETS.new()
|
||||
add_child(_renderer)
|
||||
# Every phase of every boss, in order -- the Warden's folds, then the
|
||||
# Cantor's verses -- so leaving the menu up shows the whole bestiary.
|
||||
for id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(id).phases:
|
||||
if not phase.emitters.is_empty():
|
||||
_phases.append(phase.emitters)
|
||||
_ctx.pool = _pool
|
||||
_ctx.rng = RandomNumberGenerator.new()
|
||||
_ctx.rng.seed = 1
|
||||
for _i in PRIME_TICKS:
|
||||
_step()
|
||||
_renderer.render_pool(_pool)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_step()
|
||||
_renderer.render_pool(_pool)
|
||||
|
||||
|
||||
func _step() -> void:
|
||||
if _phases.is_empty():
|
||||
return
|
||||
var centre := get_viewport_rect().size * 0.5
|
||||
var phase_index := (_tick / PHASE_TICKS) % _phases.size()
|
||||
var local := _tick % PHASE_TICKS
|
||||
_ctx.origin = centre
|
||||
# Aimed patterns need someone to aim at. A point drifting slowly round the
|
||||
# centre, so aimed volleys sweep rather than all going the same way.
|
||||
var t := float(_tick) * 0.006
|
||||
_ctx.target = centre + Vector2(cos(t), sin(t * 0.7)) * 260.0
|
||||
_ctx.has_target = true
|
||||
_ctx.bounds = get_viewport_rect()
|
||||
_ctx.local_tick = local
|
||||
# Telegraphed strikes announce through the context; there is nobody to warn.
|
||||
_ctx.events = []
|
||||
for e: BulletEmitter in _phases[phase_index]:
|
||||
if e == null or not e.should_fire(local):
|
||||
continue
|
||||
_ctx.shot_index = e.shot_index_at(local)
|
||||
e.fire(_ctx)
|
||||
_pool.step()
|
||||
_tick += 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpbu6rmxpqg0e
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bikom644gux4e
|
||||
+75
-31
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://noaqo4x7so77
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://chmutr64rrmsh
|
||||
@@ -0,0 +1,213 @@
|
||||
extends GutTest
|
||||
## HUD and connect-screen behaviour that does not need a screen to check.
|
||||
## What things LOOK like is screenshot.tscn's job; this is what they DO.
|
||||
|
||||
const HUD := preload("res://src/ui/hud.gd")
|
||||
const MENU := preload("res://src/ui/main_menu.gd")
|
||||
const BACKDROP := preload("res://src/ui/menu_backdrop.gd")
|
||||
|
||||
|
||||
func _hud_with(hp: int, max_hp: int) -> Array:
|
||||
var hud: CanvasLayer = HUD.new()
|
||||
add_child_autofree(hud)
|
||||
var c: ClientRuntime = autofree(ClientRuntime.new())
|
||||
c.my_hp = hp
|
||||
c.my_max_hp = max_hp
|
||||
hud.client = c
|
||||
hud._hp_trail = float(hp) / float(max_hp)
|
||||
hud._hp_last = hud._hp_trail
|
||||
return [hud, c]
|
||||
|
||||
|
||||
# --- The damage trail -------------------------------------------------------
|
||||
|
||||
func test_a_hit_leaves_a_trail_where_the_bar_was() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 400
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "the trail holds at the old value")
|
||||
assert_almost_eq(hud._hp_last, 0.4, 0.001)
|
||||
|
||||
|
||||
func test_the_trail_holds_and_then_drains_to_meet_the_bar() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 400
|
||||
hud._watch_health(0.016)
|
||||
# Within the hold, nothing moves -- that pause is what makes the amount
|
||||
# readable.
|
||||
for _i in int(HUD.TRAIL_HOLD / 0.016) - 2:
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001, "still holding")
|
||||
for _i in 200:
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 0.4, 0.001, "and then drained all the way down")
|
||||
|
||||
|
||||
func test_healing_leaves_no_trail() -> void:
|
||||
var pair := _hud_with(400, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 900
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 0.9, 0.001,
|
||||
"a trail is for what was lost; on a heal the bar and trail move together")
|
||||
|
||||
|
||||
func test_a_second_hit_during_the_hold_keeps_the_original_top() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_hp = 700
|
||||
hud._watch_health(0.016)
|
||||
c.my_hp = 300
|
||||
hud._watch_health(0.016)
|
||||
assert_almost_eq(hud._hp_trail, 1.0, 0.001,
|
||||
"two quick hits read as one big one, measured from where it started")
|
||||
|
||||
|
||||
# --- Experience -------------------------------------------------------------
|
||||
|
||||
func test_experience_arriving_quickly_is_summed_into_one_number() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
hud._seen_character = "a"
|
||||
c.my_total_xp = 100
|
||||
hud._watch_experience(0.016)
|
||||
c.my_total_xp = 112
|
||||
hud._watch_experience(0.016)
|
||||
c.my_total_xp = 120
|
||||
hud._watch_experience(0.016)
|
||||
assert_eq(hud._xp_gain, 20, "+12 then +8 shows as +20, not as a flicker")
|
||||
|
||||
|
||||
func test_the_first_sight_of_a_character_is_not_a_gain() -> void:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
c.my_total_xp = 5000
|
||||
hud._watch_experience(0.016)
|
||||
assert_eq(hud._xp_flash, 0.0, "logging in with 5000 experience did not just earn it")
|
||||
|
||||
|
||||
# --- The connect screen -----------------------------------------------------
|
||||
|
||||
func _menu() -> Control:
|
||||
var m: Control = MENU.new()
|
||||
add_child_autofree(m)
|
||||
return m
|
||||
|
||||
|
||||
func test_a_port_that_cannot_connect_disables_join_and_says_why() -> void:
|
||||
var m := _menu()
|
||||
m._port.text = "twenty"
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled)
|
||||
assert_true(m._host_button.disabled)
|
||||
assert_string_contains(m._status.text, "port")
|
||||
m._port.text = "70000"
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled, "out of range is as useless as not a number")
|
||||
m._port.text = "27015"
|
||||
m._validate()
|
||||
assert_false(m._join_button.disabled)
|
||||
assert_eq(m._status.text, "", "and the complaint goes when the problem does")
|
||||
|
||||
|
||||
func test_no_address_disables_join_but_not_host() -> void:
|
||||
var m := _menu()
|
||||
m._address.text = " "
|
||||
m._validate()
|
||||
assert_true(m._join_button.disabled, "nowhere to join")
|
||||
assert_false(m._host_button.disabled, "hosting needs no address")
|
||||
|
||||
|
||||
func test_busy_wins_over_valid() -> void:
|
||||
var m := _menu()
|
||||
m.set_busy(true)
|
||||
assert_true(m._join_button.disabled, "a connection in flight is not a second invitation")
|
||||
m.set_busy(false)
|
||||
assert_false(m._join_button.disabled)
|
||||
|
||||
|
||||
func test_the_connect_screen_asks_for_no_name() -> void:
|
||||
# It used to, and the answer went nowhere a person could see -- it only named
|
||||
# the characters bots create.
|
||||
var m := _menu()
|
||||
for node in m.find_children("*", "LineEdit", true, false):
|
||||
assert_ne((node as LineEdit).placeholder_text, "name")
|
||||
assert_eq(m.find_children("*", "LineEdit", true, false).size(), 2,
|
||||
"an address and a port, nothing else")
|
||||
|
||||
|
||||
func test_the_backdrop_opens_on_a_pattern_already_in_flow() -> void:
|
||||
# Every pattern starts at the centre of the screen, behind the panel. Primed,
|
||||
# the first frame already has bullets well clear of it.
|
||||
var b: Node2D = BACKDROP.new()
|
||||
add_child_autofree(b)
|
||||
var centre := b.get_viewport_rect().size * 0.5
|
||||
var clear_of_panel := 0
|
||||
for i in b._pool.high_water:
|
||||
if b._pool.alive[i] != 0 and b._pool.pos[i].distance_to(centre) > 320.0:
|
||||
clear_of_panel += 1
|
||||
assert_gt(clear_of_panel, 10, "bullets beyond the panel on the very first frame")
|
||||
|
||||
|
||||
# --- The boss bar -----------------------------------------------------------
|
||||
|
||||
## A HUD whose fog sees from the left of a room, with a wall down the middle.
|
||||
func _hud_with_sight() -> Array:
|
||||
var pair := _hud_with(1000, 1000)
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
var g := MapGrid.new(40, 20, MapGrid.Kind.FLOOR)
|
||||
for ty in 20:
|
||||
g.set_tile(20, ty, MapGrid.Kind.WALL)
|
||||
var sight := Visibility.new()
|
||||
sight.compute(g, Vector2(10.5, 10.5) * MapGrid.TILE, 460.0)
|
||||
hud.sight = sight
|
||||
c.instance_id = 3
|
||||
c.boss_def = Content.boss(&"warden")
|
||||
return [hud, c]
|
||||
|
||||
|
||||
func _boss_at(x_tiles: float, phase: int = 0) -> Dictionary:
|
||||
return {"id": 1, "pos": Vector2(x_tiles, 10.5) * MapGrid.TILE, "phase": phase, "hp": 100}
|
||||
|
||||
|
||||
func test_the_bar_waits_until_the_boss_has_been_seen() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
# Behind the wall: in the snapshot -- the interest radius passes through
|
||||
# walls -- but not seen.
|
||||
assert_false(hud.track_boss(_boss_at(24.0)), "known about is not the same as seen")
|
||||
assert_true(hud.track_boss(_boss_at(16.0)), "in the light, it shows")
|
||||
|
||||
|
||||
func test_once_seen_it_stays_for_the_fight() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
hud.track_boss(_boss_at(16.0))
|
||||
assert_true(hud.track_boss(_boss_at(24.0)),
|
||||
"stepping behind a pillar must not take the bar away")
|
||||
|
||||
|
||||
func test_a_new_dungeon_has_to_be_seen_again() -> void:
|
||||
var pair := _hud_with_sight()
|
||||
var hud: CanvasLayer = pair[0]
|
||||
var c: ClientRuntime = pair[1]
|
||||
hud.track_boss(_boss_at(16.0))
|
||||
# Every dungeon's boss is actor 1, so the id alone would carry the last
|
||||
# fight's "seen" straight into the next one.
|
||||
c.instance_id = 4
|
||||
assert_false(hud.track_boss(_boss_at(24.0)))
|
||||
|
||||
|
||||
func test_a_phase_change_is_announced_and_the_first_phase_is_not() -> void:
|
||||
var hud: CanvasLayer = _hud_with_sight()[0]
|
||||
hud.track_boss(_boss_at(16.0, 1))
|
||||
assert_eq(hud._banner_title, "", "the phase it was in when found is not a change")
|
||||
hud.track_boss(_boss_at(16.0, 2))
|
||||
assert_eq(hud._banner_title, Content.boss(&"warden").phases[2].name.to_upper())
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn1mu2io17h4i
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://beivpa4pxiu3f
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user