Compare commits

..
4 Commits
Author SHA1 Message Date
Adyrem b25156a438 Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only
half-addressed. The server dropped inputs past a lead of 12 while the client
only re-synced past 16, so a client whose lead drifted into 13-16 had every
input silently rejected while believing its timing was fine. The server coasted
on held_input and then stopped; the client kept predicting. The two separated
permanently and the reconciler fought it every snapshot -- "shoved around".
It needed two independent clocks to drift, hence "only after some time", and
nothing in the loop could notice, hence "then persists". The listen-server
diagnostic could never reproduce it: one process, one physics tick, lead
constant by construction.

Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction
band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a
test; and an ack-stall detector re-syncs when last_input_tick stops advancing,
which catches the whole class regardless of cause -- lead alone cannot, because
a wrong lead looks normal from the client. diag_prediction.gd now injects a +14
tick drift and exits non-zero unless the gap recovers.

Also:
- No invulnerability frames. Every bullet that touches a player lands; i-frames
  made dense patterns safer than sparse ones, which inverts the genre. Measured:
  a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s
  drifting. spawn_grace remains the only invulnerable state.
- Death is exited with a HUD button, disabled for the first 3s. The lockout is
  enforced in SimWorld, not just by graying the button -- a client that ignores
  its own UI still waits. The interact key no longer respawns.
- Joining a server that is not there no longer drops the player into an empty
  lobby they cannot act in. Net.join() only creates an ENet object; the game
  scene now waits for the server to actually place us in an instance, with an
  8s timeout, and headless runs exit non-zero instead of idling.

Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass.
2026-09-03 19:19:33 +02:00
Adyrem 1dc1952a3c Fix bullet/ship desync; rework death, escape, arrival and hub awareness
ci / verify (push) Successful in 45s
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
2026-09-03 18:43:19 +02:00
Adyrem e2b849c6ef Fix lobby/HUD UI pinned at (0,0); shrink player hitbox below visual size
ci / verify (push) Successful in 45s
The lobby connect menu and three pieces of the HUD (hit-flash overlay, boss
bar centering, death-message centering, hint label) were all silently broken
by the same Godot gotcha: set_anchors_preset(preset) with the default
keep_offsets=false does NOT zero the offsets to the preset's margins -- it
recomputes them to preserve the control's *current* rect, which for a
freshly constructed Control is (0,0). Anchors end up correct; the actual
rect stays pinned to the top-left corner regardless. Fixed by switching to
set_anchors_and_offsets_preset() everywhere a Control is built in code, and
by using offset_left/offset_top (anchor-relative) instead of .position
(absolute) for the HUD hint label. Documented as gotcha #5 in CLAUDE.md.

Also split PLAYER_RADIUS into two constants: PLAYER_RADIUS (6.0, the
authoritative hitbox used by SimWorld) and PLAYER_VISUAL_RADIUS (13.0,
view-only, used by world_view.gd). The client renders every ship a little
late relative to the server -- interpolation delay, reconciliation
smoothing -- so a hitbox that matched the sprite would let bullets connect
against a ship the player watched dodge clear of them. A smaller hitbox
means the occasional bullet visibly clips the sprite without a hit, which
reads as more forgiving of latency than the reverse.

Verified headlessly: a throwaway scene instantiating MainMenu/HUD under a
real 1280x720 viewport, asserting the panel is centered and _canvas.size /
hint position resolve correctly, before and after each fix. check.sh,
test.sh (78/78) and smoke.sh all pass.
2026-09-03 16:58:42 +02:00
Adyrem 651c4ad94a Initial commit: Transcience MVP
ci / verify (push) Successful in 1m57s
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
2026-09-03 16:03:57 +02:00
237 changed files with 732 additions and 20298 deletions

No files matched your search

