441 lines
11 KiB
Python
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
|