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>
275 lines
8.3 KiB
GDScript
275 lines
8.3 KiB
GDScript
extends GutTest
|
|
## Emitters are the authoring surface for every enemy and boss, so their shapes
|
|
## are asserted directly rather than eyeballed in game.
|
|
|
|
var pool: BulletPool
|
|
var ctx: EmitContext
|
|
|
|
|
|
func before_each() -> void:
|
|
pool = BulletPool.new()
|
|
ctx = EmitContext.new()
|
|
ctx.pool = pool
|
|
ctx.origin = Vector2.ZERO
|
|
ctx.rng = RandomNumberGenerator.new()
|
|
ctx.rng.seed = 1
|
|
|
|
|
|
func _angles() -> Array[float]:
|
|
var out: Array[float] = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
out.append(pool.vel[i].angle())
|
|
return out
|
|
|
|
|
|
func test_ring_emits_the_requested_count() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 8
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 8)
|
|
|
|
|
|
func test_closed_ring_is_evenly_spaced_with_no_duplicate_at_the_seam() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 8
|
|
e.arc_deg = 360.0
|
|
e.spin_per_shot_deg = 0.0
|
|
e.fire(ctx)
|
|
var angles := _angles()
|
|
angles.sort()
|
|
for i in range(1, angles.size()):
|
|
assert_almost_eq(angles[i] - angles[i - 1], TAU / 8.0, 0.001)
|
|
|
|
|
|
func test_spin_advances_with_shot_index() -> void:
|
|
var e := RingEmitter.new()
|
|
e.count = 1
|
|
e.spin_per_shot_deg = 30.0
|
|
e.fire(ctx)
|
|
var first := _angles()[0]
|
|
pool.clear()
|
|
ctx.shot_index = 1
|
|
e.fire(ctx)
|
|
assert_almost_eq(_angles()[0] - first, deg_to_rad(30.0), 0.001)
|
|
|
|
|
|
func test_should_fire_respects_interval_and_window() -> void:
|
|
var e := RingEmitter.new()
|
|
e.start_tick = 10
|
|
e.end_tick = 40
|
|
e.interval = 10
|
|
assert_false(e.should_fire(9), "not armed before start_tick")
|
|
assert_true(e.should_fire(10))
|
|
assert_false(e.should_fire(15))
|
|
assert_true(e.should_fire(30))
|
|
assert_false(e.should_fire(50), "not armed after end_tick")
|
|
|
|
|
|
func test_shot_index_matches_the_number_of_shots_so_far() -> void:
|
|
var e := RingEmitter.new()
|
|
e.start_tick = 10
|
|
e.interval = 10
|
|
assert_eq(e.shot_index_at(10), 0)
|
|
assert_eq(e.shot_index_at(30), 2)
|
|
|
|
|
|
func test_aimed_spread_centres_on_the_target() -> void:
|
|
var e := AimedSpreadEmitter.new()
|
|
e.count = 3
|
|
e.spread_deg = 30.0
|
|
ctx.has_target = true
|
|
ctx.target = Vector2(100.0, 0.0)
|
|
e.fire(ctx)
|
|
var angles := _angles()
|
|
angles.sort()
|
|
assert_eq(angles.size(), 3)
|
|
assert_almost_eq(angles[1], 0.0, 0.001, "the middle shot must point at the target")
|
|
assert_almost_eq(angles[2] - angles[0], deg_to_rad(30.0), 0.001)
|
|
|
|
|
|
func test_aimed_spread_falls_back_when_nobody_is_alive() -> void:
|
|
var e := AimedSpreadEmitter.new()
|
|
e.count = 1
|
|
ctx.has_target = false
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 1, "an emitter with no target must still be safe to fire")
|
|
|
|
|
|
func test_wall_leaves_a_gap_of_the_requested_width() -> void:
|
|
var e := WallGapEmitter.new()
|
|
e.count = 20
|
|
e.gap_width = 3
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 17)
|
|
|
|
|
|
func test_wall_gap_slides_between_shots() -> void:
|
|
var e := WallGapEmitter.new()
|
|
e.count = 20
|
|
e.gap_width = 3
|
|
e.gap_step = 5
|
|
e.fire(ctx)
|
|
var first_xs: Array = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
first_xs.append(snappedf(pool.pos[i].x, 0.01))
|
|
pool.clear()
|
|
ctx.shot_index = 1
|
|
e.fire(ctx)
|
|
var second_xs: Array = []
|
|
for i in pool.high_water:
|
|
if pool.alive[i] == 1:
|
|
second_xs.append(snappedf(pool.pos[i].x, 0.01))
|
|
assert_ne(first_xs, second_xs, "the safe lane must move so it cannot be camped")
|
|
|
|
|
|
func test_arc_sweep_emits_every_arm() -> void:
|
|
var e := ArcSweepEmitter.new()
|
|
e.arms = 3
|
|
e.bullets_per_arm = 4
|
|
e.fire(ctx)
|
|
assert_eq(pool.live_count, 12)
|
|
|
|
|
|
func test_arc_sweep_angle_changes_over_the_phase() -> void:
|
|
var e := ArcSweepEmitter.new()
|
|
e.arms = 1
|
|
e.bullets_per_arm = 1
|
|
e.sweep_period = 4.0
|
|
e.sweep_deg = 90.0
|
|
ctx.local_tick = 0
|
|
e.fire(ctx)
|
|
var a0 := _angles()[0]
|
|
pool.clear()
|
|
ctx.local_tick = 30
|
|
e.fire(ctx)
|
|
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")
|