diff --git a/src/sim/patterns/bullet_emitter.gd b/src/sim/patterns/bullet_emitter.gd index 588c477..f7f0f77 100644 --- a/src/sim/patterns/bullet_emitter.gd +++ b/src/sim/patterns/bullet_emitter.gd @@ -54,7 +54,44 @@ func shot_index_at(local_tick: int) -> int: ## 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 ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind, accel, deg_to_rad(turn_deg)) diff --git a/src/sim/patterns/telegraphed_strike_emitter.gd b/src/sim/patterns/telegraphed_strike_emitter.gd index d21b59a..1d80e88 100644 --- a/src/sim/patterns/telegraphed_strike_emitter.gd +++ b/src/sim/patterns/telegraphed_strike_emitter.gd @@ -59,7 +59,10 @@ func _burst(ctx: EmitContext, at: Vector2) -> void: return for i in 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 diff --git a/tests/unit/test_emitters.gd b/tests/unit/test_emitters.gd index 977e4f5..88aec5a 100644 --- a/tests/unit/test_emitters.gd +++ b/tests/unit/test_emitters.gd @@ -145,3 +145,130 @@ func test_arc_sweep_angle_changes_over_the_phase() -> void: 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")