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")