Files
battery/tests/test_traps.py
2026-08-02 00:03:58 -04:00

441 lines
11 KiB
Python

"""Spike, arrow_shooter, warp, phase blocks + 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, 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: block
at: [4, 3]
phase: true
fade: 0.2
trigger: { within: 2 }
""")
pb = g.level.traps[0]
place(g, 1, 3)
step(g)
assert not pb.materialized and pb.alpha == 0 # dormant far away
run(g, 40, lambda i: hold(right=True))
assert pb.materialized 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: block
at: [3, 2]
phase: true
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: block
at: [4, 1]
phase: true
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.materialized 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: block
at: [1, 1]
phase: true
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: block
at: [3, 2]
phase: true
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.materialized # snapped fully visible for the freeze
def test_deadly_phase_block_harmless_until_materialized(make_game):
# A deadly phase block is neither solid nor a hazard while dormant, then
# becomes a (never-solid) hazard once it materialises.
g = make_game("""
name: t
tile_size: 32
map: |
##########
#........#
#........#
#P......G#
##########
traps:
- type: block
at: [4, 2]
phase: true
deadly: true
fade: 0.15
trigger: { within: 3 }
""")
b = g.level.traps[0]
place(g, 1, 3) # far away -> dormant
step(g)
assert not b.solid_rects() and not b.hazard_rects()
# Materialise it by standing on the cell; a deadly block never becomes solid.
place(g, 4, 2)
d0 = g.deaths
killed = False
for _ in range(12):
step(g)
if g.deaths > d0:
killed = True
break
assert killed # the materialised hazard kills
assert not b.solid_rects() # deadly -> never solid, even materialised
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