From e0c1e0d5c66ff675e9bbaf68b6b2db3b753ce2a1 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 16:32:12 +0200 Subject: [PATCH] Stage 5: bosses that move, attacks that warn, and a second boss Boss 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) handled generically in SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary is gone rather than kept beside the phases: a flag claiming the boss stood still while a phase walked around would be a second source of truth and the wrong one, so moves() is derived. CHASE holds a distance instead of closing, because a boss standing on top of you is a boss whose bullets cannot be read. Waypoints are fractions of the arena so one phase works in rooms of different sizes. Every mode is speed clamped in one place -- ORBIT computes an absolute destination and would otherwise snap onto its circle on the first tick -- and movement slides against geometry so a boss cannot walk through the pillars its own arena was designed around. The room clamp moved to after movement, where it is finally 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, so walking out is always an escape, and that only holds if the boss cannot follow. TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment between is the feature: a burst at your feet is a coin flip, the same burst with a second of notice is a question. It stays stateless like every other emitter -- they are shared resources and two bosses of the same kind must not stomp each other -- so strike positions are derived from the volley number and a test asserts the burst lands where the marker promised. Markers are drawn through fog and through walls, unlike everything else in the view, because a warning you cannot see is an unavoidable hit with extra steps. The Cantor of the Vault fights in the choir vault: static, then a four-corner circuit, then a chase, then orbiting while marking. It exists to prove the format stretched, and a test asserts it uses both new mechanisms. Which boss a run has now comes from its SEED rather than its depth. Depth is a dev flag nothing in play raises, so the arena was keyed to something no player can change and the second boss was unreachable in an actual game. Two things found while finishing: - tools/export_content.gd had a hand-maintained boss list and had already gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and ALL_BOSSES now feed the export tool, the renderer and five tests that each kept their own copy. - diag_loot failed intermittently after another diagnostic. Taking over from the bot cleared its input queue but not its HELD input, so a starved server coasted on the bot's last movement vector for half a second and walked the player off the item it had been placed on. The press arrived correctly, which is why "the press reached the simulation" passed while everything it should have caused failed. check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics green. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 17 +- docs/DECISIONS.md | 35 +++ docs/ROADMAP.md | 90 ++++-- resources/bosses/cantor.tres | 163 ++++++++++ src/actors/boss/boss_def.gd | 16 +- src/actors/boss/boss_phase.gd | 35 +++ src/content/content.gd | 206 ++++++++++++- src/content/rooms.gd | 10 + src/instances/instance.gd | 2 +- src/net/client_runtime.gd | 30 ++ src/net/net_codec.gd | 9 + src/net/protocol.gd | 3 +- src/sim/map_gen.gd | 12 +- src/sim/patterns/emit_context.gd | 20 ++ .../patterns/telegraphed_strike_emitter.gd | 84 +++++ .../telegraphed_strike_emitter.gd.uid | 1 + src/sim/sim_boss.gd | 4 + src/sim/sim_events.gd | 4 + src/sim/sim_world.gd | 82 ++++- src/view/art.gd | 13 +- src/view/world_view.gd | 29 +- tests/unit/test_art.gd | 3 +- tests/unit/test_boss.gd | 111 +++++++ tests/unit/test_boss_movement.gd | 286 ++++++++++++++++++ tests/unit/test_boss_movement.gd.uid | 1 + tests/unit/test_bullet_pool.gd | 7 +- tests/unit/test_content.gd | 3 +- tests/unit/test_interest.gd | 7 +- tests/unit/test_net_codec.gd | 28 ++ tests/unit/test_telegraph.gd | 219 ++++++++++++++ tests/unit/test_telegraph.gd.uid | 1 + tools/diag_loot.gd | 7 + tools/export_content.gd | 5 +- tools/smoke.sh | 4 + 34 files changed, 1498 insertions(+), 49 deletions(-) create mode 100644 resources/bosses/cantor.tres create mode 100644 src/sim/patterns/telegraphed_strike_emitter.gd create mode 100644 src/sim/patterns/telegraphed_strike_emitter.gd.uid create mode 100644 tests/unit/test_boss_movement.gd create mode 100644 tests/unit/test_boss_movement.gd.uid create mode 100644 tests/unit/test_telegraph.gd create mode 100644 tests/unit/test_telegraph.gd.uid diff --git a/CLAUDE.md b/CLAUDE.md index 90e3fdd..339d59f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fb7fabc..578c7f4 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -362,3 +362,38 @@ 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1e7d6f..80c69d8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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` | 364 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 | @@ -314,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. --- diff --git a/resources/bosses/cantor.tres b/resources/bosses/cantor.tres new file mode 100644 index 0000000..45fde27 --- /dev/null +++ b/resources/bosses/cantor.tres @@ -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")]) diff --git a/src/actors/boss/boss_def.gd b/src/actors/boss/boss_def.gd index 7aa45d6..5b8effd 100644 --- a/src/actors/boss/boss_def.gd +++ b/src/actors/boss/boss_def.gd @@ -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: diff --git a/src/actors/boss/boss_phase.gd b/src/actors/boss/boss_phase.gd index 2beb76e..34e60ca 100644 --- a/src/actors/boss/boss_phase.gd +++ b/src/actors/boss/boss_phase.gd @@ -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 diff --git a/src/content/content.gd b/src/content/content.gd index b2d19fc..b5d9bdc 100644 --- a/src/content/content.gd +++ b/src/content/content.gd @@ -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() @@ -159,8 +172,8 @@ static func warden() -> BossDef: b.display_name = "Warden of the Fold" 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 @@ -337,3 +350,194 @@ static func _warden_p4() -> BossPhase: 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 diff --git a/src/content/rooms.gd b/src/content/rooms.gd index 95ea929..67cbadd 100644 --- a/src/content/rooms.gd +++ b/src/content/rooms.gd @@ -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 diff --git a/src/instances/instance.gd b/src/instances/instance.gd index a05288e..4774cbf 100644 --- a/src/instances/instance.gd +++ b/src/instances/instance.gd @@ -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 diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 2923f8f..8ad67fd 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -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"]) diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index ffe67ae..bfb028b 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -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) diff --git a/src/net/protocol.gd b/src/net/protocol.gd index a9c119e..92d2465 100644 --- a/src/net/protocol.gd +++ b/src/net/protocol.gd @@ -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 diff --git a/src/sim/map_gen.gd b/src/sim/map_gen.gd index 3ed43ac..62923a7 100644 --- a/src/sim/map_gen.gd +++ b/src/sim/map_gen.gd @@ -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), } diff --git a/src/sim/patterns/emit_context.gd b/src/sim/patterns/emit_context.gd index 80705fb..48dd9c7 100644 --- a/src/sim/patterns/emit_context.gd +++ b/src/sim/patterns/emit_context.gd @@ -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 diff --git a/src/sim/patterns/telegraphed_strike_emitter.gd b/src/sim/patterns/telegraphed_strike_emitter.gd new file mode 100644 index 0000000..d21b59a --- /dev/null +++ b/src/sim/patterns/telegraphed_strike_emitter.gd @@ -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 diff --git a/src/sim/patterns/telegraphed_strike_emitter.gd.uid b/src/sim/patterns/telegraphed_strike_emitter.gd.uid new file mode 100644 index 0000000..31ef231 --- /dev/null +++ b/src/sim/patterns/telegraphed_strike_emitter.gd.uid @@ -0,0 +1 @@ +uid://dkswilm3f3jc1 diff --git a/src/sim/sim_boss.gd b/src/sim/sim_boss.gd index ea80f4b..490a748 100644 --- a/src/sim/sim_boss.gd +++ b/src/sim/sim_boss.gd @@ -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 diff --git a/src/sim/sim_events.gd b/src/sim/sim_events.gd index 155c684..215baf0 100644 --- a/src/sim/sim_events.gd +++ b/src/sim/sim_events.gd @@ -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 } diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 3d9099b..ca7728f 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -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 diff --git a/src/view/art.gd b/src/view/art.gd index 0b6a3ba..d700adf 100644 --- a/src/view/art.gd +++ b/src/view/art.gd @@ -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) diff --git a/src/view/world_view.gd b/src/view/world_view.gd index 72c6816..91d84dd 100644 --- a/src/view/world_view.gd +++ b/src/view/world_view.gd @@ -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) diff --git a/tests/unit/test_art.gd b/tests/unit/test_art.gd index 6ec4e3c..cd3ded8 100644 --- a/tests/unit/test_art.gd +++ b/tests/unit/test_art.gd @@ -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]) diff --git a/tests/unit/test_boss.gd b/tests/unit/test_boss.gd index 0575410..b6fc9e3 100644 --- a/tests/unit/test_boss.gd +++ b/tests/unit/test_boss.gd @@ -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") diff --git a/tests/unit/test_boss_movement.gd b/tests/unit/test_boss_movement.gd new file mode 100644 index 0000000..0505a3c --- /dev/null +++ b/tests/unit/test_boss_movement.gd @@ -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") diff --git a/tests/unit/test_boss_movement.gd.uid b/tests/unit/test_boss_movement.gd.uid new file mode 100644 index 0000000..a5c41dd --- /dev/null +++ b/tests/unit/test_boss_movement.gd.uid @@ -0,0 +1 @@ +uid://dreuvmvux87rj diff --git a/tests/unit/test_bullet_pool.gd b/tests/unit/test_bullet_pool.gd index e1624a8..98837b0 100644 --- a/tests/unit/test_bullet_pool.gd +++ b/tests/unit/test_bullet_pool.gd @@ -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 diff --git a/tests/unit/test_content.gd b/tests/unit/test_content.gd index c0a5a6f..06226dd 100644 --- a/tests/unit/test_content.gd +++ b/tests/unit/test_content.gd @@ -124,8 +124,7 @@ func test_hits_to_kill_a_player_is_what_it_was() -> void: ## 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.ENEMY_DRIFTER, Content.ENEMY_TURRET, - Content.ENEMY_STALKER, Content.ENEMY_DUMMY]: + for id in Content.ALL_ENEMIES: assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id) diff --git a/tests/unit/test_interest.gd b/tests/unit/test_interest.gd index 01ef77d..a2787d2 100644 --- a/tests/unit/test_interest.gd +++ b/tests/unit/test_interest.gd @@ -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, diff --git a/tests/unit/test_net_codec.gd b/tests/unit/test_net_codec.gd index b0a7224..c0451c0 100644 --- a/tests/unit/test_net_codec.gd +++ b/tests/unit/test_net_codec.gd @@ -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) diff --git a/tests/unit/test_telegraph.gd b/tests/unit/test_telegraph.gd new file mode 100644 index 0000000..e465fdb --- /dev/null +++ b/tests/unit/test_telegraph.gd @@ -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) diff --git a/tests/unit/test_telegraph.gd.uid b/tests/unit/test_telegraph.gd.uid new file mode 100644 index 0000000..0b49650 --- /dev/null +++ b/tests/unit/test_telegraph.gd.uid @@ -0,0 +1 @@ +uid://bu6ayw438erq8 diff --git a/tools/diag_loot.gd b/tools/diag_loot.gd index 7273b32..277b79f 100644 --- a/tools/diag_loot.gd +++ b/tools/diag_loot.gd @@ -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 diff --git a/tools/export_content.gd b/tools/export_content.gd index 39525bf..471b943 100644 --- a/tools/export_content.gd +++ b/tools/export_content.gd @@ -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) diff --git a/tools/smoke.sh b/tools/smoke.sh index 5d3c3f8..2c3973d 100755 --- a/tools/smoke.sh +++ b/tools/smoke.sh @@ -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.