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>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 17:24:34 +02:00
1 parent 20715e77cc
commit 7b710471df
3 files changed
+169 -2

No files matched your search

+38 -1
View File
@@ -54,7 +54,44 @@ func shot_index_at(local_tick: int) -> int:
## Shared spawn path so every emitter produces bullets with identical physics. ## Shared spawn path so every emitter produces bullets with identical physics.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void: ##
## [param from] is where the shot appears, which for nearly every emitter is a
## muzzle offset away from the shooter -- up to 46px here, and an arc sweep's
## outer arms further still. That offset is a teleport: [BulletPool] samples
## geometry once per tick and never sees the jump, so a shot could be born on
## the far side of a 32px wall and fly away down the other side of it. A
## barricade makes it worse than a curiosity, because a barricade stops bullets
## while deliberately NOT stopping sight -- so an enemy behind one sees you,
## aggros, fires, and hits you through the cover that exists to prevent exactly
## that. Shots that cannot reach their own muzzle are dropped, which is what
## would have happened a tick later anyway if they had had to travel there.
func emit_shot(ctx: EmitContext, angle: float, from: Vector2,
speed_scale: float = 1.0) -> void:
emit_shot_from(ctx, angle, from, ctx.origin, speed_scale)
## As [method emit_shot], for a burst that is not travelling out of the shooter.
## [param travels_from] is the point the shot is treated as leaving -- a
## telegraphed strike passes its own marker, because that burst is born at the
## spot the player was warned about and has nothing to do with where the boss is
## standing.
func emit_shot_from(ctx: EmitContext, angle: float, from: Vector2,
travels_from: Vector2, speed_scale: float = 1.0) -> void:
# ctx.pool.map is null when a test fires a pattern into a bare pool, which
# keeps EmitContext as narrow as its own comment promises.
var map: MapGrid = ctx.pool.map
if map != null:
# Two questions, not one. The path check skips its own starting tile, so
# a shooter standing in a doorway still resolves -- but that skip also
# lets a burst whose centre and whose ring both sit inside one wall tile
# through. Those bullets are born in the wall, announced to every client,
# and die on the next tick: a flicker of shots inside a barricade. A
# bullet must never exist inside geometry, so the destination is asked
# separately.
if map.bullet_blocked(from):
return
if not map.bullet_path_clear(travels_from, from):
return
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind, ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
accel, deg_to_rad(turn_deg)) accel, deg_to_rad(turn_deg))
@@ -59,7 +59,10 @@ func _burst(ctx: EmitContext, at: Vector2) -> void:
return return
for i in burst_count: for i in burst_count:
var a := TAU * (float(i) / float(burst_count)) var a := TAU * (float(i) / float(burst_count))
emit_shot(ctx, a, at + Vector2.RIGHT.rotated(a) * (blast_radius * 0.25)) # 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 ## Where volley [param volley]'s strike [param index] lands, inside
+127
View File
@@ -145,3 +145,130 @@ func test_arc_sweep_angle_changes_over_the_phase() -> void:
ctx.local_tick = 30 ctx.local_tick = 30
e.fire(ctx) e.fire(ctx)
assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001)) assert_ne(snappedf(a0, 0.001), snappedf(_angles()[0], 0.001))
# --- Muzzles against geometry -----------------------------------------------
# An emitter places bullets away from its shooter, and BulletPool samples
# geometry once per tick, so it never sees that jump. A curtain spans the whole
# arena and an arc sweep's outer arms sit 100px out -- both were spawning shots
# on the far side of cover. Both boss arenas are built out of cover: the vault's
# barricades exist precisely so there is no clean firing lane, and the hall has
# pillars.
func _walled_world(kind: MapGrid.Kind) -> SimWorld:
var g := MapGrid.new(30, 20, MapGrid.Kind.FLOOR)
for ty in 20:
g.set_tile(15, ty, kind)
var w := SimWorld.new(1)
w.set_map(g)
return w
func _ctx_for(w: SimWorld, at: Vector2) -> EmitContext:
var ctx := EmitContext.new()
ctx.pool = w.pool
ctx.origin = at
ctx.target = at + Vector2(0.0, 200.0)
ctx.has_target = true
ctx.rng = w.rng
ctx.bounds = Rect2(at - Vector2(320.0, 320.0), Vector2(640.0, 640.0))
return ctx
## Bullets born strictly beyond the barrier column, which spans one tile.
func _past_the_barrier(w: SimWorld) -> int:
var beyond: float = w.map.origin.x + 16.0 * MapGrid.TILE
var n := 0
for i in w.pool.high_water:
if w.pool.alive[i] != 0 and w.pool.pos[i].x >= beyond:
n += 1
return n
func _fire_once(w: SimWorld, em: BulletEmitter, at: Vector2) -> void:
var ctx := _ctx_for(w, at)
ctx.local_tick = 0
ctx.shot_index = 0
em.fire(ctx)
func test_a_curtain_does_not_spawn_through_a_barricade() -> void:
# A curtain spans its bounds, so most of it is nowhere near the shooter --
# which is exactly how it was reaching the far side of the room.
var em := WallGapEmitter.new()
em.count = 16
em.speed = 100.0
var open_world := _walled_world(MapGrid.Kind.FLOOR)
var at := Vector2(open_world.map.origin.x + 14.0 * MapGrid.TILE,
open_world.map.tile_centre(14, 10).y)
_fire_once(open_world, em, at)
assert_gt(_past_the_barrier(open_world), 0,
"with nothing in the way the curtain reaches across the room")
var blocked := _walled_world(MapGrid.Kind.BARRICADE)
_fire_once(blocked, em, at)
assert_eq(_past_the_barrier(blocked), 0,
"a barricade stops bullets, so it has to stop them being born past it")
assert_gt(blocked.pool.live_count, 0,
"and the near half of the curtain still fires")
func test_an_arc_sweeps_outer_arms_do_not_reach_through_a_wall() -> void:
var em := ArcSweepEmitter.new()
em.arms = 4
em.arm_spacing = 26.0
em.muzzle_offset = 26.0
em.speed = 100.0
var at_open := _walled_world(MapGrid.Kind.FLOOR)
# Flush against the barrier, sweeping into it.
var at := Vector2(at_open.map.origin.x + 15.0 * MapGrid.TILE - 20.0,
at_open.map.tile_centre(10, 10).y)
_fire_once(at_open, em, at)
var reach_open := _past_the_barrier(at_open)
var blocked := _walled_world(MapGrid.Kind.WALL)
_fire_once(blocked, em, at)
assert_gt(reach_open, 0, "the arms do reach past a tile when nothing stops them")
assert_eq(_past_the_barrier(blocked), 0, "and must not when something does")
func test_no_boss_ever_spawns_a_bullet_inside_its_own_arena_s_cover() -> void:
# The gameplay statement, against the real arenas. Both are authored around
# cover -- the vault's barricades exist so that knowing where the Cantor is
# does not hand you a firing lane, and the hall has pillars -- and a spawner
# that ignores geometry quietly cancels all of it.
for dungeon in [Dungeons.STANDARD, Dungeons.VAULT]:
var inst := Instance.make_dungeon(2, 4242, 1, dungeon)
var w := inst.world
var bait := w.add_player(1, "bait")
bait.pos = inst.boss_spawn + Vector2(90.0, 0.0)
var offenders := 0
for _t in 900:
w.step()
for slot in w.pool.spawn_log:
if w.map.bullet_blocked(w.pool.pos[slot]):
offenders += 1
assert_eq(offenders, 0,
"%s spawned %d bullet(s) inside its own arena's cover"
% [dungeon, offenders])
## The Cantor's strike_point avoids the room's EDGE and knows nothing about the
## barricades and pillars inside it, so a volley can be marked on top of cover.
## What lands there is nothing at all -- which is cover doing its job, and is
## pinned here so it stays a known shape rather than a surprise.
func test_a_strike_marked_on_cover_is_a_dud_rather_than_a_shot_through_it() -> void:
var g := MapGrid.new(20, 20, MapGrid.Kind.FLOOR)
g.set_tile(10, 10, MapGrid.Kind.BARRICADE)
var w := SimWorld.new(1)
w.set_map(g)
var em := TelegraphedStrikeEmitter.new()
em.burst_count = 8
em.blast_radius = 40.0
em.speed = 100.0
var ctx := _ctx_for(w, g.tile_centre(2, 2))
# Burst centred on the barricade: nothing may come out of it, and nothing
# may appear on the far side of it either.
em._burst(ctx, g.tile_centre(10, 10))
assert_eq(w.pool.live_count, 0,
"a burst inside cover stays inside it, and dies there")