Compare commits
2 Commits
b351bc2d55
...
e0c1e0d5c6
| Author | SHA1 | Date | |
|---|---|---|---|
| e0c1e0d5c6 | |||
| cb2c1e7840 |
@@ -92,7 +92,7 @@ and `tests/integration/test_replica_parity.gd` pin this down.
|
||||
| --- | --- |
|
||||
| `src/sim/` | The whole game as plain RefCounted objects. No nodes, no physics server, no rendering. |
|
||||
| `src/actors/` | Data-only `Resource` definitions: `EnemyDef`, `BossDef`, `ItemDef`, `LootDrop`, `DungeonDef`, `UpgradeDef`. Shapes, not instances. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. |
|
||||
| `src/sim/patterns/` | Bullet emitters — the authoring surface for every enemy and boss. Emitters are stateless: they are shared resources, and two bosses of the same kind must not stomp each other. |
|
||||
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunk streaming. |
|
||||
| `src/sim/map_gen.gd` | Dungeon generation; `build()` is the only entry point. |
|
||||
| `src/content/rooms.gd` | Hand-authored room stamps (hub, boss arenas) as text. |
|
||||
@@ -197,6 +197,12 @@ ticks in milliseconds with no SceneTree.
|
||||
- **No contact damage.** Every enemy threatens through bullets only; touching
|
||||
one is harmless. `tests/unit/test_content.gd` enforces that every hostile has
|
||||
an emitter.
|
||||
- **A boss never leaves its arena.** `SimWorld._step_boss` clamps to
|
||||
`SimBoss.room` *after* movement. Boss rooms deliberately do not lock, so
|
||||
walking out is always an escape — which only holds if the boss cannot follow.
|
||||
- **A telegraph must be visible through fog.** `WorldView._draw_telegraphs`
|
||||
ignores line of sight on purpose; everything else in the view respects it. A
|
||||
warning you cannot see is an unavoidable hit with extra steps.
|
||||
- **No i-frames.** Every bullet that touches a player lands; `spawn_grace` is
|
||||
the only invulnerable state. Do not reintroduce post-hit immunity — it makes
|
||||
dense patterns safer than sparse ones.
|
||||
@@ -209,8 +215,13 @@ ticks in milliseconds with no SceneTree.
|
||||
|
||||
A new enemy or boss is data, never code. Add a builder to
|
||||
`src/content/content.gd` returning an `EnemyDef` / `BossDef` made of the
|
||||
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()`, and
|
||||
add a test. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
|
||||
emitters in `src/sim/patterns/`, register its id in `enemy()` / `boss()` **and
|
||||
in `ALL_ENEMIES` / `ALL_BOSSES`** (the export tool, the renderer and the tests
|
||||
all iterate those), and add a test.
|
||||
|
||||
A boss phase can move — `BossPhase.Move` is `STATIC`, `ORBIT`, `CHASE` or
|
||||
`WAYPOINTS`, handled generically in `SimWorld._move_boss`. Movement is a
|
||||
property of the phase, not of the boss. `tests/unit/test_boss.gd::test_a_brand_new_boss_needs_no_engine_changes`
|
||||
builds a boss from scratch and asserts the simulation needs no changes to run
|
||||
it — if you find yourself adding a per-boss branch to `SimWorld`, stop and add
|
||||
an emitter type instead.
|
||||
|
||||
@@ -340,3 +340,60 @@ pins the clamp.
|
||||
reliable channel, and the client learns enemy health from the snapshot anyway.
|
||||
Death is still announced, because the experience award is keyed on that event
|
||||
and a kill by poison has to score.
|
||||
|
||||
---
|
||||
|
||||
## The scale of the numbers
|
||||
|
||||
**Health and damage are ten times what they started as, and every ratio is
|
||||
unchanged.** A shot is 60, a fresh character has 1000, the Warden has 36000,
|
||||
and enemy health and emitter damage were scaled with them — time to kill is
|
||||
exactly what it was.
|
||||
|
||||
The reason is rounding, not balance. Damage is an integer, and at a base of 6
|
||||
the +5% that every upgrade carries computed to 6.3 and rounded back to 6: a
|
||||
player took their first upgrade, was told it made them stronger, and it did
|
||||
nothing. Ten times the base means every 5% step is worth 3 damage. The same
|
||||
applies to the boss's per-phase armour multiplier, which at 6 damage rounded
|
||||
1.15 into 1.17.
|
||||
|
||||
**The practice dummy is indestructible by flag, not by a large number.** The
|
||||
old 100000 health was already past the u16 the snapshot sends enemy health in,
|
||||
and once a shot did 60 a patient player could have worn it down and left the
|
||||
hub without a practice target until the next restart. A flag says what was
|
||||
actually meant.
|
||||
|
||||
---
|
||||
|
||||
## Bosses that move, and attacks that warn
|
||||
|
||||
**Movement belongs to the phase, not to the boss.** A fight that stands still
|
||||
and then starts hunting you is one boss with two phases. `BossDef.stationary`
|
||||
was removed rather than kept alongside the phases: a flag claiming the boss
|
||||
stood still while one of its phases walked around would be a second source of
|
||||
truth, and the wrong one. `BossDef.moves()` is derived.
|
||||
|
||||
**CHASE holds a distance instead of closing.** A boss standing on top of a
|
||||
player is a boss whose bullets cannot be read, and this genre cannot afford
|
||||
that. It backs off when you come inside its preferred range.
|
||||
|
||||
**Waypoints are fractions of the arena.** The Warden's hall and the Choir Vault
|
||||
are different sizes, and a phase written against absolute coordinates would only
|
||||
work in one of them.
|
||||
|
||||
**The boss room clamp moved after movement.** It was a no-op while every boss
|
||||
was stationary. Boss rooms deliberately do not lock, so walking out is always an
|
||||
escape — which is only true if the boss cannot follow.
|
||||
|
||||
**Telegraphed strikes are announced by a stateless emitter.** Emitters are
|
||||
shared resources, so nothing may be remembered between the warning and the
|
||||
strike; the positions are derived from the volley number instead, and a test
|
||||
asserts the burst lands where the marker promised.
|
||||
|
||||
**Telegraph markers are drawn through fog and through walls**, unlike every
|
||||
other thing in the view. A warning you cannot see is an unavoidable hit with
|
||||
extra steps, which is the opposite of what a telegraph is for.
|
||||
|
||||
**Which boss a run has comes from its seed, not its depth.** Depth is a dev flag
|
||||
that nothing in play raises, so keying the arena to it meant the second boss
|
||||
existed and no player could ever reach it.
|
||||
|
||||
+96
-33
@@ -20,8 +20,8 @@ What "everything passes" currently means. Numbers move; the shape does not.
|
||||
| Gate | Covers | Runtime |
|
||||
| --- | --- | --- |
|
||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||
| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `tools/test.sh` | 409 GUT tests, no SceneTree | ~4s |
|
||||
| `tools/smoke.sh` | 19 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s |
|
||||
| `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 |
|
||||
@@ -219,6 +219,29 @@ shares the interact key.
|
||||
|
||||
---
|
||||
|
||||
## The scale of health and damage
|
||||
|
||||
Health and damage are an order of magnitude larger than they started. A shot is
|
||||
60, a fresh character has 1000, the Warden has 36000. **Every ratio is
|
||||
unchanged** — enemy health, boss health and every emitter's damage were scaled
|
||||
together, so time to kill is exactly what it was.
|
||||
|
||||
The reason is rounding. At the old base of 6 damage, the +5% every upgrade
|
||||
carries computed to 6.3 and rounded straight back to 6, so a player's first
|
||||
upgrade visibly did nothing. At 60 it is +3, and every percentage in the game
|
||||
now has somewhere to land.
|
||||
|
||||
`tests/unit/test_content.gd` pins both halves of that: that a single upgrade
|
||||
changes damage, and that shots-to-kill for each enemy is what it was before.
|
||||
Do not tidy these numbers back down without scaling `content.gd` with them.
|
||||
|
||||
The hub's practice dummy stopped relying on a huge health pool at the same
|
||||
time: it is `indestructible` by flag now. The old 100000 was already past the
|
||||
u16 the snapshot sends enemy health in, and after the rescale a patient player
|
||||
could have destroyed the hub's only practice target for everyone.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4 — Upgrades · *done*
|
||||
|
||||
Every level gained banks one choice. Choices are spent at the **quartermaster**,
|
||||
@@ -284,9 +307,6 @@ All four settled with the user; the reasoning is in
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so
|
||||
6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10
|
||||
damage), but the first one visibly does nothing. See the open question below.
|
||||
- **One panel, not two screens.** The brief asked for a separate screen listing
|
||||
upgrades taken; it is the lower half of the choice panel instead, because the
|
||||
two are read together.
|
||||
@@ -294,24 +314,80 @@ All four settled with the user; the reasoning is in
|
||||
|
||||
---
|
||||
|
||||
## Stage 5 — Boss features and new bosses · *todo*
|
||||
## Stage 5 — Boss features and new bosses · *done*
|
||||
|
||||
| Feature | State |
|
||||
| --- | --- |
|
||||
| Stationary phases | done — every current phase |
|
||||
| Boss confined to its room | done — `SimWorld._step_boss` clamps to `SimBoss.room` |
|
||||
| Roaming / chasing within the boss room | todo |
|
||||
| Phases that move to preset locations | todo |
|
||||
| Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog |
|
||||
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` |
|
||||
| Feature | State | Where |
|
||||
| --- | --- | --- |
|
||||
| Stationary phases | done | every Warden phase |
|
||||
| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving |
|
||||
| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff |
|
||||
| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative |
|
||||
| Orbiting the arena | done | `BossPhase.Move.ORBIT` |
|
||||
| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` |
|
||||
| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` |
|
||||
|
||||
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
|
||||
asserts the simulation needs no changes to run it. Movement is the first thing
|
||||
that format has not covered, so expect `BossPhase` to gain a movement field
|
||||
rather than `SimWorld` gaining a per-boss branch.
|
||||
### Boss movement
|
||||
|
||||
Remember boss rooms **do not lock** (a settled decision): a player can always
|
||||
walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.
|
||||
Movement is a property of the **phase**, not of the boss: a fight that stands
|
||||
still and then starts hunting you is one boss with two phases. Four modes —
|
||||
`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in
|
||||
`SimWorld._move_boss`, so adding a boss that moves is still writing data.
|
||||
|
||||
- **Waypoints are fractions of the arena**, not absolute positions, so one phase
|
||||
works in the Warden's hall and the Choir Vault alike.
|
||||
- **CHASE holds a distance rather than closing.** A boss standing on top of you
|
||||
is a boss whose bullets cannot be read.
|
||||
- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute
|
||||
destination and would otherwise snap onto its circle on the first tick.
|
||||
- **Movement slides against geometry**, so a boss cannot walk through the
|
||||
pillars its own arena was designed around.
|
||||
- **The room clamp is now load-bearing.** It was a no-op while every boss stood
|
||||
still — which is exactly when an invariant is cheapest to establish. Boss
|
||||
rooms deliberately do not lock, and that is only an escape if the boss cannot
|
||||
follow you out.
|
||||
|
||||
### Telegraphed strikes
|
||||
|
||||
`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment
|
||||
later. The moment in between is the whole feature: a burst that appears at your
|
||||
feet is a coin flip, the same burst with a second of notice is a question.
|
||||
|
||||
- **The emitter is stateless**, like every other one — they are shared resources
|
||||
and two bosses of the same kind must not stomp each other. Strike positions
|
||||
are *derived* from the volley number, so the announcement and the strike
|
||||
compute the same points with nothing stored between them.
|
||||
- **`EmitContext` gained an `events` list**, the only thing an emitter can do
|
||||
besides spawn bullets.
|
||||
- **Markers are drawn through fog and through walls.** Everything else in the
|
||||
view respects line of sight; a warning you cannot see is not a warning. See
|
||||
`WorldView._draw_telegraphs`.
|
||||
|
||||
### The Cantor of the Vault
|
||||
|
||||
| Phase | Movement | Idea |
|
||||
| --- | --- | --- |
|
||||
| Call to Prayer | static | Teaches the marker, and nothing else. |
|
||||
| Processional | waypoints, four corners | The safe half of the room keeps moving. |
|
||||
| Antiphon | chase at 220u | It comes for you; walls punish running straight. |
|
||||
| Final Cadence | orbit | Movement and telegraphs at once. |
|
||||
|
||||
**Which boss you meet comes from the run's seed**, not its depth. Depth is a dev
|
||||
flag nothing in play raises, so keying the arena to it left the second boss
|
||||
existing and unreachable. Every run rolls a fresh seed, so it is a coin flip per
|
||||
dungeon, and `test_both_bosses_are_reachable_at_the_depth_people_play` pins it.
|
||||
|
||||
`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were
|
||||
hand-maintaining their own copy of the content list, and `tools/export_content.gd`
|
||||
had already gone stale and silently stopped writing the second boss.
|
||||
|
||||
### Known gaps
|
||||
|
||||
- **No boss-specific music, intro or death sequence.** A boss dies like an
|
||||
enemy, only louder.
|
||||
- **Telegraph markers are one shape.** A circle is the only warning the client
|
||||
can draw; a lane or a cone would need another event field.
|
||||
- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where
|
||||
the generator's "boss arena on the right-hand side" assumption will be tested.
|
||||
|
||||
---
|
||||
|
||||
@@ -337,19 +413,6 @@ 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.
|
||||
|
||||
### Worth a decision soon
|
||||
|
||||
1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is
|
||||
6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does
|
||||
nothing at all until the second one. It comes out right in aggregate, but a
|
||||
player who takes Split Shot and sees no damage change has been told
|
||||
something untrue.
|
||||
|
||||
The clean fix is to scale base damage and enemy health together — ×4 would
|
||||
put a shot at 24 and make every 5% step land — which changes no time-to-kill
|
||||
but touches every number in `content.gd`. That is a balance edit, so it is
|
||||
yours to call rather than mine.
|
||||
|
||||
### Blocking nothing yet
|
||||
|
||||
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
[gd_resource type="Resource" script_class="BossDef" format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://src/actors/items/loot_drop.gd" id="1_mkon8"]
|
||||
[ext_resource type="Script" path="res://src/actors/boss/boss_phase.gd" id="2_08s2a"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/bullet_emitter.gd" id="3_i3ijt"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/telegraphed_strike_emitter.gd" id="4_c737k"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/aimed_spread_emitter.gd" id="5_s4e7m"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/ring_emitter.gd" id="6_1xgol"]
|
||||
[ext_resource type="Script" path="res://src/sim/patterns/wall_gap_emitter.gd" id="7_jvosp"]
|
||||
[ext_resource type="Script" path="res://src/actors/boss/boss_def.gd" id="8_vujs6"]
|
||||
|
||||
[sub_resource type="Resource" id="Resource_y5tlt"]
|
||||
script = ExtResource("1_mkon8")
|
||||
item = &"health_potion"
|
||||
|
||||
[sub_resource type="Resource" id="Resource_vaonm"]
|
||||
script = ExtResource("1_mkon8")
|
||||
item = &"wardens_ration"
|
||||
instanced = true
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ldxsn"]
|
||||
script = ExtResource("4_c737k")
|
||||
warn_ticks = 80
|
||||
burst_count = 10
|
||||
blast_radius = 66.0
|
||||
interval = 180
|
||||
speed = 130.0
|
||||
lifetime = 240
|
||||
damage = 130
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ik5lx"]
|
||||
script = ExtResource("5_s4e7m")
|
||||
count = 3
|
||||
spread_deg = 18.0
|
||||
muzzle_offset = 40.0
|
||||
start_tick = 40
|
||||
interval = 120
|
||||
speed = 190.0
|
||||
radius = 6.0
|
||||
damage = 110
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_cfxp7"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Call to Prayer"
|
||||
loop_ticks = 360
|
||||
telegraph_ticks = 60
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_ldxsn"), SubResource("Resource_ik5lx")])
|
||||
|
||||
[sub_resource type="Resource" id="Resource_mrfx8"]
|
||||
script = ExtResource("6_1xgol")
|
||||
count = 3
|
||||
spin_per_shot_deg = 27.0
|
||||
muzzle_offset = 40.0
|
||||
interval = 12
|
||||
speed = 125.0
|
||||
lifetime = 400
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_oppcg"]
|
||||
script = ExtResource("4_c737k")
|
||||
strikes = 2
|
||||
warn_ticks = 90
|
||||
blast_radius = 70.0
|
||||
pattern_seed = 17
|
||||
start_tick = 60
|
||||
interval = 200
|
||||
speed = 145.0
|
||||
lifetime = 240
|
||||
damage = 140
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_eg17u"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Processional"
|
||||
enter_at_hp_fraction = 0.75
|
||||
loop_ticks = 480
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_mrfx8"), SubResource("Resource_oppcg")])
|
||||
move = 3
|
||||
move_speed = 105.0
|
||||
waypoints = Array[Vector2]([Vector2(0.18, 0.2), Vector2(0.82, 0.2), Vector2(0.82, 0.8), Vector2(0.18, 0.8)])
|
||||
waypoint_dwell = 70
|
||||
|
||||
[sub_resource type="Resource" id="Resource_bffie"]
|
||||
script = ExtResource("5_s4e7m")
|
||||
spread_deg = 34.0
|
||||
jitter_deg = 2.5
|
||||
muzzle_offset = 40.0
|
||||
interval = 90
|
||||
speed = 200.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_xcmnn"]
|
||||
script = ExtResource("7_jvosp")
|
||||
direction = 1
|
||||
count = 16
|
||||
gap_width = 4
|
||||
gap_step = 6
|
||||
start_tick = 70
|
||||
interval = 160
|
||||
speed = 165.0
|
||||
radius = 8.0
|
||||
damage = 150
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_31prv"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Antiphon"
|
||||
enter_at_hp_fraction = 0.45
|
||||
loop_ticks = 540
|
||||
telegraph_ticks = 50
|
||||
damage_taken_mult = 1.12
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_bffie"), SubResource("Resource_xcmnn")])
|
||||
move = 2
|
||||
move_speed = 120.0
|
||||
move_param = 220.0
|
||||
|
||||
[sub_resource type="Resource" id="Resource_wuthe"]
|
||||
script = ExtResource("6_1xgol")
|
||||
spin_per_shot_deg = 15.0
|
||||
muzzle_offset = 40.0
|
||||
interval = 40
|
||||
speed = 140.0
|
||||
lifetime = 380
|
||||
damage = 130
|
||||
|
||||
[sub_resource type="Resource" id="Resource_phxte"]
|
||||
script = ExtResource("4_c737k")
|
||||
strikes = 4
|
||||
warn_ticks = 70
|
||||
burst_count = 14
|
||||
blast_radius = 72.0
|
||||
pattern_seed = 91
|
||||
interval = 150
|
||||
lifetime = 240
|
||||
damage = 150
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_p6400"]
|
||||
script = ExtResource("2_08s2a")
|
||||
name = "Final Cadence"
|
||||
enter_at_hp_fraction = 0.18
|
||||
loop_ticks = 420
|
||||
telegraph_ticks = 55
|
||||
damage_taken_mult = 1.25
|
||||
emitters = Array[ExtResource("3_i3ijt")]([SubResource("Resource_wuthe"), SubResource("Resource_phxte")])
|
||||
move = 1
|
||||
move_speed = 150.0
|
||||
move_param = 150.0
|
||||
|
||||
[resource]
|
||||
script = ExtResource("8_vujs6")
|
||||
id = &"cantor"
|
||||
display_name = "Cantor of the Vault"
|
||||
max_hp = 28000
|
||||
radius = 36.0
|
||||
spawn_pos = Vector2(0, -150)
|
||||
visual = 1
|
||||
phases = Array[ExtResource("2_08s2a")]([SubResource("Resource_cfxp7"), SubResource("Resource_eg17u"), SubResource("Resource_31prv"), SubResource("Resource_p6400")])
|
||||
loot = Array[ExtResource("1_mkon8")]([SubResource("Resource_y5tlt"), SubResource("Resource_vaonm")])
|
||||
@@ -27,7 +27,7 @@ interval = 45
|
||||
speed = 135.0
|
||||
radius = 8.0
|
||||
lifetime = 420
|
||||
damage = 14
|
||||
damage = 140
|
||||
|
||||
[sub_resource type="Resource" id="Resource_a0mrn"]
|
||||
script = ExtResource("5_v02no")
|
||||
@@ -37,6 +37,7 @@ start_tick = 120
|
||||
interval = 150
|
||||
speed = 210.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_pfjxl"]
|
||||
@@ -54,6 +55,7 @@ muzzle_offset = 46.0
|
||||
interval = 9
|
||||
speed = 120.0
|
||||
lifetime = 480
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_k1e8f"]
|
||||
script = ExtResource("6_x6ufy")
|
||||
@@ -63,7 +65,7 @@ start_tick = 90
|
||||
interval = 170
|
||||
speed = 175.0
|
||||
radius = 8.0
|
||||
damage = 16
|
||||
damage = 160
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_4sp1g"]
|
||||
@@ -83,7 +85,7 @@ sweep_period = 5.0
|
||||
interval = 5
|
||||
speed = 165.0
|
||||
lifetime = 400
|
||||
damage = 13
|
||||
damage = 130
|
||||
|
||||
[sub_resource type="Resource" id="Resource_3fm5w"]
|
||||
script = ExtResource("5_v02no")
|
||||
@@ -95,6 +97,7 @@ start_tick = 60
|
||||
interval = 110
|
||||
speed = 195.0
|
||||
radius = 6.0
|
||||
damage = 120
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_20c8f"]
|
||||
@@ -106,6 +109,7 @@ end_tick = 600
|
||||
interval = 60
|
||||
speed = 105.0
|
||||
lifetime = 420
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ymr56"]
|
||||
script = ExtResource("2_f5abt")
|
||||
@@ -124,7 +128,7 @@ interval = 11
|
||||
speed = 130.0
|
||||
turn_deg = 0.55
|
||||
lifetime = 400
|
||||
damage = 15
|
||||
damage = 150
|
||||
|
||||
[sub_resource type="Resource" id="Resource_3n6oq"]
|
||||
script = ExtResource("6_x6ufy")
|
||||
@@ -134,7 +138,7 @@ start_tick = 40
|
||||
interval = 130
|
||||
speed = 200.0
|
||||
radius = 8.0
|
||||
damage = 18
|
||||
damage = 180
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_8ppic"]
|
||||
@@ -145,7 +149,7 @@ start_tick = 100
|
||||
interval = 80
|
||||
speed = 300.0
|
||||
radius = 5.0
|
||||
damage = 16
|
||||
damage = 160
|
||||
kind = 2
|
||||
|
||||
[sub_resource type="Resource" id="Resource_yqxmm"]
|
||||
@@ -161,7 +165,7 @@ emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubReso
|
||||
script = ExtResource("8_axg03")
|
||||
id = &"warden"
|
||||
display_name = "Warden of the Fold"
|
||||
max_hp = 3600
|
||||
max_hp = 36000
|
||||
radius = 42.0
|
||||
spawn_pos = Vector2(0, -150)
|
||||
phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")])
|
||||
|
||||
@@ -12,7 +12,7 @@ spread_deg = 20.0
|
||||
interval = 120
|
||||
radius = 6.0
|
||||
lifetime = 240
|
||||
damage = 10
|
||||
damage = 100
|
||||
|
||||
[sub_resource type="Resource" id="Resource_lnbnj"]
|
||||
script = ExtResource("3_vpd04")
|
||||
@@ -23,6 +23,7 @@ chance = 0.08
|
||||
script = ExtResource("4_k36hi")
|
||||
id = &"drifter"
|
||||
display_name = "Drifter"
|
||||
max_hp = 400
|
||||
speed = 55.0
|
||||
emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")])
|
||||
pattern_loop_ticks = 120
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
script = ExtResource("3_2tv1l")
|
||||
id = &"dummy"
|
||||
display_name = "Target Dummy"
|
||||
max_hp = 100000
|
||||
max_hp = 1000
|
||||
radius = 20.0
|
||||
move = 0
|
||||
visual = 3
|
||||
indestructible = true
|
||||
|
||||
@@ -13,7 +13,7 @@ interval = 45
|
||||
speed = 260.0
|
||||
radius = 6.0
|
||||
lifetime = 18
|
||||
damage = 14
|
||||
damage = 140
|
||||
kind = 3
|
||||
|
||||
[sub_resource type="Resource" id="Resource_ep81w"]
|
||||
@@ -25,7 +25,7 @@ chance = 0.08
|
||||
script = ExtResource("4_yp71e")
|
||||
id = &"stalker"
|
||||
display_name = "Stalker"
|
||||
max_hp = 30
|
||||
max_hp = 300
|
||||
radius = 12.0
|
||||
move = 3
|
||||
speed = 95.0
|
||||
|
||||
@@ -11,6 +11,7 @@ count = 10
|
||||
spin_per_shot_deg = 18.0
|
||||
interval = 150
|
||||
speed = 130.0
|
||||
damage = 120
|
||||
|
||||
[sub_resource type="Resource" id="Resource_fttpl"]
|
||||
script = ExtResource("3_hc01i")
|
||||
@@ -21,7 +22,7 @@ chance = 0.08
|
||||
script = ExtResource("4_6rb75")
|
||||
id = &"turret"
|
||||
display_name = "Turret"
|
||||
max_hp = 70
|
||||
max_hp = 700
|
||||
radius = 16.0
|
||||
move = 0
|
||||
speed = 0.0
|
||||
|
||||
@@ -7,10 +7,10 @@ extends Resource
|
||||
@export var display_name: String = "Boss"
|
||||
@export var max_hp: int = 4000
|
||||
@export var radius: float = 44.0
|
||||
## Stationary bosses are the MVP shape; the field exists so a later boss can
|
||||
## move without changing the runtime.
|
||||
@export var stationary: bool = true
|
||||
@export var spawn_pos := Vector2(0.0, -140.0)
|
||||
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
|
||||
## the simulation never learns that art exists.
|
||||
@export var visual: int = 0
|
||||
@export var phases: Array[BossPhase] = []
|
||||
## What the kill leaves behind. Bosses are the guaranteed source: a run that
|
||||
## reaches the end should always be worth something, so unlike trash loot these
|
||||
@@ -18,6 +18,16 @@ extends Resource
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
|
||||
## Whether any phase moves this boss. Derived from the phases rather than
|
||||
## stored alongside them: a flag that said "stationary" while a phase walked
|
||||
## around would be a second source of truth, and the wrong one.
|
||||
func moves() -> bool:
|
||||
for phase in phases:
|
||||
if phase != null and phase.moves():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
|
||||
## boss at 0.2 hp picks the lowest-threshold phase that still covers it.
|
||||
func phase_index_for(hp_fraction: float) -> int:
|
||||
|
||||
@@ -16,3 +16,38 @@ extends Resource
|
||||
## phases rather than adding hit points, so the fight length stays predictable.
|
||||
@export var damage_taken_mult: float = 1.0
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
|
||||
@export_group("Movement")
|
||||
## How the boss moves during this phase. Movement is a property of the PHASE,
|
||||
## not of the boss: a fight that stands still and then starts hunting you is
|
||||
## one boss with two phases, and expressing it any other way would put a
|
||||
## per-boss branch in the simulation.
|
||||
@export var move: Move = Move.STATIC
|
||||
## Units per second. Zero is equivalent to STATIC.
|
||||
@export var move_speed: float = 0.0
|
||||
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
|
||||
## hold from you -- closing all the way would mean a boss you cannot see past.
|
||||
@export var move_param: float = 120.0
|
||||
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
|
||||
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
|
||||
## right.
|
||||
@export var waypoints: Array[Vector2] = []
|
||||
## Ticks spent standing at each waypoint before moving on.
|
||||
@export var waypoint_dwell: int = 60
|
||||
|
||||
enum Move {
|
||||
## Never moves. Every phase written before bosses could move.
|
||||
STATIC,
|
||||
## Circles the centre of its arena at [member move_param] radius.
|
||||
ORBIT,
|
||||
## Closes on the nearest player, holding [member move_param] distance.
|
||||
CHASE,
|
||||
## Walks a fixed circuit of [member waypoints], pausing at each.
|
||||
WAYPOINTS,
|
||||
}
|
||||
|
||||
|
||||
## Whether this phase actually moves the boss. Derived rather than stored, so a
|
||||
## phase cannot claim to move and then sit still.
|
||||
func moves() -> bool:
|
||||
return move != Move.STATIC and move_speed > 0.0
|
||||
|
||||
@@ -36,6 +36,11 @@ enum Move {
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
## The emitter timeline wraps at this many ticks.
|
||||
@export var pattern_loop_ticks: int = 240
|
||||
## Never takes damage. Exists for the hub's practice target, whose whole job is
|
||||
## to still be there tomorrow -- expressing that as a flag rather than as a
|
||||
## large health pool means it cannot be worn down by a patient player, and
|
||||
## keeps its health inside the u16 the snapshot sends.
|
||||
@export var indestructible: bool = false
|
||||
## What this enemy may leave behind. Rolled once per entry on death, against
|
||||
## the world's own RNG. Empty for anything that should drop nothing.
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
+230
-20
@@ -13,6 +13,18 @@ const ENEMY_TURRET := &"turret"
|
||||
const ENEMY_STALKER := &"stalker"
|
||||
const ENEMY_DUMMY := &"dummy"
|
||||
const BOSS_WARDEN := &"warden"
|
||||
const BOSS_CANTOR := &"cantor"
|
||||
|
||||
## Every id this file defines. Kept here so the export tool, the renderer and
|
||||
## the tests all iterate the same list -- each of them used to hand-maintain
|
||||
## its own copy, and the export tool had already gone stale and stopped writing
|
||||
## the second boss.
|
||||
const ALL_ENEMIES: Array[StringName] = [
|
||||
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
|
||||
]
|
||||
const ALL_BOSSES: Array[StringName] = [
|
||||
BOSS_WARDEN, BOSS_CANTOR,
|
||||
]
|
||||
|
||||
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
|
||||
## dungeon run should be survivable on what it hands you, but never comfortably.
|
||||
@@ -32,6 +44,7 @@ static func enemy(id: StringName) -> EnemyDef:
|
||||
static func boss(id: StringName) -> BossDef:
|
||||
match id:
|
||||
BOSS_WARDEN: return warden()
|
||||
BOSS_CANTOR: return cantor()
|
||||
push_error("unknown boss id: %s" % id)
|
||||
return warden()
|
||||
|
||||
@@ -43,7 +56,7 @@ static func drifter() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DRIFTER
|
||||
d.display_name = "Drifter"
|
||||
d.max_hp = 40
|
||||
d.max_hp = 400
|
||||
d.radius = 14.0
|
||||
d.move = EnemyDef.Move.DRIFT
|
||||
d.speed = 55.0
|
||||
@@ -57,7 +70,7 @@ static func drifter() -> EnemyDef:
|
||||
fan.spread_deg = 20.0
|
||||
fan.speed = 150.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 10
|
||||
fan.damage = 100
|
||||
fan.lifetime = 240
|
||||
fan.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [fan]
|
||||
@@ -70,7 +83,7 @@ static func turret() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_TURRET
|
||||
d.display_name = "Turret"
|
||||
d.max_hp = 70
|
||||
d.max_hp = 700
|
||||
d.radius = 16.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.speed = 0.0
|
||||
@@ -84,7 +97,7 @@ static func turret() -> EnemyDef:
|
||||
ring.spin_per_shot_deg = 18.0
|
||||
ring.speed = 130.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 12
|
||||
ring.damage = 120
|
||||
ring.lifetime = 300
|
||||
ring.kind = SimConfig.KIND_ORB
|
||||
d.emitters = [ring]
|
||||
@@ -104,7 +117,7 @@ static func stalker() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_STALKER
|
||||
d.display_name = "Stalker"
|
||||
d.max_hp = 30
|
||||
d.max_hp = 300
|
||||
d.radius = 12.0
|
||||
d.move = EnemyDef.Move.APPROACH
|
||||
d.speed = 95.0
|
||||
@@ -119,7 +132,7 @@ static func stalker() -> EnemyDef:
|
||||
lunge.spread_deg = 62.0
|
||||
lunge.speed = 260.0
|
||||
lunge.radius = 6.0
|
||||
lunge.damage = 14
|
||||
lunge.damage = 140
|
||||
# 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to-
|
||||
# player distance at any range you would call "not point blank".
|
||||
lunge.lifetime = 18
|
||||
@@ -130,12 +143,18 @@ static func stalker() -> EnemyDef:
|
||||
return d
|
||||
|
||||
|
||||
## Lobby target dummy: inert, tough, so players can feel out the gun.
|
||||
## Lobby target dummy: inert, indestructible, so players can feel out the gun.
|
||||
##
|
||||
## Indestructible by flag rather than by a huge health pool. The old 100000 was
|
||||
## already past the u16 the snapshot sends enemy health in, and after the x10
|
||||
## rescale a determined player could have destroyed the hub's only practice
|
||||
## target for everyone until the server restarted.
|
||||
static func dummy() -> EnemyDef:
|
||||
var d := EnemyDef.new()
|
||||
d.id = ENEMY_DUMMY
|
||||
d.display_name = "Target Dummy"
|
||||
d.max_hp = 100000
|
||||
d.max_hp = 1000
|
||||
d.indestructible = true
|
||||
d.radius = 20.0
|
||||
d.move = EnemyDef.Move.STATIC
|
||||
d.visual = 3
|
||||
@@ -151,10 +170,10 @@ static func warden() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_WARDEN
|
||||
b.display_name = "Warden of the Fold"
|
||||
b.max_hp = 3600
|
||||
b.max_hp = 36000
|
||||
b.radius = 42.0
|
||||
b.stationary = true
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.visual = 0
|
||||
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
|
||||
# Guaranteed, and deliberately one of each visibility. The potion is shared,
|
||||
# so a party still has something to divide up; the ration is instanced, so
|
||||
@@ -182,7 +201,7 @@ static func _warden_p1() -> BossPhase:
|
||||
ring.spin_per_shot_deg = 9.0
|
||||
ring.speed = 135.0
|
||||
ring.radius = 8.0
|
||||
ring.damage = 14
|
||||
ring.damage = 140
|
||||
ring.lifetime = 420
|
||||
ring.muzzle_offset = 46.0
|
||||
|
||||
@@ -193,7 +212,7 @@ static func _warden_p1() -> BossPhase:
|
||||
fan.spread_deg = 26.0
|
||||
fan.speed = 210.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 46.0
|
||||
|
||||
@@ -216,7 +235,7 @@ static func _warden_p2() -> BossPhase:
|
||||
spiral.spin_per_shot_deg = 23.0
|
||||
spiral.speed = 120.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 12
|
||||
spiral.damage = 120
|
||||
spiral.lifetime = 480
|
||||
spiral.muzzle_offset = 46.0
|
||||
|
||||
@@ -229,7 +248,7 @@ static func _warden_p2() -> BossPhase:
|
||||
wall.gap_step = 7
|
||||
wall.speed = 175.0
|
||||
wall.radius = 8.0
|
||||
wall.damage = 16
|
||||
wall.damage = 160
|
||||
wall.lifetime = 300
|
||||
wall.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
@@ -256,7 +275,7 @@ static func _warden_p3() -> BossPhase:
|
||||
arms.sweep_period = 5.0
|
||||
arms.speed = 165.0
|
||||
arms.radius = 7.0
|
||||
arms.damage = 13
|
||||
arms.damage = 130
|
||||
arms.lifetime = 400
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
@@ -268,7 +287,7 @@ static func _warden_p3() -> BossPhase:
|
||||
fan.speed = 195.0
|
||||
fan.edge_speed_bonus = 0.25
|
||||
fan.radius = 6.0
|
||||
fan.damage = 12
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
var counter_ring := RingEmitter.new()
|
||||
@@ -279,7 +298,7 @@ static func _warden_p3() -> BossPhase:
|
||||
counter_ring.spin_per_shot_deg = -14.0
|
||||
counter_ring.speed = 105.0
|
||||
counter_ring.radius = 7.0
|
||||
counter_ring.damage = 12
|
||||
counter_ring.damage = 120
|
||||
counter_ring.lifetime = 420
|
||||
|
||||
p.emitters = [arms, fan, counter_ring]
|
||||
@@ -303,7 +322,7 @@ static func _warden_p4() -> BossPhase:
|
||||
curve.speed = 130.0
|
||||
curve.turn_deg = 0.55
|
||||
curve.radius = 7.0
|
||||
curve.damage = 15
|
||||
curve.damage = 150
|
||||
curve.lifetime = 400
|
||||
curve.muzzle_offset = 46.0
|
||||
|
||||
@@ -316,7 +335,7 @@ static func _warden_p4() -> BossPhase:
|
||||
walls.gap_step = 5
|
||||
walls.speed = 200.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 18
|
||||
walls.damage = 180
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var snipe := AimedSpreadEmitter.new()
|
||||
@@ -326,8 +345,199 @@ static func _warden_p4() -> BossPhase:
|
||||
snipe.spread_deg = 10.0
|
||||
snipe.speed = 300.0
|
||||
snipe.radius = 5.0
|
||||
snipe.damage = 16
|
||||
snipe.damage = 160
|
||||
snipe.kind = SimConfig.KIND_NEEDLE
|
||||
|
||||
p.emitters = [curve, walls, snipe]
|
||||
return p
|
||||
|
||||
|
||||
# --- The Cantor of the Vault ------------------------------------------------
|
||||
|
||||
## The second boss, and the one that proves the format stretched. The Warden is
|
||||
## four phases of standing still and filling space; the Cantor walks.
|
||||
##
|
||||
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
|
||||
## see over but not shoot through -- so its patterns are about position rather
|
||||
## than about density. Everything below is data: the simulation gained a
|
||||
## movement mode and a telegraphing emitter, and no line anywhere knows this
|
||||
## boss exists.
|
||||
static func cantor() -> BossDef:
|
||||
var b := BossDef.new()
|
||||
b.id = BOSS_CANTOR
|
||||
b.display_name = "Cantor of the Vault"
|
||||
b.max_hp = 28000
|
||||
b.radius = 36.0
|
||||
b.spawn_pos = Vector2(0.0, -150.0)
|
||||
b.visual = 1
|
||||
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
|
||||
b.loot = [
|
||||
LootDrop.make(Items.HEALTH_POTION, 1.0),
|
||||
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
|
||||
]
|
||||
return b
|
||||
|
||||
|
||||
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
|
||||
## three spots, fills them. The whole phase exists to teach the marker.
|
||||
static func _cantor_p1() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Call to Prayer"
|
||||
p.enter_at_hp_fraction = 1.0
|
||||
p.loop_ticks = 360
|
||||
p.telegraph_ticks = 60
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.interval = 180
|
||||
strikes.warn_ticks = 80
|
||||
strikes.strikes = 3
|
||||
strikes.burst_count = 10
|
||||
strikes.blast_radius = 66.0
|
||||
strikes.speed = 130.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 130
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.start_tick = 40
|
||||
fan.interval = 120
|
||||
fan.count = 3
|
||||
fan.spread_deg = 18.0
|
||||
fan.speed = 190.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 110
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 40.0
|
||||
|
||||
p.emitters = [strikes, fan]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
|
||||
## at each, so the safe half of the room keeps changing while a spiral fills
|
||||
## whatever it has left behind.
|
||||
static func _cantor_p2() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Processional"
|
||||
p.enter_at_hp_fraction = 0.75
|
||||
p.loop_ticks = 480
|
||||
p.telegraph_ticks = 45
|
||||
p.move = BossPhase.Move.WAYPOINTS
|
||||
p.move_speed = 105.0
|
||||
p.waypoint_dwell = 70
|
||||
# Fractions of the arena, so the same walk works in a room of any size.
|
||||
p.waypoints = [
|
||||
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
|
||||
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
|
||||
]
|
||||
|
||||
var spiral := RingEmitter.new()
|
||||
spiral.interval = 12
|
||||
spiral.count = 3
|
||||
spiral.spin_per_shot_deg = 27.0
|
||||
spiral.speed = 125.0
|
||||
spiral.radius = 7.0
|
||||
spiral.damage = 120
|
||||
spiral.lifetime = 400
|
||||
spiral.muzzle_offset = 40.0
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.start_tick = 60
|
||||
strikes.interval = 200
|
||||
strikes.warn_ticks = 90
|
||||
strikes.strikes = 2
|
||||
strikes.burst_count = 12
|
||||
strikes.blast_radius = 70.0
|
||||
strikes.pattern_seed = 17
|
||||
strikes.speed = 145.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 140
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [spiral, strikes]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
|
||||
## fight stays readable, and the aimed fire punishes standing still while the
|
||||
## walls punish running in a straight line.
|
||||
static func _cantor_p3() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Antiphon"
|
||||
p.enter_at_hp_fraction = 0.45
|
||||
p.loop_ticks = 540
|
||||
p.telegraph_ticks = 50
|
||||
p.damage_taken_mult = 1.12
|
||||
p.move = BossPhase.Move.CHASE
|
||||
p.move_speed = 120.0
|
||||
p.move_param = 220.0
|
||||
|
||||
var fan := AimedSpreadEmitter.new()
|
||||
fan.interval = 90
|
||||
fan.count = 5
|
||||
fan.spread_deg = 34.0
|
||||
fan.jitter_deg = 2.5
|
||||
fan.speed = 200.0
|
||||
fan.radius = 6.0
|
||||
fan.damage = 120
|
||||
fan.kind = SimConfig.KIND_NEEDLE
|
||||
fan.muzzle_offset = 40.0
|
||||
|
||||
var walls := WallGapEmitter.new()
|
||||
walls.start_tick = 70
|
||||
walls.interval = 160
|
||||
walls.direction = 1
|
||||
walls.count = 16
|
||||
walls.gap_width = 4
|
||||
walls.gap_step = 6
|
||||
walls.speed = 165.0
|
||||
walls.radius = 8.0
|
||||
walls.damage = 150
|
||||
walls.lifetime = 300
|
||||
walls.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [fan, walls]
|
||||
return p
|
||||
|
||||
|
||||
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
|
||||
## once, which is the point: both were added for this fight and this is where
|
||||
## they have to work together.
|
||||
static func _cantor_p4() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.name = "Final Cadence"
|
||||
p.enter_at_hp_fraction = 0.18
|
||||
p.loop_ticks = 420
|
||||
p.telegraph_ticks = 55
|
||||
p.damage_taken_mult = 1.25
|
||||
p.move = BossPhase.Move.ORBIT
|
||||
p.move_speed = 150.0
|
||||
p.move_param = 150.0
|
||||
|
||||
var ring := RingEmitter.new()
|
||||
ring.interval = 40
|
||||
ring.count = 12
|
||||
ring.spin_per_shot_deg = 15.0
|
||||
ring.speed = 140.0
|
||||
ring.radius = 7.0
|
||||
ring.damage = 130
|
||||
ring.lifetime = 380
|
||||
ring.muzzle_offset = 40.0
|
||||
|
||||
var strikes := TelegraphedStrikeEmitter.new()
|
||||
strikes.interval = 150
|
||||
strikes.warn_ticks = 70
|
||||
strikes.strikes = 4
|
||||
strikes.burst_count = 14
|
||||
strikes.blast_radius = 72.0
|
||||
strikes.pattern_seed = 91
|
||||
strikes.speed = 150.0
|
||||
strikes.radius = 7.0
|
||||
strikes.damage = 150
|
||||
strikes.lifetime = 240
|
||||
strikes.kind = SimConfig.KIND_HEAVY
|
||||
|
||||
p.emitters = [ring, strikes]
|
||||
return p
|
||||
|
||||
@@ -69,6 +69,16 @@ static func choir_vault() -> PackedStringArray:
|
||||
])
|
||||
|
||||
|
||||
## Which boss each arena belongs to. Kept beside the stamps because the pairing
|
||||
## is a content decision, not a generation one: the Cantor's patterns assume
|
||||
## the vault's barricades the way the Warden's assume the hall's pits.
|
||||
static func boss_for_arena(arena: StringName) -> StringName:
|
||||
match arena:
|
||||
&"warden_hall": return Content.BOSS_WARDEN
|
||||
&"choir_vault": return Content.BOSS_CANTOR
|
||||
return Content.BOSS_WARDEN
|
||||
|
||||
|
||||
static func size_of(stamp: PackedStringArray) -> Vector2i:
|
||||
if stamp.is_empty():
|
||||
return Vector2i.ZERO
|
||||
|
||||
@@ -35,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0
|
||||
## the ship -- and on a remote client, the ship is also drawn a tick or two
|
||||
## ahead of the server, so this margin is what absorbs that too.
|
||||
const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0
|
||||
const PLAYER_MAX_HP := 100
|
||||
## Health and damage are deliberately an order of magnitude larger than the
|
||||
## numbers they started as. Every ratio in the game is unchanged -- enemy
|
||||
## health, boss health and every emitter's damage were scaled with them -- but
|
||||
## percentages now have somewhere to land. At the old base of 6 damage, the +5%
|
||||
## that every upgrade carries computed to 6.3 and rounded straight back to 6,
|
||||
## so a player's first upgrade visibly did nothing at all. At 60 it is +3.
|
||||
## Do not "tidy" these back down without scaling content.gd with them.
|
||||
const PLAYER_MAX_HP := 1000
|
||||
const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec)
|
||||
const PLAYER_BULLET_SPEED := 620.0
|
||||
const PLAYER_BULLET_RADIUS := 4.0
|
||||
const PLAYER_BULLET_LIFETIME := 90 # ticks
|
||||
const PLAYER_BULLET_DAMAGE := 6
|
||||
const PLAYER_BULLET_DAMAGE := 60
|
||||
## There are deliberately NO invulnerability frames after a hit. In a bullet
|
||||
## hell the wall of bullets IS the threat, and i-frames turn a dense pattern
|
||||
## into a single cheap hit -- you get punished for the first bullet and gifted
|
||||
|
||||
@@ -75,7 +75,7 @@ static func make_dungeon(instance_id: int, dungeon_seed: int, dungeon_depth: int
|
||||
# Arriving into a fight already in progress needs a moment of protection;
|
||||
# arriving in the hub does not.
|
||||
inst.world.spawn_grace_ticks = SimConfig.SPAWN_GRACE_TICKS
|
||||
inst.boss_id = Content.BOSS_WARDEN
|
||||
inst.boss_id = built["boss_id"]
|
||||
inst.state = State.FORMING
|
||||
inst._populate()
|
||||
return inst
|
||||
|
||||
@@ -10,8 +10,9 @@ extends RefCounted
|
||||
const MAX_LEVEL := 15
|
||||
const START_LEVEL := 1
|
||||
## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a
|
||||
## capped character has PLAYER_MAX_HP + 14 * this.
|
||||
const HP_PER_LEVEL := 10
|
||||
## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else
|
||||
## -- see the note on SimConfig.PLAYER_MAX_HP.
|
||||
const HP_PER_LEVEL := 100
|
||||
|
||||
## Experience for the first level-up. The curve is tuned so one full clear of a
|
||||
## depth-1 dungeon lands a little past this -- the first run should end with a
|
||||
|
||||
@@ -88,6 +88,11 @@ var selected_character: String = ""
|
||||
## characters yet" from "not asked yet".
|
||||
var characters_known: bool = false
|
||||
|
||||
## Attacks that have been announced but not yet landed, as
|
||||
## [{ "pos": Vector2, "r": float, "until": int, "ticks": int }]. Server-pushed;
|
||||
## the client never invents one and never decides one is over early.
|
||||
var telegraphs: Array[Dictionary] = []
|
||||
|
||||
## Whole seconds until a cleared dungeon returns the party, or
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
@@ -171,10 +176,24 @@ func _physics_process(delta: float) -> void:
|
||||
Net.send_input(NetCodec.encode_inputs(redundant))
|
||||
|
||||
world.step()
|
||||
_expire_telegraphs()
|
||||
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
|
||||
_maybe_bot_leave()
|
||||
|
||||
|
||||
## Drop warnings whose attack has already landed. Keyed on the estimated server
|
||||
## tick rather than on wall time, so a warning lasts exactly as long as the
|
||||
## server said it would however the frame rate is behaving.
|
||||
func _expire_telegraphs() -> void:
|
||||
if telegraphs.is_empty():
|
||||
return
|
||||
var live: Array[Dictionary] = []
|
||||
for t in telegraphs:
|
||||
if server_tick_est < int(t["until"]):
|
||||
live.append(t)
|
||||
telegraphs = live
|
||||
|
||||
|
||||
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
|
||||
## disconnect is caught by the same channel a SIGKILL is. Deferred because
|
||||
## Net.shutdown() frees this node.
|
||||
@@ -422,6 +441,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
request_respawn = false
|
||||
my_respawn_wait = 0.0
|
||||
my_inventory = []
|
||||
telegraphs = []
|
||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||
instance_changed.emit()
|
||||
@@ -533,6 +553,16 @@ func on_events(data: PackedByteArray) -> void:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
boss_died.emit()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
telegraphs.append({
|
||||
"pos": ev["pos"],
|
||||
"r": ev["r"],
|
||||
"ticks": maxi(int(ev["ticks"]), 1),
|
||||
# Aged by however long the packet spent in flight, the same
|
||||
# way a bullet is, so a warning drawn late still disappears
|
||||
# on time rather than lingering past its own attack.
|
||||
"until": int(packet["tick"]) + int(ev["ticks"]),
|
||||
})
|
||||
SimEvent.Type.ITEM_PICKED_UP:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_picked_up.emit(ev["item"])
|
||||
|
||||
@@ -258,6 +258,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
body.put_u32(ev["id"])
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
body.put_u8(clampi(int(ev["phase"]), 0, 255))
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
body.put_float(ev["pos"].x)
|
||||
body.put_float(ev["pos"].y)
|
||||
body.put_float(ev["r"])
|
||||
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
count += 1
|
||||
@@ -312,6 +317,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
ev["id"] = b.get_u32()
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
ev["phase"] = b.get_u8()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
||||
ev["r"] = b.get_float()
|
||||
ev["ticks"] = b.get_u16()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
out.append(ev)
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ extends RefCounted
|
||||
## the dungeon you are standing in, replacing the single portal position.
|
||||
## 8: upgrades. A new server -> client upgrade-state message, a new
|
||||
## client -> server choice message, and two more SelectResult values.
|
||||
const VERSION := 8
|
||||
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
|
||||
const VERSION := 9
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
+11
-1
@@ -65,6 +65,7 @@ static func _build_lobby() -> Dictionary:
|
||||
"npc": npc,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
"boss_id": &"",
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +86,12 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
||||
# Which arena -- and therefore which boss -- comes from the SEED, not the
|
||||
# depth. Depth is a dev flag that nothing in play raises, so keying the
|
||||
# arena to it meant the second boss existed and no player could ever reach
|
||||
# it. Every run rolls a fresh seed, so this is a coin flip per dungeon.
|
||||
var arena := &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
|
||||
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
@@ -156,6 +162,10 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
"npc": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
||||
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||
# hall, where its patterns assume geometry that is not there.
|
||||
"boss_id": Rooms.boss_for_arena(arena),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ var rng: RandomNumberGenerator
|
||||
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
|
||||
|
||||
|
||||
## Where an emitter announces something before it happens. Points at the
|
||||
## owning world's event list; emitters fired into a bare pool by a test simply
|
||||
## append here and nobody reads it.
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
|
||||
## Warn that an attack will land at [param at] in [param ticks] ticks.
|
||||
##
|
||||
## The only thing an emitter can do besides spawn bullets, and it exists so a
|
||||
## strike that appears out of thin air is a *choice* the player was given time
|
||||
## to answer rather than an unavoidable hit.
|
||||
func telegraph(at: Vector2, warn_radius: float, ticks: int) -> void:
|
||||
events.append({
|
||||
"t": SimEvent.Type.TELEGRAPH,
|
||||
"pos": at,
|
||||
"r": warn_radius,
|
||||
"ticks": ticks,
|
||||
})
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
if not has_target:
|
||||
return PI * 0.5
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class_name TelegraphedStrikeEmitter
|
||||
extends BulletEmitter
|
||||
## Marks spots in the arena, then fills them with bullets a moment later.
|
||||
##
|
||||
## The whole point is the moment in between. A burst that appears at your feet
|
||||
## with no warning is not a pattern, it is a coin flip; the same burst with a
|
||||
## second of notice is a question about where you would rather be standing.
|
||||
## [member warn_ticks] is that second, and the client draws the marker from the
|
||||
## TELEGRAPH event this emits.
|
||||
##
|
||||
## Stateless, like every other emitter -- emitters are shared resources and two
|
||||
## bosses of the same kind must not stomp each other's timers. The strike
|
||||
## positions are therefore *derived* from the volley number rather than rolled
|
||||
## and remembered, so the announcement and the strike compute the same points
|
||||
## without anything being stored between them.
|
||||
|
||||
## Spots marked per volley.
|
||||
@export var strikes: int = 3
|
||||
## Ticks between the marker appearing and the burst landing. Must be shorter
|
||||
## than [member interval], or a volley would land after the next was announced.
|
||||
@export var warn_ticks: int = 75
|
||||
## Bullets in each burst, fired outwards from the marked spot.
|
||||
@export var burst_count: int = 12
|
||||
## Radius of the marked area, and the ring the burst is born on.
|
||||
@export var blast_radius: float = 64.0
|
||||
## Keeps strikes off the arena's walls, where half the burst would be wasted.
|
||||
@export var margin: float = 72.0
|
||||
## Distinguishes two strike emitters in one phase, which would otherwise derive
|
||||
## the same points and stack every burst on top of itself.
|
||||
@export var pattern_seed: int = 0
|
||||
|
||||
|
||||
## Armed on two ticks per volley: the announcement, and the strike itself.
|
||||
func should_fire(local_tick: int) -> bool:
|
||||
if not is_armed(local_tick) or interval <= 0:
|
||||
return false
|
||||
var into := posmod(local_tick - start_tick, interval)
|
||||
return into == 0 or into == posmod(warn_ticks, interval)
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if strikes <= 0 or interval <= 0:
|
||||
return
|
||||
var offset := ctx.local_tick - start_tick
|
||||
var volley := int(floor(float(offset) / float(interval)))
|
||||
var announcing := posmod(offset, interval) == 0
|
||||
for i in strikes:
|
||||
var at := strike_point(volley, i, ctx.bounds)
|
||||
if announcing:
|
||||
ctx.telegraph(at, blast_radius, warn_ticks)
|
||||
else:
|
||||
_burst(ctx, at)
|
||||
|
||||
|
||||
## The burst leaves the marked spot outwards, so standing on the marker is the
|
||||
## one place that is certainly wrong and the edge is survivable.
|
||||
func _burst(ctx: EmitContext, at: Vector2) -> void:
|
||||
if burst_count <= 0:
|
||||
return
|
||||
for i in burst_count:
|
||||
var a := TAU * (float(i) / float(burst_count))
|
||||
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
|
||||
|
||||
|
||||
## Where volley [param volley]'s strike [param index] lands, inside
|
||||
## [param bounds]. A pure function of its arguments: called once when the
|
||||
## warning goes up and again when the burst lands, and it has to agree with
|
||||
## itself both times without anything being remembered in between.
|
||||
func strike_point(volley: int, index: int, bounds: Rect2) -> Vector2:
|
||||
var inner := bounds.grow(-margin)
|
||||
if inner.size.x <= 0.0 or inner.size.y <= 0.0:
|
||||
inner = bounds
|
||||
return inner.position + Vector2(
|
||||
_unit(volley, index * 2) * inner.size.x,
|
||||
_unit(volley, index * 2 + 1) * inner.size.y)
|
||||
|
||||
|
||||
## A small integer hash in [0, 1). Not a good random number generator and does
|
||||
## not need to be -- it needs to be scattered, and it needs to give the same
|
||||
## answer twice.
|
||||
func _unit(a: int, b: int) -> float:
|
||||
var h := (a * 374761393 + b * 668265263 + pattern_seed * 2246822519) & 0x7FFFFFFF
|
||||
h = ((h ^ (h >> 13)) * 1274126177) & 0x7FFFFFFF
|
||||
return float((h >> 7) & 0xFFFF) / 65536.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkswilm3f3jc1
|
||||
@@ -15,6 +15,10 @@ var phase_tick: int = 0
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
## WAYPOINTS movement: which point it is walking to, and how long it still
|
||||
## stands at the one it reached.
|
||||
var waypoint_index: int = 0
|
||||
var waypoint_wait: int = 0
|
||||
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
|
||||
var poison: PoisonTrack = null
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@ enum Type {
|
||||
ITEM_PICKED_UP, ## peer, item
|
||||
ITEM_USED, ## peer, item
|
||||
ITEM_DROPPED, ## peer, item
|
||||
## An attack is about to land somewhere, and the player has to be shown
|
||||
## where before it does. Carries the spot, its radius and how long the
|
||||
## warning lasts; the client draws it and the server fires into it later.
|
||||
TELEGRAPH, ## pos, r, ticks
|
||||
}
|
||||
|
||||
+79
-5
@@ -568,11 +568,14 @@ func _step_boss() -> void:
|
||||
var phase := boss.current_phase()
|
||||
if phase == null:
|
||||
return
|
||||
# A boss never leaves its arena. Enforced here rather than left to each
|
||||
# boss's movement code, because boss rooms deliberately do not lock: the
|
||||
# player can always walk out, and the fight only stays a fight if the boss
|
||||
# cannot follow. Currently a no-op (every boss is stationary), which is
|
||||
# exactly when an invariant is cheapest to establish.
|
||||
|
||||
_move_boss(phase)
|
||||
|
||||
# A boss never leaves its arena. Enforced here rather than inside each
|
||||
# movement mode, because boss rooms deliberately do not lock: the player can
|
||||
# always walk out, and the fight only stays a fight if the boss cannot
|
||||
# follow. It was a no-op while every boss stood still, which is exactly when
|
||||
# an invariant is cheapest to establish -- now it is load-bearing.
|
||||
if boss.room.size != Vector2.ZERO:
|
||||
boss.pos = boss.room.position + Vector2(
|
||||
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
|
||||
@@ -584,6 +587,72 @@ func _step_boss() -> void:
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## One tick of boss movement, driven entirely by the phase's data. Every mode
|
||||
## here is generic: adding a boss that moves is writing a [BossPhase], not
|
||||
## touching this function.
|
||||
func _move_boss(phase: BossPhase) -> void:
|
||||
if not phase.moves():
|
||||
return
|
||||
var dt := SimConfig.TICK_DELTA
|
||||
var step := Vector2.ZERO
|
||||
match phase.move:
|
||||
BossPhase.Move.ORBIT:
|
||||
var centre := boss.room.get_center() if boss.room.size != Vector2.ZERO \
|
||||
else boss.def.spawn_pos
|
||||
var angle := float(boss.phase_tick) * dt \
|
||||
* (phase.move_speed / maxf(phase.move_param, 1.0))
|
||||
step = centre + Vector2.RIGHT.rotated(angle) * phase.move_param - boss.pos
|
||||
BossPhase.Move.CHASE:
|
||||
var quarry := nearest_player(boss.pos)
|
||||
if quarry == null:
|
||||
return
|
||||
var to_player := quarry.pos - boss.pos
|
||||
var gap := to_player.length()
|
||||
if gap < 0.001:
|
||||
return
|
||||
# Signed, so it backs off when you close inside its preferred
|
||||
# distance. A boss that ends up standing on you is a boss whose
|
||||
# bullets you cannot see coming.
|
||||
step = (to_player / gap) * signf(gap - phase.move_param) * phase.move_speed * dt
|
||||
BossPhase.Move.WAYPOINTS:
|
||||
if phase.waypoints.is_empty():
|
||||
return
|
||||
if boss.waypoint_wait > 0:
|
||||
boss.waypoint_wait -= 1
|
||||
return
|
||||
var goal := _waypoint_world(phase, boss.waypoint_index)
|
||||
var to_goal := goal - boss.pos
|
||||
if to_goal.length() <= phase.move_speed * dt:
|
||||
boss.pos = goal
|
||||
boss.waypoint_index = (boss.waypoint_index + 1) % phase.waypoints.size()
|
||||
boss.waypoint_wait = phase.waypoint_dwell
|
||||
return
|
||||
step = to_goal
|
||||
_:
|
||||
return
|
||||
# Clamped here rather than in each branch, so no mode can teleport: ORBIT in
|
||||
# particular computes an absolute destination and would otherwise snap to
|
||||
# its circle on the first tick of the phase.
|
||||
var reach := phase.move_speed * dt
|
||||
if step.length() > reach:
|
||||
step = step.normalized() * reach
|
||||
# Slid rather than assigned, so a boss cannot walk through the pillars its
|
||||
# own arena was designed around.
|
||||
boss.pos = map.slide_circle(boss.pos, step, boss.def.radius)
|
||||
|
||||
|
||||
## A phase waypoint, given as a fraction of the arena, in world coordinates.
|
||||
## Fractions rather than absolutes so one phase can be dropped into any room --
|
||||
## the Warden's hall and the Choir Vault are different sizes.
|
||||
func _waypoint_world(phase: BossPhase, index: int) -> Vector2:
|
||||
var w: Vector2 = phase.waypoints[index]
|
||||
if boss.room.size == Vector2.ZERO:
|
||||
return boss.def.spawn_pos
|
||||
return boss.room.position + Vector2(
|
||||
clampf(w.x, 0.0, 1.0) * boss.room.size.x,
|
||||
clampf(w.y, 0.0, 1.0) * boss.room.size.y)
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
@@ -592,6 +661,9 @@ func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick:
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
# Re-pointed every call: drain_events() replaces the array wholesale, so a
|
||||
# context holding the old one would announce into a list nobody reads.
|
||||
_ctx.events = events
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
@@ -746,6 +818,8 @@ func _damage_player(p: SimPlayer, amount: int) -> void:
|
||||
## from the snapshot anyway. Death is still announced either way, because that
|
||||
## is what the experience award is keyed on.
|
||||
func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
|
||||
if e.def.indestructible:
|
||||
return
|
||||
e.hp = maxi(e.hp - amount, 0)
|
||||
if not silent:
|
||||
events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp})
|
||||
|
||||
+12
-1
@@ -97,7 +97,18 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
Rect2(368, 40, 16, 16), # goblin -> stalker
|
||||
Rect2(288, 336, 16, 16), # red flask -> practice target
|
||||
]
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
## BossDef.visual -> first idle frame. Same convention as ENEMY_IDLE.
|
||||
const BOSS_IDLE_FRAMES: Array[Rect2] = [
|
||||
Rect2(16, 428, 32, 36), # big demon -> the Warden of the Fold
|
||||
Rect2(144, 428, 32, 36), # big ogre -> the Cantor of the Vault
|
||||
]
|
||||
## The first boss's strip, kept as a name because plenty of code and tests read
|
||||
## "the boss sprite" and only one of them cares which boss.
|
||||
const BOSS_IDLE := BOSS_IDLE_FRAMES[0]
|
||||
|
||||
|
||||
static func boss_idle(visual: int) -> Rect2:
|
||||
return BOSS_IDLE_FRAMES[clampi(visual, 0, BOSS_IDLE_FRAMES.size() - 1)]
|
||||
## The hub's quartermaster. A different character strip from the knight the
|
||||
## player wears, so an NPC never reads as another player standing still.
|
||||
const NPC_IDLE := Rect2(128, 36, 16, 28)
|
||||
|
||||
+26
-3
@@ -63,6 +63,7 @@ func _draw() -> void:
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
_draw_telegraphs()
|
||||
for p in client.remote_players():
|
||||
if _visible(p["pos"]):
|
||||
_draw_remote_player(p)
|
||||
@@ -95,8 +96,7 @@ func _draw_debug() -> void:
|
||||
## Aggro radius by visual index. Read from the content definitions rather than
|
||||
## hardcoded, so the overlay cannot drift from what the server actually uses.
|
||||
func _aggro_for(visual: int) -> float:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
var def := Content.enemy(id)
|
||||
if def.visual == visual:
|
||||
return def.aggro_range if not def.emitters.is_empty() else 0.0
|
||||
@@ -254,12 +254,35 @@ func _draw_enemy(e: Dictionary) -> void:
|
||||
Color(0.95, 0.55, 0.55, 0.35), 1.5)
|
||||
|
||||
|
||||
## Attacks that have been announced but not yet landed.
|
||||
##
|
||||
## Drawn through fog and through walls, deliberately. Everything else in this
|
||||
## view respects line of sight, but a warning you cannot see is not a warning --
|
||||
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
|
||||
## exist is to make the strike a decision. The marker is inside the boss arena
|
||||
## you are already standing in, so this hides nothing a player could not walk
|
||||
## two paces and see.
|
||||
func _draw_telegraphs() -> void:
|
||||
for t in client.telegraphs:
|
||||
var at: Vector2 = t["pos"]
|
||||
var r: float = t["r"]
|
||||
var left := float(int(t["until"]) - client.server_tick_est)
|
||||
var progress := clampf(1.0 - left / float(int(t["ticks"])), 0.0, 1.0)
|
||||
# Fills up as the moment approaches, so the warning reads as a clock and
|
||||
# not merely as a place.
|
||||
draw_circle(at, r, Color(1.0, 0.35, 0.3, 0.10 + 0.14 * progress))
|
||||
draw_arc(at, r, -PI * 0.5, -PI * 0.5 + TAU * progress, 40,
|
||||
Color(1.0, 0.5, 0.35, 0.9), 3.0)
|
||||
draw_arc(at, r, 0.0, TAU, 40, Color(1.0, 0.45, 0.35, 0.35), 1.5)
|
||||
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var pos: Vector2 = b["pos"]
|
||||
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
|
||||
var visual := client.boss_def.visual if client.boss_def != null else 0
|
||||
var src := Art.frame(Art.boss_idle(visual), Art.anim_frame(_anim_time, 0))
|
||||
_draw_sprite(Art.TILESET, src, pos)
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
|
||||
|
||||
@@ -75,8 +75,7 @@ func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
|
||||
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
|
||||
## the wrong creature.
|
||||
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
var v := Content.enemy(id).visual
|
||||
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
|
||||
|
||||
|
||||
@@ -113,3 +113,114 @@ func test_a_brand_new_boss_needs_no_engine_changes() -> void:
|
||||
world.spawn_boss(def)
|
||||
world.step()
|
||||
assert_eq(world.pool.live_count, 6)
|
||||
|
||||
|
||||
# --- The second boss --------------------------------------------------------
|
||||
|
||||
func test_the_cantor_is_registered_and_whole() -> void:
|
||||
var b := Content.boss(Content.BOSS_CANTOR)
|
||||
assert_eq(b.id, Content.BOSS_CANTOR)
|
||||
assert_gt(b.max_hp, 0)
|
||||
assert_gt(b.phases.size(), 1)
|
||||
for phase in b.phases:
|
||||
assert_gt(phase.emitters.size(), 0, "%s fires nothing" % phase.name)
|
||||
assert_gt(b.loot.size(), 0, "a boss kill has to be worth something")
|
||||
|
||||
|
||||
## Phases are picked by "the last one whose threshold still covers this hp
|
||||
## fraction", so a list that is not sorted downwards silently skips phases.
|
||||
func test_every_boss_lists_its_phases_from_full_health_downwards() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var previous := 2.0
|
||||
for phase in Content.boss(id).phases:
|
||||
assert_lt(phase.enter_at_hp_fraction, previous,
|
||||
"%s: %s is not below the phase before it" % [id, phase.name])
|
||||
previous = phase.enter_at_hp_fraction
|
||||
|
||||
|
||||
func test_every_boss_reaches_all_of_its_phases() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var def := Content.boss(id)
|
||||
var seen := {}
|
||||
for step_index in 101:
|
||||
seen[def.phase_index_for(float(step_index) / 100.0)] = true
|
||||
assert_eq(seen.size(), def.phases.size(),
|
||||
"%s has a phase that no health fraction selects" % id)
|
||||
|
||||
|
||||
func test_there_is_a_sprite_for_every_boss() -> void:
|
||||
for id in Content.ALL_BOSSES:
|
||||
var def := Content.boss(id)
|
||||
assert_lt(def.visual, Art.BOSS_IDLE_FRAMES.size(),
|
||||
"%s has visual %d with no sprite" % [id, def.visual])
|
||||
for n in Art.ACTOR_FRAMES:
|
||||
var f := Art.frame(Art.boss_idle(def.visual), n)
|
||||
assert_lte(f.end.x, float(Art.TILESET.get_width()))
|
||||
assert_lte(f.end.y, float(Art.TILESET.get_height()))
|
||||
|
||||
|
||||
func test_the_two_bosses_look_different() -> void:
|
||||
assert_ne(Content.warden().visual, Content.cantor().visual)
|
||||
|
||||
|
||||
## The Cantor's patterns assume the vault's barricades the way the Warden's
|
||||
## assume the hall's pits, so which boss appears has to follow from which arena
|
||||
## was stamped rather than being chosen separately.
|
||||
## Every id in the registry has to resolve. The lists exist so nothing has to
|
||||
## be hand-maintained in five places; this is what keeps them honest.
|
||||
func test_the_registry_lists_resolve() -> void:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
assert_eq(Content.enemy(id).id, id)
|
||||
for id in Content.ALL_BOSSES:
|
||||
assert_eq(Content.boss(id).id, id)
|
||||
|
||||
|
||||
func test_each_arena_summons_its_own_boss() -> void:
|
||||
assert_eq(Rooms.boss_for_arena(&"warden_hall"), Content.BOSS_WARDEN)
|
||||
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
|
||||
|
||||
|
||||
## Seed parity picks the arena, and the boss follows it. Deliberately NOT the
|
||||
## depth: depth is a dev flag nothing in play raises, so keying the arena to it
|
||||
## left the second boss unreachable in an actual game.
|
||||
func test_a_generated_dungeon_gets_the_boss_its_arena_belongs_to() -> void:
|
||||
var even := MapGen.generate(1234, 1)
|
||||
var odd := MapGen.generate(1235, 1)
|
||||
assert_eq(StringName(even["boss_id"]), Content.BOSS_WARDEN)
|
||||
assert_eq(StringName(odd["boss_id"]), Content.BOSS_CANTOR)
|
||||
assert_not_null(Content.boss(StringName(even["boss_id"])))
|
||||
assert_not_null(Content.boss(StringName(odd["boss_id"])))
|
||||
|
||||
|
||||
## Both bosses have to actually turn up. A run picks its seed at random, so
|
||||
## this is the check that neither is effectively unreachable.
|
||||
func test_both_bosses_are_reachable_at_the_depth_people_play() -> void:
|
||||
var seen := {}
|
||||
for run in 40:
|
||||
seen[StringName(MapGen.generate(run * 7919 + 3, 1)["boss_id"])] = true
|
||||
for id in Content.ALL_BOSSES:
|
||||
assert_true(seen.has(id), "%s never appears at depth 1" % id)
|
||||
|
||||
|
||||
func test_an_instance_spawns_the_boss_its_map_asked_for() -> void:
|
||||
var inst := Instance.make_dungeon(2, 4321, 1)
|
||||
assert_eq(inst.boss_id, Content.BOSS_CANTOR)
|
||||
assert_eq(inst.world.boss.def.id, Content.BOSS_CANTOR)
|
||||
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
|
||||
"and it starts inside its own arena")
|
||||
|
||||
|
||||
## The Cantor exists to prove the boss format stretched to movement and
|
||||
## telegraphs. If it stopped using either, it would have stopped doing its job.
|
||||
func test_the_cantor_actually_uses_both_new_mechanisms() -> void:
|
||||
var def := Content.cantor()
|
||||
var moves := false
|
||||
var telegraphs := false
|
||||
for phase in def.phases:
|
||||
if phase.moves():
|
||||
moves = true
|
||||
for e in phase.emitters:
|
||||
if e is TelegraphedStrikeEmitter:
|
||||
telegraphs = true
|
||||
assert_true(moves, "the Cantor should move")
|
||||
assert_true(telegraphs, "and should telegraph")
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
extends GutTest
|
||||
## Bosses that move. Movement is a property of the PHASE, so all of this is
|
||||
## driven by building a BossPhase and stepping the world -- if any of it needed
|
||||
## a per-boss branch in SimWorld, the boss format would have stopped being data.
|
||||
|
||||
var world: SimWorld
|
||||
var boss: SimBoss
|
||||
|
||||
const ROOM := Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
var map := MapGrid.new(60, 40, MapGrid.Kind.WALL)
|
||||
map.fill_rect(Rect2i(1, 1, 58, 38), MapGrid.Kind.FLOOR)
|
||||
map.centre_on_origin()
|
||||
world = SimWorld.new(3)
|
||||
world.set_map(map)
|
||||
|
||||
|
||||
func _with_phase(phase: BossPhase, at := Vector2.ZERO) -> SimBoss:
|
||||
var def := BossDef.new()
|
||||
def.id = &"test_boss"
|
||||
def.max_hp = 1000
|
||||
def.radius = 30.0
|
||||
def.phases = [phase]
|
||||
boss = world.spawn_boss(def)
|
||||
boss.pos = at
|
||||
boss.room = ROOM
|
||||
return boss
|
||||
|
||||
|
||||
func _static_phase() -> BossPhase:
|
||||
var p := BossPhase.new()
|
||||
p.enter_at_hp_fraction = 1.0
|
||||
p.telegraph_ticks = 0
|
||||
p.loop_ticks = 600
|
||||
return p
|
||||
|
||||
|
||||
func _step(n: int) -> void:
|
||||
for _i in n:
|
||||
world.step()
|
||||
|
||||
|
||||
# --- The derived "does it move" flag ----------------------------------------
|
||||
|
||||
func test_a_phase_with_no_mode_or_no_speed_does_not_move() -> void:
|
||||
var p := _static_phase()
|
||||
assert_false(p.moves())
|
||||
p.move = BossPhase.Move.CHASE
|
||||
assert_false(p.moves(), "a mode with no speed is still standing still")
|
||||
p.move_speed = 60.0
|
||||
assert_true(p.moves())
|
||||
|
||||
|
||||
## Derived from the phases rather than stored, so a boss cannot claim to be
|
||||
## stationary while one of its phases walks around.
|
||||
func test_a_boss_moves_if_any_of_its_phases_does() -> void:
|
||||
assert_false(Content.warden().moves(), "the Warden still stands still")
|
||||
assert_true(Content.cantor().moves(), "the Cantor does not")
|
||||
|
||||
|
||||
func test_a_static_phase_leaves_the_boss_exactly_where_it_was() -> void:
|
||||
var b := _with_phase(_static_phase(), Vector2(50.0, 20.0))
|
||||
_step(120)
|
||||
assert_eq(b.pos, Vector2(50.0, 20.0))
|
||||
|
||||
|
||||
# --- Waypoints ---------------------------------------------------------------
|
||||
|
||||
func _waypoint_phase() -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.WAYPOINTS
|
||||
p.move_speed = 200.0
|
||||
p.waypoint_dwell = 30
|
||||
p.waypoints = [Vector2(0.0, 0.0), Vector2(1.0, 0.0)]
|
||||
return p
|
||||
|
||||
|
||||
func test_a_waypoint_boss_walks_to_its_first_point() -> void:
|
||||
var b := _with_phase(_waypoint_phase(), ROOM.get_center())
|
||||
var corner := ROOM.position
|
||||
var before := b.pos.distance_to(corner)
|
||||
_step(30)
|
||||
assert_lt(b.pos.distance_to(corner), before, "it should be closing")
|
||||
|
||||
|
||||
func test_it_arrives_dwells_and_moves_on() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
var b := _with_phase(phase, ROOM.position)
|
||||
_step(2)
|
||||
assert_eq(b.waypoint_index, 1, "standing on the first point advances it")
|
||||
var held := b.pos
|
||||
_step(phase.waypoint_dwell - 4)
|
||||
assert_eq(b.pos, held, "and it waits there rather than setting off at once")
|
||||
_step(60)
|
||||
assert_ne(b.pos, held, "then it goes")
|
||||
|
||||
|
||||
## Fractions of the arena, not absolute positions -- the Warden's hall and the
|
||||
## Choir Vault are different sizes, and one phase has to work in either.
|
||||
func test_waypoints_are_fractions_of_the_room() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
phase.waypoints = [Vector2(0.5, 0.5)]
|
||||
var b := _with_phase(phase, ROOM.position)
|
||||
_step(200)
|
||||
assert_almost_eq(b.pos.x, ROOM.get_center().x, 4.0)
|
||||
assert_almost_eq(b.pos.y, ROOM.get_center().y, 4.0)
|
||||
|
||||
|
||||
func test_a_waypoint_phase_with_no_points_stands_still() -> void:
|
||||
var phase := _waypoint_phase()
|
||||
phase.waypoints = []
|
||||
var b := _with_phase(phase, Vector2(10.0, 10.0))
|
||||
_step(60)
|
||||
assert_eq(b.pos, Vector2(10.0, 10.0))
|
||||
|
||||
|
||||
# --- Chase -------------------------------------------------------------------
|
||||
|
||||
func _chase_phase(standoff: float) -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.CHASE
|
||||
p.move_speed = 180.0
|
||||
p.move_param = standoff
|
||||
return p
|
||||
|
||||
|
||||
func test_it_closes_when_you_are_far_away() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(250.0, 0.0)
|
||||
var before := b.pos.distance_to(p.pos)
|
||||
_step(60)
|
||||
assert_lt(b.pos.distance_to(p.pos), before)
|
||||
|
||||
|
||||
## Backs off rather than piling onto you. A boss standing on top of a player is
|
||||
## a boss whose bullets cannot be read, which is the one thing this genre
|
||||
## cannot afford.
|
||||
func test_it_backs_off_when_you_get_too_close() -> void:
|
||||
var b := _with_phase(_chase_phase(200.0), Vector2.ZERO)
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(20.0, 0.0)
|
||||
_step(60)
|
||||
assert_gt(b.pos.distance_to(p.pos), 20.0)
|
||||
|
||||
|
||||
func test_it_settles_at_the_distance_it_was_given() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(100.0, 0.0)
|
||||
_step(240)
|
||||
assert_almost_eq(b.pos.distance_to(p.pos), 150.0, 12.0)
|
||||
|
||||
|
||||
func test_it_stands_still_with_nobody_to_chase() -> void:
|
||||
var b := _with_phase(_chase_phase(150.0), Vector2(40.0, 0.0))
|
||||
_step(60)
|
||||
assert_eq(b.pos, Vector2(40.0, 0.0))
|
||||
|
||||
|
||||
# --- Orbit -------------------------------------------------------------------
|
||||
|
||||
func _orbit_phase() -> BossPhase:
|
||||
var p := _static_phase()
|
||||
p.move = BossPhase.Move.ORBIT
|
||||
p.move_speed = 160.0
|
||||
p.move_param = 120.0
|
||||
return p
|
||||
|
||||
|
||||
## ORBIT computes an absolute destination, so without the shared speed clamp it
|
||||
## would snap onto its circle on the very first tick.
|
||||
func test_orbiting_never_teleports_onto_the_circle() -> void:
|
||||
var phase := _orbit_phase()
|
||||
var b := _with_phase(phase, ROOM.get_center() + Vector2(280.0, 0.0))
|
||||
var before := b.pos
|
||||
world.step()
|
||||
assert_lte(b.pos.distance_to(before), phase.move_speed * SimConfig.TICK_DELTA + 0.5,
|
||||
"one tick may move it at most one tick's worth")
|
||||
|
||||
|
||||
func test_orbiting_ends_up_on_the_circle_and_keeps_going() -> void:
|
||||
var phase := _orbit_phase()
|
||||
var b := _with_phase(phase, ROOM.get_center())
|
||||
_step(300)
|
||||
var centre := ROOM.get_center()
|
||||
assert_almost_eq(b.pos.distance_to(centre), phase.move_param, 25.0)
|
||||
var somewhere := b.pos
|
||||
_step(90)
|
||||
assert_gt(b.pos.distance_to(somewhere), 20.0, "and it is still travelling")
|
||||
|
||||
|
||||
# --- The invariants movement had to not break --------------------------------
|
||||
|
||||
## Boss rooms deliberately do not lock: a player can always walk out. That only
|
||||
## works as an escape if the boss cannot follow.
|
||||
func test_a_moving_boss_never_leaves_its_arena() -> void:
|
||||
var phase := _chase_phase(0.0)
|
||||
phase.move_speed = 400.0
|
||||
var b := _with_phase(phase, ROOM.get_center())
|
||||
var p := world.add_player(1, "bait")
|
||||
for step_index in 400:
|
||||
# Drag the bait right out of the room and around the map.
|
||||
p.pos = Vector2(900.0, 500.0).rotated(float(step_index) * 0.05)
|
||||
world.step()
|
||||
assert_true(ROOM.has_point(b.pos) or ROOM.abs().grow(1.0).has_point(b.pos),
|
||||
"the boss left its arena at %s" % b.pos)
|
||||
|
||||
|
||||
## A single pillar proves nothing -- the boss would end up at its quarry either
|
||||
## way, and only the final position was ever checked. A wall it cannot go round
|
||||
## is the test: if movement ignores geometry the boss simply appears on the far
|
||||
## side of it.
|
||||
func test_a_moving_boss_does_not_walk_through_geometry() -> void:
|
||||
var wall_x := world.map.to_tile(Vector2.ZERO).x
|
||||
for ty in range(world.map.to_tile(Vector2(0.0, -260.0)).y,
|
||||
world.map.to_tile(Vector2(0.0, 260.0)).y + 1):
|
||||
world.map.set_tile(wall_x, ty, MapGrid.Kind.WALL)
|
||||
|
||||
var b := _with_phase(_chase_phase(0.0), Vector2(-200.0, 0.0))
|
||||
var p := world.add_player(1, "bait")
|
||||
p.pos = Vector2(200.0, 0.0)
|
||||
for _i in 300:
|
||||
world.step()
|
||||
assert_false(world.map.circle_blocked(b.pos, 2.0),
|
||||
"the boss stepped inside solid geometry at %s" % b.pos)
|
||||
assert_lt(b.pos.x, 0.0,
|
||||
"the wall spans the arena, so the boss must still be on its own side")
|
||||
|
||||
|
||||
## The replica draws bosses from snapshots. If it moved one itself, the drawn
|
||||
## boss and the authoritative one would drift apart with nothing to correct it.
|
||||
func test_a_replica_never_moves_a_boss() -> void:
|
||||
var replica := SimWorld.new(3)
|
||||
replica.authoritative = false
|
||||
var def := BossDef.new()
|
||||
def.max_hp = 1000
|
||||
def.radius = 30.0
|
||||
def.phases = [_waypoint_phase()]
|
||||
var b := replica.spawn_boss(def)
|
||||
b.pos = Vector2(77.0, -33.0)
|
||||
b.room = ROOM
|
||||
for _i in 200:
|
||||
replica.step()
|
||||
assert_eq(b.pos, Vector2(77.0, -33.0))
|
||||
|
||||
|
||||
# --- The real fight ---------------------------------------------------------
|
||||
|
||||
## Drives the actual Cantor through every phase in its own arena. Nothing here
|
||||
## asserts a specific pattern -- the point is that a boss which moves, walks a
|
||||
## circuit, chases, orbits and telegraphs runs for thousands of ticks without
|
||||
## leaving its room, standing in a wall, or firing nothing.
|
||||
func test_the_cantor_survives_its_own_fight() -> void:
|
||||
var inst := Instance.make_dungeon(2, 9183, 1)
|
||||
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: an odd seed is the vault")
|
||||
var b := inst.world.boss
|
||||
var bait := inst.world.add_player(1, "bait")
|
||||
bait.pos = b.pos + Vector2(180.0, 0.0)
|
||||
bait.spawn_grace = 1000000 # watching, not fighting
|
||||
|
||||
var phases_seen := {}
|
||||
var telegraphs := 0
|
||||
var full := b.def.max_hp
|
||||
for stage in b.def.phases.size():
|
||||
# Set health to each phase's own threshold rather than to fractions
|
||||
# picked by hand -- guessing them missed the last phase entirely, and
|
||||
# would go stale the moment the fight was retuned.
|
||||
b.hp = maxi(roundi(float(full) * b.def.phases[stage].enter_at_hp_fraction), 1)
|
||||
for _i in 600:
|
||||
inst.step()
|
||||
phases_seen[b.phase_index] = true
|
||||
assert_true(b.room.grow(1.0).has_point(b.pos),
|
||||
"the Cantor left its arena at %s" % b.pos)
|
||||
assert_false(inst.world.map.circle_blocked(b.pos, 2.0),
|
||||
"the Cantor stood inside geometry at %s" % b.pos)
|
||||
for ev in inst.world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
||||
telegraphs += 1
|
||||
inst.world.drain_events()
|
||||
|
||||
assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran")
|
||||
assert_gt(telegraphs, 0, "and it warned before striking at least once")
|
||||
assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting")
|
||||
@@ -0,0 +1 @@
|
||||
uid://dreuvmvux87rj
|
||||
@@ -103,10 +103,11 @@ func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
||||
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
|
||||
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
||||
var emitters: Array[BulletEmitter] = []
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
emitters.append_array(Content.enemy(id).emitters)
|
||||
for phase in Content.warden().phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for boss_id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(boss_id).phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for e in emitters:
|
||||
# Accelerating bullets reach their top speed at the end of their life.
|
||||
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
|
||||
|
||||
@@ -76,3 +76,74 @@ func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
|
||||
world.step()
|
||||
assert_lt(world.pool.live_count, 12,
|
||||
"a short lifetime should keep spent point-blank shots from accumulating")
|
||||
|
||||
|
||||
# --- The scale of the numbers -----------------------------------------------
|
||||
|
||||
## Health and damage were multiplied by ten so that percentage modifiers have
|
||||
## somewhere to land: at the old base of 6 damage, the +5% every upgrade carries
|
||||
## rounded straight back to 6 and a player's first upgrade did nothing visible.
|
||||
## These tests pin the properties that made the rescale worth doing, and the
|
||||
## ratios it had to leave alone.
|
||||
func test_a_single_upgrade_visibly_changes_damage() -> void:
|
||||
var none := PlayerStats.build([] as Array[StringName])
|
||||
for id in Upgrades.ORDER:
|
||||
var one := PlayerStats.build([id] as Array[StringName])
|
||||
assert_ne(one.damage, none.damage,
|
||||
"%s carries +%d%% damage and must not round away" % [
|
||||
id, roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)])
|
||||
|
||||
|
||||
## Time to kill is the balance-relevant number, and the rescale was explicitly
|
||||
## not allowed to change it. Written as shots rather than as a ratio so a
|
||||
## careless edit to either side shows up as a number a designer recognises.
|
||||
func test_shots_to_kill_is_what_it_was_before_the_rescale() -> void:
|
||||
var expected := {
|
||||
Content.ENEMY_DRIFTER: 7, # 400 hp / 60
|
||||
Content.ENEMY_TURRET: 12, # 700 hp / 60
|
||||
Content.ENEMY_STALKER: 5, # 300 hp / 60
|
||||
}
|
||||
for id in expected:
|
||||
var def := Content.enemy(id)
|
||||
var shots := ceili(float(def.max_hp) / float(SimConfig.PLAYER_BULLET_DAMAGE))
|
||||
assert_eq(shots, int(expected[id]), "%s takes %d shots" % [id, shots])
|
||||
|
||||
|
||||
## Likewise for how long an unarmoured player survives standing in the open.
|
||||
func test_hits_to_kill_a_player_is_what_it_was() -> void:
|
||||
var base := SimConfig.PLAYER_MAX_HP
|
||||
var worst := 0
|
||||
for id in HOSTILES:
|
||||
for e in Content.enemy(id).emitters:
|
||||
worst = maxi(worst, e.damage)
|
||||
assert_eq(worst, 140, "the stalker's point-blank pellet is still the hardest hit")
|
||||
assert_eq(base / worst, 7, "and still takes this many to drop a fresh character")
|
||||
|
||||
|
||||
## Enemy health rides the snapshot as a u16. Anything above that is silently
|
||||
## misreported rather than rejected, which is exactly the kind of bug that
|
||||
## survives a rescale unnoticed -- the old practice dummy was already past it.
|
||||
func test_every_enemy_fits_the_health_field_the_wire_gives_it() -> void:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id)
|
||||
|
||||
|
||||
## A levelled character's health rides the same kind of field.
|
||||
func test_a_capped_character_fits_the_health_field_too() -> void:
|
||||
assert_lte(Progression.max_hp_for_level(Progression.MAX_LEVEL), 65535)
|
||||
|
||||
|
||||
## The hub's practice target has to still be there tomorrow. Expressed as a
|
||||
## flag, so a patient player cannot wear it down and leave the hub without one.
|
||||
func test_the_practice_dummy_cannot_be_destroyed() -> void:
|
||||
var world := SimWorld.new(1)
|
||||
var e := world.spawn_enemy(Content.dummy(), Vector2(200.0, 0.0))
|
||||
world._damage_enemy(e, 10_000_000)
|
||||
assert_true(e.alive)
|
||||
assert_eq(e.hp, e.def.max_hp, "and takes no damage at all")
|
||||
|
||||
|
||||
func test_nothing_else_is_indestructible() -> void:
|
||||
for id in HOSTILES:
|
||||
assert_false(Content.enemy(id).indestructible,
|
||||
"%s must be killable, or it is scenery" % id)
|
||||
|
||||
@@ -90,10 +90,11 @@ func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
|
||||
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]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
emitters.append_array(Content.enemy(id).emitters)
|
||||
for phase in Content.warden().phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for boss_id in Content.ALL_BOSSES:
|
||||
for phase in Content.boss(boss_id).phases:
|
||||
emitters.append_array(phase.emitters)
|
||||
for e in emitters:
|
||||
worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA)
|
||||
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
|
||||
|
||||
@@ -483,3 +483,31 @@ func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void:
|
||||
b.put_u16(0)
|
||||
var out := NetCodec.decode_upgrade_state(b.data_array)
|
||||
assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName])
|
||||
|
||||
|
||||
func test_telegraph_events_round_trip() -> void:
|
||||
var events: Array[Dictionary] = [
|
||||
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(-120.5, 64.25),
|
||||
"r": 70.0, "ticks": 90},
|
||||
]
|
||||
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
||||
assert_eq(out.size(), 1)
|
||||
assert_almost_eq((out[0]["pos"] as Vector2).x, -120.5, 0.01)
|
||||
assert_almost_eq((out[0]["pos"] as Vector2).y, 64.25, 0.01)
|
||||
assert_almost_eq(float(out[0]["r"]), 70.0, 0.01)
|
||||
assert_eq(int(out[0]["ticks"]), 90)
|
||||
|
||||
|
||||
## A telegraph carries a fixed-width body like every other event. If its length
|
||||
## were wrong the rest of the packet would decode as nonsense rather than fail.
|
||||
func test_events_after_a_telegraph_still_decode() -> void:
|
||||
var events: Array[Dictionary] = [
|
||||
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(10.0, 20.0), "r": 50.0, "ticks": 60},
|
||||
{"t": SimEvent.Type.ENEMY_HIT, "id": 77, "dmg": 130, "hp": 900},
|
||||
{"t": SimEvent.Type.BOSS_PHASE, "phase": 2},
|
||||
]
|
||||
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
|
||||
assert_eq(out.size(), 3)
|
||||
assert_eq(int(out[1]["id"]), 77)
|
||||
assert_eq(int(out[1]["hp"]), 900)
|
||||
assert_eq(int(out[2]["phase"]), 2)
|
||||
|
||||
@@ -39,7 +39,9 @@ func test_fractions_accumulate_rather_than_being_lost() -> void:
|
||||
|
||||
func test_it_scales_with_maximum_health_so_levels_do_not_dilute_it() -> void:
|
||||
var low := _player()
|
||||
low.max_hp = 100
|
||||
# Taken from the curve rather than written down, so a rescale of the health
|
||||
# numbers cannot leave this test comparing two arbitrary constants.
|
||||
low.max_hp = Progression.max_hp_for_level(Progression.START_LEVEL)
|
||||
low.hp = 1
|
||||
for _i in SimConfig.TICK_RATE:
|
||||
world.step()
|
||||
@@ -47,7 +49,7 @@ func test_it_scales_with_maximum_health_so_levels_do_not_dilute_it() -> void:
|
||||
|
||||
var world2 := SimWorld.new(1)
|
||||
var high := world2.add_player(PEER, "tester")
|
||||
high.max_hp = 240
|
||||
high.max_hp = Progression.max_hp_for_level(Progression.MAX_LEVEL)
|
||||
high.hp = 1
|
||||
for _i in SimConfig.TICK_RATE:
|
||||
world2.step()
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
extends GutTest
|
||||
## Telegraphed strikes: the warning, the strike, and the promise that the two
|
||||
## land in the same place.
|
||||
##
|
||||
## The emitter is stateless -- emitters are shared resources and two bosses of
|
||||
## the same kind must not stomp each other -- so the strike positions are
|
||||
## derived from the volley number rather than rolled and remembered. Most of
|
||||
## what is worth testing here follows from that.
|
||||
|
||||
const BOUNDS := Rect2(Vector2(-400.0, -300.0), Vector2(800.0, 600.0))
|
||||
|
||||
var emitter: TelegraphedStrikeEmitter
|
||||
var ctx: EmitContext
|
||||
var pool: BulletPool
|
||||
|
||||
|
||||
func before_each() -> void:
|
||||
emitter = TelegraphedStrikeEmitter.new()
|
||||
emitter.interval = 120
|
||||
emitter.warn_ticks = 60
|
||||
emitter.strikes = 3
|
||||
emitter.burst_count = 8
|
||||
emitter.blast_radius = 60.0
|
||||
emitter.speed = 140.0
|
||||
emitter.damage = 100
|
||||
pool = BulletPool.new()
|
||||
ctx = EmitContext.new()
|
||||
ctx.pool = pool
|
||||
ctx.bounds = BOUNDS
|
||||
ctx.rng = RandomNumberGenerator.new()
|
||||
|
||||
|
||||
func _run(local_tick: int) -> void:
|
||||
ctx.local_tick = local_tick
|
||||
ctx.shot_index = emitter.shot_index_at(local_tick)
|
||||
if emitter.should_fire(local_tick):
|
||||
emitter.fire(ctx)
|
||||
|
||||
|
||||
func _telegraphs() -> Array[Dictionary]:
|
||||
var out: Array[Dictionary] = []
|
||||
for ev in ctx.events:
|
||||
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
||||
out.append(ev)
|
||||
return out
|
||||
|
||||
|
||||
# --- The two moments ---------------------------------------------------------
|
||||
|
||||
func test_a_volley_announces_before_it_fires() -> void:
|
||||
_run(0)
|
||||
assert_eq(_telegraphs().size(), emitter.strikes, "one warning per strike")
|
||||
assert_eq(pool.live_count, 0, "and not a single bullet yet")
|
||||
|
||||
|
||||
func test_the_burst_lands_when_the_warning_runs_out() -> void:
|
||||
_run(0)
|
||||
_run(emitter.warn_ticks)
|
||||
assert_eq(pool.live_count, emitter.strikes * emitter.burst_count)
|
||||
|
||||
|
||||
func test_nothing_happens_between_the_warning_and_the_strike() -> void:
|
||||
for t in range(0, emitter.warn_ticks):
|
||||
_run(t)
|
||||
assert_eq(pool.live_count, 0)
|
||||
assert_eq(_telegraphs().size(), emitter.strikes,
|
||||
"and the warning is announced once, not every tick")
|
||||
|
||||
|
||||
func test_the_warning_carries_where_how_big_and_how_long() -> void:
|
||||
_run(0)
|
||||
for ev in _telegraphs():
|
||||
assert_eq(float(ev["r"]), emitter.blast_radius)
|
||||
assert_eq(int(ev["ticks"]), emitter.warn_ticks)
|
||||
assert_true(BOUNDS.has_point(ev["pos"]))
|
||||
|
||||
|
||||
## The whole reason the emitter is written the way it is: the warning and the
|
||||
## burst are computed at different ticks with nothing stored in between, and
|
||||
## they have to agree. A marker that lied about where the strike would land
|
||||
## would be worse than no marker.
|
||||
func test_the_burst_lands_where_the_warning_said_it_would() -> void:
|
||||
_run(0)
|
||||
var promised: Array[Vector2] = []
|
||||
for ev in _telegraphs():
|
||||
promised.append(ev["pos"])
|
||||
_run(emitter.warn_ticks)
|
||||
for i in pool.high_water:
|
||||
if pool.alive[i] == 0:
|
||||
continue
|
||||
var nearest := INF
|
||||
for spot in promised:
|
||||
nearest = minf(nearest, spot.distance_to(pool.pos[i]))
|
||||
assert_lt(nearest, emitter.blast_radius,
|
||||
"a bullet appeared %.0f from any marked spot" % nearest)
|
||||
|
||||
|
||||
func test_strike_points_are_a_pure_function_of_the_volley() -> void:
|
||||
for volley in 20:
|
||||
for index in emitter.strikes:
|
||||
assert_eq(emitter.strike_point(volley, index, BOUNDS),
|
||||
emitter.strike_point(volley, index, BOUNDS))
|
||||
|
||||
|
||||
func test_consecutive_volleys_pick_different_places() -> void:
|
||||
var moved := 0
|
||||
for volley in 30:
|
||||
if emitter.strike_point(volley, 0, BOUNDS) \
|
||||
!= emitter.strike_point(volley + 1, 0, BOUNDS):
|
||||
moved += 1
|
||||
assert_gt(moved, 25, "a strike that always lands in one place is a wall")
|
||||
|
||||
|
||||
func test_the_strikes_in_one_volley_are_not_all_the_same_spot() -> void:
|
||||
var distinct := {}
|
||||
for index in emitter.strikes:
|
||||
distinct[emitter.strike_point(3, index, BOUNDS)] = true
|
||||
assert_eq(distinct.size(), emitter.strikes)
|
||||
|
||||
|
||||
## Two strike emitters in one phase would otherwise derive identical points and
|
||||
## stack every burst on top of itself.
|
||||
func test_the_pattern_seed_separates_two_emitters() -> void:
|
||||
var other := TelegraphedStrikeEmitter.new()
|
||||
other.pattern_seed = 91
|
||||
var same := 0
|
||||
for volley in 20:
|
||||
if emitter.strike_point(volley, 0, BOUNDS) == other.strike_point(volley, 0, BOUNDS):
|
||||
same += 1
|
||||
assert_lt(same, 3)
|
||||
|
||||
|
||||
## Half a burst spent against a wall is half a burst the player never had to
|
||||
## dodge.
|
||||
func test_strikes_stay_clear_of_the_arena_edge() -> void:
|
||||
var inner := BOUNDS.grow(-emitter.margin)
|
||||
for volley in 50:
|
||||
for index in emitter.strikes:
|
||||
assert_true(inner.has_point(emitter.strike_point(volley, index, BOUNDS)))
|
||||
|
||||
|
||||
## A tiny room cannot be shrunk by the margin without inverting. Falling back to
|
||||
## the whole room beats emitting at a negative-size rectangle's corner.
|
||||
func test_a_room_smaller_than_the_margin_still_produces_points_inside_it() -> void:
|
||||
var tiny := Rect2(Vector2(-20.0, -20.0), Vector2(40.0, 40.0))
|
||||
for volley in 20:
|
||||
assert_true(tiny.has_point(emitter.strike_point(volley, 0, tiny)))
|
||||
|
||||
|
||||
# --- Wired into a real fight -------------------------------------------------
|
||||
|
||||
func test_a_boss_running_the_emitter_announces_through_the_world() -> void:
|
||||
var world := SimWorld.new(5)
|
||||
var def := BossDef.new()
|
||||
def.max_hp = 5000
|
||||
def.radius = 30.0
|
||||
var phase := BossPhase.new()
|
||||
phase.enter_at_hp_fraction = 1.0
|
||||
phase.telegraph_ticks = 0
|
||||
phase.loop_ticks = 600
|
||||
phase.emitters = [emitter]
|
||||
def.phases = [phase]
|
||||
var boss := world.spawn_boss(def)
|
||||
boss.room = Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
|
||||
world.step()
|
||||
var announced := 0
|
||||
for ev in world.events:
|
||||
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
||||
announced += 1
|
||||
assert_eq(announced, emitter.strikes,
|
||||
"the emitter's warning has to reach the world's event list")
|
||||
|
||||
|
||||
## Every strike emitter in the game has to warn inside its own cycle, or a
|
||||
## volley lands after the next one has already been announced and the markers
|
||||
## stop meaning anything.
|
||||
func test_every_authored_strike_warns_before_its_next_volley() -> void:
|
||||
var phases: Array[BossPhase] = []
|
||||
phases.append_array(Content.warden().phases)
|
||||
phases.append_array(Content.cantor().phases)
|
||||
var found := 0
|
||||
for phase in phases:
|
||||
for e in phase.emitters:
|
||||
if e is TelegraphedStrikeEmitter:
|
||||
found += 1
|
||||
assert_lt((e as TelegraphedStrikeEmitter).warn_ticks, e.interval,
|
||||
"a strike must land before the next volley is called")
|
||||
assert_gt(found, 0, "setup: some boss should actually use these")
|
||||
|
||||
|
||||
# --- The client's side ------------------------------------------------------
|
||||
|
||||
## Warnings expire on the SERVER's clock, not on wall time: a frame-rate dip
|
||||
## must not leave a marker sitting over ground that was struck seconds ago.
|
||||
func test_the_client_drops_a_warning_when_its_attack_has_landed() -> void:
|
||||
var client: ClientRuntime = autofree(ClientRuntime.new())
|
||||
client.server_tick_est = 100
|
||||
client.telegraphs = [
|
||||
{"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 130},
|
||||
{"pos": Vector2(10.0, 0.0), "r": 60.0, "ticks": 60, "until": 160},
|
||||
]
|
||||
client._expire_telegraphs()
|
||||
assert_eq(client.telegraphs.size(), 2, "neither has landed yet")
|
||||
client.server_tick_est = 140
|
||||
client._expire_telegraphs()
|
||||
assert_eq(client.telegraphs.size(), 1)
|
||||
client.server_tick_est = 200
|
||||
client._expire_telegraphs()
|
||||
assert_eq(client.telegraphs.size(), 0)
|
||||
|
||||
|
||||
## Entering an instance has to clear them, or a warning from the dungeon you
|
||||
## just left would hang over the hub floor.
|
||||
func test_arriving_somewhere_clears_the_old_warnings() -> void:
|
||||
var client: ClientRuntime = autofree(ClientRuntime.new())
|
||||
client.telegraphs = [{"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 999}]
|
||||
client.on_enter_instance(7, Protocol.InstanceKind.LOBBY, 0, "", Vector2.ZERO,
|
||||
20, 20, PackedByteArray(), "", Vector2.ZERO)
|
||||
assert_eq(client.telegraphs.size(), 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu6ayw438erq8
|
||||
@@ -158,6 +158,13 @@ func _enter_dungeon() -> void:
|
||||
# Drop the bot's backlog. The client keeps roughly INPUT_TARGET_LEAD frames
|
||||
# in flight, so without this the first press queues up behind them.
|
||||
p.input_queue.clear()
|
||||
# And stop it COASTING. A starved server repeats the last frame it was
|
||||
# given for INPUT_MAX_AGE ticks, so the bot's final movement vector kept
|
||||
# walking the player for half a second after the takeover -- far enough off
|
||||
# the item it had been placed on that the pickup found nothing. The press
|
||||
# itself arrived correctly, which is why "the press reached the simulation"
|
||||
# passed while everything it should have caused failed.
|
||||
p.held_input = InputFrame.new()
|
||||
# Long arrival protection instead of god mode: it is a state the game
|
||||
# already has, so nothing here is testing a code path players never hit.
|
||||
p.spawn_grace = 100000
|
||||
|
||||
@@ -17,10 +17,9 @@ func _init() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(OUT_ENEMIES)
|
||||
DirAccess.make_dir_recursive_absolute(OUT_BOSSES)
|
||||
var written := 0
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER,
|
||||
Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
written += _save(Content.enemy(id), "%s/%s.tres" % [OUT_ENEMIES, id])
|
||||
for id in [Content.BOSS_WARDEN]:
|
||||
for id in Content.ALL_BOSSES:
|
||||
written += _save(Content.boss(id), "%s/%s.tres" % [OUT_BOSSES, id])
|
||||
print("exported %d resources" % written)
|
||||
quit(0)
|
||||
|
||||
@@ -120,6 +120,10 @@ check "and are played" "$OUT/server.log" "playing 'bot1'"
|
||||
# do is produce a drop to act on -- that end of it is tools/diag_loot.tscn.
|
||||
check "inventories reach the save file" "$OUT/characters.json" "\"inventory\""
|
||||
check "a dungeon instance opened" "$OUT/server.log" "opened dungeon instance"
|
||||
# Which arena a run gets is a coin flip on its seed, so this only asserts that
|
||||
# SOME boss spawned and that it is one the content knows about -- which is what
|
||||
# catches an arena wired to a boss id that does not resolve.
|
||||
check "a boss spawned" "$OUT/server.log" "BOSS_SPAWNED (warden|cantor) in instance"
|
||||
# Bots pick their entrance from their account id, so a run with several of them
|
||||
# opens one of each kind. This is what catches a portal wired to the wrong
|
||||
# dungeon, or an instance matcher that ignores which dungeon was asked for.
|
||||
|
||||
Reference in New Issue
Block a user