Compare commits

...

8 Commits

Author SHA1 Message Date
claude b8332b697d Documentation pass for a cold start
ci / verify (push) Successful in 49s
Audited the whole set rather than appending to it, and the two files a fresh
session reads first were both wrong.

README claimed the emergency escape takes three seconds and is CANCELLED BY
DAMAGE. It takes one, and damage explicitly does not interrupt it -- that is a
settled decision with its own entry in DECISIONS.md, and the front page said
the opposite. It also described one stationary boss, one hub portal, and none
of characters, permadeath, levels, inventory, loot, upgrades, settings or
credits. Rewritten, with the "not ready for the internet" warning made explicit.

ROADMAP had no "what is next" at all: every stage reads *done*, which for a
cold start is a dead end. It opens with where things stand and a table of
candidates -- auth, a reason to play past level 15, an economy, hit feedback,
replacing the non-redistributable packs, DTLS -- each with what blocks it, and
says plainly that the user has chosen none of them. Open questions renumbered
from the orphaned 8-11 they were left at, with the solved one dropped and three
real ones added.

ARCHITECTURE's file map listed two files twice, missed five subsystems
(upgrades, stats, poison, settings, credits, the UI theme), and still said
MapGen.build() is "the entry point both sides use" -- it is server-only, and
the whole anti-map-hack story depends on that. Rebuilt by layer and audited
against the tree: every path listed exists, and every one of the 64 source
files is covered.

CLAUDE.md's security paragraph said a client can send "exactly two things"
plus two roster requests. There are five client -> server messages. That number
is the security model, so it is now a table naming each one and what it
carries. Also records that nothing automated can see the screen.

Smaller: a stale 156/156 test count in ASSETS.md, and test counts refreshed to
468 where they are quoted.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green, no broken
internal links.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:20:10 +02:00
claude 61680d00d7 Style the rest of the interface: panels, sliders, scrollbars, cards
ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else.

Dialogs sit on panels now rather than being text over a dimmed world -- the
pause menu, character select, upgrades, settings and credits all root through
UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK.
The set is cream throughout, which is right for buttons and wrong for a dialog
over a dark dungeon; tinting keeps the pixel border and the corner shape and
lets every label the game already draws in light colours stay readable, instead
of recolouring every label in five screens to suit the art.

Sliders and scrollbars are the pack's too, section headings sit on its banner
ribbon, and an upgrade card's rarity is now its frame rather than a word on it
-- three choices are compared at a glance and a colour reads faster than a
label.

Two bugs of the same shape, and neither was findable without looking at the
screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's
minimum size, which for a StyleBoxTexture is its content margins. Mine were
zero, so both resolved the correct stylebox, reported the correct texture, and
drew a groove zero pixels tall. A probe confirmed the theme was resolving
perfectly while the track was invisible. Tests now assert every slider and
scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md.

The first attempt at the slider groove also used the pack's HOLLOW bar sprite,
whose middle is transparent -- tinting it dark left an outline and nothing
else. It uses the solid one.

tools/screenshot.tscn gained the upgrade screen, which it has to stage: the
game scene owns that screen's visibility and re-asserts it every frame, so
setting `visible` lasted exactly one frame, and standing the player at the NPC
client-side lasted until reconciliation pulled them back. It now moves the
player on both sides and holds it until the shot. That tool has caught four
bugs the automated gates all passed.

check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:45:46 +02:00
claude 132646f6c3 Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.

UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.

The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.

Two things found by actually looking at the screen, which is the point:

  - The FIRST version of this styled nothing. A Control inherits its theme from
    Control ANCESTORS only, and the chain breaks at the first plain Node or
    CanvasLayer -- which is every screen here. get_window().theme set the
    property, changed nothing, and read as correct. check.sh, 458 tests and a
    clean smoke run all passed with the entire interface unstyled. The theme
    test now instantiates every screen and asks what its buttons resolve.
  - The settings screen showed Fire bound to the right mouse button, because
    the test suite was writing the player's real user://settings.cfg --
    rebinding calls save() and nothing had redirected the path. Settings.path
    is now redirectable, the fixture points it at a scratch file, and a test
    asserts the default is still the player's own.

tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.

check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:12:13 +02:00
claude 872922e9e2 Fix boss jitter; add a settings screen for controls and volume
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state()
handed back the newest snapshot raw while players and enemies both went
through the interpolator. The boss therefore stepped at the 20Hz snapshot rate
instead of the frame rate. Invisible for as long as every boss stood still,
and the first one that moved looked broken. It is interpolated now -- but not
across an instance change, where the previous snapshot describes a different
fight in a different room and lerping to it would fling the new boss across
the map for a frame.

The smaller cause was server-side: a CHASE boss corrects by the SIGN of its
distance error, so at the standoff the sign flipped every tick and the boss
vibrated a couple of pixels at 60Hz. It has a dead band now.

The settings screen covers rebindable controls and volume, reachable from both
the main menu and the in-game menu. Bindings are stored as physical keycodes
-- following key position, the choice setup_input_map.gd already made -- and
labelled back through the active layout so an AZERTY player reads the letter on
the key their fingers are on. A rebind replaces every event on the action
rather than the first, because an action that kept its alternates would still
answer to the key you just moved away from. A key already in use is refused and
the clash is named. Reset restores what the PROJECT shipped, captured once
before anything overrides it -- captured later it would restore the last
session's choice, which is the thing being undone.

Effects play on an SFX bus created at runtime, so both sliders are real mixer
settings rather than a number multiplied into every play() call.

Worth recording: the test suite AND the smoke test both passed while a client
logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null)
does not mean "no default" -- it means the key is absent and no default was
given, and the engine logs an error per action. Nothing caught it because the
smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none
of those. It surfaced from running the client and reading the output. smoke.sh
now asserts no plain engine errors either, excluding by name the one line Godot
prints on every clean exit, and reintroducing the bug makes it fail.

One mutation caught nothing and should not have: the early return in
linear_to_db_clamped was dead code, since the clamp beneath it already prevents
negative infinity. Removed rather than left looking tested.

check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics
green, and a real client boots with zero engine errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:56:44 +02:00
claude 4a98cf4b0e The Cantor gets its own portal; bullets stop strobing
ci / verify (push) Successful in 48s
A dungeon now names the boss arena it ends in, and the arena decides the boss.
Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds.

That replaces the seed coin-flip from the last commit, which was the wrong
call. 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. Only the Proving Grounds still leaves its arena open, because a
harness you re-enter every couple of minutes wants whichever fight comes up
first -- and it is the one place where "either will do" is true.

test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here:
adding a boss and forgetting to give it a way in is now a failing test rather
than a boss nobody meets. The smoke test asserts all three portals open over a
real socket -- bots pick theirs by account id, so a run exercises each.

Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather
than a shape change, so running it had every bullet on screen strobing through
a palette in unison, which with a few hundred in the air is exactly as hard to
look at as it sounds. Each bullet holds one frame and turns slowly instead,
offset by its own id so a ring of twenty does not rotate 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 spin, and a test pins that against
the longest-lived bullet in the game. The renderer slices four textures at
startup now instead of thirty-two.

check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:36:06 +02:00
claude 42568a40ad Fix: every zone change was handing the player a blank character
ci / verify (push) Successful in 48s
SimWorld knows nothing about characters, so Instance.add_peer builds a fresh
SimPlayer -- level 1, base stats, empty inventory. Something has to give that
player back its character, and only character SELECT ever did. Every portal
into a dungeon and every escape back to the hub therefore reset the player's
level, experience, upgrades and bag. The record on disk stayed correct
throughout, which is what made it read as a display glitch: the level shown was
1 because the level being played really was 1, and the first kill's experience
grant partially repaired it, so the numbers appeared to come and go.

_place now adopts, through a single _adopt_character that every transfer runs.
That let _enter_world_as drop its adopt/reset/adopt dance -- three lines that
existed only because reset_for_instance clobbered the health adopt had just
computed -- and let the level-up path derive maximum health through
recompute_max_hp instead of keeping a second copy of the formula.

diag_upgrades now walks hub -> dungeon -> hub after taking an upgrade and
asserts level, experience, upgrades, damage, maximum health and inventory all
survive each leg. With the fix reverted it reports exactly what was described:
level 1, no upgrades, base damage, empty bag. One of the new checks compared
health against a formula fed the player's own level, which agrees with itself
even when the level is wrong; it compares against the character record instead.

Also drops "(every upgrade)" from the choice cards. That a flat bonus rides
along with all of them is a design principle, not something a player needs
told -- the number is enough.

check.sh clean, 409 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:13:14 +02:00
claude e0c1e0d5c6 Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
Boss 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) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally 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, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00
claude cb2c1e7840 Scale health and damage by ten so percentages have somewhere to land
A shot is 60, a fresh character has 1000, the Warden has 36000. Enemy health,
boss health and every emitter's damage were scaled with them, so every ratio
is unchanged and time to kill is exactly what it was -- tests/unit/test_content
now pins shots-to-kill per enemy and hits-to-kill a player so a careless edit
to either side shows up as a number a designer recognises.

The reason is rounding rather than balance. Damage is an integer, and at a base
of 6 the +5% every upgrade carries computed to 6.3 and rounded straight back to
6: a player took their first upgrade, was told it made them stronger, and
nothing happened. diag_upgrades used to print "damage matches the formula
(6 -> 6)"; it now prints (60 -> 63). The boss's per-phase armour multiplier had
the same problem, turning 1.15 into an effective 1.17.

The practice dummy stopped relying on a huge health pool at the same time. Its
old 100000 was already past the u16 the snapshot sends enemy health in, and
once a shot did 60 a patient player could have destroyed the hub's only
practice target for everyone until the next restart. EnemyDef.indestructible
says what was actually meant, and a test asserts nothing else uses it.

check.sh clean, 364 tests, SMOKE PASS, all four diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:16:38 +02:00
119 changed files with 4999 additions and 295 deletions
+67 -13
View File
@@ -13,7 +13,7 @@ the obvious default.
```bash
tools/check.sh # parse-check every script (~5s) -- run after every edit
tools/test.sh # GUT suite, headless (~3s)
tools/test.sh # 468 GUT tests, headless (~4s)
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
tools/server.sh # dedicated server
tools/client.sh --listen # host and play, no menu
@@ -29,6 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
godot --headless --path . --script tools/bench.gd # sim cost per tick
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (needs a display)
```
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
@@ -36,6 +37,12 @@ test cannot cover any of them: bots are poor shots, so they rarely kill
anything, which means they neither earn levels, produce drops, nor ever reach
the quartermaster.
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
have all passed with the entire interface rendering unstyled, an invisible
slider and an invisible scrollbar. After touching anything under `src/ui/` or
`src/view/`, run `screenshot.tscn` and open the files. It needs a display, so it
is not a gate.
Everything after `--` goes to `GameOpts.parse()`:
| Flag | Effect |
@@ -68,12 +75,22 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
**The server decides everything; the client only sends intent.**
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
five button bits, an inventory slot) and a handshake — plus the two low-rate
character-roster requests, which are also pure intent. There is no message for
"I moved here", "I hit that", "I took damage", "my escape finished" or "I now
own this item". 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.
There are exactly **five** client -> server messages, and that number is worth
watching:
| Message | Carries |
| --- | --- |
| `c_hello` | protocol version + an opaque auth ticket |
| `c_input` | an [InputFrame]: move vector, aim angle, five button bits, an inventory slot |
| `c_select_character` | a character id, checked against *that account's* list |
| `c_create_character` | a name, sanitised at the boundary |
| `c_choose_upgrade` | an index into the three options the SERVER put on the table |
Every one is pure intent. There is no message for "I moved here", "I hit that",
"I took damage", "my escape finished", "I now own this item", "I am in this
dungeon" or "my damage is X". Adding one would collapse the whole security
model, so don't — validate-after-the-fact is strictly weaker than having no code
path at all.
When a new player action needs a message, look at whether it fits in the input
frame first. Item use and drop did, and got the redundancy, the replay guard and
@@ -92,9 +109,9 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| --- | --- |
| `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; `build()` is the only entry point. |
| `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. |
@@ -104,6 +121,9 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
| `src/instances/` | Lobby hub and dungeon runs. |
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. |
| `src/core/credits.gd` | Every third-party asset and its licence. Two are CC BY, so this is a legal requirement, not a nicety. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/autoload/net.gd` | The only autoload. RPC surface. |
| `tools/` | Headless tooling. |
@@ -136,7 +156,21 @@ ticks in milliseconds with no SceneTree.
coordinate even on an anchored control; `offset_left`/`offset_top` are the
anchor-relative ones. This combination silently broke the main menu and
three pieces of the HUD.
6. **`ProjectSettings.save()` drops settings equal to the engine default** and
6. **A Control's theme is inherited from Control ANCESTORS only.** The chain
breaks at the first parent that is a plain `Node` or a `CanvasLayer`, which
here is every screen: they hang off `main.gd` or off a CanvasLayer. Setting
`get_window().theme` therefore compiles, runs, changes the property — and
styles nothing. Apply the theme to each screen's own root Control
(`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every
screen and asks what its buttons actually resolve, because this failure is
invisible to every other kind of check.
7. **A themed `Slider` or `ScrollBar` takes its THICKNESS from the stylebox's
minimum size**, which for a `StyleBoxTexture` is its content margins. Style
one with margins of zero and it resolves correctly, reports the right
texture, and draws a groove zero pixels tall — indistinguishable from having
no theme at all. Both happened. `tests/unit/test_ui_theme.gd` asserts every
slider and scrollbar stylebox has a non-zero minimum.
8. **`ProjectSettings.save()` drops settings equal to the engine default** and
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
in `src/main.gd` instead of trusted to `project.godot`.
@@ -165,6 +199,10 @@ ticks in milliseconds with no SceneTree.
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.
- **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
@@ -181,7 +219,8 @@ ticks in milliseconds with no SceneTree.
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).
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
@@ -197,6 +236,16 @@ ticks in milliseconds with no SceneTree.
- **No contact damage.** Every enemy threatens through bullets only; touching
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
an emitter.
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
walking out is always an escape — which only holds if the boss cannot follow.
- **Every actor the client draws is interpolated between snapshots.** Players,
enemies and the boss all go through the same lerp. The boss did not for a
long time, which is invisible while bosses stand still and looks broken the
moment one moves.
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
ignores line of sight on purpose; everything else in the view respects it. A
warning you cannot see is an unavoidable hit with extra steps.
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
dense patterns safer than sparse ones.
@@ -209,8 +258,13 @@ ticks in milliseconds with no SceneTree.
A new enemy or boss is data, never code. Add a builder to
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
all iterate those), and add a test.
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
property of the phase, not of the boss. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
builds a boss from scratch and asserts the simulation needs no changes to run
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
an emitter type instead.
+14 -2
View File
@@ -1,8 +1,14 @@
# Credits
Every third-party asset pack used or bundled during development, credited
regardless of whether its licence requires it. Two of them do not — they are
here because the work deserves it.
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
@@ -11,6 +17,12 @@ 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/).
+36 -24
View File
@@ -12,39 +12,51 @@ tools/client.sh --join --name ada # connect a client
Or run one client and press **Host and play** for a listen server.
**Controls** — WASD move, mouse aim, LMB fire, **E** on the ring in the hub to
enter a dungeon, **hold F** for three seconds to escape back to the hub.
## Develop
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # GUT suite, headless ~2s
tools/smoke.sh # server + 2 bot clients, ENet ~35s
godot --headless --path . --script tools/bench.gd
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon.
**Controls** — WASD move, mouse aim, LMB fire, **E** to pick up loot and to use
the hub's portals and quartermaster, **14** use an inventory slot, **shift+14**
drop one, **hold F** for one second to escape a dungeon, **Esc** menu, **F1**
hitbox overlay. Every one of those is rebindable in Settings.
## What is here
| | |
| --- | --- |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~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. |
| **Twin-stick bullet hell** | Server-simulated bullet field, ~300 concurrent bullets at 0.28 ms/tick — around 60x headroom against the 60 Hz budget. |
| **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. |
| **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. |
| **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. |
| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. |
| **Characters** | Up to five living per account, permadeath, levels 115, experience shared across the party undivided. |
| **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. |
| **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. |
| **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. |
| **Settings** | Rebindable controls and volumes, and an in-game credits screen. |
## Develop
```bash
tools/check.sh # parse-check every script ~5s
tools/test.sh # 468 GUT tests, headless ~4s
tools/smoke.sh # server + 4 bot clients over ENet ~40s
```
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
will otherwise cost an afternoon.
## Docs
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built, what is next, where each feature lives
- [docs/DECISIONS.md](docs/DECISIONS.md) — settled design decisions and their reasoning
- [CREDITS.md](CREDITS.md) — third-party asset attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and why the setup looks like this
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — code map and the node-free simulation
- [docs/NETCODE.md](docs/NETCODE.md) — replication model, hit validation, known gaps
- [docs/ROADMAP.md](docs/ROADMAP.md) — what is built and what is next
- [docs/WORKFLOW.md](docs/WORKFLOW.md) — agent-assisted Godot development, and the verification traps this project has actually hit
- [docs/ASSETS.md](docs/ASSETS.md) — asset packs and their licence findings
- [CREDITS.md](CREDITS.md) — third-party attribution
- [CLAUDE.md](CLAUDE.md) — commands, invariants, Godot CLI gotchas
## Not ready for the internet
`LocalAuthProvider` lets any client claim any account id. It exists to have the
same shape as Steamworks so swapping is one class, and it must be replaced
before this is reachable from anywhere untrusted. There is no transport
encryption either. See [docs/ROADMAP.md](docs/ROADMAP.md).
+14 -3
View File
@@ -12,6 +12,17 @@ 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.
Nothing is here yet — the game currently draws itself from primitives and one
runtime-generated dot texture ([src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd)).
Art is Stage 2 of [docs/ROADMAP.md](../docs/ROADMAP.md).
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.

After

Width:  |  Height:  |  Size: 303 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 127 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 146 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 138 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 93 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 161 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 161 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 163 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 164 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 389 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 368 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 306 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 255 B

@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 190 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 189 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 124 B