-17
View File
@@ -1,22 +1,5 @@
# Godot's import cache and editor state. Regenerated by `godot --import`. # Godot's import cache and editor state. Regenerated by `godot --import`.
.godot/ .godot/
# Assets whose licence forbids redistribution. bdragon1727's packs say
# plainly: "You cannot do: Resell / redistribute this asset." Publishing a repo
# containing them IS redistribution, so they stay local-only. The game loads
# them if present and falls back to generated placeholders if not, so a clone
# without them still runs. See docs/ASSETS.md.
/assets/local/*
!/assets/local/README.md
# Raw asset packs: gigabytes of source artwork and audio. Redistributable
# third-party material belongs where it was bought or downloaded, not in a code
# repo. The directory itself and its guards stay tracked, so a fresh clone has
# the .gdignore in place before anyone drops packs back in -- without it the
# next `tools/check.sh` tries to import the lot.
/assetpacks/*
!/assetpacks/.gdignore
!/assetpacks/README.md
# Export artefacts. # Export artefacts.
build/ build/
*.pck *.pck
+15 -175
View File
@@ -3,51 +3,17 @@
Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend. Top-down twin-stick bullet-hell with a dedicated, server-authoritative backend.
Godot 4.7, GDScript only. One executable is both server and client. Godot 4.7, GDScript only. One executable is both server and client.
**Starting a new session?** [docs/ROADMAP.md](docs/ROADMAP.md) says what is
built, what is next, and which file implements each feature.
[docs/DECISIONS.md](docs/DECISIONS.md) records what the user has already decided
and why — read it before asking a design question, several answers there are not
the obvious default.
## Commands ## Commands
```bash ```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # 576 GUT tests, headless (~4s) tools/test.sh # GUT suite, headless (~2s)
tools/smoke.sh # real server + 5 bot clients over ENet (~40s) 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/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 # every screen, the fog, a staged boss fight (needs a display; own store and account)
```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
test cannot cover any of them: bots are poor shots, so they rarely kill
anything, which means they neither earn levels, produce drops, nor ever reach
the quartermaster.
**Every key the game names to the player goes through `Settings.short_label()`.**
All of them are rebindable, and a prompt that names the default is wrong for
anyone who changed it — the HUD said "LMB fire" and the upgrade screen said
"Close (E)" whatever those were bound to.
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
have all passed with the entire interface rendering unstyled, an invisible
slider and an invisible scrollbar. After touching anything under `src/ui/` or
`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()`: Everything after `--` goes to `GameOpts.parse()`:
| Flag | Effect | | Flag | Effect |
@@ -56,13 +22,9 @@ Everything after `--` goes to `GameOpts.parse()`:
| `--join` | Skip the menu and connect. | | `--join` | Skip the menu and connect. |
| `--listen` | Skip the menu and host a listen server with a local player. | | `--listen` | Skip the menu and host a listen server with a local player. |
| `--bot` | Scripted input instead of the keyboard. Implies `--join`. | | `--bot` | Scripted input instead of the keyboard. Implies `--join`. |
| `--host`, `--port` | Connection details. | | `--host`, `--port`, `--name` | Connection details. |
| `--name` | Bots only: what a bot calls the character it creates. People name characters on the roster screen; the connect screen no longer asks. |
| `--autoquit N` | Quit after N physics ticks. | | `--autoquit N` | Quit after N physics ticks. |
| `--boss-rush` | Server-side: dungeons spawn the boss and no trash. | | `--boss-rush` | Server-side: new dungeons open straight onto the boss. |
| `--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. | | `--verbose` / `--quiet` | Log level. |
A change is done when `check.sh`, `test.sh` and — if it touched networking, A change is done when `check.sh`, `test.sh` and — if it touched networking,
@@ -81,28 +43,11 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
**The server decides everything; the client only sends intent.** **The server decides everything; the client only sends intent.**
There are exactly **five** client -> server messages, and that number is worth A client can send exactly two things: an [InputFrame] (move vector, aim angle,
watching: 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
| Message | Carries | the whole security model, so don't — validate-after-the-fact is strictly weaker
| --- | --- | than having no code path at all.
| `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.
`SimWorld.authoritative` is `true` on the server and `false` on the client. In `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 replica mode the world runs no AI, fires no emitters and resolves no hits; it
@@ -114,22 +59,11 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| Path | What lives there | | Path | What lives there |
| --- | --- | | --- | --- |
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. | | `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. |
| `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/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/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/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/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. | | `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. | | `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. | | `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. | | `tools/` | Headless tooling. |
@@ -162,21 +96,7 @@ ticks in milliseconds with no SceneTree.
coordinate even on an anchored control; `offset_left`/`offset_top` are the coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD. three pieces of the HUD.
6. **A Control's theme is inherited from Control ANCESTORS only.** The chain 6. **`ProjectSettings.save()` drops settings equal to the engine default** and
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
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`. in `src/main.gd` instead of trusted to `project.godot`.
@@ -196,81 +116,6 @@ ticks in milliseconds with no SceneTree.
dead zone where the server rejects everything and the client never notices — dead zone where the server rejects everything and the client never notices —
the ship and the authoritative position separate permanently. Pinned by the ship and the authoritative position separate permanently. Pinned by
`tests/unit/test_input_lead.gd`. `tests/unit/test_input_lead.gd`.
- **Never send the map, or its seed.** Geometry is streamed per peer in chunks
around that peer's player (`ServerRuntime._stream_map`). The seed would let
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
`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.
Current health comes from the record too (`Character.hp`), and is banked by
`_bank_health` wherever a player leaves a world — miss one and that door
becomes a free heal.
- **One account, one session.** `on_hello` refuses a peer whose account is
already playing; two live players on one character duplicate items.
- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and
`GameOpts.account_override` first.** The default is the player's real store and
account; `screenshot.tscn` once played their character into dungeons.
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
recomputes them from the character's upgrade ids every time, so a saved stat
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
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.
- **The fog works by draw order, not by deciding per object.** `WorldView._draw`
draws terrain and every actor within sight range, then the darkness from
`Visibility` over them, then whatever must stay visible. That is what lets a
tile or an enemy be half lit. Anything new that fog may hide goes *before*
`_draw_darkness`; anything it must not goes after.
- **No enemy bullet may land without having been drawn first.** Bullets fade
beyond the edge of sight, but `BulletRenderer.visibility()` stretches each
enemy bullet's draw distance with its speed so it is always fully on screen
`WARNING_TIME` before it could reach you. A faster bullet in new content is
covered automatically; `test_bullet_draw.gd` fails if it is not. Never hide
bullets by shadow alone — you can walk round a corner into one.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
is drawn after the darkness on purpose; everything else in the view is under it. A
warning you cannot see is an unavoidable hit with extra steps.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is - **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 the only invulnerable state. Do not reintroduce post-hit immunity — it makes
dense patterns safer than sparse ones. dense patterns safer than sparse ones.
@@ -283,13 +128,8 @@ ticks in milliseconds with no SceneTree.
A new enemy or boss is data, never code. Add a builder to A new enemy or boss is data, never code. Add a builder to
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the `src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and 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 add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
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`
builds a boss from scratch and asserts the simulation needs no changes to run 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 it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
an emitter type instead. an emitter type instead.
-63
View File
@@ -1,63 +0,0 @@
# 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.
**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.**
**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.**
**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.
**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.
## Present during development, not used in the game
Kept in the untracked `assetpacks/considering_dont_use_yet/` and referenced by
nothing. Credited for completeness.
**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.
**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).
+20 -36
View File
@@ -7,57 +7,41 @@ Godot 4.7, GDScript, no external runtime dependencies.
```bash ```bash
tools/server.sh # dedicated server on :27015 tools/server.sh # dedicated server on :27015
tools/client.sh --join # connect a client; you name a character on the roster screen tools/client.sh --join --name ada # connect a client
``` ```
Or run one client and press **Host and play** for a listen server. 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 **Controls** — WASD move, mouse aim, LMB fire, **E** on the ring in the hub to
the hub's portals and quartermaster, **1–4** use an inventory slot, **shift+1–4** enter a dungeon, **hold F** for three seconds to escape back to the hub.
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 — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. |
| **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. |
| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
| **Settings** | Rebindable controls and volumes, and an in-game credits screen. |
## Develop ## Develop
```bash ```bash
tools/check.sh # parse-check every script ~5s tools/check.sh # parse-check every script ~5s
tools/test.sh # 576 GUT tests, headless ~4s tools/test.sh # GUT suite, headless ~2s
tools/smoke.sh # server + 5 bot clients over ENet ~40s 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 Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon. 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
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives - [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning - [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/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/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/ROADMAP.md](docs/ROADMAP.md) — what is built and what is next
- [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).
View File
Whitespace-only changes.
-32
View File
@@ -1,32 +0,0 @@
# Raw asset packs
Source material as downloaded: unedited, uncropped, and mostly unused. **Not in
git** — this directory is gigabytes, and the contents are redistributable
artwork and audio that belong wherever you bought or downloaded them, not in a
code repository. Only this README and the `.gdignore` beside it are tracked, so
a fresh clone has the guard in place before anyone drops packs back in.
## Why `.gdignore`
Godot's importer walks everything under `res://`. Without the `.gdignore` here,
`tools/check.sh` — which runs `--import` — would try to import several thousand
PNGs and 2,100 studio-master WAVs on the next run, and every run after it. The
`.gdignore` makes the engine skip this tree entirely: nothing here is imported,
nothing lands in `.godot/imported/`, and nothing reaches an export.
The cost of that is the point: **files here cannot be referenced from a scene or
script.** Godot does not know they exist.
## Getting an asset into the game
Copy the specific files you need into `res://assets/`, converted to something
game-ready first. That directory *is* imported and *is* committed, so it stays
small and every file in it is one the game actually uses.
Audio in particular needs conversion. The Helton Yan pack is 24-bit / 96 kHz
stereo — studio masters, averaging ~2 MB for a single gunshot. Ship 16-bit /
44.1 kHz mono for SFX unless something specifically needs better.
Attribution and licence status for every pack is recorded in
[docs/ASSETS.md](../docs/ASSETS.md). Keep it current — the licence files
themselves are not in git, because the packs are not.
-28
View File
@@ -1,28 +0,0 @@
# Game-ready assets
Assets the game actually references. Committed, imported by Godot, and expected
to stay small.
Everything here should already be in its final form: cropped, atlased where it
helps, audio downsampled to something sane for a game. Raw packs live in
[`assetpacks/`](../assetpacks/README.md), which Godot ignores and git does not
track.
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.
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://ba050ytxjw6uf"
path="res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"
[deps]
source_file="res://assets/audio/sfx/boss_death.wav"
dest_files=["res://.godot/imported/boss_death.wav-0f78bd9c615fc1e1f69662e3e2b02b1e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://b4yijsry80ccc"
path="res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"
[deps]
source_file="res://assets/audio/sfx/enemy_death.wav"
dest_files=["res://.godot/imported/enemy_death.wav-6bae1d1bbf98e070071e748aa5403ba3.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c7abhuhkwrnbi"
path="res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"
[deps]
source_file="res://assets/audio/sfx/player_hit.wav"
dest_files=["res://.godot/imported/player_hit.wav-478748d8be6b3a13fa393c622448db8e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
Binary file not shown.
-24
View File
@@ -1,24 +0,0 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cgxntaxdtxyqp"
path="res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"
[deps]
source_file="res://assets/audio/sfx/shoot.wav"
dest_files=["res://.godot/imported/shoot.wav-f3ce9b4ae60220ecd3cf78df7278a10f.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
-24
View File
@@ -1,24 +0,0 @@
# Local-only assets
Files here are **deliberately not in git**, because their licences forbid
redistribution — and publishing a repository containing them is redistribution.
Currently:
| File | Pack | Restriction |
| --- | --- | --- |
| `bullets.png` | [Fire Pixel Bullet 16x16](https://bdragon1727.itch.io/fire-pixel-bullet-16x16) | "You cannot do: Resell / redistribute this asset." Free tier is non-commercial only. |
| `impact.png` | [750 Effect and FX Pixel All](https://bdragon1727.itch.io/750-effect-and-fx-pixel-all) | Same wording. Even the paid tier: "You can NOT re-distribute the file, no matter how much you modify it." |
The game degrades rather than breaks without them: `Art.bullets_texture()`
returns null and the renderer falls back to a generated dot. That fallback is
what a fresh clone gets, and it is why nothing here may be `preload()`ed —
`preload` is resolved at compile time and would fail the whole build on a
machine that does not have these files.
To restore them locally, copy from `assetpacks/` (also untracked):
```
cp "assetpacks/New_All_Fire_Bullet_Pixel_16x16/All_Fire_Bullet_Pixel_16x16_00.png" assets/local/bullets.png
cp "assetpacks/Effect and FX Pixel All Free/Part 1/03.png" assets/local/impact.png
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

-40
View File
@@ -1,40 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bircs55cjivvx"
path="res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/sprites/dungeon_tileset.png"
dest_files=["res://.godot/imported/dungeon_tileset.png-32ddb268ab9db09e8f122db6a00ca77a.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: 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 -128
View File
@@ -45,43 +45,11 @@ This project deliberately does not, for four reasons:
prototyping and wrong for this shape: it would replicate per-bullet state and prototyping and wrong for this shape: it would replicate per-bullet state and
saturate the link. See [NETCODE.md](NETCODE.md). saturate the link. See [NETCODE.md](NETCODE.md).
The trade is that there is no physics engine, so collision is hand-written. The trade is that there is no physics engine — no arbitrary collision shapes, no
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)): tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight For a bullet hell that is not a limitation; hitboxes in the genre are circles by
for aggro. Actor-vs-actor hits stay circle-vs-circle, which is the genre convention anyway. A dungeon with real geometry would need a static collision
convention anyway. The client's fog no longer shares that line test: it casts representation added to `SimWorld`, not a switch back to nodes.
exact rays through the same grid ([Visibility](../src/view/visibility.gd)), so
shadows cut through tiles rather than snapping to them.
That was the right call rather than a compromise: the same grid answers
collision, sight, and per-peer interest management as array lookups. On arbitrary
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.
## Tick ## Tick
@@ -107,13 +75,6 @@ BossDef = stats + ordered phases
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can `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 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. 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 ## Entry point
@@ -126,95 +87,17 @@ after `--`:
## File map ## 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 | | File | Role |
| --- | --- | | --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. | | `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/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/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/core/settings.gd` | Client-local preferences. Never reaches the server. | | `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `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_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. |
| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. |
| `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. | | `src/sim/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/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/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/sim/poison_track.gd` | Damage over time. O(1) per actor per tick however many doses. | | `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `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/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. |
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
### Network
| 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/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. | | `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/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. The only autoload. | | `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. |
### View and UI (read-only; decides nothing)
| File | Role |
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. |
| `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
### Tools
| File | Role |
| --- | --- |
| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. |
| `tools/test.sh` | The GUT suite, headless. |
| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. |
| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. |
| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. |
| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. |
| `tools/bench.gd` | Simulation cost per tick. |
| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** |
| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. |
-134
View File
@@ -1,134 +0,0 @@
# Asset packs and attribution
The packs themselves are **not in git** (see
[`assetpacks/README.md`](../assetpacks/README.md)), which means their bundled
licence files are not either. This file is the record that survives. Keep it
current when a pack is added or removed.
## How assets get from a pack into the game
`assetpacks/` carries a `.gdignore`, so Godot skips it entirely — nothing there
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
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.
| 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 |
### The blocking clause
Both bdragon1727 packs state, verbatim:
> **You cannot do:** Resell / redistribute this asset.
And the paid tier of the FX pack is no better for this purpose:
> 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.
Putting those files in a public repository distributes them to everyone who
clones it, which is squarely what that forbids. Paying for the commercial tier
does **not** unlock it — the restriction survives payment and survives
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.
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.
### Not in use
`assetpacks/considering_dont_use_yet/` is the "not chosen" marker and nothing
there is referenced. It contains RF Catacombs v1.0 (public domain per its
bundled `public-license.txt` — free for personal or commercial use, credit
appreciated, no reselling; artwork by Szadi art), plus Tiny RPG Character Pack
02 and neo_zero demos, whose terms are unrecorded.
## Converting audio before use
Helton Yan's pack is 24-bit / 96 kHz stereo — studio masters, averaging ~2 MB
per file and 5 MB at the largest. That is roughly a hundred times what a game
needs for a gunshot.
Downsample to 16-bit / 44.1 kHz mono before copying anything into
`res://assets/`. Godot imports `.wav` losslessly and keeps it in memory
uncompressed for low-latency playback, so the source format is what you pay for
in RAM and in export size.
## Where converted assets go
`res://assets/`, which is committed and imported. See
[`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.
-637
View File
@@ -1,637 +0,0 @@
# Settled decisions
Design choices the user has already made, with the reasoning. **Check here
before asking** — re-litigating a settled decision wastes a round trip, and
several of these look like defaults you would otherwise pick differently.
Ordered newest last.
---
## Simulation shape
**Game logic lives in plain `RefCounted` objects, not nodes.** No
`CharacterBody2D`, no `Area2D`, no physics server. Bullet counts make per-bullet
nodes unaffordable, the dedicated server allocates nothing, and the whole suite
runs with no `SceneTree`. Full reasoning in [ARCHITECTURE.md](ARCHITECTURE.md).
**Content is GDScript, not `.tres`.** A boss is a readable diff, there are no
resource UIDs churning in version control, and a test can build content inline.
`tools/export_content.gd` writes `.tres` copies for inspector tuning, but code
is the source of truth — port inspector edits back.
---
## Netcode
**The client sends input and nothing else.** No message exists for position,
hits, damage, or a finished escape. Having no code path is strictly stronger
than validating one.
**Bullets replicate as spawn events, not state.** ~36 bytes once per bullet;
both sides run the identical integration. Only early deaths (a hit, or a wall)
need announcing. Pinned by `tests/integration/test_replica_parity.gd`.
**No lag compensation.** Rewinding the world to a shooter's view would mean a
player who dodged on their own screen still takes the hit. If it becomes a
complaint, lag-compensate *player bullets against enemies only* — never enemy
bullets against players.
**`INPUT_MAX_LEAD` must stay well above `INPUT_LEAD_MAX`.** The server's input
acceptance window has to be wider than the band in which the client re-syncs its
own numbering. When it was not, drifting clocks landed in a dead zone where the
server silently rejected every input and the client never noticed. Pinned by
`tests/unit/test_input_lead.gd`.
---
## Combat feel
**Hitbox is smaller than the sprite** (`PLAYER_RADIUS` 6 vs
`PLAYER_VISUAL_RADIUS` 13), and `PLAYER_MUZZLE_OFFSET` derives from the visual
radius. A visible near-miss reads as fair; an invisible hit does not.
**No invulnerability frames.** Every bullet that touches you lands. I-frames
make dense patterns *safer* than sparse ones, which inverts the genre. Measured
cost: ~13.6s stationary in the Warden's opening phase. `spawn_grace` on entering
a dungeon is the sole exception, and it is a transition, not a combat mechanic.
**No contact damage.** Every threat is a bullet you can see and dodge. The
Stalker carries a point-blank shotgun rather than damaging you by touch.
`tests/unit/test_content.gd` asserts every hostile enemy has an emitter.
---
## Leaving a run
**Escape is a 1-second channel that damage does not interrupt.** An
interruptible channel makes killing the process better than pressing the button.
**A disconnect runs the same channel.** The player stays in the world as
`linkdead`, still killable. All four exits (key, menu button, clean disconnect,
SIGKILL) converge on one server-side path keyed on the socket closing — there is
deliberately no "clean leave" message. `tools/smoke.sh` asserts both the hard
kill and the polite disconnect.
**Boss rooms do not lock.** *(User, this session.)* You can always walk out of a
boss fight, and the boss cannot follow. The consequence: fights cannot rely on
trapping the player, and disengaging is always available.
---
## World
**Tile grid, generated layout, hand-authored boss arenas.** *(User, this
session.)* A grid because collision, line of sight and interest management all
become array lookups; authored arenas because a generated boss room is a bad one
about as often as a good one.
**Hard fog.** *(User, this session.)* No remembered terrain — anything outside
current line of sight is not drawn, including ground already walked over.
**The fog is geometry, not a verdict per tile — and a little less strict.**
*(User asked: "no longer grid based ... able to light up cells partially", and
"a bit less strict". How strict was left to me.)* `Visibility` casts rays from
the eye — a fixed sweep plus two aimed either side of every wall corner, which
is what makes each shadow edge a straight line — and the view draws terrain and
actors first, then darkness over everything the rays did not reach. A shadow
edge runs straight through a tile; an enemy rounding a corner appears a slice
at a time instead of popping in whole.
"Less strict" is three specific things. The face of a wall you can see is lit
26 units deep, measured perpendicular to the face — along the ray it was a
sliver for any wall seen at a grazing angle, and the wall beside you vanished,
which is the complaint the old fog had. Shadows end in an 8-unit fade instead
of a hard line. And the edge of sight fades over its last 64 units instead of
stopping at a circle. Nothing is remembered: still hard fog.
Two lines this deliberately does not cross: light stops the moment a ray steps
back out of the wall it was in, so it never rounds a corner or reaches the far
side of a one-tile wall; and two blocking tiles that touch only at a corner are
closed, not a crack. Bullets are drawn over the darkness — a shot out of the
black is still one you can dodge.
**Bullets fade out past the edge of sight, but never sooner than is safe.**
*(User asked to stop drawing bullets beyond sight, with the condition that
nobody is ever hit by a bullet they could not see.)* Every enemy bullet is
drawn at full strength out to the edge of sight or to however far it could close
in `BulletRenderer.WARNING_TIME` (0.75s) with you running straight at it,
whichever is further, then fades over 80 units. Today the edge of sight is always
further: the fastest enemy bullet is 300 u/s, so even head-on it is on screen
0.83s before it lands. `test_bullet_draw.gd` checks that for every emitter in the
content, and for bullets far faster. Player shots fade at the edge of sight
regardless, since they cannot hurt you. Bullets in shadow *within* sight range
are still drawn: you can step round a corner into one, and hiding it would be
exactly the invisible hit the condition rules out.
**Dungeon size scales with depth.** *(User, this session.)* `--depth N` is a dev
flag; what raises depth in actual play is still open.
**Never send the map, or its seed.** *(User, this session — corrected an earlier
choice of mine.)* Sending `(seed, depth)` and regenerating client-side is far
cheaper on the wire and hands any modified client the entire floor plan. Tiles
stream per peer instead. The accepted trade, in the user's words: a cheater
seeing further than they should is tolerable; seeing the whole map is not.
Consequence to preserve: the client holds *real* geometry it cannot see, because
it predicts movement against walls and simulates bullets that die on them. So
hard fog is a rendering rule, not secrecy. The secrecy is in what the server
declines to send.
---
## Identity and persistence
**Steam-shaped auth abstraction.** *(User, this session.)* The goal is
eventually Steam, so build the shape Steamworks uses and keep it swappable:
client presents an opaque ticket, server validates it and gets a stable 64-bit
account id (SteamID64's stand-in). A local dev provider persists a generated id
in `user://`, so **no Steam account is needed now**.
Not integrating GodotSteam yet: it needs a running Steam client and an app ID,
which would break the "no Steam account" requirement. Swapping it in later
should be one provider class and no schema change.
---
## Progression
**XP from kills, bosses worth far more.** *(User, this session.)* A first full
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 — among everyone who was
there.** Everyone alive receives the full amount for a kill. Splitting it would
make bringing a friend cost you progress, which is the opposite of what the hub
roster exists to encourage.
Presence is a separate question from the split, and it has a radius:
`SimConfig.XP_AWARD_RADIUS`, ten percent past `FOG_VIEW_RADIUS`. Anyone who
watched something die is inside it; a player parked in the entrance room while
the party clears the dungeon is not, and used to collect the whole run. Ten
percent past sight rather than exactly on it, so the edge of the fog is
comfortably inside and nobody loses a kill they actually saw. One consequence
worth knowing: an upgraded shot outranges this several times over, so a kill
made somewhere you cannot see pays nothing.
**A level-up heals by the amount it added.** Gaining a level mid-fight should
feel like relief, not like the bar you were watching got further from full.
**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 in a dungeon, with no
out-of-combat gate.** A percentage rather than a flat rate, so it does not become
irrelevant at level 15 — a capped character regains 1.2 hp/s against a level 1's
0.5. No combat gate because at this rate it cannot out-heal anything actually
shooting at you, and a trickle that never stops is easier to reason about than a
timer players have to learn.
**The hub regenerates at 10% per second, and health is carried through a door.**
Arriving anywhere used to set health to maximum, which meant a run cost nothing:
walk in, take what you can, walk out, walk back in whole. Current health now
lives on the character record — the same reason the inventory does, since
`SimWorld` builds a blank player on every instance change — and the hub is where
it is paid off, over about ten seconds. Fast enough that recovering is not a
chore, slow enough that it is a decision to make before walking back in. The
dungeon rate is unchanged, so waiting inside one is still not a plan.
More friction is expected here later; this is the floor, not the design.
**A dead character is gone, as far as the player is concerned.** Retirement is
the server's own bookkeeping for archival and troubleshooting; the roster the
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.
---
## The anti-cheat audit, and the rules it left behind
*(User asked for an audit for design flaws and cheats, then "fix these".)* Each
of these was found by reading the server's code paths and then proving the
problem with a probe before fixing it.
**One account, one session — and the newcomer is refused, not the incumbent.**
Two peers on one account each got a player built from the same character record,
so both held a copy of one bag and whichever wrote last won: drop the one potion
from both copies and two potions hit the floor. Refusing the second connection is
the only fix that closes it. The *new* peer is the one turned away because, with
an auth provider anyone can spoof, "kick whoever holds this account" would let
anyone disconnect anyone. A dropped player's linkdead body still holds the
account until its escape channel resolves, which is deliberate: reconnecting
during that second would put the character in the world twice.
**A rejection must reach the client.** `Net.reject_and_kick` defers the kick,
because an RPC only leaves on the frame's flush and kicking in the same call threw
the reason away — a protocol mismatch used to arrive as "server closed the
connection".
**Bullets are never born on the far side of geometry.** A muzzle offset is a
teleport the pool never sees. The player's shot falls back to the shooter's
centre when blocked (six Doubleshots used to put shots through a one-tile wall);
an enemy's shot is dropped (boss curtains were spawning through the arenas' own
pillars and barricades). A telegraphed burst is checked from its marker, not from
the boss.
**Bullet spawns are culled per bullet, not by a flat radius.** Each spawn event
carries the shooter's exact position, and the flat 2900-unit radius exceeded the
whole depth-1 map, so every firing enemy was located for every player.
`ServerRuntime._bullet_concerns` asks whether *this* bullet can reach ground the
player can see.
**No packet buys unbounded work.** Selecting a character you already hold is a
no-op; select and create are paced per peer (`CONTROL_COOLDOWN_TICKS`), and the
pacing logs once per burst, not once per packet. Item moves and experience grants
mark the store dirty and it is written once per tick at most.
**The hub has no escape channel.** Completing it there meant transferring into
the instance you were already in — teleport, full heal, map re-streamed.
**A player's drop is refused when the floor is full.** The cap evicts the oldest
item, which is right for a dungeon's own drops and was wrong for the hub, where it
let a player destroy whatever someone else had left there longest.
## Health, experience and a debug dungeon
**The Proving Grounds is a debugging tool and does not ship.** *(User.)* It pays
full experience for a boss with 8% of its health. Remove it from `Dungeons.ORDER`
and its `P` marker before a release — see the roadmap.
## Tunnels and what is in them
**Tunnels are 2 to 4 tiles wide, rolled per leg.** *(User: "a varied width with a
minimum of 2".)* A one-tile corridor in a bullet hell is a shooting gallery. The
width is per leg of each L, not varying along a leg; that was the simpler reading
and the user has not asked for the other. The arena is excluded from every carve —
an L that bent the wrong way used to slit 45% of arenas' west walls open.
**Now and then a tunnel has a group in it.** *(User: "rarely".)* A 10% chance per
leg of two or three of one kind; about one depth-1 run in three gets one. Never
within aggro range of the entrance, checked per member.
**Nothing can be watching the entrance when you arrive.** Room 0 was always left
empty for this, but only room 0 — and wider tunnels tripled how often a
neighbouring room could see the entrance (25 to 75 runs in 200). Enemies that
would be awake on arrival are skipped: about one in sixteen. Clearing everything
within aggro range would have removed nearly a third, which is a balance change
the user has not asked for.
## Interface
**Every key a prompt names is the one actually bound.** All actions are
rebindable; prompts go through `Settings.short_label()`.
**The boss bar waits until the boss has been seen.** The server sends the boss
through walls from 800 units, and the bar used to announce it to a player still in
the entrance room. It appears once the fog has lit the boss and stays for the
fight. A judgement call, flagged to the user as reversible.
**The controls hint fades six seconds into a dungeon** and stays in the hub. Also
a judgement call; one constant (`Hud.HINT_HOLD`).
**The connect screen asks for no name.** The field it had only named the
characters bots create; people name characters on the roster screen.
**Getting hit reddens the screen's edge, not the whole screen** — a full-screen
tint dimmed the bullets at the moment you were among them.
+10 -238
View File
@@ -9,26 +9,17 @@ prediction and reconciliation for the local player.
client server (60 Hz) client server (60 Hz)
| | | |
|-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort |-- InputFrame (60 Hz, x3) ---->| queue_input(): validate, drop, sort
| move, aim, 5 button bits, | SimWorld.step(): move, AI, emitters, | move, aim, 3 button bits | SimWorld.step(): move, AI, emitters,
| inventory slot | integrate bullets, resolve hits, items | | integrate bullets, resolve hits
|<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths, |<-- events (reliable, ch 3) ---| bullet spawns/despawns, hits, deaths
| | item pickups/uses/drops |<-- snapshot (unrel., ch 2) ---| players, enemies, boss @ 20 Hz
|<-- snapshot (unrel., ch 2) ---| players, enemies, boss, ground loot,
| | your own inventory @ 20 Hz
| | | |
predict locally, reconcile authoritative for everything predict locally, reconcile authoritative for everything
``` ```
The client has no message that expresses a position, a hit, damage taken, a The client has no message that expresses a position, a hit, damage taken, or a
completed escape, or an item it now owns. This is deliberate and stronger than completed escape. This is deliberate and stronger than validating such messages
validating such messages after the fact: there is no code path to exploit, only after the fact: there is no code path to exploit, only intent to interpret.
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.
## Why bullets are not replicated as state ## Why bullets are not replicated as state
@@ -113,195 +104,6 @@ Two defences now:
`tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap `tools/diag_prediction.gd` injects a +14 tick drift mid-run and asserts the gap
returns to normal; it exits non-zero if it does not. returns to normal; it exits non-zero if it does not.
### Every exit runs the same channel
There is deliberately no "I am leaving cleanly" message. The escape channel is
keyed on the socket closing, so all four ways out converge on it:
| Route | Path |
| --- | --- |
| Hold F | `BTN_ESCAPE` -> channel |
| Menu -> "Return to hub" | synthesises a held `BTN_ESCAPE` -> channel |
| Menu -> "Disconnect to menu" | `Net.shutdown()` -> socket close -> `linkdead` -> channel |
| Killing the process / pulling the cable | socket close -> `linkdead` -> channel |
The last two are the same server-side code path, and `tools/smoke.sh` asserts
both: one bot is SIGKILLed mid-dungeon, another calls the same `Net.shutdown()`
the menu button calls. Adding a clean-leave message that skipped the channel
would break that test, which is the point of having it.
The menu says so out loud when you are in a dungeon -- the mechanic only reads
as fair if the player knows the cost before clicking.
## Maps are streamed, never sent
A dungeon's geometry reaches a client as **chunks around that client's own
player**, and nothing else. In particular the server does *not* send the
generation seed, even though maps are a pure function of (kind, seed, depth) and
sending two integers would be far cheaper: a client holding the seed can
regenerate the entire dungeon locally, which is a complete map hack requiring no
skill. Streaming bounds what a modified client can know to roughly where its
player has physically been.
`MAP_STREAM_RADIUS` (900u) is deliberately wider than `FOG_VIEW_RADIUS` (460u).
The client needs real geometry it cannot see, because it predicts its own
movement against walls and simulates bullets that die on them. The accepted
trade is that a cheater sees somewhat further than the fog shows -- what they
cannot get is the floor plan.
Two consequences fall out of partial knowledge:
- **Wall deaths are announced.** A bullet dying to its lifetime or by leaving
the map is derivable from the spawn event and the map's dimensions, so it
costs nothing. A bullet dying against a *wall* is not derivable by a client
that has not been streamed that wall, so the server sends an explicit
`BULLET_DESPAWN`. Getting the order of those two checks wrong is easy and was
caught by a test: out-of-bounds tiles read as `WALL` by design, so testing
geometry before bounds reports every bullet that merely left the map as a
wall kill.
- **Unknown tiles are treated as empty**, not solid, so an unstreamed region can
never wrongly stop a prediction. The stream radius keeps far enough ahead of
the player that this never decides anything visible.
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
lives in what the server declines to send: terrain outside the stream radius,
and actors outside the interest radius (below).
## Actors are scoped per peer
Snapshots are encoded **once per peer**, not once and broadcast: an actor
further than `ACTOR_INTEREST_RADIUS` (800u) from a player is never sent to that
player. Before this, fog was hiding enemies the client had already been handed,
which is no defence at all against a modified client.
Measured cost of encoding four filtered snapshots instead of one shared:
95.6µs against 24.9µs, which amortised over the 3-tick snapshot interval is
0.032 ms/tick — 0.2% of the frame budget.
Two rules fall out of it:
- **The observer's own player record is never filtered**, however far outside
the radius the arithmetic puts it. The client reconciles its prediction
against that record; dropping it would break the player's own movement rather
than merely hide someone.
- **Bullet despawns are not filtered, only spawns.** A client that was told
about a bullet must always be told it died, or it keeps a phantom until the
lifetime expires.
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
at its speed and acceleration over its remaining life, could reach ground the
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
withheld at spawn that later flies into view is invisible damage — so the rule
errs long.
It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the
longest-travelling shot in the game. That was a leak: every spawn event carries
the shooter's exact position, and at 2900 units the radius exceeded the entire
depth-1 map, so every firing enemy anywhere on the floor was located for every
player — past the actor cull, past the map stream, through the fog. The constant
survives only as a hard ceiling, and `test_interest.gd` checks both: that no real
enemy's bullet is announced across a dungeon, and that the ceiling still covers
the longest shot in the game.
The same rule decides what is sent to a player arriving mid-fight
(`_live_bullet_events`), which is the moment they have seen least of the map.
## Loot has two visibilities, and one of them is an interest rule
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
dodge, which is the contract the genre runs on; an enemy that damages you for
occupying the same space is an unavoidable, unreadable hit.
The Stalker used to be exactly that -- it walked at you and dealt contact
damage. It now carries a point-blank shotgun instead: five pellets, 62 degrees,
and an 18-tick lifetime that gives it about 78px of reach, so it still has to
close the distance and still leaves nothing lingering in the arena.
`tests/unit/test_content.gd` asserts that every hostile enemy has at least one
emitter, so a new enemy cannot quietly reintroduce the mechanic.
## No invulnerability frames ## No invulnerability frames
A hit grants no immunity — every bullet that touches you deals its damage. In a A hit grants no immunity — every bullet that touches you deals its damage. In a
@@ -324,16 +126,10 @@ transition, not a combat mechanic.
| Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` | | Input claiming the future | `f.tick > tick + INPUT_MAX_LEAD` |
| Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped | | Flood | queue capped at `INPUT_MAX_AGE`, oldest dropped |
| Leaving the hub early after death | `RESPAWN_LOCKOUT_TICKS`, server-side | | 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 lives in `SimWorld`, not only in the HUD's disabled button — The respawn lockout is worth calling out: the HUD disables its button for the
but note what reaches it. A connected player is unbound on the tick it dies and same three seconds, but that is presentation. A client that ignores its own UI
sent to the roster screen (a settled decision; see DECISIONS.md), so it never and holds the bit down still waits, because the check lives in `SimWorld`.
waits at all. The lockout governs the *linkdead* body, which has nobody to show a
roster to and asks for itself.
Beyond that, the wire format itself constrains the cheat surface: the move Beyond that, the wire format itself constrains the cheat surface: the move
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()` vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
@@ -424,30 +220,6 @@ the snapshot stream:
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected `Protocol.VERSION` is checked at handshake and mismatched clients are rejected
with a reason, rather than desyncing later. with a reason, rather than desyncing later.
## What the handshake and the server refuse
- **One account, one session.** A second connection claiming an account that is
already playing is refused — the newcomer, not the incumbent, or a spoofed
claim would kick anyone off. Two peers on one account each held a copy of one
character's bag, which duplicated items. A linkdead body still holds its
account until its escape channel resolves.
- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks
after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in
the same call discarded it, and the client's disconnect handler overwrote it
anyway. Both are fixed; smoke asserts a refused client reads its reason.
- **No small packet buys large work.** Character select and create rewrite the
whole store, re-place the player and broadcast a roster, so they are paced per
peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character
you hold is a no-op. Item moves and experience grants — the two persistence
paths a client can trigger every tick — mark the store dirty, and it is written
once per tick at most (`CharacterStore.save_deferred` / `flush`).
- **The two hot decoders check their bounds.** A short or hostile snapshot
decodes as `valid: false` and is dropped; the snapshot channel is unreliable
with ENet's checksum off, so this is a network event, not only an attack. Event
lengths are declared per type (`NetCodec.event_payload_size`), and an unknown
type stops the stream rather than unaligning everything after it. Both size
tables are pinned to their encoders by tests.
## Instances ## Instances
One server process hosts the lobby hub plus every concurrent dungeon. An One server process hosts the lobby hub plus every concurrent dungeon. An
+63 -554
View File
@@ -1,558 +1,67 @@
# Status and roadmap # Roadmap
**Read this first when picking up work.** It maps every feature in the design ## Built
brief to its current state and the files that implement it, so you can find the
relevant code without re-reading the whole project.
Art and audio source packs, and their licence status, are recorded in | MVP requirement | State |
[ASSETS.md](ASSETS.md) — the packs themselves are not in git.
Design decisions that are already settled — and the reasoning behind them — live
in [DECISIONS.md](DECISIONS.md). Check there before asking the user something
that may already have an answer.
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
---
## 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.
Since then, in one long session:
- **An anti-cheat audit, and its fixes.** Item duplication through two sessions
on one account; shots through walls from muzzle offsets (the player's and every
boss's); every firing enemy located by the flat bullet-interest radius;
unbounded store writes from small packets; the hub's escape channel; hub loot
griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under
"The anti-cheat audit".
- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes
only to players within `XP_AWARD_RADIUS` of the kill.
- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)),
and **bullets fade past the edge of sight** without ever arriving unseen.
- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the
entrance on arrival, and arenas that keep their walls.
- **Two interface passes** — see the Interface section of DECISIONS.md.
- **The smoke flake is fixed** (below).
So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.**
| 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. |
And smaller things raised with the user and not yet answered or chosen:
| Open item | What is known |
| --- | --- | | --- | --- |
| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. | | Top-down 2D twin-stick bullet hell | Movement, aim, fire, i-frames, death and respawn; MultiMesh bullet rendering. |
| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. | | Multiplayer with server-side hit validation | Authoritative dedicated server; clients send input only; all hits resolved server-side for both players and enemies. No PvP. |
| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. | | Simple, predictable enemies | Drifter, Turret, Stalker, plus a lobby dummy. Five movement behaviours, all closed-form. |
| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. | | Stationary boss, adaptable format | The Warden of the Fold: four phases, data-defined. A new boss is one function and zero simulation changes. |
| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. | | Lobby hub with dungeon entry | Persistent lobby instance, portal, party forming window, dungeons opened on demand. |
| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. | | Emergency escape | One-second server-owned channel, cancelled by release but not by damage; a disconnect runs the same channel so quitting is never a cheaper exit. |
| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). | | Leaving and arriving | Downed players return to the hub on request (no timed respawn); entering a dungeon grants 2s of invulnerable, weapons-cold arrival protection. |
| Hub awareness | Server-pushed roster showing who is online and which dungeon they are in. |
Each stage below also carries its own **Known gaps** section — those are smaller | In-game menu | Escape opens return-to-hub / disconnect / quit. |
and more specific than the table above.
91 tests plus an end-to-end smoke test over a real socket, including a bot that
### The smoke flake (diagnosed and fixed) is SIGKILLed mid-dungeon to prove the disconnect path.
`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose ## Next, in rough order of value
logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1,
whose account picks the Proving Grounds, had gone into the Choir Vault. The **1. Make the dungeon a place.** Right now it is one arena and three stages.
bot wandered for 90 of every 120 ticks in the hub and then walked to its Rooms, doors, and a `SimWorld` with static geometry — which means adding a
entrance holding interact the whole way; the three entrances sit in a row, so a collision representation for walls (segment-vs-circle for players, segment
wander that ended near the middle one carried it across the wrong ring with the crossing for bullets) since there is no physics engine to lean on.
key down. Which dungeons opened depended on where a timing-dependent wander
stopped. The bot now walks straight to its own entrance and presses interact **1b. Progression, and what it turns on.** Several things are currently sized
only on it (`ClientRuntime._bot_input`). If smoke fails again, push with for "no persistence yet" and should be revisited together with it: death sends
`KEEP=1 git push` so the logs survive. you to the hub rather than costing anything, a linkdead body is simply deleted
once it channels out (there is no hub state to put it in), and `peer_names` is
--- client-supplied and trusted for display. All three are fine now and none of them
are once a character has anything worth losing.
## Verification surface
**2. Art and feel.** Everything is drawn with `draw_circle` and a generated dot
What "everything passes" currently means. Numbers move; the shape does not. texture. Sprites, hit flashes, screen shake, muzzle flashes, death effects, and
sound. None of it touches the simulation — this is entirely `src/view/`.
| Gate | Covers | Runtime |
| --- | --- | --- | **3. A second boss.** The format claims to be reusable; the way to find out is
| `tools/check.sh` | every script parses and type-checks | ~5s | to use it. A mobile boss will exercise `BossDef.stationary`, which the runtime
| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s | already reads but no content uses yet.
| `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~40s |
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | **4. Progression.** Loot, character stats, persistence. Needs a decision on
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | storage: for a dedicated server, player state belongs server-side in a database,
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s | not in a client save file.
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
**5. Netcode hardening.** In the order they will actually matter:
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display, - DTLS on `ENetMultiplayerPeer` before any public server.
and it is the only thing in the project that can tell you the interface - Authentication; `peer_names` is currently client-supplied and trusted for
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures display only, which is fine now and will not be once there is progression.
the connect screen, the hub standing on a portal, the pause menu, settings, - Snapshot delta compression, once enemy counts grow.
credits, the upgrade choice, the roster after a death, a dungeon arrival, a - Interest management inside an instance, once arenas are larger than a
tunnel (with and without F1), a staged boss fight, and the escape banner. It runs screen.
on **its own character store and account** — it used to host on the player's
real ones — and stages what it cannot earn (an offer, a party, a death). **6. Scale.** The simulation costs ~1.4% of a core per instance, so the ceiling
Timings are frame counts and frames run as fast as the display allows, so is bandwidth and process supervision, not CPU. A shard manager that runs several
anything tied to server ticks is captured by condition instead (see the escape server processes behind a lobby-of-lobbies is the shape, but it is premature
shot). until there is a game to fill it.
It has now caught four bugs that every other gate passed: the theme applying to ## Deliberately not done
nothing, the test suite rewriting the player's settings, an invisible slider
track and an invisible scrollbar. - **Lag compensation.** Discussed in [NETCODE.md](NETCODE.md): rewinding for a
shooter's view would mean a player who dodged still gets hit, which is the
The four diagnostics exist because the smoke test structurally cannot reach wrong trade for this genre.
what they cover: bots are poor shots (so they neither level up, produce drops, - **Pattern-level bullet replication.** A large bandwidth win that couples the
nor walk to the quartermaster), and a listen server cannot drift its own clock client to emitter behaviour. Not worth it until bandwidth binds.
against itself.
---
## Stage 1 — World and exploration · *done*
| Feature | State | Where |
| --- | --- | --- |
| Tile grid: movement / bullet / sight blocking | done | [src/sim/map_grid.gd](../src/sim/map_grid.gd) |
| Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) |
| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) |
| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables |
| Enemies placed per room at creation | done | `Instance._populate()` |
| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on |
| Prompts name the keys actually bound | done | `Settings.short_label()` |
| Health bar: dark track, number inside, damage trail | done | `Hud._bar`, `Hud._watch_health` |
| Centre banners: level-ups, boss phases, leaving | done | `Hud.banner`, `Hud.track_boss` |
| Bullets fade out beyond sight, never sooner than is safe | done | `BulletRenderer.visibility()` — every enemy bullet is drawn at least 0.75s before it could land |
| Markers pointing to party members out of sight | done | `Hud._draw_party_markers` |
| Connect screen: live boss pattern behind it, port validation, credits | done | [src/ui/main_menu.gd](../src/ui/main_menu.gd), [src/ui/menu_backdrop.gd](../src/ui/menu_backdrop.gd) |
| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` |
| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` |
| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance |
| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) |
| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` |
| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` |
| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` |
| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` |
| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. |
| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. |
### The invariants that matter most here
**Maps are streamed per peer, and the generation seed is never sent.** See
[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must
stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against
terrain it does not have.
**Three radii, deliberately different, and each has a floor it must respect:**
| Radius | Value | Must exceed | Why |
| --- | --- | --- | --- |
| `FOG_VIEW_RADIUS` | 460 | — | What the player can see. |
| `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. |
| `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. |
| `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. |
| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. |
Fog is a *rendering* rule and defends nothing on its own — a modified client
draws whatever it holds. The defence is what the server declines to send.
---
## 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.
**It is a debugging tool and does not ship.** It pays exactly the same
experience a real run does — `Progression.xp_for_boss` ignores its argument and
`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not
touch — while its boss has 8% of its health and its trash drops ten times as
often. That makes it strictly the best place to level and to farm, which is
correct for a harness and would be ruinous in a release. Remove it from
`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or
give `DungeonDef` an `xp_mult` alongside the three multipliers it already has.
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
two have to stay in the same order.
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
play off server events. Enough to prove the pipeline, not a finished look.
| Feature | State | Where |
| --- | --- | --- |
| Atlas/sound table in one place | done | [src/view/art.gd](../src/view/art.gd) |
| Terrain, actors, boss from the 0x72 atlas | done | `WorldView._draw_tile` / `_draw_sprite` |
| Animated bullet sprites, one MultiMesh per kind | done | [src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd) |
| SFX pool driven by server events | done | [src/view/sfx.gd](../src/view/sfx.gd) |
| 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. |
| 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*
| 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 1–15, +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 |
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.
### 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, **1–4** use a slot, **shift+1–4** 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).
---
## The scale of health and damage
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.
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.
---
## Stage 4 — Upgrades · *done*
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.**
---
## Stage 5 — Boss features and new bosses · *done*
| 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()` |
### 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 is covered by the darkness; 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.
---
## Deliberate omissions
Not oversights — each was considered and rejected for now, with the reasoning in
[NETCODE.md](NETCODE.md) or [DECISIONS.md](DECISIONS.md):
- **Lag compensation.** Rewinding to a shooter's view means a player who dodged
still gets hit; wrong trade for this genre.
- **Snapshot delta compression.** Fine at current actor counts.
- **DTLS / encryption.** `ENetMultiplayerPeer` supports it. Required before any
public server, not before then.
- **Pattern-level bullet replication.** A real bandwidth win that couples the
client to emitter behaviour.
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape - **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape
for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md). for a bullet hell. See [ARCHITECTURE.md](ARCHITECTURE.md).
---
## 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.
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.
+2 -142
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: per tick. Current numbers, 4 players, 60s of play:
``` ```
boss fight 0.26 ms/tick peak ~300 bullets headroom x64 boss fight 0.237 ms/tick peak 352 bullets headroom x70
trash wave 0.16 ms/tick peak ~82 bullets headroom x103 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 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 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 concurrent dungeons, and it took ten seconds to get because the simulation has
@@ -170,94 +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 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. 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.
### Slicing between two headings deletes whatever was between them
Replacing a section by cutting from its heading to "the next big heading" took
**451 lines** of the roadmap with it — every stage section sat between the two —
and it was committed and pushed before anything noticed, because no gate reads
prose. End a slice at the marker that actually closes the section (here the
`---` rule after it), and before committing any doc edit, check
`git diff --numstat` for a deletion count bigger than the edit you meant.
### A mutation that survives is a test that cannot fail
Twice in one session a new test passed with its fix reverted. One staged a turret
behind a barricade — a turret's muzzle offset cannot clear its own radius plus a
tile, so the scenario could never have failed. Another computed its sample point
*from* the constant under test, so zeroing the constant moved the sample instead
of failing it. Reverting the fix and watching the test go red is the only way to
know it tests anything.
### A flaky gate needs its logs kept, not rerun
The smoke test failed intermittently for weeks, always in the pre-push hook,
which discards logs; every rerun passed and the trail went cold. Pushing with
`KEEP=1 git push` kept the third failure's logs, which named the cause in one
grep (a bot walking through the wrong portal). Keep the evidence first; rerun
second.
### A tool that hosts a server must not use the player's data
`screenshot.tscn` hosted a listen server with the default character store and
account — the player's own — and walked their character into dungeons for
pictures. Every diagnostic already set `GameOpts.store_path` and
`GameOpts.account_override`; the screenshot tool had simply never been asked to.
Anything new that calls `Net.host()` sets both first.
## CI ## CI
`.github/workflows/ci.yml` runs check → test → smoke on a container image with `.github/workflows/ci.yml` runs check → test → smoke on a container image with
@@ -269,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) - [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: 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) - [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) "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] [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] [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="1_7m6h6"]
[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="2_b0fvw"]
[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="3_f5abt"]
[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="4_tmo3e"]
[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="5_rswgd"]
[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="6_v02no"]
[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="7_x6ufy"]
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_axg03"]
[sub_resource type="Resource" id="Resource_s17mk"] [sub_resource type="Resource" id="Resource_bbcbd"]
script = ExtResource("1_b0fvw") script = ExtResource("3_f5abt")
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")
count = 14 count = 14
spin_per_shot_deg = 9.0 spin_per_shot_deg = 9.0
muzzle_offset = 46.0 muzzle_offset = 46.0
@@ -27,56 +17,54 @@ interval = 45
speed = 135.0 speed = 135.0
radius = 8.0 radius = 8.0
lifetime = 420 lifetime = 420
damage = 140 damage = 14
[sub_resource type="Resource" id="Resource_a0mrn"] [sub_resource type="Resource" id="Resource_axg03"]
script = ExtResource("5_v02no") script = ExtResource("4_tmo3e")
spread_deg = 26.0 spread_deg = 26.0
muzzle_offset = 46.0 muzzle_offset = 46.0
start_tick = 120 start_tick = 120
interval = 150 interval = 150
speed = 210.0 speed = 210.0
radius = 6.0 radius = 6.0
damage = 120
kind = 2 kind = 2
[sub_resource type="Resource" id="Resource_pfjxl"] [sub_resource type="Resource" id="Resource_s17mk"]
script = ExtResource("2_f5abt") script = ExtResource("1_7m6h6")
name = "Opening Fold" name = "Opening Fold"
loop_ticks = 480 loop_ticks = 480
telegraph_ticks = 60 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"] [sub_resource type="Resource" id="Resource_0452f"]
script = ExtResource("4_rswgd") script = ExtResource("3_f5abt")
count = 4 count = 4
spin_per_shot_deg = 23.0 spin_per_shot_deg = 23.0
muzzle_offset = 46.0 muzzle_offset = 46.0
interval = 9 interval = 9
speed = 120.0 speed = 120.0
lifetime = 480 lifetime = 480
damage = 120
[sub_resource type="Resource" id="Resource_k1e8f"] [sub_resource type="Resource" id="Resource_1p7x0"]
script = ExtResource("6_x6ufy") script = ExtResource("5_rswgd")
count = 20 count = 20
gap_step = 7 gap_step = 7
start_tick = 90 start_tick = 90
interval = 170 interval = 170
speed = 175.0 speed = 175.0
radius = 8.0 radius = 8.0
damage = 160 damage = 16
kind = 3 kind = 3
[sub_resource type="Resource" id="Resource_4sp1g"] [sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("2_f5abt") script = ExtResource("1_7m6h6")
name = "Sliding Lattice" name = "Sliding Lattice"
enter_at_hp_fraction = 0.72 enter_at_hp_fraction = 0.72
telegraph_ticks = 50 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"] [sub_resource type="Resource" id="Resource_pfjxl"]
script = ExtResource("7_bbcbd") script = ExtResource("6_v02no")
arms = 3 arms = 3
bullets_per_arm = 2 bullets_per_arm = 2
arm_spacing = 26.0 arm_spacing = 26.0
@@ -85,10 +73,10 @@ sweep_period = 5.0
interval = 5 interval = 5
speed = 165.0 speed = 165.0
lifetime = 400 lifetime = 400
damage = 130 damage = 13
[sub_resource type="Resource" id="Resource_3fm5w"] [sub_resource type="Resource" id="Resource_txml3"]
script = ExtResource("5_v02no") script = ExtResource("4_tmo3e")
count = 7 count = 7
spread_deg = 40.0 spread_deg = 40.0
jitter_deg = 3.0 jitter_deg = 3.0
@@ -97,11 +85,10 @@ start_tick = 60
interval = 110 interval = 110
speed = 195.0 speed = 195.0
radius = 6.0 radius = 6.0
damage = 120
kind = 2 kind = 2
[sub_resource type="Resource" id="Resource_20c8f"] [sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("4_rswgd") script = ExtResource("3_f5abt")
count = 18 count = 18
spin_per_shot_deg = -14.0 spin_per_shot_deg = -14.0
start_tick = 240 start_tick = 240
@@ -109,18 +96,17 @@ end_tick = 600
interval = 60 interval = 60
speed = 105.0 speed = 105.0
lifetime = 420 lifetime = 420
damage = 120
[sub_resource type="Resource" id="Resource_ymr56"] [sub_resource type="Resource" id="Resource_4sp1g"]
script = ExtResource("2_f5abt") script = ExtResource("1_7m6h6")
name = "Reaping Arms" name = "Reaping Arms"
enter_at_hp_fraction = 0.42 enter_at_hp_fraction = 0.42
loop_ticks = 720 loop_ticks = 720
damage_taken_mult = 1.15 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"] [sub_resource type="Resource" id="Resource_aldxl"]
script = ExtResource("4_rswgd") script = ExtResource("3_f5abt")
count = 5 count = 5
spin_per_shot_deg = 31.0 spin_per_shot_deg = 31.0
muzzle_offset = 46.0 muzzle_offset = 46.0
@@ -128,45 +114,44 @@ interval = 11
speed = 130.0 speed = 130.0
turn_deg = 0.55 turn_deg = 0.55
lifetime = 400 lifetime = 400
damage = 150 damage = 15
[sub_resource type="Resource" id="Resource_3n6oq"] [sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("6_x6ufy") script = ExtResource("5_rswgd")
direction = 2 direction = 2
count = 16 count = 16
start_tick = 40 start_tick = 40
interval = 130 interval = 130
speed = 200.0 speed = 200.0
radius = 8.0 radius = 8.0
damage = 180 damage = 18
kind = 3 kind = 3
[sub_resource type="Resource" id="Resource_8ppic"] [sub_resource type="Resource" id="Resource_20c8f"]
script = ExtResource("5_v02no") script = ExtResource("4_tmo3e")
count = 3 count = 3
spread_deg = 10.0 spread_deg = 10.0
start_tick = 100 start_tick = 100
interval = 80 interval = 80
speed = 300.0 speed = 300.0
radius = 5.0 radius = 5.0
damage = 160 damage = 16
kind = 2 kind = 2
[sub_resource type="Resource" id="Resource_yqxmm"] [sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("2_f5abt") script = ExtResource("1_7m6h6")
name = "Collapse" name = "Collapse"
enter_at_hp_fraction = 0.16 enter_at_hp_fraction = 0.16
loop_ticks = 420 loop_ticks = 420
telegraph_ticks = 60 telegraph_ticks = 60
damage_taken_mult = 1.3 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] [resource]
script = ExtResource("8_axg03") script = ExtResource("7_x6ufy")
id = &"warden" id = &"warden"
display_name = "Warden of the Fold" display_name = "Warden of the Fold"
max_hp = 36000 max_hp = 3600
radius = 42.0 radius = 42.0
spawn_pos = Vector2(0, -150) spawn_pos = Vector2(0, -150)
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")]) phases = Array[ExtResource("1_7m6h6")]([SubResource("Resource_s17mk"), SubResource("Resource_a0mrn"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56")])
loot = Array[ExtResource("1_b0fvw")]([SubResource("Resource_s17mk"), SubResource("Resource_0452f")])
+9 -16
View File
@@ -1,30 +1,23 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3] [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/bullet_emitter.gd" id="1_801wv"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_e6oj1"] [ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_bjhtk"]
[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="3_e6oj1"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_k36hi"]
[sub_resource type="Resource" id="Resource_rsrg2"] [sub_resource type="Resource" id="Resource_vpd04"]
script = ExtResource("2_e6oj1") script = ExtResource("2_bjhtk")
count = 3 count = 3
spread_deg = 20.0 spread_deg = 20.0
interval = 120 interval = 120
radius = 6.0 radius = 6.0
lifetime = 240 lifetime = 240
damage = 100 damage = 10
[sub_resource type="Resource" id="Resource_lnbnj"]
script = ExtResource("3_vpd04")
item = &"health_potion"
chance = 0.08
[resource] [resource]
script = ExtResource("4_k36hi") script = ExtResource("3_e6oj1")
id = &"drifter" id = &"drifter"
display_name = "Drifter" display_name = "Drifter"
max_hp = 400 contact_damage = 8
speed = 55.0 speed = 55.0
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")]) emitters = Array[ExtResource("1_801wv")]([SubResource("Resource_vpd04")])
pattern_loop_ticks = 120 pattern_loop_ticks = 120
loot = Array[ExtResource("3_vpd04")]([SubResource("Resource_lnbnj")])
+5 -6
View File
@@ -1,15 +1,14 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3] [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/sim/patterns/bullet_emitter.gd" id="1_ajtny"]
[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="2_i7w5w"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="3_2tv1l"]
[resource] [resource]
script = ExtResource("3_2tv1l") script = ExtResource("2_i7w5w")
id = &"dummy" id = &"dummy"
display_name = "Target Dummy" display_name = "Target Dummy"
max_hp = 1000 max_hp = 100000
radius = 20.0 radius = 20.0
contact_damage = 0
move = 0 move = 0
visual = 3 visual = 3
indestructible = true
+5 -25
View File
@@ -1,36 +1,16 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3] [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/bullet_emitter.gd" id="1_dkoo8"]
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="2_ehenj"] [ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="2_t1lyl"]
[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"]
[sub_resource type="Resource" id="Resource_oeksl"]
script = ExtResource("2_ehenj")
spread_deg = 62.0
muzzle_offset = 10.0
interval = 45
speed = 260.0
radius = 6.0
lifetime = 18
damage = 140
kind = 3
[sub_resource type="Resource" id="Resource_ep81w"]
script = ExtResource("3_tpjo4")
item = &"health_potion"
chance = 0.08
[resource] [resource]
script = ExtResource("4_yp71e") script = ExtResource("2_t1lyl")
id = &"stalker" id = &"stalker"
display_name = "Stalker" display_name = "Stalker"
max_hp = 300 max_hp = 30
radius = 12.0 radius = 12.0
contact_damage = 16
move = 3 move = 3
speed = 95.0 speed = 95.0
retarget_interval = 20 retarget_interval = 20
visual = 2 visual = 2
emitters = Array[ExtResource("1_e7xon")]([SubResource("Resource_oeksl")])
pattern_loop_ticks = 45
loot = Array[ExtResource("3_tpjo4")]([SubResource("Resource_ep81w")])
+9 -16
View File
@@ -1,32 +1,25 @@
[gd_resource type="Resource" script_class="EnemyDef" format=3] [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/bullet_emitter.gd" id="1_asksf"]
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_5udip"] [ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="2_7m5d5"]
[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="3_5udip"]
[ext_resource type="Script" path="res://src/actors/enemies/enemy_def.gd" id="4_6rb75"]
[sub_resource type="Resource" id="Resource_kv0ny"] [sub_resource type="Resource" id="Resource_hc01i"]
script = ExtResource("2_5udip") script = ExtResource("2_7m5d5")
count = 10 count = 10
spin_per_shot_deg = 18.0 spin_per_shot_deg = 18.0
interval = 150 interval = 150
speed = 130.0 speed = 130.0
damage = 120
[sub_resource type="Resource" id="Resource_fttpl"]
script = ExtResource("3_hc01i")
item = &"health_potion"
chance = 0.08
[resource] [resource]
script = ExtResource("4_6rb75") script = ExtResource("3_5udip")
id = &"turret" id = &"turret"
display_name = "Turret" display_name = "Turret"
max_hp = 700 max_hp = 70
radius = 16.0 radius = 16.0
contact_damage = 0
move = 0 move = 0
speed = 0.0 speed = 0.0
visual = 1 visual = 1
emitters = Array[ExtResource("1_7m5d5")]([SubResource("Resource_kv0ny")]) emitters = Array[ExtResource("1_asksf")]([SubResource("Resource_hc01i")])
pattern_loop_ticks = 150 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 display_name: String = "Boss"
@export var max_hp: int = 4000 @export var max_hp: int = 4000
@export var radius: float = 44.0 @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) @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] = [] @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 ## 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. ## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0 @export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = [] @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
+1 -11
View File
@@ -21,6 +21,7 @@ enum Move {
@export var display_name: String = "Drone" @export var display_name: String = "Drone"
@export var max_hp: int = 40 @export var max_hp: int = 40
@export var radius: float = 14.0 @export var radius: float = 14.0
@export var contact_damage: int = 10
@export var move: Move = Move.DRIFT @export var move: Move = Move.DRIFT
@export var speed: float = 60.0 @export var speed: float = 60.0
## ORBIT radius, or STRAFE preferred distance. ## ORBIT radius, or STRAFE preferred distance.
@@ -28,19 +29,8 @@ enum Move {
## Ticks between direction re-evaluations for APPROACH/STRAFE, so enemies read ## Ticks between direction re-evaluations for APPROACH/STRAFE, so enemies read
## as deliberate rather than twitchy. ## as deliberate rather than twitchy.
@export var retarget_interval: int = 30 @export var retarget_interval: int = 30
## How far this enemy notices a player, in world units. It also needs line of
## sight -- range alone would mean shooting through walls.
@export var aggro_range: float = 420.0
## Index the client renderer uses to pick a shape/colour. ## Index the client renderer uses to pick a shape/colour.
@export var visual: int = 0 @export var visual: int = 0
@export var emitters: Array[BulletEmitter] = [] @export var emitters: Array[BulletEmitter] = []
## The emitter timeline wraps at this many ticks. ## The emitter timeline wraps at this many ticks.
@export var pattern_loop_ticks: int = 240 @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
+10 -153
View File
@@ -13,11 +13,6 @@ signal state_changed(new_state: State)
enum State { OFFLINE, CONNECTING, ONLINE, FAILED } enum State { OFFLINE, CONNECTING, ONLINE, FAILED }
## How long a refused peer is left connected so its rejection can actually go
## out. Long enough to survive a frame's flush and the round trip, short enough
## that nobody notices; see [method reject_and_kick].
const REJECT_LINGER := 0.5
## Peer id the engine gives the host. On a listen server this is also a real ## Peer id the engine gives the host. On a listen server this is also a real
## player, which is why every send goes through the helpers below. ## player, which is why every send goes through the helpers below.
const LOCAL_PEER := 1 const LOCAL_PEER := 1
@@ -27,10 +22,6 @@ var server: ServerRuntime = null
var client: ClientRuntime = null var client: ClientRuntime = null
var last_error: String = "" 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: func _set_state(s: State) -> void:
state = s state = s
@@ -102,31 +93,8 @@ func shutdown() -> void:
_set_state(State.OFFLINE) _set_state(State.OFFLINE)
## Tell a peer why it is not welcome, then close the socket.
##
## The kick is DEFERRED, which is the whole point of this existing. An RPC is
## queued and only leaves on the frame's flush, so disconnecting in the same
## call throws the reason away with the packet -- a client turned away for a
## protocol mismatch was told only that the server closed the connection. The
## peer can do nothing during the wait: it has no account bound, and every
## message that needs one already refuses it.
func reject_and_kick(peer_id: int, reason: String) -> void:
send_reject(peer_id, reason)
if _is_local(peer_id):
return
get_tree().create_timer(REJECT_LINGER).timeout.connect(
func() -> void: kick(peer_id))
func kick(peer_id: int) -> void: func kick(peer_id: int) -> void:
if multiplayer.multiplayer_peer is not ENetMultiplayerPeer: if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
return
# The peer may have hung up first. A rejected client closes its own socket
# as soon as it reads the reason, and reject_and_kick's timer lands after
# that -- asking ENet to disconnect someone already gone is an engine error,
# not a quiet no-op.
if not multiplayer.get_peers().has(peer_id):
return
(multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id) (multiplayer.multiplayer_peer as ENetMultiplayerPeer).disconnect_peer(peer_id)
@@ -148,7 +116,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
func _on_connected() -> void: func _on_connected() -> void:
_set_state(State.ONLINE) _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: func _on_connect_failed() -> void:
@@ -158,12 +126,6 @@ func _on_connect_failed() -> void:
func _on_server_disconnected() -> void: func _on_server_disconnected() -> void:
# A rejection lands immediately before the socket closes, and it is the
# useful half of the pair: "protocol mismatch: server 9, client 8" is
# actionable and "server closed the connection" is not. Overwriting it here
# made every reject reason in the game unreachable -- the reason was sent,
# decoded, stored, and then clobbered a frame later by this line.
if state != State.FAILED:
last_error = "server closed the connection" last_error = "server closed the connection"
GameLog.warn("net", last_error) GameLog.warn("net", last_error)
shutdown() shutdown()
@@ -180,7 +142,7 @@ func start_local_client() -> void:
client = ClientRuntime.new() client = ClientRuntime.new()
client.name = "Client" client.name = "Client"
add_child(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: func _is_local(peer_id: int) -> bool:
@@ -198,25 +160,12 @@ func send_welcome(peer_id: int) -> void:
s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION) s_welcome.rpc_id(peer_id, peer_id, Protocol.VERSION)
## Deliberately carries the map's SIZE and not its seed. Handing over the seed
## would let any client regenerate the whole dungeon locally, which is a map
## 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, 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, boss_id: String, spawn: Vector2) -> void:
portals: PackedByteArray, dungeon: String, npc: Vector2) -> void:
if _is_local(peer_id): if _is_local(peer_id):
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
map_h, portals, dungeon, npc)
else: else:
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn, s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn)
map_w, map_h, portals, dungeon, npc)
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id):
client.on_map_chunks(instance_id, data)
else:
s_map_chunks.rpc_id(peer_id, instance_id, data)
func send_snapshot(peer_id: int, data: PackedByteArray) -> void: func send_snapshot(peer_id: int, data: PackedByteArray) -> void:
@@ -233,27 +182,6 @@ func send_events(peer_id: int, data: PackedByteArray) -> void:
s_events.rpc_id(peer_id, data) 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: func send_roster(peer_id: int, data: PackedByteArray) -> void:
if _is_local(peer_id): if _is_local(peer_id):
client.on_roster(data) client.on_roster(data)
@@ -268,30 +196,6 @@ func send_reject(peer_id: int, reason: String) -> void:
s_reject.rpc_id(peer_id, reason) 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: func send_input(data: PackedByteArray) -> void:
if server != null: if server != null:
server.on_input(LOCAL_PEER, data) # listen server: no transport at all server.on_input(LOCAL_PEER, data) # listen server: no transport at all
@@ -302,31 +206,10 @@ func send_input(data: PackedByteArray) -> void:
# --- Client -> server ------------------------------------------------------- # --- Client -> server -------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable", 1) @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: if server == null:
return return
server.on_hello(multiplayer.get_remote_sender_id(), version, ticket) server.on_hello(multiplayer.get_remote_sender_id(), version, display_name)
@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)
@rpc("any_peer", "call_remote", "unreliable_ordered", 4) @rpc("any_peer", "call_remote", "unreliable_ordered", 4)
@@ -348,18 +231,10 @@ func s_welcome(peer_id: int, _version: int) -> void:
@rpc("authority", "call_remote", "reliable", 1) @rpc("authority", "call_remote", "reliable", 1)
func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String, func s_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
spawn: Vector2, map_w: int, map_h: int, portals: PackedByteArray, spawn: Vector2) -> void:
dungeon: String, npc: Vector2) -> void:
if client == null: if client == null:
return return
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w, client.on_enter_instance(id, kind, server_tick, boss_id, spawn)
map_h, portals, dungeon, npc)
@rpc("authority", "call_remote", "reliable", 1)
func s_map_chunks(instance_id: int, data: PackedByteArray) -> void:
if client != null:
client.on_map_chunks(instance_id, data)
@rpc("authority", "call_remote", "reliable", 1) @rpc("authority", "call_remote", "reliable", 1)
@@ -369,24 +244,6 @@ func s_reject(reason: String) -> void:
_set_state(State.FAILED) _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) @rpc("authority", "call_remote", "reliable", 1)
func s_roster(data: PackedByteArray) -> void: func s_roster(data: PackedByteArray) -> void:
if client != null: if client != null:
+24 -268
View File
@@ -13,22 +13,6 @@ const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker" const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy" const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden" 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: static func enemy(id: StringName) -> EnemyDef:
@@ -44,7 +28,6 @@ static func enemy(id: StringName) -> EnemyDef:
static func boss(id: StringName) -> BossDef: static func boss(id: StringName) -> BossDef:
match id: match id:
BOSS_WARDEN: return warden() BOSS_WARDEN: return warden()
BOSS_CANTOR: return cantor()
push_error("unknown boss id: %s" % id) push_error("unknown boss id: %s" % id)
return warden() return warden()
@@ -56,8 +39,9 @@ static func drifter() -> EnemyDef:
var d := EnemyDef.new() var d := EnemyDef.new()
d.id = ENEMY_DRIFTER d.id = ENEMY_DRIFTER
d.display_name = "Drifter" d.display_name = "Drifter"
d.max_hp = 400 d.max_hp = 40
d.radius = 14.0 d.radius = 14.0
d.contact_damage = 8
d.move = EnemyDef.Move.DRIFT d.move = EnemyDef.Move.DRIFT
d.speed = 55.0 d.speed = 55.0
d.visual = 0 d.visual = 0
@@ -70,11 +54,10 @@ static func drifter() -> EnemyDef:
fan.spread_deg = 20.0 fan.spread_deg = 20.0
fan.speed = 150.0 fan.speed = 150.0
fan.radius = 6.0 fan.radius = 6.0
fan.damage = 100 fan.damage = 10
fan.lifetime = 240 fan.lifetime = 240
fan.kind = SimConfig.KIND_ORB fan.kind = SimConfig.KIND_ORB
d.emitters = [fan] d.emitters = [fan]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d return d
@@ -83,8 +66,9 @@ static func turret() -> EnemyDef:
var d := EnemyDef.new() var d := EnemyDef.new()
d.id = ENEMY_TURRET d.id = ENEMY_TURRET
d.display_name = "Turret" d.display_name = "Turret"
d.max_hp = 700 d.max_hp = 70
d.radius = 16.0 d.radius = 16.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC d.move = EnemyDef.Move.STATIC
d.speed = 0.0 d.speed = 0.0
d.visual = 1 d.visual = 1
@@ -97,65 +81,36 @@ static func turret() -> EnemyDef:
ring.spin_per_shot_deg = 18.0 ring.spin_per_shot_deg = 18.0
ring.speed = 130.0 ring.speed = 130.0
ring.radius = 7.0 ring.radius = 7.0
ring.damage = 120 ring.damage = 12
ring.lifetime = 300 ring.lifetime = 300
ring.kind = SimConfig.KIND_ORB ring.kind = SimConfig.KIND_ORB
d.emitters = [ring] d.emitters = [ring]
d.loot = [LootDrop.make(Items.HEALTH_POTION, TRASH_POTION_CHANCE)]
return d return d
## Walks at you and fires a point-blank burst. The pressure enemy. ## Walks at you and does nothing else. The pressure enemy.
##
## It used to deal contact damage and nothing else. Nothing in this game hurts
## you by touching you -- every threat is a bullet you can see coming and dodge,
## which is the whole contract of the genre. So the Stalker's melee is a
## shotgun with a deliberately tiny lifetime: ~78px of travel, which only
## threatens once it has closed the distance, and leaves nothing lingering in
## the arena afterwards.
static func stalker() -> EnemyDef: static func stalker() -> EnemyDef:
var d := EnemyDef.new() var d := EnemyDef.new()
d.id = ENEMY_STALKER d.id = ENEMY_STALKER
d.display_name = "Stalker" d.display_name = "Stalker"
d.max_hp = 300 d.max_hp = 30
d.radius = 12.0 d.radius = 12.0
d.contact_damage = 16
d.move = EnemyDef.Move.APPROACH d.move = EnemyDef.Move.APPROACH
d.speed = 95.0 d.speed = 95.0
d.retarget_interval = 20 d.retarget_interval = 20
d.visual = 2 d.visual = 2
d.pattern_loop_ticks = 45
var lunge := AimedSpreadEmitter.new()
lunge.start_tick = 0
lunge.interval = 45
lunge.count = 5
lunge.spread_deg = 62.0
lunge.speed = 260.0
lunge.radius = 6.0
lunge.damage = 140
# 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 return d
## Lobby target dummy: inert, indestructible, so players can feel out the gun. ## Lobby target dummy: inert, tough, 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.
static func dummy() -> EnemyDef: static func dummy() -> EnemyDef:
var d := EnemyDef.new() var d := EnemyDef.new()
d.id = ENEMY_DUMMY d.id = ENEMY_DUMMY
d.display_name = "Target Dummy" d.display_name = "Target Dummy"
d.max_hp = 1000 d.max_hp = 100000
d.indestructible = true
d.radius = 20.0 d.radius = 20.0
d.contact_damage = 0
d.move = EnemyDef.Move.STATIC d.move = EnemyDef.Move.STATIC
d.visual = 3 d.visual = 3
return d return d
@@ -170,19 +125,11 @@ static func warden() -> BossDef:
var b := BossDef.new() var b := BossDef.new()
b.id = BOSS_WARDEN b.id = BOSS_WARDEN
b.display_name = "Warden of the Fold" b.display_name = "Warden of the Fold"
b.max_hp = 36000 b.max_hp = 3600
b.radius = 42.0 b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0) b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 0
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()] 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 return b
@@ -201,7 +148,7 @@ static func _warden_p1() -> BossPhase:
ring.spin_per_shot_deg = 9.0 ring.spin_per_shot_deg = 9.0
ring.speed = 135.0 ring.speed = 135.0
ring.radius = 8.0 ring.radius = 8.0
ring.damage = 140 ring.damage = 14
ring.lifetime = 420 ring.lifetime = 420
ring.muzzle_offset = 46.0 ring.muzzle_offset = 46.0
@@ -212,7 +159,7 @@ static func _warden_p1() -> BossPhase:
fan.spread_deg = 26.0 fan.spread_deg = 26.0
fan.speed = 210.0 fan.speed = 210.0
fan.radius = 6.0 fan.radius = 6.0
fan.damage = 120 fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 46.0 fan.muzzle_offset = 46.0
@@ -235,7 +182,7 @@ static func _warden_p2() -> BossPhase:
spiral.spin_per_shot_deg = 23.0 spiral.spin_per_shot_deg = 23.0
spiral.speed = 120.0 spiral.speed = 120.0
spiral.radius = 7.0 spiral.radius = 7.0
spiral.damage = 120 spiral.damage = 12
spiral.lifetime = 480 spiral.lifetime = 480
spiral.muzzle_offset = 46.0 spiral.muzzle_offset = 46.0
@@ -248,7 +195,7 @@ static func _warden_p2() -> BossPhase:
wall.gap_step = 7 wall.gap_step = 7
wall.speed = 175.0 wall.speed = 175.0
wall.radius = 8.0 wall.radius = 8.0
wall.damage = 160 wall.damage = 16
wall.lifetime = 300 wall.lifetime = 300
wall.kind = SimConfig.KIND_HEAVY wall.kind = SimConfig.KIND_HEAVY
@@ -275,7 +222,7 @@ static func _warden_p3() -> BossPhase:
arms.sweep_period = 5.0 arms.sweep_period = 5.0
arms.speed = 165.0 arms.speed = 165.0
arms.radius = 7.0 arms.radius = 7.0
arms.damage = 130 arms.damage = 13
arms.lifetime = 400 arms.lifetime = 400
var fan := AimedSpreadEmitter.new() var fan := AimedSpreadEmitter.new()
@@ -287,7 +234,7 @@ static func _warden_p3() -> BossPhase:
fan.speed = 195.0 fan.speed = 195.0
fan.edge_speed_bonus = 0.25 fan.edge_speed_bonus = 0.25
fan.radius = 6.0 fan.radius = 6.0
fan.damage = 120 fan.damage = 12
fan.kind = SimConfig.KIND_NEEDLE fan.kind = SimConfig.KIND_NEEDLE
var counter_ring := RingEmitter.new() var counter_ring := RingEmitter.new()
@@ -298,7 +245,7 @@ static func _warden_p3() -> BossPhase:
counter_ring.spin_per_shot_deg = -14.0 counter_ring.spin_per_shot_deg = -14.0
counter_ring.speed = 105.0 counter_ring.speed = 105.0
counter_ring.radius = 7.0 counter_ring.radius = 7.0
counter_ring.damage = 120 counter_ring.damage = 12
counter_ring.lifetime = 420 counter_ring.lifetime = 420
p.emitters = [arms, fan, counter_ring] p.emitters = [arms, fan, counter_ring]
@@ -322,7 +269,7 @@ static func _warden_p4() -> BossPhase:
curve.speed = 130.0 curve.speed = 130.0
curve.turn_deg = 0.55 curve.turn_deg = 0.55
curve.radius = 7.0 curve.radius = 7.0
curve.damage = 150 curve.damage = 15
curve.lifetime = 400 curve.lifetime = 400
curve.muzzle_offset = 46.0 curve.muzzle_offset = 46.0
@@ -335,7 +282,7 @@ static func _warden_p4() -> BossPhase:
walls.gap_step = 5 walls.gap_step = 5
walls.speed = 200.0 walls.speed = 200.0
walls.radius = 8.0 walls.radius = 8.0
walls.damage = 180 walls.damage = 18
walls.kind = SimConfig.KIND_HEAVY walls.kind = SimConfig.KIND_HEAVY
var snipe := AimedSpreadEmitter.new() var snipe := AimedSpreadEmitter.new()
@@ -345,199 +292,8 @@ static func _warden_p4() -> BossPhase:
snipe.spread_deg = 10.0 snipe.spread_deg = 10.0
snipe.speed = 300.0 snipe.speed = 300.0
snipe.radius = 5.0 snipe.radius = 5.0
snipe.damage = 160 snipe.damage = 16
snipe.kind = SimConfig.KIND_NEEDLE snipe.kind = SimConfig.KIND_NEEDLE
p.emitters = [curve, walls, snipe] p.emitters = [curve, walls, snipe]
return p 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
-142
View File
@@ -1,142 +0,0 @@
class_name Rooms
extends RefCounted
## Hand-authored room stamps, as text.
##
## Boss arenas are authored rather than generated: a boss fight is a designed
## space -- sightlines, cover, room to dodge a wall pattern -- and a generator
## that produces "a room" produces a bad one about as often as a good one. The
## corridors and filler rooms around them are generated; the fights are not.
##
## Legend:
## # wall o pillar ~ pit = barricade
## . floor B boss spawn D doorway (floor, corridors connect here)
const LEGEND := {
"#": MapGrid.Kind.WALL,
"o": MapGrid.Kind.PILLAR,
"~": MapGrid.Kind.PIT,
"=": MapGrid.Kind.BARRICADE,
".": MapGrid.Kind.FLOOR,
"B": MapGrid.Kind.FLOOR,
"D": MapGrid.Kind.FLOOR,
"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"]
## Wide, with pillars to break the Warden's rings and pits that shape where you
## can retreat to. The door is deliberately on one wall only: the boss cannot
## follow you out, so the exit is a real decision rather than an accident.
static func warden_hall() -> PackedStringArray:
return PackedStringArray([
"#########################",
"#.......................#",
"#..~~~...........~~~....#",
"#..~~~....o.o....~~~....#",
"#.........................",
"#....o.................o#",
"#.............B.........#",
"#....o.................o#",
"D.........................",
"#..~~~....o.o....~~~....#",
"#..~~~..........~~~.....#",
"#.......................#",
"#########################",
])
## Tighter and more claustrophobic, with barricades you can see over but not
## shoot through -- a room that rewards knowing where the boss is without
## giving you a firing lane to it.
static func choir_vault() -> PackedStringArray:
return PackedStringArray([
"#####################",
"#...................#",
"#..===.......===....#",
"#...................#",
"#....o.......o......#",
"D.........B.........#",
"#....o.......o......#",
"#...................#",
"#..===.......===....#",
"#...................#",
"#####################",
])
## 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
var w := 0
for row in stamp:
w = maxi(w, row.length())
return Vector2i(w, stamp.size())
## Paint a stamp into [param grid] with its top-left at [param origin].
## Returns the marker positions found, keyed by their legend character.
static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dictionary:
var markers := {}
for m in MARKERS:
markers[m] = []
for y in s.size():
var row := s[y]
for x in row.length():
var ch := row[x]
if not LEGEND.has(ch):
continue
var tx := origin.x + x
var ty := origin.y + y
grid.set_tile(tx, ty, LEGEND[ch])
if markers.has(ch):
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.
static func lobby() -> PackedStringArray:
return PackedStringArray([
"#########################################",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........P.........P.........P.........#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.........T...................U.........#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.....o...........................o.....#",
"#...................S...................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#.......................................#",
"#########################################",
])
-1
View File
@@ -1 +0,0 @@
uid://cb5nycudm0o6e
-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
-33
View File
@@ -17,28 +17,11 @@ static var bot_client: bool = false
static var autoquit_ticks: int = 0 static var autoquit_ticks: int = 0
## Skip the menu and connect straight away. Implied by --bot. ## Skip the menu and connect straight away. Implied by --bot.
static var autojoin: bool = false static var autojoin: bool = false
## Bot harness only: gracefully disconnect after this many ticks inside a
## dungeon, the way the in-game menu's "Disconnect" button does. Lets the smoke
## test cover the clean-exit path as well as a hard kill -- both must be caught
## by the same server-side escape channel.
static var bot_leave_after: int = 0
## Skip the menu and start a listen server (host + local player). ## Skip the menu and start a listen server (host + local player).
static var listen: bool = false static var listen: bool = false
## Server-side dev switch: new dungeons open straight onto the boss, skipping ## Server-side dev switch: new dungeons open straight onto the boss, skipping
## the trash waves. For iterating on a pattern without clearing two waves first. ## the trash waves. For iterating on a pattern without clearing two waves first.
static var boss_rush: bool = false 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 static var parsed: bool = false
@@ -55,26 +38,10 @@ static func parse(argv: PackedStringArray = PackedStringArray()) -> void:
autojoin = true autojoin = true
"--join": "--join":
autojoin = true autojoin = true
"--leave-after":
i += 1
if i < argv.size():
bot_leave_after = int(argv[i])
"--listen": "--listen":
listen = true listen = true
"--boss-rush": "--boss-rush":
boss_rush = true 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():
dungeon_depth = int(argv[i])
"--port": "--port":
i += 1 i += 1
if i < argv.size(): if i < argv.size():
+9 -34
View File
@@ -12,15 +12,11 @@ static func sanitize_move(raw: Vector2) -> Vector2:
## One tick of player locomotion. Returns the new position. ## One tick of player locomotion. Returns the new position.
## static func step_player(pos: Vector2, move: Vector2, speed: float, bounds: Vector2) -> Vector2:
## Collision resolution lives in [MapGrid.slide_circle], and the client runs var next := pos + sanitize_move(move) * speed * SimConfig.TICK_DELTA
## this exact call during prediction and reconciliation -- so the map the client next.x = clampf(next.x, -bounds.x, bounds.x)
## holds has to match the server's, which is why maps are replicated in full next.y = clampf(next.y, -bounds.y, bounds.y)
## even where fog hides them. return next
static func step_player(pos: Vector2, move: Vector2, speed: float, map: MapGrid,
radius: float = SimConfig.PLAYER_RADIUS) -> Vector2:
var delta := sanitize_move(move) * speed * SimConfig.TICK_DELTA
return map.slide_circle(pos, delta, radius)
## Circle-vs-circle overlap test used for every hit in the game. ## Circle-vs-circle overlap test used for every hit in the game.
@@ -29,28 +25,7 @@ static func circles_overlap(a: Vector2, ar: float, b: Vector2, br: float) -> boo
return a.distance_squared_to(b) <= r * r return a.distance_squared_to(b) <= r * r
## Outside the map's extent. Derivable on both sides from the map's dimensions ## True when a point has drifted outside the arena plus the cull margin.
## alone, so it needs no announcement even when tiles have not been streamed. static func outside_arena(p: Vector2, margin: float = SimConfig.BULLET_CULL_MARGIN) -> bool:
static func outside_map(p: Vector2, map: MapGrid) -> bool: return absf(p.x) > SimConfig.ARENA_HALF.x + margin \
if map == null: or absf(p.y) > SimConfig.ARENA_HALF.y + margin
return false
return not map.world_rect().grow(SimConfig.BULLET_CULL_MARGIN).has_point(p)
## The furthest a bullet with these parameters could travel before it expires.
##
## Used to decide whether a spawn is worth telling a client about. Deliberately
## an OVER-estimate: acceleration is integrated at face value while a negative
## one is ignored, and a turning bullet curves, so the straight-line answer is
## always at least the real path. Over-estimating costs a packet; under-
## estimating is a bullet that arrives invisible.
static func bullet_reach(speed: float, accel: float, life_ticks: int) -> float:
var t := float(maxi(life_ticks, 0)) * SimConfig.TICK_DELTA
return maxf(speed, 0.0) * t + 0.5 * maxf(accel, 0.0) * t * t
## Outside the map, or stopped by geometry.
static func bullet_stopped(p: Vector2, map: MapGrid) -> bool:
if map == null:
return false
return outside_map(p, map) or map.bullet_blocked(p)
-296
View File
@@ -1,296 +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
## The server last joined, so the connect screen does not ask for the same
## address every launch. Empty until the first join. Client-local like
## everything else here -- it is where you play, not who you are.
static var last_address: String = ""
static var last_port: int = 0
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
## 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)
last_address = String(cfg.get_value("connection", "address", ""))
last_port = clampi(int(cfg.get_value("connection", "port", 0)), 0, 65535)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
# 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)
if not last_address.is_empty():
cfg.set_value("connection", "address", last_address)
cfg.set_value("connection", "port", last_port)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
## Remember where the player just tried to connect.
static func remember_server(address: String, port: int) -> void:
var clean := address.strip_edges()
if clean.is_empty() or port <= 0 or port > 65535:
return
last_address = clean
last_port = port
save()
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
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))
## The binding as short as it can be said, for prompts on screen: "LMB" rather
## than "Mouse Left", "Esc" rather than "Escape". Every key the game names to the
## player goes through this, because every one of them is rebindable and a
## prompt that names the default is wrong for anyone who changed it.
static func short_label(action: String) -> String:
var label := binding_label(action)
match label:
"Mouse Left": return "LMB"
"Mouse Right": return "RMB"
"Mouse Middle": return "MMB"
"Escape": return "Esc"
return label
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
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
+10 -139
View File
@@ -13,7 +13,8 @@ const SNAPSHOT_INTERVAL := 3
const INTERPOLATION_DELAY_TICKS := 6 const INTERPOLATION_DELAY_TICKS := 6
# --- Arena ------------------------------------------------------------------ # --- Arena ------------------------------------------------------------------
## Bullets are culled once they leave the map by this margin. const ARENA_HALF := Vector2(620.0, 340.0)
## Bullets are culled once they leave the arena by this margin.
const BULLET_CULL_MARGIN := 64.0 const BULLET_CULL_MARGIN := 64.0
# --- Player ----------------------------------------------------------------- # --- Player -----------------------------------------------------------------
@@ -35,19 +36,12 @@ const PLAYER_VISUAL_RADIUS := 13.0
## the ship -- and on a remote client, the ship is also drawn a tick or two ## 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. ## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0 const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
## Health and damage are deliberately an order of magnitude larger than the const PLAYER_MAX_HP := 100
## numbers they started as. Every ratio in the game is unchanged -- enemy const PLAYER_FIRE_COOLDOWN := 7 # ticks
## 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_BULLET_SPEED := 620.0 const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0 const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks 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 ## 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 ## 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 ## into a single cheap hit -- you get punished for the first bullet and gifted
@@ -58,30 +52,10 @@ const PLAYER_BULLET_DAMAGE := 60
## live bullet field is survivable. Both halves matter: invulnerability alone ## live bullet field is survivable. Both halves matter: invulnerability alone
## would make the spawn point a free firing position. ## would make the spawn point a free firing position.
const SPAWN_GRACE_TICKS := 120 # 2 seconds const SPAWN_GRACE_TICKS := 120 # 2 seconds
## Passive healing, as a percentage of MAXIMUM health per second -- so it ## A downed player cannot leave for the hub until this has elapsed. Enforced
## scales with level rather than becoming irrelevant at level 15. There is ## here rather than only by grey-ing out the button, because a disabled button
## deliberately no out-of-combat gate: a slow trickle that never stops is ## is a suggestion -- the server is the only thing a modified client cannot
## simpler to reason about than a timer players have to learn, and at this rate ## argue with.
## it cannot outpace anything that is actually shooting at you.
const HP_REGEN_PERCENT_PER_SEC := 0.5
## What the hub heals at instead. Arriving anywhere used to snap you to full,
## which made health free: enter a dungeon, take what you could, leave, walk
## back in whole. Recovering it takes ten seconds of standing in the hub now,
## so the run you just left cost you something -- and the cost is time in the
## one place nothing is shooting at you, not a resource to manage.
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
## A downed player cannot leave for the hub until this has elapsed. Enforced in
## the simulation rather than only by grey-ing out the button, because a
## disabled button is a suggestion.
##
## Worth knowing what actually reaches it: a player whose peer is still
## connected never waits, because ServerRuntime unbinds it on the same tick it
## dies and leaves it at the roster screen -- that is a settled decision, not an
## oversight (docs/DECISIONS.md, "Death unbinds the character entirely"). What
## the lockout governs is the LINKDEAD body, which has nobody to show a roster
## to and asks for itself; the countdown is what stops it vanishing out of a
## fight the instant it is killed.
const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds const RESPAWN_LOCKOUT_TICKS := 180 # 3 seconds
# --- Input timing ----------------------------------------------------------- # --- Input timing -----------------------------------------------------------
@@ -109,25 +83,6 @@ const INPUT_MAX_LEAD := 40
## backstop that makes the failure above self-healing whatever its cause. ## backstop that makes the failure above self-healing whatever its cause.
const INPUT_ACK_STALL_LIMIT := 8 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 ------------------------------------------------------- # --- Emergency escape -------------------------------------------------------
const ESCAPE_CHANNEL_TICKS := 60 # 1 second const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## Taking damage does NOT interrupt the channel. It used to, which sounds like ## Taking damage does NOT interrupt the channel. It used to, which sounds like
@@ -137,43 +92,8 @@ const ESCAPE_CHANNEL_TICKS := 60 # 1 second
## connection now runs the same one-second channel (see SimPlayer.linkdead), ## connection now runs the same one-second channel (see SimPlayer.linkdead),
## which only works if being shot cannot cancel it. ## 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 ---------------------------------------------------------------- # --- Bullets ----------------------------------------------------------------
const MAX_BULLETS := 4096 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_PLAYER := 0
const TEAM_ENEMY := 1 const TEAM_ENEMY := 1
@@ -193,55 +113,6 @@ const DUNGEON_FORMING_TICKS := 300
## player through the portal opens a fresh instance. ## player through the portal opens a fresh instance.
const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
# --- Fog of war -------------------------------------------------------------
## How far a player can see. Comfortably inside MAP_STREAM_RADIUS so the client
## always holds the terrain it is about to reveal.
const FOG_VIEW_RADIUS := 460.0
# --- Interest management ----------------------------------------------------
## How near a kill you have to be to be paid for it. Ten percent beyond what a
## player can see, so someone who watched it die is always inside and someone
## parked at the entrance never is. Experience stays undivided among everyone
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
## each of them gets.
##
## Worth knowing: an upgraded shot outranges this several times over, so a kill
## made far enough away that you cannot see it pays nothing.
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
## Actors further than this from a player are not sent to that player at all.
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
## sight, but far short of a dungeon, so a modified client cannot read enemy
## positions across the map.
const ACTOR_INTEREST_RADIUS := 800.0
## Bullet spawns are filtered far more generously than actors, and deliberately
## so. A bullet is announced once, at spawn, and then simulated locally -- if we
## withhold it and it later flies into view, it is invisible damage, which is a
## gameplay bug rather than a cosmetic one. An enemy appearing at the edge of
## sight is neither.
##
## The floor is (longest bullet travel + FOG_VIEW_RADIUS): a bullet spawned
## further away than that cannot reach visible ground before it expires.
## 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
# --- Map streaming ----------------------------------------------------------
## How far around a player the server streams map tiles. Comfortably wider than
## the viewport so movement prediction and bullet simulation always run against
## known geometry, and comfortably narrower than a dungeon so a modified client
## still cannot draw the whole thing.
const MAP_STREAM_RADIUS := 900.0
## Chunks sent to one peer per snapshot tick. Caps the burst when a player
## arrives or sprints into virgin territory.
const MAP_CHUNKS_PER_TICK := 6
# --- Portal ----------------------------------------------------------------- # --- Portal -----------------------------------------------------------------
## Positions are per-world (SimWorld.portals), taken from the hub's map. Two const PORTAL_POS := Vector2(0.0, -220.0)
## 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.
const PORTAL_RADIUS := 60.0 const PORTAL_RADIUS := 60.0
+2 -22
View File
@@ -1,15 +1,10 @@
[gd_scene load_steps=11 format=3] [gd_scene load_steps=6 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"] [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"] [ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
[ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"] [ext_resource type="Script" path="res://src/view/bullet_renderer.gd" id="3"]
[ext_resource type="Script" path="res://src/ui/hud.gd" id="4"] [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/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"] [node name="Game" type="Node2D"]
script = ExtResource("1") script = ExtResource("1")
@@ -18,7 +13,7 @@ script = ExtResource("1")
position = Vector2(640, 360) position = Vector2(640, 360)
script = ExtResource("2") script = ExtResource("2")
[node name="Bullets" type="Node2D" parent="WorldView"] [node name="Bullets" type="MultiMeshInstance2D" parent="WorldView"]
script = ExtResource("3") script = ExtResource("3")
[node name="HUD" type="CanvasLayer" parent="."] [node name="HUD" type="CanvasLayer" parent="."]
@@ -26,18 +21,3 @@ script = ExtResource("4")
[node name="GameMenu" type="CanvasLayer" parent="."] [node name="GameMenu" type="CanvasLayer" parent="."]
script = ExtResource("5") 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")
+50 -217
View File
@@ -20,35 +20,11 @@ var peers: Array[int] = []
var state: State = State.ACTIVE var state: State = State.ACTIVE
var age: int = 0 var age: int = 0
var seed_value: 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. ## Dungeon progression. -1 is the pre-fight breather.
var stage: int = -1 var stage: int = -1
var stage_delay: int = 0 var stage_delay: int = 0
var boss_id: StringName = &"" var boss_id: StringName = &""
## Generated layout, used to place enemies room by room.
var rooms: Array[Rect2i] = []
## The rectangle each tunnel leg was carved over -- see MapGen. Overlaps the
## rooms at its ends, so _tunnel_tiles() decides what is actually tunnel.
var tunnels: Array[Rect2i] = []
var boss_spawn := Vector2.ZERO
var boss_room := Rect2i()
## Chance that any one tunnel leg has a group of enemies in it. Rolled per leg,
## and most legs are too short or too near the entrance to qualify anyway, so a
## depth-1 dungeon has a tunnel group about one run in three. Rare on purpose:
## tunnels are where you go between fights, and a tunnel that is usually empty
## is what makes the one that is not a surprise.
const TUNNEL_GROUP_CHANCE := 0.1
const TUNNEL_GROUP_MIN := 2
const TUNNEL_GROUP_MAX := 3
## Members stand within this many tiles of the group's centre, so a group reads
## as one encounter rather than a tunnel's worth of strays.
const TUNNEL_GROUP_SPREAD := 2
static func make_lobby(instance_id: int) -> Instance: static func make_lobby(instance_id: int) -> Instance:
@@ -57,205 +33,31 @@ static func make_lobby(instance_id: int) -> Instance:
inst.kind = Protocol.InstanceKind.LOBBY inst.kind = Protocol.InstanceKind.LOBBY
inst.seed_value = 1 inst.seed_value = 1
inst.world = SimWorld.new(inst.seed_value) inst.world = SimWorld.new(inst.seed_value)
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0) inst.world.portal_enabled = true
inst.world.set_map(built["grid"]) inst.world.spawn_point = Vector2(0.0, 120.0)
inst.world.portals = built["portals"]
inst.world.upgrade_npc = built["npc"]
inst.world.has_upgrade_npc = true
inst.world.spawn_point = built["spawn"]
# Nothing here to escape from, and completing the channel in the hub means
# transferring to the hub -- see SimWorld.escape_enabled.
inst.world.escape_enabled = false
# The hub is where a run's damage is paid off. Arriving used to heal you to
# full, which made health free between runs; ten seconds of standing here
# does the same job at a price.
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
inst.state = State.ACTIVE inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before # A single inert dummy so players can feel out the gun before committing to
# committing to a run. # a run. It has no emitters and no contact damage.
inst.world.spawn_enemy(Content.dummy(), built["dummy"]) inst.world.spawn_enemy(Content.dummy(), Vector2(-220.0, -40.0))
return inst return inst
## [param dungeon] picks the flavour of run. The map, the rooms, the enemies and static func make_dungeon(instance_id: int, dungeon_seed: int) -> Instance:
## 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:
var inst := Instance.new() var inst := Instance.new()
inst.id = instance_id inst.id = instance_id
inst.kind = Protocol.InstanceKind.DUNGEON inst.kind = Protocol.InstanceKind.DUNGEON
inst.seed_value = dungeon_seed 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) inst.world = SimWorld.new(dungeon_seed)
var flavour := Dungeons.get_or_default(inst.dungeon_id) inst.world.spawn_point = Vector2(0.0, 260.0)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
inst.depth, flavour.arena)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.tunnels = built["tunnels"]
inst.boss_spawn = built["boss_pos"]
inst.boss_room = built["boss_room"]
inst.world.spawn_point = built["spawn"]
# Arriving into a fight already in progress needs a moment of protection; # Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not. # arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS 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.state = State.FORMING
inst._populate()
return inst
## Enemies are placed once, per room, at generation -- not spawned in waves.
## A dungeon you explore has to be populated before you arrive in it: waves
## would make the map's contents depend on when you walk in rather than where,
## and aggro range would have nothing left to gate.
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)))
boss.pos = boss_spawn
boss.room = _room_rect_world(boss_room)
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
if GameOpts.boss_rush: if GameOpts.boss_rush:
return # dev switch: an empty dungeon with just the fight in it # Dev switch: the next stage advance lands on the boss.
for i in rooms.size(): inst.stage = 1
# Room 0 holds the entrance; arriving into a fight you cannot see yet return inst
# is not a decision, it is an ambush.
if i == 0:
continue
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 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)
if world.map.circle_blocked(at, def.radius):
continue
if _would_see_the_entrance(def, at):
continue
world.spawn_enemy(def, at, rng.randi_range(0, 120))
# After every room, so adding tunnel groups leaves each room's population
# exactly what that seed always produced.
_populate_tunnels(rng, flavour)
## Now and then, a group of enemies in a tunnel.
##
## Never within aggro range of the entrance, by the same reasoning room 0 is
## left empty: arriving into a fight you cannot see yet is not a decision, it
## is an ambush. And never a lone straggler -- if a leg cannot fit at least
## TUNNEL_GROUP_MIN, it gets nobody.
func _populate_tunnels(rng: RandomNumberGenerator, flavour: DungeonDef) -> void:
var clear_of_spawn := _widest_aggro() + MapGrid.TILE * 2.0
for leg in tunnels:
# Every leg rolls the same three numbers whether or not it is used, so
# one tunnel's dice never depend on another tunnel's shape.
var roll := rng.randf()
var pick := rng.randi()
var size := rng.randi_range(TUNNEL_GROUP_MIN, TUNNEL_GROUP_MAX)
if roll >= TUNNEL_GROUP_CHANCE:
continue
var open := _tunnel_tiles(leg)
if open.size() < TUNNEL_GROUP_MAX * 2:
continue
var centre: Vector2i = open[pick % open.size()]
var def := flavour.apply_to_enemy(_pick_enemy(rng))
var spots: Array[Vector2] = []
for t in open:
if absi(t.x - centre.x) > TUNNEL_GROUP_SPREAD \
or absi(t.y - centre.y) > TUNNEL_GROUP_SPREAD:
continue
var at := world.map.tile_centre(t.x, t.y)
# Per member, not per group: members stand up to two tiles off the
# centre, so a centre that clears the entrance does not mean every
# member does.
if at.distance_to(world.spawn_point) < clear_of_spawn:
continue
if not world.map.circle_blocked(at, def.radius):
spots.append(at)
if spots.size() < TUNNEL_GROUP_MIN:
continue
for i in range(spots.size() - 1, 0, -1):
var j := rng.randi_range(0, i)
var held := spots[i]
spots[i] = spots[j]
spots[j] = held
for i in mini(size, spots.size()):
# A fresh definition per member: apply_to_enemy mutates in place,
# and sharing one object would scale it once per member.
var member := def if i == 0 else flavour.apply_to_enemy(Content.enemy(def.id))
world.spawn_enemy(member, spots[i], rng.randi_range(0, 120))
## Whether an enemy placed at [param at] would be awake the moment the party
## arrives: within its aggro range of the entrance, with nothing in the way --
## the same two questions SimWorld._aggro_target asks.
##
## Room 0 is left empty for exactly this reason, but that only ever covered room
## 0. A neighbouring room in sight of the entrance woke on arrival in one run in
## eight, and once tunnels were widened -- more open ground, more lines of sight
## -- in three runs in eight. Skipping these removes about one enemy in sixteen;
## clearing everything within aggro range instead would have removed nearly a
## third, which is a different dungeon rather than a fixed one.
##
## The rng draws for the skipped enemy have already been made, so every other
## enemy in the dungeon is exactly where that seed always put it.
func _would_see_the_entrance(def: EnemyDef, at: Vector2) -> bool:
if at.distance_to(world.spawn_point) > def.aggro_range:
return false
return world.map.has_line_of_sight(at, world.spawn_point)
## Floor tiles of [param leg] that are tunnel rather than room: clear of every
## room by a tile, so a group is never standing in a doorway, and outside the
## boss arena.
func _tunnel_tiles(leg: Rect2i) -> Array[Vector2i]:
var out: Array[Vector2i] = []
for ty in range(leg.position.y, leg.end.y):
for tx in range(leg.position.x, leg.end.x):
if world.map.at(tx, ty) != MapGrid.Kind.FLOOR:
continue
var here := Vector2i(tx, ty)
if boss_room.has_point(here):
continue
var in_room := false
for r in rooms:
if r.grow(1).has_point(here):
in_room = true
break
if not in_room:
out.append(here)
return out
## The furthest any enemy in the game notices a player from.
static func _widest_aggro() -> float:
var widest := 0.0
for id in Content.ALL_ENEMIES:
widest = maxf(widest, Content.enemy(id).aggro_range)
return widest
func _pick_enemy(rng: RandomNumberGenerator) -> EnemyDef:
var roll := rng.randf()
if roll < 0.4:
return Content.drifter()
if roll < 0.75:
return Content.turret()
return Content.stalker()
## Tile-space room rect to world space, for confining a boss to its arena.
func _room_rect_world(r: Rect2i) -> Rect2:
if r.size == Vector2i.ZERO:
return Rect2()
var lo := world.map.tile_centre(r.position.x + 1, r.position.y + 1)
var hi := world.map.tile_centre(r.end.x - 2, r.end.y - 2)
return Rect2(lo, hi - lo)
func add_peer(peer_id: int, display_name: String) -> void: func add_peer(peer_id: int, display_name: String) -> void:
@@ -303,12 +105,8 @@ func exit_countdown_seconds() -> int:
Protocol.COUNTDOWN_NONE - 1) Protocol.COUNTDOWN_NONE - 1)
## [param dungeon] has to match: walking into the Proving Grounds portal must func accepts_new_party_member() -> bool:
## never drop you into a standard run that happens to still be forming, however
## conveniently timed.
func accepts_new_party_member(dungeon: StringName) -> bool:
return kind == Protocol.InstanceKind.DUNGEON \ return kind == Protocol.InstanceKind.DUNGEON \
and dungeon_id == dungeon \
and state == State.FORMING \ and state == State.FORMING \
and peers.size() < SimConfig.DUNGEON_PARTY_MAX and peers.size() < SimConfig.DUNGEON_PARTY_MAX
@@ -320,9 +118,11 @@ func step() -> void:
_step_dungeon() _step_dungeon()
## The dungeon is fully populated at generation, so all that is left to track ## Straight-line progression: two trash waves, then the boss. Waves gate on
## is the party forming and the boss dying. ## "everything dead" rather than a timer so a slow party is never overrun.
func _step_dungeon() -> void: func _step_dungeon() -> void:
# The delay has to tick down before the CLEARED check, or a cleared dungeon
# would sit on its exit timer forever and never release its party.
if stage_delay > 0: if stage_delay > 0:
stage_delay -= 1 stage_delay -= 1
return return
@@ -332,13 +132,31 @@ func _step_dungeon() -> void:
if state == State.FORMING: if state == State.FORMING:
if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX: if age >= SimConfig.DUNGEON_FORMING_TICKS or peers.size() >= SimConfig.DUNGEON_PARTY_MAX:
state = State.ACTIVE state = State.ACTIVE
# stage is left as make_dungeon set it -- resetting it here would
# silently undo the --boss-rush dev switch.
stage_delay = 90
return return
if world.boss != null and not world.boss.alive: if stage >= 0 and _live_enemy_count() > 0:
return
if stage == 2:
if world.boss != null and world.boss.alive:
return
state = State.CLEARED state = State.CLEARED
stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS stage_delay = SimConfig.DUNGEON_CLEARED_EXIT_TICKS
GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [ GameLog.info("instance", "instance %d CLEARED, returning party in %ds" % [
id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE]) id, SimConfig.DUNGEON_CLEARED_EXIT_TICKS / SimConfig.TICK_RATE])
return
stage += 1
stage_delay = 60
match stage:
0: _spawn_wave_one()
1: _spawn_wave_two()
2:
world.spawn_boss(Content.boss(boss_id))
GameLog.info("instance", "BOSS_SPAWNED %s in instance %d" % [boss_id, id])
GameLog.debug("instance", "instance %d entered stage %d" % [id, stage])
func _live_enemy_count() -> int: func _live_enemy_count() -> int:
@@ -347,3 +165,18 @@ func _live_enemy_count() -> int:
if e.alive: if e.alive:
n += 1 n += 1
return n return n
func _spawn_wave_one() -> void:
for i in 3:
world.spawn_enemy(Content.drifter(), Vector2(-320.0 + 320.0 * float(i), -120.0), i * 40)
world.spawn_enemy(Content.turret(), Vector2(-420.0, -220.0), 0)
world.spawn_enemy(Content.turret(), Vector2(420.0, -220.0), 75)
func _spawn_wave_two() -> void:
for i in 4:
world.spawn_enemy(Content.stalker(), Vector2(-300.0 + 200.0 * float(i), -260.0), i * 15)
for i in 3:
world.spawn_enemy(Content.turret(), Vector2(-380.0 + 380.0 * float(i), -60.0), i * 50)
world.spawn_enemy(Content.drifter(), Vector2(0.0, -300.0), 20)
+3 -38
View File
@@ -10,8 +10,6 @@ const GAME_SCENE := preload("res://src/game.tscn")
const CONNECT_TIMEOUT_SEC := 8.0 const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null var _menu: Control = null
var _settings: CanvasLayer = null
var _credits: CanvasLayer = null
var _game: Node = null var _game: Node = null
var _ticks: int = 0 var _ticks: int = 0
var _connecting: bool = false var _connecting: bool = false
@@ -25,10 +23,6 @@ func _ready() -> void:
# engine default and would silently desync server and client if that # engine default and would silently desync server and client if that
# default ever changed. # default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE 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) Net.state_changed.connect(_on_net_state)
if GameOpts.is_server: if GameOpts.is_server:
@@ -64,19 +58,13 @@ func _run_dedicated_server() -> void:
func _show_menu() -> void: func _show_menu() -> void:
if _menu != null:
return
_clear_game() _clear_game()
if _menu == null:
_menu = preload("res://src/ui/main_menu.gd").new() _menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join) _menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play) _menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
_menu.credits_requested.connect(_open_credits)
add_child(_menu) 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
# disabled from _menu_busy(true), and skipping this left the player locked
# out of both joining and hosting with no way back short of a restart.
_menu.set_busy(false)
## Note what this deliberately does NOT do: enter the game. Net.join() only ## Note what this deliberately does NOT do: enter the game. Net.join() only
@@ -86,8 +74,6 @@ func _show_menu() -> void:
## acknowledged them. The game scene now waits for the server to actually place ## acknowledged them. The game scene now waits for the server to actually place
## us in an instance. ## us in an instance.
func _join(address: String, port: int) -> void: func _join(address: String, port: int) -> void:
if _connecting:
return
GameOpts.host_address = address GameOpts.host_address = address
GameOpts.port = port GameOpts.port = port
if Net.join(address, port) != OK: if Net.join(address, port) != OK:
@@ -120,7 +106,7 @@ func _abort_connect(reason: String) -> void:
get_tree().quit(1) get_tree().quit(1)
return return
Net.shutdown() Net.shutdown()
_show_menu() # re-enables the connect buttons _show_menu()
_menu_status(reason) _menu_status(reason)
@@ -128,8 +114,6 @@ func _abort_connect(reason: String) -> void:
## connected over loopback. The client takes no shortcuts because of it -- it ## connected over loopback. The client takes no shortcuts because of it -- it
## still only sends input and still only learns outcomes from snapshots. ## still only sends input and still only learns outcomes from snapshots.
func _host_and_play(port: int) -> void: func _host_and_play(port: int) -> void:
if _connecting:
return
if Net.host(port) != OK: if Net.host(port) != OK:
_menu_status(Net.last_error) _menu_status(Net.last_error)
return return
@@ -147,25 +131,6 @@ func _enter_game() -> void:
add_child(_game) 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: func _clear_game() -> void:
if _game != null: if _game != null:
_game.queue_free() _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
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff. Show more