Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.
The four ambiguities in the brief were settled with the user first, since
each changes what gets written:
damage base x (1 + sum additive) x product multiplicative. The flat
+5% every upgrade carries, spread's -10%, doubleshot's -50%
and glass cannon's +100% pool; sniper multiplies on top, so
two snipers is 4x and not +200%.
glass half the LEVELLED maximum, multiplying if taken twice, so the
price does not fade to a rounding error by level 15.
poison independent stacks, not a refresh.
split +/-45 degrees from the original heading.
Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.
Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- 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 has to keep what it was fired
with rather than gaining Poison because the shooter just took it.
Two invariants this collided with, both now pinned:
- bullet speed gained a ceiling. Wall collision samples once per tick, so
anything over a tile per tick tunnels; two snipers asked for 2480 u/s
against a 1920 threshold, and a tunnelling bullet looks like a bullet.
- BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
now the longest-travelling bullet in the game. test_interest measured
the worst case from static content, which upgrades quietly invalidated.
Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.
tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.
Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.
check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,13 +26,15 @@ non-zero on failure, so they gate like tests:
|
||||
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
|
||||
```
|
||||
|
||||
`diag_progression` and `diag_loot` exist because the bot smoke test cannot cover
|
||||
either: bots are poor shots, so they rarely kill anything, which means they
|
||||
neither earn levels nor produce drops.
|
||||
`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.
|
||||
|
||||
Everything after `--` goes to `GameOpts.parse()`:
|
||||
|
||||
@@ -89,7 +91,7 @@ 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/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`. Shapes, not instances. |
|
||||
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
|
||||
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
||||
@@ -98,6 +100,7 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| `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. |
|
||||
@@ -158,8 +161,15 @@ 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 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.
|
||||
|
||||
@@ -280,3 +280,63 @@ drop you into the other's run purely on timing.
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -257,6 +257,27 @@ 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
|
||||
@@ -294,6 +315,8 @@ transition, not a combat mechanic.
|
||||
| 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
|
||||
|
||||
+78
-50
@@ -20,15 +20,17 @@ 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` | 288 GUT tests, no SceneTree | ~3s |
|
||||
| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s |
|
||||
| `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s |
|
||||
| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted | ~10s |
|
||||
|
||||
The three diagnostics exist because the smoke test structurally cannot reach
|
||||
what they cover: bots are poor shots (so they neither level up nor produce
|
||||
drops), and a listen server cannot drift its own clock against itself.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -217,49 +219,78 @@ shares the interact key.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Upgrades · *todo, blocked on decisions*
|
||||
## Stage 4 — Upgrades · *done*
|
||||
|
||||
**Do not start this without answering the open questions below.** The damage
|
||||
formula in particular determines the shape of every upgrade.
|
||||
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.
|
||||
|
||||
### The mechanism
|
||||
| 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 |
|
||||
|
||||
- An NPC in the hub. Each level gained grants one choice.
|
||||
- The choice offers **3 random upgrades**.
|
||||
- **Every upgrade also carries a +5% damage buff, additive** ("adaptively
|
||||
scaling" in the brief — read as additive, confirm if wrong).
|
||||
- The choice screen must show that buff **and** all the upgrade's other effects.
|
||||
- A separate screen lists the upgrades already taken.
|
||||
|
||||
### The upgrades, as specified
|
||||
### The seven upgrades
|
||||
|
||||
| Upgrade | Rarity | Effect |
|
||||
| --- | --- | --- |
|
||||
| Split shot | common | Hitting an enemy spawns 2 of the same bullet at a 45° angle behind the enemy. A shot cannot split twice unless the upgrade is taken again. |
|
||||
| Glass cannon | common | +100% damage, −50% health. |
|
||||
| Spread | common | Adds 2 side projectiles in a cone. −10% damage. |
|
||||
| Sniper | common | 2× damage (**multiplicative, not additive**), 0.5× fire rate, 2× bullet speed. |
|
||||
| Doubleshot | rarer than common | Adds 1 projectile firing parallel to the others. −50% damage. |
|
||||
| Poison | rare | Each projectile deals an additional 50% of its damage over the next 10 seconds. |
|
||||
| Eraser | legendary | Shots have a 1% chance to delete a projectile they pass through. |
|
||||
| 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. |
|
||||
|
||||
Upgrades stack — "cannot split twice *unless upgraded again*" says so directly.
|
||||
Draw weights: common 100, uncommon 45, rare 18, legendary 4.
|
||||
|
||||
### What this implies
|
||||
### The decisions behind the numbers
|
||||
|
||||
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`. It becomes a
|
||||
per-player computed stat, so `SimWorld._fire_player_shot` grows a stats block.
|
||||
Split, spread and doubleshot all change how many bullets a shot produces, so
|
||||
they belong in the same place.
|
||||
All four settled with the user; the reasoning is in
|
||||
[DECISIONS.md](DECISIONS.md).
|
||||
|
||||
Two constraints already pinned by tests that upgrades will collide with:
|
||||
- **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.
|
||||
|
||||
- **Sniper doubles bullet speed.** `test_bullet_speeds_stay_below_the_tunnelling_threshold`
|
||||
asserts that even at 2× a bullet stays under one tile per tick. Stacking two
|
||||
snipers would break wall collision, so the multiplier needs a ceiling.
|
||||
- **Longer/faster bullets widen `BULLET_INTEREST_RADIUS`.** `test_interest.gd`
|
||||
recomputes the floor from live content; upgrades change bullet travel *per
|
||||
player*, which that test does not currently model.
|
||||
### 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
|
||||
|
||||
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
|
||||
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
|
||||
damage), but the first one visibly does nothing. See the open question below.
|
||||
- **One panel, not two screens.** The brief asked for a separate screen listing
|
||||
upgrades taken; it is the lower half of the choice panel instead, because the
|
||||
two are read together.
|
||||
- **No respec, and no way to see another player's build.**
|
||||
|
||||
---
|
||||
|
||||
@@ -306,21 +337,18 @@ Not oversights — each was considered and rejected for now, with the reasoning
|
||||
Genuinely unspecified. **Do not guess at these** — each changes the design, and
|
||||
several have no obvious default.
|
||||
|
||||
### Blocking Stage 4 (upgrades)
|
||||
### Worth a decision soon
|
||||
|
||||
1. **Damage stacking order.** Sniper is explicitly multiplicative; the +5% per
|
||||
upgrade and the ± percentages read as additive. Is it
|
||||
`base × (1 + Σ additive) × Π multiplicative`, or something else?
|
||||
2. **Rarity weights** for common / rarer / rare / legendary.
|
||||
3. **Split shot geometry** — ±22.5° from the original heading (45° total), or
|
||||
45° to each side (90° total)?
|
||||
4. **Poison stacking** — do applications stack, or does a new hit refresh one
|
||||
damage-over-time effect?
|
||||
5. **Eraser's target** — does it delete *enemy bullets* it passes through?
|
||||
6. **Do unclaimed level-ups queue?** Reaching level 4 and 5 inside one run:
|
||||
two pending choices at the NPC, or one?
|
||||
7. **Glass cannon's −50% health** — of base HP, or of the character's levelled
|
||||
maximum?
|
||||
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
|
||||
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
|
||||
nothing at all until the second one. It comes out right in aggregate, but a
|
||||
player who takes Split Shot and sees no damage change has been told
|
||||
something untrue.
|
||||
|
||||
The clean fix is to scale base damage and enemy health together — ×4 would
|
||||
put a shot at 24 and make every 5% step land — which changes no time-to-kill
|
||||
but touches every number in `content.gd`. That is a balance edit, so it is
|
||||
yours to call rather than mine.
|
||||
|
||||
### Blocking nothing yet
|
||||
|
||||
|
||||
@@ -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
|
||||
+35
-5
@@ -169,13 +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,
|
||||
portals: PackedByteArray, dungeon: String) -> 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, portals, dungeon)
|
||||
map_h, portals, dungeon, npc)
|
||||
else:
|
||||
s_enter_instance.rpc_id(peer_id, id, kind, server_tick, boss_id, spawn,
|
||||
map_w, map_h, portals, dungeon)
|
||||
map_w, map_h, portals, dungeon, npc)
|
||||
|
||||
|
||||
func send_map_chunks(peer_id: int, instance_id: int, data: PackedByteArray) -> void:
|
||||
@@ -213,6 +213,13 @@ func send_select_result(peer_id: int, result: int, reason: String) -> void:
|
||||
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)
|
||||
@@ -234,6 +241,16 @@ func select_character(character_id: String) -> void:
|
||||
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)
|
||||
@@ -271,6 +288,13 @@ func c_create_character(character_name: String) -> void:
|
||||
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)
|
||||
func c_input(data: PackedByteArray) -> void:
|
||||
if server == null:
|
||||
@@ -291,11 +315,11 @@ 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, portals: PackedByteArray,
|
||||
dungeon: String) -> void:
|
||||
dungeon: String, npc: Vector2) -> void:
|
||||
if client == null:
|
||||
return
|
||||
client.on_enter_instance(id, kind, server_tick, boss_id, spawn, map_w,
|
||||
map_h, portals, dungeon)
|
||||
map_h, portals, dungeon, npc)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 1)
|
||||
@@ -323,6 +347,12 @@ func s_select_result(result: int, reason: String) -> void:
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
@@ -99,6 +100,7 @@ static func stamp(grid: MapGrid, s: PackedStringArray, origin: Vector2i) -> Dict
|
||||
## 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. Two
|
||||
## of them now: the real run and the Proving Grounds, side by side so they can
|
||||
@@ -116,7 +118,7 @@ static func lobby() -> PackedStringArray:
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.......................................#",
|
||||
"#.........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
|
||||
+31
-1
@@ -117,8 +117,36 @@ 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 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
|
||||
|
||||
@@ -161,7 +189,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
|
||||
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=8 format=3]
|
||||
[gd_scene load_steps=9 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"]
|
||||
@@ -7,6 +7,7 @@
|
||||
[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"]
|
||||
|
||||
[node name="Game" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
@@ -29,3 +30,6 @@ script = ExtResource("6")
|
||||
|
||||
[node name="CharacterSelect" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("7")
|
||||
|
||||
[node name="UpgradeScreen" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("8")
|
||||
|
||||
@@ -44,6 +44,8 @@ static func make_lobby(instance_id: int) -> Instance:
|
||||
var built := MapGen.build(Protocol.InstanceKind.LOBBY, inst.seed_value, 0)
|
||||
inst.world.set_map(built["grid"])
|
||||
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
|
||||
|
||||
+46
-1
@@ -28,6 +28,22 @@ var died_unix: int = 0
|
||||
## 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
|
||||
@@ -80,8 +96,12 @@ static func sanitize_name(raw: String) -> String:
|
||||
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 Progression.max_hp_for_level(level)
|
||||
return maxi(1, roundi(float(Progression.max_hp_for_level(level))
|
||||
* PlayerStats.build(upgrades).max_hp_mult))
|
||||
|
||||
|
||||
func xp_progress() -> float:
|
||||
@@ -120,9 +140,19 @@ func to_dict() -> Dictionary:
|
||||
# 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:
|
||||
@@ -150,9 +180,24 @@ static func from_dict(d: Dictionary) -> Character:
|
||||
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)
|
||||
|
||||
@@ -116,6 +116,47 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam
|
||||
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:
|
||||
|
||||
@@ -23,6 +23,9 @@ 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
|
||||
@@ -95,6 +98,15 @@ var portals: Array[Dictionary] = []
|
||||
## 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
|
||||
@@ -339,6 +351,30 @@ 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()
|
||||
@@ -346,10 +382,11 @@ func on_roster(data: PackedByteArray) -> void:
|
||||
|
||||
func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
spawn: Vector2, map_w: int, map_h: int, portals_data: PackedByteArray,
|
||||
dungeon: String) -> void:
|
||||
dungeon: String, npc: Vector2) -> void:
|
||||
instance_id = id
|
||||
instance_kind = kind as Protocol.InstanceKind
|
||||
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.
|
||||
|
||||
@@ -462,6 +462,55 @@ static func decode_portals(data: PackedByteArray) -> Array[Dictionary]:
|
||||
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.
|
||||
|
||||
+8
-1
@@ -19,7 +19,9 @@ extends RefCounted
|
||||
## 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.
|
||||
const VERSION := 7
|
||||
## 8: upgrades. A new server -> client upgrade-state message, a new
|
||||
## client -> server choice message, and two more SelectResult values.
|
||||
const VERSION := 8
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
@@ -46,6 +48,11 @@ enum SelectResult {
|
||||
## 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.
|
||||
|
||||
@@ -307,9 +307,63 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
|
||||
p.adopt(c)
|
||||
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:
|
||||
@@ -332,7 +386,8 @@ func _place(peer_id: int, inst: Instance) -> void:
|
||||
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,
|
||||
NetCodec.encode_portals(inst.world.portals), String(inst.dungeon_id))
|
||||
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)
|
||||
@@ -489,6 +544,10 @@ func _grant_xp(peer_id: int, amount: int) -> void:
|
||||
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.
|
||||
@@ -501,8 +560,10 @@ func _grant_xp(peer_id: int, amount: int) -> void:
|
||||
p.level = c.level
|
||||
p.max_hp = c.max_hp()
|
||||
p.hp = mini(p.hp + (p.max_hp - before), p.max_hp)
|
||||
GameLog.info("server", "peer %d reached level %d" % [peer_id, c.level])
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -52,12 +52,17 @@ static func _build_lobby() -> Dictionary:
|
||||
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,
|
||||
"portals": portals,
|
||||
"dummy": target,
|
||||
"npc": npc,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
}
|
||||
@@ -148,6 +153,7 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
"spawn": spawn,
|
||||
"portals": [] as Array[SimPortal],
|
||||
"dummy": Vector2.ZERO,
|
||||
"npc": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
class_name PlayerStats
|
||||
extends RefCounted
|
||||
## A player's combat numbers, computed from the upgrades they hold.
|
||||
##
|
||||
## Derived, never stored: the character record keeps the list of upgrade ids and
|
||||
## this is rebuilt from it. One source of truth means a saved stat can never
|
||||
## disagree with the upgrades that produced it -- the same reasoning that makes
|
||||
## [Progression] derive level from experience rather than storing both.
|
||||
|
||||
## The damage formula, settled with the user:
|
||||
##
|
||||
## base x (1 + sum of additive) x product of multiplicative
|
||||
##
|
||||
## The +5% every upgrade carries, Spread's −10%, Doubleshot's −50% and Glass
|
||||
## Cannon's +100% all pool into the additive term. Sniper's 2x multiplies the
|
||||
## result, which is why it stays worth taking however many percentages have
|
||||
## already piled up -- and why two Snipers is 4x rather than +200%.
|
||||
|
||||
var damage: int = SimConfig.PLAYER_BULLET_DAMAGE
|
||||
var fire_cooldown: int = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
var bullet_speed: float = SimConfig.PLAYER_BULLET_SPEED
|
||||
## Multiplies the level's maximum health. See SimPlayer.recompute_max_hp().
|
||||
var max_hp_mult: float = 1.0
|
||||
var side_shots: int = 0
|
||||
var parallel_shots: int = 0
|
||||
var split_charges: int = 0
|
||||
var poison_fraction: float = 0.0
|
||||
var erase_chance: float = 0.0
|
||||
## How many upgrades produced these numbers, duplicates included. Only used for
|
||||
## display, but it is the number a player counts.
|
||||
var upgrade_count: int = 0
|
||||
## True when the bullet-speed ceiling actually bit. Surfaced so the choice
|
||||
## screen can say a second Sniper buys no more speed instead of silently
|
||||
## selling one.
|
||||
var speed_capped: bool = false
|
||||
|
||||
|
||||
static func build(ids: Array[StringName]) -> PlayerStats:
|
||||
var s := PlayerStats.new()
|
||||
var additive := 0.0
|
||||
var multiplicative := 1.0
|
||||
var fire_rate := 1.0
|
||||
var speed := 1.0
|
||||
var hp := 1.0
|
||||
|
||||
for id in ids:
|
||||
var def := Upgrades.get_def(id)
|
||||
if def == null:
|
||||
continue # an upgrade this build no longer has: ignored, not fatal
|
||||
s.upgrade_count += 1
|
||||
# Every upgrade carries this, whatever else it does.
|
||||
additive += SimConfig.UPGRADE_DAMAGE_BONUS
|
||||
additive += def.damage_add
|
||||
multiplicative *= def.damage_mult
|
||||
fire_rate *= def.fire_rate_mult
|
||||
speed *= def.bullet_speed_mult
|
||||
hp *= def.max_hp_mult
|
||||
s.side_shots += def.side_shots
|
||||
s.parallel_shots += def.parallel_shots
|
||||
s.split_charges += def.split_charges
|
||||
s.poison_fraction += def.poison_fraction
|
||||
s.erase_chance += def.erase_chance
|
||||
|
||||
# Floored at 1 rather than allowed to reach zero. Stacking Doubleshot and
|
||||
# Spread can in principle drive the additive pool below −100%, and a shot
|
||||
# that deals nothing is indistinguishable from a bug.
|
||||
s.damage = maxi(1, roundi(float(SimConfig.PLAYER_BULLET_DAMAGE)
|
||||
* (1.0 + additive) * multiplicative))
|
||||
# A division, so "half the fire rate" is half the shots per second rather
|
||||
# than half the cooldown.
|
||||
s.fire_cooldown = maxi(1, roundi(float(SimConfig.PLAYER_FIRE_COOLDOWN)
|
||||
/ maxf(fire_rate, 0.01)))
|
||||
var wanted := SimConfig.PLAYER_BULLET_SPEED * speed
|
||||
s.bullet_speed = minf(wanted, SimConfig.MAX_BULLET_SPEED)
|
||||
s.speed_capped = wanted > SimConfig.MAX_BULLET_SPEED
|
||||
s.max_hp_mult = maxf(hp, 0.01)
|
||||
s.erase_chance = clampf(s.erase_chance, 0.0, 1.0)
|
||||
return s
|
||||
@@ -0,0 +1 @@
|
||||
uid://57ld2euau2ua
|
||||
@@ -0,0 +1,70 @@
|
||||
class_name PoisonTrack
|
||||
extends RefCounted
|
||||
## Damage over time on one actor, from any number of independent doses.
|
||||
##
|
||||
## The user chose independent stacks: every poisoned hit starts its own full
|
||||
## window rather than refreshing a shared one, so a fast weapon can have dozens
|
||||
## running at once. That could have meant walking dozens of timers per enemy per
|
||||
## tick, and it does not, because every dose lasts exactly the same number of
|
||||
## ticks:
|
||||
##
|
||||
## - the total damage per tick is one running float, [member rate];
|
||||
## - doses therefore expire in the order they were added, so the pending
|
||||
## expiries are a plain FIFO and only the front of it is ever examined.
|
||||
##
|
||||
## The result is O(1) per tick per actor no matter how many doses are live,
|
||||
## which is what makes "43 concurrent stacks" a non-event rather than a budget.
|
||||
|
||||
## Damage per tick, summed over every live dose.
|
||||
var rate: float = 0.0
|
||||
|
||||
## Fractional damage carried between ticks. A single dose is far under one hit
|
||||
## point per tick, so without this it would round to nothing forever.
|
||||
var _carry: float = 0.0
|
||||
var _expiry := PackedInt32Array()
|
||||
var _rate := PackedFloat32Array()
|
||||
var _head: int = 0
|
||||
|
||||
|
||||
## Add a dose worth [param total_damage] spread over the poison window.
|
||||
func add(total_damage: float, at_tick: int) -> void:
|
||||
if total_damage <= 0.0:
|
||||
return
|
||||
var per_tick := total_damage / float(SimConfig.POISON_DURATION_TICKS)
|
||||
_expiry.append(at_tick + SimConfig.POISON_DURATION_TICKS)
|
||||
_rate.append(per_tick)
|
||||
rate += per_tick
|
||||
|
||||
|
||||
## Retire finished doses and return whole hit points to apply this tick.
|
||||
func step(at_tick: int) -> int:
|
||||
while _head < _expiry.size() and _expiry[_head] <= at_tick:
|
||||
rate -= _rate[_head]
|
||||
_head += 1
|
||||
if _head >= _expiry.size():
|
||||
# Everything expired. Reset rather than subtract to zero: the running
|
||||
# sum accumulates float error, and "nothing is poisoned" has to mean
|
||||
# exactly nothing.
|
||||
_expiry.clear()
|
||||
_rate.clear()
|
||||
_head = 0
|
||||
rate = 0.0
|
||||
_carry = 0.0
|
||||
return 0
|
||||
if rate <= 0.0:
|
||||
return 0
|
||||
_carry += rate
|
||||
if _carry < 1.0:
|
||||
return 0
|
||||
var whole := int(_carry)
|
||||
_carry -= float(whole)
|
||||
return whole
|
||||
|
||||
|
||||
func active() -> bool:
|
||||
return rate > 0.0
|
||||
|
||||
|
||||
## Live doses, for tests and the debug overlay.
|
||||
func dose_count() -> int:
|
||||
return _expiry.size() - _head
|
||||
@@ -0,0 +1 @@
|
||||
uid://vuiinq8qng3i
|
||||
@@ -15,6 +15,14 @@ var phase_tick: int = 0
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
|
||||
var poison: PoisonTrack = null
|
||||
|
||||
|
||||
func poison_track() -> PoisonTrack:
|
||||
if poison == null:
|
||||
poison = PoisonTrack.new()
|
||||
return poison
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
@@ -19,6 +19,15 @@ var target_dir := Vector2.ZERO
|
||||
## sight. Refreshed by the world every tick; null means idle, which is the
|
||||
## normal state for most of a dungeon.
|
||||
var target: SimPlayer = null
|
||||
## Poison doses ticking on this enemy. Built lazily: most enemies in a dungeon
|
||||
## are never poisoned, and most runs never see the upgrade at all.
|
||||
var poison: PoisonTrack = null
|
||||
|
||||
|
||||
func poison_track() -> PoisonTrack:
|
||||
if poison == null:
|
||||
poison = PoisonTrack.new()
|
||||
return poison
|
||||
|
||||
|
||||
func hp_fraction() -> float:
|
||||
|
||||
+18
-2
@@ -42,6 +42,10 @@ var inventory: Array[StringName] = []
|
||||
var prev_buttons: int = 0
|
||||
var prev_slot: int = -1
|
||||
|
||||
## Combat numbers derived from the character's upgrades. Rebuilt whenever the
|
||||
## upgrade list changes, never stored on disk -- see [PlayerStats].
|
||||
var stats := PlayerStats.new()
|
||||
|
||||
## The peer's connection dropped, but the player is deliberately still in the
|
||||
## world. Held here rather than deleted so a disconnect cannot be used to dodge
|
||||
## a dangerous moment: a linkdead player keeps channelling the escape (and stays
|
||||
@@ -86,7 +90,7 @@ func can_fire() -> bool:
|
||||
## SimConfig.SPAWN_GRACE_TICKS for a dungeon.
|
||||
func reset_for_instance(spawn: Vector2, grace: int = 0) -> void:
|
||||
pos = spawn
|
||||
max_hp = Progression.max_hp_for_level(level)
|
||||
recompute_max_hp()
|
||||
hp = max_hp
|
||||
alive = true
|
||||
spawn_grace = grace
|
||||
@@ -120,11 +124,23 @@ func adopt(c: Character) -> void:
|
||||
level = c.level
|
||||
total_xp = c.total_xp
|
||||
colour = c.colour
|
||||
max_hp = c.max_hp()
|
||||
stats = PlayerStats.build(c.upgrades)
|
||||
recompute_max_hp()
|
||||
hp = mini(hp, max_hp)
|
||||
set_inventory(c.inventory)
|
||||
|
||||
|
||||
## Maximum health is the level's value scaled by whatever the upgrades do to it
|
||||
## -- Glass Cannon halves it. Multiplying the LEVELLED value rather than the
|
||||
## base is what keeps that trade a real price at level 15 instead of a rounding
|
||||
## error. Floored at 1: an upgrade must never be able to make you unkillable by
|
||||
## making you already dead.
|
||||
func recompute_max_hp() -> void:
|
||||
max_hp = maxi(1, roundi(float(Progression.max_hp_for_level(level))
|
||||
* stats.max_hp_mult))
|
||||
hp = mini(hp, max_hp)
|
||||
|
||||
|
||||
## Replace the whole inventory, padding or trimming to the configured slot
|
||||
## count so a save file written when the game had a different number of slots
|
||||
## still loads into a valid player.
|
||||
|
||||
+141
-12
@@ -37,6 +37,11 @@ var map: MapGrid = null
|
||||
## A list rather than a single position because which dungeon an entrance opens
|
||||
## is the whole reason there is more than one.
|
||||
var portals: Array[SimPortal] = []
|
||||
## Where the hub's upgrade NPC stands. Only meaningful when
|
||||
## [member has_upgrade_npc] is true -- the map is centred on the origin, so
|
||||
## Vector2.ZERO is a real position and cannot double as "there isn't one".
|
||||
var upgrade_npc := Vector2.ZERO
|
||||
var has_upgrade_npc: bool = false
|
||||
var spawn_point := Vector2(0.0, 240.0)
|
||||
## Arrival protection granted to players entering this world. 0 in the hub,
|
||||
## SimConfig.SPAWN_GRACE_TICKS in a dungeon.
|
||||
@@ -131,6 +136,14 @@ func portal_at(at: Vector2) -> SimPortal:
|
||||
return best
|
||||
|
||||
|
||||
## Whether [param at] is close enough to spend a level-up. Checked on the
|
||||
## server for the same reason the portal is: where a player is standing is the
|
||||
## one thing a modified client cannot fake.
|
||||
func at_upgrade_npc(at: Vector2) -> bool:
|
||||
return has_upgrade_npc \
|
||||
and at.distance_to(upgrade_npc) <= SimConfig.UPGRADE_NPC_RADIUS
|
||||
|
||||
|
||||
func alive_player_count() -> int:
|
||||
var n := 0
|
||||
for p in players.values():
|
||||
@@ -186,6 +199,7 @@ func step() -> void:
|
||||
_step_boss()
|
||||
pool.step()
|
||||
_resolve_bullet_hits()
|
||||
_step_poison()
|
||||
_emit_wall_kill_events()
|
||||
_emit_spawn_events()
|
||||
else:
|
||||
@@ -276,18 +290,44 @@ func _button_edge(p: SimPlayer, frame: InputFrame) -> int:
|
||||
return edge
|
||||
|
||||
|
||||
## One trigger pull. How many bullets that is, and what they carry, comes
|
||||
## entirely from the shooter's [PlayerStats] -- there is no per-upgrade branch
|
||||
## here, which is what lets a new upgrade be a table entry in [Upgrades].
|
||||
func _fire_player_shot(p: SimPlayer) -> void:
|
||||
p.fire_cooldown = SimConfig.PLAYER_FIRE_COOLDOWN
|
||||
p.fire_cooldown = p.stats.fire_cooldown
|
||||
events.append({"t": SimEvent.Type.PLAYER_FIRED, "peer": p.peer_id})
|
||||
var dir := Vector2.RIGHT.rotated(p.aim)
|
||||
pool.spawn(
|
||||
p.pos + dir * SimConfig.PLAYER_MUZZLE_OFFSET,
|
||||
dir * SimConfig.PLAYER_BULLET_SPEED,
|
||||
var forward := Vector2.RIGHT.rotated(p.aim)
|
||||
var muzzle := p.pos + forward * SimConfig.PLAYER_MUZZLE_OFFSET
|
||||
|
||||
# The aimed shot plus Spread's cone, alternating sides so an odd count still
|
||||
# comes out symmetric about the aim.
|
||||
_spawn_player_bullet(p, muzzle, forward)
|
||||
for i in p.stats.side_shots:
|
||||
var step := float(i / 2 + 1) * SimConfig.SPREAD_STEP_DEG
|
||||
var side_sign := 1.0 if i % 2 == 0 else -1.0
|
||||
_spawn_player_bullet(p, muzzle,
|
||||
forward.rotated(deg_to_rad(step * side_sign)))
|
||||
|
||||
# Doubleshot's extras travel parallel rather than fanned, so they are an
|
||||
# offset at the muzzle and not an angle.
|
||||
for i in p.stats.parallel_shots:
|
||||
var gap := float(i / 2 + 1) * SimConfig.PARALLEL_OFFSET
|
||||
var lateral_sign := 1.0 if i % 2 == 0 else -1.0
|
||||
_spawn_player_bullet(p, muzzle + forward.orthogonal() * gap * lateral_sign,
|
||||
forward)
|
||||
|
||||
|
||||
func _spawn_player_bullet(p: SimPlayer, at: Vector2, dir: Vector2) -> void:
|
||||
var slot := pool.spawn(
|
||||
at,
|
||||
dir * p.stats.bullet_speed,
|
||||
SimConfig.PLAYER_BULLET_RADIUS,
|
||||
SimConfig.PLAYER_BULLET_LIFETIME,
|
||||
SimConfig.PLAYER_BULLET_DAMAGE,
|
||||
p.stats.damage,
|
||||
SimConfig.TEAM_PLAYER,
|
||||
SimConfig.KIND_PLAYER_SHOT)
|
||||
pool.set_mods(slot, p.stats.split_charges, p.stats.poison_fraction,
|
||||
p.stats.erase_chance)
|
||||
|
||||
|
||||
# --- Items and loot ---------------------------------------------------------
|
||||
@@ -592,7 +632,12 @@ func _resolve_bullet_hits() -> void:
|
||||
var consumed := false
|
||||
if boss != null and boss.alive \
|
||||
and Movement.circles_overlap(bp, br, boss.pos, boss.def.radius):
|
||||
_damage_boss(pool.damage[i])
|
||||
# Poison is taken from the damage the boss ACTUALLY took, so a
|
||||
# phase that takes 30% extra is poisoned 30% harder too.
|
||||
var applied := _damage_boss(pool.damage[i])
|
||||
if pool.poison[i] > 0.0:
|
||||
boss.poison_track().add(float(applied) * pool.poison[i], tick)
|
||||
_split_shot(i, boss.pos, boss.def.radius)
|
||||
consumed = true
|
||||
if not consumed:
|
||||
for e in enemies.values():
|
||||
@@ -600,10 +645,82 @@ func _resolve_bullet_hits() -> void:
|
||||
continue
|
||||
if Movement.circles_overlap(bp, br, e.pos, e.def.radius):
|
||||
_damage_enemy(e, pool.damage[i])
|
||||
if pool.poison[i] > 0.0:
|
||||
e.poison_track().add(
|
||||
float(pool.damage[i]) * pool.poison[i], tick)
|
||||
_split_shot(i, e.pos, e.def.radius)
|
||||
consumed = true
|
||||
break
|
||||
if consumed:
|
||||
_kill_bullet(i)
|
||||
elif pool.erase[i] > 0.0:
|
||||
_try_erase(i)
|
||||
|
||||
|
||||
## Split Shot: two children of the shot that just landed, leaving the target at
|
||||
## 45 degrees to either side.
|
||||
##
|
||||
## They are born just PAST the target rather than on it. A child spawned inside
|
||||
## the thing that 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.
|
||||
##
|
||||
## Children inherit the remaining lifetime rather than a fresh one, so splitting
|
||||
## cannot extend a shot's reach indefinitely; a split at the end of a shot's
|
||||
## life produces two short-lived children, which is the conservative reading of
|
||||
## "two of the same bullet".
|
||||
func _split_shot(slot: int, from: Vector2, target_radius: float) -> void:
|
||||
if pool.split[slot] <= 0:
|
||||
return
|
||||
var v: Vector2 = pool.vel[slot]
|
||||
var speed := v.length()
|
||||
if speed < 0.001:
|
||||
return
|
||||
var charges := pool.split[slot] - 1
|
||||
var clearance := target_radius + pool.radius[slot] + 2.0
|
||||
for side_sign in [1.0, -1.0]:
|
||||
var dir := (v / speed).rotated(deg_to_rad(SimConfig.SPLIT_ANGLE_DEG) * side_sign)
|
||||
var child := pool.spawn(from + dir * clearance, dir * speed,
|
||||
pool.radius[slot], pool.life[slot], pool.damage[slot],
|
||||
pool.team[slot], pool.kind[slot])
|
||||
pool.set_mods(child, charges, pool.poison[slot], pool.erase[slot])
|
||||
|
||||
|
||||
## Eraser: at most ONE roll per shot per tick, against the first enemy
|
||||
## projectile it is overlapping.
|
||||
##
|
||||
## Rolling once per overlapping pair would multiply the stated 1% by however
|
||||
## many bullets happened to occupy the same place, which in a boss ring is a
|
||||
## lot. One opportunity per tick, taken or not, keeps the number the player was
|
||||
## promised close to the number they get.
|
||||
##
|
||||
## The scan is O(bullets) per erasing shot, and runs only for shots that carry
|
||||
## the upgrade -- which is why it is worth nothing until a legendary is drawn.
|
||||
func _try_erase(slot: int) -> void:
|
||||
var p: Vector2 = pool.pos[slot]
|
||||
var r: float = pool.radius[slot]
|
||||
for j in pool.high_water:
|
||||
if pool.alive[j] == 0 or pool.team[j] != SimConfig.TEAM_ENEMY:
|
||||
continue
|
||||
if not Movement.circles_overlap(p, r, pool.pos[j], pool.radius[j]):
|
||||
continue
|
||||
if rng.randf() < pool.erase[slot]:
|
||||
_kill_bullet(j)
|
||||
return
|
||||
|
||||
|
||||
## One tick of every live poison dose. Cheap regardless of how many are running
|
||||
## -- see [PoisonTrack] for why.
|
||||
func _step_poison() -> void:
|
||||
for e in enemies.values():
|
||||
if not e.alive or e.poison == null:
|
||||
continue
|
||||
var dealt: int = e.poison.step(tick)
|
||||
if dealt > 0:
|
||||
_damage_enemy(e, dealt, true)
|
||||
if boss != null and boss.alive and boss.poison != null:
|
||||
var on_boss: int = boss.poison.step(tick)
|
||||
if on_boss > 0:
|
||||
_damage_boss(on_boss, true, false)
|
||||
|
||||
|
||||
## Bullets removed early must be announced -- clients cannot derive a hit.
|
||||
@@ -624,9 +741,14 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.PLAYER_DIED, "peer": p.peer_id})
|
||||
|
||||
|
||||
func _damage_enemy(e: SimEnemy, amount: int) -> void:
|
||||
## [param silent] suppresses the ENEMY_HIT event. Poison ticks use it: they
|
||||
## land many times a second on the reliable channel, and the client learns hp
|
||||
## from the snapshot anyway. Death is still announced either way, because that
|
||||
## is what the experience award is keyed on.
|
||||
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
e.hp = maxi(e.hp - amount, 0)
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
if not silent:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
if e.hp <= 0:
|
||||
e.alive = false
|
||||
_drop_loot(e.def.loot, e.pos)
|
||||
@@ -635,16 +757,23 @@ func _damage_enemy(e: SimEnemy, amount: int) -> void:
|
||||
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
|
||||
|
||||
|
||||
func _damage_boss(amount: int) -> void:
|
||||
## Returns the damage actually applied, after the phase's armour multiplier, so
|
||||
## the caller can derive poison from the same number.
|
||||
##
|
||||
## [param scale] is false for poison ticks, whose damage was already scaled when
|
||||
## the dose was applied -- scaling again would compound the multiplier.
|
||||
func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
|
||||
var phase := boss.current_phase()
|
||||
var mult := 1.0 if phase == null else phase.damage_taken_mult
|
||||
var mult := 1.0 if phase == null or not scale else phase.damage_taken_mult
|
||||
var applied := maxi(1, roundi(float(amount) * mult))
|
||||
boss.hp = maxi(boss.hp - applied, 0)
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
|
||||
if not silent:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": boss.id, "dmg": applied, "hp": boss.hp})
|
||||
if boss.hp <= 0:
|
||||
boss.alive = false
|
||||
_drop_loot(boss.def.loot, boss.pos)
|
||||
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
|
||||
return applied
|
||||
|
||||
|
||||
## Bullets that died against geometry. A client is only streamed the map near
|
||||
|
||||
@@ -147,6 +147,7 @@ func _draw_hud() -> void:
|
||||
_draw_roster()
|
||||
_draw_inventory()
|
||||
_draw_pickup_prompt()
|
||||
_draw_level_up_notice()
|
||||
|
||||
if not client.my_alive:
|
||||
# Centred on the canvas, which is only correct because _canvas actually
|
||||
@@ -161,6 +162,23 @@ func _draw_hud() -> void:
|
||||
Color(1.0, 0.2, 0.25, 0.18 * _hit_flash))
|
||||
|
||||
|
||||
## Unspent level-ups. Shown wherever you are, because the choice is banked in a
|
||||
## dungeon and spent in the hub -- if it only appeared next to the NPC, a player
|
||||
## would have to already know to go and look.
|
||||
func _draw_level_up_notice() -> void:
|
||||
if client.upgrades_pending <= 0:
|
||||
return
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.004)
|
||||
var where := " — see the quartermaster in the hub" \
|
||||
if client.instance_kind != Protocol.InstanceKind.LOBBY else ""
|
||||
_canvas.draw_string(ThemeDB.fallback_font,
|
||||
Vector2(MARGIN, MARGIN + 74.0),
|
||||
"%d LEVEL-UP%s TO SPEND%s" % [client.upgrades_pending,
|
||||
"" if client.upgrades_pending == 1 else "S", where],
|
||||
HORIZONTAL_ALIGNMENT_LEFT, -1, 14,
|
||||
Color(1.0, 0.85, 0.4, 0.6 + 0.4 * pulse))
|
||||
|
||||
|
||||
## Four slots, always on screen. Deliberately not a panel you open: an
|
||||
## inventory you have to stop and read is a menu, and a menu is a death in a
|
||||
## game where the floor is bullets. Everything drawn here comes from the
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
extends CanvasLayer
|
||||
## The quartermaster: spend a level-up, and review what you have taken.
|
||||
##
|
||||
## The three options are the SERVER's, held on the character until one is
|
||||
## spent -- closing and reopening this screen shows the same three, because
|
||||
## otherwise it would be a reroll button and everyone would press it until a
|
||||
## legendary appeared.
|
||||
##
|
||||
## The brief asked for a separate screen listing upgrades already taken. It is
|
||||
## the lower half of this one instead: the two are read together (what do I
|
||||
## have, what should I add) and splitting them would mean closing one panel to
|
||||
## answer a question raised by the other.
|
||||
|
||||
signal choose_requested(index: int)
|
||||
signal closed
|
||||
|
||||
const RARITY_COLOURS := {
|
||||
UpgradeDef.Rarity.COMMON: Color(0.72, 0.76, 0.84),
|
||||
UpgradeDef.Rarity.UNCOMMON: Color(0.55, 0.85, 0.6),
|
||||
UpgradeDef.Rarity.RARE: Color(0.55, 0.7, 1.0),
|
||||
UpgradeDef.Rarity.LEGENDARY: Color(1.0, 0.72, 0.3),
|
||||
}
|
||||
|
||||
var _title: Label
|
||||
var _cards: HBoxContainer
|
||||
var _taken: Label
|
||||
var _summary: Label
|
||||
var _status: Label
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 28
|
||||
visible = false
|
||||
var root := Control.new()
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(root)
|
||||
|
||||
var scrim := ColorRect.new()
|
||||
scrim.color = Color(0.03, 0.03, 0.06, 0.9)
|
||||
scrim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(scrim)
|
||||
|
||||
var centre := CenterContainer.new()
|
||||
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
root.add_child(centre)
|
||||
|
||||
var panel := VBoxContainer.new()
|
||||
panel.custom_minimum_size = Vector2(760.0, 0.0)
|
||||
panel.add_theme_constant_override("separation", 10)
|
||||
centre.add_child(panel)
|
||||
|
||||
_title = Label.new()
|
||||
_title.add_theme_font_size_override("font_size", 26)
|
||||
panel.add_child(_title)
|
||||
|
||||
_cards = HBoxContainer.new()
|
||||
_cards.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(_cards)
|
||||
|
||||
_status = Label.new()
|
||||
_status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.5))
|
||||
panel.add_child(_status)
|
||||
|
||||
panel.add_child(_rule())
|
||||
|
||||
var taken_title := Label.new()
|
||||
taken_title.text = "ALREADY TAKEN"
|
||||
taken_title.add_theme_font_size_override("font_size", 15)
|
||||
taken_title.add_theme_color_override("font_color", Color(0.6, 0.65, 0.78))
|
||||
panel.add_child(taken_title)
|
||||
|
||||
_taken = Label.new()
|
||||
_taken.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_taken.custom_minimum_size = Vector2(740.0, 0.0)
|
||||
panel.add_child(_taken)
|
||||
|
||||
_summary = Label.new()
|
||||
_summary.add_theme_color_override("font_color", Color(0.65, 0.72, 0.85))
|
||||
_summary.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_summary.custom_minimum_size = Vector2(740.0, 0.0)
|
||||
panel.add_child(_summary)
|
||||
|
||||
var close := Button.new()
|
||||
close.text = "Close (E)"
|
||||
close.custom_minimum_size = Vector2(160.0, 34.0)
|
||||
close.pressed.connect(func() -> void: closed.emit())
|
||||
panel.add_child(close)
|
||||
|
||||
|
||||
func _rule() -> Control:
|
||||
var line := ColorRect.new()
|
||||
line.color = Color(0.2, 0.22, 0.3)
|
||||
line.custom_minimum_size = Vector2(0.0, 1.0)
|
||||
return line
|
||||
|
||||
|
||||
func set_status(text: String) -> void:
|
||||
_status.text = text
|
||||
|
||||
|
||||
## Rebuild from the server's numbers. Called on every upgrade change, so the
|
||||
## screen can never show an offer that has already been spent.
|
||||
func refresh(pending: int, offer: Array[StringName], taken: Array[StringName]) -> void:
|
||||
_status.text = ""
|
||||
if pending > 0:
|
||||
_title.text = "CHOOSE AN UPGRADE (%d waiting)" % pending
|
||||
else:
|
||||
_title.text = "QUARTERMASTER — nothing to spend"
|
||||
for child in _cards.get_children():
|
||||
child.queue_free()
|
||||
if pending > 0:
|
||||
for i in offer.size():
|
||||
_cards.add_child(_make_card(i, offer[i]))
|
||||
_taken.text = _taken_text(taken)
|
||||
_summary.text = _summary_text(taken)
|
||||
|
||||
|
||||
func _make_card(index: int, id: StringName) -> Control:
|
||||
var def := Upgrades.get_def(id)
|
||||
var card := VBoxContainer.new()
|
||||
card.custom_minimum_size = Vector2(240.0, 0.0)
|
||||
card.add_theme_constant_override("separation", 6)
|
||||
if def == null:
|
||||
return card
|
||||
var tint: Color = RARITY_COLOURS.get(def.rarity, Color.WHITE)
|
||||
|
||||
var name_label := Label.new()
|
||||
name_label.text = def.display_name
|
||||
name_label.add_theme_font_size_override("font_size", 18)
|
||||
name_label.add_theme_color_override("font_color", tint)
|
||||
card.add_child(name_label)
|
||||
|
||||
var rarity := Label.new()
|
||||
rarity.text = UpgradeDef.rarity_name(def.rarity).to_upper()
|
||||
rarity.add_theme_font_size_override("font_size", 11)
|
||||
rarity.add_theme_color_override("font_color", Color(tint, 0.7))
|
||||
card.add_child(rarity)
|
||||
|
||||
var body := Label.new()
|
||||
body.text = def.description
|
||||
body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
body.custom_minimum_size = Vector2(230.0, 96.0)
|
||||
body.add_theme_font_size_override("font_size", 13)
|
||||
card.add_child(body)
|
||||
|
||||
# Shown on every card, because it applies to every card. The brief asks for
|
||||
# the choice screen to show this buff as well as the upgrade's own effects,
|
||||
# and a player comparing three cards should not have to remember it.
|
||||
var bonus := Label.new()
|
||||
bonus.text = "+%d%% damage (every upgrade)" % roundi(
|
||||
SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)
|
||||
bonus.add_theme_font_size_override("font_size", 12)
|
||||
bonus.add_theme_color_override("font_color", Color(0.55, 0.8, 0.6))
|
||||
card.add_child(bonus)
|
||||
|
||||
var take := Button.new()
|
||||
take.text = "Take"
|
||||
take.custom_minimum_size = Vector2(0.0, 34.0)
|
||||
take.pressed.connect(func() -> void: choose_requested.emit(index))
|
||||
card.add_child(take)
|
||||
return card
|
||||
|
||||
|
||||
## Counted rather than listed one per line: "Split Shot x3" is the number that
|
||||
## matters, and a level 15 character has fourteen of these.
|
||||
func _taken_text(taken: Array[StringName]) -> String:
|
||||
if taken.is_empty():
|
||||
return "nothing yet"
|
||||
var counts := {}
|
||||
var order: Array[StringName] = []
|
||||
for id in taken:
|
||||
if not counts.has(id):
|
||||
counts[id] = 0
|
||||
order.append(id)
|
||||
counts[id] += 1
|
||||
var parts: Array[String] = []
|
||||
for id in order:
|
||||
var def := Upgrades.get_def(id)
|
||||
var name_text := def.display_name if def != null else String(id)
|
||||
parts.append("%s x%d" % [name_text, int(counts[id])] if int(counts[id]) > 1
|
||||
else name_text)
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
## What the upgrades actually add up to. Built through PlayerStats, the same
|
||||
## class the server fires with, so this cannot drift from the real numbers.
|
||||
func _summary_text(taken: Array[StringName]) -> String:
|
||||
var s := PlayerStats.build(taken)
|
||||
var shots := 1 + s.side_shots + s.parallel_shots
|
||||
var per_second := float(SimConfig.TICK_RATE) / float(s.fire_cooldown)
|
||||
var parts: Array[String] = [
|
||||
"%d damage per shot" % s.damage,
|
||||
"%.1f shots/sec" % per_second,
|
||||
"%d projectile%s per shot" % [shots, "" if shots == 1 else "s"],
|
||||
"%d bullet speed%s" % [roundi(s.bullet_speed),
|
||||
" (capped)" if s.speed_capped else ""],
|
||||
]
|
||||
if s.max_hp_mult != 1.0:
|
||||
parts.append("%d%% max health" % roundi(s.max_hp_mult * 100.0))
|
||||
if s.split_charges > 0:
|
||||
parts.append("splits %dx" % s.split_charges)
|
||||
if s.poison_fraction > 0.0:
|
||||
parts.append("+%d%% as poison" % roundi(s.poison_fraction * 100.0))
|
||||
if s.erase_chance > 0.0:
|
||||
parts.append("%.1f%% erase" % (s.erase_chance * 100.0))
|
||||
return " · ".join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cy32nmdfkt57i
|
||||
@@ -98,6 +98,9 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
Rect2(288, 336, 16, 16), # red flask -> practice target
|
||||
]
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
## The hub's quartermaster. A different character strip from the knight the
|
||||
## player wears, so an NPC never reads as another player standing still.
|
||||
const NPC_IDLE := Rect2(128, 36, 16, 28)
|
||||
|
||||
## ItemDef.visual -> atlas rect. Order matches Items.ORDER: health potion,
|
||||
## Warden's ration. Both come from the tileset's flask row -- there is no food
|
||||
|
||||
+35
-1
@@ -8,11 +8,17 @@ extends Node2D
|
||||
@onready var menu: CanvasLayer = $GameMenu
|
||||
@onready var sfx: Node = $Sfx
|
||||
@onready var characters: CanvasLayer = $CharacterSelect
|
||||
@onready var upgrades: CanvasLayer = $UpgradeScreen
|
||||
|
||||
var _bound: ClientRuntime = null
|
||||
## Opened deliberately from the menu, as opposed to forced open by having no
|
||||
## character to play.
|
||||
var _roster_open: bool = false
|
||||
## The quartermaster panel. Opened by walking to the NPC and pressing interact,
|
||||
## and closed the moment you walk away -- the server refuses a choice made from
|
||||
## anywhere else, so leaving the panel open at a distance would only offer a
|
||||
## button that gets rejected.
|
||||
var _upgrades_open: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -27,6 +33,8 @@ func _ready() -> void:
|
||||
Net.select_character(id))
|
||||
characters.create_requested.connect(func(n: String) -> void: Net.create_character(n))
|
||||
characters.closed.connect(func() -> void: _roster_open = false)
|
||||
upgrades.closed.connect(func() -> void: _upgrades_open = false)
|
||||
upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i))
|
||||
|
||||
|
||||
var _screen_centre := Vector2.ZERO
|
||||
@@ -66,7 +74,11 @@ func _process(_delta: float) -> void:
|
||||
_bound.item_used.connect(func(_i: StringName) -> void:
|
||||
sfx.play(Art.SFX_SHOOT, -10.0))
|
||||
_bound.characters_changed.connect(_refresh_characters)
|
||||
_bound.select_failed.connect(func(why: String) -> void: characters.set_status(why))
|
||||
_bound.upgrades_changed.connect(_refresh_upgrades)
|
||||
_refresh_upgrades()
|
||||
_bound.select_failed.connect(func(why: String) -> void:
|
||||
characters.set_status(why)
|
||||
upgrades.set_status(why))
|
||||
_refresh_characters()
|
||||
# The roster screen is shown exactly when there is nothing to play: first
|
||||
# login, or after the last living character died.
|
||||
@@ -79,11 +91,33 @@ func _process(_delta: float) -> void:
|
||||
_roster_open = false
|
||||
characters.visible = forced or _roster_open
|
||||
characters.set_dismissible(not forced)
|
||||
if _bound != null and _upgrades_open and not _bound.at_upgrade_npc():
|
||||
_upgrades_open = false
|
||||
upgrades.visible = _upgrades_open
|
||||
_follow_camera()
|
||||
menu.set_in_dungeon(_bound != null
|
||||
and _bound.instance_kind == Protocol.InstanceKind.DUNGEON)
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not event.is_action_pressed("interact"):
|
||||
return
|
||||
if _bound == null or not _bound.at_upgrade_npc():
|
||||
return
|
||||
# Interact is also pick-up. Loot wins, exactly as it does on the server, so
|
||||
# the key never does one thing here and another there.
|
||||
if not _bound.loot_in_reach().is_empty():
|
||||
return
|
||||
_upgrades_open = not _upgrades_open
|
||||
_refresh_upgrades()
|
||||
|
||||
|
||||
func _refresh_upgrades() -> void:
|
||||
if _bound != null:
|
||||
upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer,
|
||||
_bound.upgrades_taken)
|
||||
|
||||
|
||||
## Routed through the same held-escape channel the F key uses, rather than a
|
||||
## direct "teleport me" message -- the server has no such message, and adding
|
||||
## one would hand clients an instant, uninterruptible exit.
|
||||
|
||||
@@ -55,6 +55,7 @@ func _draw() -> void:
|
||||
return
|
||||
_draw_terrain()
|
||||
_draw_portals()
|
||||
_draw_upgrade_npc()
|
||||
for l in client.ground_loot():
|
||||
if _visible(l["pos"]):
|
||||
_draw_loot(l)
|
||||
@@ -185,6 +186,35 @@ func _draw_portals() -> void:
|
||||
_draw_portal_label(at, def)
|
||||
|
||||
|
||||
## The quartermaster. Drawn with a prompt rather than left to be discovered:
|
||||
## the whole upgrade system is behind one unmarked figure in a large room.
|
||||
func _draw_upgrade_npc() -> void:
|
||||
if client.instance_kind != Protocol.InstanceKind.LOBBY:
|
||||
return
|
||||
var at := client.upgrade_npc
|
||||
if not _visible(at):
|
||||
return
|
||||
var src := Art.frame(Art.NPC_IDLE, Art.anim_frame(_anim_time, 3))
|
||||
_draw_sprite(Art.TILESET, src, at, Color.WHITE, false, Art.PLAYER_ANCHOR)
|
||||
var waiting := client.upgrades_pending
|
||||
var tint := Color(1.0, 0.85, 0.4) if waiting > 0 else Color(0.65, 0.7, 0.82)
|
||||
var label := "QUARTERMASTER"
|
||||
if waiting > 0:
|
||||
label = "QUARTERMASTER — %d level-up%s to spend" % [
|
||||
waiting, "" if waiting == 1 else "s"]
|
||||
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 34.0), label,
|
||||
HORIZONTAL_ALIGNMENT_CENTER, 300.0, 14, tint)
|
||||
if client.at_upgrade_npc():
|
||||
draw_string(ThemeDB.fallback_font, at - Vector2(150.0, 18.0),
|
||||
"E talk", HORIZONTAL_ALIGNMENT_CENTER, 300.0, 13,
|
||||
Color(0.8, 0.85, 0.95))
|
||||
# A pending choice pulses, so it is visible from across the hub.
|
||||
if waiting > 0:
|
||||
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.005)
|
||||
draw_arc(at, 26.0 + 4.0 * pulse, 0.0, TAU, 32,
|
||||
Color(1.0, 0.85, 0.4, 0.25 + 0.35 * pulse), 2.0)
|
||||
|
||||
|
||||
func _draw_portal_label(at: Vector2, def: DungeonDef) -> void:
|
||||
var font := ThemeDB.fallback_font
|
||||
var top := at - Vector2(0.0, SimConfig.PORTAL_RADIUS + 26.0)
|
||||
|
||||
@@ -84,15 +84,33 @@ func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void:
|
||||
assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable")
|
||||
|
||||
|
||||
## Wall collision samples the bullet's position once per tick, so a bullet that
|
||||
## travels more than one tile per tick can step straight over a wall. Nothing in
|
||||
## the game comes close today, but a future "fast projectile" upgrade could, and
|
||||
## it would look like walls randomly failing.
|
||||
## Wall collision samples a position once per tick, so a bullet that covers
|
||||
## more than a tile in that tick passes through solid geometry. The ceiling
|
||||
## exists because upgrades multiply bullet speed and two Snipers would ask for
|
||||
## 2480 u/s -- above the threshold, and silently, since a tunnelling bullet
|
||||
## looks like a bullet.
|
||||
func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
assert_lt(SimConfig.PLAYER_BULLET_SPEED * 2.0, limit,
|
||||
"even at the doubled speed an upgrade could grant, a bullet must not " +
|
||||
"cross a whole tile in one tick or it will tunnel through walls")
|
||||
assert_lt(SimConfig.MAX_BULLET_SPEED, limit,
|
||||
"the speed ceiling itself has to be under a tile per tick")
|
||||
var stacked: Array[StringName] = []
|
||||
for _i in 6:
|
||||
stacked.append(Upgrades.SNIPER)
|
||||
assert_lt(PlayerStats.build(stacked).bullet_speed, limit,
|
||||
"%d stacked Snipers must not produce a tunnelling bullet" % stacked.size())
|
||||
|
||||
|
||||
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
var emitters: Array[BulletEmitter] = []
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
|
||||
emitters.append_array(Content.enemy(id).emitters)
|
||||
for phase in Content.warden().phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for e in emitters:
|
||||
# Accelerating bullets reach their top speed at the end of their life.
|
||||
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
|
||||
assert_lt(top, limit, "an emitter reaches %.0f u/s" % top)
|
||||
|
||||
|
||||
func test_a_pool_with_no_map_does_not_cull() -> void:
|
||||
|
||||
@@ -210,3 +210,105 @@ func test_setting_an_inventory_on_a_missing_character_is_harmless() -> void:
|
||||
var nothing: Array[StringName] = [Items.HEALTH_POTION]
|
||||
store.set_inventory(ACC, "no-such-id", nothing)
|
||||
assert_eq(store.characters_for(ACC).size(), 0)
|
||||
|
||||
|
||||
# --- Upgrades ---------------------------------------------------------------
|
||||
|
||||
func test_a_level_banks_a_choice_and_puts_three_on_the_table() -> void:
|
||||
var c := store.create_character(ACC, "Riser")
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
assert_eq(c.pending_choices, 1)
|
||||
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES)
|
||||
|
||||
|
||||
## The offer is rolled once and held. If it regenerated per request, closing and
|
||||
## reopening the screen would be a free reroll until a legendary appeared.
|
||||
func test_the_offer_is_stable_until_something_is_taken() -> void:
|
||||
var c := store.create_character(ACC, "Picky")
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
var first := c.offer.duplicate()
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
assert_eq(c.offer, first, "banking another level must not reroll the table")
|
||||
|
||||
|
||||
## Two levels in one run owe two choices. Losing one for doing well is a
|
||||
## punishment nobody would guess at.
|
||||
func test_choices_queue() -> void:
|
||||
var c := store.create_character(ACC, "Fast")
|
||||
store.grant_choices(ACC, c.id, 3)
|
||||
assert_eq(c.pending_choices, 3)
|
||||
store.take_upgrade(ACC, c.id, 0)
|
||||
assert_eq(c.pending_choices, 2)
|
||||
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES, "and the next three appear")
|
||||
|
||||
|
||||
func test_taking_an_upgrade_records_it_and_rerolls_the_table() -> void:
|
||||
var c := store.create_character(ACC, "Taker")
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
var wanted: StringName = c.offer[1]
|
||||
var taken := store.take_upgrade(ACC, c.id, 1)
|
||||
assert_eq(taken, wanted)
|
||||
assert_eq(c.upgrades, [wanted] as Array[StringName])
|
||||
assert_eq(c.pending_choices, 0)
|
||||
assert_true(c.offer.is_empty(), "nothing pending means nothing on the table")
|
||||
|
||||
|
||||
func test_taking_without_a_pending_choice_is_refused() -> void:
|
||||
var c := store.create_character(ACC, "Greedy")
|
||||
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
|
||||
assert_eq(c.upgrades.size(), 0)
|
||||
|
||||
|
||||
## The index arrives from a client, so nonsense has to be a refusal rather than
|
||||
## a crash or a free upgrade.
|
||||
func test_an_index_outside_the_offer_is_refused() -> void:
|
||||
var c := store.create_character(ACC, "Sneaky")
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
assert_eq(store.take_upgrade(ACC, c.id, -1), &"")
|
||||
assert_eq(store.take_upgrade(ACC, c.id, 99), &"")
|
||||
assert_eq(c.pending_choices, 1, "and costs nothing")
|
||||
|
||||
|
||||
func test_a_dead_character_cannot_spend_a_choice() -> void:
|
||||
var c := store.create_character(ACC, "Late")
|
||||
store.grant_choices(ACC, c.id, 1)
|
||||
store.retire_character(ACC, c.id)
|
||||
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
|
||||
|
||||
|
||||
func test_upgrades_and_pending_choices_survive_a_reload() -> void:
|
||||
var c := store.create_character(ACC, "Persistent")
|
||||
store.grant_choices(ACC, c.id, 2)
|
||||
var taken := store.take_upgrade(ACC, c.id, 0)
|
||||
var offer := c.offer.duplicate()
|
||||
|
||||
var reloaded := CharacterStore.new(store._path)
|
||||
assert_true(reloaded.load_from_disk())
|
||||
var got := reloaded.get_character(ACC, c.id)
|
||||
assert_eq(got.upgrades, [taken] as Array[StringName])
|
||||
assert_eq(got.pending_choices, 1)
|
||||
assert_eq(got.offer, offer,
|
||||
"the table has to survive a restart, or it is a reroll on every crash")
|
||||
|
||||
|
||||
## Upgrades change what a character IS, so the roster has to show the real
|
||||
## number rather than the one the level alone implies.
|
||||
func test_the_roster_health_reflects_the_upgrades_taken() -> void:
|
||||
var c := store.create_character(ACC, "Fragile")
|
||||
var before := c.max_hp()
|
||||
c.upgrades.append(Upgrades.GLASS_CANNON)
|
||||
assert_eq(c.max_hp(), before / 2)
|
||||
|
||||
|
||||
## A save written by a build with an upgrade this one lacks must not leave a
|
||||
## phantom that counts toward the flat damage bonus and does nothing else.
|
||||
func test_an_unknown_upgrade_in_a_save_is_dropped() -> void:
|
||||
var restored := Character.from_dict({
|
||||
"id": "z", "name": "Old", "xp": 0,
|
||||
"upgrades": ["sniper", "telekinesis"],
|
||||
"pending_choices": 2,
|
||||
"offer": ["poison", "not_a_thing"],
|
||||
})
|
||||
assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName])
|
||||
assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName])
|
||||
assert_eq(restored.pending_choices, 2)
|
||||
|
||||
@@ -83,7 +83,11 @@ func test_omitting_the_observer_encodes_the_whole_world() -> void:
|
||||
## real content rather than guessed. Adding a faster or longer-lived bullet
|
||||
## should fail here rather than produce invisible damage in play.
|
||||
func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
|
||||
var worst := SimConfig.PLAYER_BULLET_SPEED \
|
||||
# The player's shot is the longest-travelling bullet in the game once
|
||||
# upgrades are involved, so the worst case is measured at the speed ceiling
|
||||
# rather than at the base speed. test_interest used to model only static
|
||||
# content, which upgrades quietly invalidated.
|
||||
var worst := SimConfig.MAX_BULLET_SPEED \
|
||||
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
|
||||
var emitters: Array[BulletEmitter] = []
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
|
||||
@@ -95,3 +99,15 @@ func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
|
||||
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
|
||||
"a bullet spawned just outside the radius must not be able to reach " +
|
||||
"ground the player can see before it expires (worst travel %.0f)" % worst)
|
||||
|
||||
|
||||
## The ceiling is what makes the radius above computable at all. Without it a
|
||||
## stack of Snipers would push bullet travel past whatever radius was chosen,
|
||||
## and the failure would be invisible damage rather than a failing test.
|
||||
func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
|
||||
var ids: Array[StringName] = []
|
||||
for _i in 8:
|
||||
ids.append(Upgrades.SNIPER)
|
||||
var travel := PlayerStats.build(ids).bullet_speed \
|
||||
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
|
||||
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)
|
||||
|
||||
@@ -436,3 +436,50 @@ func test_an_empty_portal_list_is_safe() -> void:
|
||||
assert_eq(NetCodec.decode_portals(
|
||||
NetCodec.encode_portals([] as Array[SimPortal])).size(), 0)
|
||||
assert_eq(NetCodec.decode_portals(PackedByteArray()).size(), 0)
|
||||
|
||||
|
||||
# --- Upgrade state ----------------------------------------------------------
|
||||
|
||||
func test_upgrade_state_round_trips() -> void:
|
||||
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON, Upgrades.ERASER]
|
||||
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.SPREAD, Upgrades.GLASS_CANNON]
|
||||
var out := NetCodec.decode_upgrade_state(
|
||||
NetCodec.encode_upgrade_state(2, offer, taken))
|
||||
assert_eq(int(out["pending"]), 2)
|
||||
assert_eq(out["offer"], offer)
|
||||
assert_eq(out["taken"], taken, "duplicates are meaningful and must survive")
|
||||
|
||||
|
||||
func test_an_empty_upgrade_state_is_safe() -> void:
|
||||
var none: Array[StringName] = []
|
||||
var out := NetCodec.decode_upgrade_state(
|
||||
NetCodec.encode_upgrade_state(0, none, none))
|
||||
assert_eq(int(out["pending"]), 0)
|
||||
assert_eq((out["offer"] as Array).size(), 0)
|
||||
assert_eq((out["taken"] as Array).size(), 0)
|
||||
assert_eq(int(NetCodec.decode_upgrade_state(PackedByteArray())["pending"]), 0)
|
||||
|
||||
|
||||
func test_a_truncated_upgrade_packet_does_not_read_past_the_end() -> void:
|
||||
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON]
|
||||
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.DOUBLESHOT, Upgrades.ERASER]
|
||||
var full := NetCodec.encode_upgrade_state(1, offer, taken)
|
||||
for cut in range(1, full.size()):
|
||||
var out := NetCodec.decode_upgrade_state(full.slice(0, cut))
|
||||
assert_lte((out["offer"] as Array).size(), 2)
|
||||
assert_lte((out["taken"] as Array).size(), 3)
|
||||
assert_eq((NetCodec.decode_upgrade_state(full)["taken"] as Array).size(), 3)
|
||||
|
||||
|
||||
## An index this build does not know decodes to nothing and is dropped, rather
|
||||
## than becoming whatever upgrade happens to sit at that position.
|
||||
func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void:
|
||||
var b := StreamPeerBuffer.new()
|
||||
b.big_endian = false
|
||||
b.put_u8(1)
|
||||
b.put_u8(2)
|
||||
b.put_u8(Upgrades.index_of(Upgrades.SNIPER))
|
||||
b.put_u8(200)
|
||||
b.put_u16(0)
|
||||
var out := NetCodec.decode_upgrade_state(b.data_array)
|
||||
assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName])
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
extends GutTest
|
||||
## What upgrades actually do in the simulation: shot geometry, splitting,
|
||||
## poison and the eraser.
|
||||
##
|
||||
## Driven through the real firing and hit-resolution paths rather than by
|
||||
## calling the effects directly, because the claim being tested is that
|
||||
## SimWorld needs no per-upgrade branches -- only that PlayerStats reaches it.
|
||||
|
||||
const ME := 1
|
||||
|
||||
var world: SimWorld
|
||||
var me: SimPlayer
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
world = SimWorld.new(11)
|
||||
me = world.add_player(ME, "me")
|
||||
me.pos = Vector2.ZERO
|
||||
me.spawn_grace = 0
|
||||
|
||||
|
||||
func _give(ids: Array) -> void:
|
||||
var typed: Array[StringName] = []
|
||||
for id in ids:
|
||||
typed.append(id)
|
||||
me.stats = PlayerStats.build(typed)
|
||||
me.recompute_max_hp()
|
||||
|
||||
|
||||
## One trigger pull, aimed along +x.
|
||||
func _fire_once() -> int:
|
||||
me.aim = 0.0
|
||||
me.fire_cooldown = 0
|
||||
world.pool.clear()
|
||||
var frames: Array[InputFrame] = [
|
||||
InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_FIRE)]
|
||||
world.queue_input(ME, frames)
|
||||
world.step()
|
||||
return world.pool.live_count
|
||||
|
||||
|
||||
func _player_bullets() -> Array[int]:
|
||||
var out: Array[int] = []
|
||||
for i in world.pool.high_water:
|
||||
if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_PLAYER:
|
||||
out.append(i)
|
||||
return out
|
||||
|
||||
|
||||
# --- Shot geometry ----------------------------------------------------------
|
||||
|
||||
func test_a_plain_shot_is_one_bullet() -> void:
|
||||
assert_eq(_fire_once(), 1)
|
||||
|
||||
|
||||
func test_spread_adds_two_and_fans_them() -> void:
|
||||
_give([Upgrades.SPREAD])
|
||||
assert_eq(_fire_once(), 3)
|
||||
var angles: Array[float] = []
|
||||
for i in _player_bullets():
|
||||
angles.append(world.pool.vel[i].angle())
|
||||
angles.sort()
|
||||
assert_almost_eq(angles[1], 0.0, 0.01, "the aimed shot is still dead ahead")
|
||||
assert_almost_eq(angles[0], -deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01)
|
||||
assert_almost_eq(angles[2], deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01)
|
||||
|
||||
|
||||
func test_two_spreads_widen_the_cone_rather_than_doubling_the_middle() -> void:
|
||||
_give([Upgrades.SPREAD, Upgrades.SPREAD])
|
||||
assert_eq(_fire_once(), 5)
|
||||
var widest := 0.0
|
||||
for i in _player_bullets():
|
||||
widest = maxf(widest, absf(world.pool.vel[i].angle()))
|
||||
assert_almost_eq(widest, deg_to_rad(SimConfig.SPREAD_STEP_DEG * 2.0), 0.01)
|
||||
|
||||
|
||||
## Parallel, not fanned: same heading, offset sideways. That is the difference
|
||||
## between Doubleshot and Spread, and it is entirely in the geometry.
|
||||
func test_doubleshot_adds_a_parallel_projectile() -> void:
|
||||
_give([Upgrades.DOUBLESHOT])
|
||||
assert_eq(_fire_once(), 2)
|
||||
var offsets: Array[float] = []
|
||||
for i in _player_bullets():
|
||||
assert_almost_eq(world.pool.vel[i].angle(), 0.0, 0.01,
|
||||
"parallel means the same heading")
|
||||
offsets.append(world.pool.pos[i].y)
|
||||
assert_gt(absf(offsets[0] - offsets[1]), 1.0, "and a different lane")
|
||||
|
||||
|
||||
func test_spread_and_doubleshot_stack_into_one_volley() -> void:
|
||||
_give([Upgrades.SPREAD, Upgrades.DOUBLESHOT])
|
||||
assert_eq(_fire_once(), 4, "one aimed, two fanned, one parallel")
|
||||
|
||||
|
||||
func test_sniper_slows_the_trigger_and_speeds_the_bullet() -> void:
|
||||
_give([Upgrades.SNIPER])
|
||||
_fire_once()
|
||||
assert_eq(me.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN * 2)
|
||||
for i in _player_bullets():
|
||||
assert_almost_eq(world.pool.vel[i].length(),
|
||||
SimConfig.PLAYER_BULLET_SPEED * 2.0, 1.0)
|
||||
|
||||
|
||||
func test_a_shot_carries_the_shooters_upgrades_onto_the_bullet() -> void:
|
||||
_give([Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER])
|
||||
_fire_once()
|
||||
for i in _player_bullets():
|
||||
assert_eq(world.pool.split[i], 1)
|
||||
assert_gt(world.pool.poison[i], 0.0)
|
||||
assert_gt(world.pool.erase[i], 0.0)
|
||||
|
||||
|
||||
## A bullet keeps what it was fired with. Taking Poison while a shot is in
|
||||
## flight must not reach back and poison it.
|
||||
func test_a_bullet_in_flight_is_not_changed_by_a_later_upgrade() -> void:
|
||||
_fire_once()
|
||||
var before := _player_bullets()
|
||||
_give([Upgrades.POISON])
|
||||
for i in before:
|
||||
assert_eq(world.pool.poison[i], 0.0)
|
||||
|
||||
|
||||
# --- Split shot -------------------------------------------------------------
|
||||
|
||||
func _shoot_at(target: Vector2, split: int, poison: float = 0.0,
|
||||
erase: float = 0.0) -> int:
|
||||
# Placed just short of the target with a crawl, so exactly one tick of
|
||||
# integration puts it inside and hit resolution runs on it.
|
||||
var slot := world.pool.spawn(target - Vector2(1.0, 0.0), Vector2(60.0, 0.0),
|
||||
SimConfig.PLAYER_BULLET_RADIUS, 90, SimConfig.PLAYER_BULLET_DAMAGE,
|
||||
SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT)
|
||||
world.pool.set_mods(slot, split, poison, erase)
|
||||
return slot
|
||||
|
||||
|
||||
func test_a_hit_with_a_split_charge_produces_two_children() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 1)
|
||||
world.step()
|
||||
assert_eq(_player_bullets().size(), 2, "the parent is consumed, two children remain")
|
||||
|
||||
|
||||
func test_the_children_leave_at_forty_five_degrees_to_either_side() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 1)
|
||||
world.step()
|
||||
var angles: Array[float] = []
|
||||
for i in _player_bullets():
|
||||
angles.append(rad_to_deg(world.pool.vel[i].angle()))
|
||||
angles.sort()
|
||||
assert_almost_eq(angles[0], -SimConfig.SPLIT_ANGLE_DEG, 0.5)
|
||||
assert_almost_eq(angles[1], SimConfig.SPLIT_ANGLE_DEG, 0.5)
|
||||
|
||||
|
||||
## Children are born past the target. Spawned on top of it they would be
|
||||
## resolved against it again on the same tick -- a free second hit, and with
|
||||
## several charges a free chain of them.
|
||||
func test_children_do_not_immediately_hit_the_enemy_they_were_born_on() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
var before := e.hp
|
||||
_shoot_at(e.pos, 1)
|
||||
# Several ticks, not one: children spawned during hit resolution are not
|
||||
# visited again until the NEXT tick, so a single step cannot tell a child
|
||||
# born clear of the enemy from one born inside it.
|
||||
for _i in 8:
|
||||
world.step()
|
||||
assert_eq(before - e.hp, SimConfig.PLAYER_BULLET_DAMAGE,
|
||||
"exactly one hit's worth of damage, not three")
|
||||
|
||||
|
||||
func test_a_child_of_a_single_charge_cannot_split_again() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 1)
|
||||
world.step()
|
||||
for i in _player_bullets():
|
||||
assert_eq(world.pool.split[i], 0, "the charge was spent by the parent")
|
||||
|
||||
|
||||
func test_two_charges_let_a_child_split_once_more() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 2)
|
||||
world.step()
|
||||
for i in _player_bullets():
|
||||
assert_eq(world.pool.split[i], 1)
|
||||
|
||||
|
||||
func test_a_shot_with_no_charges_does_not_split() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 0)
|
||||
world.step()
|
||||
assert_eq(_player_bullets().size(), 0)
|
||||
|
||||
|
||||
func test_splitting_works_on_the_boss_too() -> void:
|
||||
var boss := world.spawn_boss(Content.warden())
|
||||
boss.pos = Vector2(150.0, 0.0)
|
||||
_shoot_at(boss.pos, 1)
|
||||
world.step()
|
||||
assert_eq(_player_bullets().size(), 2)
|
||||
|
||||
|
||||
# --- Poison -----------------------------------------------------------------
|
||||
|
||||
func test_a_poisoned_hit_keeps_damaging_after_the_bullet_is_gone() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
_shoot_at(e.pos, 0, 0.5)
|
||||
world.step()
|
||||
var after_hit := e.hp
|
||||
for _i in SimConfig.POISON_DURATION_TICKS:
|
||||
world.step()
|
||||
assert_lt(e.hp, after_hit, "poison should have kept working")
|
||||
|
||||
|
||||
## The user's decision: stacks are independent, so a second dose adds to the
|
||||
## first rather than replacing it.
|
||||
func test_doses_stack_rather_than_refreshing() -> void:
|
||||
var one := PoisonTrack.new()
|
||||
one.add(600.0, 0)
|
||||
var two := PoisonTrack.new()
|
||||
two.add(600.0, 0)
|
||||
two.add(600.0, 0)
|
||||
var single := 0
|
||||
var double := 0
|
||||
# From tick 0, the way the world drives it: a dose applied on tick T ticks
|
||||
# on T and the DURATION-1 ticks after it.
|
||||
for t in SimConfig.POISON_DURATION_TICKS:
|
||||
single += one.step(t)
|
||||
double += two.step(t)
|
||||
assert_almost_eq(float(single), 600.0, 2.0)
|
||||
assert_almost_eq(float(double), 1200.0, 2.0,
|
||||
"two doses must deliver two doses' worth")
|
||||
|
||||
|
||||
func test_a_dose_delivers_its_whole_value_over_the_window() -> void:
|
||||
var track := PoisonTrack.new()
|
||||
track.add(300.0, 0)
|
||||
var total := 0
|
||||
for t in SimConfig.POISON_DURATION_TICKS:
|
||||
total += track.step(t)
|
||||
assert_eq(total, 300, "the whole dose, to the hit point")
|
||||
track.step(SimConfig.POISON_DURATION_TICKS)
|
||||
assert_false(track.active(), "and then stops")
|
||||
|
||||
|
||||
## Doses all last the same number of ticks, so they expire in the order they
|
||||
## were added. That is the property that lets the queue be a plain FIFO whose
|
||||
## front is the only entry ever examined -- and therefore the property that
|
||||
## makes dozens of concurrent stacks cost nothing.
|
||||
func test_poison_expires_in_the_order_it_was_applied() -> void:
|
||||
var track := PoisonTrack.new()
|
||||
track.add(600.0, 0)
|
||||
track.add(600.0, 300)
|
||||
assert_eq(track.dose_count(), 2)
|
||||
for t in range(0, SimConfig.POISON_DURATION_TICKS + 1):
|
||||
track.step(t)
|
||||
assert_true(track.active(), "the later dose is still running")
|
||||
assert_eq(track.dose_count(), 1, "the earlier one retired first")
|
||||
for t in range(SimConfig.POISON_DURATION_TICKS + 1, 300 + SimConfig.POISON_DURATION_TICKS + 1):
|
||||
track.step(t)
|
||||
assert_false(track.active())
|
||||
assert_eq(track.dose_count(), 0)
|
||||
|
||||
|
||||
func test_fractions_below_one_hit_point_are_not_lost() -> void:
|
||||
var track := PoisonTrack.new()
|
||||
# 3 damage over 600 ticks is 0.005/tick -- it would round to nothing on
|
||||
# every single tick without the carry.
|
||||
track.add(3.0, 0)
|
||||
var total := 0
|
||||
for t in SimConfig.POISON_DURATION_TICKS:
|
||||
total += track.step(t)
|
||||
assert_eq(total, 3)
|
||||
|
||||
|
||||
## Poison must be able to finish something off, or a kill it caused would never
|
||||
## be scored and the experience would vanish.
|
||||
func test_a_kill_by_poison_is_still_announced() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 3
|
||||
e.poison_track().add(600.0, world.tick)
|
||||
var died := false
|
||||
for _i in 120:
|
||||
world.step()
|
||||
for ev in world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.ENEMY_DIED:
|
||||
died = true
|
||||
world.drain_events()
|
||||
assert_true(died, "experience is keyed on this event")
|
||||
|
||||
|
||||
## Poison lands many times a second. Putting each tick on the reliable event
|
||||
## channel would be a flood, and the client learns hp from the snapshot anyway.
|
||||
func test_poison_ticks_are_not_announced_as_hits() -> void:
|
||||
var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 100000
|
||||
e.poison_track().add(6000.0, world.tick)
|
||||
world.drain_events()
|
||||
for _i in 120:
|
||||
world.step()
|
||||
var hits := 0
|
||||
for ev in world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.ENEMY_HIT:
|
||||
hits += 1
|
||||
assert_eq(hits, 0)
|
||||
assert_lt(e.hp, 100000, "but the damage still happened")
|
||||
|
||||
|
||||
## The boss takes extra damage in later phases. Poison is derived from the
|
||||
## damage actually applied, so it inherits that -- and must not be scaled a
|
||||
## second time when it ticks.
|
||||
func test_boss_poison_is_scaled_once_not_twice() -> void:
|
||||
var boss := world.spawn_boss(Content.warden())
|
||||
boss.pos = Vector2(150.0, 0.0)
|
||||
# Hurt into a phase that actually has an armour multiplier -- at full health
|
||||
# the multiplier is 1.0 and scaling twice is indistinguishable from scaling
|
||||
# once, which is exactly how this test used to pass while proving nothing.
|
||||
# Two things have to be true for this test to be able to fail. The boss has
|
||||
# to be in a phase with an armour multiplier at all -- at full health it is
|
||||
# 1.0, and scaling twice is then indistinguishable from scaling once. And
|
||||
# the per-tick poison has to be big enough that the multiplier survives
|
||||
# rounding: at 1 damage a tick, roundi(1 x 1.15) is still 1, so the bug
|
||||
# hides completely. A large pool gives both.
|
||||
boss.def.max_hp = 100000
|
||||
boss.hp = 20000 # 20% -> a phase that takes extra
|
||||
world.step()
|
||||
var armour := boss.current_phase().damage_taken_mult
|
||||
assert_gt(armour, 1.0, "setup: this phase has to have armour")
|
||||
var track := boss.poison_track()
|
||||
track.add(6000.0, world.tick) # 10 damage per tick
|
||||
var before := boss.hp
|
||||
for _i in SimConfig.POISON_DURATION_TICKS + 5:
|
||||
world.step()
|
||||
var dealt := float(before - boss.hp)
|
||||
assert_almost_eq(dealt, 6000.0, 60.0,
|
||||
"scaled a second time this would be about %.0f" % (6000.0 * armour))
|
||||
|
||||
|
||||
func test_a_replica_never_applies_poison() -> void:
|
||||
var replica := SimWorld.new(1)
|
||||
replica.authoritative = false
|
||||
var e := replica.spawn_enemy(Content.turret(), Vector2(150.0, 0.0))
|
||||
e.hp = 500
|
||||
e.poison_track().add(600.0, 0)
|
||||
for _i in 200:
|
||||
replica.step()
|
||||
assert_eq(e.hp, 500, "a client must never decide it dealt damage")
|
||||
|
||||
|
||||
# --- Eraser -----------------------------------------------------------------
|
||||
|
||||
func _enemy_bullet(at: Vector2) -> int:
|
||||
return world.pool.spawn(at, Vector2.ZERO, 8.0, 600, 5,
|
||||
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
||||
|
||||
|
||||
## Certainty rather than 1%, so the test measures the mechanism instead of the
|
||||
## random number generator.
|
||||
func test_an_erasing_shot_deletes_an_enemy_projectile_it_overlaps() -> void:
|
||||
var target := Vector2(100.0, 0.0)
|
||||
var incoming := _enemy_bullet(target)
|
||||
_shoot_at(target, 0, 0.0, 1.0)
|
||||
world.step()
|
||||
assert_eq(world.pool.alive[incoming], 0)
|
||||
|
||||
|
||||
func test_the_deletion_is_announced_so_no_client_keeps_a_phantom() -> void:
|
||||
var target := Vector2(100.0, 0.0)
|
||||
var incoming := _enemy_bullet(target)
|
||||
var incoming_uid: int = world.pool.uid[incoming]
|
||||
world.drain_events()
|
||||
_shoot_at(target, 0, 0.0, 1.0)
|
||||
world.step()
|
||||
var announced := false
|
||||
for ev in world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.BULLET_DESPAWN and int(ev["uid"]) == incoming_uid:
|
||||
announced = true
|
||||
assert_true(announced)
|
||||
|
||||
|
||||
func test_a_shot_without_the_upgrade_passes_straight_through() -> void:
|
||||
var target := Vector2(100.0, 0.0)
|
||||
var incoming := _enemy_bullet(target)
|
||||
_shoot_at(target, 0, 0.0, 0.0)
|
||||
world.step()
|
||||
assert_eq(world.pool.alive[incoming], 1,
|
||||
"enemy bullets are not obstacles for ordinary shots")
|
||||
|
||||
|
||||
func test_an_erasing_shot_is_not_consumed_by_erasing() -> void:
|
||||
var target := Vector2(100.0, 0.0)
|
||||
_enemy_bullet(target)
|
||||
var mine := _shoot_at(target, 0, 0.0, 1.0)
|
||||
world.step()
|
||||
assert_eq(world.pool.alive[mine], 1, "the shot keeps going")
|
||||
|
||||
|
||||
## One roll per shot per tick, not one per overlapping pair. A boss ring puts
|
||||
## dozens of bullets in the same place, and rolling against each would multiply
|
||||
## the 1% the player was promised.
|
||||
func test_only_one_projectile_is_erased_per_tick() -> void:
|
||||
var target := Vector2(100.0, 0.0)
|
||||
for _i in 5:
|
||||
_enemy_bullet(target)
|
||||
_shoot_at(target, 0, 0.0, 1.0)
|
||||
world.step()
|
||||
var left := 0
|
||||
for i in world.pool.high_water:
|
||||
if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_ENEMY:
|
||||
left += 1
|
||||
assert_eq(left, 4, "four of the five survive the tick")
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvmvcowixcc6s
|
||||
@@ -0,0 +1,226 @@
|
||||
extends GutTest
|
||||
## Upgrades: the registry, the damage formula, and how they stack.
|
||||
##
|
||||
## The formula is settled (see docs/DECISIONS.md): base x (1 + sum additive) x
|
||||
## product multiplicative, with every upgrade adding a flat +5% to the additive
|
||||
## pool on top of whatever else it does. These tests are the record of that.
|
||||
|
||||
const BASE_DMG := SimConfig.PLAYER_BULLET_DAMAGE
|
||||
|
||||
|
||||
func _stats(ids: Array) -> PlayerStats:
|
||||
var typed: Array[StringName] = []
|
||||
for id in ids:
|
||||
typed.append(id)
|
||||
return PlayerStats.build(typed)
|
||||
|
||||
|
||||
func test_every_registered_id_has_a_definition() -> void:
|
||||
for id in Upgrades.ORDER:
|
||||
assert_not_null(Upgrades.get_def(id), "%s is in ORDER with no definition" % id)
|
||||
|
||||
|
||||
func test_the_wire_index_round_trips() -> void:
|
||||
for id in Upgrades.ORDER:
|
||||
assert_eq(Upgrades.by_index(Upgrades.index_of(id)), id)
|
||||
assert_gt(Upgrades.index_of(id), 0, "0 is reserved for 'no upgrade'")
|
||||
assert_eq(Upgrades.by_index(0), &"")
|
||||
assert_eq(Upgrades.by_index(250), &"")
|
||||
assert_eq(Upgrades.index_of(&"telekinesis"), 0)
|
||||
|
||||
|
||||
func test_the_index_fits_the_byte_the_wire_gives_it() -> void:
|
||||
assert_lte(Upgrades.ORDER.size(), 254)
|
||||
|
||||
|
||||
func test_no_upgrades_means_exactly_the_base_numbers() -> void:
|
||||
var s := _stats([])
|
||||
assert_eq(s.damage, BASE_DMG)
|
||||
assert_eq(s.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN)
|
||||
assert_eq(s.bullet_speed, SimConfig.PLAYER_BULLET_SPEED)
|
||||
assert_eq(s.max_hp_mult, 1.0)
|
||||
assert_eq(s.upgrade_count, 0)
|
||||
|
||||
|
||||
## The +5% is a property of taking an upgrade, not of any particular one, so
|
||||
## even an upgrade with no damage effect of its own raises damage.
|
||||
func test_every_upgrade_carries_the_flat_damage_bonus() -> void:
|
||||
for id in Upgrades.ORDER:
|
||||
var s := _stats([id])
|
||||
var def := Upgrades.get_def(id)
|
||||
var expected := roundi(float(BASE_DMG)
|
||||
* (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS + def.damage_add)
|
||||
* def.damage_mult)
|
||||
assert_eq(s.damage, maxi(1, expected), "%s" % id)
|
||||
|
||||
|
||||
func test_split_shot_alone_still_raises_damage_by_the_flat_bonus() -> void:
|
||||
assert_eq(_stats([Upgrades.SPLIT_SHOT]).damage,
|
||||
roundi(float(BASE_DMG) * (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS)))
|
||||
|
||||
|
||||
## The settled formula, spelled out on the exact case that distinguishes it
|
||||
## from the alternatives: additive percentages pool, and Sniper multiplies the
|
||||
## pooled result rather than joining it.
|
||||
func test_the_damage_formula_is_additive_pool_times_multiplicative() -> void:
|
||||
var s := _stats([Upgrades.GLASS_CANNON, Upgrades.SPREAD, Upgrades.SNIPER])
|
||||
# +5% x3 = +15%, glass cannon +100%, spread -10% -> pool = 2.05
|
||||
# sniper x2 on top.
|
||||
assert_eq(s.damage, roundi(float(BASE_DMG) * 2.05 * 2.0))
|
||||
|
||||
|
||||
func test_two_snipers_multiply_rather_than_add() -> void:
|
||||
var one := _stats([Upgrades.SNIPER]).damage
|
||||
var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER]).damage
|
||||
# If Sniper were additive, two of them would be far less than four times
|
||||
# the base. Multiplicative means the second is worth as much as the first.
|
||||
assert_gt(float(two) / float(one), 1.9)
|
||||
|
||||
|
||||
func test_damage_never_falls_below_one() -> void:
|
||||
var stacked: Array[StringName] = []
|
||||
for _i in 6:
|
||||
stacked.append(Upgrades.DOUBLESHOT) # -50% each
|
||||
assert_gte(PlayerStats.build(stacked).damage, 1,
|
||||
"a shot that deals nothing is indistinguishable from a bug")
|
||||
|
||||
|
||||
# --- Fire rate, speed, health ------------------------------------------------
|
||||
|
||||
## "Half the fire rate" has to mean half the shots per second, which is a
|
||||
## doubled cooldown -- halving the cooldown would do the opposite of the brief.
|
||||
func test_sniper_halves_the_shots_per_second() -> void:
|
||||
assert_eq(_stats([Upgrades.SNIPER]).fire_cooldown,
|
||||
SimConfig.PLAYER_FIRE_COOLDOWN * 2)
|
||||
|
||||
|
||||
func test_the_cooldown_never_reaches_zero() -> void:
|
||||
var many: Array[StringName] = []
|
||||
for _i in 10:
|
||||
many.append(Upgrades.SNIPER)
|
||||
assert_gte(PlayerStats.build(many).fire_cooldown, 1)
|
||||
|
||||
|
||||
func test_sniper_doubles_bullet_speed_up_to_the_ceiling() -> void:
|
||||
assert_eq(_stats([Upgrades.SNIPER]).bullet_speed,
|
||||
SimConfig.PLAYER_BULLET_SPEED * 2.0)
|
||||
assert_false(_stats([Upgrades.SNIPER]).speed_capped)
|
||||
var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER])
|
||||
assert_eq(two.bullet_speed, SimConfig.MAX_BULLET_SPEED)
|
||||
assert_true(two.speed_capped, "the screen has to be able to say so")
|
||||
|
||||
|
||||
## Half of the LEVELLED maximum, so the trade stays a real price at level 15
|
||||
## instead of fading to a rounding error.
|
||||
func test_glass_cannon_halves_the_levelled_maximum() -> void:
|
||||
assert_eq(_stats([Upgrades.GLASS_CANNON]).max_hp_mult, 0.5)
|
||||
var p := SimPlayer.new()
|
||||
p.level = Progression.MAX_LEVEL
|
||||
p.stats = _stats([Upgrades.GLASS_CANNON])
|
||||
p.recompute_max_hp()
|
||||
assert_eq(p.max_hp, Progression.max_hp_for_level(Progression.MAX_LEVEL) / 2)
|
||||
|
||||
|
||||
func test_two_glass_cannons_multiply() -> void:
|
||||
assert_almost_eq(_stats([Upgrades.GLASS_CANNON, Upgrades.GLASS_CANNON]).max_hp_mult,
|
||||
0.25, 0.0001)
|
||||
|
||||
|
||||
func test_health_can_never_be_scaled_to_zero() -> void:
|
||||
var many: Array[StringName] = []
|
||||
for _i in 12:
|
||||
many.append(Upgrades.GLASS_CANNON)
|
||||
var p := SimPlayer.new()
|
||||
p.stats = PlayerStats.build(many)
|
||||
p.recompute_max_hp()
|
||||
assert_gte(p.max_hp, 1)
|
||||
|
||||
|
||||
func test_dropping_below_the_new_maximum_is_immediate() -> void:
|
||||
var p := SimPlayer.new()
|
||||
p.hp = p.max_hp
|
||||
p.stats = _stats([Upgrades.GLASS_CANNON])
|
||||
p.recompute_max_hp()
|
||||
assert_eq(p.hp, p.max_hp, "taking glass cannon at full health must not leave you over cap")
|
||||
|
||||
|
||||
# --- Shot counts -------------------------------------------------------------
|
||||
|
||||
func test_spread_and_doubleshot_add_projectiles() -> void:
|
||||
assert_eq(_stats([Upgrades.SPREAD]).side_shots, 2)
|
||||
assert_eq(_stats([Upgrades.SPREAD, Upgrades.SPREAD]).side_shots, 4)
|
||||
assert_eq(_stats([Upgrades.DOUBLESHOT]).parallel_shots, 1)
|
||||
|
||||
|
||||
func test_split_charges_accumulate() -> void:
|
||||
assert_eq(_stats([Upgrades.SPLIT_SHOT]).split_charges, 1)
|
||||
assert_eq(_stats([Upgrades.SPLIT_SHOT, Upgrades.SPLIT_SHOT]).split_charges, 2)
|
||||
|
||||
|
||||
func test_erase_chance_is_clamped_to_certainty() -> void:
|
||||
var many: Array[StringName] = []
|
||||
for _i in 200:
|
||||
many.append(Upgrades.ERASER)
|
||||
assert_lte(PlayerStats.build(many).erase_chance, 1.0)
|
||||
|
||||
|
||||
## A save from a build with an upgrade this one lacks must produce coherent
|
||||
## stats, not a phantom that counts toward the +5% and does nothing else.
|
||||
func test_an_unknown_upgrade_is_ignored_entirely() -> void:
|
||||
var mixed: Array[StringName] = [Upgrades.SNIPER, &"telekinesis"]
|
||||
var s := PlayerStats.build(mixed)
|
||||
assert_eq(s.upgrade_count, 1)
|
||||
assert_eq(s.damage, _stats([Upgrades.SNIPER]).damage)
|
||||
|
||||
|
||||
# --- Offers ------------------------------------------------------------------
|
||||
|
||||
func test_an_offer_has_the_configured_number_of_distinct_options() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
for seed_value in 40:
|
||||
rng.seed = seed_value
|
||||
var offer := Upgrades.roll_offer(rng, SimConfig.UPGRADE_CHOICES)
|
||||
assert_eq(offer.size(), SimConfig.UPGRADE_CHOICES)
|
||||
var seen := {}
|
||||
for id in offer:
|
||||
assert_false(seen.has(id), "the same option twice in one offer")
|
||||
seen[id] = true
|
||||
assert_not_null(Upgrades.get_def(id))
|
||||
|
||||
|
||||
## Rarity has to actually mean something, or the weights are decoration.
|
||||
func test_commons_come_up_far_more_often_than_legendaries() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 99
|
||||
var counts := {}
|
||||
for _i in 600:
|
||||
for id in Upgrades.roll_offer(rng, 1):
|
||||
counts[id] = int(counts.get(id, 0)) + 1
|
||||
var commons := int(counts.get(Upgrades.SPLIT_SHOT, 0))
|
||||
var legendary := int(counts.get(Upgrades.ERASER, 0))
|
||||
assert_gt(commons, legendary * 3,
|
||||
"a legendary that turns up as often as a common is not a legendary")
|
||||
|
||||
|
||||
## Asking for more options than exist must terminate rather than spin looking
|
||||
## for a distinct one it can never find.
|
||||
func test_asking_for_more_options_than_exist_is_safe() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 3
|
||||
var offer := Upgrades.roll_offer(rng, Upgrades.ORDER.size() + 5)
|
||||
assert_eq(offer.size(), Upgrades.ORDER.size())
|
||||
|
||||
|
||||
## Duplicates across offers are the point: taking Split Shot twice is how a
|
||||
## shot splits twice, so an upgrade you hold must still be offerable.
|
||||
func test_an_upgrade_you_already_hold_can_be_offered_again() -> void:
|
||||
var rng := RandomNumberGenerator.new()
|
||||
var seen_repeat := false
|
||||
for seed_value in 60:
|
||||
rng.seed = seed_value
|
||||
var a := Upgrades.roll_offer(rng, 1)
|
||||
var b := Upgrades.roll_offer(rng, 1)
|
||||
if not a.is_empty() and a == b:
|
||||
seen_repeat = true
|
||||
break
|
||||
assert_true(seen_repeat, "offers must not exclude what you already have")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dx1wdyhuevon8
|
||||
@@ -0,0 +1,209 @@
|
||||
extends Node
|
||||
## End-to-end check of the upgrade loop: level -> banked choice -> the three
|
||||
## on the table -> spent at the NPC -> new stats -> persisted.
|
||||
##
|
||||
## godot --headless --path . res://tools/diag_upgrades.tscn
|
||||
##
|
||||
## Runs as a scene because ServerRuntime needs the Net autoload. Exits non-zero
|
||||
## on any failure, so it gates like a test.
|
||||
##
|
||||
## The unit tests cover the formula and the store in isolation. What only shows
|
||||
## up here is the wiring between them: that a level earned in a dungeon reaches
|
||||
## the hub as a choice, that the server refuses one made from the wrong place,
|
||||
## and that taking it changes the numbers the player is actually shooting with.
|
||||
|
||||
const STORE_PATH := "user://diag_upgrades.json"
|
||||
|
||||
var _fails: Array[String] = []
|
||||
var _step: int = 0
|
||||
var _srv: ServerRuntime
|
||||
var _account: int = 616161
|
||||
var _character: Character
|
||||
var _offer_before: Array[StringName] = []
|
||||
var _damage_before: int = 0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
|
||||
GameOpts.bot_client = true
|
||||
GameOpts.account_override = _account
|
||||
if Net.host(27403) != OK:
|
||||
push_error("could not host")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
_srv = Net.server
|
||||
_srv.store = CharacterStore.new(STORE_PATH)
|
||||
_srv.store.load_from_disk()
|
||||
Net.start_local_client()
|
||||
|
||||
|
||||
func _check(ok: bool, what: String) -> void:
|
||||
if ok:
|
||||
print(" ok %s" % what)
|
||||
else:
|
||||
print(" FAIL %s" % what)
|
||||
_fails.append(what)
|
||||
|
||||
|
||||
func _me() -> SimPlayer:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
return inst.world.players.get(Net.LOCAL_PEER) if inst != null else null
|
||||
|
||||
|
||||
func _stored() -> Character:
|
||||
return _srv.store.get_character(_account, _character.id)
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
_step += 1
|
||||
match _step:
|
||||
20: _login()
|
||||
30: _earn_a_level()
|
||||
40: _refused_in_a_dungeon()
|
||||
50: _back_to_the_hub()
|
||||
60: _refused_away_from_the_npc()
|
||||
70: _take_it_at_the_npc()
|
||||
80: _it_changed_things()
|
||||
90: _refused_with_nothing_pending()
|
||||
100: _finish()
|
||||
|
||||
|
||||
func _login() -> void:
|
||||
_character = _srv.store.last_played(_account)
|
||||
_check(_character != null, "a character exists after login")
|
||||
if _character == null:
|
||||
_finish()
|
||||
return
|
||||
_check(_character.upgrades.is_empty(), "and starts with no upgrades")
|
||||
_check(_character.pending_choices == 0, "and nothing to spend")
|
||||
_check(Net.client.upgrades_pending == 0, "the client agrees")
|
||||
_damage_before = _me().stats.damage
|
||||
_check(_damage_before == SimConfig.PLAYER_BULLET_DAMAGE,
|
||||
"shooting for the base damage")
|
||||
|
||||
|
||||
## Levelling happens in a dungeon; the NPC is in the hub. The choice has to
|
||||
## survive the journey or it would be lost the moment it was earned.
|
||||
func _earn_a_level() -> void:
|
||||
_srv._send_to_dungeon(Net.LOCAL_PEER)
|
||||
_srv._grant_xp(Net.LOCAL_PEER, Progression.total_xp_for_level(3))
|
||||
var c := _stored()
|
||||
_check(c.level >= 2, "experience produced a level (now %d)" % c.level)
|
||||
_check(c.pending_choices == c.level - 1,
|
||||
"one choice per level gained (%d)" % c.pending_choices)
|
||||
_check(c.offer.size() == SimConfig.UPGRADE_CHOICES,
|
||||
"and %d options on the table" % SimConfig.UPGRADE_CHOICES)
|
||||
_check(Net.client.upgrades_pending == c.pending_choices,
|
||||
"the client was told without asking")
|
||||
_check(Net.client.upgrade_offer.size() == SimConfig.UPGRADE_CHOICES,
|
||||
"and was given the same table")
|
||||
_offer_before = c.offer.duplicate()
|
||||
|
||||
|
||||
func _refused_in_a_dungeon() -> void:
|
||||
Net.choose_upgrade(0)
|
||||
_check(_stored().upgrades.is_empty(),
|
||||
"an upgrade cannot be taken from inside a dungeon")
|
||||
_check(_stored().offer == _offer_before,
|
||||
"and the refusal does not reroll the table")
|
||||
|
||||
|
||||
func _back_to_the_hub() -> void:
|
||||
_srv._send_to_lobby(Net.LOCAL_PEER)
|
||||
# Stop the bot steering itself around the hub -- see _stand_at_the_npc().
|
||||
Net.client.set_physics_process(false)
|
||||
|
||||
|
||||
func _refused_away_from_the_npc() -> void:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
_check(inst != null and inst.kind == Protocol.InstanceKind.LOBBY,
|
||||
"back in the hub")
|
||||
var p := _me()
|
||||
# Far from the quartermaster. Where a player is standing is the one thing a
|
||||
# modified client cannot fake, which is why the check lives here and not in
|
||||
# the UI that greys out the button.
|
||||
p.pos = inst.world.upgrade_npc + Vector2(SimConfig.UPGRADE_NPC_RADIUS * 4.0, 0.0)
|
||||
Net.choose_upgrade(0)
|
||||
_check(_stored().upgrades.is_empty(),
|
||||
"standing across the room is not standing at the NPC")
|
||||
_check(_stored().pending_choices > 0, "and the choice is still owed")
|
||||
|
||||
|
||||
## Standing at the NPC is set deliberately from here on. Whether the player
|
||||
## happens to be near it is exactly what is under test, and a bot steering
|
||||
## itself around the hub would decide that at random.
|
||||
func _stand_at_the_npc() -> void:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
if inst != null and _me() != null:
|
||||
_me().pos = inst.world.upgrade_npc
|
||||
|
||||
|
||||
func _take_it_at_the_npc() -> void:
|
||||
var inst := _srv.instance_of(Net.LOCAL_PEER)
|
||||
var owed_before := _stored().pending_choices
|
||||
_stand_at_the_npc()
|
||||
_check(inst.world.at_upgrade_npc(_me().pos), "setup: standing at the NPC")
|
||||
Net.choose_upgrade(1)
|
||||
var c := _stored()
|
||||
_check(c.upgrades.size() == 1, "the upgrade is taken")
|
||||
_check(c.upgrades[0] == _offer_before[1],
|
||||
"and it is the one the index named, from the server's own table")
|
||||
_check(c.pending_choices == owed_before - 1,
|
||||
"exactly one choice was spent (%d -> %d)" % [owed_before, c.pending_choices])
|
||||
_check(not c.offer.is_empty() or c.pending_choices == 0,
|
||||
"a table is on offer exactly while something is pending")
|
||||
_check(c.offer != _offer_before, "and the next table is a fresh one")
|
||||
|
||||
|
||||
func _it_changed_things() -> void:
|
||||
var p := _me()
|
||||
var c := _stored()
|
||||
_check(p.stats.upgrade_count == c.upgrades.size(),
|
||||
"the player in the world is shooting with the new list")
|
||||
var expected := PlayerStats.build(c.upgrades)
|
||||
_check(p.stats.damage == expected.damage,
|
||||
"damage matches the formula (%d -> %d)" % [_damage_before, p.stats.damage])
|
||||
_check(p.max_hp == maxi(1, roundi(float(Progression.max_hp_for_level(p.level))
|
||||
* expected.max_hp_mult)), "and maximum health follows the multiplier")
|
||||
_check(Net.client.upgrades_taken.size() == c.upgrades.size(),
|
||||
"the client's copy was refreshed")
|
||||
|
||||
# Persisted, not merely held in memory: the character store is what makes an
|
||||
# upgrade survive a restart, and it is written the moment one is taken.
|
||||
var reloaded := CharacterStore.new(STORE_PATH)
|
||||
reloaded.load_from_disk()
|
||||
var saved := reloaded.get_character(_account, _character.id)
|
||||
_check(saved != null and saved.upgrades == c.upgrades,
|
||||
"the upgrade is on disk already")
|
||||
_check(saved != null and saved.offer == c.offer,
|
||||
"and so is the table, so a crash is not a reroll")
|
||||
|
||||
|
||||
func _refused_with_nothing_pending() -> void:
|
||||
var c := _stored()
|
||||
# Bounded rather than a plain "while owed": a choice that were ever refused
|
||||
# here would otherwise spin forever instead of reporting a failure, which is
|
||||
# precisely what it did the first time this was written.
|
||||
var guard := Progression.MAX_LEVEL + 2
|
||||
while c.pending_choices > 0 and guard > 0:
|
||||
guard -= 1
|
||||
_stand_at_the_npc()
|
||||
Net.choose_upgrade(0)
|
||||
c = _stored()
|
||||
_check(c.pending_choices == 0, "every owed choice could actually be spent")
|
||||
var held := c.upgrades.size()
|
||||
_stand_at_the_npc()
|
||||
Net.choose_upgrade(0)
|
||||
_check(_stored().upgrades.size() == held,
|
||||
"with nothing owed, asking again gets nothing")
|
||||
_check(_stored().offer.is_empty(), "and no table is left on the counter")
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("---")
|
||||
if _fails.is_empty():
|
||||
print("UPGRADES_OK")
|
||||
else:
|
||||
print("UPGRADES_FAIL (%d)" % _fails.size())
|
||||
Net.shutdown()
|
||||
get_tree().quit(0 if _fails.is_empty() else 1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://70ijixd36nt5
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://tools/diag_upgrades.gd" id="1"]
|
||||
|
||||
[node name="DiagUpgrades" type="Node"]
|
||||
script = ExtResource("1")
|
||||
@@ -134,6 +134,11 @@ check "the dropped body is released" "$OUT/server.log" "released from instance
|
||||
check "a polite disconnect leaves too" "$OUT/leavebot.log" "BOT_GRACEFUL_LEAVE"
|
||||
check "a polite disconnect is also channelled" \
|
||||
"$OUT/server.log" "'leavebot' dropped in instance [0-9]+, channelling out"
|
||||
# No assertion for upgrades here on purpose. Bots never walk to the
|
||||
# quartermaster, so anything written would either duplicate a check above or
|
||||
# assert nothing at all. The upgrade-state message DOES cross the wire on every
|
||||
# login, so a malformed one shows up in the script-error refutations below --
|
||||
# and tools/diag_upgrades.tscn covers the loop itself.
|
||||
refute "no server script errors" "$OUT/server.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
refute "no client script errors" "$OUT/bot1.log" "SCRIPT ERROR|Parse Error|USER ERROR"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user