+40
View File
@@ -0,0 +1,40 @@
[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.

After

Width:  |  Height:  |  Size: 186 B

@@ -0,0 +1,40 @@
[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
+78 -16
View File
@@ -62,15 +62,24 @@ travel less than one tile per tick or the point test steps over walls (pinned by
## Where the layers sit now
Stage 1 and 2 added two things worth knowing before reading any file:
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 and experience
live there. The simulation reads a player's level and maximum health; it never
writes progression. One writer means a level cannot disagree with the
- **`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
@@ -96,6 +105,13 @@ BossDef = stats + ordered phases
`SimWorld._run_emitters()` is shared by enemies and bosses, so any pattern can
be dropped on either. A boss is four phases layering one idea at a time; a new
boss is a new function in `src/content/content.gd` and zero simulation changes.
`SimWorld._move_boss()` is the same idea for movement: the mode is a field on
the phase, and adding a boss that walks needs no code.
Emitters are **stateless**. They are shared resources — two bosses of the same
kind would otherwise stomp each other's timers — so anything an emitter needs to
remember between two ticks has to be derived instead. `TelegraphedStrikeEmitter`
computes its strike positions from the volley number for exactly this reason.
## Entry point
@@ -108,30 +124,76 @@ after `--`:
## File map
Grouped by layer. The rule the whole thing hangs on: **`src/sim/` imports
nothing from `net`, `view` or `ui`, and never touches `Net`.**
### Shared constants and helpers
| File | Role |
| --- | --- |
| `src/core/sim_config.gd` | Every constant server and client must agree on. |
| `src/core/movement.gd` | Pure movement + overlap helpers. Shared by prediction. |
| `src/core/game_log.gd`, `game_opts.gd` | Static; usable from tools and tests. |
| `src/sim/sim_world.gd` | The simulation. Authority flag decides what runs. |
| `src/core/settings.gd` | Client-local preferences. Never reaches the server. |
| `src/core/credits.gd` | Third-party assets and their licences. Two are CC BY, so this is a legal requirement. |
### The simulation
| File | Role |
| --- | --- |
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
| `src/sim/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/net/net_codec.gd` | Snapshot / event / input binary codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, broadcast. |
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
| `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. |
| `src/sim/poison_track.gd` | Damage over time. O(1) per actor per tick however many doses. |
| `src/sim/sim_loot.gd` | An item on the ground: world-shared, or owned by one peer. |
| `src/sim/sim_portal.gd` | A dungeon entrance, and which dungeon it opens. |
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. |
| `src/sim/map_gen.gd` | Dungeon generation. `build()` is the only entry point both sides use. |
| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. |
| `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/sim/sim_loot.gd` | An item on the ground. World-shared, or owned by one peer. |
| `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. Server-owned. |
| `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/net/net_codec.gd` | Snapshot / event / input / roster / character / map-chunk codecs. |
| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, map streaming. |
| `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/protocol.gd` | Wire version and constants. Bump `VERSION` whenever a layout changes. |
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. The only autoload. |
### View and UI (read-only; decides nothing)
| File | Role |
| --- | --- |
| `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. |
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
| `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. |
| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. |
| `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. |
| `src/ui/character_select.gd` | Roster screen: pick or create. |
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
| `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. |
+34 -12
View File
@@ -12,22 +12,24 @@ can be referenced from a scene or script. Files are copied into `res://assets/`
in game-ready form, and only then imported. The tree is 3.9 GB and
`tools/check.sh` runs `--import`, which would otherwise walk all of it every run.
What is in the game today totals **548 KB** across seven files: three sprite
sheets and four sounds. Sprite rects and sound paths are all declared in one
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. **Two of the four forbid
redistribution**, which is what publishing a repository containing them would
be — so they are not in the repository.
Checked against each pack's itch.io page, 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 |
@@ -49,19 +51,39 @@ 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 suite still
passes 156/156 and the game runs.
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. **Helton Yan's CC BY 4.0 requires attribution**, and it is not satisfied by
a file in the source tree once the game ships. Players need to be able to
see it. [CREDITS.md](../CREDITS.md) records it for now; an in-game credits
screen is a todo.
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
+148
View File
@@ -297,6 +297,15 @@ 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
@@ -340,3 +349,142 @@ pins the clamp.
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.
+201 -67
View File
@@ -13,6 +13,41 @@ 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.
So there is no obvious "next ticket". What follows is the honest list of
candidates, none of which the user has chosen — **ask before starting one.**
| Candidate | Why it might be next | What it needs first |
| --- | --- | --- |
| **Replace `LocalAuthProvider`** | It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. | Nothing technical — it is one `AuthProvider` subclass. Needs a decision about Steam vs something else. |
| **A reason to keep playing past level 15** | Levels cap, `--depth` never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. | Open question 1 below. |
| **An economy** | Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. | Open questions 2 and 3. |
| **Impact and hit feedback** | `Art.IMPACT` is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. | Nothing. This is the cheapest visible win. |
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
Each stage below also carries its own **Known gaps** section — those are smaller
and more specific than the table above.
### Known flake
`tools/smoke.sh` failed once in a pre-push hook and then passed on four
consecutive runs with no stray processes. It was not reproduced and is not
diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets
and was competing with a full suite run — but that is a guess. If it recurs,
that is the first thing to check.
---
## Verification surface
What "everything passes" currently means. Numbers move; the shape does not.
@@ -20,12 +55,23 @@ What "everything passes" currently means. Numbers move; the shape does not.
| Gate | Covers | Runtime |
| --- | --- | --- |
| `tools/check.sh` | every script parses and type-checks | ~5s |
| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted | ~10s |
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted**survives every zone change** | ~10s |
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
and it is the only thing in the project that can tell you the interface
actually rendered. Run it after touching `src/ui/`. It captures the main menu,
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
those by staging an offer, because earning one would make the shot depend on a
dungeon run.
It has now caught four bugs that every other gate passed: the theme applying to
nothing, the test suite rewriting the player's settings, an invisible slider
track and an invisible scrollbar.
The four diagnostics exist because the smoke test structurally cannot reach
what they cover: bots are poor shots (so they neither level up, produce drops,
@@ -74,24 +120,33 @@ draws whatever it holds. The defence is what the server declines to send.
## Dungeon kinds · *done*
Two entrances stand side by side in the hub, labelled, and open different runs.
Three entrances stand side by side in the hub, labelled with the fight each one
ends in.
| Dungeon | `Dungeons` id | Enemy HP | Boss HP | Loot chance |
| --- | --- | --- | --- | --- |
| Warden's Descent | `warden_descent` | ×1 | ×1 (3600) | ×1 (trash 8%) |
| Proving Grounds | `proving_grounds` | ×0.2 | ×0.08 (288) | ×10 (trash 80%, clamped) |
| 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, the same four-phase Warden — everything simply dies
faster and drops 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 the two back to
back without restarting the server.
same rooms, same enemies, but everything dies far faster and drops far more. A
manual pass over loot, the inventory, dropping and all four boss phases takes a
couple of minutes instead of a quarter of an hour, and because it is a portal
rather than a launch flag you can compare it to a real run without restarting
the server.
Adding a third 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.
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.
Three things worth knowing:
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
@@ -99,10 +154,9 @@ Three things worth knowing:
- **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 the real run.
- **`accepts_new_party_member` takes the dungeon id.** Without that, walking
into the Proving Grounds would drop you into whatever standard run happened
to still be forming.
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.
---
@@ -120,8 +174,13 @@ play off server events. Enough to prove the pipeline, not a finished look.
| Rects validated without a display | done | `tests/unit/test_art.gd` |
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
| Directional sprites, hit flashes, screen shake | todo | |
| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB |
| **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. |
| 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*
@@ -219,6 +278,29 @@ shares the interact key.
---
## 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**,
@@ -284,9 +366,6 @@ All four settled with the user; the reasoning is in
### Known gaps
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
damage), but the first one visibly does nothing. See the open question below.
- **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.
@@ -294,24 +373,86 @@ All four settled with the user; the reasoning is in
---
## Stage 5 — Boss features and new bosses · *todo*
## Stage 5 — Boss features and new bosses · *done*
| Feature | State |
| --- | --- |
| Stationary phases | done every current phase |
| Boss confined to its room | done `SimWorld._step_boss` clamps to `SimBoss.room` |
| Roaming / chasing within the boss room | todo |
| Phases that move to preset locations | todo |
| Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog |
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` |
| 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()` |
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
asserts the simulation needs no changes to run it. Movement is the first thing
that format has not covered, so expect `BossPhase` to gain a movement field
rather than `SimWorld` gaining a per-boss branch.
### Boss movement
Remember boss rooms **do not lock** (a settled decision): a player can always
walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.
Movement is a property of the **phase**, not of the boss: a fight that stands
still and then starts hunting you is one boss with two phases. Four modes —
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
works in the Warden's hall and the Choir Vault alike.
- **CHASE holds a distance rather than closing.** A boss standing on top of you
is a boss whose bullets cannot be read.
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
destination and would otherwise snap onto its circle on the first tick.
- **Movement slides against geometry**, so a boss cannot walk through the
pillars its own arena was designed around.
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
still — which is exactly when an invariant is cheapest to establish. Boss
rooms deliberately do not lock, and that is only an escape if the boss cannot
follow you out.
### Telegraphed strikes
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
later. The moment in between is the whole feature: a burst that appears at your
feet is a coin flip, the same burst with a second of notice is a question.
- **The emitter is stateless**, like every other one — they are shared resources
and two bosses of the same kind must not stomp each other. Strike positions
are *derived* from the volley number, so the announcement and the strike
compute the same points with nothing stored between them.
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
besides spawn bullets.
- **Markers are drawn through fog and through walls.** Everything else in the
view respects line of sight; a warning you cannot see is not a warning. See
`WorldView._draw_telegraphs`.
### The Cantor of the Vault
| Phase | Movement | Idea |
| --- | --- | --- |
| Call to Prayer | static | Teaches the marker, and nothing else. |
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
| Final Cadence | orbit | Movement and telegraphs at once. |
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
from the arena its dungeon names, not from the seed and certainly not from
`--depth`, which nothing in play raises.
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
giving it a way in is a failing test rather than a boss nobody meets.
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
had already gone stale and silently stopped writing the second boss.
### Known gaps
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
newest snapshot with no interpolation — a visible step at 20 Hz — and every
test passed for as long as bosses stood still. There is still no automated
check that a moving actor renders smoothly; the ones added cover the
interpolation call, not the impression.
- **No boss-specific music, intro or death sequence.** A boss dies like an
enemy, only louder.
- **Telegraph markers are one shape.** A circle is the only warning the client
can draw; a lane or a cone would need another event field.
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
the generator's "boss arena on the right-hand side" assumption will be tested.
---
@@ -335,31 +476,24 @@ Not oversights — each was considered and rejected for now, with the reasoning
## Open questions for the user
Genuinely unspecified. **Do not guess at these** — each changes the design, and
several have no obvious default.
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.
### Worth a decision soon
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
nothing at all until the second one. It comes out right in aggregate, but a
player who takes Split Shot and sees no damage change has been told
something untrue.
The clean fix is to scale base damage and enemy health together — ×4 would
put a shot at 24 and make every 5% step land — which changes no time-to-kill
but touches every number in `content.gd`. That is a balance edit, so it is
yours to call rather than mine.
### Blocking nothing yet
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
in play. Depth drives map size and could drive difficulty and rewards.
9. **Where do items come from outside a dungeon?** Loot only drops from kills.
If the hub should sell potions, that is the Stage 4 NPC's second job — and
it needs a currency, which the game does not have.
10. **Should items stack?** Four potions currently take four slots, which makes
a 4-slot bag small. Stacking is a count byte per slot plus a rule for
splitting one; neither is hard, but both change the UI.
11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are
non-redistributable and local-only, and there is no in-game credits screen
yet, which CC BY 4.0 requires for the audio.
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.
+46
View File
@@ -235,3 +235,49 @@ Godot preinstalled. All three are headless, so nothing extra is needed.
- [GUT](https://github.com/bitwes/Gut) · [gdUnit4](https://github.com/godot-gdunit-labs/gdUnit4)
- [Godot: high-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
- [Godot: command line tutorial](https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html)
### A green suite is not a quiet one
`tools/test.sh` and `tools/smoke.sh` both passed while a client logged twelve
engine errors at every startup. `ConfigFile.get_value(section, key, null)` does
not mean "no default" — it means the key is absent *and* no default was given,
so the engine logs an error for each one. Nothing caught it because the smoke
test's refutations matched `SCRIPT ERROR|Parse Error|USER ERROR`, and a plain
`ERROR:` is none of those.
It surfaced from actually running the client (`--listen --autoquit`) and reading
the output, which is worth doing after any change to startup.
`smoke.sh` now asserts no plain engine errors either, excluding by name the one
line Godot prints on every clean exit — naming it means anything else that turns
up is a real finding rather than noise to be squinted past.
### Nothing automated can see the screen
Every gate passed — 458 tests, a clean smoke run, no engine errors — while the
entire interface rendered in Godot's default style and none of the UI pack was
visible. A Control inherits its theme from Control *ancestors only*, and the
chain breaks at the first plain `Node` or `CanvasLayer`; every screen in this
game hangs off one. `get_window().theme = ...` set the property, changed
nothing, and looked correct in the code.
It took `tools/screenshot.tscn` — boot the client, save a PNG, open it — to
find. The same run then showed **Fire bound to the right mouse button**, which
was the test suite writing the player's real `user://settings.cfg`, because
rebinding calls `save()` and nothing had redirected the path.
Styling the rest of the interface then produced two more of exactly the same
shape: an invisible slider track and an invisible scrollbar. Both resolved the
right stylebox, reported the right texture, and drew nothing, because a
`Slider` and a `ScrollBar` take their thickness from the stylebox's *minimum
size* — its content margins — and mine were zero. Neither was findable without
looking.
Three lessons, and the last is the general one:
- After touching `src/ui/`, look at it. There is no substitute.
- When a control resolves the right theme item and still looks wrong, check
what Godot derives from that item's *size* rather than its picture.
- A test that exercises a code path which writes to user state **will write to
user state**. Redirect the path in the fixture, and assert the default is
still the real one so the redirect cannot escape.
+163
View File
@@ -0,0 +1,163 @@
[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")])
+11 -7
View File
@@ -27,7 +27,7 @@ interval = 45
speed = 135.0
radius = 8.0
lifetime = 420
damage = 14
damage = 140
[sub_resource type="Resource" id="Resource_a0mrn"]
script = ExtResource("5_v02no")
@@ -37,6 +37,7 @@ start_tick = 120
interval = 150
speed = 210.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_pfjxl"]
@@ -54,6 +55,7 @@ muzzle_offset = 46.0
interval = 9
speed = 120.0
lifetime = 480
damage = 120
[sub_resource type="Resource" id="Resource_k1e8f"]
script = ExtResource("6_x6ufy")
@@ -63,7 +65,7 @@ start_tick = 90
interval = 170
speed = 175.0
radius = 8.0
damage = 16
damage = 160
kind = 3
[sub_resource type="Resource" id="Resource_4sp1g"]
@@ -83,7 +85,7 @@ sweep_period = 5.0
interval = 5
speed = 165.0
lifetime = 400
damage = 13
damage = 130
[sub_resource type="Resource" id="Resource_3fm5w"]
script = ExtResource("5_v02no")
@@ -95,6 +97,7 @@ start_tick = 60
interval = 110
speed = 195.0
radius = 6.0
damage = 120
kind = 2
[sub_resource type="Resource" id="Resource_20c8f"]
@@ -106,6 +109,7 @@ end_tick = 600
interval = 60
speed = 105.0
lifetime = 420
damage = 120
[sub_resource type="Resource" id="Resource_ymr56"]
script = ExtResource("2_f5abt")
@@ -124,7 +128,7 @@ interval = 11
speed = 130.0
turn_deg = 0.55
lifetime = 400
damage = 15
damage = 150
[sub_resource type="Resource" id="Resource_3n6oq"]
script = ExtResource("6_x6ufy")
@@ -134,7 +138,7 @@ start_tick = 40
interval = 130
speed = 200.0
radius = 8.0
damage = 18
damage = 180
kind = 3
[sub_resource type="Resource" id="Resource_8ppic"]
@@ -145,7 +149,7 @@ start_tick = 100
interval = 80
speed = 300.0
radius = 5.0
damage = 16
damage = 160
kind = 2
[sub_resource type="Resource" id="Resource_yqxmm"]
@@ -161,7 +165,7 @@ emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubReso
script = ExtResource("8_axg03")
id = &"warden"
display_name = "Warden of the Fold"
max_hp = 3600
max_hp = 36000
radius = 42.0
spawn_pos = Vector2(0, -150)
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")])
+2 -1
View File
@@ -12,7 +12,7 @@ spread_deg = 20.0
interval = 120
radius = 6.0
lifetime = 240
damage = 10
damage = 100
[sub_resource type="Resource" id="Resource_lnbnj"]
script = ExtResource("3_vpd04")
@@ -23,6 +23,7 @@ chance = 0.08
script = ExtResource("4_k36hi")
id = &"drifter"
display_name = "Drifter"
max_hp = 400
speed = 55.0
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")])
pattern_loop_ticks = 120
+2 -1
View File
@@ -8,7 +8,8 @@
script = ExtResource("3_2tv1l")
id = &"dummy"
display_name = "Target Dummy"
max_hp = 100000
max_hp = 1000
radius = 20.0
move = 0
visual = 3
indestructible = true
+2 -2
View File
@@ -13,7 +13,7 @@ interval = 45
speed = 260.0
radius = 6.0
lifetime = 18
damage = 14
damage = 140
kind = 3
[sub_resource type="Resource" id="Resource_ep81w"]
@@ -25,7 +25,7 @@ chance = 0.08
script = ExtResource("4_yp71e")
id = &"stalker"
display_name = "Stalker"
max_hp = 30
max_hp = 300
radius = 12.0
move = 3
speed = 95.0
+2 -1
View File
@@ -11,6 +11,7 @@ count = 10
spin_per_shot_deg = 18.0
interval = 150
speed = 130.0
damage = 120
[sub_resource type="Resource" id="Resource_fttpl"]
script = ExtResource("3_hc01i")
@@ -21,7 +22,7 @@ chance = 0.08
script = ExtResource("4_6rb75")
id = &"turret"
display_name = "Turret"
max_hp = 70
max_hp = 700
radius = 16.0
move = 0
speed = 0.0
+13 -3
View File
@@ -7,10 +7,10 @@ extends Resource
@export var display_name: String = "Boss"
@export var max_hp: int = 4000
@export var radius: float = 44.0
## Stationary bosses are the MVP shape; the field exists so a later boss can
## move without changing the runtime.
@export var stationary: bool = true
@export var spawn_pos := Vector2(0.0, -140.0)
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
## the simulation never learns that art exists.
@export var visual: int = 0
@export var phases: Array[BossPhase] = []
## What the kill leaves behind. Bosses are the guaranteed source: a run that
## reaches the end should always be worth something, so unlike trash loot these
@@ -18,6 +18,16 @@ extends Resource
@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
## boss at 0.2 hp picks the lowest-threshold phase that still covers it.
func phase_index_for(hp_fraction: float) -> int:
+35
View File
@@ -16,3 +16,38 @@ extends Resource
## phases rather than adding hit points, so the fight length stays predictable.
@export var damage_taken_mult: float = 1.0
@export var emitters: Array[BulletEmitter] = []
@export_group("Movement")
## How the boss moves during this phase. Movement is a property of the PHASE,
## not of the boss: a fight that stands still and then starts hunting you is
## one boss with two phases, and expressing it any other way would put a
## per-boss branch in the simulation.
@export var move: Move = Move.STATIC
## Units per second. Zero is equivalent to STATIC.
@export var move_speed: float = 0.0
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
## hold from you -- closing all the way would mean a boss you cannot see past.
@export var move_param: float = 120.0
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
## right.
@export var waypoints: Array[Vector2] = []
## Ticks spent standing at each waypoint before moving on.
@export var waypoint_dwell: int = 60
enum Move {
## Never moves. Every phase written before bosses could move.
STATIC,
## Circles the centre of its arena at [member move_param] radius.
ORBIT,
## Closes on the nearest player, holding [member move_param] distance.
CHASE,
## Walks a fixed circuit of [member waypoints], pausing at each.
WAYPOINTS,
}
## Whether this phase actually moves the boss. Derived rather than stored, so a
## phase cannot claim to move and then sit still.
func moves() -> bool:
return move != Move.STATIC and move_speed > 0.0
+4
View File
@@ -13,6 +13,10 @@ extends Resource
## 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
+5
View File
@@ -36,6 +36,11 @@ enum Move {
@export var emitters: Array[BulletEmitter] = []
## The emitter timeline wraps at this many ticks.
@export var pattern_loop_ticks: int = 240
## Never takes damage. Exists for the hub's practice target, whose whole job is
## to still be there tomorrow -- expressing that as a flag rather than as a
## large health pool means it cannot be worn down by a patient player, and
## keeps its health inside the u16 the snapshot sends.
@export var indestructible: bool = false
## What this enemy may leave behind. Rolled once per entry on death, against
## the world's own RNG. Empty for anything that should drop nothing.
@export var loot: Array[LootDrop] = []
+230 -20
View File
@@ -13,6 +13,18 @@ const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden"
const BOSS_CANTOR := &"cantor"
## Every id this file defines. Kept here so the export tool, the renderer and
## the tests all iterate the same list -- each of them used to hand-maintain
## its own copy, and the export tool had already gone stale and stopped writing
## the second boss.
const ALL_ENEMIES: Array[StringName] = [
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
]
const ALL_BOSSES: Array[StringName] = [
BOSS_WARDEN, BOSS_CANTOR,
]
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
## dungeon run should be survivable on what it hands you, but never comfortably.
@@ -32,6 +44,7 @@ static func enemy(id: StringName) -> EnemyDef:
static func boss(id: StringName) -> BossDef:
match id:
BOSS_WARDEN: return warden()
BOSS_CANTOR: return cantor()
push_error("unknown boss id: %s" % id)
return warden()
@@ -43,7 +56,7 @@ static func drifter() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DRIFTER
d.display_name = "Drifter"
d.max_hp = 40
d.max_hp = 400
d.radius = 14.0
d.move = EnemyDef.Move.DRIFT
d.speed = 55.0
@@ -57,7 +70,7 @@ static func drifter() -> EnemyDef:
fan.spread_deg = 20.0
fan.speed = 150.0
fan.radius = 6.0
fan.damage = 10
fan.damage = 100
fan.lifetime = 240
fan.kind = SimConfig.KIND_ORB
d.emitters = [fan]
@@ -70,7 +83,7 @@ static func turret() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_TURRET
d.display_name = "Turret"
d.max_hp = 70
d.max_hp = 700
d.radius = 16.0
d.move = EnemyDef.Move.STATIC
d.speed = 0.0
@@ -84,7 +97,7 @@ static func turret() -> EnemyDef:
ring.spin_per_shot_deg = 18.0
ring.speed = 130.0
ring.radius = 7.0
ring.damage = 12
ring.damage = 120
ring.lifetime = 300
ring.kind = SimConfig.KIND_ORB
d.emitters = [ring]
@@ -104,7 +117,7 @@ static func stalker() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_STALKER
d.display_name = "Stalker"
d.max_hp = 30
d.max_hp = 300
d.radius = 12.0
d.move = EnemyDef.Move.APPROACH
d.speed = 95.0
@@ -119,7 +132,7 @@ static func stalker() -> EnemyDef:
lunge.spread_deg = 62.0
lunge.speed = 260.0
lunge.radius = 6.0
lunge.damage = 14
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
@@ -130,12 +143,18 @@ static func stalker() -> EnemyDef:
return d
## Lobby target dummy: inert, tough, so players can feel out the gun.
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
##
## Indestructible by flag rather than by a huge health pool. The old 100000 was
## already past the u16 the snapshot sends enemy health in, and after the x10
## rescale a determined player could have destroyed the hub's only practice
## target for everyone until the server restarted.
static func dummy() -> EnemyDef:
var d := EnemyDef.new()
d.id = ENEMY_DUMMY
d.display_name = "Target Dummy"
d.max_hp = 100000
d.max_hp = 1000
d.indestructible = true
d.radius = 20.0
d.move = EnemyDef.Move.STATIC
d.visual = 3
@@ -151,10 +170,10 @@ static func warden() -> BossDef:
var b := BossDef.new()
b.id = BOSS_WARDEN
b.display_name = "Warden of the Fold"
b.max_hp = 3600
b.max_hp = 36000
b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 0
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
# Guaranteed, and deliberately one of each visibility. The potion is shared,
# so a party still has something to divide up; the ration is instanced, so
@@ -182,7 +201,7 @@ static func _warden_p1() -> BossPhase:
ring.spin_per_shot_deg = 9.0
ring.speed = 135.0
ring.radius = 8.0
ring.damage = 14
ring.damage = 140
ring.lifetime = 420
ring.muzzle_offset = 46.0
@@ -193,7 +212,7 @@ static func _warden_p1() -> BossPhase:
fan.spread_deg = 26.0
fan.speed = 210.0
fan.radius = 6.0
fan.damage = 12
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 46.0
@@ -216,7 +235,7 @@ static func _warden_p2() -> BossPhase:
spiral.spin_per_shot_deg = 23.0
spiral.speed = 120.0
spiral.radius = 7.0
spiral.damage = 12
spiral.damage = 120
spiral.lifetime = 480
spiral.muzzle_offset = 46.0
@@ -229,7 +248,7 @@ static func _warden_p2() -> BossPhase:
wall.gap_step = 7
wall.speed = 175.0
wall.radius = 8.0
wall.damage = 16
wall.damage = 160
wall.lifetime = 300
wall.kind = SimConfig.KIND_HEAVY
@@ -256,7 +275,7 @@ static func _warden_p3() -> BossPhase:
arms.sweep_period = 5.0
arms.speed = 165.0
arms.radius = 7.0
arms.damage = 13
arms.damage = 130
arms.lifetime = 400
var fan := AimedSpreadEmitter.new()
@@ -268,7 +287,7 @@ static func _warden_p3() -> BossPhase:
fan.speed = 195.0
fan.edge_speed_bonus = 0.25
fan.radius = 6.0
fan.damage = 12
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
var counter_ring := RingEmitter.new()
@@ -279,7 +298,7 @@ static func _warden_p3() -> BossPhase:
counter_ring.spin_per_shot_deg = -14.0
counter_ring.speed = 105.0
counter_ring.radius = 7.0
counter_ring.damage = 12
counter_ring.damage = 120
counter_ring.lifetime = 420
p.emitters = [arms, fan, counter_ring]
@@ -303,7 +322,7 @@ static func _warden_p4() -> BossPhase:
curve.speed = 130.0
curve.turn_deg = 0.55
curve.radius = 7.0
curve.damage = 15
curve.damage = 150
curve.lifetime = 400
curve.muzzle_offset = 46.0
@@ -316,7 +335,7 @@ static func _warden_p4() -> BossPhase:
walls.gap_step = 5
walls.speed = 200.0
walls.radius = 8.0
walls.damage = 18
walls.damage = 180
walls.kind = SimConfig.KIND_HEAVY
var snipe := AimedSpreadEmitter.new()
@@ -326,8 +345,199 @@ static func _warden_p4() -> BossPhase:
snipe.spread_deg = 10.0
snipe.speed = 300.0
snipe.radius = 5.0
snipe.damage = 16
snipe.damage = 160
snipe.kind = SimConfig.KIND_NEEDLE
p.emitters = [curve, walls, snipe]
return p
# --- The Cantor of the Vault ------------------------------------------------
## The second boss, and the one that proves the format stretched. The Warden is
## four phases of standing still and filling space; the Cantor walks.
##
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
## see over but not shoot through -- so its patterns are about position rather
## than about density. Everything below is data: the simulation gained a
## movement mode and a telegraphing emitter, and no line anywhere knows this
## boss exists.
static func cantor() -> BossDef:
var b := BossDef.new()
b.id = BOSS_CANTOR
b.display_name = "Cantor of the Vault"
b.max_hp = 28000
b.radius = 36.0
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 1
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
## three spots, fills them. The whole phase exists to teach the marker.
static func _cantor_p1() -> BossPhase:
var p := BossPhase.new()
p.name = "Call to Prayer"
p.enter_at_hp_fraction = 1.0
p.loop_ticks = 360
p.telegraph_ticks = 60
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 180
strikes.warn_ticks = 80
strikes.strikes = 3
strikes.burst_count = 10
strikes.blast_radius = 66.0
strikes.speed = 130.0
strikes.radius = 7.0
strikes.damage = 130
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
var fan := AimedSpreadEmitter.new()
fan.start_tick = 40
fan.interval = 120
fan.count = 3
fan.spread_deg = 18.0
fan.speed = 190.0
fan.radius = 6.0
fan.damage = 110
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
p.emitters = [strikes, fan]
return p
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
## at each, so the safe half of the room keeps changing while a spiral fills
## whatever it has left behind.
static func _cantor_p2() -> BossPhase:
var p := BossPhase.new()
p.name = "Processional"
p.enter_at_hp_fraction = 0.75
p.loop_ticks = 480
p.telegraph_ticks = 45
p.move = BossPhase.Move.WAYPOINTS
p.move_speed = 105.0
p.waypoint_dwell = 70
# Fractions of the arena, so the same walk works in a room of any size.
p.waypoints = [
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
]
var spiral := RingEmitter.new()
spiral.interval = 12
spiral.count = 3
spiral.spin_per_shot_deg = 27.0
spiral.speed = 125.0
spiral.radius = 7.0
spiral.damage = 120
spiral.lifetime = 400
spiral.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.start_tick = 60
strikes.interval = 200
strikes.warn_ticks = 90
strikes.strikes = 2
strikes.burst_count = 12
strikes.blast_radius = 70.0
strikes.pattern_seed = 17
strikes.speed = 145.0
strikes.radius = 7.0
strikes.damage = 140
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [spiral, strikes]
return p
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
## fight stays readable, and the aimed fire punishes standing still while the
## walls punish running in a straight line.
static func _cantor_p3() -> BossPhase:
var p := BossPhase.new()
p.name = "Antiphon"
p.enter_at_hp_fraction = 0.45
p.loop_ticks = 540
p.telegraph_ticks = 50
p.damage_taken_mult = 1.12
p.move = BossPhase.Move.CHASE
p.move_speed = 120.0
p.move_param = 220.0
var fan := AimedSpreadEmitter.new()
fan.interval = 90
fan.count = 5
fan.spread_deg = 34.0
fan.jitter_deg = 2.5
fan.speed = 200.0
fan.radius = 6.0
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
var walls := WallGapEmitter.new()
walls.start_tick = 70
walls.interval = 160
walls.direction = 1
walls.count = 16
walls.gap_width = 4
walls.gap_step = 6
walls.speed = 165.0
walls.radius = 8.0
walls.damage = 150
walls.lifetime = 300
walls.kind = SimConfig.KIND_HEAVY
p.emitters = [fan, walls]
return p
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
## once, which is the point: both were added for this fight and this is where
## they have to work together.
static func _cantor_p4() -> BossPhase:
var p := BossPhase.new()
p.name = "Final Cadence"
p.enter_at_hp_fraction = 0.18
p.loop_ticks = 420
p.telegraph_ticks = 55
p.damage_taken_mult = 1.25
p.move = BossPhase.Move.ORBIT
p.move_speed = 150.0
p.move_param = 150.0
var ring := RingEmitter.new()
ring.interval = 40
ring.count = 12
ring.spin_per_shot_deg = 15.0
ring.speed = 140.0
ring.radius = 7.0
ring.damage = 130
ring.lifetime = 380
ring.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 150
strikes.warn_ticks = 70
strikes.strikes = 4
strikes.burst_count = 14
strikes.blast_radius = 72.0
strikes.pattern_seed = 91
strikes.speed = 150.0
strikes.radius = 7.0
strikes.damage = 150
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [ring, strikes]
return p
+25 -1
View File
@@ -9,10 +9,12 @@ extends RefCounted
## one entry plus one marker.
const STANDARD := &"warden_descent"
const VAULT := &"choir_vault"
const PROVING := &"proving_grounds"
const ORDER: Array[StringName] = [
STANDARD,
VAULT,
PROVING,
]
@@ -24,6 +26,7 @@ static func default_id() -> StringName:
static func get_def(id: StringName) -> DungeonDef:
match id:
STANDARD: return standard()
VAULT: return vault()
PROVING: return proving_grounds()
return null
@@ -53,11 +56,28 @@ static func standard() -> DungeonDef:
var d := DungeonDef.new()
d.id = STANDARD
d.display_name = "Warden's Descent"
d.subtitle = "the real run"
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
@@ -71,6 +91,10 @@ static func proving_grounds() -> DungeonDef:
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,
+15 -5
View File
@@ -69,6 +69,16 @@ static func choir_vault() -> PackedStringArray:
])
## Which boss each arena belongs to. Kept beside the stamps because the pairing
## is a content decision, not a generation one: the Cantor's patterns assume
## the vault's barricades the way the Warden's assume the hall's pits.
static func boss_for_arena(arena: StringName) -> StringName:
match arena:
&"warden_hall": return Content.BOSS_WARDEN
&"choir_vault": return Content.BOSS_CANTOR
return Content.BOSS_WARDEN
static func size_of(stamp: PackedStringArray) -> Vector2i:
if stamp.is_empty():
return Vector2i.ZERO
@@ -102,17 +112,17 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
## 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. Two
## of them now: the real run and the Proving Grounds, side by side so they can
## be compared without restarting anything. Adding a third dungeon means adding
## a third marker here.
## 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.........P.........P.........#",
"#.......................................#",
"#.....o...........................o.....#",
"#.......................................#",
+76
View File
@@ -0,0 +1,76 @@
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
@@ -0,0 +1 @@
uid://drpadmmsrvoax
+262
View File
@@ -0,0 +1,262 @@
class_name Settings
extends RefCounted
## Player preferences: key bindings and volumes.
##
## Client-side and local. Nothing here reaches the server or the simulation --
## which key you press to fire is not something the server has an opinion about,
## and the input frame it eventually produces looks identical either way.
##
## Static rather than an autoload for the same reason [GameOpts] is: a `--script`
## tool has no main loop and cannot resolve autoload names, and the settings
## file is something a headless test may well want to read.
const DEFAULT_PATH := "user://settings.cfg"
## Where preferences are read from and written to. A variable rather than a
## constant so tests can point it at a scratch file: rebinding calls save(),
## and a suite run was otherwise rewriting the player's real settings -- which
## it did, silently, until a screenshot showed Fire bound to the right mouse
## button.
static var path: String = DEFAULT_PATH
## Actions the settings screen offers, in the order it lists them. Anything not
## here keeps whatever `tools/setup_input_map.gd` gave it and cannot be changed
## -- which is the right default for a binding nobody should be reassigning.
const REBINDABLE: Array = [
["move_up", "Move up"],
["move_down", "Move down"],
["move_left", "Move left"],
["move_right", "Move right"],
["fire", "Fire"],
["interact", "Interact / pick up"],
["emergency_escape", "Emergency escape"],
["use_slot_1", "Use slot 1"],
["use_slot_2", "Use slot 2"],
["use_slot_3", "Use slot 3"],
["use_slot_4", "Use slot 4"],
["system_menu", "Menu"],
["debug_overlay", "Hitbox overlay"],
]
## Bus every sound effect plays on. Created at runtime rather than shipped as a
## bus layout resource, so there is one fewer file to keep in step with the code
## that reads it.
const SFX_BUS := "SFX"
static var master_volume: float = 0.8
static var sfx_volume: float = 0.8
## action -> a serialisable description of its event. See _describe().
static var bindings: Dictionary[String, Dictionary] = {}
## What the project shipped, captured before anything is overridden. Without it
## "reset to defaults" would restore whatever the last session happened to save.
static var _defaults: Dictionary[String, Dictionary] = {}
static var _defaults_captured: bool = false
# --- Lifecycle ---------------------------------------------------------------
## Read the file and apply everything. Safe to call with no file present, which
## is every first run.
static func load_and_apply() -> void:
_capture_defaults()
var cfg := ConfigFile.new()
if cfg.load(path) == OK:
master_volume = clampf(float(cfg.get_value("audio", "master", master_volume)), 0.0, 1.0)
sfx_volume = clampf(float(cfg.get_value("audio", "sfx", sfx_volume)), 0.0, 1.0)
bindings.clear()
for action in REBINDABLE:
var key := String(action[0])
# has_section_key first: passing null as the default to get_value()
# does NOT mean "no default", it means the key is absent AND no
# default was given, and the engine logs an error for every missing
# one. A settings file written before an action existed is normal.
if not cfg.has_section_key("input", key):
continue
var stored: Variant = cfg.get_value("input", key)
# A binding this build cannot make sense of is dropped, not
# guessed at: an unusable control is worse than the default one.
if typeof(stored) == TYPE_DICTIONARY and _event_from(stored) != null:
bindings[key] = stored
apply_audio()
apply_input()
static func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", master_volume)
cfg.set_value("audio", "sfx", sfx_volume)
for action in bindings:
cfg.set_value("input", action, bindings[action])
var err := cfg.save(path)
if err != OK:
GameLog.warn("settings", "could not write %s (error %d)" % [path, err])
# --- Audio -------------------------------------------------------------------
## Ensure the SFX bus exists and push both volumes onto the mixer.
static func apply_audio() -> void:
var sfx := AudioServer.get_bus_index(SFX_BUS)
if sfx < 0:
AudioServer.add_bus()
sfx = AudioServer.bus_count - 1
AudioServer.set_bus_name(sfx, SFX_BUS)
AudioServer.set_bus_send(sfx, "Master")
AudioServer.set_bus_volume_db(0, linear_to_db_clamped(master_volume))
AudioServer.set_bus_mute(0, master_volume <= 0.0)
AudioServer.set_bus_volume_db(sfx, linear_to_db_clamped(sfx_volume))
AudioServer.set_bus_mute(sfx, sfx_volume <= 0.0)
## Godot's linear_to_db(0) is -inf, which serialises badly and reads as a bug
## when it turns up in a log. The clamp is what prevents that -- 0.0001 comes
## out at exactly -80dB, far below audible -- so a zero or a negative from a
## corrupt settings file lands there rather than at negative infinity. Actual
## silence is the bus mute flag, set alongside this.
static func linear_to_db_clamped(linear: float) -> float:
return linear_to_db(clampf(linear, 0.0001, 1.0))
# --- Input -------------------------------------------------------------------
## Snapshot the project's own bindings. Called before anything overrides them,
## and only once -- a second call after an override would capture the override.
static func _capture_defaults() -> void:
if _defaults_captured:
return
_defaults_captured = true
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var events := InputMap.action_get_events(action)
if not events.is_empty():
_defaults[action] = _describe(events[0])
## Rewrite the InputMap from [member bindings]. Actions with no override are
## restored to what the project shipped, so clearing one binding cannot leave a
## previous session's choice behind.
static func apply_input() -> void:
_capture_defaults()
for entry in REBINDABLE:
var action := String(entry[0])
if not InputMap.has_action(action):
continue
var described: Dictionary = bindings.get(action, _defaults.get(action, {}))
var event := _event_from(described)
if event == null:
continue
# Replacing every event rather than the first: an action that kept its
# alternates would still answer to the key the player just moved away
# from, which reads as the rebind not having worked.
InputMap.action_erase_events(action)
InputMap.action_add_event(action, event)
## Assign [param event] to [param action]. Returns the action it collided with,
## or an empty string on success -- one key doing two things is a broken
## control scheme, so it is refused rather than silently accepted.
static func rebind(action: String, event: InputEvent) -> String:
var described := _describe(event)
if described.is_empty():
return action
for entry in REBINDABLE:
var other := String(entry[0])
if other == action:
continue
if current_binding(other) == described:
return other
bindings[action] = described
apply_input()
save()
return ""
static func reset_bindings() -> void:
bindings.clear()
apply_input()
save()
## What [param action] is bound to right now: the override if there is one,
## otherwise what the project shipped.
static func current_binding(action: String) -> Dictionary:
_capture_defaults()
return bindings.get(action, _defaults.get(action, {}))
static func binding_label(action: String) -> String:
return describe_label(current_binding(action))
static func describe_label(described: Dictionary) -> String:
match String(described.get("type", "")):
"key":
var code := int(described.get("code", 0))
# 0 is not a key. _event_from refuses to build one, so a stored 0
# never reaches the input map -- but it would render as a blank
# button, which looks like a bound key with no name.
if code == 0:
return "unbound"
return OS.get_keycode_string(_layout_keycode(code))
"mouse":
match int(described.get("code", 0)):
MOUSE_BUTTON_LEFT: return "Mouse Left"
MOUSE_BUTTON_RIGHT: return "Mouse Right"
MOUSE_BUTTON_MIDDLE: return "Mouse Middle"
return "Mouse %d" % int(described["code"])
return "unbound"
## Bindings are stored as PHYSICAL keycodes, so they follow key position rather
## than layout -- the same choice tools/setup_input_map.gd makes. The label is
## translated back through the active layout so an AZERTY player reads "A" for
## the key their fingers are on, rather than the QWERTY name of that position.
##
## The translation is skipped on a display server that has no keyboard, which
## is headless: the call is not merely unsupported there, it pushes an engine
## error and hands the argument straight back, so asking is worse than not.
## There is no feature flag to test for it.
static func _layout_keycode(physical: int) -> int:
if physical == 0 or DisplayServer.get_name() == "headless":
return physical
return DisplayServer.keyboard_get_keycode_from_physical(physical)
## Only keys and mouse buttons are accepted. A rebind listener that took any
## InputEvent would happily capture mouse MOTION the instant the player moved
## the mouse, which is not a binding anyone meant to make.
static func is_bindable(event: InputEvent) -> bool:
return not _describe(event).is_empty()
static func _describe(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var key := event as InputEventKey
var code := key.physical_keycode if key.physical_keycode != 0 else key.keycode
if code == 0:
return {}
return {"type": "key", "code": int(code)}
if event is InputEventMouseButton:
return {"type": "mouse", "code": int((event as InputEventMouseButton).button_index)}
return {}
static func _event_from(described: Dictionary) -> InputEvent:
match String(described.get("type", "")):
"key":
var k := InputEventKey.new()
# device -1 is the only value that matches input from a real
# device; a freshly constructed event defaults to 16, which
# silently matches nothing. See tools/setup_input_map.gd.
k.device = -1
k.physical_keycode = int(described.get("code", 0))
return k if k.physical_keycode != 0 else null
"mouse":
var m := InputEventMouseButton.new()
m.device = -1
m.button_index = int(described.get("code", 0))
return m if m.button_index != 0 else null
return null
+1
View File
@@ -0,0 +1 @@
uid://pdbmt03hxfum
+16 -2
View File
@@ -35,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0
## the ship -- and on a remote client, the ship is also drawn a tick or two
## ahead of the server, so this margin is what absorbs that too.
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
const PLAYER_MAX_HP := 100
## Health and damage are deliberately an order of magnitude larger than the
## numbers they started as. Every ratio in the game is unchanged -- enemy
## health, boss health and every emitter's damage were scaled with them -- but
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
## Do not "tidy" these back down without scaling content.gd with them.
const PLAYER_MAX_HP := 1000
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
const PLAYER_BULLET_SPEED := 620.0
const PLAYER_BULLET_RADIUS := 4.0
const PLAYER_BULLET_LIFETIME := 90 # ticks
const PLAYER_BULLET_DAMAGE := 6
const PLAYER_BULLET_DAMAGE := 60
## There are deliberately NO invulnerability frames after a hit. In a bullet
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
## into a single cheap hit -- you get punished for the first bullet and gifted
@@ -132,6 +139,13 @@ const PARALLEL_OFFSET := 15.0
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.
+9 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=9 format=3]
[gd_scene load_steps=11 format=3]
[ext_resource type="Script" path="res://src/view/game_scene.gd" id="1"]
[ext_resource type="Script" path="res://src/view/world_view.gd" id="2"]
@@ -8,6 +8,8 @@
[ext_resource type="Script" path="res://src/view/sfx.gd" id="6"]
[ext_resource type="Script" path="res://src/ui/character_select.gd" id="7"]
[ext_resource type="Script" path="res://src/ui/upgrade_screen.gd" id="8"]
[ext_resource type="Script" path="res://src/ui/settings_screen.gd" id="9"]
[ext_resource type="Script" path="res://src/ui/credits_screen.gd" id="10"]
[node name="Game" type="Node2D"]
script = ExtResource("1")
@@ -33,3 +35,9 @@ 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")
+4 -2
View File
@@ -66,7 +66,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
inst.depth = maxi(dungeon_depth, 1)
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
inst.world = SimWorld.new(dungeon_seed)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
var flavour := Dungeons.get_or_default(inst.dungeon_id)
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
inst.depth, flavour.arena)
inst.world.set_map(built["grid"])
inst.rooms = built["rooms"]
inst.boss_spawn = built["boss_pos"]
@@ -75,7 +77,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
# Arriving into a fight already in progress needs a moment of protection;
# arriving in the hub does not.
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
inst.boss_id = Content.BOSS_WARDEN
inst.boss_id = built["boss_id"]
inst.state = State.FORMING
inst._populate()
return inst
+26
View File
@@ -10,6 +10,8 @@ const GAME_SCENE := preload("res://src/game.tscn")
const CONNECT_TIMEOUT_SEC := 8.0
var _menu: Control = null
var _settings: CanvasLayer = null
var _credits: CanvasLayer = null
var _game: Node = null
var _ticks: int = 0
var _connecting: bool = false
@@ -23,6 +25,10 @@ func _ready() -> void:
# engine default and would silently desync server and client if that
# default ever changed.
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
# Bindings and volumes, before anything can read the input map or play a
# sound. A dedicated server has neither, so it does not pay for them.
if not GameOpts.is_server:
Settings.load_and_apply()
Net.state_changed.connect(_on_net_state)
if GameOpts.is_server:
@@ -63,6 +69,7 @@ func _show_menu() -> void:
_menu = preload("res://src/ui/main_menu.gd").new()
_menu.join_requested.connect(_join)
_menu.host_requested.connect(_host_and_play)
_menu.settings_requested.connect(_open_settings)
add_child(_menu)
# Unconditional, and the reason this is not an early-return when the menu
# already exists: a failed connection returns here with the buttons still
@@ -139,6 +146,25 @@ func _enter_game() -> void:
add_child(_game)
## The main menu's copy of the settings screen. The in-game menu has its own --
## they are the same screen, and neither exists while the other is on display.
func _open_settings() -> void:
if _settings == null:
_settings = preload("res://src/ui/settings_screen.gd").new()
_settings.closed.connect(func() -> void: _settings.visible = false)
_settings.credits_requested.connect(_open_credits)
add_child(_settings)
_settings.open()
func _open_credits() -> void:
if _credits == null:
_credits = preload("res://src/ui/credits_screen.gd").new()
_credits.closed.connect(func() -> void: _credits.visible = false)
add_child(_credits)
_credits.open()
func _clear_game() -> void:
if _game != null:
_game.queue_free()
+3 -2
View File
@@ -10,8 +10,9 @@ extends RefCounted
const MAX_LEVEL := 15
const START_LEVEL := 1
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
## capped character has PLAYER_MAX_HP + 14 * this.
const HP_PER_LEVEL := 10
## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else
## -- see the note on SimConfig.PLAYER_MAX_HP.
const HP_PER_LEVEL := 100
## Experience for the first level-up. The curve is tuned so one full clear of a
## depth-1 dungeon lands a little past this -- the first run should end with a
+48 -1
View File
@@ -88,6 +88,11 @@ var selected_character: String = ""
## characters yet" from "not asked yet".
var characters_known: bool = false
## Attacks that have been announced but not yet landed, as
## [{ "pos": Vector2, "r": float, "until": int, "ticks": int }]. Server-pushed;
## the client never invents one and never decides one is over early.
var telegraphs: Array[Dictionary] = []
## Whole seconds until a cleared dungeon returns the party, or
## Protocol.COUNTDOWN_NONE outside that state.
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
@@ -171,10 +176,24 @@ func _physics_process(delta: float) -> void:
Net.send_input(NetCodec.encode_inputs(redundant))
world.step()
_expire_telegraphs()
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
_maybe_bot_leave()
## Drop warnings whose attack has already landed. Keyed on the estimated server
## tick rather than on wall time, so a warning lasts exactly as long as the
## server said it would however the frame rate is behaving.
func _expire_telegraphs() -> void:
if telegraphs.is_empty():
return
var live: Array[Dictionary] = []
for t in telegraphs:
if server_tick_est < int(t["until"]):
live.append(t)
telegraphs = live
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
## disconnect is caught by the same channel a SIGKILL is. Deferred because
## Net.shutdown() frees this node.
@@ -422,6 +441,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
request_respawn = false
my_respawn_wait = 0.0
my_inventory = []
telegraphs = []
cleared_countdown = Protocol.COUNTDOWN_NONE
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
instance_changed.emit()
@@ -533,6 +553,16 @@ func on_events(data: PackedByteArray) -> void:
enemy_died.emit()
SimEvent.Type.BOSS_DIED:
boss_died.emit()
SimEvent.Type.TELEGRAPH:
telegraphs.append({
"pos": ev["pos"],
"r": ev["r"],
"ticks": maxi(int(ev["ticks"]), 1),
# Aged by however long the packet spent in flight, the same
# way a bullet is, so a warning drawn late still disappears
# on time rather than lingering past its own attack.
"until": int(packet["tick"]) + int(ev["ticks"]),
})
SimEvent.Type.ITEM_PICKED_UP:
if int(ev["peer"]) == my_peer:
item_picked_up.emit(ev["item"])
@@ -595,10 +625,27 @@ func inventory_full() -> bool:
return true
## The boss, interpolated between the last two snapshots exactly like every
## other actor.
##
## It used to be handed back raw, which meant it jumped to each new snapshot the
## moment it arrived -- a visible step three times slower than the frame rate.
## Nobody noticed while every boss stood still. The first one that moved looked
## broken.
func boss_state() -> Dictionary:
if snap_curr.is_empty() or snap_curr.get("boss") == null:
return {}
return snap_curr["boss"]
var rec: Dictionary = (snap_curr["boss"] as Dictionary).duplicate()
if snap_prev.is_empty() or snap_prev.get("boss") == null:
return rec
var old: Dictionary = snap_prev["boss"]
# Only between the same boss. Across an instance change the previous
# snapshot describes a different fight in a different room, and lerping to
# it would fling the new boss across the map for one frame.
if int(old["id"]) != int(rec["id"]):
return rec
rec["pos"] = (old["pos"] as Vector2).lerp(rec["pos"], _interp)
return rec
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
+9
View File
@@ -258,6 +258,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
body.put_u32(ev["id"])
SimEvent.Type.BOSS_PHASE:
body.put_u8(clampi(int(ev["phase"]), 0, 255))
SimEvent.Type.TELEGRAPH:
body.put_float(ev["pos"].x)
body.put_float(ev["pos"].y)
body.put_float(ev["r"])
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
SimEvent.Type.BOSS_DIED:
pass
count += 1
@@ -312,6 +317,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
ev["id"] = b.get_u32()
SimEvent.Type.BOSS_PHASE:
ev["phase"] = b.get_u8()
SimEvent.Type.TELEGRAPH:
ev["pos"] = Vector2(b.get_float(), b.get_float())
ev["r"] = b.get_float()
ev["ticks"] = b.get_u16()
SimEvent.Type.BOSS_DIED:
pass
out.append(ev)
+2 -1
View File
@@ -21,7 +21,8 @@ extends RefCounted
## the dungeon you are standing in, replacing the single portal position.
## 8: upgrades. A new server -> client upgrade-state message, a new
## client -> server choice message, and two more SelectResult values.
const VERSION := 8
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
const VERSION := 9
const DEFAULT_PORT := 27015
const MAX_CLIENTS := 32
+33 -6
View File
@@ -299,12 +299,10 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
store.set_last_played(account, c.id)
# _place adopts, so the old three-line adopt/reset/adopt dance here is gone:
# it existed to work around reset_for_instance clobbering the health that
# adopt had just computed, and both now derive it the same way.
_place(peer_id, lobby)
var p: SimPlayer = lobby.world.players.get(peer_id)
if p != null:
p.adopt(c)
p.reset_for_instance(lobby.world.spawn_point, 0)
p.adopt(c)
GameLog.info("server", "peer %d playing '%s' (level %d)"
% [peer_id, c.display_name, c.level])
_send_upgrades(peer_id)
@@ -381,6 +379,13 @@ func instance_of(peer_id: int) -> Instance:
func _place(peer_id: int, inst: Instance) -> void:
inst.add_peer(peer_id, peer_names.get(peer_id, "player"))
peer_instance[peer_id] = inst.id
# The world hands back a BLANK player -- level 1, base stats, empty bag --
# because SimWorld knows nothing about characters. Giving it back its
# character is this layer's job, and for a long time it only happened on
# character select: every portal and every escape quietly reset the player
# to level 1 with no upgrades and nothing carried. The record on disk was
# always correct, which is what made it look like a display bug.
_adopt_character(peer_id, inst)
# Size only: the seed stays server-side, or a client could rebuild the map.
peer_chunks[peer_id] = {}
Net.send_enter_instance(peer_id, inst.id, int(inst.kind), inst.world.tick,
@@ -400,6 +405,26 @@ func _place(peer_id: int, inst: Instance) -> void:
inst.exit_countdown_seconds(), peer_id))
## Give the player in [param inst] the character its peer is playing.
##
## The one place a character record becomes a live player, so there is a single
## thing to get right rather than one per transition.
func _adopt_character(peer_id: int, inst: Instance) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return # authenticated but still at the roster screen
var c := store.get_character(account, character_id)
var p: SimPlayer = inst.world.players.get(peer_id)
if c == null or p == null:
return
p.adopt(c)
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
# new ceiling, which would leave a character whose upgrades RAISED it
# arriving wounded for no reason.
p.hp = p.max_hp
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
@@ -558,7 +583,9 @@ func _grant_xp(peer_id: int, amount: int) -> void:
if p != null:
var before := p.max_hp
p.level = c.level
p.max_hp = c.max_hp()
# Derived rather than copied from the character, so there is one
# formula for maximum health and not two that can disagree.
p.recompute_max_hp()
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
GameLog.info("server", "peer %d reached level %d (%d choice(s) pending)"
% [peer_id, c.level, c.pending_choices])
+20 -4
View File
@@ -19,10 +19,11 @@ extends RefCounted
## Generation is still deterministic from (kind, seed, depth), which is what
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
## pins that.
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int,
arena: StringName = &"") -> Dictionary:
if kind == Protocol.InstanceKind.LOBBY:
return _build_lobby()
return generate(seed_value, depth)
return generate(seed_value, depth, arena)
static func _build_lobby() -> Dictionary:
@@ -65,12 +66,17 @@ static func _build_lobby() -> Dictionary:
"npc": npc,
"boss_pos": Vector2.ZERO,
"boss_room": Rect2i(),
"boss_id": &"",
}
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
## (Vector2), boss_room (Rect2i).
static func generate(seed_value: int, depth: int) -> Dictionary:
## [param arena] names the boss arena to stamp, and therefore which boss the run
## ends with. Empty leaves it to the seed, which is what the Proving Grounds
## wants and nothing else does -- for a real dungeon the boss is the thing a
## player chose at the portal.
static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Dictionary:
var rng := RandomNumberGenerator.new()
rng.seed = seed_value
var d := maxi(depth, 1)
@@ -85,11 +91,17 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
# The boss arena is placed first and everything else works around it, so a
# generated corridor can never carve through the authored fight.
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
# Normally the dungeon says which arena it is; only the test harness leaves
# it open, and then the seed decides.
var chosen := arena
if chosen.is_empty():
chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
var stamp := Rooms.warden_hall() if chosen == &"warden_hall" else Rooms.choir_vault()
var bs := Rooms.size_of(stamp)
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
var markers := Rooms.stamp(grid, stamp, boss_origin)
var boss_room := Rect2i(boss_origin, bs)
var arena_id := chosen
var boss_pos := grid.tile_centre(
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
@@ -156,6 +168,10 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
"npc": Vector2.ZERO,
"boss_pos": boss_pos,
"boss_room": boss_room,
# Which boss lives here follows from WHICH arena was stamped. Choosing
# it anywhere else would let a dungeon put the Cantor in the Warden's
# hall, where its patterns assume geometry that is not there.
"boss_id": Rooms.boss_for_arena(arena_id),
}
+20
View File
@@ -22,6 +22,26 @@ var rng: RandomNumberGenerator
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
## Where an emitter announces something before it happens. Points at the
## owning world's event list; emitters fired into a bare pool by a test simply
## append here and nobody reads it.
var events: Array[Dictionary] = []
## Warn that an attack will land at [param at] in [param ticks] ticks.
##
## The only thing an emitter can do besides spawn bullets, and it exists so a
## strike that appears out of thin air is a *choice* the player was given time
## to answer rather than an unavoidable hit.
func telegraph(at: Vector2, warn_radius: float, ticks: int) -> void:
events.append({
"t": SimEvent.Type.TELEGRAPH,
"pos": at,
"r": warn_radius,
"ticks": ticks,
})
func aim_angle() -> float:
if not has_target:
return PI * 0.5
@@ -0,0 +1,84 @@
class_name TelegraphedStrikeEmitter
extends BulletEmitter
## Marks spots in the arena, then fills them with bullets a moment later.
##
## The whole point is the moment in between. A burst that appears at your feet
## with no warning is not a pattern, it is a coin flip; the same burst with a
## second of notice is a question about where you would rather be standing.
## [member warn_ticks] is that second, and the client draws the marker from the
## TELEGRAPH event this emits.
##
## Stateless, like every other emitter -- emitters are shared resources and two
## bosses of the same kind must not stomp each other's timers. The strike
## positions are therefore *derived* from the volley number rather than rolled
## and remembered, so the announcement and the strike compute the same points
## without anything being stored between them.
## Spots marked per volley.
@export var strikes: int = 3
## Ticks between the marker appearing and the burst landing. Must be shorter
## than [member interval], or a volley would land after the next was announced.
@export var warn_ticks: int = 75
## Bullets in each burst, fired outwards from the marked spot.
@export var burst_count: int = 12
## Radius of the marked area, and the ring the burst is born on.
@export var blast_radius: float = 64.0
## Keeps strikes off the arena's walls, where half the burst would be wasted.
@export var margin: float = 72.0
## Distinguishes two strike emitters in one phase, which would otherwise derive
## the same points and stack every burst on top of itself.
@export var pattern_seed: int = 0
## Armed on two ticks per volley: the announcement, and the strike itself.
func should_fire(local_tick: int) -> bool:
if not is_armed(local_tick) or interval <= 0:
return false
var into := posmod(local_tick - start_tick, interval)
return into == 0 or into == posmod(warn_ticks, interval)
func fire(ctx: EmitContext) -> void:
if strikes <= 0 or interval <= 0:
return
var offset := ctx.local_tick - start_tick
var volley := int(floor(float(offset) / float(interval)))
var announcing := posmod(offset, interval) == 0
for i in strikes:
var at := strike_point(volley, i, ctx.bounds)
if announcing:
ctx.telegraph(at, blast_radius, warn_ticks)
else:
_burst(ctx, at)
## The burst leaves the marked spot outwards, so standing on the marker is the
## one place that is certainly wrong and the edge is survivable.
func _burst(ctx: EmitContext, at: Vector2) -> void:
if burst_count <= 0:
return
for i in burst_count:
var a := TAU * (float(i) / float(burst_count))
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
## Where volley [param volley]'s strike [param index] lands, inside
## [param bounds]. A pure function of its arguments: called once when the
## warning goes up and again when the burst lands, and it has to agree with
## itself both times without anything being remembered in between.
func strike_point(volley: int, index: int, bounds: Rect2) -> Vector2:
var inner := bounds.grow(-margin)
if inner.size.x <= 0.0 or inner.size.y <= 0.0:
inner = bounds
return inner.position + Vector2(
_unit(volley, index * 2) * inner.size.x,
_unit(volley, index * 2 + 1) * inner.size.y)
## A small integer hash in [0, 1). Not a good random number generator and does
## not need to be -- it needs to be scattered, and it needs to give the same
## answer twice.
func _unit(a: int, b: int) -> float:
var h := (a * 374761393 + b * 668265263 + pattern_seed * 2246822519) & 0x7FFFFFFF
h = ((h ^ (h >> 13)) * 1274126177) & 0x7FFFFFFF
return float((h >> 7) & 0xFFFF) / 65536.0
@@ -0,0 +1 @@
uid://dkswilm3f3jc1
+4
View File
@@ -15,6 +15,10 @@ var phase_tick: int = 0
## player can always disengage by walking out -- which is the trade for the
## boss room having no door that locks.
var room := Rect2()
## WAYPOINTS movement: which point it is walking to, and how long it still
## stands at the one it reached.
var waypoint_index: int = 0
var waypoint_wait: int = 0
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
var poison: PoisonTrack = null
+4
View File
@@ -34,4 +34,8 @@ enum Type {
ITEM_PICKED_UP, ## peer, item
ITEM_USED, ## peer, item
ITEM_DROPPED, ## peer, item
## An attack is about to land somewhere, and the player has to be shown
## where before it does. Carries the spot, its radius and how long the
## warning lasts; the client draws it and the server fires into it later.
TELEGRAPH, ## pos, r, ticks
}
+84 -5
View File
@@ -568,11 +568,14 @@ func _step_boss() -> void:
var phase := boss.current_phase()
if phase == null:
return
# A boss never leaves its arena. Enforced here rather than left to each
# boss's movement code, because boss rooms deliberately do not lock: the
# player can always walk out, and the fight only stays a fight if the boss
# cannot follow. Currently a no-op (every boss is stationary), which is
# exactly when an invariant is cheapest to establish.
_move_boss(phase)
# A boss never leaves its arena. Enforced here rather than inside each
# movement mode, because boss rooms deliberately do not lock: the player can
# always walk out, and the fight only stays a fight if the boss cannot
# follow. It was a no-op while every boss stood still, which is exactly when
# an invariant is cheapest to establish -- now it is load-bearing.
if boss.room.size != Vector2.ZERO:
boss.pos = boss.room.position + Vector2(
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
@@ -584,6 +587,77 @@ func _step_boss() -> void:
boss.phase_tick += 1
## One tick of boss movement, driven entirely by the phase's data. Every mode
## here is generic: adding a boss that moves is writing a [BossPhase], not
## touching this function.
func _move_boss(phase: BossPhase) -> void:
if not phase.moves():
return
var dt := SimConfig.TICK_DELTA
var step := Vector2.ZERO
match phase.move:
BossPhase.Move.ORBIT:
var centre := boss.room.get_center() if boss.room.size != Vector2.ZERO \
else boss.def.spawn_pos
var angle := float(boss.phase_tick) * dt \
* (phase.move_speed / maxf(phase.move_param, 1.0))
step = centre + Vector2.RIGHT.rotated(angle) * phase.move_param - boss.pos
BossPhase.Move.CHASE:
var quarry := nearest_player(boss.pos)
if quarry == null:
return
var to_player := quarry.pos - boss.pos
var gap := to_player.length()
if gap < 0.001:
return
# Signed, so it backs off when you close inside its preferred
# distance. A boss that ends up standing on you is a boss whose
# bullets you cannot see coming.
var error := gap - phase.move_param
# Close enough. Without this the sign flips every tick once it
# arrives and the boss buzzes on the spot at the tick rate.
if absf(error) <= SimConfig.BOSS_CHASE_DEADBAND:
return
step = (to_player / gap) * signf(error) * phase.move_speed * dt
BossPhase.Move.WAYPOINTS:
if phase.waypoints.is_empty():
return
if boss.waypoint_wait > 0:
boss.waypoint_wait -= 1
return
var goal := _waypoint_world(phase, boss.waypoint_index)
var to_goal := goal - boss.pos
if to_goal.length() <= phase.move_speed * dt:
boss.pos = goal
boss.waypoint_index = (boss.waypoint_index + 1) % phase.waypoints.size()
boss.waypoint_wait = phase.waypoint_dwell
return
step = to_goal
_:
return
# Clamped here rather than in each branch, so no mode can teleport: ORBIT in
# particular computes an absolute destination and would otherwise snap to
# its circle on the first tick of the phase.
var reach := phase.move_speed * dt
if step.length() > reach:
step = step.normalized() * reach
# Slid rather than assigned, so a boss cannot walk through the pillars its
# own arena was designed around.
boss.pos = map.slide_circle(boss.pos, step, boss.def.radius)
## A phase waypoint, given as a fraction of the arena, in world coordinates.
## Fractions rather than absolutes so one phase can be dropped into any room --
## the Warden's hall and the Choir Vault are different sizes.
func _waypoint_world(phase: BossPhase, index: int) -> Vector2:
var w: Vector2 = phase.waypoints[index]
if boss.room.size == Vector2.ZERO:
return boss.def.spawn_pos
return boss.room.position + Vector2(
clampf(w.x, 0.0, 1.0) * boss.room.size.x,
clampf(w.y, 0.0, 1.0) * boss.room.size.y)
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
## be dropped onto a trash mob and vice versa.
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
@@ -592,6 +666,9 @@ func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick:
return
var target := nearest_player(origin)
_ctx.pool = pool
# Re-pointed every call: drain_events() replaces the array wholesale, so a
# context holding the old one would announce into a list nobody reads.
_ctx.events = events
_ctx.origin = origin
# Curtain patterns span the room they are fired in. Without a room, fall
# back to a box around the shooter rather than the whole map, or a trash
@@ -746,6 +823,8 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
## from the snapshot anyway. Death is still announced either way, because that
## is what the experience award is keyed on.
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
if e.def.indestructible:
return
e.hp = maxi(e.hp - amount, 0)
if not silent:
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
+7 -3
View File
@@ -24,8 +24,7 @@ var _selected: String = ""
func _ready() -> void:
layer = 30
_rng.randomize()
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
@@ -37,10 +36,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
centre.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(460.0, 0.0)
panel.add_theme_constant_override("separation", 8)
centre.add_child(panel)
frame.add_child(panel)
_title = Label.new()
_title.text = "CHARACTERS"
+99
View File
@@ -0,0 +1,99 @@
extends CanvasLayer
## Attribution, in the game rather than only in the repository.
##
## CC BY 4.0 requires credit "in any reasonable manner", and a markdown file
## nobody who downloads a build will ever see does not clear that bar. Two of
## the packs the game ships are CC BY, so this screen is a licence obligation
## and not a nicety -- which is why its contents come from [Credits] and a test
## asserts nothing in there is missing from it.
signal closed
var _list: VBoxContainer
func _ready() -> void:
layer = 34
visible = false
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
scrim.color = Color(0.03, 0.03, 0.06, 0.95)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
root.add_child(scrim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
centre.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(620.0, 0.0)
panel.add_theme_constant_override("separation", 8)
frame.add_child(panel)
var title := Label.new()
title.text = "CREDITS"
title.add_theme_font_size_override("font_size", 26)
panel.add_child(title)
var intro := Label.new()
intro.text = "Third-party work this game is built from. " \
+ "Credited whether or not the licence asks."
intro.add_theme_font_size_override("font_size", 12)
intro.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
intro.custom_minimum_size = Vector2(620.0, 0.0)
panel.add_child(intro)
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(620.0, 340.0)
panel.add_child(scroll)
_list = VBoxContainer.new()
_list.custom_minimum_size = Vector2(600.0, 0.0)
_list.add_theme_constant_override("separation", 12)
scroll.add_child(_list)
for entry in Credits.SOURCES:
_add_entry(entry)
var close := Button.new()
close.text = "Close"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
panel.add_child(close)
func _add_entry(entry: Dictionary) -> void:
var block := VBoxContainer.new()
block.add_theme_constant_override("separation", 1)
_list.add_child(block)
var heading := Label.new()
heading.text = "%s%s" % [entry["name"], entry["author"]]
heading.add_theme_font_size_override("font_size", 16)
block.add_child(heading)
var detail := Label.new()
var licence := String(entry["licence"])
var link := Credits.licence_url(licence)
# The licence LINK, not just its name: CC BY asks for one, and a player
# reading this screen has no other way to reach the terms.
detail.text = "%s · %s%s\n%s" % [
entry["used_for"], licence,
" · " + link if not link.is_empty() else "",
entry["url"]]
detail.add_theme_font_size_override("font_size", 12)
detail.add_theme_color_override("font_color", Color(0.62, 0.67, 0.8))
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail.custom_minimum_size = Vector2(600.0, 0.0)
block.add_child(detail)
func open() -> void:
visible = true
+1
View File
@@ -0,0 +1 @@
uid://bo3xikvfahke5
+11 -3
View File
@@ -10,6 +10,7 @@ signal resumed
signal return_to_hub_requested
signal disconnect_requested
signal characters_requested
signal settings_requested
var _panel: VBoxContainer
var _hub_button: Button
@@ -22,8 +23,7 @@ var _open: bool = false
func _ready() -> void:
layer = 20
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var root := UiTheme.themed_root()
add_child(root)
# Dim the game behind the menu so it is obvious the world is still running.
@@ -37,10 +37,15 @@ func _ready() -> void:
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(center)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
center.add_child(frame)
_panel = VBoxContainer.new()
_panel.custom_minimum_size = Vector2(320.0, 0.0)
_panel.add_theme_constant_override("separation", 10)
center.add_child(_panel)
frame.add_child(_panel)
var title := Label.new()
title.text = "PAUSED"
@@ -60,6 +65,9 @@ func _ready() -> void:
_characters_button = _button("Change character", func() -> void:
characters_requested.emit()
close())
_button("Settings", func() -> void:
settings_requested.emit()
close())
_button("Resume", func() -> void: close())
_disconnect_button = _button("Disconnect to menu", func() -> void:
disconnect_requested.emit()
+73 -20
View File
@@ -6,8 +6,17 @@ const MARGIN := 24.0
const BAR_W := 260.0
const BAR_H := 16.0
## Inventory slot box, and the gap between boxes.
const SLOT := 46.0
##
## Exactly twice the 32px source sprite. A non-integer scale on pixel art with
## nearest filtering gives uneven pixel widths, which on a 1px border reads as
## a wobble along the edge of every slot.
const SLOT := 64.0
const SLOT_GAP := 8.0
## Source-pixel width of the caps on the bar sprite. The bar is stretched only
## horizontally, so the ends are drawn at their own size and the middle takes
## whatever is left -- a plain stretched draw would smear a 1px border out to
## eight.
const BAR_CAP := 6.0
## How far above the bottom of the screen the inventory row sits.
const SLOT_BOTTOM := 26.0
@@ -31,6 +40,7 @@ func _ready() -> void:
# and the death-message centering down with it, since all three read
# _canvas.size. set_anchors_and_offsets_preset() sets both correctly.
_canvas.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
UiTheme.apply_to(_canvas)
_canvas.mouse_filter = Control.MOUSE_FILTER_IGNORE
_canvas.draw.connect(_draw_hud)
add_child(_canvas)
@@ -44,7 +54,10 @@ func _ready() -> void:
# here means "56px above the bottom anchor", which is what "-56" was meant
# to say in the first place.
_hint.offset_left = MARGIN
_hint.offset_top = -56.0
# Above the inventory row, not through it. Derived from the row's own
# geometry rather than written down, because the slots grew from 46px to 64
# and a hardcoded -56 put the control hints straight across them.
_hint.offset_top = -(SLOT_BOTTOM + SLOT + 34.0)
# A real Button rather than something drawn in _draw_hud: this is the one
# HUD element the player has to actually click, and it starts disabled so
@@ -126,13 +139,13 @@ func _draw_hud() -> void:
var origin := Vector2(MARGIN, MARGIN + 28.0)
_bar(origin, float(client.my_hp) / float(maxi(client.my_max_hp, 1)),
Color(0.35, 0.9, 0.6), Color(0.1, 0.15, 0.18))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 3.0))
_draw_xp_bar(origin + Vector2(0.0, BAR_H + 4.0))
if client.my_escaping:
_bar(origin + Vector2(0.0, BAR_H + 8.0), client.my_escape,
_bar(origin + Vector2(0.0, (BAR_H + 4.0) * 2.0), client.my_escape,
Color(0.5, 0.85, 1.0), Color(0.1, 0.15, 0.2))
_canvas.draw_string(ThemeDB.fallback_font,
origin + Vector2(BAR_W + 12.0, BAR_H + 8.0 + BAR_H),
origin + Vector2(BAR_W + 12.0, (BAR_H + 4.0) * 2.0 + BAR_H - 3.0),
"ESCAPING", HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(0.5, 0.85, 1.0))
_draw_boss_bar()
@@ -195,15 +208,15 @@ func _draw_inventory() -> void:
var at := origin + Vector2(float(i) * (SLOT + SLOT_GAP), 0.0)
var item := Items.by_index(int(client.my_inventory[i]))
var def := Items.get_def(item)
var frame_col := Color(0.55, 0.6, 0.72, 0.75) if i == held \
else Color(0.3, 0.33, 0.42, 0.6)
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), Color(0.07, 0.08, 0.12, 0.72))
_canvas.draw_rect(Rect2(at, Vector2(SLOT, SLOT)), frame_col, false, 1.5)
# The held slot uses the pack's pressed-in slot art rather than a
# recoloured border, so "this is the one the key is on" reads the same
# way every other pressed thing in the interface does.
var slot_art := UiTheme.texture("slot_active" if i == held else "slot")
_canvas.draw_texture_rect(slot_art, Rect2(at, Vector2(SLOT, SLOT)), false)
# The slot number, because the key that uses it is the only thing the
# player actually needs to know about a slot.
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(4.0, 13.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 11,
Color(0.5, 0.55, 0.68))
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(6.0, 17.0),
str(i + 1), HORIZONTAL_ALIGNMENT_LEFT, -1, 12, UiTheme.INK_DIM)
if def == null:
continue
var icon := Art.item_icon(item)
@@ -248,13 +261,12 @@ func _draw_xp_bar(at: Vector2) -> void:
var capped := level >= Progression.MAX_LEVEL
var progress := Progression.level_progress(client.my_total_xp)
var tint := Color(1.0, 0.85, 0.4) if capped else Color(0.6, 0.55, 1.0)
_canvas.draw_rect(Rect2(at, Vector2(BAR_W, 5.0)), Color(0.1, 0.12, 0.18))
_canvas.draw_rect(Rect2(at, Vector2(BAR_W * progress, 5.0)), tint)
_bar(at, progress, tint, Color.BLACK)
# The number as well as the bar: "how far to the next level" is a question
# a bar answers vaguely and a percentage answers exactly.
var text := "MAX" if capped else "%d%% to level %d" % [
int(floor(progress * 100.0)), level + 1]
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 6.0),
_canvas.draw_string(ThemeDB.fallback_font, at + Vector2(BAR_W + 10.0, 13.0),
text, HORIZONTAL_ALIGNMENT_LEFT, -1, 12, tint)
@@ -299,8 +311,13 @@ func _draw_boss_bar() -> void:
var w := 560.0
var pos := Vector2((_canvas.size.x - w) * 0.5, MARGIN)
var frac := clampf(float(b["hp"]) / float(client.boss_def.max_hp), 0.0, 1.0)
_canvas.draw_rect(Rect2(pos, Vector2(w, BAR_H)), Color(0.12, 0.08, 0.1))
_canvas.draw_rect(Rect2(pos, Vector2(w * frac, BAR_H)), Color(0.95, 0.35, 0.45))
var rect := Rect2(pos, Vector2(w, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inner := Rect2(rect.position + Vector2(3.0, 3.0),
Vector2((w - 6.0) * frac, BAR_H - 6.0))
if inner.size.x > 0.5:
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false,
Color(0.95, 0.35, 0.45))
var phase_index := int(b["phase"])
var phase_name := ""
if phase_index < client.boss_def.phases.size():
@@ -310,9 +327,45 @@ func _draw_boss_bar() -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, 14, Color(1.0, 0.75, 0.8))
func _bar(pos: Vector2, frac: float, fill: Color, back: Color) -> void:
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W, BAR_H)), back)
_canvas.draw_rect(Rect2(pos, Vector2(BAR_W * clampf(frac, 0.0, 1.0), BAR_H)), fill)
## A bar in the pack's art: the frame stretched horizontally, the fill tinted.
##
## [param back] is no longer a colour -- the frame sprite is the background --
## but the signature keeps it so callers read the same and a future flat-bar
## fallback has somewhere to go.
func _bar(pos: Vector2, frac: float, fill: Color, _back: Color) -> void:
var rect := Rect2(pos, Vector2(BAR_W, BAR_H))
_hslice(UiTheme.texture("bar"), rect, Color.WHITE)
var inset := 3.0
var inner := Rect2(rect.position + Vector2(inset, inset),
Vector2((rect.size.x - inset * 2.0) * clampf(frac, 0.0, 1.0),
rect.size.y - inset * 2.0))
if inner.size.x > 0.5:
# The fill sprite is a flat strip, so tinting it is the whole palette --
# one texture covers health, experience, the escape channel and the
# boss.
_canvas.draw_texture_rect(UiTheme.texture("bar_fill"), inner, false, fill)
## Draw a horizontally stretchable sprite as three slices: left cap, stretched
## middle, right cap. Godot's nine-patch lives on nodes rather than on the
## immediate-mode API, and the HUD is drawn rather than built out of controls.
func _hslice(tex: Texture2D, rect: Rect2, tint: Color) -> void:
if tex == null:
return
var src := Vector2(tex.get_size())
var cap := minf(BAR_CAP, src.x * 0.5)
var draw_cap := cap * (rect.size.y / src.y)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position, Vector2(draw_cap, rect.size.y)),
Rect2(0.0, 0.0, cap, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(draw_cap, 0.0),
Vector2(maxf(rect.size.x - draw_cap * 2.0, 0.0), rect.size.y)),
Rect2(cap, 0.0, src.x - cap * 2.0, src.y), tint)
_canvas.draw_texture_rect_region(tex,
Rect2(rect.position + Vector2(rect.size.x - draw_cap, 0.0),
Vector2(draw_cap, rect.size.y)),
Rect2(src.x - cap, 0.0, cap, src.y), tint)
func flash_hit() -> void:
+7
View File
@@ -4,6 +4,7 @@ extends Control
signal join_requested(address: String, port: int)
signal host_requested(port: int)
signal settings_requested
var _address: LineEdit
var _port: LineEdit
@@ -23,6 +24,7 @@ func _ready() -> void:
# what actually caused the "menu stuck in the top-left corner" bug --
# set_anchors_and_offsets_preset() sets both halves correctly in one call.
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
UiTheme.apply_to(self)
# A CenterContainer recomputes its child's centered position itself, every
# time the child's minimum size or the window size changes -- no manual
@@ -55,6 +57,11 @@ func _ready() -> void:
_host_button.pressed.connect(_on_host)
panel.add_child(_host_button)
var settings := Button.new()
settings.text = "Settings"
settings.pressed.connect(func() -> void: settings_requested.emit())
panel.add_child(settings)
var quit := Button.new()
quit.text = "Quit"
quit.pressed.connect(func() -> void: get_tree().quit())
+246
View File
@@ -0,0 +1,246 @@
extends CanvasLayer
## Volumes and key bindings.
##
## Reachable from the main menu and from the in-game menu, and the same screen
## both times: settings are local to this client, so there is nothing about the
## game's state that should change what it can do.
signal closed
signal credits_requested
var _rows: Dictionary[String, Button] = {}
var _status: Label
var _master: HSlider
var _sfx: HSlider
var _master_value: Label
var _sfx_value: Label
## The action currently waiting for a key press, or empty.
var _listening: String = ""
func _ready() -> void:
layer = 32
visible = false
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
scrim.color = Color(0.03, 0.03, 0.06, 0.94)
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scrim.mouse_filter = Control.MOUSE_FILTER_STOP
root.add_child(scrim)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
centre.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(520.0, 0.0)
panel.add_theme_constant_override("separation", 8)
frame.add_child(panel)
var title := Label.new()
title.text = "SETTINGS"
title.add_theme_font_size_override("font_size", 26)
panel.add_child(title)
panel.add_child(_heading("SOUND"))
var master_row := _slider_row(panel, "Master")
_master = master_row[0]
_master_value = master_row[1]
var sfx_row := _slider_row(panel, "Effects")
_sfx = sfx_row[0]
_sfx_value = sfx_row[1]
panel.add_child(_heading("CONTROLS"))
_status = Label.new()
_status.add_theme_font_size_override("font_size", 12)
_status.add_theme_color_override("font_color", Color(1.0, 0.7, 0.5))
panel.add_child(_status)
# Scrolled: thirteen rows is more than fits on a short window, and a list
# that runs off the bottom of the screen is a list with unreachable entries.
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(520.0, 300.0)
panel.add_child(scroll)
var list := VBoxContainer.new()
list.custom_minimum_size = Vector2(500.0, 0.0)
list.add_theme_constant_override("separation", 3)
scroll.add_child(list)
for entry in Settings.REBINDABLE:
_rows[String(entry[0])] = _binding_row(list, String(entry[0]), String(entry[1]))
var buttons := HBoxContainer.new()
buttons.add_theme_constant_override("separation", 8)
panel.add_child(buttons)
var reset := Button.new()
reset.text = "Reset controls"
reset.custom_minimum_size = Vector2(160.0, 34.0)
reset.pressed.connect(func() -> void:
Settings.reset_bindings()
_status.text = "controls reset"
refresh())
buttons.add_child(reset)
var credits := Button.new()
credits.text = "Credits"
credits.custom_minimum_size = Vector2(160.0, 34.0)
credits.pressed.connect(func() -> void: credits_requested.emit())
buttons.add_child(credits)
var close := Button.new()
close.text = "Close"
close.custom_minimum_size = Vector2(160.0, 34.0)
close.pressed.connect(func() -> void: closed.emit())
buttons.add_child(close)
## A section heading on the pack's banner ribbon, so the two halves of this
## screen are visibly two sections rather than one long list.
## A section heading on the pack's banner ribbon.
##
## Deliberately narrow and centred rather than stretched across the dialog: at
## full width the ribbon stops reading as a label and becomes a bright divider
## competing with everything under it.
func _heading(text: String) -> Control:
var row := CenterContainer.new()
var holder := MarginContainer.new()
holder.custom_minimum_size = Vector2(210.0, 34.0)
row.add_child(holder)
var banner := NinePatchRect.new()
banner.texture = UiTheme.texture("banner")
# The ribbon's tails hang off both ends; stretching through them would
# smear the shape, so they are the patch margins and only the body grows.
banner.patch_margin_left = 14
banner.patch_margin_right = 14
banner.patch_margin_top = 8
banner.patch_margin_bottom = 8
holder.add_child(banner)
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 13)
l.add_theme_color_override("font_color", UiTheme.INK)
l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
holder.add_child(l)
return row
func _slider_row(parent: Control, label: String) -> Array:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
parent.add_child(row)
var name_label := Label.new()
name_label.text = label
name_label.custom_minimum_size = Vector2(90.0, 0.0)
row.add_child(name_label)
var slider := HSlider.new()
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.05
slider.custom_minimum_size = Vector2(340.0, 20.0)
row.add_child(slider)
var value := Label.new()
value.custom_minimum_size = Vector2(50.0, 0.0)
row.add_child(value)
return [slider, value]
func _binding_row(parent: Control, action: String, label: String) -> Button:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
parent.add_child(row)
var name_label := Label.new()
name_label.text = label
name_label.custom_minimum_size = Vector2(260.0, 0.0)
row.add_child(name_label)
var button := Button.new()
button.custom_minimum_size = Vector2(200.0, 30.0)
button.pressed.connect(func() -> void: _listen_for(action))
row.add_child(button)
return button
func open() -> void:
visible = true
_listening = ""
_status.text = ""
refresh()
func refresh() -> void:
_master.value = Settings.master_volume
_sfx.value = Settings.sfx_volume
_master_value.text = "%d%%" % roundi(Settings.master_volume * 100.0)
_sfx_value.text = "%d%%" % roundi(Settings.sfx_volume * 100.0)
# Connected here rather than in _ready so setting the values above cannot
# fire the handler and write the file back on every open.
if not _master.value_changed.is_connected(_on_master):
_master.value_changed.connect(_on_master)
_sfx.value_changed.connect(_on_sfx)
for action in _rows:
_rows[action].text = "listening..." if action == _listening \
else Settings.binding_label(action)
func _on_master(value: float) -> void:
Settings.master_volume = value
Settings.apply_audio()
Settings.save()
_master_value.text = "%d%%" % roundi(value * 100.0)
func _on_sfx(value: float) -> void:
Settings.sfx_volume = value
Settings.apply_audio()
Settings.save()
_sfx_value.text = "%d%%" % roundi(value * 100.0)
func _listen_for(action: String) -> void:
_listening = action
_status.text = "press a key or mouse button (Escape cancels)"
refresh()
## Captures the next key or mouse button while a row is listening.
##
## _input rather than _unhandled_input on purpose: the buttons in this screen
## consume clicks, and a rebind that could never see a mouse button would be a
## rebind that cannot bind Fire.
func _input(event: InputEvent) -> void:
if not visible or _listening.is_empty():
return
if event is InputEventKey and not (event as InputEventKey).pressed:
return
if event is InputEventMouseButton and not (event as InputEventMouseButton).pressed:
return
if not Settings.is_bindable(event):
return
get_viewport().set_input_as_handled()
var action := _listening
_listening = ""
# Escape is the way out of a listening row, so it cannot also be captured
# by one -- binding the cancel key would leave no way to cancel.
if event is InputEventKey \
and (event as InputEventKey).physical_keycode == KEY_ESCAPE:
_status.text = "cancelled"
refresh()
return
var clash := Settings.rebind(action, event)
if clash.is_empty():
_status.text = ""
else:
_status.text = "%s is already %s" % [
Settings.binding_label(clash), _label_of(clash)]
refresh()
func _label_of(action: String) -> String:
for entry in Settings.REBINDABLE:
if String(entry[0]) == action:
return String(entry[1])
return action
+1
View File
@@ -0,0 +1 @@
uid://m6dfka8r5y2o
+21 -14
View File
@@ -31,8 +31,7 @@ var _status: Label
func _ready() -> void:
layer = 28
visible = false
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var root := UiTheme.themed_root()
add_child(root)
var scrim := ColorRect.new()
@@ -44,10 +43,15 @@ func _ready() -> void:
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.add_child(centre)
# A panel, so a dialog reads as a thing laid over the world rather
# than as text floating on a dimmed one.
var frame := UiTheme.dialog_panel()
centre.add_child(frame)
var panel := VBoxContainer.new()
panel.custom_minimum_size = Vector2(760.0, 0.0)
panel.add_theme_constant_override("separation", 10)
centre.add_child(panel)
frame.add_child(panel)
_title = Label.new()
_title.add_theme_font_size_override("font_size", 26)
@@ -117,12 +121,16 @@ func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -
func _make_card(index: int, id: StringName) -> Control:
var def := Upgrades.get_def(id)
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(240.0, 0.0)
card.add_theme_constant_override("separation", 6)
if def == null:
return card
return Control.new()
var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE)
# The rarity is the card's frame, not just a word on it. Three cards side by
# side are compared at a glance, and a colour reads faster than a label.
var frame := UiTheme.card_panel(tint.lerp(Color(0.10, 0.10, 0.14), 0.72))
frame.custom_minimum_size = Vector2(244.0, 0.0)
var card := VBoxContainer.new()
card.add_theme_constant_override("separation", 6)
frame.add_child(card)
var name_label := Label.new()
name_label.text = def.display_name
@@ -139,16 +147,15 @@ func _make_card(index: int, id: StringName) -> Control:
var body := Label.new()
body.text = def.description
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
body.custom_minimum_size = Vector2(230.0, 96.0)
body.custom_minimum_size = Vector2(214.0, 104.0)
body.add_theme_font_size_override("font_size", 13)
card.add_child(body)
# Shown on every card, because it applies to every card. The brief asks for
# the choice screen to show this buff as well as the upgrade's own effects,
# and a player comparing three cards should not have to remember it.
# Shown on every card because it applies to every card. Deliberately NOT
# labelled "every upgrade": that a flat bonus rides along with all of them
# is a design principle, and the player only needs the number.
var bonus := Label.new()
bonus.text = "+%d%% damage (every upgrade)" % roundi(
SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
bonus.text = "+%d%% damage" % roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
bonus.add_theme_font_size_override("font_size", 12)
bonus.add_theme_color_override("font_color", Color(0.55, 0.8, 0.6))
card.add_child(bonus)
@@ -158,7 +165,7 @@ func _make_card(index: int, id: StringName) -> Control:
take.custom_minimum_size = Vector2(0.0, 34.0)
take.pressed.connect(func() -> void: choose_requested.emit(index))
card.add_child(take)
return card
return frame
## Counted rather than listed one per line: "Split Shot x3" is the number that
+25 -3
View File
@@ -97,7 +97,18 @@ const ENEMY_IDLE: Array[Rect2] = [
Rect2(368, 40, 16, 16), # goblin -> stalker
Rect2(288, 336, 16, 16), # red flask -> practice target
]
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
## BossDef.visual -> first idle frame. Same convention as ENEMY_IDLE.
const BOSS_IDLE_FRAMES: Array[Rect2] = [
Rect2(16, 428, 32, 36), # big demon -> the Warden of the Fold
Rect2(144, 428, 32, 36), # big ogre -> the Cantor of the Vault
]
## The first boss's strip, kept as a name because plenty of code and tests read
## "the boss sprite" and only one of them cares which boss.
const BOSS_IDLE := BOSS_IDLE_FRAMES[0]
static func boss_idle(visual: int) -> Rect2:
return BOSS_IDLE_FRAMES[clampi(visual, 0, BOSS_IDLE_FRAMES.size() - 1)]
## The hub's quartermaster. A different character strip from the knight the
## player wears, so an NPC never reads as another player standing still.
const NPC_IDLE := Rect2(128, 36, 16, 28)
@@ -129,8 +140,19 @@ static func item_icon(item: StringName) -> Rect2:
# still, which is exactly what it looked like on screen.
const BULLET_CELL := 16.0
const BULLET_FRAMES := 8
## Seconds per bullet animation frame.
const BULLET_FRAME_SECONDS := 0.08
## Which of the sheet's frames is actually drawn.
##
## One, and always the same one. The sheet's eight frames are a COLOUR cycle
## rather than a shape change, and cycling them put a few hundred bullets on
## screen all strobing through a palette together -- which is exactly as hard to
## look at as it sounds. The bullets turn slowly instead, which reads as motion
## without flickering. The other seven frames stay in the atlas because that is
## what the source art is; nothing draws them.
const BULLET_STILL_FRAME := 0
## Radians per second a bullet turns as it travels. Deliberately slow: at this
## rate nothing completes a full turn inside its own lifetime, so the effect is
## a drift rather than a spin.
const BULLET_SPIN_RATE := 0.55
## Region for a bullet kind at an animation frame.
+27 -30
View File
@@ -9,6 +9,11 @@ extends Node2D
## carries a single texture, and each kind needs a different region of the sprite
## sheet. Four kinds means four draw calls, which is nothing, and it avoids a
## custom shader passing UV offsets through per-instance custom data.
##
## Bullets do not animate. The sheet's frames are a colour cycle, and running it
## meant every bullet on screen strobed through a palette in unison -- unreadable
## with a few hundred of them in the air. Each bullet turns slowly as it flies
## instead, which is motion you can look at. See Art.BULLET_STILL_FRAME.
## Used only in fallback mode, where every bullet is the same generated dot and
## colour is the only thing distinguishing a kind.
@@ -20,9 +25,8 @@ const _FALLBACK_COLOURS: Array[Color] = [
]
var _layers: Array[MultiMeshInstance2D] = []
## [kind][frame] -> standalone texture. See _slice_frames().
var _frames: Array[Array] = []
## Advances the bullets' own animation, independent of the simulation.
## Elapsed seconds, driving the slow turn. Wall time rather than a frame count,
## so bullets drift at the same rate whatever the frame rate is doing.
var _anim_time: float = 0.0
## True when the licence-restricted bullet sheet is absent and we are drawing
## generated dots instead. See Art.bullets_texture().
@@ -34,16 +38,17 @@ func _ready() -> void:
_fallback = sheet == null
var art: Texture2D = sheet if sheet != null else _make_dot_texture()
var kinds := SimConfig.KIND_HEAVY + 1
var per_kind: Array[Texture2D] = []
if not _fallback:
_slice_frames(sheet, kinds)
art = _frames[0][0]
per_kind = _slice_still_frames(sheet, kinds)
if _fallback:
GameLog.info("view", "bullet sheet absent, drawing generated dots")
var quad := QuadMesh.new()
quad.size = Vector2.ONE
for kind in kinds:
var layer := MultiMeshInstance2D.new()
layer.texture = art
# Assigned once and never touched again -- that swap was the animation.
layer.texture = per_kind[kind] if not _fallback else art
var mm := MultiMesh.new()
mm.transform_format = MultiMesh.TRANSFORM_2D
mm.use_colors = true
@@ -57,8 +62,6 @@ func _ready() -> void:
func _process(delta: float) -> void:
_anim_time += delta
if not _fallback:
_apply_frame()
## A soft-edged disc with a bright core, generated at runtime. This is what the
@@ -77,33 +80,23 @@ func _make_dot_texture() -> ImageTexture:
return ImageTexture.create_from_image(img)
## Slice the atlas into one standalone texture per (kind, frame).
## Cut one standalone texture per kind out of the atlas.
##
## A MultiMeshInstance2D cannot show a sub-region of a texture. Its mesh UVs run
## 0..1 across whatever texture it is given, and handing it an AtlasTexture does
## not help -- the draw resolves to the underlying atlas and the region is
## ignored, so every bullet rendered as the entire sheet. Pre-slicing costs 32
## tiny textures once at startup and makes each frame a single assignment.
func _slice_frames(sheet: Texture2D, kinds: int) -> void:
## ignored, so every bullet rendered as the entire sheet. Slicing is the way
## round that, and now that only one frame is ever drawn it costs four small
## textures at startup rather than thirty-two.
func _slice_still_frames(sheet: Texture2D, kinds: int) -> Array[Texture2D]:
var img := sheet.get_image()
var out: Array[Texture2D] = []
for kind in kinds:
var per_kind: Array[Texture2D] = []
for f in Art.BULLET_FRAMES:
var r := Art.bullet_region(kind, f)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
per_kind.append(ImageTexture.create_from_image(cell))
_frames.append(per_kind)
## Swap each layer to this frame's texture. One assignment per kind per frame;
## every bullet in a layer shares it, so there is no per-instance work.
func _apply_frame() -> void:
if _frames.is_empty():
return
var f := int(_anim_time / Art.BULLET_FRAME_SECONDS) % Art.BULLET_FRAMES
for kind in _layers.size():
_layers[kind].texture = _frames[kind][f]
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
var cell := Image.create(int(r.size.x), int(r.size.y), false, img.get_format())
cell.blit_rect(img, Rect2i(r), Vector2i.ZERO)
out.append(ImageTexture.create_from_image(cell))
return out
func render_pool(pool: BulletPool) -> void:
@@ -121,7 +114,11 @@ func render_pool(pool: BulletPool) -> void:
# little larger so a bullet looks like the threat it is -- the same
# "prefer a visible near-miss" trade the ship sprite makes.
var d: float = pool.radius[i] * 3.0
var angle: float = (pool.vel[i] as Vector2).angle()
# Heading, plus a slow turn. The offset is taken from the bullet's own
# id so a ring of twenty does not rotate as one rigid wheel.
var angle: float = (pool.vel[i] as Vector2).angle() \
+ _anim_time * Art.BULLET_SPIN_RATE \
+ float(pool.uid[i] % 64) * (TAU / 64.0)
_layers[kind].multimesh.set_instance_transform_2d(
n, Transform2D(angle, Vector2(d, d), 0.0, pool.pos[i]))
_layers[kind].multimesh.set_instance_color(n,
+6
View File
@@ -9,6 +9,8 @@ extends Node2D
@onready var sfx: Node = $Sfx
@onready var characters: CanvasLayer = $CharacterSelect
@onready var upgrades: CanvasLayer = $UpgradeScreen
@onready var settings: CanvasLayer = $SettingsScreen
@onready var credits: CanvasLayer = $CreditsScreen
var _bound: ClientRuntime = null
## Opened deliberately from the menu, as opposed to forced open by having no
@@ -34,6 +36,10 @@ func _ready() -> void:
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
characters.closed.connect(func() -> void: _roster_open = false)
upgrades.closed.connect(func() -> void: _upgrades_open = false)
menu.settings_requested.connect(func() -> void: settings.open())
settings.closed.connect(func() -> void: settings.visible = false)
settings.credits_requested.connect(func() -> void: credits.open())
credits.closed.connect(func() -> void: credits.visible = false)
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
+5 -1
View File
@@ -20,9 +20,13 @@ var _frame: int = 0
func _ready() -> void:
# Settings.apply_audio() creates the bus; make sure it exists before the
# players are pointed at it, or they silently fall back to Master and the
# effects slider does nothing.
Settings.apply_audio()
for i in VOICES:
var p := AudioStreamPlayer.new()
p.bus = "Master"
p.bus = Settings.SFX_BUS
add_child(p)
_voices.append(p)
+206
View File
@@ -0,0 +1,206 @@
class_name UiTheme
extends RefCounted
## The game's control theme, built in code from Crusenho's UI pack.
##
## Built rather than authored as a .tres for the same reason [Content] is code:
## it is a readable diff, there are no resource UIDs churning in version
## control, and a test can build it without the editor.
##
## Applied to each screen's root Control ([method apply_to]). It cannot be
## applied once to the Window instead: Godot's theme lookup walks a Control's
## ancestors, and the chain BREAKS at the first plain Node -- which for every
## screen here is `main.gd` or a CanvasLayer. Setting `window.theme` looked like
## it worked and changed nothing.
const DIR := "res://assets/sprites/ui/"
## Every texture the theme needs. Named so a test can assert they all load: a
## missing file gives a null texture and a silently unstyled control, which
## looks like the theme never being applied at all.
const TEXTURES := {
"button": DIR + "Button01a_1.png",
"button_hover": DIR + "Button01a_2.png",
"button_pressed": DIR + "Button01a_3.png",
"button_disabled": DIR + "Button01a_4.png",
"panel": DIR + "Frame01a.png",
"panel_accent": DIR + "Frame02a.png",
"panel_warn": DIR + "Frame03a.png",
"card": DIR + "FrameMarker01a.png",
"slot": DIR + "FrameSlot01a.png",
"slot_active": DIR + "FrameSlot01b.png",
"bar": DIR + "Bar01a.png",
"bar_fill": DIR + "BarFill01f.png",
"track": DIR + "Bar05a.png",
"trough": DIR + "Bar07a.png",
"grabber": DIR + "Handle03a.png",
"grabber_small": DIR + "Handle02a.png",
"field": DIR + "InputField01a.png",
"banner": DIR + "Banner01a.png",
}
## Panels are the pack's shape in this game's colours.
##
## The set is cream throughout, which is right for its buttons -- they are meant
## to be the bright thing you press -- and wrong for a full-screen dialog laid
## over a dark dungeon. Modulating the panel art keeps the pixel border and the
## corner shape and lets every label the game already draws in light colours
## stay readable, instead of a rewrite of every colour override in five screens.
const PANEL_TINT := Color(0.17, 0.18, 0.24)
const TRACK_TINT := Color(0.30, 0.32, 0.40)
## The art is cream with a black outline, so text on it has to be dark. Every
## label the game draws over the dark world stays light; only controls that sit
## on this pack's own surfaces flip.
const INK := Color(0.12, 0.11, 0.13)
const INK_DIM := Color(0.42, 0.40, 0.44)
static var _theme: Theme = null
static func texture(key: String) -> Texture2D:
return load(TEXTURES[key]) as Texture2D
## One shared instance. Themes are pure data and every control only reads them,
## so building a second would be waste rather than isolation.
static func get_theme() -> Theme:
if _theme != null:
return _theme
var t := Theme.new()
t.default_font_size = 14
t.set_stylebox("normal", "Button", _box("button", 8, 12, 7))
t.set_stylebox("hover", "Button", _box("button_hover", 8, 12, 7))
t.set_stylebox("pressed", "Button", _box("button_pressed", 8, 12, 7))
t.set_stylebox("disabled", "Button", _box("button_disabled", 8, 12, 7))
# Focus deliberately reuses the hover art rather than adding an outline:
# the pack has no focus state, and an invented one would be the only thing
# on screen that is not from the set.
t.set_stylebox("focus", "Button", _box("button_hover", 8, 12, 7))
t.set_color("font_color", "Button", INK)
t.set_color("font_hover_color", "Button", INK)
t.set_color("font_pressed_color", "Button", INK)
t.set_color("font_disabled_color", "Button", INK_DIM)
t.set_color("font_focus_color", "Button", INK)
var dialog := _box("panel", 10, 22, 18)
dialog.modulate_color = PANEL_TINT
t.set_stylebox("panel", "Panel", dialog)
t.set_stylebox("panel", "PanelContainer", dialog)
# Sliders: a groove, a filled portion, and a grabber big enough to aim at.
#
# The groove uses the SOLID bar rather than the pack's hollow one. Tinting a
# sprite whose middle is transparent leaves an outline and nothing else, so
# the first version of this had an invisible track and a grabber apparently
# floating in space.
# The content margins are load-bearing here, not padding: a Slider draws its
# groove in a band whose thickness IS the stylebox's minimum size, and a
# stylebox with no content margins has a minimum size of zero. With them at
# 0 the track resolved correctly, reported the right texture, and drew
# nothing at all.
var groove := _box("trough", 4, 0, 5)
groove.modulate_color = TRACK_TINT
t.set_stylebox("slider", "HSlider", groove)
var filled := _box("bar_fill", 1, 0, 4)
filled.modulate_color = Color(0.55, 0.78, 1.0)
t.set_stylebox("grabber_area", "HSlider", filled)
t.set_stylebox("grabber_area_highlight", "HSlider", filled)
# Twice size, not three times: the pack's handle is 8x10 and at 3x it was
# taller than the row it sits in.
var grab := scaled("grabber", 2)
t.set_icon("grabber", "HSlider", grab)
t.set_icon("grabber_highlight", "HSlider", grab)
t.set_icon("grabber_disabled", "HSlider", grab)
t.set_constant("center_grabber", "HSlider", 1)
# Scrollbars. Two screens scroll, and Godot's default bar is the one
# remaining piece of grey chrome once everything else is the pack's.
# Content margins again, and for the same reason: a ScrollBar takes its
# THICKNESS from the minimum size of its styleboxes. At zero the bar is
# zero pixels wide, which is indistinguishable from having no scrollbar --
# and that is precisely how it looked.
for bar_class in ["VScrollBar", "HScrollBar"]:
var trough := _box("trough", 4, 5, 5)
trough.modulate_color = TRACK_TINT
t.set_stylebox("scroll", bar_class, trough)
t.set_stylebox("scroll_focus", bar_class, trough)
for state in ["grabber", "grabber_highlight", "grabber_pressed"]:
t.set_stylebox(state, bar_class, _box("grabber_small", 3, 5, 5))
t.set_stylebox("normal", "LineEdit", _box("field", 8, 10, 6))
t.set_stylebox("focus", "LineEdit", _box("field", 8, 10, 6))
t.set_color("font_color", "LineEdit", INK)
t.set_color("font_placeholder_color", "LineEdit", INK_DIM)
t.set_color("caret_color", "LineEdit", INK)
_theme = t
return _theme
## Give [param root] the theme. Everything under it inherits, so one call per
## screen covers that screen.
##
## Per-screen rather than once on the Window, because theme inheritance only
## travels Control-to-Control: a Control whose parent is a plain Node or a
## CanvasLayer resolves against the default theme however the Window is set.
## Every screen in this game hangs off one or the other.
static func apply_to(root: Control) -> void:
if root != null:
root.theme = get_theme()
## A full-rect Control with the theme already on it -- the root every screen
## built in code starts from. Using this rather than a bare Control is what
## stops a new screen quietly rendering unstyled.
static func themed_root() -> Control:
var root := Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
apply_to(root)
return root
## A copy of a texture at a whole multiple of its size, nearest-filtered.
##
## For theme items Godot draws at the texture's own size -- a slider grabber,
## for one -- where the pack's 8x10 handle would otherwise be a speck. Nearest
## rather than smooth: this is pixel art, and a bilinear upscale of an 8px
## sprite is mush.
static func scaled(key: String, factor: int) -> ImageTexture:
var img := texture(key).get_image()
img.resize(img.get_width() * factor, img.get_height() * factor,
Image.INTERPOLATE_NEAREST)
return ImageTexture.create_from_image(img)
## A panel to put a dialog's contents in, so a screen is a thing on the world
## rather than text floating over it.
static func dialog_panel() -> PanelContainer:
var frame := PanelContainer.new()
apply_to(frame)
return frame
## A card: the pack's marker frame, tinted. Used for the upgrade choices, where
## the tint carries the rarity.
static func card_panel(tint: Color) -> PanelContainer:
var card := PanelContainer.new()
apply_to(card)
var style := _box("card", 10, 12, 10)
style.modulate_color = tint
card.add_theme_stylebox_override("panel", style)
return card
## A nine-patch stylebox. [param margin] is the corner size in source pixels;
## the middle is what stretches.
static func _box(key: String, margin: int, pad_x: int, pad_y: int) -> StyleBoxTexture:
var sb := StyleBoxTexture.new()
sb.texture = texture(key)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
sb.set_texture_margin(side, float(margin))
sb.set_content_margin(SIDE_LEFT, float(pad_x))
sb.set_content_margin(SIDE_RIGHT, float(pad_x))
sb.set_content_margin(SIDE_TOP, float(pad_y))
sb.set_content_margin(SIDE_BOTTOM, float(pad_y))
return sb
+1
View File
@@ -0,0 +1 @@
uid://ddaj0c8xfjitx
+26 -3
View File
@@ -63,6 +63,7 @@ func _draw() -> void:
if _visible(e["pos"]):
_draw_enemy(e)
_draw_boss()
_draw_telegraphs()
for p in client.remote_players():
if _visible(p["pos"]):
_draw_remote_player(p)
@@ -95,8 +96,7 @@ func _draw_debug() -> void:
## Aggro radius by visual index. Read from the content definitions rather than
## hardcoded, so the overlay cannot drift from what the server actually uses.
func _aggro_for(visual: int) -> float:
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
for id in Content.ALL_ENEMIES:
var def := Content.enemy(id)
if def.visual == visual:
return def.aggro_range if not def.emitters.is_empty() else 0.0
@@ -254,12 +254,35 @@ func _draw_enemy(e: Dictionary) -> void:
Color(0.95, 0.55, 0.55, 0.35), 1.5)
## Attacks that have been announced but not yet landed.
##
## Drawn through fog and through walls, deliberately. Everything else in this
## view respects line of sight, but a warning you cannot see is not a warning --
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
## exist is to make the strike a decision. The marker is inside the boss arena
## you are already standing in, so this hides nothing a player could not walk
## two paces and see.
func _draw_telegraphs() -> void:
for t in client.telegraphs:
var at: Vector2 = t["pos"]
var r: float = t["r"]
var left := float(int(t["until"]) - client.server_tick_est)
var progress := clampf(1.0 - left / float(int(t["ticks"])), 0.0, 1.0)
# Fills up as the moment approaches, so the warning reads as a clock and
# not merely as a place.
draw_circle(at, r, Color(1.0, 0.35, 0.3, 0.10 + 0.14 * progress))
draw_arc(at, r, -PI * 0.5, -PI * 0.5 + TAU * progress, 40,
Color(1.0, 0.5, 0.35, 0.9), 3.0)
draw_arc(at, r, 0.0, TAU, 40, Color(1.0, 0.45, 0.35, 0.35), 1.5)
func _draw_boss() -> void:
var b := client.boss_state()
if b.is_empty() or not _visible(b["pos"]):
return
var pos: Vector2 = b["pos"]
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
var visual := client.boss_def.visual if client.boss_def != null else 0
var src := Art.frame(Art.boss_idle(visual), Art.anim_frame(_anim_time, 0))
_draw_sprite(Art.TILESET, src, pos)
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
+30 -2
View File
@@ -47,6 +47,9 @@ func test_every_animation_frame_is_inside_the_tileset() -> void:
"frame %d of strip %s runs off the atlas at %s" % [n, strip, f])
## Only BULLET_STILL_FRAME is ever drawn, but the whole sheet still has to be
## the shape the atlas builder produced -- a short one would mean the still
## frame of a later kind is cut from the wrong row.
func test_every_bullet_frame_is_inside_the_sheet() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
@@ -59,6 +62,32 @@ func test_every_bullet_frame_is_inside_the_sheet() -> void:
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
func test_the_still_frame_exists_for_every_kind() -> void:
var sheet := Art.bullets_texture()
if sheet == null:
pass_test("bullet sheet not present locally")
return
assert_lt(Art.BULLET_STILL_FRAME, Art.BULLET_FRAMES)
for kind in SimConfig.KIND_HEAVY + 1:
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
assert_true(_fits(sheet, r), "kind %d has no still frame" % kind)
## Slow enough that nothing completes a turn inside its own lifetime. A bullet
## that visibly spins is the flicker this replaced, wearing a different hat.
func test_bullets_turn_slowly_enough_to_read_as_drift() -> void:
var longest := float(SimConfig.PLAYER_BULLET_LIFETIME)
for id in Content.ALL_ENEMIES:
for e in Content.enemy(id).emitters:
longest = maxf(longest, float(e.lifetime))
for id in Content.ALL_BOSSES:
for phase in Content.boss(id).phases:
for e in phase.emitters:
longest = maxf(longest, float(e.lifetime))
var turn := Art.BULLET_SPIN_RATE * longest * SimConfig.TICK_DELTA
assert_lt(turn, TAU, "the longest-lived bullet turns %.2f rad in its life" % turn)
## The atlas has one row per kind. A short sheet would silently draw the wrong
## bullet rather than error.
func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
@@ -75,8 +104,7 @@ func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
## the wrong creature.
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
for id in Content.ALL_ENEMIES:
var v := Content.enemy(id).visual
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
+127
View File
@@ -113,3 +113,130 @@ func test_a_brand_new_boss_needs_no_engine_changes() -> void:
world.spawn_boss(def)
world.step()
assert_eq(world.pool.live_count, 6)
# --- The second boss --------------------------------------------------------
func test_the_cantor_is_registered_and_whole() -> void:
var b := Content.boss(Content.BOSS_CANTOR)
assert_eq(b.id, Content.BOSS_CANTOR)
assert_gt(b.max_hp, 0)
assert_gt(b.phases.size(), 1)
for phase in b.phases:
assert_gt(phase.emitters.size(), 0, "%s fires nothing" % phase.name)
assert_gt(b.loot.size(), 0, "a boss kill has to be worth something")
## Phases are picked by "the last one whose threshold still covers this hp
## fraction", so a list that is not sorted downwards silently skips phases.
func test_every_boss_lists_its_phases_from_full_health_downwards() -> void:
for id in Content.ALL_BOSSES:
var previous := 2.0
for phase in Content.boss(id).phases:
assert_lt(phase.enter_at_hp_fraction, previous,
"%s: %s is not below the phase before it" % [id, phase.name])
previous = phase.enter_at_hp_fraction
func test_every_boss_reaches_all_of_its_phases() -> void:
for id in Content.ALL_BOSSES:
var def := Content.boss(id)
var seen := {}
for step_index in 101:
seen[def.phase_index_for(float(step_index) / 100.0)] = true
assert_eq(seen.size(), def.phases.size(),
"%s has a phase that no health fraction selects" % id)
func test_there_is_a_sprite_for_every_boss() -> void:
for id in Content.ALL_BOSSES:
var def := Content.boss(id)
assert_lt(def.visual, Art.BOSS_IDLE_FRAMES.size(),
"%s has visual %d with no sprite" % [id, def.visual])
for n in Art.ACTOR_FRAMES:
var f := Art.frame(Art.boss_idle(def.visual), n)
assert_lte(f.end.x, float(Art.TILESET.get_width()))
assert_lte(f.end.y, float(Art.TILESET.get_height()))
func test_the_two_bosses_look_different() -> void:
assert_ne(Content.warden().visual, Content.cantor().visual)
## The Cantor's patterns assume the vault's barricades the way the Warden's
## assume the hall's pits, so which boss appears has to follow from which arena
## was stamped rather than being chosen separately.
## Every id in the registry has to resolve. The lists exist so nothing has to
## be hand-maintained in five places; this is what keeps them honest.
func test_the_registry_lists_resolve() -> void:
for id in Content.ALL_ENEMIES:
assert_eq(Content.enemy(id).id, id)
for id in Content.ALL_BOSSES:
assert_eq(Content.boss(id).id, id)
func test_each_arena_summons_its_own_boss() -> void:
assert_eq(Rooms.boss_for_arena(&"warden_hall"), Content.BOSS_WARDEN)
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
## The arena a dungeon names decides its boss, whatever the seed. 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.
func test_the_named_arena_decides_the_boss_whatever_the_seed() -> void:
for run in 12:
var seed_value := run * 7919 + 3
assert_eq(StringName(MapGen.generate(seed_value, 1, &"warden_hall")["boss_id"]),
Content.BOSS_WARDEN)
assert_eq(StringName(MapGen.generate(seed_value, 1, &"choir_vault")["boss_id"]),
Content.BOSS_CANTOR)
## Only the test harness leaves its arena open, and then the seed decides. Both
## fights still have to turn up there, or half the harness is unreachable.
func test_an_unnamed_arena_still_produces_both_fights() -> void:
var seen := {}
for run in 40:
seen[StringName(MapGen.generate(run * 7919 + 3, 1)["boss_id"])] = true
for id in Content.ALL_BOSSES:
assert_true(seen.has(id), "%s never appears with the arena left open" % id)
## The real check that a boss is reachable in play: some dungeon in the hub ends
## with it. Adding a boss and forgetting to give it a way in is exactly the
## mistake this catches.
func test_every_boss_has_a_dungeon_that_reaches_it() -> void:
var reachable := {}
for dungeon_id in Dungeons.ORDER:
var arena := Dungeons.get_def(dungeon_id).arena
if arena.is_empty():
continue # the harness rolls; covered above
reachable[Rooms.boss_for_arena(arena)] = true
for id in Content.ALL_BOSSES:
assert_true(reachable.has(id), "no hub portal leads to %s" % id)
func test_an_instance_spawns_the_boss_its_dungeon_asked_for() -> void:
for pair in [[Dungeons.STANDARD, Content.BOSS_WARDEN],
[Dungeons.VAULT, Content.BOSS_CANTOR]]:
var inst := Instance.make_dungeon(2, 4321, 1, pair[0])
assert_eq(inst.boss_id, pair[1], "%s should end with %s" % pair)
assert_eq(inst.world.boss.def.id, pair[1])
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
"and it starts inside its own arena")
## The Cantor exists to prove the boss format stretched to movement and
## telegraphs. If it stopped using either, it would have stopped doing its job.
func test_the_cantor_actually_uses_both_new_mechanisms() -> void:
var def := Content.cantor()
var moves := false
var telegraphs := false
for phase in def.phases:
if phase.moves():
moves = true
for e in phase.emitters:
if e is TelegraphedStrikeEmitter:
telegraphs = true
assert_true(moves, "the Cantor should move")
assert_true(telegraphs, "and should telegraph")
+311
View File
@@ -0,0 +1,311 @@
extends GutTest
## Bosses that move. Movement is a property of the PHASE, so all of this is
## driven by building a BossPhase and stepping the world -- if any of it needed
## a per-boss branch in SimWorld, the boss format would have stopped being data.
var world: SimWorld
var boss: SimBoss
const ROOM := Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
func before_each() -> void:
var map := MapGrid.new(60, 40, MapGrid.Kind.WALL)
map.fill_rect(Rect2i(1, 1, 58, 38), MapGrid.Kind.FLOOR)
map.centre_on_origin()
world = SimWorld.new(3)
world.set_map(map)
func _with_phase(phase: BossPhase, at := Vector2.ZERO) -> SimBoss:
var def := BossDef.new()
def.id = &"test_boss"
def.max_hp = 1000
def.radius = 30.0
def.phases = [phase]
boss = world.spawn_boss(def)
boss.pos = at
boss.room = ROOM
return boss
func _static_phase() -> BossPhase:
var p := BossPhase.new()
p.enter_at_hp_fraction = 1.0
p.telegraph_ticks = 0
p.loop_ticks = 600
return p
func _step(n: int) -> void:
for _i in n:
world.step()
# --- The derived "does it move" flag ----------------------------------------
func test_a_phase_with_no_mode_or_no_speed_does_not_move() -> void:
var p := _static_phase()
assert_false(p.moves())
p.move = BossPhase.Move.CHASE
assert_false(p.moves(), "a mode with no speed is still standing still")
p.move_speed = 60.0
assert_true(p.moves())
## Derived from the phases rather than stored, so a boss cannot claim to be
## stationary while one of its phases walks around.
func test_a_boss_moves_if_any_of_its_phases_does() -> void:
assert_false(Content.warden().moves(), "the Warden still stands still")
assert_true(Content.cantor().moves(), "the Cantor does not")
func test_a_static_phase_leaves_the_boss_exactly_where_it_was() -> void:
var b := _with_phase(_static_phase(), Vector2(50.0, 20.0))
_step(120)
assert_eq(b.pos, Vector2(50.0, 20.0))
# --- Waypoints ---------------------------------------------------------------
func _waypoint_phase() -> BossPhase:
var p := _static_phase()
p.move = BossPhase.Move.WAYPOINTS
p.move_speed = 200.0
p.waypoint_dwell = 30
p.waypoints = [Vector2(0.0, 0.0), Vector2(1.0, 0.0)]
return p
func test_a_waypoint_boss_walks_to_its_first_point() -> void:
var b := _with_phase(_waypoint_phase(), ROOM.get_center())
var corner := ROOM.position
var before := b.pos.distance_to(corner)
_step(30)
assert_lt(b.pos.distance_to(corner), before, "it should be closing")
func test_it_arrives_dwells_and_moves_on() -> void:
var phase := _waypoint_phase()
var b := _with_phase(phase, ROOM.position)
_step(2)
assert_eq(b.waypoint_index, 1, "standing on the first point advances it")
var held := b.pos
_step(phase.waypoint_dwell - 4)
assert_eq(b.pos, held, "and it waits there rather than setting off at once")
_step(60)
assert_ne(b.pos, held, "then it goes")
## Fractions of the arena, not absolute positions -- the Warden's hall and the
## Choir Vault are different sizes, and one phase has to work in either.
func test_waypoints_are_fractions_of_the_room() -> void:
var phase := _waypoint_phase()
phase.waypoints = [Vector2(0.5, 0.5)]
var b := _with_phase(phase, ROOM.position)
_step(200)
assert_almost_eq(b.pos.x, ROOM.get_center().x, 4.0)
assert_almost_eq(b.pos.y, ROOM.get_center().y, 4.0)
func test_a_waypoint_phase_with_no_points_stands_still() -> void:
var phase := _waypoint_phase()
phase.waypoints = []
var b := _with_phase(phase, Vector2(10.0, 10.0))
_step(60)
assert_eq(b.pos, Vector2(10.0, 10.0))
# --- Chase -------------------------------------------------------------------
func _chase_phase(standoff: float) -> BossPhase:
var p := _static_phase()
p.move = BossPhase.Move.CHASE
p.move_speed = 180.0
p.move_param = standoff
return p
func test_it_closes_when_you_are_far_away() -> void:
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
var p := world.add_player(1, "bait")
p.pos = Vector2(250.0, 0.0)
var before := b.pos.distance_to(p.pos)
_step(60)
assert_lt(b.pos.distance_to(p.pos), before)
## Backs off rather than piling onto you. A boss standing on top of a player is
## a boss whose bullets cannot be read, which is the one thing this genre
## cannot afford.
func test_it_backs_off_when_you_get_too_close() -> void:
var b := _with_phase(_chase_phase(200.0), Vector2.ZERO)
var p := world.add_player(1, "bait")
p.pos = Vector2(20.0, 0.0)
_step(60)
assert_gt(b.pos.distance_to(p.pos), 20.0)
func test_it_settles_at_the_distance_it_was_given() -> void:
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
var p := world.add_player(1, "bait")
p.pos = Vector2(100.0, 0.0)
_step(240)
assert_almost_eq(b.pos.distance_to(p.pos), 150.0, 12.0)
func test_it_stands_still_with_nobody_to_chase() -> void:
var b := _with_phase(_chase_phase(150.0), Vector2(40.0, 0.0))
_step(60)
assert_eq(b.pos, Vector2(40.0, 0.0))
# --- Orbit -------------------------------------------------------------------
func _orbit_phase() -> BossPhase:
var p := _static_phase()
p.move = BossPhase.Move.ORBIT
p.move_speed = 160.0
p.move_param = 120.0
return p
## ORBIT computes an absolute destination, so without the shared speed clamp it
## would snap onto its circle on the very first tick.
func test_orbiting_never_teleports_onto_the_circle() -> void:
var phase := _orbit_phase()
var b := _with_phase(phase, ROOM.get_center() + Vector2(280.0, 0.0))
var before := b.pos
world.step()
assert_lte(b.pos.distance_to(before), phase.move_speed * SimConfig.TICK_DELTA + 0.5,
"one tick may move it at most one tick's worth")
func test_orbiting_ends_up_on_the_circle_and_keeps_going() -> void:
var phase := _orbit_phase()
var b := _with_phase(phase, ROOM.get_center())
_step(300)
var centre := ROOM.get_center()
assert_almost_eq(b.pos.distance_to(centre), phase.move_param, 25.0)
var somewhere := b.pos
_step(90)
assert_gt(b.pos.distance_to(somewhere), 20.0, "and it is still travelling")
# --- The invariants movement had to not break --------------------------------
## Boss rooms deliberately do not lock: a player can always walk out. That only
## works as an escape if the boss cannot follow.
func test_a_moving_boss_never_leaves_its_arena() -> void:
var phase := _chase_phase(0.0)
phase.move_speed = 400.0
var b := _with_phase(phase, ROOM.get_center())
var p := world.add_player(1, "bait")
for step_index in 400:
# Drag the bait right out of the room and around the map.
p.pos = Vector2(900.0, 500.0).rotated(float(step_index) * 0.05)
world.step()
assert_true(ROOM.has_point(b.pos) or ROOM.abs().grow(1.0).has_point(b.pos),
"the boss left its arena at %s" % b.pos)
## A single pillar proves nothing -- the boss would end up at its quarry either
## way, and only the final position was ever checked. A wall it cannot go round
## is the test: if movement ignores geometry the boss simply appears on the far
## side of it.
func test_a_moving_boss_does_not_walk_through_geometry() -> void:
var wall_x := world.map.to_tile(Vector2.ZERO).x
for ty in range(world.map.to_tile(Vector2(0.0, -260.0)).y,
world.map.to_tile(Vector2(0.0, 260.0)).y + 1):
world.map.set_tile(wall_x, ty, MapGrid.Kind.WALL)
var b := _with_phase(_chase_phase(0.0), Vector2(-200.0, 0.0))
var p := world.add_player(1, "bait")
p.pos = Vector2(200.0, 0.0)
for _i in 300:
world.step()
assert_false(world.map.circle_blocked(b.pos, 2.0),
"the boss stepped inside solid geometry at %s" % b.pos)
assert_lt(b.pos.x, 0.0,
"the wall spans the arena, so the boss must still be on its own side")
## The replica draws bosses from snapshots. If it moved one itself, the drawn
## boss and the authoritative one would drift apart with nothing to correct it.
func test_a_replica_never_moves_a_boss() -> void:
var replica := SimWorld.new(3)
replica.authoritative = false
var def := BossDef.new()
def.max_hp = 1000
def.radius = 30.0
def.phases = [_waypoint_phase()]
var b := replica.spawn_boss(def)
b.pos = Vector2(77.0, -33.0)
b.room = ROOM
for _i in 200:
replica.step()
assert_eq(b.pos, Vector2(77.0, -33.0))
# --- The real fight ---------------------------------------------------------
## Drives the actual Cantor through every phase in its own arena. Nothing here
## asserts a specific pattern -- the point is that a boss which moves, walks a
## circuit, chases, orbits and telegraphs runs for thousands of ticks without
## leaving its room, standing in a wall, or firing nothing.
func test_the_cantor_survives_its_own_fight() -> void:
var inst := Instance.make_dungeon(2, 9183, 1, Dungeons.VAULT)
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: the vault is the Cantor's")
var b := inst.world.boss
var bait := inst.world.add_player(1, "bait")
bait.pos = b.pos + Vector2(180.0, 0.0)
bait.spawn_grace = 1000000 # watching, not fighting
var phases_seen := {}
var telegraphs := 0
var full := b.def.max_hp
for stage in b.def.phases.size():
# Set health to each phase's own threshold rather than to fractions
# picked by hand -- guessing them missed the last phase entirely, and
# would go stale the moment the fight was retuned.
b.hp = maxi(roundi(float(full) * b.def.phases[stage].enter_at_hp_fraction), 1)
for _i in 600:
inst.step()
phases_seen[b.phase_index] = true
assert_true(b.room.grow(1.0).has_point(b.pos),
"the Cantor left its arena at %s" % b.pos)
assert_false(inst.world.map.circle_blocked(b.pos, 2.0),
"the Cantor stood inside geometry at %s" % b.pos)
for ev in inst.world.events:
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
telegraphs += 1
inst.world.drain_events()
assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran")
assert_gt(telegraphs, 0, "and it warned before striking at least once")
assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting")
# --- Not looking jittery ----------------------------------------------------
## Once it has arrived it stays arrived. The correction is signed, so without a
## dead band the sign flips every tick at the standoff distance and the boss
## vibrates on the spot -- tiny in world units, unmistakable on screen.
func test_a_chasing_boss_settles_instead_of_buzzing() -> void:
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
var p := world.add_player(1, "bait")
p.pos = Vector2(100.0, 0.0)
_step(300)
var settled := b.pos
var worst := 0.0
for _i in 120:
world.step()
worst = maxf(worst, b.pos.distance_to(settled))
assert_lt(worst, 0.001, "it moved %.2fpx after settling" % worst)
func test_the_dead_band_is_smaller_than_the_distance_it_guards() -> void:
# A band as wide as the standoff would mean the boss stops anywhere.
for phase in Content.cantor().phases:
if phase.move == BossPhase.Move.CHASE:
assert_lt(SimConfig.BOSS_CHASE_DEADBAND, phase.move_param * 0.25)
+1
View File
@@ -0,0 +1 @@
uid://dreuvmvux87rj
+4 -3
View File
@@ -103,10 +103,11 @@ func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for boss_id in Content.ALL_BOSSES:
for phase in Content.boss(boss_id).phases:
emitters.append_array(phase.emitters)
for e in emitters:
# Accelerating bullets reach their top speed at the end of their life.
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
+71
View File
@@ -76,3 +76,74 @@ func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
world.step()
assert_lt(world.pool.live_count, 12,
"a short lifetime should keep spent point-blank shots from accumulating")
# --- The scale of the numbers -----------------------------------------------
## Health and damage were multiplied by ten so that percentage modifiers have
## somewhere to land: at the old base of 6 damage, the +5% every upgrade carries
## rounded straight back to 6 and a player's first upgrade did nothing visible.
## These tests pin the properties that made the rescale worth doing, and the
## ratios it had to leave alone.
func test_a_single_upgrade_visibly_changes_damage() -> void:
var none := PlayerStats.build([] as Array[StringName])
for id in Upgrades.ORDER:
var one := PlayerStats.build([id] as Array[StringName])
assert_ne(one.damage, none.damage,
"%s carries +%d%% damage and must not round away" % [
id, roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)])
## Time to kill is the balance-relevant number, and the rescale was explicitly
## not allowed to change it. Written as shots rather than as a ratio so a
## careless edit to either side shows up as a number a designer recognises.
func test_shots_to_kill_is_what_it_was_before_the_rescale() -> void:
var expected := {
Content.ENEMY_DRIFTER: 7, # 400 hp / 60
Content.ENEMY_TURRET: 12, # 700 hp / 60
Content.ENEMY_STALKER: 5, # 300 hp / 60
}
for id in expected:
var def := Content.enemy(id)
var shots := ceili(float(def.max_hp) / float(SimConfig.PLAYER_BULLET_DAMAGE))
assert_eq(shots, int(expected[id]), "%s takes %d shots" % [id, shots])
## Likewise for how long an unarmoured player survives standing in the open.
func test_hits_to_kill_a_player_is_what_it_was() -> void:
var base := SimConfig.PLAYER_MAX_HP
var worst := 0
for id in HOSTILES:
for e in Content.enemy(id).emitters:
worst = maxi(worst, e.damage)
assert_eq(worst, 140, "the stalker's point-blank pellet is still the hardest hit")
assert_eq(base / worst, 7, "and still takes this many to drop a fresh character")
## Enemy health rides the snapshot as a u16. Anything above that is silently
## misreported rather than rejected, which is exactly the kind of bug that
## survives a rescale unnoticed -- the old practice dummy was already past it.
func test_every_enemy_fits_the_health_field_the_wire_gives_it() -> void:
for id in Content.ALL_ENEMIES:
assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id)
## A levelled character's health rides the same kind of field.
func test_a_capped_character_fits_the_health_field_too() -> void:
assert_lte(Progression.max_hp_for_level(Progression.MAX_LEVEL), 65535)
## The hub's practice target has to still be there tomorrow. Expressed as a
## flag, so a patient player cannot wear it down and leave the hub without one.
func test_the_practice_dummy_cannot_be_destroyed() -> void:
var world := SimWorld.new(1)
var e := world.spawn_enemy(Content.dummy(), Vector2(200.0, 0.0))
world._damage_enemy(e, 10_000_000)
assert_true(e.alive)
assert_eq(e.hp, e.def.max_hp, "and takes no damage at all")
func test_nothing_else_is_indestructible() -> void:
for id in HOSTILES:
assert_false(Content.enemy(id).indestructible,
"%s must be killable, or it is scenery" % id)

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