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.