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