"""Spike, arrow_shooter, warp, phase_block + the invisible flag & block arrays.""" import hashlib import pygame from conftest import (FakeGame, step, run, place, hold, DT) from game.assets import AssetStore from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block # --- spike ------------------------------------------------------------------- def test_spike_active_by_trigger(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ##### #P.G# ##### traps: - type: spike at: [1, 1] trigger: always direction: up """) sp = lvl.traps[0] sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4))) assert sp.active and sp.hazard_rects() def test_spike_hazard_is_half_tile_on_the_direction_edge(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ##### #P.G# ##### traps: - type: spike at: [1, 1] trigger: always direction: down """) sp = lvl.traps[0] sp.update(DT, FakeGame(pygame.Rect(0, 0, 4, 4))) hz = sp.hazard_rects()[0] # down spike occupies the TOP half of its cell assert hz.height == 16 and hz.top == sp.base_rect.top def test_spike_sprite_rotates_per_direction(tmp_path): # A deliberately asymmetric sprite so each rotation is distinct. surf = pygame.Surface((8, 8), pygame.SRCALPHA) surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only adir = tmp_path / "assets" adir.mkdir() pygame.image.save(surf, str(adir / "spike.png")) a = AssetStore(str(adir)) up = a.get("spike", 32, 16, 0) down = a.get("spike", 32, 16, 180) left = a.get("spike", 16, 32, 90) def h(s): return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest() assert h(up) != h(down) # rotation actually happened assert left.get_size() == (16, 32) and up.get_size() == (32, 16) # --- arrow shooter ----------------------------------------------------------- def test_arrow_shooter_fires_on_interval(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ########## #.......G# #P.......# ########## traps: - type: arrow_shooter at: [7, 1] direction: left speed: 200 interval: 0.5 """) sh = lvl.traps[0] g = FakeGame(pygame.Rect(0, 0, 4, 4)) for _ in range(40): # ~0.66s -> at least one shot sh.update(DT, g) assert sh.arrows # spawned arrows assert sh.hazard_rects() # arrows are hazards def test_arrow_shooter_trigger_gates_firing(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ########## #.......G# #P.......# ########## traps: - type: arrow_shooter at: [7, 1] direction: left interval: 0.2 trigger: { within: 1 } """) sh = lvl.traps[0] far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near for _ in range(60): sh.update(DT, far) assert not sh.arrows # never fired while player out of range # --- invisible wall, now an array of invisible blocks ------------------------ def test_invisible_block_array_is_solid_and_invisible(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ###### #....# #....# #P..G# ###### traps: - type: block at: [3, 1] invisible: true count: [1, 2] """) blocks = [t for t in lvl.traps if isinstance(t, Block)] assert len(blocks) == 2 # one per cell assert all(b.invisible and b.solid_rects() for b in blocks) ys = sorted(b.current_rect().top for b in blocks) assert ys == [1 * 32, 2 * 32] # stacked vertically # --- warp -------------------------------------------------------------------- def test_warp_teleports_and_rearms(make_game): g = make_game(""" name: t tile_size: 32 map: | ########## #.......G# #P.......# ########## traps: - type: warp at: [4, 2] to: [7, 1] """) place(g, 2, 2) warped = False for _ in range(120): step(g, hold(right=True)) if g.player.rect.x >= 7 * 32 - 4: warped = True break assert warped def test_warp_flash_pulses_and_fades(make_game): g = make_game(""" name: t tile_size: 32 map: | ########## #.......G# #P.......# ########## traps: - type: warp at: [4, 2] to: [7, 1] """) warp = g.level.traps[0] assert warp._pulse == 0.0 # dormant: no aura place(g, 4, 2) # stand on the warp tile step(g) # -> teleports, aura flashes on assert warp._pulse > 0.9 # The aura paints at BOTH the source and the destination — even though the # warp is invisible and we're not in debug. a = AssetStore("noassets") surf = pygame.Surface((g.level.width, g.level.height)) surf.fill((0, 0, 0)) warp.render(surf, a) assert surf.get_at(warp.base_rect.center)[:3] != (0, 0, 0) assert surf.get_at(warp._dest_rect().center)[:3] != (0, 0, 0) # It quickly fades back to nothing within the pulse window. for _ in range(int(warp._PULSE_TIME / DT) + 2): step(g) assert warp._pulse == 0.0 # --- phase block ------------------------------------------------------------- def test_phase_block_intangible_until_triggered(make_game): g = make_game(""" name: t tile_size: 32 map: | ########## #........# #........# #P......G# ########## traps: - type: phase_block at: [4, 3] fade: 0.2 trigger: { within: 2 } """) pb = g.level.traps[0] place(g, 1, 3) step(g) assert not pb.solid and pb.alpha == 0 # dormant far away run(g, 40, lambda i: hold(right=True)) assert pb.solid and pb.alpha > 0 # phased in solid on approach def test_phase_block_kills_if_inside_when_it_forms(make_game): g = make_game(""" name: t tile_size: 32 map: | ######## #......# #.....G# ######## traps: - type: phase_block at: [3, 2] fade: 0.2 trigger: { within: 3 } """) pb = g.level.traps[0] place(g, 3, 2) # standing right where it will form killed, d0 = False, g.deaths for _ in range(10): step(g) if g.deaths > d0: killed = True break assert killed def test_phase_block_nudges_player_clipping_edge(make_game): # Only clipping the edge of a forming phase block -> shoved clear, not killed. g = make_game(""" name: t tile_size: 32 map: | ######### #.......# #......G# ######### traps: - type: phase_block at: [4, 1] fade: 0.2 trigger: { within: 5 } """) pb = g.level.traps[0] b = pb.base_rect # Straddle the block's left edge: mostly outside, just clipping into it. g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap g.player.fy = float(b.top + 2) g.player.vx = g.player.vy = 0.0 g.player._sync_rect() d0 = g.deaths step(g) # survived, and pushed out to the left so it no longer overlaps the cell assert g.deaths == d0 assert g.player.rect.right <= b.left assert pb.solid and not pb.emerge_kill def test_phase_block_kills_when_shove_would_squish(make_game): # Clipping the edge but backed by a wall on the escape side -> lethal. g = make_game(""" name: t tile_size: 32 map: | ######## #......# #.....G# ######## traps: - type: phase_block at: [1, 1] fade: 0.2 trigger: { within: 5 } """) pb = g.level.traps[0] b = pb.base_rect # Clip the block's left edge, but the level wall (col 0) is right there, so # the leftward shove has nowhere to go — squished. g.player.fx = float(b.left - g.player.w + 5) g.player.fy = float(b.top + 2) g.player.vx = g.player.vy = 0.0 g.player._sync_rect() killed, d0 = False, g.deaths for _ in range(6): step(g) if g.deaths > d0: killed = True break assert killed def test_phase_block_snaps_visible_on_death(make_game): # Dying while a phase block is forming should snap it fully visible for the # frozen death tableau. g = make_game(""" name: t tile_size: 32 map: | ######## #......# #.....G# ######## traps: - type: phase_block at: [3, 2] fade: 0.5 trigger: { within: 3 } """) pb = g.level.traps[0] place(g, 5, 2) # near enough to trigger, not on the cell for _ in range(6): # let it partially fade in step(g) assert 0 < pb.alpha < 1 g._start_death() # die from something assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze def test_debug_overscan_reveals_offmap_trap(make_level): # In debug the play surface is enlarged and shifted (render_offset) so a trap # placed just off the map is drawn into the overscan instead of being clipped. from game import settings as S lvl = make_level( "name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n" "traps:\n - type: block\n at: [5, 1]\n deadly: true\n") # col 5 = off-map a = AssetStore("noassets") m = S.DEBUG_VIEW_MARGIN * lvl.tile lvl.render_offset = (m, m) surf = pygame.Surface((lvl.width + 2 * m, lvl.height + 2 * m)) surf.fill((0, 0, 0)) lvl.draw(surf, a) cx = 5 * lvl.tile + m + lvl.tile // 2 cy = 1 * lvl.tile + m + lvl.tile // 2 assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan # --- generic invisible flag -------------------------------------------------- def test_invisible_flag_on_any_trap(make_level): lvl = make_level(""" name: t tile_size: 32 map: | ##### #...# #P.G# ##### traps: - type: spike at: [2, 1] trigger: always direction: up invisible: true """) a = AssetStore("no_assets_dir") sp = lvl.traps[0] g = FakeGame(pygame.Rect(999, 999, 4, 4)) def painted(debug): lvl.debug = debug sp.tick(DT, g) w = pygame.Surface((lvl.width, lvl.height)) w.fill((0, 0, 0)) sp.render(w, a) return any(w.get_at((x, y))[:3] != (0, 0, 0) for y in range(32, 96) for x in range(32, 96)) assert painted(False) is False # invisible in play assert painted(True) is True # revealed in debug assert sp.hazard_rects() # still deadly either way