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