Compare commits

..

2 Commits

Author SHA1 Message Date
claude 33845a8a42 Remove non-redistributable art from the repo; record licence terms
ci / verify (push) Successful in 46s
Researched the licence at each of the four itch.io sources. Two of the four
forbid redistribution, and publishing a repository containing them is exactly
that, so they are out of the tree.

  0x72 DungeonTileset II    CC0 1.0    commercial ok, no credit required
  Helton Yan Pixel Combat   CC BY 4.0  commercial ok, CREDIT REQUIRED
  Fire Pixel Bullet 16x16   custom     non-commercial, NO REDISTRIBUTION
  750+ Effect and FX Pixel  custom     non-commercial, NO REDISTRIBUTION

Both bdragon1727 packs state verbatim: "You cannot do: Resell / redistribute
this asset." The FX pack's paid tier is no better for this purpose -- "You can
NOT re-distribute the file, no matter how much you modify it" -- so paying does
not unlock it and neither does editing the art.

bullets.png and impact.png therefore move to assets/local/, gitignored.
Art.bullets_texture() / impact_texture() load them at runtime and return null
when absent; the bullet renderer falls back to the generated dot it used before,
tinted per kind so bullet types stay distinguishable. Nothing there may be
preload()ed -- preload resolves at compile time and would fail the build on
every machine without the files, which is every clone.

Verified both ways rather than assumed: with the files present the game uses
them and no fallback triggers; with them moved aside, 156/156 tests pass and the
game runs clean on the generated dots.

Added CREDITS.md, because Helton Yan's CC BY 4.0 makes attribution a licence
obligation rather than a courtesy. A file in the source tree does not discharge
it once the game ships -- an in-game credits screen is now a tracked todo, and
flagged as a licence requirement rather than polish.

NOT addressed here, and it needs a decision: these files remain in git history
at 4a41ccb. Removing them from the tip does not remove them from a clone. If
this repo is ever made public, history has to be rewritten (git filter-repo)
and force-pushed. Also, both bdragon packs are non-commercial in their free
tier, which conflicts with the stated Steam goal -- either contribute for the
commercial tier (still no redistribution) or replace with CC0 art, which would
solve both problems and let the assets back into the repo.
2026-09-03 23:52:41 +02:00
claude 4a41ccb600 Replace placeholder rendering with real sprites and audio
ci / verify (push) Successful in 47s
Terrain, actors, boss and bullets are sprites now, and four sounds play off
server events. 548KB total across seven files, out of 3.9GB of source packs.

- src/view/art.gd holds every atlas rect and sound path in one table, copied
  from the 0x72 pack's own tile_list. Scattering coordinates through draw calls
  would make re-cutting the atlas a hunt, and a wrong rect invisible.
- Bullets get one MultiMesh per kind rather than one overall: a
  MultiMeshInstance2D carries a single texture and each kind needs a different
  region of the sheet. Four draw calls, no custom shader passing UVs through
  per-instance data.
- src/view/sfx.gd is a 16-voice round-robin pool. Bullet-hell fire rates mean
  sounds overlap constantly, and identical sounds landing within two frames are
  collapsed so a ring hitting eight bullets is a bang rather than clipping.
  Every sound is triggered by a server event, never a local guess, so what you
  hear matches what happened.

Audio was converted, not copied: the pack ships 24-bit/96kHz masters averaging
2MB. Downsampled to 16-bit/44.1kHz mono, silence-trimmed, and the shoot sound
hard-capped to 0.30s -- it came out at 2.03s, against a fire cooldown of 0.12s,
which would have smeared held fire into noise.

tests/unit/test_art.gd checks what cannot be eyeballed here: every atlas rect
lands inside its texture, every animation frame of a strip fits (the last frame
is what runs off the sheet, not the first), there is a sprite per bullet kind
and per enemy visual, and no sound is long enough to stack badly. A wrong atlas
coordinate does not error -- it silently draws the wrong pixels.

Licence sources recorded in docs/ASSETS.md now that they are known. All four
are free versions and four rows still say "confirm at source": free itch packs
vary on credit and commercial use, and there is no credits screen yet.

