Compare commits
8 Commits
b351bc2d55
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b8332b697d | |||
| 61680d00d7 | |||
| 132646f6c3 | |||
| 872922e9e2 | |||
| 4a98cf4b0e | |||
| 42568a40ad | |||
| e0c1e0d5c6 | |||
| cb2c1e7840 |
@@ -13,7 +13,7 @@ the obvious default.
|
||||
|
||||
```bash
|
||||
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
||||
tools/test.sh # GUT suite, headless (~3s)
|
||||
tools/test.sh # 468 GUT tests, headless (~4s)
|
||||
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
||||
tools/server.sh # dedicated server
|
||||
tools/client.sh --listen # host and play, no menu
|
||||
@@ -29,6 +29,7 @@ godot --headless --path . res://tools/diag_loot.tscn # drop -> pick up
|
||||
godot --headless --path . res://tools/diag_upgrades.tscn # level -> choice -> taken at the NPC -> new stats
|
||||
godot --headless --path . --script tools/bench.gd # sim cost per tick
|
||||
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
|
||||
SHOT_DIR=/tmp/shots godot --path . res://tools/screenshot.tscn # capture the menus and HUD (needs a display)
|
||||
```
|
||||
|
||||
`diag_progression`, `diag_loot` and `diag_upgrades` exist because the bot smoke
|
||||
@@ -36,6 +37,12 @@ test cannot cover any of them: bots are poor shots, so they rarely kill
|
||||
anything, which means they neither earn levels, produce drops, nor ever reach
|
||||
the quartermaster.
|
||||
|
||||
**Nothing automated can see the screen.** `check.sh`, the suite and `smoke.sh`
|
||||
have all passed with the entire interface rendering unstyled, an invisible
|
||||
slider and an invisible scrollbar. After touching anything under `src/ui/` or
|
||||
`src/view/`, run `screenshot.tscn` and open the files. It needs a display, so it
|
||||
is not a gate.
|
||||
|
||||
Everything after `--` goes to `GameOpts.parse()`:
|
||||
|
||||
| Flag | Effect |
|
||||
@@ -68,12 +75,22 @@ push (~35s, skip deliberately with `SKIP_SMOKE_HOOK=1 git push`).
|
||||
|
||||
**The server decides everything; the client only sends intent.**
|
||||
|
||||
A client can send exactly two things: an [InputFrame] (move vector, aim angle,
|
||||
five button bits, an inventory slot) and a handshake — plus the two low-rate
|
||||
character-roster requests, which are also pure intent. There is no message for
|
||||
"I moved here", "I hit that", "I took damage", "my escape finished" or "I now
|
||||
own this item". Adding one would collapse the whole security model, so don't —
|
||||
validate-after-the-fact is strictly weaker than having no code path at all.
|
||||
There are exactly **five** client -> server messages, and that number is worth
|
||||
watching:
|
||||
|
||||
| Message | Carries |
|
||||
| --- | --- |
|
||||
| `c_hello` | protocol version + an opaque auth ticket |
|
||||
| `c_input` | an [InputFrame]: move vector, aim angle, five button bits, an inventory slot |
|
||||
| `c_select_character` | a character id, checked against *that account's* list |
|
||||
| `c_create_character` | a name, sanitised at the boundary |
|
||||
| `c_choose_upgrade` | an index into the three options the SERVER put on the table |
|
||||
|
||||
Every one is pure intent. There is no message for "I moved here", "I hit that",
|
||||
"I took damage", "my escape finished", "I now own this item", "I am in this
|
||||
dungeon" or "my damage is X". Adding one would collapse the whole security
|
||||
model, so don't — validate-after-the-fact is strictly weaker than having no code
|
||||
path at all.
|
||||
|
||||
When a new player action needs a message, look at whether it fits in the input
|
||||
frame first. Item use and drop did, and got the redundancy, the replay guard and
|
||||
@@ -92,9 +109,9 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| --- | --- |
|
||||
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
|
||||
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
|
||||
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation. **Server-side only** — handing a client the seed would be a map hack with no work required. |
|
||||
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
|
||||
| `src/meta/` | Accounts, characters, persistence, XP curve. Server-owned. |
|
||||
| `src/content/content.gd` | All enemies and bosses, defined in code. Source of truth. |
|
||||
@@ -104,6 +121,9 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| `src/net/` | Codec, `ServerRuntime`, `ClientRuntime`. |
|
||||
| `src/instances/` | Lobby hub and dungeon runs. |
|
||||
| `src/view/`, `src/ui/` | Read-only rendering. Never decides anything. |
|
||||
| `src/core/settings.gd` | Client-local preferences: key bindings and volumes. Never reaches the server. |
|
||||
| `src/core/credits.gd` | Every third-party asset and its licence. Two are CC BY, so this is a legal requirement, not a nicety. |
|
||||
| `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. |
|
||||
| `src/autoload/net.gd` | The only autoload. RPC surface. |
|
||||
| `tools/` | Headless tooling. |
|
||||
|
||||
@@ -136,7 +156,21 @@ ticks in milliseconds with no SceneTree.
|
||||
coordinate even on an anchored control; `offset_left`/`offset_top` are the
|
||||
anchor-relative ones. This combination silently broke the main menu and
|
||||
three pieces of the HUD.
|
||||
6. **`ProjectSettings.save()` drops settings equal to the engine default** and
|
||||
6. **A Control's theme is inherited from Control ANCESTORS only.** The chain
|
||||
breaks at the first parent that is a plain `Node` or a `CanvasLayer`, which
|
||||
here is every screen: they hang off `main.gd` or off a CanvasLayer. Setting
|
||||
`get_window().theme` therefore compiles, runs, changes the property — and
|
||||
styles nothing. Apply the theme to each screen's own root Control
|
||||
(`UiTheme.themed_root()`). `tests/unit/test_ui_theme.gd` instantiates every
|
||||
screen and asks what its buttons actually resolve, because this failure is
|
||||
invisible to every other kind of check.
|
||||
7. **A themed `Slider` or `ScrollBar` takes its THICKNESS from the stylebox's
|
||||
minimum size**, which for a `StyleBoxTexture` is its content margins. Style
|
||||
one with margins of zero and it resolves correctly, reports the right
|
||||
texture, and draws a groove zero pixels tall — indistinguishable from having
|
||||
no theme at all. Both happened. `tests/unit/test_ui_theme.gd` asserts every
|
||||
slider and scrollbar stylebox has a non-zero minimum.
|
||||
8. **`ProjectSettings.save()` drops settings equal to the engine default** and
|
||||
strips comments. Anything load-bearing (the 60 Hz tick) is asserted in code
|
||||
in `src/main.gd` instead of trusted to `project.godot`.
|
||||
|
||||
@@ -165,6 +199,10 @@ ticks in milliseconds with no SceneTree.
|
||||
speed, so `SimConfig.MAX_BULLET_SPEED` clamps the result — without it two
|
||||
Snipers put shots through walls. Pinned by
|
||||
`test_bullet_speeds_stay_below_the_tunnelling_threshold`.
|
||||
- **A character becomes a live player only in `ServerRuntime._adopt_character`,
|
||||
from `_place`.** `SimWorld` knows nothing about characters and builds a blank
|
||||
player on every instance change, so any new path that puts someone in a world
|
||||
must go through `_place` or it hands them a level-1 body with an empty bag.
|
||||
- **A player's combat numbers are derived, never stored.** `PlayerStats.build()`
|
||||
recomputes them from the character's upgrade ids every time, so a saved stat
|
||||
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
|
||||
@@ -181,7 +219,8 @@ ticks in milliseconds with no SceneTree.
|
||||
makes every existing client decode a potion as a ration, so it needs a
|
||||
`Protocol.VERSION` bump. `Dungeons.ORDER` is the same, and additionally
|
||||
decides which hub portal opens which dungeon (Nth `P` marker in the lobby
|
||||
stamp → Nth entry).
|
||||
stamp → Nth entry). A dungeon names the boss arena it ends in, so adding a
|
||||
boss means giving it a dungeon or nobody will ever meet it.
|
||||
- **Which dungeon you enter comes from where you are standing, never from the
|
||||
client.** `SimWorld.portal_at()` resolves the player's server-side position
|
||||
to a portal, and the `PORTAL_USED` event carries the answer. There is no
|
||||
@@ -197,6 +236,16 @@ ticks in milliseconds with no SceneTree.
|
||||
- **No contact damage.** Every enemy threatens through bullets only; touching
|
||||
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
|
||||
an emitter.
|
||||
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
|
||||
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
|
||||
walking out is always an escape — which only holds if the boss cannot follow.
|
||||
- **Every actor the client draws is interpolated between snapshots.** Players,
|
||||
enemies and the boss all go through the same lerp. The boss did not for a
|
||||
long time, which is invisible while bosses stand still and looks broken the
|
||||
moment one moves.
|
||||
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
|
||||
ignores line of sight on purpose; everything else in the view respects it. A
|
||||
warning you cannot see is an unavoidable hit with extra steps.
|
||||
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
|
||||
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
|
||||
dense patterns safer than sparse ones.
|
||||
@@ -209,8 +258,13 @@ ticks in milliseconds with no SceneTree.
|
||||
|
||||
A new enemy or boss is data, never code. Add a builder to
|
||||
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
|
||||
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
|
||||
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
|
||||
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and
|
||||
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
|
||||
all iterate those), and add a test.
|
||||
|
||||
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
|
||||
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
|
||||
property of the phase, not of the boss. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
|
||||
builds a boss from scratch and asserts the simulation needs no changes to run
|
||||
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
|
||||
an emitter type instead.
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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, **1–4** use an inventory slot, **shift+1–4**
|
||||
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 1–15, 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 127 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 146 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 138 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 93 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 161 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 161 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 163 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 164 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 389 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 368 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 306 B |
@@ -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
|
||||
|
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
|
||||
|
After Width: | Height: | Size: 190 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 189 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 124 B |
@@ -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
|
||||
|
After Width: | Height: | Size: 124 B |
@@ -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
|
||||
|
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
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,6 +297,15 @@ lives in `SimConfig.UPGRADE_DAMAGE_BONUS` and is applied once per upgrade held,
|
||||
rather than being baked into seven definitions where it would be seven places
|
||||
to get wrong.
|
||||
|
||||
**A character record becomes a live player in exactly one place.**
|
||||
`ServerRuntime._adopt_character`, called from `_place`, which every transfer
|
||||
goes through. `SimWorld` knows nothing about characters and hands back a blank
|
||||
player on every instance change, so something has to give it back its level,
|
||||
upgrades and bag — and for a long time only character *select* did. Every
|
||||
portal and every escape reset the player to level 1 with base stats and an
|
||||
empty inventory, while the record on disk stayed correct, which made it look
|
||||
like a display bug.
|
||||
|
||||
**Stats are derived from the upgrade list, never stored.** `PlayerStats.build()`
|
||||
recomputes them from the ids the character holds, the same way `Progression`
|
||||
derives level from experience. One source of truth means a saved stat can never
|
||||
@@ -340,3 +349,142 @@ pins the clamp.
|
||||
reliable channel, and the client learns enemy health from the snapshot anyway.
|
||||
Death is still announced, because the experience award is keyed on that event
|
||||
and a kill by poison has to score.
|
||||
|
||||
---
|
||||
|
||||
## The scale of the numbers
|
||||
|
||||
**Health and damage are ten times what they started as, and every ratio is
|
||||
unchanged.** A shot is 60, a fresh character has 1000, the Warden has 36000,
|
||||
and enemy health and emitter damage were scaled with them — time to kill is
|
||||
exactly what it was.
|
||||
|
||||
The reason is rounding, not balance. Damage is an integer, and at a base of 6
|
||||
the +5% that every upgrade carries computed to 6.3 and rounded back to 6: a
|
||||
player took their first upgrade, was told it made them stronger, and it did
|
||||
nothing. Ten times the base means every 5% step is worth 3 damage. The same
|
||||
applies to the boss's per-phase armour multiplier, which at 6 damage rounded
|
||||
1.15 into 1.17.
|
||||
|
||||
**The practice dummy is indestructible by flag, not by a large number.** The
|
||||
old 100000 health was already past the u16 the snapshot sends enemy health in,
|
||||
and once a shot did 60 a patient player could have worn it down and left the
|
||||
hub without a practice target until the next restart. A flag says what was
|
||||
actually meant.
|
||||
|
||||
---
|
||||
|
||||
## Bosses that move, and attacks that warn
|
||||
|
||||
**Movement belongs to the phase, not to the boss.** A fight that stands still
|
||||
and then starts hunting you is one boss with two phases. `BossDef.stationary`
|
||||
was removed rather than kept alongside the phases: a flag claiming the boss
|
||||
stood still while one of its phases walked around would be a second source of
|
||||
truth, and the wrong one. `BossDef.moves()` is derived.
|
||||
|
||||
**CHASE holds a distance instead of closing.** A boss standing on top of a
|
||||
player is a boss whose bullets cannot be read, and this genre cannot afford
|
||||
that. It backs off when you come inside its preferred range.
|
||||
|
||||
**Waypoints are fractions of the arena.** The Warden's hall and the Choir Vault
|
||||
are different sizes, and a phase written against absolute coordinates would only
|
||||
work in one of them.
|
||||
|
||||
**The boss room clamp moved after movement.** It was a no-op while every boss
|
||||
was stationary. Boss rooms deliberately do not lock, so walking out is always an
|
||||
escape — which is only true if the boss cannot follow.
|
||||
|
||||
**Telegraphed strikes are announced by a stateless emitter.** Emitters are
|
||||
shared resources, so nothing may be remembered between the warning and the
|
||||
strike; the positions are derived from the volley number instead, and a test
|
||||
asserts the burst lands where the marker promised.
|
||||
|
||||
**Telegraph markers are drawn through fog and through walls**, unlike every
|
||||
other thing in the view. A warning you cannot see is an unavoidable hit with
|
||||
extra steps, which is the opposite of what a telegraph is for.
|
||||
|
||||
**Each boss has its own entrance in the hub.** A dungeon names the arena it ends
|
||||
in, and the arena decides the boss. Which fight you are walking into is the one
|
||||
thing a player chooses before a run, so it is not rolled for them — not from the
|
||||
depth (a dev flag nothing in play raises, which left the second boss
|
||||
unreachable) and not from the seed either. Only the Proving Grounds leaves it
|
||||
open, because a harness you re-enter every couple of minutes wants whichever
|
||||
fight comes up first.
|
||||
|
||||
---
|
||||
|
||||
## Bullets do not animate
|
||||
|
||||
**One frame, and a slow turn.** The bullet sheet's eight frames are a colour
|
||||
cycle rather than a shape change, and running it meant every bullet on screen
|
||||
strobed through a palette in unison — genuinely hard to look at with a few
|
||||
hundred in the air, which is the normal case for this game.
|
||||
|
||||
Each bullet now holds one frame and rotates at `Art.BULLET_SPIN_RATE`, offset by
|
||||
its own id so a ring of twenty does not turn as one rigid wheel. The rate is
|
||||
slow enough that nothing completes a full turn inside its own lifetime, so it
|
||||
reads as drift rather than as spin — a test pins that against the
|
||||
longest-lived bullet in the game.
|
||||
|
||||
The other seven frames stay in the atlas because that is what the source art is.
|
||||
Nothing draws them, and the renderer now slices four textures at startup instead
|
||||
of thirty-two.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
**Preferences are local to the client and nothing else knows about them.**
|
||||
Which key fires produces the same [InputFrame] either way, so the server has no
|
||||
opinion and never hears about it. `Settings` is static for the same reason
|
||||
`GameOpts` is: a `--script` tool has no main loop and cannot resolve autoloads.
|
||||
|
||||
**Bindings are stored as physical keycodes and labelled through the active
|
||||
layout.** Physical is what `tools/setup_input_map.gd` already uses, so bindings
|
||||
follow key *position*; translating the label back means an AZERTY player reads
|
||||
the letter printed on the key their fingers are on. The translation is skipped
|
||||
on a display server with no keyboard — headless does not merely lack the call,
|
||||
it logs an engine error and hands the argument back, and there is no feature
|
||||
flag to test for.
|
||||
|
||||
**A rebind replaces every event on the action, not just the first.** Keeping the
|
||||
alternates would mean the action still answered to the key you just moved away
|
||||
from, which reads as the rebind not having worked. The cost is that rebinding
|
||||
movement loses the arrow keys; untouched actions keep all their defaults.
|
||||
|
||||
**One key doing two things is refused, and the clash is named.** Silently
|
||||
accepting it produces a control scheme that is broken in a way the player has to
|
||||
diagnose themselves.
|
||||
|
||||
**"Reset" restores what the PROJECT shipped**, captured once before anything
|
||||
overrides it. Captured later it would restore the last session's choice — that
|
||||
is, the thing the player was trying to undo.
|
||||
|
||||
**Panels are the pack's shape in this game's colours.** The set is cream
|
||||
throughout, which is right for buttons — they are meant to be the bright thing
|
||||
you press — and wrong for a dialog laid over a dark dungeon. The panel stylebox
|
||||
is modulated dark, which keeps the pixel border and the corner shape and lets
|
||||
every label the game already draws in light colours stay readable. The
|
||||
alternative was recolouring every label in five screens to suit the art.
|
||||
|
||||
**A card's rarity is its frame, not a word on it.** Three upgrade choices are
|
||||
compared at a glance, and a colour reads faster than a label.
|
||||
|
||||
**Effects play on an SFX bus created at runtime.** A bus layout resource would
|
||||
be one more file to keep in step with the code that reads it, and the sliders
|
||||
being real mixer settings beats multiplying a number into every `play()` call.
|
||||
|
||||
---
|
||||
|
||||
## Drawing a boss that moves
|
||||
|
||||
**The boss is interpolated between snapshots like every other actor.** It was
|
||||
not — `boss_state()` handed back the newest snapshot raw, so the boss stepped at
|
||||
the 20 Hz snapshot rate rather than the frame rate. Invisible for as long as
|
||||
every boss stood still, and the first one that moved looked broken. Not
|
||||
interpolated across an instance change, though: the previous snapshot describes
|
||||
a different fight in a different room.
|
||||
|
||||
**A CHASE boss has a dead band around its preferred distance.** The correction
|
||||
is signed, so without one the sign flips every tick at the standoff and the boss
|
||||
vibrates on the spot at the tick rate — a couple of pixels, and unmistakable.
|
||||
|
||||
@@ -13,6 +13,41 @@ that may already have an answer.
|
||||
|
||||
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
|
||||
|
||||
---
|
||||
|
||||
## Where things stand
|
||||
|
||||
**Every stage in the original brief is built.** The MVP asked for a twin-stick
|
||||
bullet hell, server-side hit validation, predictable enemies, an adaptable boss,
|
||||
a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus
|
||||
the four stages that followed: characters and permadeath, inventory and loot,
|
||||
upgrades, and a second boss that moves.
|
||||
|
||||
So there is no obvious "next ticket". What follows is the honest list of
|
||||
candidates, none of which the user has chosen — **ask before starting one.**
|
||||
|
||||
| Candidate | Why it might be next | What it needs first |
|
||||
| --- | --- | --- |
|
||||
| **Replace `LocalAuthProvider`** | It is the one thing standing between this and a server anyone else can reach. Any client can claim any account id. | Nothing technical — it is one `AuthProvider` subclass. Needs a decision about Steam vs something else. |
|
||||
| **A reason to keep playing past level 15** | Levels cap, `--depth` never rises in play, and a cleared dungeon gives experience and a potion. The long loop is missing. | Open question 1 below. |
|
||||
| **An economy** | Loot only drops from kills; nothing sells anything. The quartermaster is the obvious shop and has no currency to take. | Open questions 2 and 3. |
|
||||
| **Impact and hit feedback** | `Art.IMPACT` is loaded and validated and nothing plays it. No hit flash, no screen shake, no death animation. | Nothing. This is the cheapest visible win. |
|
||||
| **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). |
|
||||
| **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. |
|
||||
|
||||
Each stage below also carries its own **Known gaps** section — those are smaller
|
||||
and more specific than the table above.
|
||||
|
||||
### Known flake
|
||||
|
||||
`tools/smoke.sh` failed once in a pre-push hook and then passed on four
|
||||
consecutive runs with no stray processes. It was not reproduced and is not
|
||||
diagnosed. The suspicion is machine load — the smoke test has fixed tick budgets
|
||||
and was competing with a full suite run — but that is a guess. If it recurs,
|
||||
that is the first thing to check.
|
||||
|
||||
---
|
||||
|
||||
## Verification surface
|
||||
|
||||
What "everything passes" currently means. Numbers move; the shape does not.
|
||||
@@ -20,12 +55,23 @@ What "everything passes" currently means. Numbers move; the shape does not.
|
||||
| Gate | Covers | Runtime |
|
||||
| --- | --- | --- |
|
||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||
| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
|
||||
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
|
||||
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted | ~10s |
|
||||
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s |
|
||||
|
||||
`tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display,
|
||||
and it is the only thing in the project that can tell you the interface
|
||||
actually rendered. Run it after touching `src/ui/`. It captures the main menu,
|
||||
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
|
||||
those by staging an offer, because earning one would make the shot depend on a
|
||||
dungeon run.
|
||||
|
||||
It has now caught four bugs that every other gate passed: the theme applying to
|
||||
nothing, the test suite rewriting the player's settings, an invisible slider
|
||||
track and an invisible scrollbar.
|
||||
|
||||
The four diagnostics exist because the smoke test structurally cannot reach
|
||||
what they cover: bots are poor shots (so they neither level up, produce drops,
|
||||
@@ -74,24 +120,33 @@ draws whatever it holds. The defence is what the server declines to send.
|
||||
|
||||
## Dungeon kinds · *done*
|
||||
|
||||
Two entrances stand side by side in the hub, labelled, and open different runs.
|
||||
Three entrances stand side by side in the hub, labelled with the fight each one
|
||||
ends in.
|
||||
|
||||
| Dungeon | `Dungeons` id | Enemy HP | Boss HP | Loot chance |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Warden's Descent | `warden_descent` | ×1 | ×1 (3600) | ×1 (trash 8%) |
|
||||
| Proving Grounds | `proving_grounds` | ×0.2 | ×0.08 (288) | ×10 (trash 80%, clamped) |
|
||||
| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) |
|
||||
| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 |
|
||||
| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) |
|
||||
|
||||
**A dungeon names its arena, and the arena decides its boss.** Which boss you
|
||||
are about to fight is the one thing a player chooses before walking into a
|
||||
dungeon, so it must not be rolled for them. Only the Proving Grounds leaves
|
||||
`arena` empty — it exists to reach *a* fight fast, and runs there last a couple
|
||||
of minutes, so the one you want is one re-entry away.
|
||||
|
||||
The Proving Grounds is a **test harness you can walk into**: same generator,
|
||||
same rooms, same enemies, the same four-phase Warden — everything simply dies
|
||||
faster and drops more. A manual pass over loot, the inventory, dropping and all
|
||||
four boss phases takes a couple of minutes instead of a quarter of an hour, and
|
||||
because it is a portal rather than a launch flag you can compare the two back to
|
||||
back without restarting the server.
|
||||
same rooms, same enemies, but everything dies far faster and drops far more. A
|
||||
manual pass over loot, the inventory, dropping and all four boss phases takes a
|
||||
couple of minutes instead of a quarter of an hour, and because it is a portal
|
||||
rather than a launch flag you can compare it to a real run without restarting
|
||||
the server.
|
||||
|
||||
Adding a third dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker
|
||||
in the lobby stamp — the Nth marker, in reading order, opens the Nth entry.
|
||||
Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in
|
||||
the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the
|
||||
two have to stay in the same order.
|
||||
|
||||
Three things worth knowing:
|
||||
Two more things worth knowing:
|
||||
|
||||
- **It is multipliers over the shared content, not a copy of it.** A duplicated
|
||||
`Content` would drift the moment anything was tuned, and "identical but
|
||||
@@ -99,10 +154,9 @@ Three things worth knowing:
|
||||
- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()`
|
||||
resolves the server-side position; the `PORTAL_USED` event carries the answer.
|
||||
No client message names a dungeon, which is what stops anyone picking the
|
||||
generous loot table from the real run.
|
||||
- **`accepts_new_party_member` takes the dungeon id.** Without that, walking
|
||||
into the Proving Grounds would drop you into whatever standard run happened
|
||||
to still be forming.
|
||||
generous loot table from a real run. `accepts_new_party_member` compares
|
||||
dungeon ids too, so walking into one entrance can never drop you into
|
||||
another's forming run on timing alone.
|
||||
|
||||
---
|
||||
|
||||
@@ -120,8 +174,13 @@ play off server events. Enough to prove the pipeline, not a finished look.
|
||||
| Rects validated without a display | done | `tests/unit/test_art.gd` |
|
||||
| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet |
|
||||
| Directional sprites, hit flashes, screen shake | todo | |
|
||||
| Audio buses and a volume setting | todo | Everything plays on Master at hardcoded dB |
|
||||
| **In-game credits screen** | **todo** | Not cosmetic: the SFX are CC BY 4.0 and attribution is a licence *requirement*. [CREDITS.md](../CREDITS.md) is not reachable by a player. |
|
||||
| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) |
|
||||
| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` |
|
||||
| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries |
|
||||
| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` |
|
||||
| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` |
|
||||
| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen |
|
||||
| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. |
|
||||
| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). |
|
||||
|
||||
## Stage 2 — Characters, persistence, levels · *done*
|
||||
@@ -219,6 +278,29 @@ shares the interact key.
|
||||
|
||||
---
|
||||
|
||||
## The scale of health and damage
|
||||
|
||||
Health and damage are an order of magnitude larger than they started. A shot is
|
||||
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
|
||||
unchanged** — enemy health, boss health and every emitter's damage were scaled
|
||||
together, so time to kill is exactly what it was.
|
||||
|
||||
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
|
||||
carries computed to 6.3 and rounded straight back to 6, so a player's first
|
||||
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
|
||||
now has somewhere to land.
|
||||
|
||||
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
|
||||
changes damage, and that shots-to-kill for each enemy is what it was before.
|
||||
Do not tidy these numbers back down without scaling `content.gd` with them.
|
||||
|
||||
The hub's practice dummy stopped relying on a huge health pool at the same
|
||||
time: it is `indestructible` by flag now. The old 100000 was already past the
|
||||
u16 the snapshot sends enemy health in, and after the rescale a patient player
|
||||
could have destroyed the hub's only practice target for everyone.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Upgrades · *done*
|
||||
|
||||
Every level gained banks one choice. Choices are spent at the **quartermaster**,
|
||||
@@ -284,9 +366,6 @@ All four settled with the user; the reasoning is in
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
|
||||
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
|
||||
damage), but the first one visibly does nothing. See the open question below.
|
||||
- **One panel, not two screens.** The brief asked for a separate screen listing
|
||||
upgrades taken; it is the lower half of the choice panel instead, because the
|
||||
two are read together.
|
||||
@@ -294,24 +373,86 @@ All four settled with the user; the reasoning is in
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — Boss features and new bosses · *todo*
|
||||
## Stage 5 — Boss features and new bosses · *done*
|
||||
|
||||
| Feature | State |
|
||||
| --- | --- |
|
||||
| Stationary phases | done — every current phase |
|
||||
| Boss confined to its room | done — `SimWorld._step_boss` clamps to `SimBoss.room` |
|
||||
| Roaming / chasing within the boss room | todo |
|
||||
| Phases that move to preset locations | todo |
|
||||
| Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog |
|
||||
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` |
|
||||
| Feature | State | Where |
|
||||
| --- | --- | --- |
|
||||
| Stationary phases | done | every Warden phase |
|
||||
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
|
||||
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
|
||||
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
|
||||
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
|
||||
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
|
||||
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
|
||||
|
||||
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
|
||||
asserts the simulation needs no changes to run it. Movement is the first thing
|
||||
that format has not covered, so expect `BossPhase` to gain a movement field
|
||||
rather than `SimWorld` gaining a per-boss branch.
|
||||
### Boss movement
|
||||
|
||||
Remember boss rooms **do not lock** (a settled decision): a player can always
|
||||
walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.
|
||||
Movement is a property of the **phase**, not of the boss: a fight that stands
|
||||
still and then starts hunting you is one boss with two phases. Four modes —
|
||||
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
|
||||
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
|
||||
|
||||
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
|
||||
works in the Warden's hall and the Choir Vault alike.
|
||||
- **CHASE holds a distance rather than closing.** A boss standing on top of you
|
||||
is a boss whose bullets cannot be read.
|
||||
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
|
||||
destination and would otherwise snap onto its circle on the first tick.
|
||||
- **Movement slides against geometry**, so a boss cannot walk through the
|
||||
pillars its own arena was designed around.
|
||||
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
|
||||
still — which is exactly when an invariant is cheapest to establish. Boss
|
||||
rooms deliberately do not lock, and that is only an escape if the boss cannot
|
||||
follow you out.
|
||||
|
||||
### Telegraphed strikes
|
||||
|
||||
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
|
||||
later. The moment in between is the whole feature: a burst that appears at your
|
||||
feet is a coin flip, the same burst with a second of notice is a question.
|
||||
|
||||
- **The emitter is stateless**, like every other one — they are shared resources
|
||||
and two bosses of the same kind must not stomp each other. Strike positions
|
||||
are *derived* from the volley number, so the announcement and the strike
|
||||
compute the same points with nothing stored between them.
|
||||
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
|
||||
besides spawn bullets.
|
||||
- **Markers are drawn through fog and through walls.** Everything else in the
|
||||
view respects line of sight; a warning you cannot see is not a warning. See
|
||||
`WorldView._draw_telegraphs`.
|
||||
|
||||
### The Cantor of the Vault
|
||||
|
||||
| Phase | Movement | Idea |
|
||||
| --- | --- | --- |
|
||||
| Call to Prayer | static | Teaches the marker, and nothing else. |
|
||||
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
|
||||
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
|
||||
| Final Cadence | orbit | Movement and telegraphs at once. |
|
||||
|
||||
**The Cantor has its own portal in the hub.** Which boss a run ends with follows
|
||||
from the arena its dungeon names, not from the seed and certainly not from
|
||||
`--depth`, which nothing in play raises.
|
||||
`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without
|
||||
giving it a way in is a failing test rather than a boss nobody meets.
|
||||
|
||||
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
|
||||
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
|
||||
had already gone stale and silently stopped writing the second boss.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **Nothing tests how the fight *looks*.** The boss was drawn straight from the
|
||||
newest snapshot with no interpolation — a visible step at 20 Hz — and every
|
||||
test passed for as long as bosses stood still. There is still no automated
|
||||
check that a moving actor renders smoothly; the ones added cover the
|
||||
interpolation call, not the impression.
|
||||
- **No boss-specific music, intro or death sequence.** A boss dies like an
|
||||
enemy, only louder.
|
||||
- **Telegraph markers are one shape.** A circle is the only warning the client
|
||||
can draw; a lane or a cone would need another event field.
|
||||
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
|
||||
the generator's "boss arena on the right-hand side" assumption will be tested.
|
||||
|
||||
---
|
||||
|
||||
@@ -335,31 +476,24 @@ Not oversights — each was considered and rejected for now, with the reasoning
|
||||
## Open questions for the user
|
||||
|
||||
Genuinely unspecified. **Do not guess at these** — each changes the design, and
|
||||
several have no obvious default.
|
||||
several have no obvious default. The seven questions that blocked Stage 4 were
|
||||
answered and are recorded in [DECISIONS.md](DECISIONS.md#upgrades); these are
|
||||
what is left.
|
||||
|
||||
### Worth a decision soon
|
||||
|
||||
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
|
||||
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
|
||||
nothing at all until the second one. It comes out right in aggregate, but a
|
||||
player who takes Split Shot and sees no damage change has been told
|
||||
something untrue.
|
||||
|
||||
The clean fix is to scale base damage and enemy health together — ×4 would
|
||||
put a shot at 24 and make every 5% step land — which changes no time-to-kill
|
||||
but touches every number in `content.gd`. That is a balance edit, so it is
|
||||
yours to call rather than mine.
|
||||
|
||||
### Blocking nothing yet
|
||||
|
||||
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
|
||||
in play. Depth drives map size and could drive difficulty and rewards.
|
||||
9. **Where do items come from outside a dungeon?** Loot only drops from kills.
|
||||
If the hub should sell potions, that is the Stage 4 NPC's second job — and
|
||||
it needs a currency, which the game does not have.
|
||||
10. **Should items stack?** Four potions currently take four slots, which makes
|
||||
a 4-slot bag small. Stacking is a count byte per slot plus a rule for
|
||||
splitting one; neither is hard, but both change the UI.
|
||||
11. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are
|
||||
non-redistributable and local-only, and there is no in-game credits screen
|
||||
yet, which CC BY 4.0 requires for the audio.
|
||||
1. **What advances dungeon depth?** `--depth` is a dev flag and nothing raises
|
||||
it in play. Depth already drives map size and could drive difficulty and
|
||||
rewards. Without an answer there is no progression past level 15.
|
||||
2. **Where do items come from outside a dungeon?** Loot only drops from kills.
|
||||
If the hub should sell potions, that is the quartermaster's second job.
|
||||
3. **Is there a currency?** Nothing drops money and nothing costs anything. A
|
||||
shop needs one; so does any reward for a cleared run beyond experience.
|
||||
4. **Should items stack?** Four potions currently take four slots, which makes a
|
||||
four-slot bag small. Stacking is a count byte per slot plus a rule for
|
||||
splitting one — neither is hard, and both change the UI.
|
||||
5. **Does a dungeon ever get harder within a run?** Enemies are placed once at
|
||||
generation and never reinforce. That is a deliberate decision
|
||||
([DECISIONS.md](DECISIONS.md#world)), but it means a slow party is never
|
||||
punished for it.
|
||||
6. **What happens to a party when one member dies?** Permadeath removes them to
|
||||
the roster screen while the run continues without them. Nobody has said
|
||||
whether that is right.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
[gd_resource type="Resource" script_class="BossDef" format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_mkon8"]
|
||||
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_08s2a"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_i3ijt"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/telegraphed_strike_emitter.gd" id="4_c737k"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_s4e7m"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="6_1xgol"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="7_jvosp"]
|
||||
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_vujs6"]
|
||||
|
||||
[sub_resource type="Resource" id="Resource_y5tlt"]
|
||||
script = ExtResource("1_mkon8")
|
||||
item = &"health_potion"
|
||||
|
||||
[sub_resource type="Resource" id="Resource_vaonm"]
|
||||
script = ExtResource("1_mkon8")
|
||||
item = &"wardens_ration"
|
||||
instanced = true
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ldxsn"]
|
||||
script = ExtResource("4_c737k")
|
||||
warn_ticks = 80
|
||||
burst_count = 10
|
||||
blast_radius = 66.0
|
||||
interval = 180
|
||||
speed = 130.0
|
||||
lifetime = 240
|
||||
damage = 130
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ik5lx"]
|
||||
script = ExtResource("5_s4e7m")
|
||||
count = 3
|
||||
spread_deg = 18.0
|
||||
muzzle_offset = 40.0
|
||||
start_tick = 40
|
||||
interval = 120
|
||||
speed = 190.0
|
||||
radius = 6.0
|
||||
damage = 110
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_cfxp7"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Call to Prayer"
|
||||
loop_ticks = 360
|
||||
telegraph_ticks = 60
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_ldxsn"), SubResource("Resource_ik5lx")])
|
||||
|
||||
[sub_resource type="Resource" id="Resource_mrfx8"]
|
||||
script = ExtResource("6_1xgol")
|
||||
count = 3
|
||||
spin_per_shot_deg = 27.0
|
||||
muzzle_offset = 40.0
|
||||
interval = 12
|
||||
speed = 125.0
|
||||
lifetime = 400
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_oppcg"]
|
||||
script = ExtResource("4_c737k")
|
||||
strikes = 2
|
||||
warn_ticks = 90
|
||||
blast_radius = 70.0
|
||||
pattern_seed = 17
|
||||
start_tick = 60
|
||||
interval = 200
|
||||
speed = 145.0
|
||||
lifetime = 240
|
||||
damage = 140
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_eg17u"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Processional"
|
||||
enter_at_hp_fraction = 0.75
|
||||
loop_ticks = 480
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_mrfx8"), SubResource("Resource_oppcg")])
|
||||
move = 3
|
||||
move_speed = 105.0
|
||||
waypoints = Array[Vector2]([Vector2(0.18, 0.2), Vector2(0.82, 0.2), Vector2(0.82, 0.8), Vector2(0.18, 0.8)])
|
||||
waypoint_dwell = 70
|
||||
|
||||
[sub_resource type="Resource" id="Resource_bffie"]
|
||||
script = ExtResource("5_s4e7m")
|
||||
spread_deg = 34.0
|
||||
jitter_deg = 2.5
|
||||
muzzle_offset = 40.0
|
||||
interval = 90
|
||||
speed = 200.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_xcmnn"]
|
||||
script = ExtResource("7_jvosp")
|
||||
direction = 1
|
||||
count = 16
|
||||
gap_width = 4
|
||||
gap_step = 6
|
||||
start_tick = 70
|
||||
interval = 160
|
||||
speed = 165.0
|
||||
radius = 8.0
|
||||
damage = 150
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_31prv"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Antiphon"
|
||||
enter_at_hp_fraction = 0.45
|
||||
loop_ticks = 540
|
||||
telegraph_ticks = 50
|
||||
damage_taken_mult = 1.12
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_bffie"), SubResource("Resource_xcmnn")])
|
||||
move = 2
|
||||
move_speed = 120.0
|
||||
move_param = 220.0
|
||||
|
||||
[sub_resource type="Resource" id="Resource_wuthe"]
|
||||
script = ExtResource("6_1xgol")
|
||||
spin_per_shot_deg = 15.0
|
||||
muzzle_offset = 40.0
|
||||
interval = 40
|
||||
speed = 140.0
|
||||
lifetime = 380
|
||||
damage = 130
|
||||
|
||||
[sub_resource type="Resource" id="Resource_phxte"]
|
||||
script = ExtResource("4_c737k")
|
||||
strikes = 4
|
||||
warn_ticks = 70
|
||||
burst_count = 14
|
||||
blast_radius = 72.0
|
||||
pattern_seed = 91
|
||||
interval = 150
|
||||
lifetime = 240
|
||||
damage = 150
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_p6400"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Final Cadence"
|
||||
enter_at_hp_fraction = 0.18
|
||||
loop_ticks = 420
|
||||
telegraph_ticks = 55
|
||||
damage_taken_mult = 1.25
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_wuthe"), SubResource("Resource_phxte")])
|
||||
move = 1
|
||||
move_speed = 150.0
|
||||
move_param = 150.0
|
||||
|
||||
[resource]
|
||||
script = ExtResource("8_vujs6")
|
||||
id = &"cantor"
|
||||
display_name = "Cantor of the Vault"
|
||||
max_hp = 28000
|
||||
radius = 36.0
|
||||
spawn_pos = Vector2(0, -150)
|
||||
visual = 1
|
||||
phases = Array[ExtResource("2_08s2a")]([SubResource("Resource_cfxp7"), SubResource("Resource_eg17u"), SubResource("Resource_31prv"), SubResource("Resource_p6400")])
|
||||
loot = Array[ExtResource("1_mkon8")]([SubResource("Resource_y5tlt"), SubResource("Resource_vaonm")])
|
||||
@@ -27,7 +27,7 @@ interval = 45
|
||||
speed = 135.0
|
||||
radius = 8.0
|
||||
lifetime = 420
|
||||
damage = 14
|
||||
damage = 140
|
||||
|
||||
[sub_resource type="Resource" id="Resource_a0mrn"]
|
||||
script = ExtResource("5_v02no")
|
||||
@@ -37,6 +37,7 @@ start_tick = 120
|
||||
interval = 150
|
||||
speed = 210.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_pfjxl"]
|
||||
@@ -54,6 +55,7 @@ muzzle_offset = 46.0
|
||||
interval = 9
|
||||
speed = 120.0
|
||||
lifetime = 480
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_k1e8f"]
|
||||
script = ExtResource("6_x6ufy")
|
||||
@@ -63,7 +65,7 @@ start_tick = 90
|
||||
interval = 170
|
||||
speed = 175.0
|
||||
radius = 8.0
|
||||
damage = 16
|
||||
damage = 160
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_4sp1g"]
|
||||
@@ -83,7 +85,7 @@ sweep_period = 5.0
|
||||
interval = 5
|
||||
speed = 165.0
|
||||
lifetime = 400
|
||||
damage = 13
|
||||
damage = 130
|
||||
|
||||
[sub_resource type="Resource" id="Resource_3fm5w"]
|
||||
script = ExtResource("5_v02no")
|
||||
@@ -95,6 +97,7 @@ start_tick = 60
|
||||
interval = 110
|
||||
speed = 195.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_20c8f"]
|
||||
@@ -106,6 +109,7 @@ end_tick = 600
|
||||
interval = 60
|
||||
speed = 105.0
|
||||
lifetime = 420
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ymr56"]
|
||||
script = ExtResource("2_f5abt")
|
||||
@@ -124,7 +128,7 @@ interval = 11
|
||||
speed = 130.0
|
||||
turn_deg = 0.55
|
||||
lifetime = 400
|
||||
damage = 15
|
||||
damage = 150
|
||||
|
||||
[sub_resource type="Resource" id="Resource_3n6oq"]
|
||||
script = ExtResource("6_x6ufy")
|
||||
@@ -134,7 +138,7 @@ start_tick = 40
|
||||
interval = 130
|
||||
speed = 200.0
|
||||
radius = 8.0
|
||||
damage = 18
|
||||
damage = 180
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_8ppic"]
|
||||
@@ -145,7 +149,7 @@ start_tick = 100
|
||||
interval = 80
|
||||
speed = 300.0
|
||||
radius = 5.0
|
||||
damage = 16
|
||||
damage = 160
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_yqxmm"]
|
||||
@@ -161,7 +165,7 @@ emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubReso
|
||||
script = ExtResource("8_axg03")
|
||||
id = &"warden"
|
||||
display_name = "Warden of the Fold"
|
||||
max_hp = 3600
|
||||
max_hp = 36000
|
||||
radius = 42.0
|
||||
spawn_pos = Vector2(0, -150)
|
||||
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")])
|
||||
|
||||
@@ -12,7 +12,7 @@ spread_deg = 20.0
|
||||
interval = 120
|
||||
radius = 6.0
|
||||
lifetime = 240
|
||||
damage = 10
|
||||
damage = 100
|
||||
|
||||
[sub_resource type="Resource" id="Resource_lnbnj"]
|
||||
script = ExtResource("3_vpd04")
|
||||
@@ -23,6 +23,7 @@ chance = 0.08
|
||||
script = ExtResource("4_k36hi")
|
||||
id = &"drifter"
|
||||
display_name = "Drifter"
|
||||
max_hp = 400
|
||||
speed = 55.0
|
||||
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")])
|
||||
pattern_loop_ticks = 120
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
script = ExtResource("3_2tv1l")
|
||||
id = &"dummy"
|
||||
display_name = "Target Dummy"
|
||||
max_hp = 100000
|
||||
max_hp = 1000
|
||||
radius = 20.0
|
||||
move = 0
|
||||
visual = 3
|
||||
indestructible = true
|
||||
|
||||
@@ -13,7 +13,7 @@ interval = 45
|
||||
speed = 260.0
|
||||
radius = 6.0
|
||||
lifetime = 18
|
||||
damage = 14
|
||||
damage = 140
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ep81w"]
|
||||
@@ -25,7 +25,7 @@ chance = 0.08
|
||||
script = ExtResource("4_yp71e")
|
||||
id = &"stalker"
|
||||
display_name = "Stalker"
|
||||
max_hp = 30
|
||||
max_hp = 300
|
||||
radius = 12.0
|
||||
move = 3
|
||||
speed = 95.0
|
||||
|
||||
@@ -11,6 +11,7 @@ count = 10
|
||||
spin_per_shot_deg = 18.0
|
||||
interval = 150
|
||||
speed = 130.0
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_fttpl"]
|
||||
script = ExtResource("3_hc01i")
|
||||
@@ -21,7 +22,7 @@ chance = 0.08
|
||||
script = ExtResource("4_6rb75")
|
||||
id = &"turret"
|
||||
display_name = "Turret"
|
||||
max_hp = 70
|
||||
max_hp = 700
|
||||
radius = 16.0
|
||||
move = 0
|
||||
speed = 0.0
|
||||
|
||||
@@ -7,10 +7,10 @@ extends Resource
|
||||
@export var display_name: String = "Boss"
|
||||
@export var max_hp: int = 4000
|
||||
@export var radius: float = 44.0
|
||||
## Stationary bosses are the MVP shape; the field exists so a later boss can
|
||||
## move without changing the runtime.
|
||||
@export var stationary: bool = true
|
||||
@export var spawn_pos := Vector2(0.0, -140.0)
|
||||
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
|
||||
## the simulation never learns that art exists.
|
||||
@export var visual: int = 0
|
||||
@export var phases: Array[BossPhase] = []
|
||||
## What the kill leaves behind. Bosses are the guaranteed source: a run that
|
||||
## reaches the end should always be worth something, so unlike trash loot these
|
||||
@@ -18,6 +18,16 @@ extends Resource
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
|
||||
## Whether any phase moves this boss. Derived from the phases rather than
|
||||
## stored alongside them: a flag that said "stationary" while a phase walked
|
||||
## around would be a second source of truth, and the wrong one.
|
||||
func moves() -> bool:
|
||||
for phase in phases:
|
||||
if phase != null and phase.moves():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
|
||||
## boss at 0.2 hp picks the lowest-threshold phase that still covers it.
|
||||
func phase_index_for(hp_fraction: float) -> int:
|
||||
|
||||
@@ -16,3 +16,38 @@ extends Resource
|
||||
## phases rather than adding hit points, so the fight length stays predictable.
|
||||
@export var damage_taken_mult: float = 1.0
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
|
||||
@export_group("Movement")
|
||||
## How the boss moves during this phase. Movement is a property of the PHASE,
|
||||
## not of the boss: a fight that stands still and then starts hunting you is
|
||||
## one boss with two phases, and expressing it any other way would put a
|
||||
## per-boss branch in the simulation.
|
||||
@export var move: Move = Move.STATIC
|
||||
## Units per second. Zero is equivalent to STATIC.
|
||||
@export var move_speed: float = 0.0
|
||||
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
|
||||
## hold from you -- closing all the way would mean a boss you cannot see past.
|
||||
@export var move_param: float = 120.0
|
||||
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
|
||||
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
|
||||
## right.
|
||||
@export var waypoints: Array[Vector2] = []
|
||||
## Ticks spent standing at each waypoint before moving on.
|
||||
@export var waypoint_dwell: int = 60
|
||||
|
||||
enum Move {
|
||||
## Never moves. Every phase written before bosses could move.
|
||||
STATIC,
|
||||
## Circles the centre of its arena at [member move_param] radius.
|
||||
ORBIT,
|
||||
## Closes on the nearest player, holding [member move_param] distance.
|
||||
CHASE,
|
||||
## Walks a fixed circuit of [member waypoints], pausing at each.
|
||||
WAYPOINTS,
|
||||
}
|
||||
|
||||
|
||||
## Whether this phase actually moves the boss. Derived rather than stored, so a
|
||||
## phase cannot claim to move and then sit still.
|
||||
func moves() -> bool:
|
||||
return move != Move.STATIC and move_speed > 0.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,6 +36,11 @@ enum Move {
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
## The emitter timeline wraps at this many ticks.
|
||||
@export var pattern_loop_ticks: int = 240
|
||||
## Never takes damage. Exists for the hub's practice target, whose whole job is
|
||||
## to still be there tomorrow -- expressing that as a flag rather than as a
|
||||
## large health pool means it cannot be worn down by a patient player, and
|
||||
## keeps its health inside the u16 the snapshot sends.
|
||||
@export var indestructible: bool = false
|
||||
## What this enemy may leave behind. Rolled once per entry on death, against
|
||||
## the world's own RNG. Empty for anything that should drop nothing.
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
@@ -13,6 +13,18 @@ const ENEMY_TURRET := &"turret"
|
||||
const ENEMY_STALKER := &"stalker"
|
||||
const ENEMY_DUMMY := &"dummy"
|
||||
const BOSS_WARDEN := &"warden"
|
||||
const BOSS_CANTOR := &"cantor"
|
||||
|
||||
## Every id this file defines. Kept here so the export tool, the renderer and
|
||||
## the tests all iterate the same list -- each of them used to hand-maintain
|
||||
## its own copy, and the export tool had already gone stale and stopped writing
|
||||
## the second boss.
|
||||
const ALL_ENEMIES: Array[StringName] = [
|
||||
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
|
||||
]
|
||||
const ALL_BOSSES: Array[StringName] = [
|
||||
BOSS_WARDEN, BOSS_CANTOR,
|
||||
]
|
||||
|
||||
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
|
||||
## dungeon run should be survivable on what it hands you, but never comfortably.
|
||||
@@ -32,6 +44,7 @@ static func enemy(id: StringName) -> EnemyDef:
|
||||
static func boss(id: StringName) -> BossDef:
|
||||
match id:
|
||||
BOSS_WARDEN: return warden()
|
||||
BOSS_CANTOR: return cantor()
|
||||
push_error("unknown boss id: %s" % id)
|
||||
return warden()
|
||||
|
||||
@@ -43,7 +56,7 @@ static func drifter() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DRIFTER
|
||||
d.display_name = "Drifter"
|
||||
d.max_hp = 40
|
||||
d.max_hp = 400
|
||||
d.radius = 14.0
|
||||
d.move = EnemyDef.Move.DRIFT
|
||||
d.speed = 55.0
|
||||
@@ -57,7 +70,7 @@ static func drifter() -> EnemyDef:
|
||||
fan.spread_deg = 20.0
|
||||
fan.speed = 150.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 10
|
||||
fan.damage = 100
|
||||
fan.lifetime = 240
|
||||
fan.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [fan]
|
||||
@@ -70,7 +83,7 @@ static func turret() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_TURRET
|
||||
d.display_name = "Turret"
|
||||
d.max_hp = 70
|
||||
d.max_hp = 700
|
||||
d.radius = 16.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.speed = 0.0
|
||||
@@ -84,7 +97,7 @@ static func turret() -> EnemyDef:
|
||||
ring.spin_per_shot_deg = 18.0
|
||||
ring.speed = 130.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 12
|
||||
ring.damage = 120
|
||||
ring.lifetime = 300
|
||||
ring.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [ring]
|
||||
@@ -104,7 +117,7 @@ static func stalker() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_STALKER
|
||||
d.display_name = "Stalker"
|
||||
d.max_hp = 30
|
||||
d.max_hp = 300
|
||||
d.radius = 12.0
|
||||
d.move = EnemyDef.Move.APPROACH
|
||||
d.speed = 95.0
|
||||
@@ -119,7 +132,7 @@ static func stalker() -> EnemyDef:
|
||||
lunge.spread_deg = 62.0
|
||||
lunge.speed = 260.0
|
||||
lunge.radius = 6.0
|
||||
lunge.damage = 14
|
||||
lunge.damage = 140
|
||||
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
|
||||
# player distance at any range you would call "not point blank".
|
||||
lunge.lifetime = 18
|
||||
@@ -130,12 +143,18 @@ static func stalker() -> EnemyDef:
|
||||
return d
|
||||
|
||||
|
||||
## Lobby target dummy: inert, tough, so players can feel out the gun.
|
||||
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
|
||||
##
|
||||
## Indestructible by flag rather than by a huge health pool. The old 100000 was
|
||||
## already past the u16 the snapshot sends enemy health in, and after the x10
|
||||
## rescale a determined player could have destroyed the hub's only practice
|
||||
## target for everyone until the server restarted.
|
||||
static func dummy() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DUMMY
|
||||
d.display_name = "Target Dummy"
|
||||
d.max_hp = 100000
|
||||
d.max_hp = 1000
|
||||
d.indestructible = true
|
||||
d.radius = 20.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.visual = 3
|
||||
@@ -151,10 +170,10 @@ static func warden() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_WARDEN
|
||||
b.display_name = "Warden of the Fold"
|
||||
b.max_hp = 3600
|
||||
b.max_hp = 36000
|
||||
b.radius = 42.0
|
||||
b.stationary = true
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.visual = 0
|
||||
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
|
||||
# Guaranteed, and deliberately one of each visibility. The potion is shared,
|
||||
# so a party still has something to divide up; the ration is instanced, so
|
||||
@@ -182,7 +201,7 @@ static func _warden_p1() -> BossPhase:
|
||||
ring.spin_per_shot_deg = 9.0
|
||||
ring.speed = 135.0
|
||||
ring.radius = 8.0
|
||||
ring.damage = 14
|
||||
ring.damage = 140
|
||||
ring.lifetime = 420
|
||||
ring.muzzle_offset = 46.0
|
||||
|
||||
@@ -193,7 +212,7 @@ static func _warden_p1() -> BossPhase:
|
||||
fan.spread_deg = 26.0
|
||||
fan.speed = 210.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 46.0
|
||||
|
||||
@@ -216,7 +235,7 @@ static func _warden_p2() -> BossPhase:
|
||||
spiral.spin_per_shot_deg = 23.0
|
||||
spiral.speed = 120.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 12
|
||||
spiral.damage = 120
|
||||
spiral.lifetime = 480
|
||||
spiral.muzzle_offset = 46.0
|
||||
|
||||
@@ -229,7 +248,7 @@ static func _warden_p2() -> BossPhase:
|
||||
wall.gap_step = 7
|
||||
wall.speed = 175.0
|
||||
wall.radius = 8.0
|
||||
wall.damage = 16
|
||||
wall.damage = 160
|
||||
wall.lifetime = 300
|
||||
wall.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
@@ -256,7 +275,7 @@ static func _warden_p3() -> BossPhase:
|
||||
arms.sweep_period = 5.0
|
||||
arms.speed = 165.0
|
||||
arms.radius = 7.0
|
||||
arms.damage = 13
|
||||
arms.damage = 130
|
||||
arms.lifetime = 400
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
@@ -268,7 +287,7 @@ static func _warden_p3() -> BossPhase:
|
||||
fan.speed = 195.0
|
||||
fan.edge_speed_bonus = 0.25
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
var counter_ring := RingEmitter.new()
|
||||
@@ -279,7 +298,7 @@ static func _warden_p3() -> BossPhase:
|
||||
counter_ring.spin_per_shot_deg = -14.0
|
||||
counter_ring.speed = 105.0
|
||||
counter_ring.radius = 7.0
|
||||
counter_ring.damage = 12
|
||||
counter_ring.damage = 120
|
||||
counter_ring.lifetime = 420
|
||||
|
||||
p.emitters = [arms, fan, counter_ring]
|
||||
@@ -303,7 +322,7 @@ static func _warden_p4() -> BossPhase:
|
||||
curve.speed = 130.0
|
||||
curve.turn_deg = 0.55
|
||||
curve.radius = 7.0
|
||||
curve.damage = 15
|
||||
curve.damage = 150
|
||||
curve.lifetime = 400
|
||||
curve.muzzle_offset = 46.0
|
||||
|
||||
@@ -316,7 +335,7 @@ static func _warden_p4() -> BossPhase:
|
||||
walls.gap_step = 5
|
||||
walls.speed = 200.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 18
|
||||
walls.damage = 180
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var snipe := AimedSpreadEmitter.new()
|
||||
@@ -326,8 +345,199 @@ static func _warden_p4() -> BossPhase:
|
||||
snipe.spread_deg = 10.0
|
||||
snipe.speed = 300.0
|
||||
snipe.radius = 5.0
|
||||
snipe.damage = 16
|
||||
snipe.damage = 160
|
||||
snipe.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
p.emitters = [curve, walls, snipe]
|
||||
return p
|
||||
|
||||
|
||||
# --- The Cantor of the Vault ------------------------------------------------
|
||||
|
||||
## The second boss, and the one that proves the format stretched. The Warden is
|
||||
## four phases of standing still and filling space; the Cantor walks.
|
||||
##
|
||||
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
|
||||
## see over but not shoot through -- so its patterns are about position rather
|
||||
## than about density. Everything below is data: the simulation gained a
|
||||
## movement mode and a telegraphing emitter, and no line anywhere knows this
|
||||
## boss exists.
|
||||
static func cantor() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_CANTOR
|
||||
b.display_name = "Cantor of the Vault"
|
||||
b.max_hp = 28000
|
||||
b.radius = 36.0
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.visual = 1
|
||||
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
|
||||
b.loot = [
|
||||
LootDrop.make(Items.HEALTH_POTION, 1.0),
|
||||
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
|
||||
]
|
||||
return b
|
||||
|
||||
|
||||
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
|
||||
## three spots, fills them. The whole phase exists to teach the marker.
|
||||
static func _cantor_p1() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Call to Prayer"
|
||||
p.enter_at_hp_fraction = 1.0
|
||||
p.loop_ticks = 360
|
||||
p.telegraph_ticks = 60
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.interval = 180
|
||||
strikes.warn_ticks = 80
|
||||
strikes.strikes = 3
|
||||
strikes.burst_count = 10
|
||||
strikes.blast_radius = 66.0
|
||||
strikes.speed = 130.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 130
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.start_tick = 40
|
||||
fan.interval = 120
|
||||
fan.count = 3
|
||||
fan.spread_deg = 18.0
|
||||
fan.speed = 190.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 110
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 40.0
|
||||
|
||||
p.emitters = [strikes, fan]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
|
||||
## at each, so the safe half of the room keeps changing while a spiral fills
|
||||
## whatever it has left behind.
|
||||
static func _cantor_p2() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Processional"
|
||||
p.enter_at_hp_fraction = 0.75
|
||||
p.loop_ticks = 480
|
||||
p.telegraph_ticks = 45
|
||||
p.move = BossPhase.Move.WAYPOINTS
|
||||
p.move_speed = 105.0
|
||||
p.waypoint_dwell = 70
|
||||
# Fractions of the arena, so the same walk works in a room of any size.
|
||||
p.waypoints = [
|
||||
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
|
||||
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
|
||||
]
|
||||
|
||||
var spiral := RingEmitter.new()
|
||||
spiral.interval = 12
|
||||
spiral.count = 3
|
||||
spiral.spin_per_shot_deg = 27.0
|
||||
spiral.speed = 125.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 120
|
||||
spiral.lifetime = 400
|
||||
spiral.muzzle_offset = 40.0
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.start_tick = 60
|
||||
strikes.interval = 200
|
||||
strikes.warn_ticks = 90
|
||||
strikes.strikes = 2
|
||||
strikes.burst_count = 12
|
||||
strikes.blast_radius = 70.0
|
||||
strikes.pattern_seed = 17
|
||||
strikes.speed = 145.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 140
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [spiral, strikes]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
|
||||
## fight stays readable, and the aimed fire punishes standing still while the
|
||||
## walls punish running in a straight line.
|
||||
static func _cantor_p3() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Antiphon"
|
||||
p.enter_at_hp_fraction = 0.45
|
||||
p.loop_ticks = 540
|
||||
p.telegraph_ticks = 50
|
||||
p.damage_taken_mult = 1.12
|
||||
p.move = BossPhase.Move.CHASE
|
||||
p.move_speed = 120.0
|
||||
p.move_param = 220.0
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.interval = 90
|
||||
fan.count = 5
|
||||
fan.spread_deg = 34.0
|
||||
fan.jitter_deg = 2.5
|
||||
fan.speed = 200.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 40.0
|
||||
|
||||
var walls := WallGapEmitter.new()
|
||||
walls.start_tick = 70
|
||||
walls.interval = 160
|
||||
walls.direction = 1
|
||||
walls.count = 16
|
||||
walls.gap_width = 4
|
||||
walls.gap_step = 6
|
||||
walls.speed = 165.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 150
|
||||
walls.lifetime = 300
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [fan, walls]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
|
||||
## once, which is the point: both were added for this fight and this is where
|
||||
## they have to work together.
|
||||
static func _cantor_p4() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Final Cadence"
|
||||
p.enter_at_hp_fraction = 0.18
|
||||
p.loop_ticks = 420
|
||||
p.telegraph_ticks = 55
|
||||
p.damage_taken_mult = 1.25
|
||||
p.move = BossPhase.Move.ORBIT
|
||||
p.move_speed = 150.0
|
||||
p.move_param = 150.0
|
||||
|
||||
var ring := RingEmitter.new()
|
||||
ring.interval = 40
|
||||
ring.count = 12
|
||||
ring.spin_per_shot_deg = 15.0
|
||||
ring.speed = 140.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 130
|
||||
ring.lifetime = 380
|
||||
ring.muzzle_offset = 40.0
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.interval = 150
|
||||
strikes.warn_ticks = 70
|
||||
strikes.strikes = 4
|
||||
strikes.burst_count = 14
|
||||
strikes.blast_radius = 72.0
|
||||
strikes.pattern_seed = 91
|
||||
strikes.speed = 150.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 150
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [ring, strikes]
|
||||
return p
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -69,6 +69,16 @@ static func choir_vault() -> PackedStringArray:
|
||||
])
|
||||
|
||||
|
||||
## Which boss each arena belongs to. Kept beside the stamps because the pairing
|
||||
## is a content decision, not a generation one: the Cantor's patterns assume
|
||||
## the vault's barricades the way the Warden's assume the hall's pits.
|
||||
static func boss_for_arena(arena: StringName) -> StringName:
|
||||
match arena:
|
||||
&"warden_hall": return Content.BOSS_WARDEN
|
||||
&"choir_vault": return Content.BOSS_CANTOR
|
||||
return Content.BOSS_WARDEN
|
||||
|
||||
|
||||
static func size_of(stamp: PackedStringArray) -> Vector2i:
|
||||
if stamp.is_empty():
|
||||
return Vector2i.ZERO
|
||||
@@ -102,17 +112,17 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
|
||||
## P a dungeon portal S player spawn T practice target
|
||||
## U the upgrade NPC
|
||||
##
|
||||
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER. Two
|
||||
## of them now: the real run and the Proving Grounds, side by side so they can
|
||||
## be compared without restarting anything. Adding a third dungeon means adding
|
||||
## a third marker here.
|
||||
## Each `P`, in reading order, opens the matching entry in Dungeons.ORDER: the
|
||||
## Warden's Descent, the Choir Vault, and the Proving Grounds. Adding a dungeon
|
||||
## means adding an entry there and a marker here, and they have to stay in the
|
||||
## same order.
|
||||
static func lobby() -> PackedStringArray:
|
||||
return PackedStringArray([
|
||||
"#########################################",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#..............P.........P..............#",
|
||||
"#.........P.........P.........P.........#",
|
||||
"#.......................................#",
|
||||
"#.....o...........................o.....#",
|
||||
"#.......................................#",
|
||||
|
||||
@@ -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, ""))
|
||||
@@ -0,0 +1 @@
|
||||
uid://drpadmmsrvoax
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://pdbmt03hxfum
|
||||
@@ -35,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0
|
||||
## the ship -- and on a remote client, the ship is also drawn a tick or two
|
||||
## ahead of the server, so this margin is what absorbs that too.
|
||||
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
|
||||
const PLAYER_MAX_HP := 100
|
||||
## Health and damage are deliberately an order of magnitude larger than the
|
||||
## numbers they started as. Every ratio in the game is unchanged -- enemy
|
||||
## health, boss health and every emitter's damage were scaled with them -- but
|
||||
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
|
||||
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
|
||||
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
|
||||
## Do not "tidy" these back down without scaling content.gd with them.
|
||||
const PLAYER_MAX_HP := 1000
|
||||
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
|
||||
const PLAYER_BULLET_SPEED := 620.0
|
||||
const PLAYER_BULLET_RADIUS := 4.0
|
||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||
const PLAYER_BULLET_DAMAGE := 6
|
||||
const PLAYER_BULLET_DAMAGE := 60
|
||||
## There are deliberately NO invulnerability frames after a hit. In a bullet
|
||||
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
|
||||
## into a single cheap hit -- you get punished for the first bullet and gifted
|
||||
@@ -132,6 +139,13 @@ const PARALLEL_OFFSET := 15.0
|
||||
const SPLIT_ANGLE_DEG := 45.0
|
||||
## How long one dose of Poison takes to deliver its damage.
|
||||
const POISON_DURATION_TICKS := 600 # 10 seconds
|
||||
## How near its preferred distance a CHASE boss counts as "there".
|
||||
##
|
||||
## Without a dead band the sign of the correction flips every tick once it
|
||||
## arrives, and the boss buzzes on the spot at 60Hz. Small in world units, and
|
||||
## unmistakable on screen.
|
||||
const BOSS_CHASE_DEADBAND := 8.0
|
||||
|
||||
## How close to the hub's upgrade NPC you must stand to spend a choice.
|
||||
## Enforced on the server, like the portal: standing somewhere is the only
|
||||
## thing a client cannot lie about.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -66,7 +66,9 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
|
||||
inst.depth = maxi(dungeon_depth, 1)
|
||||
inst.dungeon_id = dungeon if not dungeon.is_empty() else Dungeons.default_id()
|
||||
inst.world = SimWorld.new(dungeon_seed)
|
||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed, inst.depth)
|
||||
var flavour := Dungeons.get_or_default(inst.dungeon_id)
|
||||
var built := MapGen.build(Protocol.InstanceKind.DUNGEON, dungeon_seed,
|
||||
inst.depth, flavour.arena)
|
||||
inst.world.set_map(built["grid"])
|
||||
inst.rooms = built["rooms"]
|
||||
inst.boss_spawn = built["boss_pos"]
|
||||
@@ -75,7 +77,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
|
||||
# Arriving into a fight already in progress needs a moment of protection;
|
||||
# arriving in the hub does not.
|
||||
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
|
||||
inst.boss_id = Content.BOSS_WARDEN
|
||||
inst.boss_id = built["boss_id"]
|
||||
inst.state = State.FORMING
|
||||
inst._populate()
|
||||
return inst
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -10,8 +10,9 @@ extends RefCounted
|
||||
const MAX_LEVEL := 15
|
||||
const START_LEVEL := 1
|
||||
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
|
||||
## capped character has PLAYER_MAX_HP + 14 * this.
|
||||
const HP_PER_LEVEL := 10
|
||||
## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else
|
||||
## -- see the note on SimConfig.PLAYER_MAX_HP.
|
||||
const HP_PER_LEVEL := 100
|
||||
|
||||
## Experience for the first level-up. The curve is tuned so one full clear of a
|
||||
## depth-1 dungeon lands a little past this -- the first run should end with a
|
||||
|
||||
@@ -88,6 +88,11 @@ var selected_character: String = ""
|
||||
## characters yet" from "not asked yet".
|
||||
var characters_known: bool = false
|
||||
|
||||
## Attacks that have been announced but not yet landed, as
|
||||
## [{ "pos": Vector2, "r": float, "until": int, "ticks": int }]. Server-pushed;
|
||||
## the client never invents one and never decides one is over early.
|
||||
var telegraphs: Array[Dictionary] = []
|
||||
|
||||
## Whole seconds until a cleared dungeon returns the party, or
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
@@ -171,10 +176,24 @@ func _physics_process(delta: float) -> void:
|
||||
Net.send_input(NetCodec.encode_inputs(redundant))
|
||||
|
||||
world.step()
|
||||
_expire_telegraphs()
|
||||
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
|
||||
_maybe_bot_leave()
|
||||
|
||||
|
||||
## Drop warnings whose attack has already landed. Keyed on the estimated server
|
||||
## tick rather than on wall time, so a warning lasts exactly as long as the
|
||||
## server said it would however the frame rate is behaving.
|
||||
func _expire_telegraphs() -> void:
|
||||
if telegraphs.is_empty():
|
||||
return
|
||||
var live: Array[Dictionary] = []
|
||||
for t in telegraphs:
|
||||
if server_tick_est < int(t["until"]):
|
||||
live.append(t)
|
||||
telegraphs = live
|
||||
|
||||
|
||||
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
|
||||
## disconnect is caught by the same channel a SIGKILL is. Deferred because
|
||||
## Net.shutdown() frees this node.
|
||||
@@ -422,6 +441,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
request_respawn = false
|
||||
my_respawn_wait = 0.0
|
||||
my_inventory = []
|
||||
telegraphs = []
|
||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||
instance_changed.emit()
|
||||
@@ -533,6 +553,16 @@ func on_events(data: PackedByteArray) -> void:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
boss_died.emit()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
telegraphs.append({
|
||||
"pos": ev["pos"],
|
||||
"r": ev["r"],
|
||||
"ticks": maxi(int(ev["ticks"]), 1),
|
||||
# Aged by however long the packet spent in flight, the same
|
||||
# way a bullet is, so a warning drawn late still disappears
|
||||
# on time rather than lingering past its own attack.
|
||||
"until": int(packet["tick"]) + int(ev["ticks"]),
|
||||
})
|
||||
SimEvent.Type.ITEM_PICKED_UP:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_picked_up.emit(ev["item"])
|
||||
@@ -595,10 +625,27 @@ func inventory_full() -> bool:
|
||||
return true
|
||||
|
||||
|
||||
## The boss, interpolated between the last two snapshots exactly like every
|
||||
## other actor.
|
||||
##
|
||||
## It used to be handed back raw, which meant it jumped to each new snapshot the
|
||||
## moment it arrived -- a visible step three times slower than the frame rate.
|
||||
## Nobody noticed while every boss stood still. The first one that moved looked
|
||||
## broken.
|
||||
func boss_state() -> Dictionary:
|
||||
if snap_curr.is_empty() or snap_curr.get("boss") == null:
|
||||
return {}
|
||||
return snap_curr["boss"]
|
||||
var rec: Dictionary = (snap_curr["boss"] as Dictionary).duplicate()
|
||||
if snap_prev.is_empty() or snap_prev.get("boss") == null:
|
||||
return rec
|
||||
var old: Dictionary = snap_prev["boss"]
|
||||
# Only between the same boss. Across an instance change the previous
|
||||
# snapshot describes a different fight in a different room, and lerping to
|
||||
# it would fling the new boss across the map for one frame.
|
||||
if int(old["id"]) != int(rec["id"]):
|
||||
return rec
|
||||
rec["pos"] = (old["pos"] as Vector2).lerp(rec["pos"], _interp)
|
||||
return rec
|
||||
|
||||
|
||||
func _interpolated(list_key: String, id_key: String, exclude: Array) -> Array[Dictionary]:
|
||||
|
||||
@@ -258,6 +258,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
body.put_u32(ev["id"])
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
body.put_u8(clampi(int(ev["phase"]), 0, 255))
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
body.put_float(ev["pos"].x)
|
||||
body.put_float(ev["pos"].y)
|
||||
body.put_float(ev["r"])
|
||||
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
count += 1
|
||||
@@ -312,6 +317,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
ev["id"] = b.get_u32()
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
ev["phase"] = b.get_u8()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
||||
ev["r"] = b.get_float()
|
||||
ev["ticks"] = b.get_u16()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
out.append(ev)
|
||||
|
||||
@@ -21,7 +21,8 @@ extends RefCounted
|
||||
## the dungeon you are standing in, replacing the single portal position.
|
||||
## 8: upgrades. A new server -> client upgrade-state message, a new
|
||||
## client -> server choice message, and two more SelectResult values.
|
||||
const VERSION := 8
|
||||
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
|
||||
const VERSION := 9
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -19,10 +19,11 @@ extends RefCounted
|
||||
## Generation is still deterministic from (kind, seed, depth), which is what
|
||||
## makes a bad run reproducible from its log line; tests/unit/test_map_gen.gd
|
||||
## pins that.
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int) -> Dictionary:
|
||||
static func build(kind: Protocol.InstanceKind, seed_value: int, depth: int,
|
||||
arena: StringName = &"") -> Dictionary:
|
||||
if kind == Protocol.InstanceKind.LOBBY:
|
||||
return _build_lobby()
|
||||
return generate(seed_value, depth)
|
||||
return generate(seed_value, depth, arena)
|
||||
|
||||
|
||||
static func _build_lobby() -> Dictionary:
|
||||
@@ -65,12 +66,17 @@ static func _build_lobby() -> Dictionary:
|
||||
"npc": npc,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
"boss_id": &"",
|
||||
}
|
||||
|
||||
|
||||
## Result keys: grid, rooms (Array[Rect2i]), spawn (Vector2), boss_pos
|
||||
## (Vector2), boss_room (Rect2i).
|
||||
static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
## [param arena] names the boss arena to stamp, and therefore which boss the run
|
||||
## ends with. Empty leaves it to the seed, which is what the Proving Grounds
|
||||
## wants and nothing else does -- for a real dungeon the boss is the thing a
|
||||
## player chose at the portal.
|
||||
static func generate(seed_value: int, depth: int, arena: StringName = &"") -> Dictionary:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = seed_value
|
||||
var d := maxi(depth, 1)
|
||||
@@ -85,11 +91,17 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
||||
# Normally the dungeon says which arena it is; only the test harness leaves
|
||||
# it open, and then the seed decides.
|
||||
var chosen := arena
|
||||
if chosen.is_empty():
|
||||
chosen = &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
|
||||
var stamp := Rooms.warden_hall() if chosen == &"warden_hall" else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
var boss_room := Rect2i(boss_origin, bs)
|
||||
var arena_id := chosen
|
||||
|
||||
var boss_pos := grid.tile_centre(
|
||||
boss_origin.x + bs.x / 2, boss_origin.y + bs.y / 2)
|
||||
@@ -156,6 +168,10 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
"npc": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
||||
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||
# hall, where its patterns assume geometry that is not there.
|
||||
"boss_id": Rooms.boss_for_arena(arena_id),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ var rng: RandomNumberGenerator
|
||||
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
|
||||
|
||||
|
||||
## Where an emitter announces something before it happens. Points at the
|
||||
## owning world's event list; emitters fired into a bare pool by a test simply
|
||||
## append here and nobody reads it.
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
|
||||
## Warn that an attack will land at [param at] in [param ticks] ticks.
|
||||
##
|
||||
## The only thing an emitter can do besides spawn bullets, and it exists so a
|
||||
## strike that appears out of thin air is a *choice* the player was given time
|
||||
## to answer rather than an unavoidable hit.
|
||||
func telegraph(at: Vector2, warn_radius: float, ticks: int) -> void:
|
||||
events.append({
|
||||
"t": SimEvent.Type.TELEGRAPH,
|
||||
"pos": at,
|
||||
"r": warn_radius,
|
||||
"ticks": ticks,
|
||||
})
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
if not has_target:
|
||||
return PI * 0.5
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class_name TelegraphedStrikeEmitter
|
||||
extends BulletEmitter
|
||||
## Marks spots in the arena, then fills them with bullets a moment later.
|
||||
##
|
||||
## The whole point is the moment in between. A burst that appears at your feet
|
||||
## with no warning is not a pattern, it is a coin flip; the same burst with a
|
||||
## second of notice is a question about where you would rather be standing.
|
||||
## [member warn_ticks] is that second, and the client draws the marker from the
|
||||
## TELEGRAPH event this emits.
|
||||
##
|
||||
## Stateless, like every other emitter -- emitters are shared resources and two
|
||||
## bosses of the same kind must not stomp each other's timers. The strike
|
||||
## positions are therefore *derived* from the volley number rather than rolled
|
||||
## and remembered, so the announcement and the strike compute the same points
|
||||
## without anything being stored between them.
|
||||
|
||||
## Spots marked per volley.
|
||||
@export var strikes: int = 3
|
||||
## Ticks between the marker appearing and the burst landing. Must be shorter
|
||||
## than [member interval], or a volley would land after the next was announced.
|
||||
@export var warn_ticks: int = 75
|
||||
## Bullets in each burst, fired outwards from the marked spot.
|
||||
@export var burst_count: int = 12
|
||||
## Radius of the marked area, and the ring the burst is born on.
|
||||
@export var blast_radius: float = 64.0
|
||||
## Keeps strikes off the arena's walls, where half the burst would be wasted.
|
||||
@export var margin: float = 72.0
|
||||
## Distinguishes two strike emitters in one phase, which would otherwise derive
|
||||
## the same points and stack every burst on top of itself.
|
||||
@export var pattern_seed: int = 0
|
||||
|
||||
|
||||
## Armed on two ticks per volley: the announcement, and the strike itself.
|
||||
func should_fire(local_tick: int) -> bool:
|
||||
if not is_armed(local_tick) or interval <= 0:
|
||||
return false
|
||||
var into := posmod(local_tick - start_tick, interval)
|
||||
return into == 0 or into == posmod(warn_ticks, interval)
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if strikes <= 0 or interval <= 0:
|
||||
return
|
||||
var offset := ctx.local_tick - start_tick
|
||||
var volley := int(floor(float(offset) / float(interval)))
|
||||
var announcing := posmod(offset, interval) == 0
|
||||
for i in strikes:
|
||||
var at := strike_point(volley, i, ctx.bounds)
|
||||
if announcing:
|
||||
ctx.telegraph(at, blast_radius, warn_ticks)
|
||||
else:
|
||||
_burst(ctx, at)
|
||||
|
||||
|
||||
## The burst leaves the marked spot outwards, so standing on the marker is the
|
||||
## one place that is certainly wrong and the edge is survivable.
|
||||
func _burst(ctx: EmitContext, at: Vector2) -> void:
|
||||
if burst_count <= 0:
|
||||
return
|
||||
for i in burst_count:
|
||||
var a := TAU * (float(i) / float(burst_count))
|
||||
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
|
||||
|
||||
|
||||
## Where volley [param volley]'s strike [param index] lands, inside
|
||||
## [param bounds]. A pure function of its arguments: called once when the
|
||||
## warning goes up and again when the burst lands, and it has to agree with
|
||||
## itself both times without anything being remembered in between.
|
||||
func strike_point(volley: int, index: int, bounds: Rect2) -> Vector2:
|
||||
var inner := bounds.grow(-margin)
|
||||
if inner.size.x <= 0.0 or inner.size.y <= 0.0:
|
||||
inner = bounds
|
||||
return inner.position + Vector2(
|
||||
_unit(volley, index * 2) * inner.size.x,
|
||||
_unit(volley, index * 2 + 1) * inner.size.y)
|
||||
|
||||
|
||||
## A small integer hash in [0, 1). Not a good random number generator and does
|
||||
## not need to be -- it needs to be scattered, and it needs to give the same
|
||||
## answer twice.
|
||||
func _unit(a: int, b: int) -> float:
|
||||
var h := (a * 374761393 + b * 668265263 + pattern_seed * 2246822519) & 0x7FFFFFFF
|
||||
h = ((h ^ (h >> 13)) * 1274126177) & 0x7FFFFFFF
|
||||
return float((h >> 7) & 0xFFFF) / 65536.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkswilm3f3jc1
|
||||
@@ -15,6 +15,10 @@ var phase_tick: int = 0
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
## WAYPOINTS movement: which point it is walking to, and how long it still
|
||||
## stands at the one it reached.
|
||||
var waypoint_index: int = 0
|
||||
var waypoint_wait: int = 0
|
||||
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
|
||||
var poison: PoisonTrack = null
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@ enum Type {
|
||||
ITEM_PICKED_UP, ## peer, item
|
||||
ITEM_USED, ## peer, item
|
||||
ITEM_DROPPED, ## peer, item
|
||||
## An attack is about to land somewhere, and the player has to be shown
|
||||
## where before it does. Carries the spot, its radius and how long the
|
||||
## warning lasts; the client draws it and the server fires into it later.
|
||||
TELEGRAPH, ## pos, r, ticks
|
||||
}
|
||||
|
||||
@@ -568,11 +568,14 @@ func _step_boss() -> void:
|
||||
var phase := boss.current_phase()
|
||||
if phase == null:
|
||||
return
|
||||
# A boss never leaves its arena. Enforced here rather than left to each
|
||||
# boss's movement code, because boss rooms deliberately do not lock: the
|
||||
# player can always walk out, and the fight only stays a fight if the boss
|
||||
# cannot follow. Currently a no-op (every boss is stationary), which is
|
||||
# exactly when an invariant is cheapest to establish.
|
||||
|
||||
_move_boss(phase)
|
||||
|
||||
# A boss never leaves its arena. Enforced here rather than inside each
|
||||
# movement mode, because boss rooms deliberately do not lock: the player can
|
||||
# always walk out, and the fight only stays a fight if the boss cannot
|
||||
# follow. It was a no-op while every boss stood still, which is exactly when
|
||||
# an invariant is cheapest to establish -- now it is load-bearing.
|
||||
if boss.room.size != Vector2.ZERO:
|
||||
boss.pos = boss.room.position + Vector2(
|
||||
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
|
||||
@@ -584,6 +587,77 @@ func _step_boss() -> void:
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## One tick of boss movement, driven entirely by the phase's data. Every mode
|
||||
## here is generic: adding a boss that moves is writing a [BossPhase], not
|
||||
## touching this function.
|
||||
func _move_boss(phase: BossPhase) -> void:
|
||||
if not phase.moves():
|
||||
return
|
||||
var dt := SimConfig.TICK_DELTA
|
||||
var step := Vector2.ZERO
|
||||
match phase.move:
|
||||
BossPhase.Move.ORBIT:
|
||||
var centre := boss.room.get_center() if boss.room.size != Vector2.ZERO \
|
||||
else boss.def.spawn_pos
|
||||
var angle := float(boss.phase_tick) * dt \
|
||||
* (phase.move_speed / maxf(phase.move_param, 1.0))
|
||||
step = centre + Vector2.RIGHT.rotated(angle) * phase.move_param - boss.pos
|
||||
BossPhase.Move.CHASE:
|
||||
var quarry := nearest_player(boss.pos)
|
||||
if quarry == null:
|
||||
return
|
||||
var to_player := quarry.pos - boss.pos
|
||||
var gap := to_player.length()
|
||||
if gap < 0.001:
|
||||
return
|
||||
# Signed, so it backs off when you close inside its preferred
|
||||
# distance. A boss that ends up standing on you is a boss whose
|
||||
# bullets you cannot see coming.
|
||||
var error := gap - phase.move_param
|
||||
# Close enough. Without this the sign flips every tick once it
|
||||
# arrives and the boss buzzes on the spot at the tick rate.
|
||||
if absf(error) <= SimConfig.BOSS_CHASE_DEADBAND:
|
||||
return
|
||||
step = (to_player / gap) * signf(error) * phase.move_speed * dt
|
||||
BossPhase.Move.WAYPOINTS:
|
||||
if phase.waypoints.is_empty():
|
||||
return
|
||||
if boss.waypoint_wait > 0:
|
||||
boss.waypoint_wait -= 1
|
||||
return
|
||||
var goal := _waypoint_world(phase, boss.waypoint_index)
|
||||
var to_goal := goal - boss.pos
|
||||
if to_goal.length() <= phase.move_speed * dt:
|
||||
boss.pos = goal
|
||||
boss.waypoint_index = (boss.waypoint_index + 1) % phase.waypoints.size()
|
||||
boss.waypoint_wait = phase.waypoint_dwell
|
||||
return
|
||||
step = to_goal
|
||||
_:
|
||||
return
|
||||
# Clamped here rather than in each branch, so no mode can teleport: ORBIT in
|
||||
# particular computes an absolute destination and would otherwise snap to
|
||||
# its circle on the first tick of the phase.
|
||||
var reach := phase.move_speed * dt
|
||||
if step.length() > reach:
|
||||
step = step.normalized() * reach
|
||||
# Slid rather than assigned, so a boss cannot walk through the pillars its
|
||||
# own arena was designed around.
|
||||
boss.pos = map.slide_circle(boss.pos, step, boss.def.radius)
|
||||
|
||||
|
||||
## A phase waypoint, given as a fraction of the arena, in world coordinates.
|
||||
## Fractions rather than absolutes so one phase can be dropped into any room --
|
||||
## the Warden's hall and the Choir Vault are different sizes.
|
||||
func _waypoint_world(phase: BossPhase, index: int) -> Vector2:
|
||||
var w: Vector2 = phase.waypoints[index]
|
||||
if boss.room.size == Vector2.ZERO:
|
||||
return boss.def.spawn_pos
|
||||
return boss.room.position + Vector2(
|
||||
clampf(w.x, 0.0, 1.0) * boss.room.size.x,
|
||||
clampf(w.y, 0.0, 1.0) * boss.room.size.y)
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
@@ -592,6 +666,9 @@ func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick:
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
# Re-pointed every call: drain_events() replaces the array wholesale, so a
|
||||
# context holding the old one would announce into a list nobody reads.
|
||||
_ctx.events = events
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
@@ -746,6 +823,8 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||
## from the snapshot anyway. Death is still announced either way, because that
|
||||
## is what the experience award is keyed on.
|
||||
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
if e.def.indestructible:
|
||||
return
|
||||
e.hp = maxi(e.hp - amount, 0)
|
||||
if not silent:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo3xikvfahke5
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://m6dfka8r5y2o
|
||||
@@ -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
|
||||
|
||||
@@ -97,7 +97,18 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
Rect2(368, 40, 16, 16), # goblin -> stalker
|
||||
Rect2(288, 336, 16, 16), # red flask -> practice target
|
||||
]
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
## BossDef.visual -> first idle frame. Same convention as ENEMY_IDLE.
|
||||
const BOSS_IDLE_FRAMES: Array[Rect2] = [
|
||||
Rect2(16, 428, 32, 36), # big demon -> the Warden of the Fold
|
||||
Rect2(144, 428, 32, 36), # big ogre -> the Cantor of the Vault
|
||||
]
|
||||
## The first boss's strip, kept as a name because plenty of code and tests read
|
||||
## "the boss sprite" and only one of them cares which boss.
|
||||
const BOSS_IDLE := BOSS_IDLE_FRAMES[0]
|
||||
|
||||
|
||||
static func boss_idle(visual: int) -> Rect2:
|
||||
return BOSS_IDLE_FRAMES[clampi(visual, 0, BOSS_IDLE_FRAMES.size() - 1)]
|
||||
## The hub's quartermaster. A different character strip from the knight the
|
||||
## player wears, so an NPC never reads as another player standing still.
|
||||
const NPC_IDLE := Rect2(128, 36, 16, 28)
|
||||
@@ -129,8 +140,19 @@ static func item_icon(item: StringName) -> Rect2:
|
||||
# still, which is exactly what it looked like on screen.
|
||||
const BULLET_CELL := 16.0
|
||||
const BULLET_FRAMES := 8
|
||||
## Seconds per bullet animation frame.
|
||||
const BULLET_FRAME_SECONDS := 0.08
|
||||
## Which of the sheet's frames is actually drawn.
|
||||
##
|
||||
## One, and always the same one. The sheet's eight frames are a COLOUR cycle
|
||||
## rather than a shape change, and cycling them put a few hundred bullets on
|
||||
## screen all strobing through a palette together -- which is exactly as hard to
|
||||
## look at as it sounds. The bullets turn slowly instead, which reads as motion
|
||||
## without flickering. The other seven frames stay in the atlas because that is
|
||||
## what the source art is; nothing draws them.
|
||||
const BULLET_STILL_FRAME := 0
|
||||
## Radians per second a bullet turns as it travels. Deliberately slow: at this
|
||||
## rate nothing completes a full turn inside its own lifetime, so the effect is
|
||||
## a drift rather than a spin.
|
||||
const BULLET_SPIN_RATE := 0.55
|
||||
|
||||
|
||||
## Region for a bullet kind at an animation frame.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddaj0c8xfjitx
|
||||
@@ -63,6 +63,7 @@ func _draw() -> void:
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
_draw_telegraphs()
|
||||
for p in client.remote_players():
|
||||
if _visible(p["pos"]):
|
||||
_draw_remote_player(p)
|
||||
@@ -95,8 +96,7 @@ func _draw_debug() -> void:
|
||||
## Aggro radius by visual index. Read from the content definitions rather than
|
||||
## hardcoded, so the overlay cannot drift from what the server actually uses.
|
||||
func _aggro_for(visual: int) -> float:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
var def := Content.enemy(id)
|
||||
if def.visual == visual:
|
||||
return def.aggro_range if not def.emitters.is_empty() else 0.0
|
||||
@@ -254,12 +254,35 @@ func _draw_enemy(e: Dictionary) -> void:
|
||||
Color(0.95, 0.55, 0.55, 0.35), 1.5)
|
||||
|
||||
|
||||
## Attacks that have been announced but not yet landed.
|
||||
##
|
||||
## Drawn through fog and through walls, deliberately. Everything else in this
|
||||
## view respects line of sight, but a warning you cannot see is not a warning --
|
||||
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
|
||||
## exist is to make the strike a decision. The marker is inside the boss arena
|
||||
## you are already standing in, so this hides nothing a player could not walk
|
||||
## two paces and see.
|
||||
func _draw_telegraphs() -> void:
|
||||
for t in client.telegraphs:
|
||||
var at: Vector2 = t["pos"]
|
||||
var r: float = t["r"]
|
||||
var left := float(int(t["until"]) - client.server_tick_est)
|
||||
var progress := clampf(1.0 - left / float(int(t["ticks"])), 0.0, 1.0)
|
||||
# Fills up as the moment approaches, so the warning reads as a clock and
|
||||
# not merely as a place.
|
||||
draw_circle(at, r, Color(1.0, 0.35, 0.3, 0.10 + 0.14 * progress))
|
||||
draw_arc(at, r, -PI * 0.5, -PI * 0.5 + TAU * progress, 40,
|
||||
Color(1.0, 0.5, 0.35, 0.9), 3.0)
|
||||
draw_arc(at, r, 0.0, TAU, 40, Color(1.0, 0.45, 0.35, 0.35), 1.5)
|
||||
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var pos: Vector2 = b["pos"]
|
||||
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
|
||||
var visual := client.boss_def.visual if client.boss_def != null else 0
|
||||
var src := Art.frame(Art.boss_idle(visual), Art.anim_frame(_anim_time, 0))
|
||||
_draw_sprite(Art.TILESET, src, pos)
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
|
||||
|
||||
@@ -47,6 +47,9 @@ func test_every_animation_frame_is_inside_the_tileset() -> void:
|
||||
"frame %d of strip %s runs off the atlas at %s" % [n, strip, f])
|
||||
|
||||
|
||||
## Only BULLET_STILL_FRAME is ever drawn, but the whole sheet still has to be
|
||||
## the shape the atlas builder produced -- a short one would mean the still
|
||||
## frame of a later kind is cut from the wrong row.
|
||||
func test_every_bullet_frame_is_inside_the_sheet() -> void:
|
||||
var sheet := Art.bullets_texture()
|
||||
if sheet == null:
|
||||
@@ -59,6 +62,32 @@ func test_every_bullet_frame_is_inside_the_sheet() -> void:
|
||||
"bullet kind %d frame %d is outside the sheet at %s" % [kind, n, r])
|
||||
|
||||
|
||||
func test_the_still_frame_exists_for_every_kind() -> void:
|
||||
var sheet := Art.bullets_texture()
|
||||
if sheet == null:
|
||||
pass_test("bullet sheet not present locally")
|
||||
return
|
||||
assert_lt(Art.BULLET_STILL_FRAME, Art.BULLET_FRAMES)
|
||||
for kind in SimConfig.KIND_HEAVY + 1:
|
||||
var r := Art.bullet_region(kind, Art.BULLET_STILL_FRAME)
|
||||
assert_true(_fits(sheet, r), "kind %d has no still frame" % kind)
|
||||
|
||||
|
||||
## Slow enough that nothing completes a turn inside its own lifetime. A bullet
|
||||
## that visibly spins is the flicker this replaced, wearing a different hat.
|
||||
func test_bullets_turn_slowly_enough_to_read_as_drift() -> void:
|
||||
var longest := float(SimConfig.PLAYER_BULLET_LIFETIME)
|
||||
for id in Content.ALL_ENEMIES:
|
||||
for e in Content.enemy(id).emitters:
|
||||
longest = maxf(longest, float(e.lifetime))
|
||||
for id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(id).phases:
|
||||
for e in phase.emitters:
|
||||
longest = maxf(longest, float(e.lifetime))
|
||||
var turn := Art.BULLET_SPIN_RATE * longest * SimConfig.TICK_DELTA
|
||||
assert_lt(turn, TAU, "the longest-lived bullet turns %.2f rad in its life" % turn)
|
||||
|
||||
|
||||
## The atlas has one row per kind. A short sheet would silently draw the wrong
|
||||
## bullet rather than error.
|
||||
func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
|
||||
@@ -75,8 +104,7 @@ func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
|
||||
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
|
||||
## the wrong creature.
|
||||
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
var v := Content.enemy(id).visual
|
||||
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
|
||||
|
||||
|
||||
@@ -113,3 +113,130 @@ func test_a_brand_new_boss_needs_no_engine_changes() -> void:
|
||||
world.spawn_boss(def)
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 6)
|
||||
|
||||
|
||||
# --- The second boss --------------------------------------------------------
|
||||
|
||||
func test_the_cantor_is_registered_and_whole() -> void:
|
||||
var b := Content.boss(Content.BOSS_CANTOR)
|
||||
assert_eq(b.id, Content.BOSS_CANTOR)
|
||||
assert_gt(b.max_hp, 0)
|
||||
assert_gt(b.phases.size(), 1)
|
||||
for phase in b.phases:
|
||||
assert_gt(phase.emitters.size(), 0, "%s fires nothing" % phase.name)
|
||||
assert_gt(b.loot.size(), 0, "a boss kill has to be worth something")
|
||||
|
||||
|
||||
## Phases are picked by "the last one whose threshold still covers this hp
|
||||
## fraction", so a list that is not sorted downwards silently skips phases.
|
||||
func test_every_boss_lists_its_phases_from_full_health_downwards() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var previous := 2.0
|
||||
for phase in Content.boss(id).phases:
|
||||
assert_lt(phase.enter_at_hp_fraction, previous,
|
||||
"%s: %s is not below the phase before it" % [id, phase.name])
|
||||
previous = phase.enter_at_hp_fraction
|
||||
|
||||
|
||||
func test_every_boss_reaches_all_of_its_phases() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var def := Content.boss(id)
|
||||
var seen := {}
|
||||
for step_index in 101:
|
||||
seen[def.phase_index_for(float(step_index) / 100.0)] = true
|
||||
assert_eq(seen.size(), def.phases.size(),
|
||||
"%s has a phase that no health fraction selects" % id)
|
||||
|
||||
|
||||
func test_there_is_a_sprite_for_every_boss() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var def := Content.boss(id)
|
||||
assert_lt(def.visual, Art.BOSS_IDLE_FRAMES.size(),
|
||||
"%s has visual %d with no sprite" % [id, def.visual])
|
||||
for n in Art.ACTOR_FRAMES:
|
||||
var f := Art.frame(Art.boss_idle(def.visual), n)
|
||||
assert_lte(f.end.x, float(Art.TILESET.get_width()))
|
||||
assert_lte(f.end.y, float(Art.TILESET.get_height()))
|
||||
|
||||
|
||||
func test_the_two_bosses_look_different() -> void:
|
||||
assert_ne(Content.warden().visual, Content.cantor().visual)
|
||||
|
||||
|
||||
## The Cantor's patterns assume the vault's barricades the way the Warden's
|
||||
## assume the hall's pits, so which boss appears has to follow from which arena
|
||||
## was stamped rather than being chosen separately.
|
||||
## Every id in the registry has to resolve. The lists exist so nothing has to
|
||||
## be hand-maintained in five places; this is what keeps them honest.
|
||||
func test_the_registry_lists_resolve() -> void:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
assert_eq(Content.enemy(id).id, id)
|
||||
for id in Content.ALL_BOSSES:
|
||||
assert_eq(Content.boss(id).id, id)
|
||||
|
||||
|
||||
func test_each_arena_summons_its_own_boss() -> void:
|
||||
assert_eq(Rooms.boss_for_arena(&"warden_hall"), Content.BOSS_WARDEN)
|
||||
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
|
||||
|
||||
|
||||
## The arena a dungeon names decides its boss, whatever the seed. Which boss you
|
||||
## are about to fight is the one thing a player chooses before walking into a
|
||||
## dungeon, so it must not be rolled for them.
|
||||
func test_the_named_arena_decides_the_boss_whatever_the_seed() -> void:
|
||||
for run in 12:
|
||||
var seed_value := run * 7919 + 3
|
||||
assert_eq(StringName(MapGen.generate(seed_value, 1, &"warden_hall")["boss_id"]),
|
||||
Content.BOSS_WARDEN)
|
||||
assert_eq(StringName(MapGen.generate(seed_value, 1, &"choir_vault")["boss_id"]),
|
||||
Content.BOSS_CANTOR)
|
||||
|
||||
|
||||
## Only the test harness leaves its arena open, and then the seed decides. Both
|
||||
## fights still have to turn up there, or half the harness is unreachable.
|
||||
func test_an_unnamed_arena_still_produces_both_fights() -> void:
|
||||
var seen := {}
|
||||
for run in 40:
|
||||
seen[StringName(MapGen.generate(run * 7919 + 3, 1)["boss_id"])] = true
|
||||
for id in Content.ALL_BOSSES:
|
||||
assert_true(seen.has(id), "%s never appears with the arena left open" % id)
|
||||
|
||||
|
||||
## The real check that a boss is reachable in play: some dungeon in the hub ends
|
||||
## with it. Adding a boss and forgetting to give it a way in is exactly the
|
||||
## mistake this catches.
|
||||
func test_every_boss_has_a_dungeon_that_reaches_it() -> void:
|
||||
var reachable := {}
|
||||
for dungeon_id in Dungeons.ORDER:
|
||||
var arena := Dungeons.get_def(dungeon_id).arena
|
||||
if arena.is_empty():
|
||||
continue # the harness rolls; covered above
|
||||
reachable[Rooms.boss_for_arena(arena)] = true
|
||||
for id in Content.ALL_BOSSES:
|
||||
assert_true(reachable.has(id), "no hub portal leads to %s" % id)
|
||||
|
||||
|
||||
func test_an_instance_spawns_the_boss_its_dungeon_asked_for() -> void:
|
||||
for pair in [[Dungeons.STANDARD, Content.BOSS_WARDEN],
|
||||
[Dungeons.VAULT, Content.BOSS_CANTOR]]:
|
||||
var inst := Instance.make_dungeon(2, 4321, 1, pair[0])
|
||||
assert_eq(inst.boss_id, pair[1], "%s should end with %s" % pair)
|
||||
assert_eq(inst.world.boss.def.id, pair[1])
|
||||
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
|
||||
"and it starts inside its own arena")
|
||||
|
||||
|
||||
## The Cantor exists to prove the boss format stretched to movement and
|
||||
## telegraphs. If it stopped using either, it would have stopped doing its job.
|
||||
func test_the_cantor_actually_uses_both_new_mechanisms() -> void:
|
||||
var def := Content.cantor()
|
||||
var moves := false
|
||||
var telegraphs := false
|
||||
for phase in def.phases:
|
||||
if phase.moves():
|
||||
moves = true
|
||||
for e in phase.emitters:
|
||||
if e is TelegraphedStrikeEmitter:
|
||||
telegraphs = true
|
||||
assert_true(moves, "the Cantor should move")
|
||||
assert_true(telegraphs, "and should telegraph")
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
extends GutTest
|
||||
## Bosses that move. Movement is a property of the PHASE, so all of this is
|
||||
## driven by building a BossPhase and stepping the world -- if any of it needed
|
||||
## a per-boss branch in SimWorld, the boss format would have stopped being data.
|
||||
|
||||
var world: SimWorld
|
||||
var boss: SimBoss
|
||||
|
||||
const ROOM := Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
var map := MapGrid.new(60, 40, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 58, 38), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
world = SimWorld.new(3)
|
||||
world.set_map(map)
|
||||
|
||||
|
||||
func _with_phase(phase: BossPhase, at := Vector2.ZERO) -> SimBoss:
|
||||
var def := BossDef.new()
|
||||
def.id = &"test_boss"
|
||||
def.max_hp = 1000
|
||||
def.radius = 30.0
|
||||
def.phases = [phase]
|
||||
boss = world.spawn_boss(def)
|
||||
boss.pos = at
|
||||
boss.room = ROOM
|
||||
return boss
|
||||
|
||||
|
||||
func _static_phase() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.enter_at_hp_fraction = 1.0
|
||||
p.telegraph_ticks = 0
|
||||
p.loop_ticks = 600
|
||||
return p
|
||||
|
||||
|
||||
func _step(n: int) -> void:
|
||||
for _i in n:
|
||||
world.step()
|
||||
|
||||
|
||||
# --- The derived "does it move" flag ----------------------------------------
|
||||
|
||||
func test_a_phase_with_no_mode_or_no_speed_does_not_move() -> void:
|
||||
var p := _static_phase()
|
||||
assert_false(p.moves())
|
||||
p.move = BossPhase.Move.CHASE
|
||||
assert_false(p.moves(), "a mode with no speed is still standing still")
|
||||
p.move_speed = 60.0
|
||||
assert_true(p.moves())
|
||||
|
||||
|
||||
## Derived from the phases rather than stored, so a boss cannot claim to be
|
||||
## stationary while one of its phases walks around.
|
||||
func test_a_boss_moves_if_any_of_its_phases_does() -> void:
|
||||
assert_false(Content.warden().moves(), "the Warden still stands still")
|
||||
assert_true(Content.cantor().moves(), "the Cantor does not")
|
||||
|
||||
|
||||
func test_a_static_phase_leaves_the_boss_exactly_where_it_was() -> void:
|
||||
var b := _with_phase(_static_phase(), Vector2(50.0, 20.0))
|
||||
_step(120)
|
||||
assert_eq(b.pos, Vector2(50.0, 20.0))
|
||||
|
||||
|
||||
# --- Waypoints ---------------------------------------------------------------
|
||||
|
||||
func _waypoint_phase() -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.WAYPOINTS
|
||||
p.move_speed = 200.0
|
||||
p.waypoint_dwell = 30
|
||||
p.waypoints = [Vector2(0.0, 0.0), Vector2(1.0, 0.0)]
|
||||
return p
|
||||
|
||||
|
||||
func test_a_waypoint_boss_walks_to_its_first_point() -> void:
|
||||
var b := _with_phase(_waypoint_phase(), ROOM.get_center())
|
||||
var corner := ROOM.position
|
||||
var before := b.pos.distance_to(corner)
|
||||
_step(30)
|
||||
assert_lt(b.pos.distance_to(corner), before, "it should be closing")
|
||||
|
||||
|
||||
func test_it_arrives_dwells_and_moves_on() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
var b := _with_phase(phase, ROOM.position)
|
||||
_step(2)
|
||||
assert_eq(b.waypoint_index, 1, "standing on the first point advances it")
|
||||
var held := b.pos
|
||||
_step(phase.waypoint_dwell - 4)
|
||||
assert_eq(b.pos, held, "and it waits there rather than setting off at once")
|
||||
_step(60)
|
||||
assert_ne(b.pos, held, "then it goes")
|
||||
|
||||
|
||||
## Fractions of the arena, not absolute positions -- the Warden's hall and the
|
||||
## Choir Vault are different sizes, and one phase has to work in either.
|
||||
func test_waypoints_are_fractions_of_the_room() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
phase.waypoints = [Vector2(0.5, 0.5)]
|
||||
var b := _with_phase(phase, ROOM.position)
|
||||
_step(200)
|
||||
assert_almost_eq(b.pos.x, ROOM.get_center().x, 4.0)
|
||||
assert_almost_eq(b.pos.y, ROOM.get_center().y, 4.0)
|
||||
|
||||
|
||||
func test_a_waypoint_phase_with_no_points_stands_still() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
phase.waypoints = []
|
||||
var b := _with_phase(phase, Vector2(10.0, 10.0))
|
||||
_step(60)
|
||||
assert_eq(b.pos, Vector2(10.0, 10.0))
|
||||
|
||||
|
||||
# --- Chase -------------------------------------------------------------------
|
||||
|
||||
func _chase_phase(standoff: float) -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.CHASE
|
||||
p.move_speed = 180.0
|
||||
p.move_param = standoff
|
||||
return p
|
||||
|
||||
|
||||
func test_it_closes_when_you_are_far_away() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(250.0, 0.0)
|
||||
var before := b.pos.distance_to(p.pos)
|
||||
_step(60)
|
||||
assert_lt(b.pos.distance_to(p.pos), before)
|
||||
|
||||
|
||||
## Backs off rather than piling onto you. A boss standing on top of a player is
|
||||
## a boss whose bullets cannot be read, which is the one thing this genre
|
||||
## cannot afford.
|
||||
func test_it_backs_off_when_you_get_too_close() -> void:
|
||||
var b := _with_phase(_chase_phase(200.0), Vector2.ZERO)
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(20.0, 0.0)
|
||||
_step(60)
|
||||
assert_gt(b.pos.distance_to(p.pos), 20.0)
|
||||
|
||||
|
||||
func test_it_settles_at_the_distance_it_was_given() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(100.0, 0.0)
|
||||
_step(240)
|
||||
assert_almost_eq(b.pos.distance_to(p.pos), 150.0, 12.0)
|
||||
|
||||
|
||||
func test_it_stands_still_with_nobody_to_chase() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(40.0, 0.0))
|
||||
_step(60)
|
||||
assert_eq(b.pos, Vector2(40.0, 0.0))
|
||||
|
||||
|
||||
# --- Orbit -------------------------------------------------------------------
|
||||
|
||||
func _orbit_phase() -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.ORBIT
|
||||
p.move_speed = 160.0
|
||||
p.move_param = 120.0
|
||||
return p
|
||||
|
||||
|
||||
## ORBIT computes an absolute destination, so without the shared speed clamp it
|
||||
## would snap onto its circle on the very first tick.
|
||||
func test_orbiting_never_teleports_onto_the_circle() -> void:
|
||||
var phase := _orbit_phase()
|
||||
var b := _with_phase(phase, ROOM.get_center() + Vector2(280.0, 0.0))
|
||||
var before := b.pos
|
||||
world.step()
|
||||
assert_lte(b.pos.distance_to(before), phase.move_speed * SimConfig.TICK_DELTA + 0.5,
|
||||
"one tick may move it at most one tick's worth")
|
||||
|
||||
|
||||
func test_orbiting_ends_up_on_the_circle_and_keeps_going() -> void:
|
||||
var phase := _orbit_phase()
|
||||
var b := _with_phase(phase, ROOM.get_center())
|
||||
_step(300)
|
||||
var centre := ROOM.get_center()
|
||||
assert_almost_eq(b.pos.distance_to(centre), phase.move_param, 25.0)
|
||||
var somewhere := b.pos
|
||||
_step(90)
|
||||
assert_gt(b.pos.distance_to(somewhere), 20.0, "and it is still travelling")
|
||||
|
||||
|
||||
# --- The invariants movement had to not break --------------------------------
|
||||
|
||||
## Boss rooms deliberately do not lock: a player can always walk out. That only
|
||||
## works as an escape if the boss cannot follow.
|
||||
func test_a_moving_boss_never_leaves_its_arena() -> void:
|
||||
var phase := _chase_phase(0.0)
|
||||
phase.move_speed = 400.0
|
||||
var b := _with_phase(phase, ROOM.get_center())
|
||||
var p := world.add_player(1, "bait")
|
||||
for step_index in 400:
|
||||
# Drag the bait right out of the room and around the map.
|
||||
p.pos = Vector2(900.0, 500.0).rotated(float(step_index) * 0.05)
|
||||
world.step()
|
||||
assert_true(ROOM.has_point(b.pos) or ROOM.abs().grow(1.0).has_point(b.pos),
|
||||
"the boss left its arena at %s" % b.pos)
|
||||
|
||||
|
||||
## A single pillar proves nothing -- the boss would end up at its quarry either
|
||||
## way, and only the final position was ever checked. A wall it cannot go round
|
||||
## is the test: if movement ignores geometry the boss simply appears on the far
|
||||
## side of it.
|
||||
func test_a_moving_boss_does_not_walk_through_geometry() -> void:
|
||||
var wall_x := world.map.to_tile(Vector2.ZERO).x
|
||||
for ty in range(world.map.to_tile(Vector2(0.0, -260.0)).y,
|
||||
world.map.to_tile(Vector2(0.0, 260.0)).y + 1):
|
||||
world.map.set_tile(wall_x, ty, MapGrid.Kind.WALL)
|
||||
|
||||
var b := _with_phase(_chase_phase(0.0), Vector2(-200.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(200.0, 0.0)
|
||||
for _i in 300:
|
||||
world.step()
|
||||
assert_false(world.map.circle_blocked(b.pos, 2.0),
|
||||
"the boss stepped inside solid geometry at %s" % b.pos)
|
||||
assert_lt(b.pos.x, 0.0,
|
||||
"the wall spans the arena, so the boss must still be on its own side")
|
||||
|
||||
|
||||
## The replica draws bosses from snapshots. If it moved one itself, the drawn
|
||||
## boss and the authoritative one would drift apart with nothing to correct it.
|
||||
func test_a_replica_never_moves_a_boss() -> void:
|
||||
var replica := SimWorld.new(3)
|
||||
replica.authoritative = false
|
||||
var def := BossDef.new()
|
||||
def.max_hp = 1000
|
||||
def.radius = 30.0
|
||||
def.phases = [_waypoint_phase()]
|
||||
var b := replica.spawn_boss(def)
|
||||
b.pos = Vector2(77.0, -33.0)
|
||||
b.room = ROOM
|
||||
for _i in 200:
|
||||
replica.step()
|
||||
assert_eq(b.pos, Vector2(77.0, -33.0))
|
||||
|
||||
|
||||
# --- The real fight ---------------------------------------------------------
|
||||
|
||||
## Drives the actual Cantor through every phase in its own arena. Nothing here
|
||||
## asserts a specific pattern -- the point is that a boss which moves, walks a
|
||||
## circuit, chases, orbits and telegraphs runs for thousands of ticks without
|
||||
## leaving its room, standing in a wall, or firing nothing.
|
||||
func test_the_cantor_survives_its_own_fight() -> void:
|
||||
var inst := Instance.make_dungeon(2, 9183, 1, Dungeons.VAULT)
|
||||
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: the vault is the Cantor's")
|
||||
var b := inst.world.boss
|
||||
var bait := inst.world.add_player(1, "bait")
|
||||
bait.pos = b.pos + Vector2(180.0, 0.0)
|
||||
bait.spawn_grace = 1000000 # watching, not fighting
|
||||
|
||||
var phases_seen := {}
|
||||
var telegraphs := 0
|
||||
var full := b.def.max_hp
|
||||
for stage in b.def.phases.size():
|
||||
# Set health to each phase's own threshold rather than to fractions
|
||||
# picked by hand -- guessing them missed the last phase entirely, and
|
||||
# would go stale the moment the fight was retuned.
|
||||
b.hp = maxi(roundi(float(full) * b.def.phases[stage].enter_at_hp_fraction), 1)
|
||||
for _i in 600:
|
||||
inst.step()
|
||||
phases_seen[b.phase_index] = true
|
||||
assert_true(b.room.grow(1.0).has_point(b.pos),
|
||||
"the Cantor left its arena at %s" % b.pos)
|
||||
assert_false(inst.world.map.circle_blocked(b.pos, 2.0),
|
||||
"the Cantor stood inside geometry at %s" % b.pos)
|
||||
for ev in inst.world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
||||
telegraphs += 1
|
||||
inst.world.drain_events()
|
||||
|
||||
assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran")
|
||||
assert_gt(telegraphs, 0, "and it warned before striking at least once")
|
||||
assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting")
|
||||
|
||||
|
||||
# --- Not looking jittery ----------------------------------------------------
|
||||
|
||||
## Once it has arrived it stays arrived. The correction is signed, so without a
|
||||
## dead band the sign flips every tick at the standoff distance and the boss
|
||||
## vibrates on the spot -- tiny in world units, unmistakable on screen.
|
||||
func test_a_chasing_boss_settles_instead_of_buzzing() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(100.0, 0.0)
|
||||
_step(300)
|
||||
var settled := b.pos
|
||||
var worst := 0.0
|
||||
for _i in 120:
|
||||
world.step()
|
||||
worst = maxf(worst, b.pos.distance_to(settled))
|
||||
assert_lt(worst, 0.001, "it moved %.2fpx after settling" % worst)
|
||||
|
||||
|
||||
func test_the_dead_band_is_smaller_than_the_distance_it_guards() -> void:
|
||||
# A band as wide as the standoff would mean the boss stops anywhere.
|
||||
for phase in Content.cantor().phases:
|
||||
if phase.move == BossPhase.Move.CHASE:
|
||||
assert_lt(SimConfig.BOSS_CHASE_DEADBAND, phase.move_param * 0.25)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dreuvmvux87rj
|
||||
@@ -103,10 +103,11 @@ func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
||||
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
var emitters: Array[BulletEmitter] = []
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
emitters.append_array(Content.enemy(id).emitters)
|
||||
for phase in Content.warden().phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for boss_id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(boss_id).phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for e in emitters:
|
||||
# Accelerating bullets reach their top speed at the end of their life.
|
||||
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
|
||||
|
||||
@@ -76,3 +76,74 @@ func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
|
||||
world.step()
|
||||
assert_lt(world.pool.live_count, 12,
|
||||
"a short lifetime should keep spent point-blank shots from accumulating")
|
||||
|
||||
|
||||
# --- The scale of the numbers -----------------------------------------------
|
||||
|
||||
## Health and damage were multiplied by ten so that percentage modifiers have
|
||||
## somewhere to land: at the old base of 6 damage, the +5% every upgrade carries
|
||||
## rounded straight back to 6 and a player's first upgrade did nothing visible.
|
||||
## These tests pin the properties that made the rescale worth doing, and the
|
||||
## ratios it had to leave alone.
|
||||
func test_a_single_upgrade_visibly_changes_damage() -> void:
|
||||
var none := PlayerStats.build([] as Array[StringName])
|
||||
for id in Upgrades.ORDER:
|
||||
var one := PlayerStats.build([id] as Array[StringName])
|
||||
assert_ne(one.damage, none.damage,
|
||||
"%s carries +%d%% damage and must not round away" % [
|
||||
id, roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)])
|
||||
|
||||
|
||||
## Time to kill is the balance-relevant number, and the rescale was explicitly
|
||||
## not allowed to change it. Written as shots rather than as a ratio so a
|
||||
## careless edit to either side shows up as a number a designer recognises.
|
||||
func test_shots_to_kill_is_what_it_was_before_the_rescale() -> void:
|
||||
var expected := {
|
||||
Content.ENEMY_DRIFTER: 7, # 400 hp / 60
|
||||
Content.ENEMY_TURRET: 12, # 700 hp / 60
|
||||
Content.ENEMY_STALKER: 5, # 300 hp / 60
|
||||
}
|
||||
for id in expected:
|
||||
var def := Content.enemy(id)
|
||||
var shots := ceili(float(def.max_hp) / float(SimConfig.PLAYER_BULLET_DAMAGE))
|
||||
assert_eq(shots, int(expected[id]), "%s takes %d shots" % [id, shots])
|
||||
|
||||
|
||||
## Likewise for how long an unarmoured player survives standing in the open.
|
||||
func test_hits_to_kill_a_player_is_what_it_was() -> void:
|
||||
var base := SimConfig.PLAYER_MAX_HP
|
||||
var worst := 0
|
||||
for id in HOSTILES:
|
||||
for e in Content.enemy(id).emitters:
|
||||
worst = maxi(worst, e.damage)
|
||||
assert_eq(worst, 140, "the stalker's point-blank pellet is still the hardest hit")
|
||||
assert_eq(base / worst, 7, "and still takes this many to drop a fresh character")
|
||||
|
||||
|
||||
## Enemy health rides the snapshot as a u16. Anything above that is silently
|
||||
## misreported rather than rejected, which is exactly the kind of bug that
|
||||
## survives a rescale unnoticed -- the old practice dummy was already past it.
|
||||
func test_every_enemy_fits_the_health_field_the_wire_gives_it() -> void:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id)
|
||||
|
||||
|
||||
## A levelled character's health rides the same kind of field.
|
||||
func test_a_capped_character_fits_the_health_field_too() -> void:
|
||||
assert_lte(Progression.max_hp_for_level(Progression.MAX_LEVEL), 65535)
|
||||
|
||||
|
||||
## The hub's practice target has to still be there tomorrow. Expressed as a
|
||||
## flag, so a patient player cannot wear it down and leave the hub without one.
|
||||
func test_the_practice_dummy_cannot_be_destroyed() -> void:
|
||||
var world := SimWorld.new(1)
|
||||
var e := world.spawn_enemy(Content.dummy(), Vector2(200.0, 0.0))
|
||||
world._damage_enemy(e, 10_000_000)
|
||||
assert_true(e.alive)
|
||||
assert_eq(e.hp, e.def.max_hp, "and takes no damage at all")
|
||||
|
||||
|
||||
func test_nothing_else_is_indestructible() -> void:
|
||||
for id in HOSTILES:
|
||||
assert_false(Content.enemy(id).indestructible,
|
||||
"%s must be killable, or it is scenery" % id)
|
||||
|
||||