Files
transcience/src/sim/patterns/telegraphed_strike_emitter.gd
T
claudeandClaude Opus 5 7b710471df Make the arenas' cover actually stop bullets
The player's muzzle was fixed last commit; every enemy and boss emitter
has one too, and theirs reach further. A ring sits 46px out, an arc
sweep's outer arms sit past 100px, and a wall-gap curtain spans the whole
arena -- most of a curtain is nowhere near the thing that fired it. The
pool samples geometry once per tick and never sees the jump, so all of
those were spawning bullets on the far side of whatever stood between.

That matters here more than it sounds, because both boss arenas are
authored out of cover. The hall has pillars; the vault has barricades,
and its own comment says they are there so that knowing where the Cantor
is does not hand you a firing lane to it. A barricade blocks bullets and
deliberately does NOT block sight -- so it is exactly the cover this bug
cancelled. Measured before fixing: the Warden's curtain put 13 shots
through a one-tile wall and its arc sweep 13 more; the Cantor's curtain,
16.

Two checks, not one. The path test skips its own starting tile so a
shooter in a doorway still resolves, and that skip let a telegraphed
burst whose centre and ring sit inside one wall tile through -- born in
the wall, announced to every client, dead on the next tick. The
destination is now asked separately, so a bullet never exists inside
geometry.

A telegraphed strike passes its own marker as the source rather than the
boss: that burst is born where the player was warned, and a pillar
between the boss and the spot has nothing to say about it. A strike
marked on cover is now simply a dud, which is cover working; pinned by a
test so it stays a known shape.

First attempt at this used a turret behind a barricade and proved
nothing -- a turret's 24px offset cannot clear its own 16px radius plus a
32px tile, so the test could not have failed. Replaced with the two
emitters that can reach, plus both real arenas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 17:24:34 +02:00

88 lines
3.7 KiB
GDScript

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))
# From the marker, not from the boss: this burst is born where the
# player was warned it would be, and a pillar between the boss and the
# spot has nothing to say about it.
emit_shot_from(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25), at)
## 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