155 tests (was 146). check.sh, test.sh and smoke.sh pass.
2026-09-03 23:43:13 +02:00
188 changed files with 502 additions and 12862 deletions
+15 -130
View File
@@ -13,36 +13,13 @@ 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 # GUT suite, headless (~2s)
tools/smoke.sh # real server + 2 bot clients over ENet (~35s)
tools/bench.gd # godot --headless --path . --script tools/bench.gd
tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu
tools/client.sh --join --name ada
```
Diagnostics. Each runs as a *scene* (they need the `Net` autoload) and exits
non-zero on failure, so they gate like tests:
```bash
godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift
godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster
godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up -> persist -> use -> drop
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)
```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
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.
**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
`src/view/`, run `screenshot.tscn` and open the files. It needs a display, so it
is not a gate.
Everything after `--` goes to `GameOpts.parse()`:
| Flag | Effect |
@@ -55,8 +32,6 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--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. |
| `--account N` | Client-side: override the local account, so several clients can coexist on one machine. |
| `--store PATH` | Server-side: character store location. Use a scratch path in tests. |
| `--verbose` / `--quiet` | Log level. |
A change is done when `check.sh`, `test.sh` and — if it touched networking,
@@ -75,28 +50,11 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
**The server decides everything; the client only sends intent.**
There are exactly **five** client -> server messages, and that number is worth
watching:
| Message | Carries |
| --- | --- |
| `c_hello` | protocol version + an opaque auth ticket |
| `c_input` | an [InputFrame]: move vector, aim angle, five button bits, an inventory slot |
| `c_select_character` | a character id, checked against *that account's* list |
| `c_create_character` | a name, sanitised at the boundary |
| `c_choose_upgrade` | an index into the three options the SERVER put on the table |
Every one is pure intent. There is no message for "I moved here", "I hit that",
"I took damage", "my escape finished", "I now own this item", "I am in this
dungeon" or "my damage is X". Adding one would collapse the whole security
model, so don't — validate-after-the-fact is strictly weaker than having no code
path at all.
When a new player action needs a message, look at whether it fits in the input
frame first. Item use and drop did, and got the redundancy, the replay guard and
the per-tick rate limit for free. The cost was one rule: anything in the input
frame that must not repeat has to be **edge-triggered** (see `prev_buttons`),
because frames are resent and a starved server coasts on the last one it holds.
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
three button bits) and a handshake. There is no message for "I moved here", "I
hit that", "I took damage" or "my escape finished". Adding one would collapse
the whole security model, so don't — validate-after-the-fact is strictly weaker
than having no code path at all.
`SimWorld.authoritative` is `true` on the server and `false` on the client. In
replica mode the world runs no AI, fires no emitters and resolves no hits; it
@@ -108,22 +66,14 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| Path | What lives there |
| --- | --- |
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — handing a client the seed would be a map hack with no work required. |
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
| `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
| `src/content/items.gd` | All items, same idea. `Items.ORDER` is the wire format — append only. |
| `src/content/dungeons.gd` | The kinds of run. `Dungeons.ORDER` is both a wire format and the hub's portal order. |
| `src/content/upgrades.gd` | The seven upgrades and their draw weights. `Upgrades.ORDER` is a wire format. |
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. |
| `src/core/credits.gd` | Every third-party asset and its licence. Two are CC BY, so this is a legal requirement, not a nicety. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. |
@@ -156,21 +106,7 @@ ticks in milliseconds with no SceneTree.
coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD.
6. **A Control's theme is inherited from Control ANCESTORS only.** The chain
breaks at the first parent that is a plain `Node` or a `CanvasLayer`, which
here is every screen: they hang off `main.gd` or off a CanvasLayer. Setting
`get_window().theme` therefore compiles, runs, changes the property — and
styles nothing. Apply the theme to each screen's own root Control
(`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every
screen and asks what its buttons actually resolve, because this failure is
invisible to every other kind of check.
7. **A themed `Slider` or `ScrollBar` takes its THICKNESS from the stylebox's
minimum size**, which for a `StyleBoxTexture` is its content margins. Style
one with margins of zero and it resolves correctly, reports the right
texture, and draws a groove zero pixels tall — indistinguishable from having
no theme at all. Both happened. `tests/unit/test_ui_theme.gd` asserts every
slider and scrollbar stylebox has a non-zero minimum.
8. **`ProjectSettings.save()` drops settings equal to the engine default** and
6. **`ProjectSettings.save()` drops settings equal to the engine default** and
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`.
@@ -195,57 +131,11 @@ ticks in milliseconds with no SceneTree.
any client regenerate the whole dungeon. `MAP_STREAM_RADIUS` must stay wider
than `FOG_VIEW_RADIUS`, or prediction runs on terrain the client lacks.
- **Bullet speed must stay under one tile per tick.** Wall collision samples
position once per tick, so anything faster tunnels. Upgrades multiply bullet
speed, so `SimConfig.MAX_BULLET_SPEED` clamps the result — without it two
Snipers put shots through walls. Pinned by
position once per tick, so anything faster tunnels. Pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
- **A character becomes a live player only in `ServerRuntime._adopt_character`,
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.
- **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
charges, poison, erase chance) travel on the bullet instead, because a shot
in flight must keep what it was fired with.
- **Only `ServerRuntime` writes progression and persistence.** The simulation
reads a player's level and max health, and moves items between the ground and
a bag; it never grants experience, retires a character, or touches the store.
It announces what happened and `ServerRuntime` banks it. One writer means a
level can never disagree with the experience that earned it, and an inventory
on disk can never disagree with the one in the world.
- **`Items.ORDER` is a wire format.** An item's index in it is the byte that
rides the snapshot and every item event. Append, never reorder — reordering
makes every existing client decode a potion as a ration, so it needs a
`Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally
decides which hub portal opens which dungeon (Nth `P` marker in the lobby
stamp → Nth entry). A dungeon names the boss arena it ends in, so adding a
boss means giving it a dungeon or nobody will ever meet it.
- **Which dungeon you enter comes from where you are standing, never from the
client.** `SimWorld.portal_at()` resolves the player's server-side position
to a portal, and the `PORTAL_USED` event carries the answer. There is no
message that names a dungeon, and adding one would let any client pick the
easy variant's loot rate.
- **Loot has two visibilities, and the instanced one is enforced in the codec.**
`NetCodec.encode_snapshot` filters items owned by another peer, exactly like
the actor interest radius. Never move that check into the client: hiding an
entity the client was handed defends nothing.
- **`LocalAuthProvider` is insecure on purpose.** Any client can claim any
account. It exists to have the same shape as Steamworks (opaque ticket in,
64-bit account id out) so swapping is one class. Do not ship it.
- **No contact damage.** Every enemy threatens through bullets only; touching
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
an emitter.
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
walking out is always an escape — which only holds if the boss cannot follow.
- **Every actor the client draws is interpolated between snapshots.** Players,
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.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
ignores line of sight on purpose; everything else in the view respects 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
dense patterns safer than sparse ones.
@@ -258,13 +148,8 @@ ticks in milliseconds with no SceneTree.
A new enemy or boss is data, never code. Add a builder to
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
all iterate those), and add a test.
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
property of the phase, not of the boss. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
builds a boss from scratch and asserts the simulation needs no changes to run
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
an emitter type instead.
+19 -52
View File
@@ -1,63 +1,30 @@
# Credits
Every third-party asset pack used or bundled during development, credited
regardless of whether its licence requires it. Some of them do not require it —
they are here because the work deserves it.
Third-party assets used in Transcience, with the attribution their licences
require.
**This file is not the only place credit appears.** Two of the packs are CC BY
4.0, which asks for attribution "in any reasonable manner", and a markdown file
in a source repository is not reasonable for someone who downloaded a build. The
game carries the same list on its own credits screen (Settings → Credits), built
from `src/core/credits.gd`; `tests/unit/test_credits.gd` asserts the two agree.
## In the game
**16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii)
Terrain, player, enemies and boss sprites.
[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) — public domain.
*Credit not required.*
**Complete UI Essential Pack** — [Crusenho Agus Hennihuno](https://crusenho.itch.io/complete-ui-essential-pack)
Buttons, panels, bars and inventory slots. Used unmodified; a subset of the
pack's individual sprites is committed under `assets/sprites/ui/`.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
## Audio
**Pixel Combat SFX** — [Helton Yan](https://heltonyan.itch.io/pixelcombat)
All sound effects. Converted to 16-bit / 44.1 kHz mono and trimmed.
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
**Credit required by licence.**
Licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
Converted to 16-bit / 44.1 kHz mono and trimmed for use in game.
**Fire Pixel Bullet 16x16** — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
Bullet sprites. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
> Attribution is **required** by this licence, not optional. It has to appear
> somewhere a player can reach — this file is not enough on its own once the
> game ships. See docs/ROADMAP.md.
**750+ Effect and FX Pixel All** — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
Impact effect artwork. Custom licence; *credit encouraged, not required.*
Not distributed with this repository — see below.
## Art
## Present during development, not used in the game
**16x16 DungeonTileset II** — [0x72 (Robert Norenberg)](https://0x72.itch.io/dungeontileset-ii)
Released under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)
(public domain). Credit is not required; it is here because the work deserves
it.
Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by
nothing. Credited for completeness.
## Not distributed with this repository
**RF Catacombs v1.0** — Szadi art. Public domain per the pack's bundled
`public-license.txt`: free for personal or commercial use, credit appreciated
but not required, no reselling of the pack itself.
Two packs are used locally but deliberately **excluded** from the repository,
because their licences forbid redistribution. They are not part of any build
made from this source alone. See [docs/ASSETS.md](docs/ASSETS.md).
**Tiny RPG Character Asset Pack 02** and **neo_zero** (v1.0 and 3.0 demo) —
creators not recorded in the packs themselves. If either is ever used, record
the source and terms here first.
## Why two packs are not in this repository
Both bdragon1727 packs forbid redistribution, in **every** tier:
> Free tier: "You cannot do: Resell / redistribute this asset."
>
> Paid tier: "You can NOT re-distribute the file, no matter how much you modify
> it you can use it but not share or re-sell it."
A public repository distributes what it contains, so those files live in the
untracked `assets/local/`. The game falls back to generated placeholders
without them. See [docs/ASSETS.md](docs/ASSETS.md).
- Fire Pixel Bullet 16x16 — [bdragon1727](https://bdragon1727.itch.io/fire-pixel-bullet-16x16)
- 750+ Effect and FX Pixel All — [bdragon1727](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all)
+20 -32
View File
@@ -12,51 +12,39 @@ tools/client.sh --join --name ada # connect a client
Or run one client and press **Host and play** for a listen server.
**Controls** — WASD move, mouse aim, LMB fire, **E** to pick up loot and to use
the hub's portals and quartermaster, **14** use an inventory slot, **shift+14**
drop one, **hold F** for one second to escape a dungeon, **Esc** menu, **F1**
hitbox overlay. Every one of those is rebindable in Settings.
## What is here
| | |
| --- | --- |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~300 concurrent bullets at 0.28 ms/tick — around 60x headroom against the 60 Hz budget. |
| **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 115, experience shared across the party undivided. |
| **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. |
| **Settings** | Rebindable controls and volumes, and an in-game credits screen. |
**Controls** — WASD move, mouse aim, LMB fire, **E** on the ring in the hub to
enter a dungeon, **hold F** for three seconds to escape back to the hub.
## Develop
```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 # GUT suite, headless ~2s
tools/smoke.sh # server + 2 bot clients, ENet ~35s
godot --headless --path . --script tools/bench.gd
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon.
## What is here
| | |
| --- | --- |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~350 concurrent bullets at 0.24 ms/tick. |
| **Authoritative multiplayer** | Clients send input only. Hits, damage, death and instance transfers are server decisions. Prediction + reconciliation for the local player. |
| **Enemies** | Four readable behaviours (static, drift, orbit, approach, strafe) composed with bullet emitters. |
| **Boss** | The Warden of the Fold: stationary, four phases, each adding one idea. Defined entirely as data. |
| **Lobby hub** | Shared persistent instance with a portal into dungeon runs. |
| **Emergency escape** | Three-second channel back to the hub, cancelled by damage. |
## Docs
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives
- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning
- [CREDITS.md](CREDITS.md) — third-party asset attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation
- [docs/NETCODE.md](docs/NETCODE.md) — replication model, hit validation, known gaps
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and the verification traps this project has actually hit
- [docs/ASSETS.md](docs/ASSETS.md) — asset packs and their licence findings
- [CREDITS.md](CREDITS.md) — third-party attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
## Not ready for the internet
`LocalAuthProvider` lets any client claim any account id. It exists to have the
same shape as Steamworks so swapping is one class, and it must be replaced
before this is reachable from anywhere untrusted. There is no transport
encryption either. See [docs/ROADMAP.md](docs/ROADMAP.md).
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built and what is next
+3 -14
View File
@@ -12,17 +12,6 @@ The split exists so that adding a 3.9 GB pack of source material costs the
repository and the import step nothing, while the handful of files the game
loads stay reviewable.
What is here:
| Path | What | Source |
| --- | --- | --- |
| `sprites/dungeon_tileset.png` | terrain, characters, bosses, item icons | 0x72, CC0 |
| `sprites/ui/` | buttons, panels, bars, inventory slots | Crusenho, CC BY 4.0 |
| `audio/sfx/` | every sound the game plays | Helton Yan, CC BY 4.0 |
| `local/` | bullet and impact art — **gitignored**, licence forbids redistribution | bdragon1727 |
Sprite rects and sound paths are declared in
[src/view/art.gd](../src/view/art.gd); UI textures in
[src/view/ui_theme.gd](../src/view/ui_theme.gd). Attribution lives in
[CREDITS.md](../CREDITS.md) and on the game's own credits screen — see
[docs/ASSETS.md](../docs/ASSETS.md) for the licence findings.
Nothing is here yet — the game currently draws itself from primitives and one
runtime-generated dot texture ([src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd)).
Art is Stage 2 of [docs/ROADMAP.md](../docs/ROADMAP.md).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://m2nhvybbia58"
path="res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Banner01a.png"
dest_files=["res://.godot/imported/Banner01a.png-79d54606f6c5efa37ca262c86fb3c88b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b4w7eso648lkb"
path="res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar01a.png"
dest_files=["res://.godot/imported/Bar01a.png-4fa78ad3d34ea11ebad35ea23bc8ff70.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://p6h7i0qxhoao"
path="res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar05a.png"
dest_files=["res://.godot/imported/Bar05a.png-6dc27709fd6ce83b5c13f1aeece67a02.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c32skror5ojq5"
path="res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Bar07a.png"
dest_files=["res://.godot/imported/Bar07a.png-4832809288d6d60de539798b274f9dea.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0txc40f6u7pc"
path="res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/BarFill01f.png"
dest_files=["res://.godot/imported/BarFill01f.png-8f813e9f1aea22470c4cd1a20dc14300.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dk2bbq73k8h27"
path="res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_1.png"
dest_files=["res://.godot/imported/Button01a_1.png-2cddde3a12e70b9f65fa9b0cba03379d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8nsxodlmgomt"
path="res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_2.png"
dest_files=["res://.godot/imported/Button01a_2.png-35c4875fe9ac1c69211a75ffbcc0fd8c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cvj6idjcu0j1s"
path="res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_3.png"
dest_files=["res://.godot/imported/Button01a_3.png-3896dc57fbcbb9c973f680df22c8b858.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dj0kffckj1iug"
path="res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Button01a_4.png"
dest_files=["res://.godot/imported/Button01a_4.png-9c12b7189c542159f43a7e003d114d95.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bybgrl0ye24w0"
path="res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame01a.png"
dest_files=["res://.godot/imported/Frame01a.png-626104f1220a56d01a83a690493b113d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://y42352ti7y2t"
path="res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame02a.png"
dest_files=["res://.godot/imported/Frame02a.png-ac57f8ba2960b573fe171292b8a7c378.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqehg1mvy1tcn"
path="res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Frame03a.png"
dest_files=["res://.godot/imported/Frame03a.png-5c1b611e7b9fe641c92bf2e2fd3b816b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 B

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bqe250bmbxli0"
path="res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameMarker01a.png"
dest_files=["res://.godot/imported/FrameMarker01a.png-7ed0bebde8d986061a9a76a04179edf2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btuhdrpfr8td3"
path="res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01a.png"
dest_files=["res://.godot/imported/FrameSlot01a.png-069d1091d125c87d7c14afba6400418d.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://btf5al7f07nl"
path="res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/FrameSlot01b.png"
dest_files=["res://.godot/imported/FrameSlot01b.png-bd6ad2d53ba974202493dbaf3f360ea5.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://e2u2b24f7dr4"
path="res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Handle02a.png"
dest_files=["res://.godot/imported/Handle02a.png-f2f44a66c106ed5473f5b69eb2a37bd8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 B

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dxd0tojksos8m"
path="res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/Handle03a.png"
dest_files=["res://.godot/imported/Handle03a.png-22d133e9d9604a2db6d3a7746a093159.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 B

@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cb41i0vnvjmri"
path="res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/ui/InputField01a.png"
dest_files=["res://.godot/imported/InputField01a.png-d4cf01240254dbae255720ff161f1d6a.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+11 -107
View File
@@ -45,41 +45,11 @@ This project deliberately does not, for four reasons:
prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md).
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.
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
polygons all three become intersection tests, and the fog query in particular
stops being cheap enough to run every frame.
What it costs: geometry is axis-aligned and 32px-quantised, and bullets must
travel less than one tile per tick or the point test steps over walls (pinned by
`test_bullet_speeds_stay_below_the_tunnelling_threshold`).
## Where the layers sit now
Four things are worth knowing before reading any file:
- **Geometry is per-world.** There is no global arena. `SimWorld.map` is a
[MapGrid](../src/sim/map_grid.gd); the hub and every dungeon have their own,
and the client holds a *partial* copy streamed to it in chunks.
- **`src/meta/` is server-only.** Accounts, characters, levels, experience,
inventories and upgrades live there. The simulation reads a player's level and
maximum health and moves items between the ground and a bag; it never writes
progression or touches the store. It announces what happened and
`ServerRuntime` banks it. One writer means a level cannot disagree with the
experience that earned it.
- **Derived, never stored.** Level comes from lifetime experience; combat
numbers come from the upgrade list ([PlayerStats](../src/sim/player_stats.gd));
maximum health comes from the level and the upgrades. Nothing that can be
recomputed is persisted, so nothing saved can disagree with what produced it.
- **`src/core/settings.gd` is the exception to "shared".** Everything else in
`src/core/` is agreed by both sides; settings are the player's own machine
and never reach the server.
The trade is that there is no physics engine — no arbitrary collision shapes, no
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
convention anyway. A dungeon with real geometry would need a static collision
representation added to `SimWorld`, not a switch back to nodes.
## Tick
@@ -105,13 +75,6 @@ BossDef = stats + ordered phases
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can
be dropped on either. A boss is four phases layering one idea at a time; a new
boss is a new function in `src/content/content.gd` and zero simulation changes.
`SimWorld._move_boss()` is the same idea for movement: the mode is a field on
the phase, and adding a boss that walks needs no code.
Emitters are **stateless**. They are shared resources — two bosses of the same
kind would otherwise stomp each other's timers — so anything an emitter needs to
remember between two ticks has to be derived instead. `TelegraphedStrikeEmitter`
computes its strike positions from the volley number for exactly this reason.
## Entry point
@@ -124,76 +87,17 @@ after `--`:
## File map
Grouped by layer. The rule the whole thing hangs on: **`src/sim/` imports
nothing from `net`, `view` or `ui`, and never touches `Net`.**
### Shared constants and helpers
| File | Role |
| --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. |
| `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. |
| `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/core/settings.gd` | Client-local preferences. Never reaches the server. |
| `src/core/credits.gd` | Third-party assets and their licences. Two are CC BY, so this is a legal requirement. |
### The simulation
| File | Role |
| --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `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. |
| `src/sim/poison_track.gd` | Damage over time. O(1) per actor per tick however many doses. |
| `src/sim/sim_loot.gd` | An item on the ground: world-shared, or owned by one peer. |
| `src/sim/sim_portal.gd` | A dungeon entrance, and which dungeon it opens. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — the client is never given the seed. |
| `src/sim/patterns/` | Bullet emitters. Stateless; the authoring surface for every fight. |
### Content (data, in code)
| File | Role |
| --- | --- |
| `src/content/content.gd` | Every enemy and boss. `ALL_ENEMIES` / `ALL_BOSSES` are what the tools and tests iterate. |
| `src/content/items.gd` | Every item. `Items.ORDER` doubles as the wire format. |
| `src/content/upgrades.gd` | The seven upgrades and their draw weights. |
| `src/content/dungeons.gd` | The kinds of run. `ORDER` is a wire format *and* the hub's portal order. |
| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. |
| `src/actors/` | The `Resource` definitions those tables build: enemy, boss, item, loot, upgrade, dungeon. |
### Server-owned state
| File | Role |
| --- | --- |
| `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/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network
| File | Role |
| --- | --- |
| `src/net/net_codec.gd` | Every binary codec: snapshot, events, input, roster, characters, map chunks, portals, upgrade state. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, persistence, map streaming. |
| `src/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/net/protocol.gd` | Wire version and constants. Bump `VERSION` whenever a layout changes. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. The only autoload. |
### View and UI (read-only; decides nothing)
| 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/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/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `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. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
+19 -58
View File
@@ -12,26 +12,24 @@ can be referenced from a scene or script. Files are copied into `res://assets/`
in game-ready form, and only then imported. The tree is 3.9 GB and
`tools/check.sh` runs `--import`, which would otherwise walk all of it every run.
What is in the game today is three sprite sheets, four sounds and twelve small
UI sprites. Sprite rects and sound paths are all declared in one
What is in the game today totals **548 KB** across seven files: three sprite
sheets and four sounds. Sprite rects and sound paths are all declared in one
place, [src/view/art.gd](../src/view/art.gd), and
`tests/unit/test_art.gd` asserts every rect lands inside its texture — a wrong
atlas coordinate does not error, it silently draws the wrong pixels.
## Licence findings
Checked against each pack's itch.io page, and against the licence file the pack
itself ships where there is one. **Two of the five forbid redistribution**,
which is what publishing a repository containing them would be — so they are not
in the repository.
Checked against each pack's itch.io page. **Two of the four forbid
redistribution**, which is what publishing a repository containing them would
be — so they are not in the repository.
| Pack | Licence | Commercial | Credit | In this repo? |
| --- | --- | --- | --- | --- |
| [0x72 DungeonTileset II](https://0x72.itch.io/dungeontileset-ii) | **CC0 1.0** (public domain) | Yes | Not required | **Yes**`assets/sprites/dungeon_tileset.png` |
| [Helton Yan Pixel Combat](https://heltonyan.itch.io/pixelcombat) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/audio/sfx/` |
| [Complete UI Essential Pack](https://crusenho.itch.io/complete-ui-essential-pack) | **CC BY 4.0** | Yes | **Required** | **Yes**`assets/sprites/ui/` |
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | Free tier non-commercial; **paid tier allows commercial** | Encouraged | **No, at any tier** — local only |
| [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No**local only |
| [750+ Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Custom | **Non-commercial only** unless you contribute | Encouraged | **No** — local only |
### The blocking clause
@@ -51,54 +49,24 @@ modification.
So they live in `assets/local/`, which is gitignored. `Art.bullets_texture()`
and `Art.impact_texture()` load them at runtime if present and return null if
not; the bullet renderer falls back to a generated dot. Verified both ways: with the files
present the game uses them, and with them hidden the whole suite still passes
and the game runs`tests/unit/test_art.gd` has an explicit case for their
absence, because a clean clone is the configuration most contributors will
actually have.
not; the bullet renderer falls back to a generated dot. Verified both ways: with
the files present the game uses them, and with them hidden the suite still
passes 156/156 and the game runs.
This is also why nothing there may be `preload()`ed — `preload` resolves at
compile time and would fail the build on every machine that lacks the files.
### The UI pack is the easy case
Crusenho's *Complete UI Essential Pack* ships its own `License.txt` naming
CC BY 4.0 outright, with the licensor and store link spelled out. That permits
redistribution and commercial use, so unlike the bullet and FX packs a subset of
it is committed — twelve PNGs, 48 KB, under `assets/sprites/ui/`, listed in
`UiTheme.TEXTURES`.
Only what the game uses was copied rather than all 98 sprites: each committed
PNG costs a Godot `.import` sidecar, and a directory of files nothing references
is a directory nobody prunes. The rest stays in the untracked pack.
The obligation it creates is the same one the audio creates — see below.
### Two obligations this creates
1. **Two packs are CC BY 4.0 and require attribution** — Helton Yan's audio and
Crusenho's UI art. A file in the source tree does not satisfy that once the
game ships, because a player who downloaded a build never sees it. **Done:**
Settings → Credits shows the same list, built from
[src/core/credits.gd](../src/core/credits.gd), including a link to the
licence text as CC BY asks. `tests/unit/test_credits.gd` asserts every source
there also appears in [CREDITS.md](../CREDITS.md) and in this file, so the
three cannot drift apart.
2. **Both bdragon packs are non-commercial in their free tier.** Buying the
full tier *does* fix that — confirmed verbatim from the FX pack's paid
licence: "You may use these assets in personal, commercial or
non-commercial projects."
It does **not** fix the repository problem. The paid licence's very next
sentence is: "You can NOT re-distribute the file, no matter how much you
modify it you can use it but not share or re-sell it." Redistribution is
forbidden at every tier, so paying moves these assets from
*local-only-and-non-commercial* to *local-only-and-commercial*. They cannot
be committed to a public repo at any price.
The only route that puts bullet and FX art back into the repository is
replacing it with something permissive (CC0, or CC BY with attribution),
which would also remove the local-asset fallback machinery.
1. **Helton Yan's CC BY 4.0 requires attribution**, and it is not satisfied by
a file in the source tree once the game ships. Players need to be able to
see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits
screen is a todo.
2. **Both bdragon packs are non-commercial in their free tier.** The stated
goal is a Steam release. Before that happens they need either a paid
contribution to bdragon1727 (which permits commercial use but still not
redistribution) or replacement with CC0 art. A CC0 replacement solves both
problems at once and lets the assets go back in the repo.
### Not in use
@@ -125,10 +93,3 @@ in RAM and in export size.
[`assets/README.md`](../assets/README.md). Art is Stage 2 in
[ROADMAP.md](ROADMAP.md); everything currently on screen is drawn from
primitives plus one runtime-generated dot texture.
## Placeholder art worth revisiting
- **Warden's Ration** draws as a gold flask (`Art.ITEM_ICONS[1]`). The 0x72
tileset has no food sprite, and the item is deliberately useless flavour, so a
recoloured flask stands in until the art pass. It reads as a potion, which is
the wrong thing for it to read as.
-362
View File
@@ -126,365 +126,3 @@ dungeon should give a bit more than is needed for the first level-up.
**Permadeath.** Death marks a character inactive — never deleted, for archival
and troubleshooting — and the player picks another character or creates one.
---
## Characters and progression
**Level 1 is base health; each level adds 10.** So level 15 is
`PLAYER_MAX_HP + 14 * 10` = 240. Level is *derived* from lifetime experience
rather than stored alongside it, so the two can never disagree — a hand-edited
save cannot produce a level 12 character with a level 3's experience.
**The five-character cap counts LIVING characters only.** Retired ones stay in
the store forever but free their slot. Counting the dead would lock a player out
of their own account permanently after five deaths, which is not a punishment
anyone signed up for.
**Death unbinds the character entirely.** There is deliberately no "return to
the hub as the character who just died" — the run is over, so the peer is
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.
**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.
**The character store refuses to start rather than starting empty.** A corrupt
or unreadable save aborts the server. Loading empty would look like it worked
and then overwrite every character on the first level-up.
**Account ids are written as decimal strings in JSON.** They are 64-bit and JSON
numbers are doubles, which would silently round them.
**Character swapping is hub-only, enforced on the server.** Swapping inside a
dungeon would be an instant, uninterruptible exit from danger — strictly better
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.
**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
client receives contains living characters only. Listing the dead would offer a
choice that cannot be taken.
**Experience rides the snapshot, not the character roster.** The roster is only
re-sent when the *set* of characters changes, so a bar fed from it moved only on
level-up or a swap. The live total is four bytes on a message that already goes
out at 20 Hz.
---
## Inventory and loot
**Four slots, permanently on screen.** An inventory you have to open is a menu,
and a menu is a death in a game where the floor is bullets. `INVENTORY_SLOTS` is
one constant that the wire format, the save record and the HUD all read, so
growing it is a one-line change — but not into a paged or scrolling UI.
**Item actions ride the input frame rather than becoming new messages.**
`InputFrame` gained `BTN_USE`, `BTN_DROP` and a slot byte. Using an item happens
*during* a fight, so it has to be ordered against movement on the same tick and
be as cheap to reject as a movement vector. Riding the existing stream gets the
redundancy that covers a dropped packet, the replay guard on `last_input_tick`,
and a rate limit of one action per tick for free. A separate reliable RPC would
have needed every one of those bolted back on.
**Item actions are edge-triggered; movement and fire are not.** The client
resends its last few frames every tick and a starved server coasts on the last
one it holds, so a level-triggered read would empty the whole inventory in four
ticks. The *slot* is part of the edge as well — tapping 2 while 1 is still held
is a second, distinct action rather than a swallowed one.
**Loot has two visibilities, and the instanced one is enforced on the wire.**
World-shared loot is one entity the first player to reach it takes.
Player-instanced loot is one entity per eligible player, and a peer is never
told the other copies exist — the filter lives in `NetCodec.encode_snapshot`
beside the actor interest radius, not in the client. It is an
interest-management rule, not a UI convention.
**The Warden's Ration is useless on purpose.** It is dropped by every boss, one
per player who was alive for the kill, and does nothing when used. Its job is to
make sure the player-instanced path runs on every single boss kill instead of
being a code path nothing exercises. If it ever gains an effect, that job needs
a new holder.
**Anything dropped becomes world-shared, whatever it was before.** An instanced
trophy you do not want should be able to reach someone who does — otherwise
"droppable" means nothing for half the items in the game.
**A potion used at full health is refused, not spent.** Nobody drinks one on
purpose at full health, so a mistimed keypress must not do it for them. The
useless ration, by contrast, *is* consumed: "does nothing" has to mean a
completed transaction or it proves nothing about the path it exists to test.
**A full bag leaves the item on the floor.** Nothing is destroyed by a failed
pickup, and the failure does not block the portal, which shares the interact
key.
**Inventories live on the character and are written on every transaction.**
Not on a timer: a crash between "picked it up" and "wrote it down" must not be a
way to lose an item, or — far worse — to duplicate one. They are stored as item
*ids* rather than wire indices, so a save survives `Items.ORDER` being appended
to, and an id this build does not know decays to an empty slot rather than to
the wrong item.
**Items do not stack.** One id per slot, no count, no charges. Everything the
game currently needs fits that, and the wire format, the save record and the UI
are all simpler for it. Add a count when something actually needs one.
**Ground loot never expires; each world caps at `MAX_LOOT_PER_INSTANCE`,
oldest evicted.** Dungeons close and take their litter with them, so only the
hub — which never closes and where players can drop things — can realistically
reach the cap.
---
## More than one dungeon
**A second dungeon is a set of multipliers over the shared content, not a
parallel copy of it.** `DungeonDef` scales enemy health, boss health and loot
chance; the generator, the rooms, the enemy mix and the boss are the same
objects the real run uses. A duplicated `Content` would drift from the original
the first time anything was tuned, and the whole value of the Proving Grounds is
that it is *identical apart from the numbers*.
**It is reachable from the hub rather than hidden behind a launch flag.** A flag
would need a server restart to switch, which makes comparing the two a chore
and makes "does this behave the same in the real run?" a question nobody
bothers to ask. Two portals a few metres apart makes it a five-second check.
**Which dungeon you enter is decided by where you are standing.** The portal is
resolved server-side from the player's own position, and the `PORTAL_USED` event
carries the answer. There is deliberately no client message that names a
dungeon: one would let any client ask for the Proving Grounds' loot rate and
walk out with it.
**The instance matcher compares dungeon ids.** A forming run only accepts party
members who asked for that kind. Without it, walking into one entrance could
drop you into the other's run purely on timing.
**Scaling clamps at both ends.** Health never scales below 1 — a creature with
zero health is a crash waiting for a divide — and a boosted drop chance never
exceeds certain, or the roll becomes dead code and "chance" stops meaning
anything.
---
## Upgrades
**Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
upgrade carries, Spread's 10%, Doubleshot's 50% and Glass Cannon's +100% pool
into the additive term; Sniper's ×2 multiplies the result. The brief called
Sniper out as multiplicative specifically, and this is what that buys: it stays
worth taking however many percentages have already piled up, and two Snipers is
4× rather than +200%.
**The +5% belongs to *taking an upgrade*, not to any particular upgrade.** It
lives in `SimConfig.UPGRADE_DAMAGE_BONUS` and is applied once per upgrade held,
rather than being baked into seven definitions where it would be seven places
to get wrong.
**A character record becomes a live player in exactly one place.**
`ServerRuntime._adopt_character`, called from `_place`, which every transfer
goes through. `SimWorld` knows nothing about characters and hands back a blank
player on every instance change, so something has to give it back its level,
upgrades and bag — and for a long time only character *select* did. Every
portal and every escape reset the player to level 1 with base stats and an
empty inventory, while the record on disk stayed correct, which made it look
like a display bug.
**Stats are derived from the upgrade list, never stored.** `PlayerStats.build()`
recomputes them from the ids the character holds, the same way `Progression`
derives level from experience. One source of truth means a saved stat can never
disagree with the upgrades that produced it.
**Glass Cannon halves the LEVELLED maximum health, and multiplies if taken
twice.** Half of base HP would be a flat 50 that fades from brutal at level 1
to nearly free at level 15, which is the wrong direction for a drawback.
**Poison doses stack independently rather than refreshing.** Chosen over the
alternatives knowing it is by far the strongest reading: at ~4.3 shots/sec that
is dozens of concurrent doses. It costs nothing to run because every dose lasts
the same number of ticks — so they expire in the order they were added, the
pending expiries are a plain FIFO, and only its front is ever examined.
**Split Shot's children leave at ±45° from the original heading**, 90° apart,
and are born just past the target rather than on it. A child spawned inside
what was just hit would be resolved against it again on the same tick — a free
second hit, and with several charges a free chain of them.
**Level-ups queue.** Reaching two levels in one run owes two choices. Losing one
for doing well is a punishment nobody would guess at.
**The offer is rolled once and held on the character.** If it regenerated when
the screen opened, closing and reopening would be a free reroll and everyone
would press it until a legendary turned up. It is written to disk with
everything else, so a crash is not a reroll either.
**Upgrades are spent standing at the hub NPC, enforced on the server.** Same
rule as the dungeon portal: where a player is standing is the one thing a
modified client cannot fake. The screen closes when you walk away so it never
offers a button that would be refused.
**Bullet speed has a hard ceiling.** Wall collision samples a position once per
tick, so anything faster than one tile per tick tunnels through geometry. Two
Snipers would ask for 2480 u/s against a 1920 threshold, and a tunnelling
bullet looks exactly like a bullet — so `MAX_BULLET_SPEED` clamps it and a test
pins the clamp.
**Poison ticks are not announced as hits.** They land many times a second on a
reliable channel, and the client learns enemy health from the snapshot anyway.
Death is still announced, because the experience award is keyed on that event
and a kill by poison has to score.
---
## The scale of the numbers
**Health and damage are ten times what they started as, and every ratio is
unchanged.** A shot is 60, a fresh character has 1000, the Warden has 36000,
and enemy health and emitter damage were scaled with them — time to kill is
exactly what it was.
The reason is rounding, not balance. Damage is an integer, and at a base of 6
the +5% that every upgrade carries computed to 6.3 and rounded back to 6: a
player took their first upgrade, was told it made them stronger, and it did
nothing. Ten times the base means every 5% step is worth 3 damage. The same
applies to the boss's per-phase armour multiplier, which at 6 damage rounded
1.15 into 1.17.
**The practice dummy is indestructible by flag, not by a large number.** The
old 100000 health was already past the u16 the snapshot sends enemy health in,
and once a shot did 60 a patient player could have worn it down and left the
hub without a practice target until the next restart. A flag says what was
actually meant.
---
## Bosses that move, and attacks that warn
**Movement belongs to the phase, not to the boss.** A fight that stands still
and then starts hunting you is one boss with two phases. `BossDef.stationary`
was removed rather than kept alongside the phases: a flag claiming the boss
stood still while one of its phases walked around would be a second source of
truth, and the wrong one. `BossDef.moves()` is derived.
**CHASE holds a distance instead of closing.** A boss standing on top of a
player is a boss whose bullets cannot be read, and this genre cannot afford
that. It backs off when you come inside its preferred range.
**Waypoints are fractions of the arena.** The Warden's hall and the Choir Vault
are different sizes, and a phase written against absolute coordinates would only
work in one of them.
**The boss room clamp moved after movement.** It was a no-op while every boss
was stationary. Boss rooms deliberately do not lock, so walking out is always an
escape — which is only true if the boss cannot follow.
**Telegraphed strikes are announced by a stateless emitter.** Emitters are
shared resources, so nothing may be remembered between the warning and the
strike; the positions are derived from the volley number instead, and a test
asserts the burst lands where the marker promised.
**Telegraph markers are drawn through fog and through walls**, unlike every
other thing in the view. A warning you cannot see is an unavoidable hit with
extra steps, which is the opposite of what a telegraph is for.
**Each boss has its own entrance in the hub.** A dungeon names the arena it ends
in, and the arena decides the boss. Which fight you are walking into is the one
thing a player chooses before a run, so it is not rolled for them — not from the
depth (a dev flag nothing in play raises, which left the second boss
unreachable) and not from the seed either. Only the Proving Grounds leaves it
open, because a harness you re-enter every couple of minutes wants whichever
fight comes up first.
---
## Bullets do not animate
**One frame, and a slow turn.** The bullet sheet's eight frames are a colour
cycle rather than a shape change, and running it meant every bullet on screen
strobed through a palette in unison — genuinely hard to look at with a few
hundred in the air, which is the normal case for this game.
Each bullet now holds one frame and rotates at `Art.BULLET_SPIN_RATE`, offset by
its own id so a ring of twenty does not turn as one rigid wheel. The rate is
slow enough that nothing completes a full turn inside its own lifetime, so it
reads as drift rather than as spin — a test pins that against the
longest-lived bullet in the game.
The other seven frames stay in the atlas because that is what the source art is.
Nothing draws them, and the renderer now slices four textures at startup instead
of thirty-two.
---
## Settings
**Preferences are local to the client and nothing else knows about them.**
Which key fires produces the same [InputFrame] either way, so the server has no
opinion and never hears about it. `Settings` is static for the same reason
`GameOpts` is: a `--script` tool has no main loop and cannot resolve autoloads.
**Bindings are stored as physical keycodes and labelled through the active
layout.** Physical is what `tools/setup_input_map.gd` already uses, so bindings
follow key *position*; translating the label back means an AZERTY player reads
the letter printed on the key their fingers are on. The translation is skipped
on a display server with no keyboard — headless does not merely lack the call,
it logs an engine error and hands the argument back, and there is no feature
flag to test for.
**A rebind replaces every event on the action, not just the first.** Keeping the
alternates would mean the action still answered to the key you just moved away
from, which reads as the rebind not having worked. The cost is that rebinding
movement loses the arrow keys; untouched actions keep all their defaults.
**One key doing two things is refused, and the clash is named.** Silently
accepting it produces a control scheme that is broken in a way the player has to
diagnose themselves.
**"Reset" restores what the PROJECT shipped**, captured once before anything
overrides it. Captured later it would restore the last session's choice — that
is, the thing the player was trying to undo.
**Panels are the pack's shape in this game's colours.** The set is cream
throughout, which is right for buttons — they are meant to be the bright thing
you press — and wrong for a dialog laid over a dark dungeon. The panel stylebox
is modulated dark, which keeps the pixel border and the corner shape and lets
every label the game already draws in light colours stay readable. The
alternative was recolouring every label in five screens to suit the art.
**A card's rarity is its frame, not a word on it.** Three upgrade choices are
compared at a glance, and a colour reads faster than a label.
**Effects play on an SFX bus created at runtime.** A bus layout resource would
be one more file to keep in step with the code that reads it, and the sliders
being real mixer settings beats multiplying a number into every `play()` call.
---
## Drawing a boss that moves
**The boss is interpolated between snapshots like every other actor.** It was
not — `boss_state()` handed back the newest snapshot raw, so the boss stepped at
the 20 Hz snapshot rate rather than the frame rate. Invisible for as long as
every boss stood still, and the first one that moved looked broken. Not
interpolated across an instance change, though: the previous snapshot describes
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.
+7 -102
View File
@@ -9,26 +9,17 @@ prediction and reconciliation for the local player.
client server (60 Hz)
| |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 5 button bits, | SimWorld.step(): move, AI, emitters,
| inventory slot | integrate bullets, resolve hits, items
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths,
| | item pickups/uses/drops
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss, ground loot,
| | your own inventory @ 20 Hz
| move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
| |
predict locally, reconcile authoritative for everything
```
The client has no message that expresses a position, a hit, damage taken, a
completed escape, or an item it now owns. This is deliberate and stronger than
validating such messages after the fact: there is no code path to exploit, only
intent to interpret.
Item actions are worth noting as the newest thing to resist becoming a message
of its own. Using or dropping an item is a request that reaches the server as
two button bits and a slot number on the ordinary input frame, and the server
decides what — if anything — happened. See *Item actions ride the input frame*
below.
The client has no message that expresses a position, a hit, damage taken, or a
completed escape. This is deliberate and stronger than validating such messages
after the fact: there is no code path to exploit, only intent to interpret.
## Why bullets are not replicated as state
@@ -196,88 +187,6 @@ is invisible damage. The floor is *longest bullet travel + fog radius* — 1500
from the live content, so adding a faster or longer-lived bullet fails a test
rather than producing bullets that wink into existence.
## Loot has two visibilities, and one of them is an interest rule
Ground loot rides the snapshot rather than an event stream: items do not move,
so re-sending them 20 times a second costs almost nothing and a lost packet
costs nothing at all — which a spawn-once event could not claim.
Each item is either **world-shared** (`SimLoot.owner_peer == 0`) or
**player-instanced** (owned by exactly one peer). The instanced kind is filtered
in `NetCodec.encode_snapshot`, next to the actor interest radius and for the
same reason: a peer is never *told* that another player's copy exists, so a
modified client has nothing to reveal. Hiding it in the UI would have been the
same class of mistake as relying on fog to hide enemies.
Two consequences:
- **An instanced item leaves with its owner.** `SimWorld.remove_player` deletes
loot owned by the departing peer. Nobody else can see or take it, so leaving
it behind would be an invisible entity the instance carries until it closes.
- **Anything dropped becomes world-shared**, whatever it was before. That is
what makes dropping worth having.
The Warden's Ration exists to keep this path honest: it is dropped instanced on
every boss kill, so the filter runs in every real fight rather than only in
tests.
## Which dungeon you enter is a position, not a request
The hub holds a list of portals, each bound to a dungeon id. Pressing interact
resolves the player's **server-side** position through `SimWorld.portal_at()`,
and the resulting `PORTAL_USED` event carries which dungeon that entrance opens.
There is no client message that names a dungeon, and there must not be: one
would let any client ask for the Proving Grounds' loot rate — ten times the drop
chance — and bring the results back to the hub. The same reasoning as everywhere
else here; the difference between "the server checks your request" and "there is
no request" is the whole model.
`enter_instance` carries the portal list (position plus a dungeon index, via
`NetCodec.encode_portals`) and the id of the dungeon you have arrived in. The
client needs the latter to scale the boss's health ceiling the same way the
server did, or the boss bar would sit near empty for an entire easy fight.
## Item actions ride the input frame
`InputFrame` carries `BTN_USE`, `BTN_DROP` and a slot byte (10 bytes total, up
from 9). The alternative — a reliable `c_use_item(slot)` RPC — would have needed
its own replay guard, its own rate limit, and its own ordering story against the
movement on the same tick. The input stream already has all three.
The one thing it does not give for free is edge detection. The client repeats
its last few frames every tick (that redundancy is what covers a dropped packet)
and a starved server coasts on the last frame it holds, so a level-triggered
read would spend four items in four ticks. `SimPlayer.prev_buttons` and
`prev_slot` hold the edge; the slot is part of it, so tapping 2 while 1 is still
held is a second action rather than a swallowed one. Movement and fire stay
level-triggered — holding them is exactly what you mean.
Pickup shares `BTN_INTERACT` with the dungeon portal. Loot wins when both are in
reach, but only on a tick where something was actually taken, so a full bag
cannot leave a player standing on the portal unable to use it.
## Upgrades change what a shot is, and none of it is client-side
A player's damage, fire rate, bullet speed, shot count and every rider
(splitting, poison, erasure) come from `PlayerStats`, rebuilt on the server from
the upgrade ids the character holds. The client builds its own copy for the
choice screen's summary, and that copy decides nothing — it exists so the panel
can show real numbers instead of promises.
Three things fall out of it worth writing down:
- **The riders live on the bullet, not on the shooter.** A shot already in
flight keeps what it was fired with, so taking Poison mid-flight cannot reach
back and poison it. They stay zero on a replica, which never resolves a hit
and has no use for them.
- **Choosing an upgrade is intent, checked three ways.** The client sends an
index; the server checks a choice is owed, that the index names one of the
three options *it* put on the table, and that the player is standing at the
hub NPC. The index selects from a list the client did not write.
- **The offer is server state.** Rolled once, stored on the character, and
persisted — so closing the screen is not a reroll, and neither is a crash.
## No contact damage
Nothing hurts you by touching it. Every threat is a bullet you can see and
@@ -313,10 +222,6 @@ transition, not a combat mechanic.
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side |
| Item action repeated by a held key | edge-triggered against `prev_buttons` / `prev_slot` |
| Inventory slot index out of range | `SimPlayer.take_slot` answers "nothing" |
| 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
+61 -388
View File
@@ -15,71 +15,6 @@ Legend: **done** · **partial** (works, with a stated gap) · **todo** (not star
---
## Where things stand
**Every stage in the original brief is built.** The MVP asked for a twin-stick
bullet hell, server-side hit validation, predictable enemies, an adaptable boss,
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.
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.**
| Candidate | Why it might be next | What it needs first |
| --- | --- | --- |
| **Replace `LocalAuthProvider`** | It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. | Nothing technical — it is one `AuthProvider` subclass. Needs a decision about Steam vs something else. |
| **A reason to keep playing past level 15** | Levels cap, `--depth` never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. | Open question 1 below. |
| **An economy** | Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. | Open questions 2 and 3. |
| **Impact and hit feedback** | `Art.IMPACT` is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. | Nothing. This is the cheapest visible win. |
| **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. |
Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above.
### Known flake
`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.
---
## Verification surface
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 |
| `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 |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
`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.
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
track and an invisible scrollbar.
The four diagnostics exist because the smoke test structurally cannot reach
what they cover: bots are poor shots (so they neither level up, produce drops,
nor walk to the quartermaster), and a listen server cannot drift its own clock
against itself.
---
## Stage 1 — World and exploration · *done*
| Feature | State | Where |
@@ -118,48 +53,6 @@ draws whatever it holds. The defence is what the server declines to send.
---
## Dungeon kinds · *done*
Three entrances stand side by side in the hub, labelled with the fight each one
ends in.
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
**A dungeon names its arena, and the arena decides its boss.** Which boss you
are about to fight is the one thing a player chooses before walking into a
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
of minutes, so the one you want is one re-entry away.
The Proving Grounds is a **test harness you can walk into**: same generator,
same rooms, same enemies, but everything dies far faster and drops far more. A
manual pass over loot, the inventory, dropping and all four boss phases takes a
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.
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.
Two more things worth knowing:
- **It is multipliers over the shared content, not a copy of it.** A duplicated
`Content` would drift the moment anything was tuned, and "identical but
easier" would quietly stop being true.
- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()`
resolves the server-side position; the `PORTAL_USED` event carries the answer.
No client message names a dungeon, which is what stops anyone picking the
generous loot table from a real run. `accepts_new_party_member` compares
dungeon ids too, so walking into one entrance can never drop you into
another's forming run on timing alone.
---
## Art and audio · *first pass done*
Placeholders are gone: terrain, actors and bullets are sprites, and four sounds
@@ -174,285 +67,75 @@ play off server events. Enough to prove the pipeline, not a finished look.
| Rects validated without a display | done | `tests/unit/test_art.gd` |
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
| Directional sprites, hit flashes, screen shake | todo | |
| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) |
| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` |
| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries |
| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` |
| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` |
| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen |
| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. |
| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB |
| **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. |
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
## Stage 2 — Characters, persistence, levels · *done*
## Stage 2 — Characters, persistence, levels · *todo, next*
| Feature | State | Where |
| --- | --- | --- |
| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) |
| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) |
| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` |
| Last-played auto-selected on login | done | `CharacterStore.last_played` |
| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` |
| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) |
| Levels 115, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) |
| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` |
| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints |
| Swap character from the hub | done | Esc menu → Change character; refused server-side in a dungeon |
| XP percentage to next level | done | `HUD._draw_xp_bar`, fed live from the snapshot |
| Dead characters hidden from the roster | done | `ServerRuntime._send_characters` sends living only |
| Suggested name when creating | done | `Character.random_name` |
| Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum |
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
identity layer, which is decided but unbuilt.
Verified end to end by `tools/diag_progression.tscn`, which drives the real
server through kill → xp → level → health and death → retire → roster. That
path cannot be covered by the bot smoke test, because bots are poor shots.
| Feature | Notes |
| --- | --- |
| Identity abstraction | Shaped like Steamworks so it swaps out cleanly: opaque ticket from client → server validates → stable 64-bit account id (a SteamID64 stand-in). Local dev provider persists a generated id in `user://`. See [DECISIONS.md](DECISIONS.md#identity). |
| Character store | Server-side, keyed by account id. Needs a schema and a file format; nothing persistent exists in the project today. |
| Up to 5 characters, random colour each | Colour is a placeholder for a later cosmetic system. |
| Last-played character auto-selected on join | |
| Permadeath → mark inactive, never delete | Archival/troubleshooting. Confirm whether the 5-character cap counts only *active* characters. |
| Death flow: pick another character or create one | Replaces today's "return to hub" button, which currently just revives you. |
| Levels 115, +10 max HP per level | Confirm whether level 1 is 100 HP (→ 240 at cap) or 110. |
| XP from kills, bosses worth much more | First full dungeon should give slightly more than one level. |
### Still open in this area
- **The local identity provider is insecure by design.** Any client can claim
any account id. Fine for a LAN; must be replaced before the game is reachable
from the internet. Swapping in Steam is one `AuthProvider` subclass and no
schema change.
- `--account` and `--store` exist so several clients and test runs can coexist
on one machine. A real provider makes `--account` unnecessary.
## Stage 3 — Inventory and loot · *done*
| Feature | State | Where |
| --- | --- | --- |
| 4 slots, permanently on screen | done | `SimConfig.INVENTORY_SLOTS`, `HUD._draw_inventory` |
| Items defined as data, not code | done | [src/content/items.gd](../src/content/items.gd), [src/actors/items/item_def.gd](../src/actors/items/item_def.gd) |
| Loot tables on enemies and bosses | done | `EnemyDef.loot` / `BossDef.loot`, rolled in `SimWorld._drop_loot` |
| Health potion, rare from trash | done | `Content.TRASH_POTION_CHANCE` = 0.08 |
| …guaranteed from the boss | done | `Content.warden()` loot table, chance 1.0 |
| World-shared loot | done | `SimLoot.owner_peer == 0` |
| Player-instanced loot | done | one `SimLoot` per living player, filtered per peer in `NetCodec.encode_snapshot` |
| Warden's Ration — useless, instanced | done | `Items.wardens_ration()` |
| Pick up, use, drop | done | `SimWorld._try_pickup` / `_use_slot` / `_drop_slot` |
| Inventories persist | done | stored on `Character`, written by `ServerRuntime._persist_inventory` |
| Ground loot drawn with a pickup prompt | done | `WorldView._draw_loot`, `HUD._draw_pickup_prompt` |
Controls: **E** picks up, **14** use a slot, **shift+14** drop one.
### The decisions worth knowing before touching this
**Item actions ride the input frame; they are not new messages.** `InputFrame`
gained `BTN_USE`, `BTN_DROP` and a slot byte. That buys the redundancy that
covers a dropped packet, the replay guard on `last_input_tick`, ordering against
movement on the same tick, and a natural rate limit of one action per tick — all
of which a separate reliable RPC would have needed bolted back on.
**Item actions are edge-triggered; movement and fire are not.** The client
repeats its last few frames every tick and a starved server coasts on the last
one it was given, so a level-triggered read empties the whole inventory in four
ticks. `SimPlayer.prev_buttons` and `prev_slot` hold the edge, and the *slot* is
part of it — tapping 2 while 1 is held is a second, distinct action.
**Instanced loot is enforced on the wire, not in the client.** A peer is never
told another player's copy exists. That makes it an interest-management rule of
the same kind as `ACTOR_INTEREST_RADIUS`, and it is why the ration is worth
having: every boss kill exercises the path.
**Anything dropped becomes world-shared, even if it arrived instanced.** That is
what makes dropping worth having — a trophy you do not want should be able to
reach someone who does.
**A potion at full health is refused rather than spent.** Nobody drinks one on
purpose at full health, so a mistimed keypress must not do it for them.
**A full bag leaves the item on the floor** and does not block the portal, which
shares the interact key.
### Known gaps
- **Nothing sells items.** Loot only comes from kills; the hub has no source.
The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap
rather than a decision.
- **No stacking.** Four potions take four slots. A count byte per slot is cheap
to add; nothing needed it yet, so the wire, the save record and the HUD all
stayed simpler for not having one.
- **Ground loot never expires**, it is only capped at
`SimConfig.MAX_LOOT_PER_INSTANCE` per world, oldest evicted. Dungeons close
and take their litter with them; only the hub can realistically reach the cap.
- **The ration draws as a gold flask.** The tileset has no food sprite. See
[ASSETS.md](ASSETS.md).
Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
`ServerRuntime.peer_names` is client-supplied and trusted for display only.
---
## The scale of health and damage
## Stage 3 — Upgrades · *todo*
Health and damage are an order of magnitude larger than they started. A shot is
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
unchanged** — enemy health, boss health and every emitter's damage were scaled
together, so time to kill is exactly what it was.
| Feature | Notes |
| --- | --- |
| Lobby NPC, 3 random choices per level gained | Confirm whether unclaimed level-ups queue. |
| Every upgrade also grants +5% damage, additive | Displayed on the choice alongside its specific effects. |
| Upgrade list | split shot, glass cannon, spread, sniper, doubleshot (rarer), poison (rare), eraser (legendary) |
| Rarity weights | **Unspecified.** Needs numbers. |
| Damage stacking formula | **Unspecified.** Sniper is explicitly multiplicative; the rest read as additive. Order needs pinning down before any of it is built. |
| "Chosen upgrades" screen | |
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
carries computed to 6.3 and rounded straight back to 6, so a player's first
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
now has somewhere to land.
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
changes damage, and that shots-to-kill for each enemy is what it was before.
Do not tidy these numbers back down without scaling `content.gd` with them.
The hub's practice dummy stopped relying on a huge health pool at the same
time: it is `indestructible` by flag now. The old 100000 was already past the
u16 the snapshot sends enemy health in, and after the rescale a patient player
could have destroyed the hub's only practice target for everyone.
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`; upgrades turn
it into a per-player computed stat, so `SimWorld._fire_player_shot` grows a
stats block. Several upgrades (split, spread, doubleshot) change how many
bullets spawn per shot, so they belong in the same place.
---
## Stage 4 — Upgrades · *done*
## Stage 4 — Inventory and loot · *todo*
Every level gained banks one choice. Choices are spent at the **quartermaster**,
an NPC in the hub: walk to it, press E, pick one of three.
| Feature | State | Where |
| --- | --- | --- |
| Upgrades as data, no engine branches | done | [src/content/upgrades.gd](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) |
| Per-player derived stats | done | [src/sim/player_stats.gd](../src/sim/player_stats.gd) |
| 3 weighted options per choice, held until spent | done | `Upgrades.roll_offer`, stored on `Character.offer` |
| Choices queue across levels | done | `Character.pending_choices` |
| Spent only at the NPC, enforced server-side | done | `SimWorld.at_upgrade_npc`, `ServerRuntime.on_choose_upgrade` |
| Choice screen showing the +5% and each effect | done | [src/ui/upgrade_screen.gd](../src/ui/upgrade_screen.gd) |
| List of upgrades already taken, plus resulting stats | done | lower half of the same panel |
| Upgrades persist, die with the character | done | `Character.upgrades`, permadeath retires the record |
### The seven upgrades
| Upgrade | Rarity | Effect |
| --- | --- | --- |
| Split Shot | common | On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks. |
| Glass Cannon | common | +100% damage, ×0.5 levelled max health. |
| Spread | common | +2 shots in a cone, 10% damage. |
| Sniper | common | ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed. |
| Doubleshot | uncommon | +1 parallel shot, 50% damage. |
| Poison | rare | Each hit deals another 50% of its damage over 10s. Doses stack independently. |
| Eraser | legendary | 1% chance per tick to delete an enemy projectile a shot is passing through. |
Draw weights: common 100, uncommon 45, rare 18, legendary 4.
### The decisions behind the numbers
All four settled with the user; the reasoning is in
[DECISIONS.md](DECISIONS.md).
- **Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every
upgrade carries, Spread's 10%, Doubleshot's 50% and Glass Cannon's +100%
pool additively; Sniper multiplies the result, which is why two Snipers is 4×
and not +200%.
- **Glass Cannon halves the LEVELLED maximum**, so the price does not fade to a
rounding error by level 15. Taken twice it multiplies again.
- **Poison doses are independent**, not refreshed. `PoisonTrack` makes that free:
every dose lasts the same number of ticks, so they expire in insertion order,
the pending expiries are a plain FIFO, and the per-tick cost is O(1) however
many are live.
- **Split Shot's children leave at ±45° from the original heading**, born just
past the target so they cannot be resolved against it again on the same tick.
### Constraints this collided with, and what it cost
- **Bullet speed now has a ceiling** (`SimConfig.MAX_BULLET_SPEED`, 1500 u/s).
Wall collision samples once per tick, so anything over one tile per tick
(1920 u/s) tunnels. Two Snipers would have asked for 2480.
- **`BULLET_INTEREST_RADIUS` rose to 2900**, because an upgraded player shot is
now the longest-travelling bullet in the game. `test_interest.gd` measures the
worst case from the ceiling rather than from static content, which is what it
used to do and what upgrades quietly invalidated.
- **Poison ticks do not emit `ENEMY_HIT`.** They land many times a second on a
reliable channel; the client learns hp from the snapshot. Death is still
announced, because the experience award is keyed on it.
- **The eraser's bullet-vs-bullet scan costs nothing until someone owns it** —
it runs only for shots carrying a non-zero chance.
### Known gaps
- **One panel, not two screens.** The brief asked for a separate screen listing
upgrades taken; it is the lower half of the choice panel instead, because the
two are read together.
- **No respec, and no way to see another player's build.**
| Feature | Notes |
| --- | --- |
| Small always-on-screen inventory | Slot count unspecified. |
| Health potions: rare from trash, guaranteed from bosses | |
| World-shared loot | Player-instanced loot planned later. |
| A unique, useless food item from bosses | Exists specifically to test player-instanced loot. Confirm whether it should be instanced *now* or just marked for it. |
| Dropping items so others can pick them up | |
---
## Stage 5 — Boss features and new bosses · *done*
## Stage 5 — Boss features and new bosses · *todo*
| Feature | State | Where |
| --- | --- | --- |
| Stationary phases | done | every Warden phase |
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
| Feature | State |
| --- | --- |
| Stationary phases | done every current phase |
| Roaming / chasing within the boss room | todo; needs `SimBoss.room` clamping (see Stage 1 partial) |
| Phases that move to preset locations | todo |
| Attacks spawned at a distance with a telegraph indicator | todo — new event type plus a renderer, and it must survive fog |
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` yet |
### Boss movement
Movement is a property of the **phase**, not of the boss: a fight that stands
still and then starts hunting you is one boss with two phases. Four modes —
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
works in the Warden's hall and the Choir Vault alike.
- **CHASE holds a distance rather than closing.** A boss standing on top of you
is a boss whose bullets cannot be read.
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
destination and would otherwise snap onto its circle on the first tick.
- **Movement slides against geometry**, so a boss cannot walk through the
pillars its own arena was designed around.
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
still — which is exactly when an invariant is cheapest to establish. Boss
rooms deliberately do not lock, and that is only an escape if the boss cannot
follow you out.
### Telegraphed strikes
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
later. The moment in between is the whole feature: a burst that appears at your
feet is a coin flip, the same burst with a second of notice is a question.
- **The emitter is stateless**, like every other one — they are shared resources
and two bosses of the same kind must not stomp each other. Strike positions
are *derived* from the volley number, so the announcement and the strike
compute the same points with nothing stored between them.
- **`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
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
| Phase | Movement | Idea |
| --- | --- | --- |
| Call to Prayer | static | Teaches the marker, and nothing else. |
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
| Final Cadence | orbit | Movement and telegraphs at once. |
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
from the arena its dungeon names, not from the seed and certainly not from
`--depth`, which nothing in play raises.
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
giving it a way in is a failing test rather than a boss nobody meets.
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
had already gone stale and silently stopped writing the second boss.
### Known gaps
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
newest snapshot with no interpolation — a visible step at 20 Hz — and every
test passed for as long as bosses stood still. There is still no automated
check that a moving actor renders smoothly; the ones added cover the
interpolation call, not the impression.
- **No boss-specific music, intro or death sequence.** A boss dies like an
enemy, only louder.
- **Telegraph markers are one shape.** A circle is the only warning the client
can draw; a lane or a cone would need another event field.
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
the generator's "boss arena on the right-hand side" assumption will be tested.
The boss format itself is proven: `tests/unit/test_boss.gd` builds a boss from
scratch and asserts the simulation needs no changes to run it. Movement will be
the first thing that format has not covered, so expect `BossPhase` to gain a
movement field rather than `SimWorld` gaining a branch.
---
@@ -475,25 +158,15 @@ Not oversights — each was considered and rejected for now, with the reasoning
## Open questions for the user
Genuinely unspecified. **Do not guess at these** — each changes the design, and
several have no obvious default. The seven questions that blocked Stage 4 were
answered and are recorded in [DECISIONS.md](DECISIONS.md#upgrades); these are
what is left.
Genuinely unspecified; do not guess at these, they change the design:
1. **What advances dungeon depth?** `--depth` is a dev flag and nothing raises
it in play. Depth already drives map size and could drive difficulty and
rewards. Without an answer there is no progression past level 15.
2. **Where do items come from outside a dungeon?** Loot only drops from kills.
If the hub should sell potions, that is the quartermaster's second job.
3. **Is there a currency?** Nothing drops money and nothing costs anything. A
shop needs one; so does any reward for a cleared run beyond experience.
4. **Should items stack?** Four potions currently take four slots, which makes a
four-slot bag small. Stacking is a count byte per slot plus a rule for
splitting one — neither is hard, and both change the UI.
5. **Does a dungeon ever get harder within a run?** Enemies are placed once at
generation and never reinforce. That is a deliberate decision
([DECISIONS.md](DECISIONS.md#world)), but it means a slow party is never
punished for it.
6. **What happens to a party when one member dies?** Permadeath removes them to
the roster screen while the run continues without them. Nobody has said
whether that is right.
1. Damage stacking order — additive pool then multiplicative, or something else?
2. Rarity weights for the four upgrade tiers.
3. Split shot geometry: ±22.5° from the original heading, or 45° to each side?
4. Poison: do applications stack, or refresh a single DoT?
5. Eraser: does it delete *enemy bullets* it passes through?
6. Inventory slot count.
7. Do unclaimed level-ups queue at the NPC?
8. Does the 5-character cap count only active characters?
9. Glass cannon's 50% health: of base HP, or of levelled max HP?
10. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
+2 -108
View File
@@ -106,16 +106,10 @@ Runs the simulation with no transport and no rendering and prints milliseconds
per tick. Current numbers, 4 players, 60s of play:
```
boss fight 0.26 ms/tick peak ~300 bullets headroom x64
trash wave 0.16 ms/tick peak ~82 bullets headroom x103
boss fight 0.237 ms/tick peak 352 bullets headroom x70
trash wave 0.179 ms/tick peak 145 bullets headroom x93
```
Note what this does *not* cover: it measures the simulation only, with no
transport. Per-peer snapshot encoding was measured separately (95.6us for four
filtered snapshots against 24.9us for one shared, or 0.032 ms/tick amortised).
Quoting a bench number for something the bench does not exercise is its own
version of the trap below.
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
is the measurement that says a single server process can host dozens of
concurrent dungeons, and it took ten seconds to get because the simulation has
@@ -170,60 +164,6 @@ auto-reload racing against agent file writes is a known source of stale state.
The honest recommendation: **start with the headless CLI loop, add the MCP when
scene inspection becomes the bottleneck.** It is one command to add later.
## Verification traps, learned the hard way
Every entry below cost at least one round trip of reporting something as fixed
when it was not. They are specific to this setup, and they generalise.
### Verify the behaviour, not the artefact you produced
Bullet sprites were reported fixed three times before they were. Each time the
*atlas* was checked and found correct. The actual fault was one layer down: a
`MultiMeshInstance2D` cannot display a sub-region of a texture, so the renderer
was structurally incapable of showing one cell no matter what the atlas held.
Checking the input to a broken stage will confirm the input forever.
Ask what the *player* would see, and find a way to check that.
### An ASCII dump only proves what you dumped
The same sprites were "verified" by rendering their alpha channel. The pack
animates as a colour shimmer over a fixed silhouette, so identical-looking
frames proved nothing at all. The check was real; it was measuring the wrong
channel.
### Measure the configuration that has the bug
The client-prediction gap was measured at 0.1px on a listen server and reported
fixed. A listen server runs both halves in one process on one physics tick, so
the quantity that had drifted — two independent clocks — was constant *by
construction*. The bug needed two processes to exist at all.
If a bug is described as appearing "after a while", ask what accumulates, and
make sure your harness lets it accumulate.
### Have the test tell you the setup was valid
`test_firing_is_reported_even_when_the_bullet_dies_instantly` asserts that no
`BULLET_SPAWN` event occurred, purely to prove the interesting condition was
actually reached. The first version placed a turret as the victim — and turrets
shoot, so the assertion passed on the turret's own bullets while testing
nothing. A setup check inside the test caught it.
### `git checkout` to clean up a probe reverts real work too
A one-file revert to remove a temporary debug hook also discarded a fix made to
the same file earlier in the session, and it had already been verified and
reported. Only `git status` showing the file *missing* from the staged set
caught it. Prefer editing the probe back out, or stash.
### A pattern-based edit can match twice
A `replace()` intended for the snapshot decoder also matched inside the
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.
## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
@@ -235,49 +175,3 @@ Godot preinstalled. All three are headless, so nothing extra is needed.
- [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)
- [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
- [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html)
### A green suite is not a quiet one
`tools/test.sh` and `tools/smoke.sh` both passed while a client logged twelve
engine errors at every startup. `ConfigFile.get_value(section, key, null)` does
not mean "no default" — it means the key is absent *and* no default was given,
so the engine logs an error for each one. Nothing caught it because the smoke
test's refutations matched `SCRIPT ERROR|Parse Error|USER ERROR`, and a plain
`ERROR:` is none of those.
It surfaced from actually running the client (`--listen --autoquit`) and reading
the output, which is worth doing after any change to startup.
`smoke.sh` now asserts no plain engine errors either, excluding by name the one
line Godot prints on every clean exit — naming it means anything else that turns
up is a real finding rather than noise to be squinted past.
### Nothing automated can see the screen
Every gate passed — 458 tests, a clean smoke run, no engine errors — while the
entire interface rendered in Godot's default style and none of the UI pack was
visible. A Control inherits its theme from Control *ancestors only*, and the
chain breaks at the first plain `Node` or `CanvasLayer`; every screen in this
game hangs off one. `get_window().theme = ...` set the property, changed
nothing, and looked correct in the code.
It took `tools/screenshot.tscn` — boot the client, save a PNG, open it — to
find. The same run then showed **Fire bound to the right mouse button**, which
was the test suite writing the player's real `user://settings.cfg`, because
rebinding calls `save()` and nothing had redirected the path.
Styling the rest of the interface then produced two more of exactly the same
shape: an invisible slider track and an invisible scrollbar. Both resolved the
right stylebox, reported the right texture, and drew nothing, because a
`Slider` and a `ScrollBar` take their thickness from the stylebox's *minimum
size* — its content margins — and mine were zero. Neither was findable without
looking.
Three lessons, and the last is the general one:
- After touching `src/ui/`, look at it. There is no substitute.
- When a control resolves the right theme item and still looks wrong, check
what Godot derives from that item's *size* rather than its picture.
- A test that exercises a code path which writes to user state **will write to
user state**. Redirect the path in the fixture, and assert the default is
still the real one so the redirect cannot escape.
-25
View File
@@ -84,31 +84,6 @@ system_menu={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_overlay={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_1={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":49,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_2={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":50,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_3={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":51,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
use_slot_4={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":52,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[rendering]
-163
View File
@@ -1,163 +0,0 @@
[gd_resource type="Resource" script_class="BossDef" format=3]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_mkon8"]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_08s2a"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_i3ijt"]
[ext_resource type="Script" path="res://src/sim/patterns/telegraphed_strike_emitter.gd" id="4_c737k"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_s4e7m"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="6_1xgol"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="7_jvosp"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_vujs6"]
[sub_resource type="Resource" id="Resource_y5tlt"]
script = ExtResource("1_mkon8")
item = &"health_potion"
[sub_resource type="Resource" id="Resource_vaonm"]
script = ExtResource("1_mkon8")
item = &"wardens_ration"
instanced = true
[sub_resource type="Resource" id="Resource_ldxsn"]
script = ExtResource("4_c737k")
warn_ticks = 80
burst_count = 10
blast_radius = 66.0
interval = 180
speed = 130.0
lifetime = 240
damage = 130
kind = 3
[sub_resource type="Resource" id="Resource_ik5lx"]
script = ExtResource("5_s4e7m")
count = 3
spread_deg = 18.0
muzzle_offset = 40.0
start_tick = 40
interval = 120
speed = 190.0
radius = 6.0
damage = 110
kind = 2
[sub_resource type="Resource" id="Resource_cfxp7"]
script = ExtResource("2_08s2a")
name = "Call to Prayer"
loop_ticks = 360
telegraph_ticks = 60
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_ldxsn"), SubResource("Resource_ik5lx")])
[sub_resource type="Resource" id="Resource_mrfx8"]
script = ExtResource("6_1xgol")
count = 3
spin_per_shot_deg = 27.0
muzzle_offset = 40.0
interval = 12
speed = 125.0
lifetime = 400
damage = 120
[sub_resource type="Resource" id="Resource_oppcg"]
script = ExtResource("4_c737k")
strikes = 2
warn_ticks = 90
blast_radius = 70.0
pattern_seed = 17
start_tick = 60
interval = 200
speed = 145.0
lifetime = 240
damage = 140
kind = 3
[sub_resource type="Resource" id="Resource_eg17u"]
script = ExtResource("2_08s2a")
name = "Processional"
enter_at_hp_fraction = 0.75
loop_ticks = 480
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_mrfx8"), SubResource("Resource_oppcg")])
move = 3
move_speed = 105.0
waypoints = Array[Vector2]([Vector2(0.18, 0.2), Vector2(0.82, 0.2), Vector2(0.82, 0.8), Vector2(0.18, 0.8)])
waypoint_dwell = 70
[sub_resource type="Resource" id="Resource_bffie"]
script = ExtResource("5_s4e7m")
spread_deg = 34.0
jitter_deg = 2.5
muzzle_offset = 40.0
interval = 90
speed = 200.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_xcmnn"]
script = ExtResource("7_jvosp")
direction = 1
count = 16
gap_width = 4
gap_step = 6
start_tick = 70
interval = 160
speed = 165.0
radius = 8.0
damage = 150
kind = 3
[sub_resource type="Resource" id="Resource_31prv"]
script = ExtResource("2_08s2a")
name = "Antiphon"
enter_at_hp_fraction = 0.45
loop_ticks = 540
telegraph_ticks = 50
damage_taken_mult = 1.12
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_bffie"), SubResource("Resource_xcmnn")])
move = 2
move_speed = 120.0
move_param = 220.0
[sub_resource type="Resource" id="Resource_wuthe"]
script = ExtResource("6_1xgol")
spin_per_shot_deg = 15.0
muzzle_offset = 40.0
interval = 40
speed = 140.0
lifetime = 380
damage = 130
[sub_resource type="Resource" id="Resource_phxte"]
script = ExtResource("4_c737k")
strikes = 4
warn_ticks = 70
burst_count = 14
blast_radius = 72.0
pattern_seed = 91
interval = 150
lifetime = 240
damage = 150
kind = 3
[sub_resource type="Resource" id="Resource_p6400"]
script = ExtResource("2_08s2a")
name = "Final Cadence"
enter_at_hp_fraction = 0.18
loop_ticks = 420
telegraph_ticks = 55
damage_taken_mult = 1.25
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_wuthe"), SubResource("Resource_phxte")])
move = 1
move_speed = 150.0
move_param = 150.0
[resource]
script = ExtResource("8_vujs6")
id = &"cantor"
display_name = "Cantor of the Vault"
max_hp = 28000
radius = 36.0
spawn_pos = Vector2(0, -150)
visual = 1
phases = Array[ExtResource("2_08s2a")]([SubResource("Resource_cfxp7"), SubResource("Resource_eg17u"), SubResource("Resource_31prv"), SubResource("Resource_p6400")])
loot = Array[ExtResource("1_mkon8")]([SubResource("Resource_y5tlt"), SubResource("Resource_vaonm")])
+48 -63
View File
@@ -1,25 +1,15 @@
[gd_resource type="Resource" script_class="BossDef" format=3]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_b0fvw"]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_f5abt"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_tmo3e"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="4_rswgd"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_v02no"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="6_x6ufy"]
[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="7_bbcbd"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_axg03"]
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="1_7m6h6"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="2_b0fvw"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="3_f5abt"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="4_tmo3e"]
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="5_rswgd"]
[ext_resource type="Script" path="res://src/sim/patterns/arc_sweep_emitter.gd" id="6_v02no"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="7_x6ufy"]
[sub_resource type="Resource" id="Resource_s17mk"]
script = ExtResource("1_b0fvw")
item = &"health_potion"
[sub_resource type="Resource" id="Resource_0452f"]
script = ExtResource("1_b0fvw")
item = &"wardens_ration"
instanced = true
[sub_resource type="Resource" id="Resource_1p7x0"]
script = ExtResource("4_rswgd")
[sub_resource type="Resource" id="Resource_bbcbd"]
script = ExtResource("3_f5abt")
count = 14
spin_per_shot_deg = 9.0
muzzle_offset = 46.0
@@ -27,56 +17,54 @@ interval = 45
speed = 135.0
radius = 8.0
lifetime = 420
damage = 140
damage = 14
[sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("5_v02no")
[sub_resource type="Resource" id="Resource_axg03"]
script = ExtResource("4_tmo3e")
spread_deg = 26.0
muzzle_offset = 46.0
start_tick = 120
interval = 150
speed = 210.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_pfjxl"]
script = ExtResource("2_f5abt")
[sub_resource type="Resource" id="Resource_s17mk"]
script = ExtResource("1_7m6h6")
name = "Opening Fold"
loop_ticks = 480
telegraph_ticks = 60
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_1p7x0"), SubResource("Resource_a0mrn")])
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_bbcbd"), SubResource("Resource_axg03")])
[sub_resource type="Resource" id="Resource_txml3"]
script = ExtResource("4_rswgd")
[sub_resource type="Resource" id="Resource_0452f"]
script = ExtResource("3_f5abt")
count = 4
spin_per_shot_deg = 23.0
muzzle_offset = 46.0
interval = 9
speed = 120.0
lifetime = 480
damage = 120
[sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("6_x6ufy")
[sub_resource type="Resource" id="Resource_1p7x0"]
script = ExtResource("5_rswgd")
count = 20
gap_step = 7
start_tick = 90
interval = 170
speed = 175.0
radius = 8.0
damage = 160
damage = 16
kind = 3
[sub_resource type="Resource" id="Resource_4sp1g"]
script = ExtResource("2_f5abt")
[sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("1_7m6h6")
name = "Sliding Lattice"
enter_at_hp_fraction = 0.72
telegraph_ticks = 50
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_txml3"), SubResource("Resource_k1e8f")])
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_0452f"), SubResource("Resource_1p7x0")])
[sub_resource type="Resource" id="Resource_aldxl"]
script = ExtResource("7_bbcbd")
[sub_resource type="Resource" id="Resource_pfjxl"]
script = ExtResource("6_v02no")
arms = 3
bullets_per_arm = 2
arm_spacing = 26.0
@@ -85,10 +73,10 @@ sweep_period = 5.0
interval = 5
speed = 165.0
lifetime = 400
damage = 130
damage = 13
[sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("5_v02no")
[sub_resource type="Resource" id="Resource_txml3"]
script = ExtResource("4_tmo3e")
count = 7
spread_deg = 40.0
jitter_deg = 3.0
@@ -97,11 +85,10 @@ start_tick = 60
interval = 110
speed = 195.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_20c8f"]
script = ExtResource("4_rswgd")
[sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("3_f5abt")
count = 18
spin_per_shot_deg = -14.0
start_tick = 240
@@ -109,18 +96,17 @@ end_tick = 600
interval = 60
speed = 105.0
lifetime = 420
damage = 120
[sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("2_f5abt")
[sub_resource type="Resource" id="Resource_4sp1g"]
script = ExtResource("1_7m6h6")
name = "Reaping Arms"
enter_at_hp_fraction = 0.42
loop_ticks = 720
damage_taken_mult = 1.15
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")])
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_pfjxl"), SubResource("Resource_txml3"), SubResource("Resource_k1e8f")])
[sub_resource type="Resource" id="Resource_7xac7"]
script = ExtResource("4_rswgd")
[sub_resource type="Resource" id="Resource_aldxl"]
script = ExtResource("3_f5abt")
count = 5
spin_per_shot_deg = 31.0
muzzle_offset = 46.0
@@ -128,45 +114,44 @@ interval = 11
speed = 130.0
turn_deg = 0.55
lifetime = 400
damage = 150
damage = 15
[sub_resource type="Resource" id="Resource_3n6oq"]
script = ExtResource("6_x6ufy")
[sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("5_rswgd")
direction = 2
count = 16
start_tick = 40
interval = 130
speed = 200.0
radius = 8.0
damage = 180
damage = 18
kind = 3
[sub_resource type="Resource" id="Resource_8ppic"]
script = ExtResource("5_v02no")
[sub_resource type="Resource" id="Resource_20c8f"]
script = ExtResource("4_tmo3e")
count = 3
spread_deg = 10.0
start_tick = 100
interval = 80
speed = 300.0
radius = 5.0
damage = 160
damage = 16
kind = 2
[sub_resource type="Resource" id="Resource_yqxmm"]
script = ExtResource("2_f5abt")
[sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("1_7m6h6")
name = "Collapse"
enter_at_hp_fraction = 0.16
loop_ticks = 420
telegraph_ticks = 60
damage_taken_mult = 1.3
emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubResource("Resource_3n6oq"), SubResource("Resource_8ppic")])
emitters = Array[ExtResource("2_b0fvw")]([SubResource("Resource_aldxl"), SubResource("Resource_3fm5w"), SubResource("Resource_20c8f")])
[resource]
script = ExtResource("8_axg03")
script = ExtResource("7_x6ufy")
id = &"warden"
display_name = "Warden of the Fold"
max_hp = 36000
max_hp = 3600
radius = 42.0
spawn_pos = Vector2(0, -150)
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")])
loot = Array[ExtResource("1_b0fvw")]([SubResource("Resource_s17mk"), SubResource("Resource_0452f")])
phases = Array[ExtResource("1_7m6h6")]([SubResource("Resource_s17mk"), SubResource("Resource_a0mrn"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56")])
+8 -16
View File
@@ -1,30 +1,22 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_bjhtk"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e6oj1"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_vpd04"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_k36hi"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_801wv"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_bjhtk"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_e6oj1"]
[sub_resource type="Resource" id="Resource_rsrg2"]
script = ExtResource("2_e6oj1")
[sub_resource type="Resource" id="Resource_vpd04"]
script = ExtResource("2_bjhtk")
count = 3
spread_deg = 20.0
interval = 120
radius = 6.0
lifetime = 240
damage = 100
[sub_resource type="Resource" id="Resource_lnbnj"]
script = ExtResource("3_vpd04")
item = &"health_potion"
chance = 0.08
damage = 10
[resource]
script = ExtResource("4_k36hi")
script = ExtResource("3_e6oj1")
id = &"drifter"
display_name = "Drifter"
max_hp = 400
speed = 55.0
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")])
emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
pattern_loop_ticks = 120
loot = Array[ExtResource("3_vpd04")]([SubResource("Resource_lnbnj")])
+4 -6
View File
@@ -1,15 +1,13 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_i7w5w"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="2_74ern"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_2tv1l"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_ajtny"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_i7w5w"]
[resource]
script = ExtResource("3_2tv1l")
script = ExtResource("2_i7w5w")
id = &"dummy"
display_name = "Target Dummy"
max_hp = 1000
max_hp = 100000
radius = 20.0
move = 0
visual = 3
indestructible = true
+9 -16
View File
@@ -1,36 +1,29 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_e7xon"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_ehenj"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_tpjo4"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_yp71e"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_t1lyl"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e7xon"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_ehenj"]
[sub_resource type="Resource" id="Resource_oeksl"]
script = ExtResource("2_ehenj")
[sub_resource type="Resource" id="Resource_tpjo4"]
script = ExtResource("2_e7xon")
spread_deg = 62.0
muzzle_offset = 10.0
interval = 45
speed = 260.0
radius = 6.0
lifetime = 18
damage = 140
damage = 14
kind = 3
[sub_resource type="Resource" id="Resource_ep81w"]
script = ExtResource("3_tpjo4")
item = &"health_potion"
chance = 0.08
[resource]
script = ExtResource("4_yp71e")
script = ExtResource("3_ehenj")
id = &"stalker"
display_name = "Stalker"
max_hp = 300
max_hp = 30
radius = 12.0
move = 3
speed = 95.0
retarget_interval = 20
visual = 2
emitters = Array[ExtResource("1_e7xon")]([SubResource("Resource_oeksl")])
emitters = Array[ExtResource("1_t1lyl")]([SubResource("Resource_tpjo4")])
pattern_loop_ticks = 45
loot = Array[ExtResource("3_tpjo4")]([SubResource("Resource_ep81w")])
+8 -16
View File
@@ -1,32 +1,24 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_7m5d5"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_5udip"]
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="3_hc01i"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_6rb75"]
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="1_asksf"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_7m5d5"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_5udip"]
[sub_resource type="Resource" id="Resource_kv0ny"]
script = ExtResource("2_5udip")
[sub_resource type="Resource" id="Resource_hc01i"]
script = ExtResource("2_7m5d5")
count = 10
spin_per_shot_deg = 18.0
interval = 150
speed = 130.0
damage = 120
[sub_resource type="Resource" id="Resource_fttpl"]
script = ExtResource("3_hc01i")
item = &"health_potion"
chance = 0.08
[resource]
script = ExtResource("4_6rb75")
script = ExtResource("3_5udip")
id = &"turret"
display_name = "Turret"
max_hp = 700
max_hp = 70
radius = 16.0
move = 0
speed = 0.0
visual = 1
emitters = Array[ExtResource("1_7m5d5")]([SubResource("Resource_kv0ny")])
emitters = Array[ExtResource("1_asksf")]([SubResource("Resource_hc01i")])
pattern_loop_ticks = 150
loot = Array[ExtResource("3_hc01i")]([SubResource("Resource_fttpl")])
+3 -17
View File
@@ -7,25 +7,11 @@ extends Resource
@export var display_name: String = "Boss"
@export var max_hp: int = 4000
@export var radius: float = 44.0
## Stationary bosses are the MVP shape; the field exists so a later boss can
## move without changing the runtime.
@export var stationary: bool = true
@export var spawn_pos := Vector2(0.0, -140.0)
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
## the simulation never learns that art exists.
@export var visual: int = 0
@export var phases: Array[BossPhase] = []
## What the kill leaves behind. Bosses are the guaranteed source: a run that
## reaches the end should always be worth something, so unlike trash loot these
## entries are normally chance 1.0.
@export var loot: Array[LootDrop] = []
## Whether any phase moves this boss. Derived from the phases rather than
## stored alongside them: a flag that said "stationary" while a phase walked
## around would be a second source of truth, and the wrong one.
func moves() -> bool:
for phase in phases:
if phase != null and phase.moves():
return true
return false
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
-35
View File
@@ -16,38 +16,3 @@ extends Resource
## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = []
@export_group("Movement")
## How the boss moves during this phase. Movement is a property of the PHASE,
## not of the boss: a fight that stands still and then starts hunting you is
## one boss with two phases, and expressing it any other way would put a
## per-boss branch in the simulation.
@export var move: Move = Move.STATIC
## Units per second. Zero is equivalent to STATIC.
@export var move_speed: float = 0.0
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
## hold from you -- closing all the way would mean a boss you cannot see past.
@export var move_param: float = 120.0
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
## right.
@export var waypoints: Array[Vector2] = []
## Ticks spent standing at each waypoint before moving on.
@export var waypoint_dwell: int = 60
enum Move {
## Never moves. Every phase written before bosses could move.
STATIC,
## Circles the centre of its arena at [member move_param] radius.
ORBIT,
## Closes on the nearest player, holding [member move_param] distance.
CHASE,
## Walks a fixed circuit of [member waypoints], pausing at each.
WAYPOINTS,
}
## Whether this phase actually moves the boss. Derived rather than stored, so a
## phase cannot claim to move and then sit still.
func moves() -> bool:
return move != Move.STATIC and move_speed > 0.0
-49
View File
@@ -1,49 +0,0 @@
class_name DungeonDef
extends Resource
## A kind of dungeon run. Same generator, same rooms, same enemies -- what
## differs is how tough its contents are and how freely they drop.
##
## Deliberately a set of multipliers over the shared content rather than a
## parallel copy of it: a second dungeon that duplicated `Content` would drift
## from the first the moment anything was tuned, and then "identical but
## easier" would quietly stop being true.
@export var id: StringName = &"dungeon"
@export var display_name: String = "Dungeon"
## One line for the portal label, so a player standing in the hub can tell the
## entrances apart without reading a wiki.
@export var subtitle: String = ""
## Which hand-authored boss arena this run ends in, and therefore which boss it
## has -- see [method Rooms.boss_for_arena]. Empty means "whichever the seed
## rolls", which only the test harness wants.
@export var arena: StringName = &""
@export var enemy_hp_mult: float = 1.0
@export var boss_hp_mult: float = 1.0
## Multiplies every loot chance, clamped at certain. Guaranteed drops stay
## guaranteed; there is nothing above 1.0 to reach for.
@export var loot_chance_mult: float = 1.0
## Portal colour, and the tint of the dungeon's name on the HUD.
@export var tint := Color(0.5, 0.9, 1.0)
## Scale a freshly built [EnemyDef] in place.
##
## Safe to mutate because every caller of `Content.enemy()` gets a new object --
## the content functions construct one per call. If that ever changes, this has
## to duplicate first, or one easy dungeon would nerf every hard one.
func apply_to_enemy(def: EnemyDef) -> EnemyDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * enemy_hp_mult))
_scale_loot(def.loot)
return def
func apply_to_boss(def: BossDef) -> BossDef:
def.max_hp = maxi(1, roundi(float(def.max_hp) * boss_hp_mult))
_scale_loot(def.loot)
return def
func _scale_loot(table: Array[LootDrop]) -> void:
for entry in table:
if entry != null:
entry.chance = clampf(entry.chance * loot_chance_mult, 0.0, 1.0)
-1
View File
@@ -1 +0,0 @@
uid://cqert4nr662vl
-8
View File
@@ -36,11 +36,3 @@ enum Move {
@export var emitters: Array[BulletEmitter] = []
## The emitter timeline wraps at this many ticks.
@export var pattern_loop_ticks: int = 240
## Never takes damage. Exists for the hub's practice target, whose whole job is
## to still be there tomorrow -- expressing that as a flag rather than as a
## large health pool means it cannot be worn down by a patient player, and
## keeps its health inside the u16 the snapshot sends.
@export var indestructible: bool = false
## What this enemy may leave behind. Rolled once per entry on death, against
## the world's own RNG. Empty for anything that should drop nothing.
@export var loot: Array[LootDrop] = []
-30
View File
@@ -1,30 +0,0 @@
class_name ItemDef
extends Resource
## Data-only description of an item, in the same spirit as [EnemyDef]: adding an
## item is a table entry in [Items], never a branch in the simulation.
##
## Items are deliberately thin. A slot holds an id and nothing else -- no
## charges, no durability, no stack count -- because everything the game
## currently needs is expressible as "one id per slot", and the wire format,
## the persistence record and the UI all get simpler for it. When something
## needs a count, add it here rather than teaching four layers about it.
enum Effect {
## Consumed and does nothing. Not a placeholder: the boss ration exists to
## prove the player-instanced loot path works end to end, and it can only
## do that if using it is a real, observable transaction.
NONE,
## Restores [member effect_value] percent of MAXIMUM health, so a potion
## keeps its value at level 15 instead of becoming a rounding error.
HEAL,
}
@export var id: StringName = &"item"
@export var display_name: String = "Item"
@export var effect: Effect = Effect.NONE
@export var effect_value: float = 0.0
## Index into the renderer's item icon table. Same idea as [member
## EnemyDef.visual]: the simulation never learns that art exists.
@export var visual: int = 0
## HUD tint. View-only, kept here so one table describes the whole item.
@export var tint := Color(0.85, 0.85, 0.9)
-1
View File
@@ -1 +0,0 @@
uid://t2fhc2hd1575
-28
View File
@@ -1,28 +0,0 @@
class_name LootDrop
extends Resource
## One entry in an enemy's or boss's loot table.
##
## [member instanced] is the interesting field, and it is the reason loot has
## two visibilities rather than one:
##
## - false -- a single entity in the instance that everyone can see and the
## first to reach takes. This is the default, and it is what makes loot a
## thing a party negotiates over.
## - true -- one entity per eligible player, each visible only to its owner.
## Nobody competes, nobody is denied. The server filters these out of every
## other peer's snapshot, so it is an interest-management rule and not merely
## a UI convention: a modified client is not told the others exist.
@export var item: StringName = &""
## Probability in [0, 1], rolled once per kill against the world's own RNG.
@export var chance: float = 1.0
@export var instanced: bool = false
static func make(item_id: StringName, drop_chance: float,
player_instanced: bool = false) -> LootDrop:
var d := LootDrop.new()
d.item = item_id
d.chance = drop_chance
d.instanced = player_instanced
return d
-1
View File
@@ -1 +0,0 @@
uid://de021jelm6hcg
-59
View File
@@ -1,59 +0,0 @@
class_name UpgradeDef
extends Resource
## One upgrade, as data. Every field here is a modifier the simulation already
## knows how to apply, so adding an upgrade is a table entry and never a branch
## in [SimWorld] -- the same rule bosses follow.
##
## Note what is NOT here: the flat +5% damage every upgrade carries. That is a
## property of *taking an upgrade*, not of any particular one, so it lives in
## [PlayerStats] where it is applied once per upgrade held. Baking it into each
## definition would mean seven places to change it and seven places to get it
## wrong.
enum Rarity { COMMON, UNCOMMON, RARE, LEGENDARY }
@export var id: StringName = &"upgrade"
@export var display_name: String = "Upgrade"
## Shown on the choice card. Says what it does, in the player's terms.
@export var description: String = ""
@export var rarity: Rarity = Rarity.COMMON
# --- Damage -----------------------------------------------------------------
## Added to the additive pool: base x (1 + sum of these) x product of the
## multiplicative ones. Negative for the upgrades that trade damage away.
@export var damage_add: float = 0.0
## Multiplied in after the pool. Sniper is the only thing that uses this, and
## the brief calls it out as multiplicative specifically so it stays sharp no
## matter how many additive percentages have piled up.
@export var damage_mult: float = 1.0
# --- Everything else --------------------------------------------------------
## Multiplies the character's levelled maximum health, so the cost of trading
## health away scales instead of fading out by level 15.
@export var max_hp_mult: float = 1.0
## Below 1.0 means slower. Applied to the cooldown as a division, so 0.5 here is
## genuinely half the shots per second.
@export var fire_rate_mult: float = 1.0
@export var bullet_speed_mult: float = 1.0
## Extra projectiles fanned out to the sides of the aim.
@export var side_shots: int = 0
## Extra projectiles parallel to the aim, offset sideways.
@export var parallel_shots: int = 0
## How many times one shot may split on hitting something. The brief's "cannot
## split twice unless the upgrade is taken again" is exactly this being a count
## rather than a flag.
@export var split_charges: int = 0
## Fraction of a hit's damage dealt again over the poison window.
@export var poison_fraction: float = 0.0
## Chance, per tick, that a shot deletes an enemy projectile it is passing
## through.
@export var erase_chance: float = 0.0
static func rarity_name(r: Rarity) -> String:
match r:
Rarity.COMMON: return "common"
Rarity.UNCOMMON: return "uncommon"
Rarity.RARE: return "rare"
Rarity.LEGENDARY: return "legendary"
return "?"
-1
View File
@@ -1 +0,0 @@
uid://d0mhvxc26ff0
+9 -100
View File
@@ -22,10 +22,6 @@ var server: ServerRuntime = null
var client: ClientRuntime = null
var last_error: String = ""
## How this build establishes identity. One line to swap for a Steam provider:
## everything downstream deals only in the 64-bit account id it produces.
var auth: AuthProvider = LocalAuthProvider.new()
func _set_state(s: State) -> void:
state = s
@@ -120,7 +116,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
func _on_connected() -> void:
_set_state(State.ONLINE)
c_hello.rpc_id(1, Protocol.VERSION, auth.get_ticket())
c_hello.rpc_id(1, Protocol.VERSION, GameOpts.player_name)
func _on_connect_failed() -> void:
@@ -146,7 +142,7 @@ func start_local_client() -> void:
client = ClientRuntime.new()
client.name = "Client"
add_child(client)
server.on_hello(LOCAL_PEER, Protocol.VERSION, auth.get_ticket())
server.on_hello(LOCAL_PEER, Protocol.VERSION, GameOpts.player_name)
func _is_local(peer_id: int) -> bool:
@@ -169,13 +165,12 @@ func send_welcome(peer_id: int) -> void:
## hack with no work required; tiles are streamed instead (send_map_chunks).
func send_enter_instance(peer_id: int, id: int, kind: int, server_tick: int,
boss_id: String, spawn: Vector2, map_w: int, map_h: int,
portals: PackedByteArray, dungeon: String, npc: Vector2) -> void:
portal: Vector2) -> void:
if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon, npc)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
map_w, map_h, portals, dungeon, npc)
map_w, map_h, portal)
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
@@ -199,27 +194,6 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
s_events.rpc_id(peer_id, data)
func send_characters(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_characters(data)
else:
s_characters.rpc_id(peer_id, data)
func send_select_result(peer_id: int, result: int, reason: String) -> void:
if _is_local(peer_id):
client.on_select_result(result, reason)
else:
s_select_result.rpc_id(peer_id, result, reason)
func send_upgrades(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_upgrades(data)
else:
s_upgrades.rpc_id(peer_id, data)
func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_roster(data)
@@ -234,30 +208,6 @@ func send_reject(peer_id: int, reason: String) -> void:
s_reject.rpc_id(peer_id, reason)
func select_character(character_id: String) -> void:
if server != null:
server.on_select_character(LOCAL_PEER, character_id)
elif state == State.ONLINE:
c_select_character.rpc_id(1, character_id)
## Spend a level-up. Intent only: the index names one of the three options the
## SERVER put on the table, and the server checks the player is standing at the
## NPC before it means anything.
func choose_upgrade(index: int) -> void:
if server != null:
server.on_choose_upgrade(LOCAL_PEER, index)
elif state == State.ONLINE:
c_choose_upgrade.rpc_id(1, index)
func create_character(character_name: String) -> void:
if server != null:
server.on_create_character(LOCAL_PEER, character_name)
elif state == State.ONLINE:
c_create_character.rpc_id(1, character_name)
func send_input(data: PackedByteArray) -> void:
if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all
@@ -268,31 +218,10 @@ func send_input(data: PackedByteArray) -> void:
# --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1)
func c_hello(version: int, ticket: PackedByteArray) -> void:
func c_hello(version: int, display_name: String) -> void:
if server == null:
return
server.on_hello(multiplayer.get_remote_sender_id(), version, ticket)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_select_character(character_id: String) -> void:
if server == null:
return
server.on_select_character(multiplayer.get_remote_sender_id(), character_id)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_create_character(character_name: String) -> void:
if server == null:
return
server.on_create_character(multiplayer.get_remote_sender_id(), character_name)
@rpc("any_peer", "call_remote", "reliable", 1)
func c_choose_upgrade(index: int) -> void:
if server == null:
return
server.on_choose_upgrade(multiplayer.get_remote_sender_id(), index)
server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4)
@@ -314,12 +243,10 @@ func s_welcome(peer_id: int, _version: int) -> void:
@rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray,
dungeon: String, npc: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
if client == null:
return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
map_h, portals, dungeon, npc)
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, map_h, portal)
@rpc("authority", "call_remote", "reliable", 1)
@@ -335,24 +262,6 @@ func s_reject(reason: String) -> void:
_set_state(State.FAILED)
@rpc("authority", "call_remote", "reliable", 1)
func s_characters(data: PackedByteArray) -> void:
if client != null:
client.on_characters(data)
@rpc("authority", "call_remote", "reliable", 1)
func s_select_result(result: int, reason: String) -> void:
if client != null:
client.on_select_result(result, reason)
@rpc("authority", "call_remote", "reliable", 1)
func s_upgrades(data: PackedByteArray) -> void:
if client != null:
client.on_upgrades(data)
@rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void:
if client != null:
+20 -245
View File
@@ -13,22 +13,6 @@ const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden"
const BOSS_CANTOR := &"cantor"
## Every id this file defines. Kept here so the export tool, the renderer and
## the tests all iterate the same list -- each of them used to hand-maintain
## its own copy, and the export tool had already gone stale and stopped writing
## the second boss.
const ALL_ENEMIES: Array[StringName] = [
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
]
const ALL_BOSSES: Array[StringName] = [
BOSS_WARDEN, BOSS_CANTOR,
]
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
## dungeon run should be survivable on what it hands you, but never comfortably.
const TRASH_POTION_CHANCE := 0.08
static func enemy(id: StringName) -> EnemyDef:
@@ -44,7 +28,6 @@ static func enemy(id: StringName) -> EnemyDef:
static func boss(id: StringName) -> BossDef:
match id:
BOSS_WARDEN: return warden()
BOSS_CANTOR: return cantor()
push_error("unknown boss id: %s" % id)
return warden()
@@ -56,7 +39,7 @@ static func drifter() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DRIFTER
d.display_name = "Drifter"
d.max_hp = 400
d.max_hp = 40
d.radius = 14.0
d.move = EnemyDef.Move.DRIFT
d.speed = 55.0
@@ -70,11 +53,10 @@ static func drifter() -> EnemyDef:
fan.spread_deg = 20.0
fan.speed = 150.0
fan.radius = 6.0
fan.damage = 100
fan.damage = 10
fan.lifetime = 240
fan.kind = SimConfig.KIND_ORB
d.emitters = [fan]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
@@ -83,7 +65,7 @@ static func turret() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_TURRET
d.display_name = "Turret"
d.max_hp = 700
d.max_hp = 70
d.radius = 16.0
d.move = EnemyDef.Move.STATIC
d.speed = 0.0
@@ -97,11 +79,10 @@ static func turret() -> EnemyDef:
ring.spin_per_shot_deg = 18.0
ring.speed = 130.0
ring.radius = 7.0
ring.damage = 120
ring.damage = 12
ring.lifetime = 300
ring.kind = SimConfig.KIND_ORB
d.emitters = [ring]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
@@ -117,7 +98,7 @@ static func stalker() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_STALKER
d.display_name = "Stalker"
d.max_hp = 300
d.max_hp = 30
d.radius = 12.0
d.move = EnemyDef.Move.APPROACH
d.speed = 95.0
@@ -132,29 +113,22 @@ static func stalker() -> EnemyDef:
lunge.spread_deg = 62.0
lunge.speed = 260.0
lunge.radius = 6.0
lunge.damage = 140
lunge.damage = 14
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
# player distance at any range you would call "not point blank".
lunge.lifetime = 18
lunge.muzzle_offset = 10.0
lunge.kind = SimConfig.KIND_HEAVY
d.emitters = [lunge]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
##
## Indestructible by flag rather than by a huge health pool. The old 100000 was
## already past the u16 the snapshot sends enemy health in, and after the x10
## rescale a determined player could have destroyed the hub's only practice
## target for everyone until the server restarted.
## Lobby target dummy: inert, tough, so players can feel out the gun.
static func dummy() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DUMMY
d.display_name = "Target Dummy"
d.max_hp = 1000
d.indestructible = true
d.max_hp = 100000
d.radius = 20.0
d.move = EnemyDef.Move.STATIC
d.visual = 3
@@ -170,19 +144,11 @@ static func warden() -> BossDef:
var b := BossDef.new()
b.id = BOSS_WARDEN
b.display_name = "Warden of the Fold"
b.max_hp = 36000
b.max_hp = 3600
b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 0
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
# Guaranteed, and deliberately one of each visibility. The potion is shared,
# so a party still has something to divide up; the ration is instanced, so
# every player who survived the fight leaves with the trophy and nobody has
# to race for it. Between them they exercise both loot paths on every kill.
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
@@ -201,7 +167,7 @@ static func _warden_p1() -> BossPhase:
ring.spin_per_shot_deg = 9.0
ring.speed = 135.0
ring.radius = 8.0
ring.damage = 140
ring.damage = 14
ring.lifetime = 420
ring.muzzle_offset = 46.0
@@ -212,7 +178,7 @@ static func _warden_p1() -> BossPhase:
fan.spread_deg = 26.0
fan.speed = 210.0
fan.radius = 6.0
fan.damage = 120
fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 46.0
@@ -235,7 +201,7 @@ static func _warden_p2() -> BossPhase:
spiral.spin_per_shot_deg = 23.0
spiral.speed = 120.0
spiral.radius = 7.0
spiral.damage = 120
spiral.damage = 12
spiral.lifetime = 480
spiral.muzzle_offset = 46.0
@@ -248,7 +214,7 @@ static func _warden_p2() -> BossPhase:
wall.gap_step = 7
wall.speed = 175.0
wall.radius = 8.0
wall.damage = 160
wall.damage = 16
wall.lifetime = 300
wall.kind = SimConfig.KIND_HEAVY
@@ -275,7 +241,7 @@ static func _warden_p3() -> BossPhase:
arms.sweep_period = 5.0
arms.speed = 165.0
arms.radius = 7.0
arms.damage = 130
arms.damage = 13
arms.lifetime = 400
var fan := AimedSpreadEmitter.new()
@@ -287,7 +253,7 @@ static func _warden_p3() -> BossPhase:
fan.speed = 195.0
fan.edge_speed_bonus = 0.25
fan.radius = 6.0
fan.damage = 120
fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE
var counter_ring := RingEmitter.new()
@@ -298,7 +264,7 @@ static func _warden_p3() -> BossPhase:
counter_ring.spin_per_shot_deg = -14.0
counter_ring.speed = 105.0
counter_ring.radius = 7.0
counter_ring.damage = 120
counter_ring.damage = 12
counter_ring.lifetime = 420
p.emitters = [arms, fan, counter_ring]
@@ -322,7 +288,7 @@ static func _warden_p4() -> BossPhase:
curve.speed = 130.0
curve.turn_deg = 0.55
curve.radius = 7.0
curve.damage = 150
curve.damage = 15
curve.lifetime = 400
curve.muzzle_offset = 46.0
@@ -335,7 +301,7 @@ static func _warden_p4() -> BossPhase:
walls.gap_step = 5
walls.speed = 200.0
walls.radius = 8.0
walls.damage = 180
walls.damage = 18
walls.kind = SimConfig.KIND_HEAVY
var snipe := AimedSpreadEmitter.new()
@@ -345,199 +311,8 @@ static func _warden_p4() -> BossPhase:
snipe.spread_deg = 10.0
snipe.speed = 300.0
snipe.radius = 5.0
snipe.damage = 160
snipe.damage = 16
snipe.kind = SimConfig.KIND_NEEDLE
p.emitters = [curve, walls, snipe]
return p
# --- The Cantor of the Vault ------------------------------------------------
## The second boss, and the one that proves the format stretched. The Warden is
## four phases of standing still and filling space; the Cantor walks.
##
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
## see over but not shoot through -- so its patterns are about position rather
## than about density. Everything below is data: the simulation gained a
## movement mode and a telegraphing emitter, and no line anywhere knows this
## boss exists.
static func cantor() -> BossDef:
var b := BossDef.new()
b.id = BOSS_CANTOR
b.display_name = "Cantor of the Vault"
b.max_hp = 28000
b.radius = 36.0
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 1
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
## three spots, fills them. The whole phase exists to teach the marker.
static func _cantor_p1() -> BossPhase:
var p := BossPhase.new()
p.name = "Call to Prayer"
p.enter_at_hp_fraction = 1.0
p.loop_ticks = 360
p.telegraph_ticks = 60
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 180
strikes.warn_ticks = 80
strikes.strikes = 3
strikes.burst_count = 10
strikes.blast_radius = 66.0
strikes.speed = 130.0
strikes.radius = 7.0
strikes.damage = 130
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
var fan := AimedSpreadEmitter.new()
fan.start_tick = 40
fan.interval = 120
fan.count = 3
fan.spread_deg = 18.0
fan.speed = 190.0
fan.radius = 6.0
fan.damage = 110
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
p.emitters = [strikes, fan]
return p
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
## at each, so the safe half of the room keeps changing while a spiral fills
## whatever it has left behind.
static func _cantor_p2() -> BossPhase:
var p := BossPhase.new()
p.name = "Processional"
p.enter_at_hp_fraction = 0.75
p.loop_ticks = 480
p.telegraph_ticks = 45
p.move = BossPhase.Move.WAYPOINTS
p.move_speed = 105.0
p.waypoint_dwell = 70
# Fractions of the arena, so the same walk works in a room of any size.
p.waypoints = [
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
]
var spiral := RingEmitter.new()
spiral.interval = 12
spiral.count = 3
spiral.spin_per_shot_deg = 27.0
spiral.speed = 125.0
spiral.radius = 7.0
spiral.damage = 120
spiral.lifetime = 400
spiral.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.start_tick = 60
strikes.interval = 200
strikes.warn_ticks = 90
strikes.strikes = 2
strikes.burst_count = 12
strikes.blast_radius = 70.0
strikes.pattern_seed = 17
strikes.speed = 145.0
strikes.radius = 7.0
strikes.damage = 140
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [spiral, strikes]
return p
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
## fight stays readable, and the aimed fire punishes standing still while the
## walls punish running in a straight line.
static func _cantor_p3() -> BossPhase:
var p := BossPhase.new()
p.name = "Antiphon"
p.enter_at_hp_fraction = 0.45
p.loop_ticks = 540
p.telegraph_ticks = 50
p.damage_taken_mult = 1.12
p.move = BossPhase.Move.CHASE
p.move_speed = 120.0
p.move_param = 220.0
var fan := AimedSpreadEmitter.new()
fan.interval = 90
fan.count = 5
fan.spread_deg = 34.0
fan.jitter_deg = 2.5
fan.speed = 200.0
fan.radius = 6.0
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
var walls := WallGapEmitter.new()
walls.start_tick = 70
walls.interval = 160
walls.direction = 1
walls.count = 16
walls.gap_width = 4
walls.gap_step = 6
walls.speed = 165.0
walls.radius = 8.0
walls.damage = 150
walls.lifetime = 300
walls.kind = SimConfig.KIND_HEAVY
p.emitters = [fan, walls]
return p
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
## once, which is the point: both were added for this fight and this is where
## they have to work together.
static func _cantor_p4() -> BossPhase:
var p := BossPhase.new()
p.name = "Final Cadence"
p.enter_at_hp_fraction = 0.18
p.loop_ticks = 420
p.telegraph_ticks = 55
p.damage_taken_mult = 1.25
p.move = BossPhase.Move.ORBIT
p.move_speed = 150.0
p.move_param = 150.0
var ring := RingEmitter.new()
ring.interval = 40
ring.count = 12
ring.spin_per_shot_deg = 15.0
ring.speed = 140.0
ring.radius = 7.0
ring.damage = 130
ring.lifetime = 380
ring.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 150
strikes.warn_ticks = 70
strikes.strikes = 4
strikes.burst_count = 14
strikes.blast_radius = 72.0
strikes.pattern_seed = 91
strikes.speed = 150.0
strikes.radius = 7.0
strikes.damage = 150
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [ring, strikes]
return p
-104
View File
@@ -1,104 +0,0 @@
class_name Dungeons
extends RefCounted
## The kinds of dungeon run, defined in code like everything else in
## `src/content/`.
##
## [constant ORDER] does double duty: it is the wire order for the dungeon id,
## and it is the order the hub's portals are assigned. The Nth `P` marker in the
## lobby stamp (reading order) opens the Nth entry here, so adding a dungeon is
## one entry plus one marker.
const STANDARD := &"warden_descent"
const VAULT := &"choir_vault"
const PROVING := &"proving_grounds"
const ORDER: Array[StringName] = [
STANDARD,
VAULT,
PROVING,
]
static func default_id() -> StringName:
return STANDARD
static func get_def(id: StringName) -> DungeonDef:
match id:
STANDARD: return standard()
VAULT: return vault()
PROVING: return proving_grounds()
return null
## Falls back to the standard run rather than to null. An id off the wire has to
## resolve to something playable, and "the normal dungeon" is the safe answer.
static func get_or_default(id: StringName) -> DungeonDef:
var d := get_def(id)
return d if d != null else standard()
static func index_of(id: StringName) -> int:
return maxi(ORDER.find(id), 0)
static func by_index(index: int) -> StringName:
if index < 0 or index >= ORDER.size():
return default_id()
return ORDER[index]
# --- The dungeons -----------------------------------------------------------
## The real thing. Every multiplier is 1.0, which is the point: this is the
## baseline the other definitions are described against.
static func standard() -> DungeonDef:
var d := DungeonDef.new()
d.id = STANDARD
d.display_name = "Warden's Descent"
d.subtitle = "the Warden of the Fold"
d.arena = &"warden_hall"
d.tint = Color(0.5, 0.9, 1.0)
return d
## The second boss's own run. Same generator and the same enemies as the
## Descent; what differs is the arena it ends in, and therefore who is waiting.
##
## A dedicated entrance rather than a coin flip on the run's seed: which boss
## you are about to fight is the single thing a player decides before walking
## into a dungeon, and rolling it for them makes that decision unavailable.
static func vault() -> DungeonDef:
var d := DungeonDef.new()
d.id = VAULT
d.display_name = "The Choir Vault"
d.subtitle = "the Cantor of the Vault"
d.arena = &"choir_vault"
d.tint = Color(0.75, 0.6, 1.0)
return d
## A test harness you can walk into.
##
## Same generator, same rooms, same enemies and the same Warden -- everything
## dies far faster and drops far more often, so a manual pass over the loot,
## the inventory and all four boss phases takes a couple of minutes instead of a
## quarter of an hour. Being reachable from the hub rather than hidden behind a
## launch flag is most of the value: you can compare the two back to back in one
## session without restarting the server.
static func proving_grounds() -> DungeonDef:
var d := DungeonDef.new()
d.id = PROVING
d.display_name = "Proving Grounds"
d.subtitle = "for testing -- fragile, generous"
# Deliberately left to the seed. The harness exists to reach a fight fast,
# and either fight will do -- runs here last a couple of minutes, so the one
# you want is one re-entry away.
d.arena = &""
d.enemy_hp_mult = 0.2
d.boss_hp_mult = 0.08
# 0.08 -> 0.8 for trash. High enough that a handful of kills fills a bag,
# which is what makes the four-slot limit and dropping testable at all.
d.loot_chance_mult = 10.0
d.tint = Color(1.0, 0.75, 0.35)
return d
-1
View File
@@ -1 +0,0 @@
uid://dw7j78sn8obvw
-81
View File
@@ -1,81 +0,0 @@
class_name Items
extends RefCounted
## Every item in the game, defined in code for the same reasons [Content] is.
##
## [constant ORDER] is load-bearing: an item's position in it is the byte that
## rides the snapshot and every item event. Item ids are strings and a slot is
## sent 20 times a second per player, so the wire carries the index instead.
## Append to the list, never reorder it -- and if you do reorder it, bump
## [constant Protocol.VERSION], because every client would otherwise decode a
## potion as a ration.
const NONE := &""
const HEALTH_POTION := &"health_potion"
const WARDENS_RATION := &"wardens_ration"
## Wire order. Index 0 is reserved for "empty slot" / "no item", so an id's
## wire value is its position here plus one.
const ORDER: Array[StringName] = [
HEALTH_POTION,
WARDENS_RATION,
]
static func get_def(id: StringName) -> ItemDef:
match id:
HEALTH_POTION: return health_potion()
WARDENS_RATION: return wardens_ration()
return null
## Wire value for an id. 0 for an empty slot or an id this build does not know,
## which is the safe direction: an unknown item reads as nothing rather than as
## whatever happens to sit at that index.
static func index_of(id: StringName) -> int:
var at := ORDER.find(id)
return at + 1 if at >= 0 else 0
static func by_index(index: int) -> StringName:
if index <= 0 or index > ORDER.size():
return NONE
return ORDER[index - 1]
static func display_name_of(id: StringName) -> String:
var def := get_def(id)
return def.display_name if def != null else ""
# --- The items --------------------------------------------------------------
## The only item with an effect. Percentage-based so it stays meaningful across
## the whole level range -- a flat 40 hp would be a full heal at level 1 and
## noise at level 15.
static func health_potion() -> ItemDef:
var d := ItemDef.new()
d.id = HEALTH_POTION
d.display_name = "Health Potion"
d.effect = ItemDef.Effect.HEAL
d.effect_value = 40.0
d.visual = 0
d.tint = Color(0.95, 0.4, 0.42)
return d
## Dropped by every boss, one per player who was alive for the kill, and does
## nothing whatsoever when used.
##
## It is not filler. Player-instanced loot is a different code path from shared
## loot -- a separate entity per owner, filtered per peer on the wire -- and the
## cheapest way to keep that path honest is to have something in the game that
## uses it every single boss kill. A trophy nobody has to fight over is exactly
## the right shape for that job.
static func wardens_ration() -> ItemDef:
var d := ItemDef.new()
d.id = WARDENS_RATION
d.display_name = "Warden's Ration"
d.effect = ItemDef.Effect.NONE
d.visual = 1
d.tint = Color(0.95, 0.82, 0.35)
return d
-1
View File
@@ -1 +0,0 @@
uid://d07ue5tca3xt8
+6 -23
View File
@@ -22,11 +22,10 @@ const LEGEND := {
"P": MapGrid.Kind.FLOOR,
"S": MapGrid.Kind.FLOOR,
"T": MapGrid.Kind.FLOOR,
"U": MapGrid.Kind.FLOOR,
}
## Legend characters that record a position rather than only painting a tile.
const MARKERS := ["B", "D", "P", "S", "T", "U"]
const MARKERS := ["B", "D", "P", "S", "T"]
## Wide, with pillars to break the Warden's rings and pits that shape where you
@@ -69,16 +68,6 @@ static func choir_vault() -> PackedStringArray:
])
## Which boss each arena belongs to. Kept beside the stamps because the pairing
## is a content decision, not a generation one: the Cantor's patterns assume
## the vault's barricades the way the Warden's assume the hall's pits.
static func boss_for_arena(arena: StringName) -> StringName:
match arena:
&"warden_hall": return Content.BOSS_WARDEN
&"choir_vault": return Content.BOSS_CANTOR
return Content.BOSS_WARDEN
static func size_of(stamp: PackedStringArray) -> Vector2i:
if stamp.is_empty():
return Vector2i.ZERO
@@ -107,28 +96,22 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
markers[ch].append(Vector2i(tx, ty))
return markers
## The hub. Hand-authored like the boss arenas.
##
## P a dungeon portal S player spawn T practice target
## U the upgrade NPC
##
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER: the
## Warden's Descent, the Choir Vault, and the Proving Grounds. Adding a dungeon
## means adding an entry there and a marker here, and they have to stay in the
## same order.
## The hub. Hand-authored like the boss arenas, and reproduced identically on
## the client from this same function -- see MapGen.build().
## P portal to the dungeons S player spawn T practice target
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........P.........P.........P.........#",
"#...................P...................#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........T...................U.........#",
"#.........T.............................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
-165
View File
@@ -1,165 +0,0 @@
class_name Upgrades
extends RefCounted
## Every upgrade in the game, in code, like the rest of `src/content/`.
##
## [constant ORDER] is a wire format: an upgrade's index is the byte that rides
## the upgrade-state message and the character save. Append, never reorder.
const SPLIT_SHOT := &"split_shot"
const GLASS_CANNON := &"glass_cannon"
const SPREAD := &"spread"
const SNIPER := &"sniper"
const DOUBLESHOT := &"doubleshot"
const POISON := &"poison"
const ERASER := &"eraser"
const ORDER: Array[StringName] = [
SPLIT_SHOT,
GLASS_CANNON,
SPREAD,
SNIPER,
DOUBLESHOT,
POISON,
ERASER,
]
## Draw weights by rarity. Relative, not percentages -- what matters is that a
## legendary is a story and a common is Tuesday.
const WEIGHTS := {
UpgradeDef.Rarity.COMMON: 100,
UpgradeDef.Rarity.UNCOMMON: 45,
UpgradeDef.Rarity.RARE: 18,
UpgradeDef.Rarity.LEGENDARY: 4,
}
static func get_def(id: StringName) -> UpgradeDef:
match id:
SPLIT_SHOT: return split_shot()
GLASS_CANNON: return glass_cannon()
SPREAD: return spread()
SNIPER: return sniper()
DOUBLESHOT: return doubleshot()
POISON: return poison()
ERASER: return eraser()
return null
## Wire value. 0 is reserved for "no upgrade", so an id's value is its position
## plus one -- same convention as [Items].
static func index_of(id: StringName) -> int:
var at := ORDER.find(id)
return at + 1 if at >= 0 else 0
static func by_index(index: int) -> StringName:
if index <= 0 or index > ORDER.size():
return &""
return ORDER[index - 1]
## Pick [param count] distinct upgrades, weighted by rarity.
##
## Distinct within one offer only -- an upgrade you already hold can and should
## come up again, because taking Split Shot twice is how a shot splits twice.
static func roll_offer(rng: RandomNumberGenerator, count: int) -> Array[StringName]:
var pool := ORDER.duplicate()
var picked: Array[StringName] = []
while picked.size() < count and not pool.is_empty():
var total := 0
for id in pool:
total += _weight_of(id)
var roll := rng.randi_range(1, maxi(total, 1))
for i in pool.size():
roll -= _weight_of(pool[i])
if roll <= 0:
picked.append(pool[i])
pool.remove_at(i)
break
return picked
static func _weight_of(id: StringName) -> int:
var def := get_def(id)
return int(WEIGHTS.get(def.rarity, 1)) if def != null else 1
# --- The upgrades -----------------------------------------------------------
static func split_shot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPLIT_SHOT
u.display_name = "Split Shot"
u.description = "Hitting an enemy spawns two more of the same shot, at 45° to either side, behind it. Take it again to split again."
u.rarity = UpgradeDef.Rarity.COMMON
u.split_charges = 1
return u
static func glass_cannon() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = GLASS_CANNON
u.display_name = "Glass Cannon"
u.description = "Double damage, half health. The health is half of whatever your level gives you, so it stays a real price."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_add = 1.0
u.max_hp_mult = 0.5
return u
static func spread() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SPREAD
u.display_name = "Spread"
u.description = "Two more shots in a cone either side of your aim. 10% damage."
u.rarity = UpgradeDef.Rarity.COMMON
u.side_shots = 2
u.damage_add = -0.10
return u
## The only multiplicative damage source, and the only thing that touches bullet
## speed. Both are capped downstream: PlayerStats clamps the speed under the
## tunnelling threshold, because a bullet that moves more than a tile per tick
## walks straight through walls.
static func sniper() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = SNIPER
u.display_name = "Sniper"
u.description = "Double damage, multiplied on top of everything else. Half the fire rate, double the bullet speed."
u.rarity = UpgradeDef.Rarity.COMMON
u.damage_mult = 2.0
u.fire_rate_mult = 0.5
u.bullet_speed_mult = 2.0
return u
static func doubleshot() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = DOUBLESHOT
u.display_name = "Doubleshot"
u.description = "One more shot travelling parallel to the rest. 50% damage."
u.rarity = UpgradeDef.Rarity.UNCOMMON
u.parallel_shots = 1
u.damage_add = -0.50
return u
static func poison() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = POISON
u.display_name = "Poison"
u.description = "Every shot deals another 50% of its damage over the next 10 seconds. Stacks with itself — every hit adds another dose."
u.rarity = UpgradeDef.Rarity.RARE
u.poison_fraction = 0.5
return u
static func eraser() -> UpgradeDef:
var u := UpgradeDef.new()
u.id = ERASER
u.display_name = "Eraser"
u.description = "Your shots have a 1% chance to delete an enemy projectile they pass through."
u.rarity = UpgradeDef.Rarity.LEGENDARY
u.erase_chance = 0.01
return u
-1
View File
@@ -1 +0,0 @@
uid://c4s2g0bi42sko
-76
View File
@@ -1,76 +0,0 @@
class_name Credits
extends RefCounted
## Every third-party asset the game ships, and the terms it ships under.
##
## This is not decoration. Two of these packs are CC BY 4.0, which requires
## attribution "in any reasonable manner" -- a file in the repository is not
## reasonable for a player who downloaded a build, so the game has to say so
## itself. See [CreditsScreen].
##
## `tests/unit/test_credits.gd` asserts every entry here also appears in
## CREDITS.md, so the two cannot drift apart.
## Each entry: what it is, who made it, the licence, a link, and what it is
## used for. `required` marks the ones the licence obliges us to show.
const SOURCES: Array[Dictionary] = [
{
"name": "16x16 DungeonTileset II",
"author": "0x72 (Robert Norenberg)",
"licence": "CC0 1.0 (public domain)",
"url": "https://0x72.itch.io/dungeontileset-ii",
"used_for": "Terrain, characters, bosses, item icons",
"required": false,
},
{
"name": "Complete UI Essential Pack",
"author": "Crusenho Agus Hennihuno",
"licence": "CC BY 4.0",
"url": "https://crusenho.itch.io/complete-ui-essential-pack",
"used_for": "Buttons, panels, bars, inventory slots",
"required": true,
},
{
"name": "Pixel Combat SFX",
"author": "Helton Yan",
"licence": "CC BY 4.0",
"url": "https://heltonyan.itch.io/pixelcombat",
"used_for": "All sound effects",
"required": true,
},
{
"name": "Fire Pixel Bullet 16x16",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/fire-pixel-bullet-16x16",
"used_for": "Bullet sprites (local builds only)",
"required": false,
},
{
"name": "750+ Effect and FX Pixel All",
"author": "bdragon1727",
"licence": "Custom - not redistributable",
"url": "https://bdragon1727.itch.io/750-effect-and-fx-pixel-all",
"used_for": "Impact effects (local builds only)",
"required": false,
},
]
## CC BY asks for a link to the licence itself, not only to the work.
const LICENCE_LINKS := {
"CC BY 4.0": "https://creativecommons.org/licenses/by/4.0/",
"CC0 1.0 (public domain)": "https://creativecommons.org/publicdomain/zero/1.0/",
}
## The packs whose licence actually obliges us to credit them. Everything else
## in [constant SOURCES] is there because the work deserves it.
static func required() -> Array[Dictionary]:
var out: Array[Dictionary] = []
for entry in SOURCES:
if bool(entry["required"]):
out.append(entry)
return out
static func licence_url(licence: String) -> String:
return String(LICENCE_LINKS.get(licence, ""))
-1
View File
@@ -1 +0,0 @@
uid://drpadmmsrvoax
-17
View File
@@ -30,15 +30,6 @@ static var boss_rush: bool = false
## Dev switch: depth of newly opened dungeons, which drives map size. Depth
## progression is a later-stage concern; this makes big maps testable now.
static var dungeon_depth: int = 1
## Override the local account id. Two clients on one machine would otherwise
## read the same user:// id file, land on the same account, and fight over one
## roster of characters -- which is exactly what the smoke test does. A real
## identity provider makes this unnecessary.
static var account_override: int = 0
## Where the server keeps characters. Overridable so a test run cannot read or
## write the characters someone is actually playing -- and so repeated runs
## start from a known state instead of resuming each other's progress.
static var store_path: String = CharacterStore.SAVE_PATH
static var parsed: bool = false
@@ -63,14 +54,6 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
listen = true
"--boss-rush":
boss_rush = true
"--account":
i += 1
if i < argv.size():
account_override = int(argv[i])
"--store":
i += 1
if i < argv.size():
store_path = argv[i]
"--depth":
i += 1
if i < argv.size():
-262
View File
@@ -1,262 +0,0 @@
class_name Settings
extends RefCounted
## Player preferences: key bindings and volumes.
##
## Client-side and local. Nothing here reaches the server or the simulation --
## which key you press to fire is not something the server has an opinion about,
## and the input frame it eventually produces looks identical either way.
##
## Static rather than an autoload for the same reason [GameOpts] is: a `--script`
## tool has no main loop and cannot resolve autoload names, and the settings
## file is something a headless test may well want to read.
const DEFAULT_PATH := "user://settings.cfg"
## Where preferences are read from and written to. A variable rather than a
## constant so tests can point it at a scratch file: rebinding calls save(),
## and a suite run was otherwise rewriting the player's real settings -- which
## it did, silently, until a screenshot showed Fire bound to the right mouse
## button.
static var path: String = DEFAULT_PATH
## Actions the settings screen offers, in the order it lists them. Anything not
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
## -- which is the right default for a binding nobody should be reassigning.
const REBINDABLE: Array = [
["move_up", "Move up"],
["move_down", "Move down"],
["move_left", "Move left"],
["move_right", "Move right"],
["fire", "Fire"],
["interact", "Interact / pick up"],
["emergency_escape", "Emergency escape"],
["use_slot_1", "Use slot 1"],
["use_slot_2", "Use slot 2"],
["use_slot_3", "Use slot 3"],
["use_slot_4", "Use slot 4"],
["system_menu", "Menu"],
["debug_overlay", "Hitbox overlay"],
]
## Bus every sound effect plays on. Created at runtime rather than shipped as a
## bus layout resource, so there is one fewer file to keep in step with the code
## that reads it.
const SFX_BUS := "SFX"
static var master_volume: float = 0.8
static var sfx_volume: float = 0.8
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
## What the project shipped, captured before anything is overridden. Without it
## "reset to defaults" would restore whatever the last session happened to save.
static var _defaults: Dictionary[String, Dictionary] = {}
static var _defaults_captured: bool = false
# --- Lifecycle ---------------------------------------------------------------
## Read the file and apply everything. Safe to call with no file present, which
## is every first run.
static func load_and_apply() -> void:
_capture_defaults()
var cfg := ConfigFile.new()
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)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
# has_section_key first: passing null as the default to get_value()
# does NOT mean "no default", it means the key is absent AND no
# default was given, and the engine logs an error for every missing
# one. A settings file written before an action existed is normal.
if not cfg.has_section_key("input", key):
continue
var stored: Variant = cfg.get_value("input", key)
# A binding this build cannot make sense of is dropped, not
# guessed at: an unusable control is worse than the default one.
if typeof(stored) == TYPE_DICTIONARY and _event_from(stored) != null:
bindings[key] = stored
apply_audio()
apply_input()
static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
static func apply_audio() -> void:
var sfx := AudioServer.get_bus_index(SFX_BUS)
if sfx < 0:
AudioServer.add_bus()
sfx = AudioServer.bus_count - 1
AudioServer.set_bus_name(sfx, SFX_BUS)
AudioServer.set_bus_send(sfx, "Master")
AudioServer.set_bus_volume_db(0, linear_to_db_clamped(master_volume))
AudioServer.set_bus_mute(0, master_volume <= 0.0)
AudioServer.set_bus_volume_db(sfx, linear_to_db_clamped(sfx_volume))
AudioServer.set_bus_mute(sfx, sfx_volume <= 0.0)
## Godot's linear_to_db(0) is -inf, which serialises badly and reads as a bug
## when it turns up in a log. The clamp is what prevents that -- 0.0001 comes
## out at exactly -80dB, far below audible -- so a zero or a negative from a
## corrupt settings file lands there rather than at negative infinity. Actual
## silence is the bus mute flag, set alongside this.
static func linear_to_db_clamped(linear: float) -> float:
return linear_to_db(clampf(linear, 0.0001, 1.0))
# --- Input -------------------------------------------------------------------
## Snapshot the project's own bindings. Called before anything overrides them,
## and only once -- a second call after an override would capture the override.
static func _capture_defaults() -> void:
if _defaults_captured:
return
_defaults_captured = true
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var events := InputMap.action_get_events(action)
if not events.is_empty():
_defaults[action] = _describe(events[0])
## Rewrite the InputMap from [member bindings]. Actions with no override are
## restored to what the project shipped, so clearing one binding cannot leave a
## previous session's choice behind.
static func apply_input() -> void:
_capture_defaults()
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var described: Dictionary = bindings.get(action, _defaults.get(action, {}))
var event := _event_from(described)
if event == null:
continue
# Replacing every event rather than the first: an action that kept its
# alternates would still answer to the key the player just moved away
# from, which reads as the rebind not having worked.
InputMap.action_erase_events(action)
InputMap.action_add_event(action, event)
## Assign [param event] to [param action]. Returns the action it collided with,
## or an empty string on success -- one key doing two things is a broken
## control scheme, so it is refused rather than silently accepted.
static func rebind(action: String, event: InputEvent) -> String:
var described := _describe(event)
if described.is_empty():
return action
for entry in REBINDABLE:
var other := String(entry[0])
if other == action:
continue
if current_binding(other) == described:
return other
bindings[action] = described
apply_input()
save()
return ""
static func reset_bindings() -> void:
bindings.clear()
apply_input()
save()
## What [param action] is bound to right now: the override if there is one,
## otherwise what the project shipped.
static func current_binding(action: String) -> Dictionary:
_capture_defaults()
return bindings.get(action, _defaults.get(action, {}))
static func binding_label(action: String) -> String:
return describe_label(current_binding(action))
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
var code := int(described.get("code", 0))
# 0 is not a key. _event_from refuses to build one, so a stored 0
# never reaches the input map -- but it would render as a blank
# button, which looks like a bound key with no name.
if code == 0:
return "unbound"
return OS.get_keycode_string(_layout_keycode(code))
"mouse":
match int(described.get("code", 0)):
MOUSE_BUTTON_LEFT: return "Mouse Left"
MOUSE_BUTTON_RIGHT: return "Mouse Right"
MOUSE_BUTTON_MIDDLE: return "Mouse Middle"
return "Mouse %d" % int(described["code"])
return "unbound"
## Bindings are stored as PHYSICAL keycodes, so they follow key position rather
## than layout -- the same choice tools/setup_input_map.gd makes. The label is
## translated back through the active layout so an AZERTY player reads "A" for
## the key their fingers are on, rather than the QWERTY name of that position.
##
## The translation is skipped on a display server that has no keyboard, which
## is headless: the call is not merely unsupported there, it pushes an engine
## error and hands the argument straight back, so asking is worse than not.
## There is no feature flag to test for it.
static func _layout_keycode(physical: int) -> int:
if physical == 0 or DisplayServer.get_name() == "headless":
return physical
return DisplayServer.keyboard_get_keycode_from_physical(physical)
## Only keys and mouse buttons are accepted. A rebind listener that took any
## InputEvent would happily capture mouse MOTION the instant the player moved
## the mouse, which is not a binding anyone meant to make.
static func is_bindable(event: InputEvent) -> bool:
return not _describe(event).is_empty()
static func _describe(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventMouseButton:
return {"type": "mouse", "code": int((event as InputEventMouseButton).button_index)}
return {}
static func _event_from(described: Dictionary) -> InputEvent:
match String(described.get("type", "")):
"key":
var k := InputEventKey.new()
# device -1 is the only value that matches input from a real
# device; a freshly constructed event defaults to 16, which
# silently matches nothing. See tools/setup_input_map.gd.
k.device = -1
k.physical_keycode = int(described.get("code", 0))
return k if k.physical_keycode != 0 else null
"mouse":
var m := InputEventMouseButton.new()
m.device = -1
m.button_index = int(described.get("code", 0))
return m if m.button_index != 0 else null
return null
-1
View File
@@ -1 +0,0 @@
uid://pdbmt03hxfum
+5 -77
View File
@@ -35,19 +35,12 @@ const PLAYER_VISUAL_RADIUS := 13.0
## the ship -- and on a remote client, the ship is also drawn a tick or two
## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
## Health and damage are deliberately an order of magnitude larger than the
## numbers they started as. Every ratio in the game is unchanged -- enemy
## health, boss health and every emitter's damage were scaled with them -- but
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
## Do not "tidy" these back down without scaling content.gd with them.
const PLAYER_MAX_HP := 1000
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
const PLAYER_MAX_HP := 100
const PLAYER_FIRE_COOLDOWN := 7 # ticks
const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
const PLAYER_BULLET_DAMAGE := 60
const PLAYER_BULLET_DAMAGE := 6
## There are deliberately NO invulnerability frames after a hit. In a bullet
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
## into a single cheap hit -- you get punished for the first bullet and gifted
@@ -58,13 +51,6 @@ const PLAYER_BULLET_DAMAGE := 60
## live bullet field is survivable. Both halves matter: invulnerability alone
## would make the spawn point a free firing position.
const SPAWN_GRACE_TICKS := 120 # 2 seconds
## Passive healing, as a percentage of MAXIMUM health per second -- so it
## scales with level rather than becoming irrelevant at level 15. There is
## deliberately no out-of-combat gate: a slow trickle that never stops is
## 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
## 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
@@ -96,25 +82,6 @@ const INPUT_MAX_LEAD := 40
## backstop that makes the failure above self-healing whatever its cause.
const INPUT_ACK_STALL_LIMIT := 8
# --- Inventory and loot -----------------------------------------------------
## Slots a character carries. Small enough to sit on screen permanently, which
## is the whole design: an inventory you have to open is a menu, and a menu in
## a bullet hell is a death. Growing this is a one-line change here -- the wire
## format, the HUD and the persistence record all read it.
const INVENTORY_SLOTS := 4
## How close you have to stand to pick something up. Comfortably larger than
## PLAYER_RADIUS so walking "onto" an item is enough; well under the distance
## at which you could grab loot you cannot see.
const LOOT_PICKUP_RADIUS := 34.0
## Radius of the ring player-instanced boss drops are laid out on. Each player
## only ever sees their own, so this is purely so a debug view of all of them
## is legible rather than one pile.
const LOOT_INSTANCED_SPREAD := 34.0
## Hard ceiling on ground loot in one instance. Dungeons are short-lived, so in
## practice this only ever bites in the hub, where players can drop things and
## nothing ever closes to clean up. Oldest goes first.
const MAX_LOOT_PER_INSTANCE := 64
# --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## Taking damage does NOT interrupt the channel. It used to, which sounds like
@@ -124,43 +91,8 @@ const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## connection now runs the same one-second channel (see SimPlayer.linkdead),
## which only works if being shot cannot cancel it.
# --- Upgrades ---------------------------------------------------------------
## Damage every upgrade adds on top of whatever else it does, into the additive
## pool. A property of taking an upgrade rather than of any one upgrade, so it
## lives here and is applied once per upgrade held.
const UPGRADE_DAMAGE_BONUS := 0.05
## Options offered per level gained.
const UPGRADE_CHOICES := 3
## Angle between consecutive shots in a Spread cone.
const SPREAD_STEP_DEG := 13.0
## Sideways gap between Doubleshot's parallel projectiles.
const PARALLEL_OFFSET := 15.0
## How far to either side a Split Shot child leaves the enemy it was born on.
const SPLIT_ANGLE_DEG := 45.0
## How long one dose of Poison takes to deliver its damage.
const POISON_DURATION_TICKS := 600 # 10 seconds
## How near its preferred distance a CHASE boss counts as "there".
##
## Without a dead band the sign of the correction flips every tick once it
## arrives, and the boss buzzes on the spot at 60Hz. Small in world units, and
## unmistakable on screen.
const BOSS_CHASE_DEADBAND := 8.0
## How close to the hub's upgrade NPC you must stand to spend a choice.
## Enforced on the server, like the portal: standing somewhere is the only
## thing a client cannot lie about.
const UPGRADE_NPC_RADIUS := 70.0
# --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096
## Hard ceiling on any bullet, however many speed multipliers stack up.
##
## Wall collision samples a bullet's position once per tick, so anything faster
## than one tile (MapGrid.TILE = 32px) per tick tunnels straight through
## geometry. At 60Hz that threshold is 1920 u/s; this leaves a margin for the
## sampling to stay honest. Two Snipers would ask for 2480 and get this instead.
## Pinned by test_bullet_pool.gd.
const MAX_BULLET_SPEED := 1500.0
const TEAM_PLAYER := 0
const TEAM_ENEMY := 1
@@ -203,9 +135,7 @@ const ACTOR_INTEREST_RADIUS := 800.0
## test_interest.gd computes that from the real content and asserts this covers
## it, so adding a faster or longer-lived bullet fails a test instead of
## producing bullets that wink into existence.
## Raised for upgrades: a Sniper's shot travels MAX_BULLET_SPEED * lifetime,
## which is further than anything the enemy content fires.
const BULLET_INTEREST_RADIUS := 2900.0
const BULLET_INTEREST_RADIUS := 2200.0
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
@@ -218,7 +148,5 @@ const MAP_STREAM_RADIUS := 900.0
const MAP_CHUNKS_PER_TICK := 6
# --- Portal -----------------------------------------------------------------
## Positions are per-world (SimWorld.portals), taken from the hub's map. Two
## entrances must be placed further apart than twice this, or their catchment
## areas overlap and which one you get stops being obvious from where you stand.
## Position is per-world now (SimWorld.portal_pos), taken from the hub's map.
const PORTAL_RADIUS := 60.0
+1 -17
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=11 format=3]
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
@@ -6,10 +6,6 @@
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"]
[ext_resource type="Script" path="res://src/ui/game_menu.gd" id="5"]
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
[ext_resource type="Script" path="res://src/ui/credits_screen.gd" id="10"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -29,15 +25,3 @@ script = ExtResource("5")
[node name="Sfx" type="Node" parent="."]
script = ExtResource("6")
[node name="CharacterSelect" type="CanvasLayer" parent="."]
script = ExtResource("7")
[node name="UpgradeScreen" type="CanvasLayer" parent="."]
script = ExtResource("8")
[node name="SettingsScreen" type="CanvasLayer" parent="."]
script = ExtResource("9")
[node name="CreditsScreen" type="CanvasLayer" parent="."]
script = ExtResource("10")
+8 -23
View File
@@ -22,8 +22,6 @@ var age: int = 0
var seed_value: int = 0
## Drives dungeon size and difficulty. The hub is always depth 0.
var depth: int = 0
## Which kind of run this is -- see [Dungeons]. Empty for the hub.
var dungeon_id: StringName = &""
## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1
@@ -43,9 +41,8 @@ static func make_lobby(instance_id: int) -> Instance:
inst.world = SimWorld.new(inst.seed_value)
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
inst.world.set_map(built["grid"])
inst.world.portals = built["portals"]
inst.world.upgrade_npc = built["npc"]
inst.world.has_upgrade_npc = true
inst.world.portal_enabled = true
inst.world.portal_pos = built["portal"]
inst.world.spawn_point = built["spawn"]
inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before
@@ -54,21 +51,14 @@ static func make_lobby(instance_id: int) -> Instance:
return inst
## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and
## the boss are the same whichever is chosen; only how much health they have and
## how freely they drop differs. See [Dungeons].
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1,
dungeon: StringName = &"") -> Instance:
static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int = 1) -> Instance:
var inst := Instance.new()
inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed
inst.depth = maxi(dungeon_depth, 1)
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
inst.world = SimWorld.new(dungeon_seed)
var flavour := Dungeons.get_or_default(inst.dungeon_id)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
inst.depth, flavour.arena)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.boss_spawn = built["boss_pos"]
@@ -77,7 +67,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
# Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
inst.boss_id = built["boss_id"]
inst.boss_id = Content.BOSS_WARDEN
inst.state = State.FORMING
inst._populate()
return inst
@@ -90,8 +80,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
func _populate() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value ^ 0x5eed
var flavour := Dungeons.get_or_default(dungeon_id)
var boss := world.spawn_boss(flavour.apply_to_boss(Content.boss(boss_id)))
var boss := world.spawn_boss(Content.boss(boss_id))
boss.pos = boss_spawn
boss.room = _room_rect_world(boss_room)
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
@@ -105,7 +94,7 @@ func _populate() -> void:
var r: Rect2i = rooms[i]
var count := rng.randi_range(1, 3 + depth / 2)
for _n in count:
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var def := _pick_enemy(rng)
var tx := rng.randi_range(r.position.x + 1, r.end.x - 2)
var ty := rng.randi_range(r.position.y + 1, r.end.y - 2)
var at := world.map.tile_centre(tx, ty)
@@ -177,12 +166,8 @@ func exit_countdown_seconds() -> int:
Protocol.COUNTDOWN_NONE - 1)
## [param dungeon] has to match: walking into the Proving Grounds portal must
## never drop you into a standard run that happens to still be forming, however
## conveniently timed.
func accepts_new_party_member(dungeon: StringName) -> bool:
func accepts_new_party_member() -> bool:
return kind == Protocol.InstanceKind.DUNGEON \
and dungeon_id == dungeon \
and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX
-26
View File
@@ -10,8 +10,6 @@ const GAME_SCENE := preload("res://src/game.tscn")
const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null
var _settings: CanvasLayer = null
var _credits: CanvasLayer = null
var _game: Node = null
var _ticks: int = 0
var _connecting: bool = false
@@ -25,10 +23,6 @@ func _ready() -> void:
# engine default and would silently desync server and client if that
# default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
# Bindings and volumes, before anything can read the input map or play a
# sound. A dedicated server has neither, so it does not pay for them.
if not GameOpts.is_server:
Settings.load_and_apply()
Net.state_changed.connect(_on_net_state)
if GameOpts.is_server:
@@ -69,7 +63,6 @@ func _show_menu() -> void:
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
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
@@ -146,25 +139,6 @@ func _enter_game() -> void:
add_child(_game)
## The main menu's copy of the settings screen. The in-game menu has its own --
## they are the same screen, and neither exists while the other is on display.
func _open_settings() -> void:
if _settings == null:
_settings = preload("res://src/ui/settings_screen.gd").new()
_settings.closed.connect(func() -> void: _settings.visible = false)
_settings.credits_requested.connect(_open_credits)
add_child(_settings)
_settings.open()
func _open_credits() -> void:
if _credits == null:
_credits = preload("res://src/ui/credits_screen.gd").new()
_credits.closed.connect(func() -> void: _credits.visible = false)
add_child(_credits)
_credits.open()
func _clear_game() -> void:
if _game != null:
_game.queue_free()
-28
View File
@@ -1,28 +0,0 @@
@abstract
class_name AuthProvider
extends RefCounted
## How the server learns who a client is.
##
## Modelled on Steamworks deliberately, so swapping to it later is one subclass
## and no schema change: the client obtains an opaque ticket, sends it with the
## handshake, and the server validates it into a stable 64-bit account id --
## exactly the shape of GetAuthSessionTicket / BeginAuthSession / SteamID64.
##
## The account id is the only identity anything downstream sees. Nothing stores
## a ticket, and nothing outside a provider interprets one.
## Reserved: never a valid account.
const NO_ACCOUNT := 0
## Client side: a ticket to present at handshake.
@abstract func get_ticket() -> PackedByteArray
## Server side: validate a ticket and return the account it proves, or
## NO_ACCOUNT to reject the connection.
@abstract func validate(ticket: PackedByteArray) -> int
## Human-readable, for logs and for the connect screen.
@abstract func provider_name() -> String
-1
View File
@@ -1 +0,0 @@
uid://fteslbujpkuo
-205
View File
@@ -1,205 +0,0 @@
class_name Character
extends RefCounted
## One playable character belonging to an account.
##
## Characters outlive a session and a server restart, so this is a persistence
## record first and a gameplay object second: everything here has to survive a
## round trip through JSON without losing meaning.
##
## Death does not delete a character. It clears [member active], which retires
## it from the roster while keeping the record for archival and for working out
## what happened after the fact.
const MAX_NAME := 20
var id: String = ""
var display_name: String = "adventurer"
## Placeholder identity until there is a cosmetic system. Random per character
## so party members are told apart at a glance.
var colour := Color.WHITE
var level: int = Progression.START_LEVEL
var total_xp: int = 0
## False once the character has died. Never deleted -- see the class note.
var active: bool = true
var created_unix: int = 0
var died_unix: int = 0
## Carried items, one id per slot, [constant Items.NONE] where empty. Stored on
## the character rather than on the session so that swapping in the hub swaps
## bags, and so a server restart does not quietly confiscate everyone's potions.
var inventory: Array[StringName] = []
## 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.
##
## The derived numbers (damage, fire rate, health multiplier) are NOT stored:
## [PlayerStats] rebuilds them from this list, so a saved stat can never
## disagree with the upgrades that produced it.
var upgrades: Array[StringName] = []
## Level-ups not yet spent at the hub NPC. They queue: reaching two levels in
## one run owes you two choices, because losing one for doing well is a
## punishment nobody would guess at.
var pending_choices: int = 0
## The choices currently on the table, held so that walking away and coming back
## shows the same three. Without this, closing and reopening the screen would be
## a free reroll until a legendary turned up.
var offer: Array[StringName] = []
## Suggested names, offered when creating a character so the field is never
## blank. Deliberately a pair of short word lists rather than a big table: the
## point is a usable default the player can overwrite, not a naming system.
const NAME_FIRST: PackedStringArray = [
"Ash", "Bram", "Cass", "Dorn", "Elm", "Fen", "Gale", "Hale", "Iva", "Jory",
"Kit", "Lark", "Mox", "Nell", "Orin", "Pike", "Quill", "Ren", "Sable", "Thorn",
"Vesper", "Wren", "Yarrow", "Zel",
]
const NAME_LAST: PackedStringArray = [
"blade", "briar", "creek", "dusk", "ember", "fell", "grim", "hollow",
"iron", "kettle", "moor", "night", "quarry", "rook", "shade", "thistle",
"vale", "wick",
]
static func random_name(rng: RandomNumberGenerator) -> String:
return "%s%s" % [
NAME_FIRST[rng.randi() % NAME_FIRST.size()],
NAME_LAST[rng.randi() % NAME_LAST.size()],
]
func _init() -> void:
inventory.resize(SimConfig.INVENTORY_SLOTS)
inventory.fill(Items.NONE)
static func create(character_name: String, rng: RandomNumberGenerator) -> Character:
var c := Character.new()
# Random-but-readable: full saturation and high value, so two characters are
# never both muddy browns, and none of them vanish against the floor.
c.colour = Color.from_hsv(rng.randf(), 0.55, 1.0)
c.id = "%d-%d" % [Time.get_unix_time_from_system(), rng.randi() & 0xFFFFFF]
c.display_name = sanitize_name(character_name)
c.created_unix = int(Time.get_unix_time_from_system())
return c
## Names come from clients and are shown to other players, so they are clamped
## here rather than trusted anywhere downstream.
static func sanitize_name(raw: String) -> String:
var clean := raw.strip_edges().substr(0, MAX_NAME)
# Control characters would let a name break the HUD's layout.
var out := ""
for ch in clean:
if ch.unicode_at(0) >= 32:
out += ch
out = out.strip_edges()
return out if not out.is_empty() else "adventurer"
## What this character actually walks around with: the level's health, scaled by
## whatever the upgrades do to it. Shown on the roster screen, so it has to be
## the real number and not the pre-upgrade one.
func max_hp() -> int:
return maxi(1, roundi(float(Progression.max_hp_for_level(level))
* PlayerStats.build(upgrades).max_hp_mult))
func xp_progress() -> float:
return Progression.level_progress(total_xp)
## Award experience and return how many levels it produced, so the caller can
## announce them. Level is derived from lifetime xp rather than tracked
## separately: one source of truth means a level can never disagree with the
## experience that earned it.
func grant_xp(amount: int) -> int:
if amount <= 0 or not active:
return 0
var before := level
total_xp += amount
level = Progression.level_for_xp(total_xp)
return level - before
func retire(when_unix: int = 0) -> void:
active = false
died_unix = when_unix if when_unix > 0 else int(Time.get_unix_time_from_system())
func to_dict() -> Dictionary:
return {
"id": id,
"name": display_name,
"colour": colour.to_html(false),
"level": level,
"xp": total_xp,
"active": active,
"created": created_unix,
"died": died_unix,
# Written as ids rather than indices: a save file has to survive
# Items.ORDER being appended to, and a human editing it should be able
# to tell what a character is carrying.
"inventory": _inventory_ids(),
"upgrades": _name_list(upgrades),
"pending_choices": pending_choices,
"offer": _name_list(offer),
}
static func _name_list(ids: Array[StringName]) -> Array:
var out := []
for id in ids:
out.append(String(id))
return out
func _inventory_ids() -> Array:
var out := []
for item in inventory:
out.append(String(item))
return out
## Tolerant of missing keys so an older save file still loads: a character that
## has lost a field is far better than an account that will not open.
static func from_dict(d: Dictionary) -> Character:
var c := Character.new()
c.id = String(d.get("id", ""))
c.display_name = sanitize_name(String(d.get("name", "")))
c.colour = Color.from_string(String(d.get("colour", "ffffff")), Color.WHITE)
c.total_xp = maxi(int(d.get("xp", 0)), 0)
# Derived, not read: a hand-edited or corrupted level cannot desync from xp.
c.level = Progression.level_for_xp(c.total_xp)
c.active = bool(d.get("active", true))
c.created_unix = int(d.get("created", 0))
c.died_unix = int(d.get("died", 0))
# Unknown ids decay to empty rather than to a wrong item -- a save written
# by a build with an item this one has never heard of must still load.
var carried: Array[StringName] = []
for raw in d.get("inventory", []):
var carried_id := StringName(String(raw))
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
c.set_inventory(carried)
# 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.
c.upgrades = _known_upgrades(d.get("upgrades", []))
c.pending_choices = maxi(int(d.get("pending_choices", 0)), 0)
c.offer = _known_upgrades(d.get("offer", []))
return c
static func _known_upgrades(raw: Array) -> Array[StringName]:
var out: Array[StringName] = []
for entry in raw:
var id := StringName(String(entry))
if Upgrades.get_def(id) != null:
out.append(id)
return out
## Replace the whole inventory, padded or trimmed to the current slot count.
func set_inventory(items: Array[StringName]) -> void:
inventory.resize(SimConfig.INVENTORY_SLOTS)
for i in SimConfig.INVENTORY_SLOTS:
inventory[i] = items[i] if i < items.size() else Items.NONE
-1
View File
@@ -1 +0,0 @@
uid://tuerwia65i3i
-227
View File
@@ -1,227 +0,0 @@
class_name CharacterStore
extends RefCounted
## Server-side persistence for accounts and their characters.
##
## JSON on disk, because the shape is small, the write rate is low (a level-up
## or a death, not a tick), and a save file you can open in a text editor is
## worth a great deal while a game is still being built. If this ever becomes a
## bottleneck the interface is narrow enough to put a database behind.
##
## Keyed by account id -- a 64-bit integer, deliberately the same shape as a
## SteamID64 so swapping [AuthProvider] for a real Steam one needs no migration.
const SAVE_PATH := "user://characters.json"
const FORMAT_VERSION := 1
## Live characters an account may hold at once. Retired ones do not count: a
## player who has died five times must not be locked out of their own account.
const MAX_ACTIVE := 5
## account_id -> { "characters": Array[Character], "last_played": String }
var _accounts: Dictionary[int, Dictionary] = {}
var _path: String = SAVE_PATH
var _rng := RandomNumberGenerator.new()
func _init(path: String = SAVE_PATH) -> void:
_path = path
_rng.randomize()
# --- Queries ----------------------------------------------------------------
func characters_for(account_id: int) -> Array[Character]:
var entry: Dictionary = _accounts.get(account_id, {})
var out: Array[Character] = []
for c in entry.get("characters", []):
out.append(c)
return out
func active_characters(account_id: int) -> Array[Character]:
var out: Array[Character] = []
for c in characters_for(account_id):
if c.active:
out.append(c)
return out
func get_character(account_id: int, character_id: String) -> Character:
for c in characters_for(account_id):
if c.id == character_id:
return c
return null
## The character to select on login: the last one played if it is still alive,
## otherwise the newest living one, otherwise nothing.
func last_played(account_id: int) -> Character:
var entry: Dictionary = _accounts.get(account_id, {})
var wanted := String(entry.get("last_played", ""))
var c := get_character(account_id, wanted)
if c != null and c.active:
return c
var living := active_characters(account_id)
if living.is_empty():
return null
var newest: Character = living[0]
for candidate in living:
if candidate.created_unix > newest.created_unix:
newest = candidate
return newest
func can_create(account_id: int) -> bool:
return active_characters(account_id).size() < MAX_ACTIVE
# --- Mutations --------------------------------------------------------------
## Returns null when the account is already at its living-character limit.
func create_character(account_id: int, character_name: String) -> Character:
if not can_create(account_id):
return null
var c := Character.create(character_name, _rng)
var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""})
entry["characters"].append(c)
entry["last_played"] = c.id
_accounts[account_id] = entry
save()
return c
func set_last_played(account_id: int, character_id: String) -> void:
var entry: Dictionary = _accounts.get(account_id, {"characters": [], "last_played": ""})
entry["last_played"] = character_id
_accounts[account_id] = entry
save()
## Death. The record stays; only its active flag changes.
func retire_character(account_id: int, character_id: String) -> void:
var c := get_character(account_id, character_id)
if c == null or not c.active:
return
c.retire()
save()
## 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:
var c := get_character(account_id, character_id)
if c == null:
return
c.set_inventory(items)
save()
## 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
## opens: an offer that regenerated per request would be a free reroll, and a
## player would simply close and reopen until a legendary appeared.
func grant_choices(account_id: int, character_id: String, count: int) -> void:
var c := get_character(account_id, character_id)
if c == null or count <= 0:
return
c.pending_choices += count
_refresh_offer(c)
save()
## Spend one choice on [param index] of the character's current offer. Returns
## the upgrade taken, or an empty id if the choice was not available -- the
## caller is the server, and "not available" is a refusal, not an error.
func take_upgrade(account_id: int, character_id: String, index: int) -> StringName:
var c := get_character(account_id, character_id)
if c == null or not c.active or c.pending_choices <= 0:
return &""
if index < 0 or index >= c.offer.size():
return &""
var chosen := c.offer[index]
c.upgrades.append(chosen)
c.pending_choices -= 1
# Cleared before re-rolling, so the next choice is a fresh three rather than
# the two that were passed over.
c.offer.clear()
_refresh_offer(c)
save()
return chosen
func _refresh_offer(c: Character) -> void:
if c.pending_choices > 0 and c.offer.is_empty():
c.offer = Upgrades.roll_offer(_rng, SimConfig.UPGRADE_CHOICES)
elif c.pending_choices <= 0:
c.offer.clear()
func grant_xp(account_id: int, character_id: String, amount: int) -> int:
var c := get_character(account_id, character_id)
if c == null:
return 0
var gained := c.grant_xp(amount)
if gained > 0:
save()
return gained
# --- Persistence ------------------------------------------------------------
func save() -> void:
var accounts := {}
for account_id in _accounts:
var entry: Dictionary = _accounts[account_id]
var chars := []
for c in entry["characters"]:
chars.append(c.to_dict())
# JSON object keys are strings; account ids are 64-bit and would lose
# precision as JSON numbers, so they are written as decimal strings.
accounts[str(account_id)] = {
"characters": chars,
"last_played": entry.get("last_played", ""),
}
var f := FileAccess.open(_path, FileAccess.WRITE)
if f == null:
GameLog.error("store", "cannot write %s (error %d)" % [_path, FileAccess.get_open_error()])
return
f.store_string(JSON.stringify({"version": FORMAT_VERSION, "accounts": accounts}, "\t"))
f.close()
## A missing file is a new server, not an error. A corrupt one is refused
## loudly and left alone rather than silently overwritten -- losing every
## character to a stray byte would be far worse than refusing to start.
func load_from_disk() -> bool:
_accounts.clear()
if not FileAccess.file_exists(_path):
return true
var f := FileAccess.open(_path, FileAccess.READ)
if f == null:
GameLog.error("store", "cannot read %s" % _path)
return false
var text := f.get_as_text()
f.close()
# JSON.new().parse() rather than JSON.parse_string(): it reports where the
# file is malformed instead of just returning null, which is the difference
# between a usable error and a mystery when someone's save will not load.
var json := JSON.new()
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
GameLog.error("store", "%s is not valid JSON (line %d: %s); refusing to overwrite it"
% [_path, json.get_error_line(), json.get_error_message()])
return false
var data: Dictionary = json.data
var accounts: Dictionary = data.get("accounts", {})
for key in accounts:
var account_id := int(str(key))
var entry: Dictionary = accounts[key]
var chars: Array[Character] = []
for raw in entry.get("characters", []):
chars.append(Character.from_dict(raw))
_accounts[account_id] = {
"characters": chars,
"last_played": String(entry.get("last_played", "")),
}
GameLog.info("store", "loaded %d account(s) from %s" % [_accounts.size(), _path])
return true
-1
View File
@@ -1 +0,0 @@
uid://becirpnytb1b8
-63
View File
@@ -1,63 +0,0 @@
class_name LocalAuthProvider
extends AuthProvider
## Development identity: no accounts, no passwords, no Steam.
##
## The client generates a 64-bit id once, stores it in user://, and presents it
## as its own ticket. The server takes it at face value.
##
## This is NOT secure and is not meant to be. Anyone can present any id, so
## anyone can claim any account's characters. It is deliberately the same shape
## as the real thing -- opaque ticket in, 64-bit account id out -- so the Steam
## provider replaces it without touching the character store, the protocol, or
## anything that consumes an account id.
##
## Before this game is reachable from the internet, this must be swapped for a
## provider that actually verifies. See docs/ROADMAP.md.
const ID_PATH := "user://account_id"
var _cached: int = AuthProvider.NO_ACCOUNT
func provider_name() -> String:
return "local-dev (insecure)"
## Read this machine's id, generating and saving one on first run.
func account_id() -> int:
if _cached != AuthProvider.NO_ACCOUNT:
return _cached
if GameOpts.account_override != AuthProvider.NO_ACCOUNT:
_cached = GameOpts.account_override
return _cached
if FileAccess.file_exists(ID_PATH):
var f := FileAccess.open(ID_PATH, FileAccess.READ)
if f != null:
var parsed := int(f.get_as_text().strip_edges())
f.close()
if parsed != AuthProvider.NO_ACCOUNT:
_cached = parsed
return _cached
var rng := RandomNumberGenerator.new()
rng.randomize()
# Positive and comfortably inside 64 bits, so it round-trips through the
# store's decimal-string keys without surprises.
_cached = absi(rng.randi()) << 20 | (absi(rng.randi()) & 0xFFFFF)
var out := FileAccess.open(ID_PATH, FileAccess.WRITE)
if out != null:
out.store_string(str(_cached))
out.close()
GameLog.info("auth", "generated local account id %d" % _cached)
return _cached
func get_ticket() -> PackedByteArray:
return str(account_id()).to_utf8_buffer()
## Accepts whatever it is given, which is the entire security model here.
func validate(ticket: PackedByteArray) -> int:
if ticket.is_empty() or ticket.size() > 64:
return AuthProvider.NO_ACCOUNT
var id := int(ticket.get_string_from_utf8().strip_edges())
return id if id > 0 else AuthProvider.NO_ACCOUNT
-1
View File
@@ -1 +0,0 @@
uid://hxyq22vllwgt
-90
View File
@@ -1,90 +0,0 @@
class_name Progression
extends RefCounted
## Levels, experience and what a level is worth.
##
## Pure functions over integers, so the curve can be tuned and tested without a
## server, a character, or a running game. The server is the only thing that
## ever calls the mutating side of this; a client is told its level and takes
## the server's word for it.
const MAX_LEVEL := 15
const START_LEVEL := 1
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else
## -- see the note on SimConfig.PLAYER_MAX_HP.
const HP_PER_LEVEL := 100
## Experience for the first level-up. The curve is tuned so one full clear of a
## depth-1 dungeon lands a little past this -- the first run should end with a
## level and some change, not exactly on the line.
const BASE_XP := 200
## How sharply the requirement grows. 1.0 would be linear; this makes level 15 a
## long-term goal without making level 2 feel far away.
const XP_CURVE := 1.35
## Experience awarded per kill, by what died.
const XP_DRIFTER := 10
const XP_TURRET := 12
const XP_STALKER := 8
const XP_BOSS := 200
## Experience needed to go from [param level] to the next one. Zero at the cap,
## which is what makes "already maxed" a total-ordering question rather than a
## special case at every call site.
static func xp_to_next(level: int) -> int:
if level >= MAX_LEVEL:
return 0
return int(round(float(BASE_XP) * pow(float(level), XP_CURVE)))
## Total experience to reach [param level] from level 1.
static func total_xp_for_level(level: int) -> int:
var total := 0
for l in range(START_LEVEL, mini(level, MAX_LEVEL)):
total += xp_to_next(l)
return total
## The level a given lifetime experience total corresponds to.
static func level_for_xp(total_xp: int) -> int:
var level := START_LEVEL
var spent := 0
while level < MAX_LEVEL:
var need := xp_to_next(level)
if total_xp - spent < need:
break
spent += need
level += 1
return level
## Progress through the current level, 0..1. Returns 1.0 at the cap so a
## progress bar reads as full rather than empty.
static func level_progress(total_xp: int) -> float:
var level := level_for_xp(total_xp)
if level >= MAX_LEVEL:
return 1.0
var into := total_xp - total_xp_for_level(level)
var need := xp_to_next(level)
return clampf(float(into) / float(maxi(need, 1)), 0.0, 1.0)
static func max_hp_for_level(level: int) -> int:
var l := clampi(level, START_LEVEL, MAX_LEVEL)
return SimConfig.PLAYER_MAX_HP + (l - START_LEVEL) * HP_PER_LEVEL
## Experience for killing an enemy, by content id. Unknown ids award nothing
## rather than a default, so a new enemy that nobody scored is obvious in play
## instead of quietly paying out.
static func xp_for_enemy(id: StringName) -> int:
match id:
Content.ENEMY_DRIFTER: return XP_DRIFTER
Content.ENEMY_TURRET: return XP_TURRET
Content.ENEMY_STALKER: return XP_STALKER
return 0
static func xp_for_boss(_id: StringName) -> int:
return XP_BOSS
-1
View File
@@ -1 +0,0 @@
uid://cjtfbpej8ywwf
+13 -276
View File
@@ -15,17 +15,6 @@ signal local_hit(damage: int)
signal shot_fired
signal enemy_died
signal boss_died
## Item transactions, straight from server events. Carry the item id so the
## view can name what happened without guessing from the inventory diff.
signal item_picked_up(item: StringName)
signal item_used(item: StringName)
signal item_dropped(item: StringName)
## The account's character roster changed: created, selected, levelled or died.
signal characters_changed
signal select_failed(reason: String)
## The played character's upgrade state changed: a level banked a choice, or one
## was spent.
signal upgrades_changed
var my_peer: int = 0
var instance_id: int = 0
@@ -43,22 +32,9 @@ var input_tick: int = 0
var predicted_pos := Vector2.ZERO
var aim: float = 0.0
var pending: Array[InputFrame] = []
## Movement from the most recent sampled input. Survives `pending` being
## drained, which is the whole point -- see is_moving().
var _last_move := Vector2.ZERO
# Authoritative mirror of the local player.
var my_hp: int = SimConfig.PLAYER_MAX_HP
## Follows the character's level, so the HUD bar cannot be computed from a
## constant.
var my_max_hp: int = SimConfig.PLAYER_MAX_HP
## Lifetime experience, straight from the snapshot so the bar moves per kill
## rather than per roster message.
var my_total_xp: int = 0
## Carried items as wire indices (0 = empty slot). Replaced wholesale by every
## snapshot, so it can never drift from what the server thinks you have --
## there is deliberately no local "I picked that up" optimism here.
var my_inventory: Array[int] = []
var my_alive: bool = true
var my_escape: float = 0.0
var my_escaping: bool = false
@@ -80,38 +56,11 @@ var request_escape: bool = false
## Who is online and where, for the hub's player list. Server-pushed.
var roster: Array[Dictionary] = []
## This account's characters, and which one is being played. Server-pushed;
## the client never invents an entry.
var characters: Array[Dictionary] = []
var selected_character: String = ""
## True once the server has told us the roster, so the UI can tell "no
## characters yet" from "not asked yet".
var characters_known: bool = false
## Attacks that have been announced but not yet landed, as
## [{ "pos": Vector2, "r": float, "until": int, "ticks": int }]. Server-pushed;
## the client never invents one and never decides one is over early.
var telegraphs: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
## Dungeon entrances in this world, as [{ "pos": Vector2, "dungeon": StringName }].
## Empty outside the hub.
var portals: Array[Dictionary] = []
## Which kind of run this instance is, or empty in the hub. Drives the HUD label
## and the boss's health ceiling -- an easier dungeon's boss has less of it, and
## a bar computed from the unscaled definition would sit near empty all fight.
var dungeon_id: StringName = &""
## Where the hub's upgrade NPC stands. Only meaningful in the hub.
var upgrade_npc := Vector2.ZERO
## Upgrade state for the played character, server-pushed. The client never
## invents an entry and never rolls an offer -- the three on the table were
## chosen by the server and held there, so closing the screen is not a reroll.
var upgrades_pending: int = 0
var upgrade_offer: Array[StringName] = []
var upgrades_taken: Array[StringName] = []
## Where this world's dungeon portal is. Per-map now, so it has to be told.
var portal_pos := Vector2.ZERO
## Offset the view applies when drawing the world: screen = world + this.
## Published by the game scene every frame, rather than assumed, so aiming
@@ -157,7 +106,6 @@ func _physics_process(delta: float) -> void:
input_tick += 1
var frame := _sample_input()
_last_move = frame.move
pending.append(frame)
# Only enough history to cover the worst reconciliation window.
while pending.size() > SimConfig.INPUT_MAX_AGE:
@@ -176,24 +124,10 @@ func _physics_process(delta: float) -> void:
Net.send_input(NetCodec.encode_inputs(redundant))
world.step()
_expire_telegraphs()
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
_maybe_bot_leave()
## Drop warnings whose attack has already landed. Keyed on the estimated server
## tick rather than on wall time, so a warning lasts exactly as long as the
## server said it would however the frame rate is behaving.
func _expire_telegraphs() -> void:
if telegraphs.is_empty():
return
var live: Array[Dictionary] = []
for t in telegraphs:
if server_tick_est < int(t["until"]):
live.append(t)
telegraphs = live
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
## disconnect is caught by the same channel a SIGKILL is. Deferred because
## Net.shutdown() frees this node.
@@ -228,36 +162,11 @@ func _sample_input() -> InputFrame:
buttons |= InputFrame.BTN_ESCAPE
if Input.is_action_pressed("interact"):
buttons |= InputFrame.BTN_INTERACT
# Number keys use a slot; shift-number drops it. The bit is sent for as long
# as the key is held and the server takes the leading edge, so a stuck or
# repeated packet cannot spend more than one item.
var slot := held_slot()
if slot >= 0:
buttons |= InputFrame.BTN_DROP if Input.is_key_pressed(KEY_SHIFT) \
else InputFrame.BTN_USE
return InputFrame.make(input_tick, move, aim, buttons, maxi(slot, 0))
## Which inventory slot key is down, or -1. Lowest wins, so pressing 1 while 2
## is held reads as "now slot 1" rather than as nothing.
func held_slot() -> int:
for i in SimConfig.INVENTORY_SLOTS:
if Input.is_action_pressed("use_slot_%d" % (i + 1)):
return i
return -1
return InputFrame.make(input_tick, move, aim, buttons)
## Scripted input so `tools/smoke.sh` can play the game with no display: orbit
## the arena, fire constantly, take the portal, then punch out with the escape.
## The portal this bot heads for. Spread across the available entrances by
## account so the smoke test opens one of each kind.
func bot_portal() -> Vector2:
if portals.is_empty():
return predicted_pos
var pick: int = absi(GameOpts.account_override) % portals.size()
return portals[pick]["pos"]
func _bot_input() -> InputFrame:
_bot_tick += 1
var t := float(_bot_tick) * SimConfig.TICK_DELTA
@@ -270,24 +179,11 @@ func _bot_input() -> InputFrame:
return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT)
if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30:
buttons |= InputFrame.BTN_INTERACT
# Walk onto a portal instead of orbiting, or interact never lands.
# Which one is picked from the account id, so a smoke run with several
# bots exercises every dungeon rather than only the first.
move = (bot_portal() - predicted_pos).normalized()
var slot := 0
if instance_kind == Protocol.InstanceKind.DUNGEON:
# Grab at whatever is underfoot and occasionally drink, so the item
# messages are exercised over a real socket by the smoke test. Bots are
# poor shots and rarely produce loot, so this is coverage of the wire
# format rather than of the drop rules -- tools/diag_loot.tscn covers
# those.
if _bot_tick % 90 < 6:
buttons |= InputFrame.BTN_INTERACT
if _bot_tick % 150 == 0:
buttons |= InputFrame.BTN_USE
# Walk onto the portal instead of orbiting, or interact never lands.
move = (portal_pos - predicted_pos).normalized()
if instance_kind == Protocol.InstanceKind.DUNGEON and _bot_tick > 900:
buttons |= InputFrame.BTN_ESCAPE
return InputFrame.make(input_tick, move, aim, buttons, slot)
return InputFrame.make(input_tick, move, aim, buttons)
# --- Server messages --------------------------------------------------------
@@ -311,13 +207,8 @@ func _resync_input_tick(server_tick: int, why: String) -> void:
## Whether the local player is moving, for choosing a run vs idle animation.
## View-only; nothing in the simulation asks.
##
## Reads the last sampled input rather than the tail of `pending`, which is
## drained on every reconcile: derived from `pending` this flickered false ~20
## times a second, so the ship alternated between its run and idle strips and
## looked like both were playing at once.
func is_moving() -> bool:
return _last_move.length_squared() > 0.04
return not pending.is_empty() and pending[pending.size() - 1].move.length_squared() > 0.04
func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
@@ -327,92 +218,17 @@ func on_map_chunks(from_instance: int, data: PackedByteArray) -> void:
NetCodec.decode_map_chunks_into(world.map, data)
func on_characters(data: PackedByteArray) -> void:
var decoded := NetCodec.decode_characters(data)
characters = decoded["characters"]
selected_character = String(decoded["selected"])
characters_known = true
characters_changed.emit()
hud_dirty.emit()
_bot_pick_character()
## A bot has no roster screen to click, so it makes the choice the screen would
## offer: resume a living character, or create one. Without this the smoke test
## would authenticate and then stand at a menu forever.
func _bot_pick_character() -> void:
if not GameOpts.bot_client or not selected_character.is_empty():
return
for c in characters:
if c["active"]:
Net.select_character(String(c["id"]))
return
Net.create_character(GameOpts.player_name)
func on_select_result(result: int, reason: String) -> void:
if result != Protocol.SelectResult.OK:
GameLog.warn("client", "character selection refused: %s" % reason)
select_failed.emit(reason)
## The character currently being played, or an empty dictionary while none is.
func current_character() -> Dictionary:
for c in characters:
if String(c["id"]) == selected_character:
return c
return {}
## True when the player has no character in the world and must pick one -- at
## first login, or after their last one died.
func needs_character() -> bool:
return characters_known and selected_character.is_empty()
func on_upgrades(data: PackedByteArray) -> void:
var decoded := NetCodec.decode_upgrade_state(data)
upgrades_pending = int(decoded["pending"])
upgrade_offer = decoded["offer"]
upgrades_taken = decoded["taken"]
upgrades_changed.emit()
hud_dirty.emit()
## True when the player is close enough for the server to accept a choice. The
## same radius the server checks, so the screen is never open on a choice that
## would be refused.
func at_upgrade_npc() -> bool:
return instance_kind == Protocol.InstanceKind.LOBBY \
and predicted_pos.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
## The player's live combat numbers, rebuilt from the upgrades the server says
## they hold. Display only -- the server computes its own copy and that is the
## one that decides anything.
func stats() -> PlayerStats:
return PlayerStats.build(upgrades_taken)
func on_roster(data: PackedByteArray) -> void:
roster = NetCodec.decode_roster(data)
hud_dirty.emit()
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray,
dungeon: String, npc: Vector2) -> void:
spawn: Vector2, map_w: int, map_h: int, portal: Vector2) -> void:
instance_id = id
instance_kind = kind as Protocol.InstanceKind
portals = NetCodec.decode_portals(portals_data)
upgrade_npc = npc
dungeon_id = StringName(dungeon)
# Scaled the same way the server scaled it, so the boss bar reads as a
# fraction of the health this particular run's boss actually has.
boss_def = null
if not boss_id.is_empty():
boss_def = Dungeons.get_or_default(dungeon_id).apply_to_boss(
Content.boss(StringName(boss_id)))
portal_pos = portal
boss_def = Content.boss(StringName(boss_id)) if not boss_id.is_empty() else null
# We are told how big the map is and nothing else. Every tile starts UNKNOWN
# and is filled in by streaming as the player moves, so the client never
# holds terrain it has not been near -- there is no seed here to regenerate
@@ -440,8 +256,6 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
request_escape = false
request_respawn = false
my_respawn_wait = 0.0
my_inventory = []
telegraphs = []
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
@@ -453,7 +267,6 @@ func on_snapshot(data: PackedByteArray) -> void:
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"])
my_inventory = snap["inventory"]
snap_prev = snap_curr
snap_curr = snap
_interp = 0.0
@@ -479,13 +292,11 @@ func on_snapshot(data: PackedByteArray) -> void:
## land where the client should actually be right now.
func _reconcile(rec: Dictionary) -> void:
my_hp = int(rec["hp"])
my_max_hp = int(rec["max_hp"])
my_alive = (int(rec["flags"]) & Protocol.F_ALIVE) != 0
my_escaping = (int(rec["flags"]) & Protocol.F_ESCAPING) != 0
my_spawn_grace = (int(rec["flags"]) & Protocol.F_SPAWN_GRACE) != 0
my_escape = float(rec["escape"])
my_respawn_wait = float(rec["respawn_wait"])
my_total_xp = int(rec["total_xp"])
if my_alive:
request_respawn = false
hud_dirty.emit()
@@ -529,7 +340,8 @@ func on_events(data: PackedByteArray) -> void:
ev["team"], ev["kind"], ev["accel"], ev["turn"], ev["uid"])
if slot >= 0 and catchup > 0:
world.pool.advance_slot(slot, catchup)
if int(ev["team"]) == SimConfig.TEAM_PLAYER:
shot_fired.emit()
SimEvent.Type.BULLET_DESPAWN:
world.apply_event(ev)
SimEvent.Type.PLAYER_HIT:
@@ -547,34 +359,10 @@ func on_events(data: PackedByteArray) -> void:
predicted_pos = ev["pos"]
pending.clear()
hud_dirty.emit()
SimEvent.Type.PLAYER_FIRED:
shot_fired.emit()
SimEvent.Type.ENEMY_DIED:
enemy_died.emit()
SimEvent.Type.BOSS_DIED:
boss_died.emit()
SimEvent.Type.TELEGRAPH:
telegraphs.append({
"pos": ev["pos"],
"r": ev["r"],
"ticks": maxi(int(ev["ticks"]), 1),
# Aged by however long the packet spent in flight, the same
# way a bullet is, so a warning drawn late still disappears
# on time rather than lingering past its own attack.
"until": int(packet["tick"]) + int(ev["ticks"]),
})
SimEvent.Type.ITEM_PICKED_UP:
if int(ev["peer"]) == my_peer:
item_picked_up.emit(ev["item"])
hud_dirty.emit()
SimEvent.Type.ITEM_USED:
if int(ev["peer"]) == my_peer:
item_used.emit(ev["item"])
hud_dirty.emit()
SimEvent.Type.ITEM_DROPPED:
if int(ev["peer"]) == my_peer:
item_dropped.emit(ev["item"])
hud_dirty.emit()
_:
pass
@@ -591,61 +379,10 @@ func enemies() -> Array[Dictionary]:
return _interpolated("enemies", "id", [])
## Ground loot the server has told us about. Not interpolated -- items do not
## move -- and never filtered here: what arrives is already exactly what this
## player is allowed to see.
func ground_loot() -> Array:
if snap_curr.is_empty():
return []
return snap_curr["loot"]
## The item that pressing interact would pick up, or an empty dictionary. Purely
## for the prompt: the server does this same search for itself and does not care
## what the client concluded.
func loot_in_reach() -> Dictionary:
var best := {}
var best_d := SimConfig.LOOT_PICKUP_RADIUS * SimConfig.LOOT_PICKUP_RADIUS
for l: Dictionary in ground_loot():
var d: float = predicted_pos.distance_squared_to(l["pos"])
if d <= best_d:
best_d = d
best = l
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:
if my_inventory.is_empty():
return false
for index in my_inventory:
if index == 0:
return false
return true
## The boss, interpolated between the last two snapshots exactly like every
## other actor.
##
## It used to be handed back raw, which meant it jumped to each new snapshot the
## moment it arrived -- a visible step three times slower than the frame rate.
## Nobody noticed while every boss stood still. The first one that moved looked
## broken.
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
var rec: Dictionary = (snap_curr["boss"] as Dictionary).duplicate()
if snap_prev.is_empty() or snap_prev.get("boss") == null:
return rec
var old: Dictionary = snap_prev["boss"]
# Only between the same boss. Across an instance change the previous
# snapshot describes a different fight in a different room, and lerping to
# it would fling the new boss across the map for one frame.
if int(old["id"]) != int(rec["id"]):
return rec
rec["pos"] = (old["pos"] as Vector2).lerp(rec["pos"], _interp)
return rec
return snap_curr["boss"]
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
+6 -253
View File
@@ -45,10 +45,6 @@ static func encode_snapshot(world: SimWorld,
b.put_float(p.pos.y)
b.put_u16(wrapi(roundi(p.aim / TAU * 65536.0), 0, 65536))
b.put_u16(clampi(p.hp, 0, 65535))
# Sent rather than assumed: max health follows the character's level, so
# a HUD bar computed from a constant would be wrong for anyone past
# level 1, and wrong for every other player in the party.
b.put_u16(clampi(p.max_hp, 1, 65535))
var flags := 0
if p.alive:
flags |= Protocol.F_ALIVE
@@ -65,14 +61,6 @@ static func encode_snapshot(world: SimWorld,
b.put_u8(clampi(roundi(float(p.respawn_lockout) / 6.0), 0, 255))
# Echoed so the owning client knows how far to rewind when reconciling.
b.put_u32(p.last_input_tick)
# Static per character, so sending it every snapshot is slightly
# wasteful -- but it is four bytes, and the alternative is a separate
# message plus the join-ordering bug where someone arrives before it.
b.put_u32(p.colour.to_rgba32())
# Experience rides the snapshot rather than waiting for a roster
# message: the bar has to move on every kill, and the roster is only
# re-sent when the set of characters actually changes.
b.put_u32(maxi(p.total_xp, 0))
var live_enemies: Array[SimEnemy] = []
for e in world.enemies.values():
@@ -103,32 +91,6 @@ static func encode_snapshot(world: SimWorld,
b.put_float(world.boss.pos.y)
b.put_u32(maxi(world.boss.hp, 0))
b.put_u8(clampi(world.boss.phase_index, 0, 255))
# Only the observer's own bag. Nobody needs to see what a party member is
# carrying, and not sending it means there is nothing to leak.
for i in SimConfig.INVENTORY_SLOTS:
var carried := Items.NONE
if observer != null and i < observer.inventory.size():
carried = observer.inventory[i]
b.put_u8(Items.index_of(carried))
# Ground loot. Player-instanced items are filtered here rather than hidden
# in the client: a peer is never told that another player's copy exists, so
# a modified client has nothing to reveal.
var visible_loot: Array[SimLoot] = []
for l in world.loot.values():
if observer != null:
if not l.visible_to(for_peer):
continue
if eye.distance_squared_to(l.pos) > cull_sq:
continue
visible_loot.append(l)
b.put_u16(mini(visible_loot.size(), 65535))
for l in visible_loot:
b.put_u32(l.id)
b.put_float(l.pos.x)
b.put_float(l.pos.y)
b.put_u8(Items.index_of(l.item))
return b.data_array
@@ -140,7 +102,6 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"tick": b.get_u32(),
"cleared_countdown": b.get_u8(),
"players": [], "enemies": [], "boss": null,
"loot": [], "inventory": [],
}
var pcount := b.get_u8()
@@ -150,13 +111,10 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"pos": Vector2(b.get_float(), b.get_float()),
"aim": float(b.get_u16()) / 65536.0 * TAU,
"hp": b.get_u16(),
"max_hp": b.get_u16(),
"flags": b.get_u8(),
"escape": float(b.get_u8()) / 255.0,
"respawn_wait": float(b.get_u8()) / 10.0,
"last_input_tick": b.get_u32(),
"colour": Color.hex(b.get_u32()),
"total_xp": b.get_u32(),
})
var ecount := b.get_u16()
@@ -176,19 +134,6 @@ static func decode_snapshot(data: PackedByteArray) -> Dictionary:
"hp": b.get_u32(),
"phase": b.get_u8(),
}
var inventory: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
inventory.append(b.get_u8())
snap["inventory"] = inventory
var lcount := b.get_u16()
for _i in lcount:
snap["loot"].append({
"id": b.get_u32(),
"pos": Vector2(b.get_float(), b.get_float()),
"item": b.get_u8(),
})
return snap
@@ -239,13 +184,8 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
body.put_u16(clampi(int(ev["dmg"]), 0, 65535))
body.put_u16(clampi(int(ev["hp"]), 0, 65535))
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
SimEvent.Type.ESCAPE_CANCELLED:
body.put_u32(ev["peer"])
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
body.put_u32(ev["peer"])
# By index, like everywhere else on the wire. See Items.ORDER.
body.put_u8(Items.index_of(ev["item"]))
SimEvent.Type.PLAYER_RESPAWNED:
body.put_u32(ev["peer"])
body.put_float(ev["pos"].x)
@@ -258,11 +198,6 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
body.put_u32(ev["id"])
SimEvent.Type.BOSS_PHASE:
body.put_u8(clampi(int(ev["phase"]), 0, 255))
SimEvent.Type.TELEGRAPH:
body.put_float(ev["pos"].x)
body.put_float(ev["pos"].y)
body.put_float(ev["r"])
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
SimEvent.Type.BOSS_DIED:
pass
count += 1
@@ -300,12 +235,8 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
ev["dmg"] = b.get_u16()
ev["hp"] = b.get_u16()
SimEvent.Type.PLAYER_DIED, SimEvent.Type.ESCAPE_STARTED, \
SimEvent.Type.ESCAPE_CANCELLED, SimEvent.Type.PLAYER_FIRED:
SimEvent.Type.ESCAPE_CANCELLED:
ev["peer"] = b.get_u32()
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
ev["peer"] = b.get_u32()
ev["item"] = Items.by_index(b.get_u8())
SimEvent.Type.PLAYER_RESPAWNED:
ev["peer"] = b.get_u32()
ev["pos"] = Vector2(b.get_float(), b.get_float())
@@ -317,10 +248,6 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
ev["id"] = b.get_u32()
SimEvent.Type.BOSS_PHASE:
ev["phase"] = b.get_u8()
SimEvent.Type.TELEGRAPH:
ev["pos"] = Vector2(b.get_float(), b.get_float())
ev["r"] = b.get_float()
ev["ticks"] = b.get_u16()
SimEvent.Type.BOSS_DIED:
pass
out.append(ev)
@@ -380,16 +307,11 @@ static func decode_roster(data: PackedByteArray) -> Array[Dictionary]:
b.data_array = data
var count := b.get_u8()
for _i in count:
if b.get_available_bytes() < 4:
break
var peer := b.get_u32()
var display := _safe_utf8(b)
# kind, instance, alive.
if b.get_available_bytes() < 1 + 4 + 1:
break
# get_utf8_string reads its own length prefix, so a truncated packet
# yields empty strings rather than reading off the end.
out.append({
"peer": peer,
"name": display,
"peer": b.get_u32(),
"name": b.get_utf8_string(),
"kind": b.get_u8(),
"instance": b.get_u32(),
"alive": b.get_u8() == 1,
@@ -431,172 +353,3 @@ static func decode_map_chunks_into(map: MapGrid, data: PackedByteArray) -> int:
map.apply_chunk(id, b.get_data(n)[1])
applied += 1
return applied
# --- Portals ----------------------------------------------------------------
# Sent once, with enter_instance. There are two of them and they never move, so
# this is about as cold as a message gets -- it is a codec only because the
# count is variable and RPC arguments are not.
static func encode_portals(portals: Array[SimPortal]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(mini(portals.size(), 255))
for portal in portals:
b.put_float(portal.pos.x)
b.put_float(portal.pos.y)
# By index, like item ids. See Dungeons.ORDER.
b.put_u8(Dungeons.index_of(portal.dungeon))
return b.data_array
## Returns [{ "pos": Vector2, "dungeon": StringName }].
static func decode_portals(data: PackedByteArray) -> Array[Dictionary]:
var out: Array[Dictionary] = []
if data.size() < 1:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var count := b.get_u8()
for _i in count:
# 4 + 4 + 1. A truncated packet gives back fewer portals rather than
# reading past the end and inventing one at a garbage position.
if b.get_available_bytes() < 9:
break
out.append({
"pos": Vector2(b.get_float(), b.get_float()),
"dungeon": Dungeons.by_index(b.get_u8()),
})
return out
# --- Upgrade state ----------------------------------------------------------
# Sent to one peer when its played character's upgrades change: a level gained,
# a choice spent, a character swapped in. Cold, and about the played character
# only -- nobody needs to know what anyone else has taken.
static func encode_upgrade_state(pending: int, offer: Array[StringName],
taken: Array[StringName]) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(clampi(pending, 0, 255))
b.put_u8(mini(offer.size(), 255))
for id in offer:
b.put_u8(Upgrades.index_of(id))
b.put_u16(mini(taken.size(), 65535))
for id in taken:
b.put_u8(Upgrades.index_of(id))
return b.data_array
## Returns { "pending": int, "offer": Array[StringName], "taken": Array[StringName] }.
static func decode_upgrade_state(data: PackedByteArray) -> Dictionary:
var offer: Array[StringName] = []
var taken: Array[StringName] = []
var out := {"pending": 0, "offer": offer, "taken": taken}
if data.size() < 2:
return out
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
out["pending"] = b.get_u8()
var offer_count := b.get_u8()
for _i in offer_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
offer.append(id)
if b.get_available_bytes() < 2:
return out
var taken_count := b.get_u16()
for _i in taken_count:
if b.get_available_bytes() < 1:
return out
var id := Upgrades.by_index(b.get_u8())
if not id.is_empty():
taken.append(id)
return out
# --- Character roster -------------------------------------------------------
# Sent once at login and after any change. Low frequency and carries strings,
# like the online roster, so it is the same fixed-header-then-utf8 shape.
static func encode_characters(chars: Array[Character], selected: String) -> PackedByteArray:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_utf8_string(selected)
b.put_u8(mini(chars.size(), 255))
for c in chars:
b.put_utf8_string(c.id)
b.put_utf8_string(c.display_name)
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))
b.put_u8(1 if c.active else 0)
b.put_u32(c.colour.to_rgba32())
for i in SimConfig.INVENTORY_SLOTS:
b.put_u8(Items.index_of(c.inventory[i] if i < c.inventory.size() else Items.NONE))
return b.data_array
## Length-prefixed string read that refuses to run off the end.
##
## StreamPeerBuffer.get_utf8_string() reads a length and then that many bytes,
## and pushes an engine error if the buffer is short -- so a truncated or
## hostile packet turns into error spam plus a garbage value. Returns an empty
## string and leaves the cursor at the end instead, which callers detect via
## get_available_bytes().
static func _safe_utf8(b: StreamPeerBuffer) -> String:
if b.get_available_bytes() < 4:
b.seek(b.get_size())
return ""
var length := b.get_u32()
if length > b.get_available_bytes():
b.seek(b.get_size())
return ""
return b.get_data(length)[1].get_string_from_utf8() if length > 0 else ""
## Returns { "selected": String, "characters": Array[Dictionary] }.
static func decode_characters(data: PackedByteArray) -> Dictionary:
var out: Array[Dictionary] = []
if data.size() < 1:
return {"selected": "", "characters": out}
var b := StreamPeerBuffer.new()
b.big_endian = false
b.data_array = data
var selected := _safe_utf8(b)
if b.get_available_bytes() < 1:
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
for _i in count:
var id := _safe_utf8(b)
var display := _safe_utf8(b)
if id.is_empty() or b.get_available_bytes() < fixed:
break
out.append({
"id": id,
"name": display,
"level": b.get_u8(),
"xp": b.get_u32(),
"progress": float(b.get_u8()) / 255.0,
"max_hp": b.get_u16(),
"active": b.get_u8() == 1,
"colour": Color.hex(b.get_u32()),
"inventory": _read_inventory(b),
})
return {"selected": selected, "characters": out}
static func _read_inventory(b: StreamPeerBuffer) -> Array[int]:
var out: Array[int] = []
for _i in SimConfig.INVENTORY_SLOTS:
out.append(b.get_u8())
return out
+1 -35
View File
@@ -7,22 +7,7 @@ extends RefCounted
## roster message.
## 3: dropped the post-hit invulnerability flag, added the respawn-lockout byte
## to each player record.
## 4: added SimEvent.Type.PLAYER_FIRED. It was inserted mid-enum, which shifts
## the wire value of every event after it -- a mismatched client would
## mis-decode every hit and death, so the handshake has to reject it.
## 5: handshake carries an auth ticket instead of a bare name; added character
## list/select/create messages, per-player max health and colour in the
## snapshot.
## 6: inventory and loot. The snapshot gained the observer's own inventory and
## the ground-loot list, the input frame gained a slot byte, and three item
## events were appended. Every one of those changes the byte layout of a
## message both ends parse positionally.
## 7: more than one dungeon. enter_instance carries a portal LIST and the id of
## the dungeon you are standing in, replacing the single portal position.
## 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
const VERSION := 3
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
@@ -37,25 +22,6 @@ const CHANNEL_COUNT := 8
enum InstanceKind { LOBBY, DUNGEON }
## Why a character selection failed. Sent rather than a bare "no", so the UI can
## say something useful instead of appearing broken.
enum SelectResult {
OK,
NO_SUCH_CHARACTER,
CHARACTER_IS_DEAD,
LIMIT_REACHED,
NOT_AUTHENTICATED,
## Swapping is hub-only. Allowing it inside a dungeon would be an instant,
## uninterruptible exit from danger -- strictly better than the one-second
## escape channel, and it would make that channel pointless.
NOT_IN_HUB,
## Upgrades are spent standing at the hub's NPC. Same rule as the portal:
## where you are is the one thing a modified client cannot fake.
NOT_AT_THE_NPC,
## No unspent level-up, or an option index that was not on the table.
NOTHING_TO_CHOOSE,
}
## Player flags packed into the snapshot's per-player byte.
const F_ALIVE := 1
const F_ESCAPING := 4
+16 -361
View File
@@ -11,14 +11,6 @@ extends Node
var instances: Dictionary[int, Instance] = {}
var peer_instance: Dictionary[int, int] = {}
var peer_names: Dictionary[int, String] = {}
## Authenticated account behind each peer. Set at handshake and never taken
## from anything the client says afterwards.
var peer_accounts: Dictionary[int, int] = {}
## Which character each peer is currently playing.
var peer_characters: Dictionary[int, String] = {}
## Characters, levels and experience. Owned here: the simulation reads a
## player's level, but only this layer ever writes progression.
var store: CharacterStore = null
## Map chunks each peer has been sent, per peer. Reset on every instance
## transfer -- knowledge of one dungeon must not carry into the next.
var peer_chunks: Dictionary[int, Dictionary] = {}
@@ -29,14 +21,6 @@ var _snapshot_phase: int = 0
func _ready() -> void:
if store == null:
store = CharacterStore.new(GameOpts.store_path)
if not store.load_from_disk():
# Refusing to start beats starting empty and saving over everyone's
# characters on the first level-up.
GameLog.error("server", "character store failed to load; refusing to start")
get_tree().quit(1)
return
lobby = Instance.make_lobby(_take_instance_id())
instances[lobby.id] = lobby
GameLog.info("server", "lobby instance %d up" % lobby.id)
@@ -89,37 +73,17 @@ func _dispatch_events(inst: Instance) -> void:
# Collected and applied after the send below, because a transfer mutates
# inst.peers and would otherwise change the list mid-broadcast.
var to_lobby: Array[int] = []
# peer -> which dungeon their portal opens.
var to_dungeon: Dictionary[int, StringName] = {}
var died: Array[int] = []
var to_dungeon: Array[int] = []
for ev in events:
match int(ev["t"]):
SimEvent.Type.ENEMY_DIED:
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
SimEvent.Type.BOSS_DIED:
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
# The simulation moved items between the ground and a bag; the
# store is what makes that survive a restart. Same division of
# labour as experience: the world decides, this layer banks it.
_persist_inventory(inst, int(ev["peer"]))
SimEvent.Type.PLAYER_DIED:
# Deferred like the transfers below: the payload has not been
# sent yet, and a player must still receive news of its own
# death before it stops being a member of the instance.
died.append(int(ev["peer"]))
SimEvent.Type.ESCAPE_COMPLETED, SimEvent.Type.RESPAWN_REQUESTED:
var peer := int(ev["peer"])
if not to_lobby.has(peer):
to_lobby.append(peer)
SimEvent.Type.PORTAL_USED:
var peer := int(ev["peer"])
# Keyed by peer, so a player brushing both entrances in one tick
# still only enters one dungeon -- the first portal that
# answered.
if not to_dungeon.has(peer):
to_dungeon[peer] = StringName(ev.get("dungeon", ""))
to_dungeon.append(peer)
_:
pass
@@ -143,12 +107,10 @@ func _dispatch_events(inst: Instance) -> void:
continue
Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer))
for peer in died:
_on_player_died(inst, peer)
for peer in to_lobby:
_send_to_lobby(peer)
for peer in to_dungeon:
_send_to_dungeon(peer, to_dungeon[peer])
_send_to_dungeon(peer)
# --- Peer lifecycle ---------------------------------------------------------
@@ -183,8 +145,6 @@ func _forget_peer(peer_id: int) -> void:
peer_instance.erase(peer_id)
peer_names.erase(peer_id)
peer_chunks.erase(peer_id)
peer_accounts.erase(peer_id)
peer_characters.erase(peer_id)
_broadcast_roster()
@@ -198,170 +158,25 @@ func _release_linkdead(peer_id: int, inst: Instance) -> void:
_forget_peer(peer_id)
## Handshake: validate the ticket into an account, then offer that account's
## characters. A peer is NOT placed in the world here -- it has no character
## yet, and a player without a character has nothing to control.
func on_hello(peer_id: int, version: int, ticket: PackedByteArray) -> void:
if peer_accounts.has(peer_id):
func on_hello(peer_id: int, version: int, display_name: String) -> void:
if peer_names.has(peer_id):
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)
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)
return
peer_accounts[peer_id] = account
# Never trust a client-supplied string for anything but display.
var clean := display_name.strip_edges().substr(0, 24)
if clean.is_empty():
clean = "player%d" % peer_id
peer_names[peer_id] = clean
Net.send_welcome(peer_id)
GameLog.info("server", "peer %d authenticated as account %d (%s)"
% [peer_id, account, Net.auth.provider_name()])
# Auto-select the last character played, so a returning player lands in the
# hub rather than at a menu they have already answered.
var resume := store.last_played(account)
if resume != null:
_enter_world_as(peer_id, resume)
_send_characters(peer_id)
func _send_characters(peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
if account == AuthProvider.NO_ACCOUNT:
return
# Living characters only. Retirement is bookkeeping for the server's own
# archive -- from the player's side a dead character is simply gone, and
# listing it would offer a choice that cannot be taken.
Net.send_characters(peer_id, NetCodec.encode_characters(
store.active_characters(account), peer_characters.get(peer_id, "")))
func on_select_character(peer_id: int, character_id: String) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
if account == AuthProvider.NO_ACCOUNT:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in")
return
# Looked up against THIS account's characters, so a client cannot select
# somebody else's by guessing an id.
# Hub only. A player inside a dungeon who could swap character would have an
# instant escape from anything dangerous -- strictly better than the escape
# channel, and it would hollow out the whole reason that channel exists.
# Checked here rather than hidden in the UI, which a modified client ignores.
var here := instance_of(peer_id)
if here != null and here.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
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")
return
if not c.active:
Net.send_select_result(peer_id, Protocol.SelectResult.CHARACTER_IS_DEAD,
"%s is dead" % c.display_name)
return
_enter_world_as(peer_id, c)
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
_send_characters(peer_id)
func on_create_character(peer_id: int, character_name: String) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
if account == AuthProvider.NO_ACCOUNT:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED, "not signed in")
return
var where := instance_of(peer_id)
if where != null and where.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"you can only change character in the hub")
return
var c := store.create_character(account, character_name)
if c == null:
Net.send_select_result(peer_id, Protocol.SelectResult.LIMIT_REACHED,
"%d living characters is the limit" % CharacterStore.MAX_ACTIVE)
return
GameLog.info("server", "account %d created '%s'" % [account, c.display_name])
_enter_world_as(peer_id, c)
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
_send_characters(peer_id)
## Put a peer into the hub playing [param c], switching characters if it was
## already in the world.
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:
previous.remove_peer(peer_id)
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
store.set_last_played(account, c.id)
# _place adopts, so the old three-line adopt/reset/adopt dance here is gone:
# it existed to work around reset_for_instance clobbering the health that
# adopt had just computed, and both now derive it the same way.
_place(peer_id, lobby)
GameLog.info("server", "peer %d playing '%s' (level %d)"
% [peer_id, c.display_name, c.level])
_send_upgrades(peer_id)
GameLog.info("server", "peer %d joined as '%s'" % [peer_id, clean])
_broadcast_roster()
## Spend one level-up on one of the three options the server put on the table.
##
## Everything about this is checked here rather than trusted: that there is a
## choice owing, that the index names an option the server itself offered, and
## that the player is standing at the NPC. The index is the only thing the
## client contributes, and it selects from a list the client did not write.
func on_choose_upgrade(peer_id: int, index: 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():
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AUTHENTICATED,
"not signed in")
return
var here := instance_of(peer_id)
if here == null or here.kind != Protocol.InstanceKind.LOBBY:
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_IN_HUB,
"upgrades are chosen in the hub")
return
var p: SimPlayer = here.world.players.get(peer_id)
if p == null or not here.world.at_upgrade_npc(p.pos):
Net.send_select_result(peer_id, Protocol.SelectResult.NOT_AT_THE_NPC,
"stand at the quartermaster to spend a level")
return
var taken := store.take_upgrade(account, character_id, index)
if taken.is_empty():
Net.send_select_result(peer_id, Protocol.SelectResult.NOTHING_TO_CHOOSE,
"nothing to choose")
_send_upgrades(peer_id)
return
# Rebuild the player's numbers from the new list. adopt() does exactly this
# and nothing else that matters here, so it stays the single place where a
# character's record becomes a player's stats.
var c := store.get_character(account, character_id)
if c != null:
p.adopt(c)
GameLog.info("server", "peer %d took upgrade '%s'" % [peer_id, taken])
Net.send_select_result(peer_id, Protocol.SelectResult.OK, "")
_send_upgrades(peer_id)
_send_characters(peer_id)
func _send_upgrades(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 c := store.get_character(account, character_id)
if c == null:
return
Net.send_upgrades(peer_id, NetCodec.encode_upgrade_state(
c.pending_choices, c.offer, c.upgrades))
func on_input(peer_id: int, data: PackedByteArray) -> void:
var inst := instance_of(peer_id)
if inst == null:
@@ -379,20 +194,11 @@ func instance_of(peer_id: int) -> Instance:
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
# The world hands back a BLANK player -- level 1, base stats, empty bag --
# because SimWorld knows nothing about characters. Giving it back its
# character is this layer's job, and for a long time it only happened on
# character select: every portal and every escape quietly reset the player
# to level 1 with no upgrades and nothing carried. The record on disk was
# always correct, which is what made it look like a display bug.
_adopt_character(peer_id, inst)
# Size only: the seed stays server-side, or a client could rebuild the map.
peer_chunks[peer_id] = {}
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
String(inst.boss_id), inst.world.spawn_point,
inst.world.map.width, inst.world.map.height,
NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id),
inst.world.upgrade_npc)
inst.world.map.width, inst.world.map.height, inst.world.portal_pos)
# Seed the area around the spawn before anything else, so the player is not
# briefly standing in an unrendered void on arrival.
_stream_map(peer_id, inst)
@@ -405,26 +211,6 @@ func _place(peer_id: int, inst: Instance) -> void:
inst.exit_countdown_seconds(), peer_id))
## Give the player in [param inst] the character its peer is playing.
##
## The one place a character record becomes a live player, so there is a single
## thing to get right rather than one per transition.
func _adopt_character(peer_id: int, inst: Instance) -> 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 # authenticated but still at the roster screen
var c := store.get_character(account, character_id)
var p: SimPlayer = inst.world.players.get(peer_id)
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
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
@@ -442,45 +228,21 @@ func _send_to_lobby(peer_id: int) -> void:
if p != null and p.linkdead:
_release_linkdead(peer_id, from)
return
# The character that just died is retired, so returning "as them" is not an
# option. Fall back to whatever is left, and leave the player at the
# character screen if nothing is.
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
if account != AuthProvider.NO_ACCOUNT:
var current := store.get_character(account, peer_characters.get(peer_id, ""))
if current == null or not current.active:
var replacement := store.last_played(account)
if replacement != null:
_enter_world_as(peer_id, replacement)
else:
if from != null:
from.remove_peer(peer_id)
peer_instance.erase(peer_id)
peer_characters.erase(peer_id)
_send_characters(peer_id)
return
GameLog.info("server", "peer %d escaped to lobby" % peer_id)
_transfer(peer_id, lobby)
## Put a peer into a forming run of the kind they asked for, opening one if
## there is none. The dungeon id comes from the portal they used, which the
## simulation reported -- never from anything the client said.
func _send_to_dungeon(peer_id: int, dungeon_id: StringName = &"") -> void:
var wanted := dungeon_id if Dungeons.get_def(dungeon_id) != null \
else Dungeons.default_id()
func _send_to_dungeon(peer_id: int) -> void:
var target: Instance = null
for inst in instances.values():
if inst.accepts_new_party_member(wanted):
if inst.accepts_new_party_member():
target = inst
break
if target == null:
target = Instance.make_dungeon(_take_instance_id(), randi(),
GameOpts.dungeon_depth, wanted)
target = Instance.make_dungeon(_take_instance_id(), randi(), GameOpts.dungeon_depth)
instances[target.id] = target
GameLog.info("server", "opened dungeon instance %d (%s)"
% [target.id, wanted])
GameLog.info("server", "opened dungeon instance %d" % target.id)
_transfer(peer_id, target)
@@ -521,113 +283,6 @@ 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:
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
return
for peer in inst.peers:
var p: SimPlayer = inst.world.players.get(peer)
if p == null or not p.alive:
continue
_grant_xp(peer, amount)
## Copy a player's bag back onto the character record it belongs to.
##
## Called on every item transaction rather than on a timer, because the whole
## 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.
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, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null:
return
store.set_inventory(account, character_id, p.inventory)
func _grant_xp(peer_id: int, amount: 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 levels := store.grant_xp(account, character_id, amount)
var earned := store.get_character(account, character_id)
if earned == null:
return
# Mirrored into the world every grant, not only on a level-up, so the bar
# tracks each kill.
var here := instance_of(peer_id)
if here != null:
var mine: SimPlayer = here.world.players.get(peer_id)
if mine != null:
mine.total_xp = earned.total_xp
if levels <= 0:
return
# One choice per level, banked rather than offered immediately: the NPC is
# in the hub and the level was earned in a dungeon, so they have to queue or
# they would be lost.
store.grant_choices(account, character_id, levels)
# A level raises max health immediately, and heals by the amount gained --
# a level-up mid-fight should feel like relief, not like a bar that grew
# further away from full.
var c := store.get_character(account, character_id)
var inst := instance_of(peer_id)
if inst != null:
var p: SimPlayer = inst.world.players.get(peer_id)
if p != null:
var before := p.max_hp
p.level = c.level
# Derived rather than copied from the character, so there is one
# formula for maximum health and not two that can disagree.
p.recompute_max_hp()
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)"
% [peer_id, c.level, c.pending_choices])
_send_characters(peer_id)
_send_upgrades(peer_id)
## Death is permanent. The character is retired -- kept for archival, never
## deleted -- and the player is taken out of the world entirely.
##
## There is deliberately no "return to the hub as the character who just died":
## the run is over, so the peer is unbound and left at the roster screen to pick
## another or make one. A linkdead player is the exception -- it has nobody to
## show a roster to, so its body is left for the escape channel to resolve.
func _on_player_died(inst: Instance, peer_id: int) -> void:
if inst.kind != Protocol.InstanceKind.DUNGEON:
return
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 c := store.get_character(account, character_id)
if c == null or not c.active:
return
store.retire_character(account, character_id)
GameLog.info("server", "peer %d lost '%s' at level %d"
% [peer_id, c.display_name, c.level])
var p: SimPlayer = inst.world.players.get(peer_id)
if p != null and p.linkdead:
_send_characters(peer_id)
_broadcast_roster()
return
inst.remove_peer(peer_id)
peer_instance.erase(peer_id)
peer_characters.erase(peer_id)
peer_chunks.erase(peer_id)
_send_characters(peer_id)
_broadcast_roster()
# --- Roster -----------------------------------------------------------------
## Tell everyone who is online and where they are, so the hub can show that a
-32
View File
@@ -20,19 +20,6 @@ var team := PackedByteArray()
var kind := PackedByteArray()
var alive := PackedByteArray()
# --- Player upgrade riders, server-only -------------------------------------
# Carried on the bullet rather than looked up from the shooter, because a shot
# already in flight keeps the properties it was fired with -- levelling up
# mid-flight must not retroactively poison a bullet that left before you took
# Poison. All three stay 0 on a client replica, which never resolves a hit and
# so has no use for them.
## Times this shot may still split on hitting something.
var split := PackedInt32Array()
## Fraction of this shot's damage to apply again as poison.
var poison := PackedFloat32Array()
## Chance per tick that this shot deletes an enemy projectile it overlaps.
var erase := PackedFloat32Array()
## The geometry bullets die against. Set by the owning SimWorld; identical on
## server and client, which is what keeps the replica in step.
var map: MapGrid = null
@@ -71,9 +58,6 @@ func _init() -> void:
team.resize(n)
kind.resize(n)
alive.resize(n)
split.resize(n)
poison.resize(n)
erase.resize(n)
func clear() -> void:
@@ -113,11 +97,6 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
team[slot] = bullet_team
kind[slot] = bullet_kind
alive[slot] = 1
# Cleared on every spawn, so a reused slot never inherits the last
# occupant's upgrades.
split[slot] = 0
poison[slot] = 0.0
erase[slot] = 0.0
if forced_uid != 0:
uid[slot] = forced_uid
else:
@@ -128,17 +107,6 @@ func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int,
return slot
## Attach a shooter's upgrade riders. Called immediately after spawn() by the
## server; never by the replica.
func set_mods(slot: int, split_charges: int, poison_fraction: float,
erase_chance: float) -> void:
if slot < 0:
return
split[slot] = split_charges
poison[slot] = poison_fraction
erase[slot] = erase_chance
func clear_spawn_log() -> void:
spawn_log.clear()
wall_kill_log.clear()
+3 -25
View File
@@ -2,46 +2,26 @@ class_name InputFrame
extends RefCounted
## One tick of player intent. This is the only thing a client is allowed to tell
## the server about its own state: no positions, no hits, no damage.
##
## Item actions live here rather than in their own reliable message, which is
## worth being explicit about. Using or dropping an item is a thing you do
## *during* a fight, so it has to be ordered against your movement on the same
## tick, and it has to be as cheap to reject as a movement vector. Riding the
## input stream gets all of that for free: the redundancy that covers a dropped
## packet, the replay guard on [member SimPlayer.last_input_tick], and a natural
## rate limit of one action per tick. A separate "use item" RPC would have
## needed every one of those bolted on again.
const BTN_FIRE := 1
const BTN_ESCAPE := 2
const BTN_INTERACT := 4
## Use the item in [member slot]. Edge-triggered by the server, so holding the
## key spends one potion and not sixty.
const BTN_USE := 8
## Drop the item in [member slot] on the ground, where anyone can take it.
const BTN_DROP := 16
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons, u8 slot.
const SIZE := 10
## Wire size in bytes: u32 tick, i8 move x/y, u16 aim, u8 buttons.
const SIZE := 9
var tick: int = 0
var move := Vector2.ZERO
var aim: float = 0.0
var buttons: int = 0
## Which inventory slot BTN_USE / BTN_DROP refer to. Meaningless without one of
## those bits set; the server clamps it before use, so a hostile value indexes
## nothing.
var slot: int = 0
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int,
p_slot: int = 0) -> InputFrame:
static func make(p_tick: int, p_move: Vector2, p_aim: float, p_buttons: int) -> InputFrame:
var f := InputFrame.new()
f.tick = p_tick
f.move = p_move
f.aim = p_aim
f.buttons = p_buttons
f.slot = p_slot
return f
@@ -57,7 +37,6 @@ func write(buf: StreamPeerBuffer) -> void:
buf.put_8(clampi(roundi(move.y * 100.0), -100, 100))
buf.put_u16(wrapi(roundi(aim / TAU * 65536.0), 0, 65536))
buf.put_u8(buttons & 0xFF)
buf.put_u8(slot & 0xFF)
static func read(buf: StreamPeerBuffer) -> InputFrame:
@@ -66,5 +45,4 @@ static func read(buf: StreamPeerBuffer) -> InputFrame:
f.move = Vector2(float(buf.get_8()) / 100.0, float(buf.get_8()) / 100.0)
f.aim = float(buf.get_u16()) / 65536.0 * TAU
f.buttons = buf.get_u8()
f.slot = buf.get_u8()
return f
+18 -55
View File
@@ -3,27 +3,20 @@ extends RefCounted
## Builds a dungeon: generated rooms and corridors around a hand-authored boss
## arena (see [Rooms]).
##
## Deterministic from (seed, depth) alone, which is what makes a failing run
## reproducible from its log line. It does NOT mean the client can rebuild the
## map -- see the note on build().
## Deterministic from (seed, depth) alone, so the server can hand a client the
## same two numbers instead of a map, and a failing run can be reproduced from
## its log line.
## The single entry point. SERVER-SIDE ONLY.
## The single entry point both server and client use.
##
## This used to be described as "both sides call it with the same three
## integers" -- that is no longer true and has not been since map streaming
## landed. Handing a client the seed would let it regenerate the entire floor
## plan, which is a map hack with no work required, so the client is given the
## map's SIZE and nothing else and fills tiles in from streamed chunks as it
## walks. See ServerRuntime._stream_map.
##
## Generation is still deterministic from (kind, seed, depth), which is what
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
## pins that.
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int,
arena: StringName = &"") -> Dictionary:
## Maps are never sent as tile data: they are a pure function of
## (kind, seed, depth), so the server ships three integers and the client
## rebuilds the identical grid. tests/unit/test_map_gen.gd pins the determinism
## that makes that safe, and it keeps a big dungeon free on the wire.
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
if kind == Protocol.InstanceKind.LOBBY:
return _build_lobby()
return generate(seed_value, depth, arena)
return generate(seed_value, depth)
static func _build_lobby() -> Dictionary:
@@ -36,47 +29,28 @@ static func _build_lobby() -> Dictionary:
if not markers["S"].is_empty():
var m: Vector2i = markers["S"][0]
spawn = grid.tile_centre(m.x, m.y)
# One portal per marker, in reading order, matched against Dungeons.ORDER.
# A stamp with fewer markers than dungeons simply makes the extra ones
# unreachable rather than crashing -- an unreachable dungeon is a content
# bug, not a runtime one.
var portals: Array[SimPortal] = []
for i in (markers["P"] as Array).size():
if i >= Dungeons.ORDER.size():
break
var m: Vector2i = markers["P"][i]
portals.append(SimPortal.make(grid.tile_centre(m.x, m.y), Dungeons.ORDER[i]))
if portals.is_empty():
portals.append(SimPortal.make(
grid.tile_centre(size.x / 2, 3), Dungeons.default_id()))
var portal := grid.tile_centre(size.x / 2, 3)
if not markers["P"].is_empty():
var m: Vector2i = markers["P"][0]
portal = grid.tile_centre(m.x, m.y)
var target := grid.tile_centre(size.x / 4, size.y / 2)
if not markers["T"].is_empty():
var m: Vector2i = markers["T"][0]
target = grid.tile_centre(m.x, m.y)
var npc := grid.tile_centre(size.x * 3 / 4, size.y / 2)
if not markers["U"].is_empty():
var m: Vector2i = markers["U"][0]
npc = grid.tile_centre(m.x, m.y)
return {
"grid": grid,
"rooms": [] as Array[Rect2i],
"spawn": spawn,
"portals": portals,
"portal": portal,
"dummy": target,
"npc": npc,
"boss_pos": Vector2.ZERO,
"boss_room": Rect2i(),
"boss_id": &"",
}
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
## (Vector2), boss_room (Rect2i).
## [param arena] names the boss arena to stamp, and therefore which boss the run
## ends with. Empty leaves it to the seed, which is what the Proving Grounds
## wants and nothing else does -- for a real dungeon the boss is the thing a
## player chose at the portal.
static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Dictionary:
static func generate(seed_value: int, depth: int) -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value
var d := maxi(depth, 1)
@@ -91,17 +65,11 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
# The boss arena is placed first and everything else works around it, so a
# generated corridor can never carve through the authored fight.
# Normally the dungeon says which arena it is; only the test harness leaves
# it open, and then the seed decides.
var chosen := arena
if chosen.is_empty():
chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
var stamp := Rooms.warden_hall() if chosen == &"warden_hall" else Rooms.choir_vault()
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
var bs := Rooms.size_of(stamp)
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
var markers := Rooms.stamp(grid, stamp, boss_origin)
var boss_room := Rect2i(boss_origin, bs)
var arena_id := chosen
var boss_pos := grid.tile_centre(
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
@@ -163,15 +131,10 @@ static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Di
"grid": grid,
"rooms": rooms,
"spawn": spawn,
"portals": [] as Array[SimPortal],
"portal": Vector2.ZERO,
"dummy": Vector2.ZERO,
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
# 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.
"boss_id": Rooms.boss_for_arena(arena_id),
}
-20
View File
@@ -22,26 +22,6 @@ var rng: RandomNumberGenerator
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
## Where an emitter announces something before it happens. Points at the
## owning world's event list; emitters fired into a bare pool by a test simply
## append here and nobody reads it.
var events: Array[Dictionary] = []
## Warn that an attack will land at [param at] in [param ticks] ticks.
##
## The only thing an emitter can do besides spawn bullets, and it exists so a
## strike that appears out of thin air is a *choice* the player was given time
## to answer rather than an unavoidable hit.
func telegraph(at: Vector2, warn_radius: float, ticks: int) -> void:
events.append({
"t": SimEvent.Type.TELEGRAPH,
"pos": at,
"r": warn_radius,
"ticks": ticks,
})
func aim_angle() -> float:
if not has_target:
return PI * 0.5

Some files were not shown because too many files have changed in this diff Show More