Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
ci / verify (push) Successful in 49s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,10 @@ extends Resource
|
||||
@export var display_name: String = "Boss"
|
||||
@export var max_hp: int = 4000
|
||||
@export var radius: float = 44.0
|
||||
## Stationary bosses are the MVP shape; the field exists so a later boss can
|
||||
## move without changing the runtime.
|
||||
@export var stationary: bool = true
|
||||
@export var spawn_pos := Vector2(0.0, -140.0)
|
||||
## Index into the renderer's boss sprite table. Same idea as EnemyDef.visual --
|
||||
## the simulation never learns that art exists.
|
||||
@export var visual: int = 0
|
||||
@export var phases: Array[BossPhase] = []
|
||||
## What the kill leaves behind. Bosses are the guaranteed source: a run that
|
||||
## reaches the end should always be worth something, so unlike trash loot these
|
||||
@@ -18,6 +18,16 @@ extends Resource
|
||||
@export var loot: Array[LootDrop] = []
|
||||
|
||||
|
||||
## Whether any phase moves this boss. Derived from the phases rather than
|
||||
## stored alongside them: a flag that said "stationary" while a phase walked
|
||||
## around would be a second source of truth, and the wrong one.
|
||||
func moves() -> bool:
|
||||
for phase in phases:
|
||||
if phase != null and phase.moves():
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
## Index of the phase that matches [param hp_fraction]. Later entries win, so a
|
||||
## boss at 0.2 hp picks the lowest-threshold phase that still covers it.
|
||||
func phase_index_for(hp_fraction: float) -> int:
|
||||
|
||||
@@ -16,3 +16,38 @@ extends Resource
|
||||
## phases rather than adding hit points, so the fight length stays predictable.
|
||||
@export var damage_taken_mult: float = 1.0
|
||||
@export var emitters: Array[BulletEmitter] = []
|
||||
|
||||
@export_group("Movement")
|
||||
## How the boss moves during this phase. Movement is a property of the PHASE,
|
||||
## not of the boss: a fight that stands still and then starts hunting you is
|
||||
## one boss with two phases, and expressing it any other way would put a
|
||||
## per-boss branch in the simulation.
|
||||
@export var move: Move = Move.STATIC
|
||||
## Units per second. Zero is equivalent to STATIC.
|
||||
@export var move_speed: float = 0.0
|
||||
## ORBIT: radius around the arena's centre. CHASE: the distance it tries to
|
||||
## hold from you -- closing all the way would mean a boss you cannot see past.
|
||||
@export var move_param: float = 120.0
|
||||
## WAYPOINTS: points in the arena as fractions of it, so one phase works in any
|
||||
## room. (0,0) is the top-left corner of the fightable area, (1,1) the bottom
|
||||
## right.
|
||||
@export var waypoints: Array[Vector2] = []
|
||||
## Ticks spent standing at each waypoint before moving on.
|
||||
@export var waypoint_dwell: int = 60
|
||||
|
||||
enum Move {
|
||||
## Never moves. Every phase written before bosses could move.
|
||||
STATIC,
|
||||
## Circles the centre of its arena at [member move_param] radius.
|
||||
ORBIT,
|
||||
## Closes on the nearest player, holding [member move_param] distance.
|
||||
CHASE,
|
||||
## Walks a fixed circuit of [member waypoints], pausing at each.
|
||||
WAYPOINTS,
|
||||
}
|
||||
|
||||
|
||||
## Whether this phase actually moves the boss. Derived rather than stored, so a
|
||||
## phase cannot claim to move and then sit still.
|
||||
func moves() -> bool:
|
||||
return move != Move.STATIC and move_speed > 0.0
|
||||
|
||||
+205
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,11 @@ var selected_character: String = ""
|
||||
## characters yet" from "not asked yet".
|
||||
var characters_known: bool = false
|
||||
|
||||
## Attacks that have been announced but not yet landed, as
|
||||
## [{ "pos": Vector2, "r": float, "until": int, "ticks": int }]. Server-pushed;
|
||||
## the client never invents one and never decides one is over early.
|
||||
var telegraphs: Array[Dictionary] = []
|
||||
|
||||
## Whole seconds until a cleared dungeon returns the party, or
|
||||
## Protocol.COUNTDOWN_NONE outside that state.
|
||||
var cleared_countdown: int = Protocol.COUNTDOWN_NONE
|
||||
@@ -171,10 +176,24 @@ func _physics_process(delta: float) -> void:
|
||||
Net.send_input(NetCodec.encode_inputs(redundant))
|
||||
|
||||
world.step()
|
||||
_expire_telegraphs()
|
||||
_interp = minf(_interp + delta * float(SimConfig.TICK_RATE) / float(SimConfig.SNAPSHOT_INTERVAL), 1.0)
|
||||
_maybe_bot_leave()
|
||||
|
||||
|
||||
## Drop warnings whose attack has already landed. Keyed on the estimated server
|
||||
## tick rather than on wall time, so a warning lasts exactly as long as the
|
||||
## server said it would however the frame rate is behaving.
|
||||
func _expire_telegraphs() -> void:
|
||||
if telegraphs.is_empty():
|
||||
return
|
||||
var live: Array[Dictionary] = []
|
||||
for t in telegraphs:
|
||||
if server_tick_est < int(t["until"]):
|
||||
live.append(t)
|
||||
telegraphs = live
|
||||
|
||||
|
||||
## Bot harness: quit cleanly mid-run so the smoke test proves a polite
|
||||
## disconnect is caught by the same channel a SIGKILL is. Deferred because
|
||||
## Net.shutdown() frees this node.
|
||||
@@ -422,6 +441,7 @@ func on_enter_instance(id: int, kind: int, server_tick: int, boss_id: String,
|
||||
request_respawn = false
|
||||
my_respawn_wait = 0.0
|
||||
my_inventory = []
|
||||
telegraphs = []
|
||||
cleared_countdown = Protocol.COUNTDOWN_NONE
|
||||
GameLog.info("client", "entered instance %d (%s)" % [id, Protocol.InstanceKind.keys()[kind]])
|
||||
instance_changed.emit()
|
||||
@@ -533,6 +553,16 @@ func on_events(data: PackedByteArray) -> void:
|
||||
enemy_died.emit()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
boss_died.emit()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
telegraphs.append({
|
||||
"pos": ev["pos"],
|
||||
"r": ev["r"],
|
||||
"ticks": maxi(int(ev["ticks"]), 1),
|
||||
# Aged by however long the packet spent in flight, the same
|
||||
# way a bullet is, so a warning drawn late still disappears
|
||||
# on time rather than lingering past its own attack.
|
||||
"until": int(packet["tick"]) + int(ev["ticks"]),
|
||||
})
|
||||
SimEvent.Type.ITEM_PICKED_UP:
|
||||
if int(ev["peer"]) == my_peer:
|
||||
item_picked_up.emit(ev["item"])
|
||||
|
||||
@@ -258,6 +258,11 @@ static func encode_events(server_tick: int, events: Array[Dictionary]) -> Packed
|
||||
body.put_u32(ev["id"])
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
body.put_u8(clampi(int(ev["phase"]), 0, 255))
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
body.put_float(ev["pos"].x)
|
||||
body.put_float(ev["pos"].y)
|
||||
body.put_float(ev["r"])
|
||||
body.put_u16(clampi(int(ev["ticks"]), 0, 65535))
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
count += 1
|
||||
@@ -312,6 +317,10 @@ static func decode_events(data: PackedByteArray) -> Dictionary:
|
||||
ev["id"] = b.get_u32()
|
||||
SimEvent.Type.BOSS_PHASE:
|
||||
ev["phase"] = b.get_u8()
|
||||
SimEvent.Type.TELEGRAPH:
|
||||
ev["pos"] = Vector2(b.get_float(), b.get_float())
|
||||
ev["r"] = b.get_float()
|
||||
ev["ticks"] = b.get_u16()
|
||||
SimEvent.Type.BOSS_DIED:
|
||||
pass
|
||||
out.append(ev)
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@ extends RefCounted
|
||||
## the dungeon you are standing in, replacing the single portal position.
|
||||
## 8: upgrades. A new server -> client upgrade-state message, a new
|
||||
## client -> server choice message, and two more SelectResult values.
|
||||
const VERSION := 8
|
||||
## 9: telegraphed boss attacks. A new TELEGRAPH event, appended to the enum.
|
||||
const VERSION := 9
|
||||
const DEFAULT_PORT := 27015
|
||||
const MAX_CLIENTS := 32
|
||||
|
||||
|
||||
+11
-1
@@ -65,6 +65,7 @@ static func _build_lobby() -> Dictionary:
|
||||
"npc": npc,
|
||||
"boss_pos": Vector2.ZERO,
|
||||
"boss_room": Rect2i(),
|
||||
"boss_id": &"",
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +86,12 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
|
||||
# The boss arena is placed first and everything else works around it, so a
|
||||
# generated corridor can never carve through the authored fight.
|
||||
var stamp := Rooms.warden_hall() if d % 2 == 1 else Rooms.choir_vault()
|
||||
# Which arena -- and therefore which boss -- comes from the SEED, not the
|
||||
# depth. Depth is a dev flag that nothing in play raises, so keying the
|
||||
# arena to it meant the second boss existed and no player could ever reach
|
||||
# it. Every run rolls a fresh seed, so this is a coin flip per dungeon.
|
||||
var arena := &"warden_hall" if posmod(seed_value, 2) == 0 else &"choir_vault"
|
||||
var stamp := Rooms.warden_hall() if arena == &"warden_hall" else Rooms.choir_vault()
|
||||
var bs := Rooms.size_of(stamp)
|
||||
var boss_origin := Vector2i(w - bs.x - 2, (h - bs.y) / 2)
|
||||
var markers := Rooms.stamp(grid, stamp, boss_origin)
|
||||
@@ -156,6 +162,10 @@ static func generate(seed_value: int, depth: int) -> Dictionary:
|
||||
"npc": Vector2.ZERO,
|
||||
"boss_pos": boss_pos,
|
||||
"boss_room": boss_room,
|
||||
# Which boss lives here follows from WHICH arena was stamped. Choosing
|
||||
# it anywhere else would let a dungeon put the Cantor in the Warden's
|
||||
# hall, where its patterns assume geometry that is not there.
|
||||
"boss_id": Rooms.boss_for_arena(arena),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ var rng: RandomNumberGenerator
|
||||
var bounds := Rect2(Vector2(-620.0, -340.0), Vector2(1240.0, 680.0))
|
||||
|
||||
|
||||
## Where an emitter announces something before it happens. Points at the
|
||||
## owning world's event list; emitters fired into a bare pool by a test simply
|
||||
## append here and nobody reads it.
|
||||
var events: Array[Dictionary] = []
|
||||
|
||||
|
||||
## Warn that an attack will land at [param at] in [param ticks] ticks.
|
||||
##
|
||||
## The only thing an emitter can do besides spawn bullets, and it exists so a
|
||||
## strike that appears out of thin air is a *choice* the player was given time
|
||||
## to answer rather than an unavoidable hit.
|
||||
func telegraph(at: Vector2, warn_radius: float, ticks: int) -> void:
|
||||
events.append({
|
||||
"t": SimEvent.Type.TELEGRAPH,
|
||||
"pos": at,
|
||||
"r": warn_radius,
|
||||
"ticks": ticks,
|
||||
})
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
if not has_target:
|
||||
return PI * 0.5
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
class_name TelegraphedStrikeEmitter
|
||||
extends BulletEmitter
|
||||
## Marks spots in the arena, then fills them with bullets a moment later.
|
||||
##
|
||||
## The whole point is the moment in between. A burst that appears at your feet
|
||||
## with no warning is not a pattern, it is a coin flip; the same burst with a
|
||||
## second of notice is a question about where you would rather be standing.
|
||||
## [member warn_ticks] is that second, and the client draws the marker from the
|
||||
## TELEGRAPH event this emits.
|
||||
##
|
||||
## Stateless, like every other emitter -- emitters are shared resources and two
|
||||
## bosses of the same kind must not stomp each other's timers. The strike
|
||||
## positions are therefore *derived* from the volley number rather than rolled
|
||||
## and remembered, so the announcement and the strike compute the same points
|
||||
## without anything being stored between them.
|
||||
|
||||
## Spots marked per volley.
|
||||
@export var strikes: int = 3
|
||||
## Ticks between the marker appearing and the burst landing. Must be shorter
|
||||
## than [member interval], or a volley would land after the next was announced.
|
||||
@export var warn_ticks: int = 75
|
||||
## Bullets in each burst, fired outwards from the marked spot.
|
||||
@export var burst_count: int = 12
|
||||
## Radius of the marked area, and the ring the burst is born on.
|
||||
@export var blast_radius: float = 64.0
|
||||
## Keeps strikes off the arena's walls, where half the burst would be wasted.
|
||||
@export var margin: float = 72.0
|
||||
## Distinguishes two strike emitters in one phase, which would otherwise derive
|
||||
## the same points and stack every burst on top of itself.
|
||||
@export var pattern_seed: int = 0
|
||||
|
||||
|
||||
## Armed on two ticks per volley: the announcement, and the strike itself.
|
||||
func should_fire(local_tick: int) -> bool:
|
||||
if not is_armed(local_tick) or interval <= 0:
|
||||
return false
|
||||
var into := posmod(local_tick - start_tick, interval)
|
||||
return into == 0 or into == posmod(warn_ticks, interval)
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if strikes <= 0 or interval <= 0:
|
||||
return
|
||||
var offset := ctx.local_tick - start_tick
|
||||
var volley := int(floor(float(offset) / float(interval)))
|
||||
var announcing := posmod(offset, interval) == 0
|
||||
for i in strikes:
|
||||
var at := strike_point(volley, i, ctx.bounds)
|
||||
if announcing:
|
||||
ctx.telegraph(at, blast_radius, warn_ticks)
|
||||
else:
|
||||
_burst(ctx, at)
|
||||
|
||||
|
||||
## The burst leaves the marked spot outwards, so standing on the marker is the
|
||||
## one place that is certainly wrong and the edge is survivable.
|
||||
func _burst(ctx: EmitContext, at: Vector2) -> void:
|
||||
if burst_count <= 0:
|
||||
return
|
||||
for i in burst_count:
|
||||
var a := TAU * (float(i) / float(burst_count))
|
||||
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25))
|
||||
|
||||
|
||||
## Where volley [param volley]'s strike [param index] lands, inside
|
||||
## [param bounds]. A pure function of its arguments: called once when the
|
||||
## warning goes up and again when the burst lands, and it has to agree with
|
||||
## itself both times without anything being remembered in between.
|
||||
func strike_point(volley: int, index: int, bounds: Rect2) -> Vector2:
|
||||
var inner := bounds.grow(-margin)
|
||||
if inner.size.x <= 0.0 or inner.size.y <= 0.0:
|
||||
inner = bounds
|
||||
return inner.position + Vector2(
|
||||
_unit(volley, index * 2) * inner.size.x,
|
||||
_unit(volley, index * 2 + 1) * inner.size.y)
|
||||
|
||||
|
||||
## A small integer hash in [0, 1). Not a good random number generator and does
|
||||
## not need to be -- it needs to be scattered, and it needs to give the same
|
||||
## answer twice.
|
||||
func _unit(a: int, b: int) -> float:
|
||||
var h := (a * 374761393 + b * 668265263 + pattern_seed * 2246822519) & 0x7FFFFFFF
|
||||
h = ((h ^ (h >> 13)) * 1274126177) & 0x7FFFFFFF
|
||||
return float((h >> 7) & 0xFFFF) / 65536.0
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkswilm3f3jc1
|
||||
@@ -15,6 +15,10 @@ var phase_tick: int = 0
|
||||
## player can always disengage by walking out -- which is the trade for the
|
||||
## boss room having no door that locks.
|
||||
var room := Rect2()
|
||||
## WAYPOINTS movement: which point it is walking to, and how long it still
|
||||
## stands at the one it reached.
|
||||
var waypoint_index: int = 0
|
||||
var waypoint_wait: int = 0
|
||||
## Poison doses ticking on the boss. Lazy for the same reason enemies' are.
|
||||
var poison: PoisonTrack = null
|
||||
|
||||
|
||||
@@ -34,4 +34,8 @@ enum Type {
|
||||
ITEM_PICKED_UP, ## peer, item
|
||||
ITEM_USED, ## peer, item
|
||||
ITEM_DROPPED, ## peer, item
|
||||
## An attack is about to land somewhere, and the player has to be shown
|
||||
## where before it does. Carries the spot, its radius and how long the
|
||||
## warning lasts; the client draws it and the server fires into it later.
|
||||
TELEGRAPH, ## pos, r, ticks
|
||||
}
|
||||
|
||||
+77
-5
@@ -568,11 +568,14 @@ func _step_boss() -> void:
|
||||
var phase := boss.current_phase()
|
||||
if phase == null:
|
||||
return
|
||||
# A boss never leaves its arena. Enforced here rather than left to each
|
||||
# boss's movement code, because boss rooms deliberately do not lock: the
|
||||
# player can always walk out, and the fight only stays a fight if the boss
|
||||
# cannot follow. Currently a no-op (every boss is stationary), which is
|
||||
# exactly when an invariant is cheapest to establish.
|
||||
|
||||
_move_boss(phase)
|
||||
|
||||
# A boss never leaves its arena. Enforced here rather than inside each
|
||||
# movement mode, because boss rooms deliberately do not lock: the player can
|
||||
# always walk out, and the fight only stays a fight if the boss cannot
|
||||
# follow. It was a no-op while every boss stood still, which is exactly when
|
||||
# an invariant is cheapest to establish -- now it is load-bearing.
|
||||
if boss.room.size != Vector2.ZERO:
|
||||
boss.pos = boss.room.position + Vector2(
|
||||
clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x),
|
||||
@@ -584,6 +587,72 @@ func _step_boss() -> void:
|
||||
boss.phase_tick += 1
|
||||
|
||||
|
||||
## One tick of boss movement, driven entirely by the phase's data. Every mode
|
||||
## here is generic: adding a boss that moves is writing a [BossPhase], not
|
||||
## touching this function.
|
||||
func _move_boss(phase: BossPhase) -> void:
|
||||
if not phase.moves():
|
||||
return
|
||||
var dt := SimConfig.TICK_DELTA
|
||||
var step := Vector2.ZERO
|
||||
match phase.move:
|
||||
BossPhase.Move.ORBIT:
|
||||
var centre := boss.room.get_center() if boss.room.size != Vector2.ZERO \
|
||||
else boss.def.spawn_pos
|
||||
var angle := float(boss.phase_tick) * dt \
|
||||
* (phase.move_speed / maxf(phase.move_param, 1.0))
|
||||
step = centre + Vector2.RIGHT.rotated(angle) * phase.move_param - boss.pos
|
||||
BossPhase.Move.CHASE:
|
||||
var quarry := nearest_player(boss.pos)
|
||||
if quarry == null:
|
||||
return
|
||||
var to_player := quarry.pos - boss.pos
|
||||
var gap := to_player.length()
|
||||
if gap < 0.001:
|
||||
return
|
||||
# Signed, so it backs off when you close inside its preferred
|
||||
# distance. A boss that ends up standing on you is a boss whose
|
||||
# bullets you cannot see coming.
|
||||
step = (to_player / gap) * signf(gap - phase.move_param) * phase.move_speed * dt
|
||||
BossPhase.Move.WAYPOINTS:
|
||||
if phase.waypoints.is_empty():
|
||||
return
|
||||
if boss.waypoint_wait > 0:
|
||||
boss.waypoint_wait -= 1
|
||||
return
|
||||
var goal := _waypoint_world(phase, boss.waypoint_index)
|
||||
var to_goal := goal - boss.pos
|
||||
if to_goal.length() <= phase.move_speed * dt:
|
||||
boss.pos = goal
|
||||
boss.waypoint_index = (boss.waypoint_index + 1) % phase.waypoints.size()
|
||||
boss.waypoint_wait = phase.waypoint_dwell
|
||||
return
|
||||
step = to_goal
|
||||
_:
|
||||
return
|
||||
# Clamped here rather than in each branch, so no mode can teleport: ORBIT in
|
||||
# particular computes an absolute destination and would otherwise snap to
|
||||
# its circle on the first tick of the phase.
|
||||
var reach := phase.move_speed * dt
|
||||
if step.length() > reach:
|
||||
step = step.normalized() * reach
|
||||
# Slid rather than assigned, so a boss cannot walk through the pillars its
|
||||
# own arena was designed around.
|
||||
boss.pos = map.slide_circle(boss.pos, step, boss.def.radius)
|
||||
|
||||
|
||||
## A phase waypoint, given as a fraction of the arena, in world coordinates.
|
||||
## Fractions rather than absolutes so one phase can be dropped into any room --
|
||||
## the Warden's hall and the Choir Vault are different sizes.
|
||||
func _waypoint_world(phase: BossPhase, index: int) -> Vector2:
|
||||
var w: Vector2 = phase.waypoints[index]
|
||||
if boss.room.size == Vector2.ZERO:
|
||||
return boss.def.spawn_pos
|
||||
return boss.room.position + Vector2(
|
||||
clampf(w.x, 0.0, 1.0) * boss.room.size.x,
|
||||
clampf(w.y, 0.0, 1.0) * boss.room.size.y)
|
||||
|
||||
|
||||
## Shared emitter driver for enemies and bosses -- the reason a boss pattern can
|
||||
## be dropped onto a trash mob and vice versa.
|
||||
func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick: int,
|
||||
@@ -592,6 +661,9 @@ func _run_emitters(emitters: Array[BulletEmitter], origin: Vector2, local_tick:
|
||||
return
|
||||
var target := nearest_player(origin)
|
||||
_ctx.pool = pool
|
||||
# Re-pointed every call: drain_events() replaces the array wholesale, so a
|
||||
# context holding the old one would announce into a list nobody reads.
|
||||
_ctx.events = events
|
||||
_ctx.origin = origin
|
||||
# Curtain patterns span the room they are fired in. Without a room, fall
|
||||
# back to a box around the shooter rather than the whole map, or a trash
|
||||
|
||||
+12
-1
@@ -97,7 +97,18 @@ const ENEMY_IDLE: Array[Rect2] = [
|
||||
Rect2(368, 40, 16, 16), # goblin -> stalker
|
||||
Rect2(288, 336, 16, 16), # red flask -> practice target
|
||||
]
|
||||
const BOSS_IDLE := Rect2(16, 428, 32, 36) # big demon
|
||||
## BossDef.visual -> first idle frame. Same convention as ENEMY_IDLE.
|
||||
const BOSS_IDLE_FRAMES: Array[Rect2] = [
|
||||
Rect2(16, 428, 32, 36), # big demon -> the Warden of the Fold
|
||||
Rect2(144, 428, 32, 36), # big ogre -> the Cantor of the Vault
|
||||
]
|
||||
## The first boss's strip, kept as a name because plenty of code and tests read
|
||||
## "the boss sprite" and only one of them cares which boss.
|
||||
const BOSS_IDLE := BOSS_IDLE_FRAMES[0]
|
||||
|
||||
|
||||
static func boss_idle(visual: int) -> Rect2:
|
||||
return BOSS_IDLE_FRAMES[clampi(visual, 0, BOSS_IDLE_FRAMES.size() - 1)]
|
||||
## The hub's quartermaster. A different character strip from the knight the
|
||||
## player wears, so an NPC never reads as another player standing still.
|
||||
const NPC_IDLE := Rect2(128, 36, 16, 28)
|
||||
|
||||
+26
-3
@@ -63,6 +63,7 @@ func _draw() -> void:
|
||||
if _visible(e["pos"]):
|
||||
_draw_enemy(e)
|
||||
_draw_boss()
|
||||
_draw_telegraphs()
|
||||
for p in client.remote_players():
|
||||
if _visible(p["pos"]):
|
||||
_draw_remote_player(p)
|
||||
@@ -95,8 +96,7 @@ func _draw_debug() -> void:
|
||||
## Aggro radius by visual index. Read from the content definitions rather than
|
||||
## hardcoded, so the overlay cannot drift from what the server actually uses.
|
||||
func _aggro_for(visual: int) -> float:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
for id in Content.ALL_ENEMIES:
|
||||
var def := Content.enemy(id)
|
||||
if def.visual == visual:
|
||||
return def.aggro_range if not def.emitters.is_empty() else 0.0
|
||||
@@ -254,12 +254,35 @@ func _draw_enemy(e: Dictionary) -> void:
|
||||
Color(0.95, 0.55, 0.55, 0.35), 1.5)
|
||||
|
||||
|
||||
## Attacks that have been announced but not yet landed.
|
||||
##
|
||||
## Drawn through fog and through walls, deliberately. Everything else in this
|
||||
## view respects line of sight, but a warning you cannot see is not a warning --
|
||||
## it is an unavoidable hit with extra steps, and the entire reason telegraphs
|
||||
## exist is to make the strike a decision. The marker is inside the boss arena
|
||||
## you are already standing in, so this hides nothing a player could not walk
|
||||
## two paces and see.
|
||||
func _draw_telegraphs() -> void:
|
||||
for t in client.telegraphs:
|
||||
var at: Vector2 = t["pos"]
|
||||
var r: float = t["r"]
|
||||
var left := float(int(t["until"]) - client.server_tick_est)
|
||||
var progress := clampf(1.0 - left / float(int(t["ticks"])), 0.0, 1.0)
|
||||
# Fills up as the moment approaches, so the warning reads as a clock and
|
||||
# not merely as a place.
|
||||
draw_circle(at, r, Color(1.0, 0.35, 0.3, 0.10 + 0.14 * progress))
|
||||
draw_arc(at, r, -PI * 0.5, -PI * 0.5 + TAU * progress, 40,
|
||||
Color(1.0, 0.5, 0.35, 0.9), 3.0)
|
||||
draw_arc(at, r, 0.0, TAU, 40, Color(1.0, 0.45, 0.35, 0.35), 1.5)
|
||||
|
||||
|
||||
func _draw_boss() -> void:
|
||||
var b := client.boss_state()
|
||||
if b.is_empty() or not _visible(b["pos"]):
|
||||
return
|
||||
var pos: Vector2 = b["pos"]
|
||||
var src := Art.frame(Art.BOSS_IDLE, Art.anim_frame(_anim_time, 0))
|
||||
var visual := client.boss_def.visual if client.boss_def != null else 0
|
||||
var src := Art.frame(Art.boss_idle(visual), Art.anim_frame(_anim_time, 0))
|
||||
_draw_sprite(Art.TILESET, src, pos)
|
||||
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
||||
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.5), 2.0)
|
||||
|
||||
Reference in New Issue
Block a user