Compare commits

..

6 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
91 changed files with 3358 additions and 234 deletions
+53 -10
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
@@ -94,7 +111,7 @@ and `tests/integration/test_replica_parity.gd` pin this down.
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
| `src/sim/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
@@ -200,6 +239,10 @@ ticks in milliseconds with no SceneTree.
- **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.
+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
+94 -3
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
@@ -394,6 +403,88 @@ asserts the burst lands where the marker promised.
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.
**Which boss a run has comes from its seed, not its depth.** Depth is a dev flag
that nothing in play raises, so keying the arena to it meant the second boss
existed and no player could ever reach it.
**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.
+111 -40
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` | 409 GUT tests, no SceneTree | ~4s |
| `tools/smoke.sh` | 19 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*
@@ -371,10 +430,11 @@ feet is a coin flip, the same burst with a second of notice is a question.
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
| Final Cadence | orbit | Movement and telegraphs at once. |
**Which boss you meet comes from the run's seed**, not its depth. Depth is a dev
flag nothing in play raises, so keying the arena to it left the second boss
existing and unreachable. Every run rolls a fresh seed, so it is a coin flip per
dungeon, and `test_both_bosses_are_reachable_at_the_depth_people_play` pins it.
**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`
@@ -382,6 +442,11 @@ 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
@@ -411,18 +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.
### 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.
+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
+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,
+5 -5
View File
@@ -112,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
+7
View File
@@ -139,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")
+3 -1
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"]
+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()
+18 -1
View File
@@ -625,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]:
+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])
+16 -10
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:
@@ -71,7 +72,11 @@ static func _build_lobby() -> Dictionary:
## 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)
@@ -86,16 +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.
# Which arena -- and therefore which boss -- comes from the SEED, not the
# depth. Depth is a dev flag that nothing in play raises, so keying the
# arena to it meant the second boss existed and no player could ever reach
# it. Every run rolls a fresh seed, so this is a coin flip per dungeon.
var arena := &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
var stamp := Rooms.warden_hall() if arena == &"warden_hall" 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)
@@ -165,7 +171,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
# 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),
"boss_id": Rooms.boss_for_arena(arena_id),
}
+6 -1
View File
@@ -613,7 +613,12 @@ func _move_boss(phase: BossPhase) -> void:
# 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.
step = (to_player / gap) * signf(gap - phase.move_param) * phase.move_speed * dt
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
+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
+13 -2
View File
@@ -140,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
+29
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:
+36 -20
View File
@@ -180,34 +180,50 @@ func test_each_arena_summons_its_own_boss() -> void:
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
## Seed parity picks the arena, and the boss follows it. Deliberately NOT the
## depth: depth is a dev flag nothing in play raises, so keying the arena to it
## left the second boss unreachable in an actual game.
func test_a_generated_dungeon_gets_the_boss_its_arena_belongs_to() -> void:
var even := MapGen.generate(1234, 1)
var odd := MapGen.generate(1235, 1)
assert_eq(StringName(even["boss_id"]), Content.BOSS_WARDEN)
assert_eq(StringName(odd["boss_id"]), Content.BOSS_CANTOR)
assert_not_null(Content.boss(StringName(even["boss_id"])))
assert_not_null(Content.boss(StringName(odd["boss_id"])))
## 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)
## Both bosses have to actually turn up. A run picks its seed at random, so
## this is the check that neither is effectively unreachable.
func test_both_bosses_are_reachable_at_the_depth_people_play() -> void:
## 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 at depth 1" % id)
assert_true(seen.has(id), "%s never appears with the arena left open" % id)
func test_an_instance_spawns_the_boss_its_map_asked_for() -> void:
var inst := Instance.make_dungeon(2, 4321, 1)
assert_eq(inst.boss_id, Content.BOSS_CANTOR)
assert_eq(inst.world.boss.def.id, Content.BOSS_CANTOR)
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
"and it starts inside its own arena")
## 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
+27 -2
View File
@@ -254,8 +254,8 @@ func test_a_replica_never_moves_a_boss() -> void:
## 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)
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: an odd seed is the vault")
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)
@@ -284,3 +284,28 @@ func test_the_cantor_survives_its_own_fight() -> void:
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)
+76
View File
@@ -0,0 +1,76 @@
extends GutTest
## Attribution. Two of the packs this game ships are CC BY 4.0, which requires
## credit -- so these are licence-compliance tests, not tidiness ones.
func test_every_source_is_completely_described() -> void:
for entry in Credits.SOURCES:
for field in ["name", "author", "licence", "url", "used_for"]:
assert_false(String(entry[field]).strip_edges().is_empty(),
"%s has no %s" % [entry.get("name", "?"), field])
assert_true(String(entry["url"]).begins_with("http"),
"%s has no usable link" % entry["name"])
## The whole point of the flag: it marks the entries a licence obliges us to
## show, as opposed to the ones credited because the work deserves it.
func test_the_cc_by_packs_are_marked_as_required() -> void:
var required := Credits.required()
assert_gt(required.size(), 0)
for entry in Credits.SOURCES:
var is_cc_by := String(entry["licence"]).begins_with("CC BY")
assert_eq(bool(entry["required"]), is_cc_by,
"%s is %s but marked required=%s" % [
entry["name"], entry["licence"], entry["required"]])
## CC BY asks for a link to the licence itself, not only to the work. A player
## reading the in-game screen has no other way to reach the terms.
func test_every_creative_commons_licence_links_to_its_terms() -> void:
for entry in Credits.SOURCES:
var licence := String(entry["licence"])
if not licence.begins_with("CC"):
continue
assert_true(Credits.licence_url(licence).begins_with(
"https://creativecommons.org/"),
"%s has no link to its terms" % licence)
## The repository file and the in-game screen have to say the same thing. They
## are maintained separately, so this is what stops one of them going stale --
## and a stale attribution is a licence problem, not a documentation one.
func test_the_credits_file_names_every_source() -> void:
var f := FileAccess.open("res://CREDITS.md", FileAccess.READ)
assert_not_null(f, "CREDITS.md should be readable")
var text := f.get_as_text()
f.close()
for entry in Credits.SOURCES:
assert_true(text.contains(String(entry["name"])),
"CREDITS.md never mentions %s" % entry["name"])
assert_true(text.contains(String(entry["author"])),
"CREDITS.md never credits %s" % entry["author"])
assert_true(text.contains(String(entry["url"])),
"CREDITS.md has no link for %s" % entry["name"])
func test_the_asset_document_covers_every_source_too() -> void:
var f := FileAccess.open("res://docs/ASSETS.md", FileAccess.READ)
assert_not_null(f)
var text := f.get_as_text()
f.close()
for entry in Credits.SOURCES:
assert_true(text.contains(String(entry["url"])),
"docs/ASSETS.md has no entry for %s" % entry["name"])
## Every pack in the repository's own asset directories must be accounted for.
## Committing art without recording where it came from is how a project ends up
## unable to say whether it may ship.
func test_the_committed_ui_art_is_attributed() -> void:
assert_true(DirAccess.dir_exists_absolute("res://assets/sprites/ui"),
"the UI art should be committed")
var names := ""
for entry in Credits.SOURCES:
names += String(entry["name"])
assert_true(names.contains("Complete UI Essential Pack"),
"the UI pack has to be credited before its art is shipped")
+1
View File
@@ -0,0 +1 @@
uid://tb74xt0r3x5s
+29 -2
View File
@@ -80,9 +80,36 @@ func test_scaling_one_dungeon_does_not_leak_into_the_next() -> void:
# --- Instances --------------------------------------------------------------
## Every dungeon in the hub names its arena, so a player knows what they are
## walking into. Only the harness leaves it to the seed.
func test_only_the_test_harness_leaves_its_boss_to_chance() -> void:
for id in Dungeons.ORDER:
var d := Dungeons.get_def(id)
if id == Dungeons.PROVING:
assert_true(d.arena.is_empty(), "the harness takes either fight")
else:
assert_false(d.arena.is_empty(), "%s must name its arena" % id)
assert_not_null(Content.boss(Rooms.boss_for_arena(d.arena)))
func test_each_real_dungeon_leads_somewhere_different() -> void:
var arenas := {}
for id in Dungeons.ORDER:
var arena := Dungeons.get_def(id).arena
if arena.is_empty():
continue
assert_false(arenas.has(arena), "two portals lead to the same fight")
arenas[arena] = true
## The seed is even on purpose. The harness rolls its arena, and only a run
## that landed in the SAME arena is comparable -- a different one is a different
## size, which moves the rooms and therefore the enemy count.
func test_a_proving_run_is_built_weaker_than_a_standard_one() -> void:
var hard := Instance.make_dungeon(2, 4242, 1, Dungeons.STANDARD)
var easy := Instance.make_dungeon(3, 4242, 1, Dungeons.PROVING)
var same_arena := 4242
assert_eq(posmod(same_arena, 2), 0, "setup: this seed has to roll warden_hall")
var hard := Instance.make_dungeon(2, same_arena, 1, Dungeons.STANDARD)
var easy := Instance.make_dungeon(3, same_arena, 1, Dungeons.PROVING)
assert_lt(easy.world.boss.hp, hard.world.boss.hp)
assert_eq(easy.world.map.width, hard.world.map.width,
"same seed and depth must still give the same size of map")
+256
View File
@@ -0,0 +1,256 @@
extends GutTest
## Player preferences. Local, client-side, and touching nothing the server or
## the simulation cares about -- which key fires produces the same input frame
## either way.
const SCRATCH := "user://test_settings_%d.cfg"
var _saved_bindings: Dictionary[String, Dictionary] = {}
var _saved_master: float = 0.0
var _saved_sfx: float = 0.0
var _scratch: String = ""
func before_each() -> void:
# Settings is static, so a test that changed it would leak into the next
# one and into every other suite that reads the input map.
_saved_bindings = Settings.bindings.duplicate()
_saved_master = Settings.master_volume
_saved_sfx = Settings.sfx_volume
# And rebinding SAVES. Without redirecting the file, running the suite
# rewrote the player's own settings -- it really did, and it took a
# screenshot of the settings screen showing Fire on the right mouse button
# to notice.
_scratch = SCRATCH % randi()
Settings.path = _scratch
func after_each() -> void:
Settings.bindings = _saved_bindings
Settings.master_volume = _saved_master
Settings.sfx_volume = _saved_sfx
Settings.apply_input()
Settings.path = Settings.DEFAULT_PATH
DirAccess.remove_absolute(ProjectSettings.globalize_path(_scratch))
func _key_event(code: Key) -> InputEventKey:
var e := InputEventKey.new()
e.device = -1
e.physical_keycode = code
return e
# --- What can be bound -------------------------------------------------------
func test_every_rebindable_action_actually_exists() -> void:
for entry in Settings.REBINDABLE:
assert_true(InputMap.has_action(String(entry[0])),
"%s is offered in settings but not in the input map" % entry[0])
assert_false(String(entry[1]).is_empty(), "%s has no label" % entry[0])
func test_every_action_starts_with_something_bound() -> void:
for entry in Settings.REBINDABLE:
assert_ne(Settings.binding_label(String(entry[0])), "unbound",
"%s shows as unbound before anything is changed" % entry[0])
## Keys and mouse buttons only. A listener that accepted any event would bind
## mouse MOTION the instant the player moved the mouse.
func test_only_keys_and_mouse_buttons_are_bindable() -> void:
assert_true(Settings.is_bindable(_key_event(KEY_J)))
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_RIGHT
assert_true(Settings.is_bindable(click))
assert_false(Settings.is_bindable(InputEventMouseMotion.new()))
assert_false(Settings.is_bindable(InputEventJoypadMotion.new()))
assert_false(Settings.is_bindable(_key_event(KEY_NONE)))
# --- Rebinding ---------------------------------------------------------------
func test_rebinding_changes_what_the_input_map_answers_to() -> void:
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "",
"an unused key should be accepted")
assert_true(InputMap.action_has_event("fire", _key_event(KEY_J)))
assert_eq(Settings.binding_label("fire"), Settings.describe_label(
{"type": "key", "code": int(KEY_J)}))
## The old key must stop working. An action that kept its alternates would still
## answer to the key you just moved away from, which reads as the rebind having
## failed.
func test_the_previous_key_stops_working() -> void:
var before := Settings.current_binding("interact")
Settings.rebind("interact", _key_event(KEY_J))
var old := InputEventKey.new()
old.device = -1
old.physical_keycode = int(before["code"])
assert_false(InputMap.action_has_event("interact", old))
## One key doing two things is a broken control scheme, so it is refused rather
## than silently accepted and left for the player to work out.
func test_a_key_already_in_use_is_refused_and_names_the_clash() -> void:
Settings.rebind("interact", _key_event(KEY_J))
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "interact")
assert_false(InputMap.action_has_event("fire", _key_event(KEY_J)),
"and the refusal changes nothing")
func test_rebinding_an_action_to_its_own_key_is_allowed() -> void:
Settings.rebind("fire", _key_event(KEY_J))
assert_eq(Settings.rebind("fire", _key_event(KEY_J)), "",
"re-confirming a binding is not a clash with itself")
func test_reset_restores_what_the_project_shipped() -> void:
var original := Settings.binding_label("move_up")
Settings.rebind("move_up", _key_event(KEY_J))
assert_ne(Settings.binding_label("move_up"), original)
Settings.reset_bindings()
assert_eq(Settings.binding_label("move_up"), original)
assert_true(Settings.bindings.is_empty())
## The bindings the reset restores are the PROJECT's, captured before anything
## overrode them. Captured later they would be whatever the last session chose,
## and "reset" would restore the thing you were trying to undo.
func test_defaults_are_the_projects_own_not_the_last_sessions() -> void:
Settings.rebind("move_left", _key_event(KEY_J))
Settings.apply_input()
Settings._capture_defaults() # a second call must be a no-op
Settings.reset_bindings()
assert_ne(Settings.binding_label("move_left"),
Settings.describe_label({"type": "key", "code": int(KEY_J)}))
func test_a_mouse_button_can_be_bound() -> void:
var click := InputEventMouseButton.new()
click.device = -1
click.button_index = MOUSE_BUTTON_RIGHT
assert_eq(Settings.rebind("fire", click), "")
assert_eq(Settings.binding_label("fire"), "Mouse Right")
# --- Persistence -------------------------------------------------------------
func test_settings_survive_a_save_and_reload() -> void:
var path := SCRATCH % randi()
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", 0.25)
cfg.set_value("audio", "sfx", 0.5)
cfg.set_value("input", "fire", {"type": "key", "code": int(KEY_J)})
cfg.save(path)
var loaded := ConfigFile.new()
assert_eq(loaded.load(path), OK)
assert_almost_eq(float(loaded.get_value("audio", "master")), 0.25, 0.001)
var described: Dictionary = loaded.get_value("input", "fire")
assert_eq(Settings.describe_label(described),
Settings.describe_label({"type": "key", "code": int(KEY_J)}))
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## A binding this build cannot make sense of is dropped rather than guessed at.
## An unusable control is worse than the default one.
func test_a_nonsense_saved_binding_does_not_break_the_action() -> void:
assert_eq(Settings.describe_label({}), "unbound")
assert_eq(Settings.describe_label({"type": "gamepad", "code": 3}), "unbound")
assert_eq(Settings.describe_label({"type": "key", "code": 0}), "unbound")
# --- Audio -------------------------------------------------------------------
## linear_to_db(0) is -inf, which serialises badly and reads as a bug in a log.
## Silence is the mute flag; this only has to floor well below audible.
func test_silence_is_a_finite_number() -> void:
var quiet := Settings.linear_to_db_clamped(0.0)
assert_true(is_finite(quiet))
assert_lt(quiet, -60.0)
func test_full_volume_is_unattenuated() -> void:
assert_almost_eq(Settings.linear_to_db_clamped(1.0), 0.0, 0.01)
func test_volume_is_monotonic() -> void:
var last := -999.0
for step in 11:
var db := Settings.linear_to_db_clamped(float(step) / 10.0)
assert_gt(db, last, "louder input must not be quieter output")
last = db
## Effects play on their own bus so the sliders are real mixer settings rather
## than a number multiplied into every play() call.
func test_applying_audio_creates_the_effects_bus() -> void:
Settings.apply_audio()
var index := AudioServer.get_bus_index(Settings.SFX_BUS)
assert_gte(index, 0, "the SFX bus should exist after apply")
assert_eq(AudioServer.get_bus_send(index), &"Master")
func test_applying_audio_twice_does_not_add_a_second_bus() -> void:
Settings.apply_audio()
var before := AudioServer.bus_count
Settings.apply_audio()
assert_eq(AudioServer.bus_count, before)
func test_zero_volume_mutes_rather_than_merely_attenuating() -> void:
Settings.master_volume = 0.0
Settings.apply_audio()
assert_true(AudioServer.is_bus_mute(0))
Settings.master_volume = 0.8
Settings.apply_audio()
assert_false(AudioServer.is_bus_mute(0))
## A corrupt or hand-edited file can hold anything. Every one of these has to
## come out as a real number the mixer will accept.
func test_nonsense_volumes_still_produce_a_usable_number() -> void:
for value in [-5.0, -0.0001, 0.0, 1.5, 1e9]:
var db := Settings.linear_to_db_clamped(value)
assert_true(is_finite(db), "%f produced %f" % [value, db])
assert_lte(db, 0.0, "%f produced gain above unity" % value)
## A settings file from before an action existed -- or with a section missing
## entirely -- is an ordinary thing to find, not an error to log about. Reading
## it must be silent.
func test_a_settings_file_missing_keys_loads_without_complaint() -> void:
var path := SCRATCH % randi()
var cfg := ConfigFile.new()
cfg.set_value("audio", "master", 0.4) # audio only, no input section
cfg.save(path)
var loaded := ConfigFile.new()
assert_eq(loaded.load(path), OK)
for entry in Settings.REBINDABLE:
assert_false(loaded.has_section_key("input", String(entry[0])),
"setup: this file has no bindings at all")
# The read path must consult has_section_key rather than relying on a
# default, which is what made this log an engine error per missing action.
assert_false(loaded.has_section_key("input", "move_up"))
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
## The redirect above is a test fixture. If it were ever left in place -- or the
## default changed to something under a scratch directory -- players would
## silently stop keeping their settings.
func test_the_default_path_is_the_players_own_file() -> void:
assert_eq(Settings.DEFAULT_PATH, "user://settings.cfg")
assert_true(Settings.DEFAULT_PATH.begins_with("user://"))
## Saving has to actually reach the file the path points at, or the redirect
## above would hide a broken save rather than isolate a working one.
func test_saving_writes_to_the_configured_path() -> void:
Settings.master_volume = 0.33
Settings.save()
assert_true(FileAccess.file_exists(_scratch), "nothing was written")
var cfg := ConfigFile.new()
assert_eq(cfg.load(_scratch), OK)
assert_almost_eq(float(cfg.get_value("audio", "master")), 0.33, 0.001)
+1
View File
@@ -0,0 +1 @@
uid://cyicgu8318br2
+47
View File
@@ -217,3 +217,50 @@ func test_arriving_somewhere_clears_the_old_warnings() -> void:
client.on_enter_instance(7, Protocol.InstanceKind.LOBBY, 0, "", Vector2.ZERO,
20, 20, PackedByteArray(), "", Vector2.ZERO)
assert_eq(client.telegraphs.size(), 0)
# --- Boss interpolation -----------------------------------------------------
func _client_with_snapshots(prev_pos: Vector2, curr_pos: Vector2,
prev_id: int = 1, curr_id: int = 1) -> ClientRuntime:
var client: ClientRuntime = autofree(ClientRuntime.new())
client.snap_prev = {"boss": {"id": prev_id, "pos": prev_pos, "hp": 10, "phase": 0}}
client.snap_curr = {"boss": {"id": curr_id, "pos": curr_pos, "hp": 10, "phase": 0}}
return client
## The boss is drawn between the last two snapshots like every other actor. It
## was not, which is invisible while bosses stand still and looks like a
## stuttering mess the moment one moves.
func test_the_boss_is_interpolated_between_snapshots() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
client._interp = 0.0
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 0.0, 0.01)
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 50.0, 0.01)
client._interp = 1.0
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 100.0, 0.01)
## Across an instance change the previous snapshot describes a different fight
## in a different room; lerping to it would fling the new boss across the map.
func test_two_different_bosses_are_never_interpolated_together() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(900.0, 0.0), 1, 2)
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 900.0, 0.01)
func test_the_first_snapshot_of_a_fight_is_used_as_is() -> void:
var client: ClientRuntime = autofree(ClientRuntime.new())
client.snap_curr = {"boss": {"id": 1, "pos": Vector2(40.0, 0.0), "hp": 5, "phase": 0}}
client._interp = 0.5
assert_almost_eq((client.boss_state()["pos"] as Vector2).x, 40.0, 0.01)
## Reading it must not rewrite the snapshot -- the next frame interpolates from
## the same pair again, and a mutated one would creep.
func test_reading_the_boss_does_not_modify_the_snapshot() -> void:
var client := _client_with_snapshots(Vector2.ZERO, Vector2(100.0, 0.0))
client._interp = 0.5
client.boss_state()
assert_almost_eq((client.snap_curr["boss"]["pos"] as Vector2).x, 100.0, 0.01)
+214
View File
@@ -0,0 +1,214 @@
extends GutTest
## The control theme. A missing texture gives a null and a silently unstyled
## control, which looks exactly like the theme never having been applied -- so
## every path is checked rather than assumed.
func test_every_declared_texture_loads() -> void:
for key in UiTheme.TEXTURES:
var tex := UiTheme.texture(key)
assert_not_null(tex, "%s (%s) did not load" % [key, UiTheme.TEXTURES[key]])
assert_gt(tex.get_width(), 0)
assert_gt(tex.get_height(), 0)
func test_the_theme_styles_the_controls_the_game_actually_builds() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled", "focus"]:
assert_true(t.has_stylebox(state, "Button"), "Button has no %s style" % state)
assert_true(t.has_stylebox("panel", "Panel"))
assert_true(t.has_stylebox("panel", "PanelContainer"))
assert_true(t.has_stylebox("normal", "LineEdit"))
## The pack's surfaces are cream with a black outline, so text on them has to be
## dark. Everything the game draws over the world stays light; only controls
## sitting on this art flip.
func test_text_on_the_packs_surfaces_is_dark() -> void:
var t := UiTheme.get_theme()
for key in ["font_color", "font_hover_color", "font_pressed_color"]:
var c: Color = t.get_color(key, "Button")
assert_lt(c.get_luminance(), 0.35, "%s is too light for cream art" % key)
assert_lt((t.get_color("font_color", "LineEdit") as Color).get_luminance(), 0.35)
## Nine-patch margins are what stop a 1px border smearing across a stretched
## button. Zero margins would render as a plain stretched texture.
func test_button_styles_are_nine_patched() -> void:
var t := UiTheme.get_theme()
for state in ["normal", "hover", "pressed", "disabled"]:
var sb := t.get_stylebox(state, "Button") as StyleBoxTexture
assert_not_null(sb)
for side in [SIDE_LEFT, SIDE_TOP, SIDE_RIGHT, SIDE_BOTTOM]:
assert_gt(sb.get_texture_margin(side), 0.0,
"%s has no stretch margin on side %d" % [state, side])
## The corner size cannot exceed half the sprite, or the two caps overlap and
## the middle has negative width.
func test_stretch_margins_fit_inside_their_sprites() -> void:
var t := UiTheme.get_theme()
for entry in [["normal", "Button"], ["panel", "Panel"], ["normal", "LineEdit"]]:
var sb := t.get_stylebox(entry[0], entry[1]) as StyleBoxTexture
var size := Vector2(sb.texture.get_size())
assert_lte(sb.get_texture_margin(SIDE_LEFT) + sb.get_texture_margin(SIDE_RIGHT),
size.x, "%s margins exceed its width" % entry[1])
assert_lte(sb.get_texture_margin(SIDE_TOP) + sb.get_texture_margin(SIDE_BOTTOM),
size.y, "%s margins exceed its height" % entry[1])
## Built once and shared. Themes are read-only data, so a second instance would
## be waste rather than isolation.
func test_the_theme_is_built_once() -> void:
assert_eq(UiTheme.get_theme(), UiTheme.get_theme())
## The four button states have to be four different pictures, or the interface
## gives no feedback and the pack's state art is going unused.
func test_the_button_states_are_visually_distinct() -> void:
var seen := {}
for key in ["button", "button_hover", "button_pressed", "button_disabled"]:
seen[UiTheme.TEXTURES[key]] = true
assert_eq(seen.size(), 4, "two button states share a texture")
## The inventory draws slots at exactly twice the source size. A non-integer
## scale on pixel art with nearest filtering gives uneven pixel widths, which on
## a 1px border reads as a wobble along every edge.
func test_the_inventory_slot_is_drawn_at_a_whole_multiple() -> void:
var slot := UiTheme.texture("slot")
var scale := HUD_SLOT / float(slot.get_width())
assert_eq(scale, floorf(scale), "slot art is scaled by %f" % scale)
assert_eq(slot.get_width(), slot.get_height(), "the slot should be square")
const HUD_SLOT := 64.0
## A Control resolves its theme by walking up the parent chain, which is why
## one assignment near the root covers screens that share no parent. Checked on
## the mechanism rather than on a Window, so it runs headless.
func test_the_theme_reaches_controls_through_their_ancestors() -> void:
var root: Control = autofree(Control.new())
root.theme = UiTheme.get_theme()
var middle := Control.new()
root.add_child(middle)
var button := Button.new()
middle.add_child(button)
assert_eq(button.get_theme_stylebox("normal", "Button"),
UiTheme.get_theme().get_stylebox("normal", "Button"),
"a button two levels down should still find the theme")
assert_eq(button.get_theme_color("font_color", "Button"), UiTheme.INK)
func test_applying_to_nothing_is_harmless() -> void:
UiTheme.apply_to(null)
assert_true(true, "a null root must not be a crash")
func test_a_themed_root_fills_its_parent_and_carries_the_theme() -> void:
var root: Control = autofree(UiTheme.themed_root())
assert_eq(root.theme, UiTheme.get_theme())
assert_eq(root.anchor_right, 1.0)
assert_eq(root.anchor_bottom, 1.0)
## Every screen in the game is built in code and hangs off either a plain Node
## or a CanvasLayer, and theme inheritance travels Control-to-Control ONLY --
## the chain breaks at the first non-Control parent. Setting the theme once on
## the Window therefore looked like it worked and styled nothing, which is a
## mistake that is invisible until somebody looks at the screen. So: every
## screen is instantiated here and asked what its buttons actually resolve.
func test_every_screen_actually_gets_the_theme() -> void:
var screens := {
"HUD": "res://src/ui/hud.gd",
"GameMenu": "res://src/ui/game_menu.gd",
"CharacterSelect": "res://src/ui/character_select.gd",
"UpgradeScreen": "res://src/ui/upgrade_screen.gd",
"SettingsScreen": "res://src/ui/settings_screen.gd",
"CreditsScreen": "res://src/ui/credits_screen.gd",
"MainMenu": "res://src/ui/main_menu.gd",
}
var wanted := UiTheme.get_theme().get_stylebox("normal", "Button")
for name in screens:
var node: Node = (load(screens[name]) as Script).new()
add_child_autofree(node)
var buttons := node.find_children("*", "Button", true, false)
assert_gt(buttons.size(), 0, "%s has no button to check" % name)
for b: Button in buttons:
assert_eq(b.get_theme_stylebox("normal", "Button"), wanted,
"%s: '%s' is not themed" % [name, b.text])
## A Slider draws its groove in a band whose thickness is the stylebox's own
## minimum size -- which for a StyleBoxTexture is its content margins. With
## those at zero the track resolves, reports the right texture, and draws
## nothing, which is exactly what it did.
func test_slider_styleboxes_have_a_thickness() -> void:
var t := UiTheme.get_theme()
for key in ["slider", "grabber_area", "grabber_area_highlight"]:
var sb := t.get_stylebox(key, "HSlider")
assert_gt(sb.get_minimum_size().y, 0.0,
"HSlider/%s would draw with no height" % key)
## The filled portion has to sit inside the groove, not overhang it.
func test_the_filled_portion_is_no_thicker_than_the_groove() -> void:
var t := UiTheme.get_theme()
assert_lte(t.get_stylebox("grabber_area", "HSlider").get_minimum_size().y,
t.get_stylebox("slider", "HSlider").get_minimum_size().y)
func test_the_slider_grabber_is_big_enough_to_aim_at() -> void:
var grab := UiTheme.get_theme().get_icon("grabber", "HSlider")
assert_not_null(grab)
assert_gte(grab.get_width(), 12, "an 8px grabber is a speck at this scale")
assert_lte(grab.get_height(), 26, "and one taller than its row overflows it")
## Nearest-neighbour and a whole multiple, or the pixel art turns to mush.
func test_scaling_a_sprite_keeps_it_a_whole_multiple() -> void:
var src := UiTheme.texture("grabber")
var out := UiTheme.scaled("grabber", 2)
assert_eq(out.get_width(), src.get_width() * 2)
assert_eq(out.get_height(), src.get_height() * 2)
## Every scrollbar piece has to be styled or the default grey chrome shows
## through on the two screens that scroll.
func test_scrollbars_are_styled() -> void:
var t := UiTheme.get_theme()
for bar_class in ["VScrollBar", "HScrollBar"]:
for key in ["scroll", "grabber", "grabber_highlight", "grabber_pressed"]:
assert_true(t.has_stylebox(key, bar_class),
"%s/%s is unstyled" % [bar_class, key])
## And they need a thickness, for the same reason sliders do: a ScrollBar sizes
## itself from its styleboxes' minimum size, so styling one with no content
## margins produces a bar zero pixels wide -- which looks exactly like having
## no scrollbar, and did.
func test_scrollbars_have_a_thickness_in_both_directions() -> void:
var t := UiTheme.get_theme()
for bar_class in ["VScrollBar", "HScrollBar"]:
for key in ["scroll", "grabber"]:
var size := t.get_stylebox(key, bar_class).get_minimum_size()
assert_gt(size.x, 0.0, "%s/%s has no width" % [bar_class, key])
assert_gt(size.y, 0.0, "%s/%s has no height" % [bar_class, key])
## Panels are the pack's shape in this game's colours: the art is cream, and a
## cream dialog over a dark dungeon would need every label in five screens
## recoloured. Tinting the stylebox keeps the border and the existing text.
func test_dialog_panels_are_tinted_dark() -> void:
var sb := UiTheme.get_theme().get_stylebox("panel", "PanelContainer") as StyleBoxTexture
assert_lt(sb.modulate_color.get_luminance(), 0.35,
"a bright panel would make every light label on it unreadable")
assert_gt(sb.get_minimum_size().x, 0.0, "a dialog needs padding")
func test_a_card_panel_carries_its_tint() -> void:
var card: PanelContainer = autofree(UiTheme.card_panel(Color(0.9, 0.2, 0.2)))
var sb := card.get_theme_stylebox("panel") as StyleBoxTexture
assert_not_null(sb)
assert_almost_eq(sb.modulate_color.r, 0.9, 0.001)
+1
View File
@@ -0,0 +1 @@
uid://ddwfq3jpwgfep
+71 -1
View File
@@ -21,6 +21,12 @@ var _account: int = 616161
var _character: Character
var _offer_before: Array[StringName] = []
var _damage_before: int = 0
## What the character was carrying and shooting with before it changed zones.
var _carried: Array[StringName] = []
var _upgrades_before: Array[StringName] = []
var _stats_before: PlayerStats = null
var _level_before: int = 0
var _xp_before: int = 0
func _ready() -> void:
@@ -65,7 +71,11 @@ func _physics_process(_delta: float) -> void:
70: _take_it_at_the_npc()
80: _it_changed_things()
90: _refused_with_nothing_pending()
100: _finish()
95: _walk_into_a_dungeon()
105: _still_mine_in_the_dungeon()
110: _walk_back_to_the_hub()
120: _still_mine_in_the_hub()
130: _finish()
func _login() -> void:
@@ -199,6 +209,66 @@ func _refused_with_nothing_pending() -> void:
_check(_stored().offer.is_empty(), "and no table is left on the counter")
## Everything below is about ONE bug, and it is worth naming: the world hands
## back a blank player on every instance transfer, and for a long time only
## character *select* gave it back its character. Every portal and every escape
## silently reset the player to level 1 with base stats and an empty bag. The
## record on disk stayed correct the whole time, so it read as a display
## glitch, and nothing here or in the test suite looked.
func _walk_into_a_dungeon() -> void:
var inst := _srv.instance_of(Net.LOCAL_PEER)
var p := _me()
# Carry something, the way a player would after a pickup.
p.add_item(Items.HEALTH_POTION)
_srv._persist_inventory(inst, Net.LOCAL_PEER)
_carried = p.inventory.duplicate()
_upgrades_before = _stored().upgrades.duplicate()
_stats_before = p.stats
_level_before = p.level
_xp_before = p.total_xp
_check(_level_before > 1, "setup: the character has levelled")
_check(not _upgrades_before.is_empty(), "setup: and holds an upgrade")
_srv._send_to_dungeon(Net.LOCAL_PEER)
func _carried_through(where: String) -> void:
var p := _me()
if p == null:
_check(false, "there is a player in the %s at all" % where)
return
_check(p.level == _level_before,
"%s: level survives the trip (%d)" % [where, p.level])
_check(p.total_xp == _xp_before, "%s: so does experience" % where)
_check(p.stats.upgrade_count == _upgrades_before.size(),
"%s: and the upgrades (%d)" % [where, p.stats.upgrade_count])
_check(p.stats.damage == _stats_before.damage,
"%s: so the gun still hits for %d" % [where, p.stats.damage])
# Compared against the CHARACTER's health, not against a formula fed the
# player's own level -- that agrees with itself even when the level is
# wrong, which is exactly the state this whole section exists to catch.
_check(p.max_hp == _stored().max_hp(),
"%s: and maximum health matches the record (%d)" % [where, p.max_hp])
_check(p.inventory == _carried, "%s: the bag came too" % where)
func _still_mine_in_the_dungeon() -> void:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.DUNGEON,
"walked into a dungeon")
_carried_through("dungeon")
func _walk_back_to_the_hub() -> void:
_srv._send_to_lobby(Net.LOCAL_PEER)
func _still_mine_in_the_hub() -> void:
var inst := _srv.instance_of(Net.LOCAL_PEER)
_check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY,
"escaped back to the hub")
_carried_through("hub")
func _finish() -> void:
print("---")
if _fails.is_empty():
+122
View File
@@ -0,0 +1,122 @@
extends Node
## Boot the client windowed, capture a few screens, and quit.
##
## SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn
##
## This exists because UI is the one part of the game the rest of the tooling
## cannot check. `check.sh` proves it parses, the suite proves the theme
## resolves, the smoke test proves nothing errors -- and all three passed
## while the entire interface rendered unstyled, because Godot's theme
## inheritance breaks at the first non-Control parent and nothing had ever
## looked at the screen.
##
## Needs a real display, so it is not part of any automated gate. Run it after
## touching anything under src/ui/ and open the files.
const GAME := preload("res://src/game.tscn")
## Frame numbers, not seconds: the point is a deterministic sequence, and a
## windowed client at 60fps gets there in about four seconds.
const AT_MENU := 30
const ENTER_GAME := 34
const AT_GAME := 200
const OPEN_MENU := 210
const AT_INGAME_MENU := 240
const OPEN_SETTINGS := 250
const AT_SETTINGS := 280
const OPEN_CREDITS := 290
const AT_CREDITS := 320
const OPEN_UPGRADES := 330
const AT_UPGRADES := 360
const DONE := 370
var _frame: int = 0
var _game: Node = null
var _menu: Control = null
var _dir: String = "user://"
func _ready() -> void:
GameOpts.parse()
Engine.physics_ticks_per_second = SimConfig.TICK_RATE
Settings.load_and_apply()
var from_env := OS.get_environment("SHOT_DIR")
if not from_env.is_empty():
_dir = from_env
get_window().size = Vector2i(1280, 720)
_menu = preload("res://src/ui/main_menu.gd").new()
add_child(_menu)
## Put the player at the quartermaster on BOTH sides. Setting only the client's
## predicted position does not hold: reconciliation pulls it back to whatever
## the server says within a tick or two, the game scene notices it is no longer
## at the NPC, and closes the screen again.
func _stand_at_the_npc() -> void:
var npc: Vector2 = Net.client.upgrade_npc
Net.client.predicted_pos = npc
var inst := Net.server.instance_of(Net.LOCAL_PEER)
if inst != null:
var p: SimPlayer = inst.world.players.get(Net.LOCAL_PEER)
if p != null:
p.pos = npc
_game.set("_upgrades_open", true)
func _process(_delta: float) -> void:
_frame += 1
# Held until the shot is taken; one frame of it is not enough.
if _frame > OPEN_UPGRADES and _frame <= AT_UPGRADES:
_stand_at_the_npc()
match _frame:
AT_MENU:
_grab("menu")
ENTER_GAME:
_menu.queue_free()
Net.host(27455)
Net.start_local_client()
_game = GAME.instantiate()
add_child(_game)
AT_GAME:
_grab("game")
OPEN_MENU:
_game.get_node("GameMenu").open()
AT_INGAME_MENU:
_grab("menu_ingame")
OPEN_SETTINGS:
_game.get_node("GameMenu").close()
_game.get_node("SettingsScreen").open()
AT_SETTINGS:
_grab("settings")
OPEN_CREDITS:
_game.get_node("SettingsScreen").visible = false
_game.get_node("CreditsScreen").open()
AT_CREDITS:
_grab("credits")
OPEN_UPGRADES:
# Driven with a made-up offer rather than by earning one: the point
# here is the card layout, and levelling a character up first would
# make the shot depend on a dungeon run.
_game.get_node("CreditsScreen").visible = false
# The game scene owns this screen's visibility and re-asserts it
# every frame, closing it unless the player is at the NPC -- so
# setting `visible` directly lasted exactly one frame. Stand the
# player at the quartermaster and open it the way the game does.
_stand_at_the_npc()
var offer: Array[StringName] = [
Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER]
var taken: Array[StringName] = [Upgrades.SNIPER, Upgrades.SPREAD]
_game.get_node("UpgradeScreen").refresh(2, offer, taken)
AT_UPGRADES:
_grab("upgrades")
DONE:
Net.shutdown()
get_tree().quit(0)
func _grab(shot_name: String) -> void:
var path := "%s/shot_%s.png" % [_dir, shot_name]
var err := get_viewport().get_texture().get_image().save_png(path)
if err != OK:
printerr("could not write %s (error %d)" % [path, err])
return
print("saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://bnuqy4w5whjce
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tools/screenshot.gd" id="1"]
[node name="Screenshot" type="Node"]
script = ExtResource("1")
+25
View File
@@ -97,6 +97,27 @@ check() { # check <label> <file> <pattern>
fails=$((fails + 1))
fi
}
# Plain engine errors, which the pattern below deliberately does not catch:
# "SCRIPT ERROR" and friends are GDScript problems, and a great many engine
# complaints are neither. A settings file missing a key logged one ERROR line
# per action at startup and this suite reported a clean pass, which is what
# motivated this check.
#
# One line is excluded by name rather than by pattern: Godot reports a resource
# still in use at exit on every run, and naming it means anything else that
# turns up is a real finding.
no_engine_errors() { # no_engine_errors <label> <file>
local hits
hits=$(grep -E "^ERROR:" "$2" | grep -vF "resources still in use at exit" | wc -l)
if [[ "$hits" == "0" ]]; then
echo " ok $1"
else
echo " FAIL $1 ($hits engine error line(s) in $(basename "$2"))"
grep -E "^ERROR:" "$2" | grep -vF "resources still in use at exit" | head -5 | sed 's/^/ /'
fails=$((fails + 1))
fi
}
refute() { # refute <label> <file> <pattern>
local hits
hits=$(grep -cE "$3" "$2" || true)
@@ -128,7 +149,9 @@ check "a boss spawned" "$OUT/server.log" "BOSS_SPAWNED (warden|can
# opens one of each kind. This is what catches a portal wired to the wrong
# dungeon, or an instance matcher that ignores which dungeon was asked for.
check "the real run opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(warden_descent\)"
check "the choir vault opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(choir_vault\)"
check "the proving grounds opened" "$OUT/server.log" "opened dungeon instance [0-9]+ \(proving_grounds\)"
check "and each ended with its own boss" "$OUT/server.log" "BOSS_SPAWNED cantor in instance"
check "emergency escape completed" "$OUT/server.log" "escaped to lobby"
check "bot1 reached a dungeon" "$OUT/bot1.log" "entered instance .*DUNGEON"
check "bot1 returned to the lobby" "$OUT/bot1.log" "entered instance .*LOBBY"
@@ -145,6 +168,8 @@ check "a polite disconnect is also channelled" \
# and tools/diag_upgrades.tscn covers the loop itself.
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
no_engine_errors "no engine errors on the server" "$OUT/server.log"
no_engine_errors "no engine errors on a client" "$OUT/bot1.log"
echo
if [[ $fails -eq 0 ]]; then