Format code with black

This commit is contained in:
James Campbell
2026-08-01 12:19:46 -04:00
parent b0ad9610be
commit f29d8c953c
16 changed files with 459 additions and 311 deletions

View File

@@ -15,8 +15,8 @@ from . import settings
class AssetStore: class AssetStore:
def __init__(self, assets_dir): def __init__(self, assets_dir):
self.assets_dir = assets_dir self.assets_dir = assets_dir
self._raw = {} # name -> original loaded Surface (or None if missing) self._raw = {} # name -> original loaded Surface (or None if missing)
self._cache = {} # (name, w, h) -> scaled Surface self._cache = {} # (name, w, h) -> scaled Surface
self._font = None self._font = None
def _font_for(self, h): def _font_for(self, h):

View File

@@ -9,8 +9,8 @@ from .assets import AssetStore
from .level import Level from .level import Level
from .player import Player, InputState from .player import Player, InputState
HUD_H = 46 # height of the status bar above the play area HUD_H = 46 # height of the status bar above the play area
MIN_W = 480 # keep the window wide enough for the HUD text MIN_W = 480 # keep the window wide enough for the HUD text
class Game: class Game:
@@ -18,7 +18,7 @@ class Game:
pygame.init() pygame.init()
pygame.display.set_caption(S.CAPTION) pygame.display.set_caption(S.CAPTION)
self.level_paths = level_paths self.level_paths = level_paths
self.force_debug = debug # --debug: turn debug view on for every level self.force_debug = debug # --debug: turn debug view on for every level
self.assets = AssetStore(assets_dir) self.assets = AssetStore(assets_dir)
self.clock = pygame.time.Clock() self.clock = pygame.time.Clock()
self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True) self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True)
@@ -26,12 +26,12 @@ class Game:
self.index = 0 self.index = 0
self.running = True self.running = True
self.state = "playing" # playing | dying | won_level | won_all self.state = "playing" # playing | dying | won_level | won_all
self.death_timer = 0.0 # counts down during the "dying" pause self.death_timer = 0.0 # counts down during the "dying" pause
self.death_rect = None # where to draw the corpse self.death_rect = None # where to draw the corpse
self.deaths = 0 # total deaths this session self.deaths = 0 # total deaths this session
self.level_deaths = 0 # deaths on the current level (resets per level) self.level_deaths = 0 # deaths on the current level (resets per level)
self._pending_reload = False # F5: reload the level file on next respawn self._pending_reload = False # F5: reload the level file on next respawn
# Size the window ONCE to fit the largest level, then never resize it — # Size the window ONCE to fit the largest level, then never resize it —
# calling set_mode again mid-session recreates the window (it flickers / # calling set_mode again mid-session recreates the window (it flickers /
@@ -62,7 +62,7 @@ class Game:
def _load_current(self): def _load_current(self):
self.level = Level(self.level_paths[self.index]) self.level = Level(self.level_paths[self.index])
if self.force_debug: if self.force_debug:
self.level.debug = True # CLI flag overrides the per-level setting self.level.debug = True # CLI flag overrides the per-level setting
self.player = Player(self.level) self.player = Player(self.level)
self.battery = self.level.battery self.battery = self.level.battery
# In debug, grow the play surface by a margin and shift all drawing into # In debug, grow the play surface by a margin and shift all drawing into
@@ -78,7 +78,7 @@ class Game:
self.state = "playing" self.state = "playing"
self.death_timer = 0.0 self.death_timer = 0.0
self.death_rect = None self.death_rect = None
self.level_deaths = 0 # fresh count for the level we just loaded self.level_deaths = 0 # fresh count for the level we just loaded
def _start_death(self): def _start_death(self):
# Begin the death pause: freeze everything, leave the corpse on screen. # Begin the death pause: freeze everything, leave the corpse on screen.
@@ -104,7 +104,7 @@ class Game:
try: try:
self._load_current() self._load_current()
self.level_deaths = kept self.level_deaths = kept
except Exception as ex: # bad edit -> don't crash except Exception as ex: # bad edit -> don't crash
print(f"[reload] {self.level_paths[self.index]}: {ex}") print(f"[reload] {self.level_paths[self.index]}: {ex}")
self.player.respawn() self.player.respawn()
self.level.reset() self.level.reset()
@@ -112,7 +112,7 @@ class Game:
else: else:
self.player.respawn() self.player.respawn()
self.level.reset() self.level.reset()
self.battery = self.level.battery # phone gets plugged back in at start self.battery = self.level.battery # phone gets plugged back in at start
self.state = "playing" self.state = "playing"
def _reload_level(self): def _reload_level(self):
@@ -129,7 +129,7 @@ class Game:
self.state = "charging" self.state = "charging"
self.charge_timer = 0.0 self.charge_timer = 0.0
tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0 tf = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0
self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start self.charge_from = tf * (self.level.battery_pct / 100.0) # near-empty start
self._final = self.index + 1 >= len(self.level_paths) self._final = self.index + 1 >= len(self.level_paths)
def _advance(self): def _advance(self):
@@ -157,8 +157,8 @@ class Game:
if self.fade_timer < S.FADE_TIME: if self.fade_timer < S.FADE_TIME:
return return
if self.fade_phase == "out": if self.fade_phase == "out":
self.fade_action() # swap levels while the screen is black self.fade_action() # swap levels while the screen is black
self.state = "fading" # _load_current() flips to "playing"; undo it self.state = "fading" # _load_current() flips to "playing"; undo it
self.fade_phase = "in" self.fade_phase = "in"
self.fade_timer = 0.0 self.fade_timer = 0.0
else: else:
@@ -195,7 +195,7 @@ class Game:
def run(self): def run(self):
while self.running: while self.running:
dt = self.clock.tick(S.FPS) / 1000.0 dt = self.clock.tick(S.FPS) / 1000.0
dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes dt = min(dt, 1 / 30) # clamp to avoid tunneling on lag spikes
inp = self._poll_events() inp = self._poll_events()
if self.state == "playing": if self.state == "playing":
@@ -224,8 +224,8 @@ class Game:
# death conditions # death conditions
pr = self.player.rect pr = self.player.rect
died = self.battery <= 0 died = self.battery <= 0
died = died or pr.top > self.level.height + 160 # fell out of the world died = died or pr.top > self.level.height + 160 # fell out of the world
died = died or self.player.crushed # pinched by a moving block died = died or self.player.crushed # pinched by a moving block
if not died: if not died:
for hz in self.level.hazard_rects(): for hz in self.level.hazard_rects():
if pr.colliderect(hz): if pr.colliderect(hz):
@@ -257,7 +257,9 @@ class Game:
if self.state == "dying" and self.death_rect is not None: if self.state == "dying" and self.death_rect is not None:
# The traps stay drawn in their moment-of-death state; the player is # The traps stay drawn in their moment-of-death state; the player is
# replaced by a corpse sprite where they fell. # replaced by a corpse sprite where they fell.
sprite = self.assets.get("player_dead", self.death_rect.w, self.death_rect.h) sprite = self.assets.get(
"player_dead", self.death_rect.w, self.death_rect.h
)
self.world.blit(sprite, self.death_rect.move(self.level.render_offset)) self.world.blit(sprite, self.death_rect.move(self.level.render_offset))
else: else:
self.player.draw(self.world, self.assets) self.player.draw(self.world, self.assets)
@@ -278,13 +280,17 @@ class Game:
if self.state == "won_level": if self.state == "won_level":
plural = "death" if self.level_deaths == 1 else "deaths" plural = "death" if self.level_deaths == 1 else "deaths"
self._draw_win_splash("LEVEL COMPLETE — phone charged!", self._draw_win_splash(
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level") "LEVEL COMPLETE — phone charged!",
f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level",
)
elif self.state == "won_all": elif self.state == "won_all":
self._draw_win_splash("YOU MADE IT! Phone fully charged.", self._draw_win_splash(
f"All levels cleared with {self.deaths} deaths. ENTER to replay") "YOU MADE IT! Phone fully charged.",
f"All levels cleared with {self.deaths} deaths. ENTER to replay",
)
if self.state == "fading": # fade-in only; fade-out returned early above if self.state == "fading": # fade-in only; fade-out returned early above
p = min(1.0, self.fade_timer / S.FADE_TIME) p = min(1.0, self.fade_timer / S.FADE_TIME)
overlay = pygame.Surface(self.screen.get_size()) overlay = pygame.Surface(self.screen.get_size())
overlay.fill((0, 0, 0)) overlay.fill((0, 0, 0))
@@ -300,16 +306,22 @@ class Game:
r = max(2, rect.h // 6) r = max(2, rect.h // 6)
inset = max(2, rect.h // 10) inset = max(2, rect.h // 10)
pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r) pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r)
pygame.draw.rect(surf, (28, 30, 40), pygame.draw.rect(
rect.inflate(-inset, -inset), border_radius=r) surf, (28, 30, 40), rect.inflate(-inset, -inset), border_radius=r
)
fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac))) fw = int((rect.w - inset * 2) * max(0.0, min(1.0, fill_frac)))
if fill_frac > 0: if fill_frac > 0:
fw = max(inset, fw) fw = max(inset, fw)
pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2), pygame.draw.rect(
border_radius=r) surf,
col,
(rect.x + inset, rect.y + inset, fw, rect.h - inset * 2),
border_radius=r,
)
nub_w, nub_h = max(3, rect.h // 4), rect.h // 2 nub_w, nub_h = max(3, rect.h // 4), rect.h // 2
pygame.draw.rect(surf, (60, 60, 72), pygame.draw.rect(
(rect.right, rect.centery - nub_h // 2, nub_w, nub_h)) surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)
)
def _draw_hud(self, hide_battery=False): def _draw_hud(self, hide_battery=False):
w = self.screen.get_width() w = self.screen.get_width()
@@ -322,17 +334,24 @@ class Game:
bar_w, bar_h = 180, 20 bar_w, bar_h = 180, 20
bx, by = 12, (HUD_H - bar_h) // 2 bx, by = 12, (HUD_H - bar_h) // 2
if not hide_battery: if not hide_battery:
time_frac = max(0.0, self.battery / self.level.battery) if self.level.battery else 0.0 time_frac = (
max(0.0, self.battery / self.level.battery)
if self.level.battery
else 0.0
)
visual = time_frac * (self.level.battery_pct / 100.0) visual = time_frac * (self.level.battery_pct / 100.0)
col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80) col = (240, 170, 60) if time_frac > 0.25 else (240, 80, 80)
self._draw_battery(self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col) self._draw_battery(
self.screen, pygame.Rect(bx, by, bar_w, bar_h), visual, col
)
secs = max(0.0, self.battery) secs = max(0.0, self.battery)
label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD) label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD)
self.screen.blit(label, (bx + bar_w + 16, by - 1)) self.screen.blit(label, (bx + bar_w + 16, by - 1))
deaths = self.hud_font.render( deaths = self.hud_font.render(
f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD) f"deaths {self.level_deaths} / {self.deaths} total", True, S.COLOR_HUD
)
self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1)) self.screen.blit(deaths, (w - deaths.get_width() - 12, by - 1))
name = self.hud_font.render(self.level.name, True, S.COLOR_HUD) name = self.hud_font.render(self.level.name, True, S.COLOR_HUD)
@@ -355,8 +374,8 @@ class Game:
it fills to 100%.""" it fills to 100%."""
W, H = self.screen.get_size() W, H = self.screen.get_size()
t = min(1.0, self.charge_timer / S.CHARGE_TIME) t = min(1.0, self.charge_timer / S.CHARGE_TIME)
move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40% move_p = 1 - (1 - min(1.0, t / 0.4)) ** 3 # ease-out; centred by 40%
fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80% fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80%
fill = self.charge_from + (1.0 - self.charge_from) * fill_p fill = self.charge_from + (1.0 - self.charge_from) * fill_p
dim = pygame.Surface((W, H), pygame.SRCALPHA) dim = pygame.Surface((W, H), pygame.SRCALPHA)
@@ -367,9 +386,12 @@ class Game:
home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2) home = pygame.Vector2(12 + bar_w / 2, (HUD_H - bar_h) // 2 + bar_h / 2)
target = self._hero_battery() target = self._hero_battery()
pos = home.lerp(pygame.Vector2(target.center), move_p) pos = home.lerp(pygame.Vector2(target.center), move_p)
rect = pygame.Rect(0, 0, rect = pygame.Rect(
round(bar_w + (target.w - bar_w) * move_p), 0,
round(bar_h + (target.h - bar_h) * move_p)) 0,
round(bar_w + (target.w - bar_w) * move_p),
round(bar_h + (target.h - bar_h) * move_p),
)
rect.center = (round(pos.x), round(pos.y)) rect.center = (round(pos.x), round(pos.y))
self._draw_battery(self.screen, rect, fill, (90, 220, 120)) self._draw_battery(self.screen, rect, fill, (90, 220, 120))
@@ -391,7 +413,10 @@ class Game:
s = self.hud_font.render(subtitle, True, S.COLOR_HUD) s = self.hud_font.render(subtitle, True, S.COLOR_HUD)
self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34))) self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34)))
def discover_levels(levels_dir): def discover_levels(levels_dir):
paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) + paths = sorted(
glob.glob(os.path.join(levels_dir, "*.yml"))) glob.glob(os.path.join(levels_dir, "*.yaml"))
+ glob.glob(os.path.join(levels_dir, "*.yml"))
)
return paths return paths

View File

@@ -55,8 +55,8 @@ class Level:
# by the Game). (0, 0) in normal play, so nothing moves. # by the Game). (0, 0) in normal play, so nothing moves.
self.render_offset = (0, 0) self.render_offset = (0, 0)
self.solids = [] # list[pygame.Rect] — full blocking self.solids = [] # list[pygame.Rect] — full blocking
self.oneways = [] # list[pygame.Rect] — blocking only from above self.oneways = [] # list[pygame.Rect] — blocking only from above
self.spawn = (self.tile, self.tile) self.spawn = (self.tile, self.tile)
self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile) self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile)
@@ -144,8 +144,10 @@ class Level:
for rect in self.oneways: for rect in self.oneways:
surface.blit(oneway_img, rect.move(ox, oy)) surface.blit(oneway_img, rect.move(ox, oy))
surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h), surface.blit(
self.goal_rect.move(ox, oy)) assets.get("goal", self.goal_rect.w, self.goal_rect.h),
self.goal_rect.move(ox, oy),
)
for tr in self.traps: for tr in self.traps:
tr.render(surface, assets) tr.render(surface, assets)
@@ -173,10 +175,12 @@ class Level:
# Shade the off-screen overscan so the real play area reads clearly. # Shade the off-screen overscan so the real play area reads clearly.
view = pygame.Rect(ox, oy, self.width, self.height) view = pygame.Rect(ox, oy, self.width, self.height)
shade = (10, 12, 20, 130) shade = (10, 12, 20, 130)
for band in (pygame.Rect(0, 0, sw, oy), # above for band in (
pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below pygame.Rect(0, 0, sw, oy), # above
pygame.Rect(0, oy, ox, self.height), # left pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below
pygame.Rect(view.right, oy, sw - view.right, self.height)): # right pygame.Rect(0, oy, ox, self.height), # left
pygame.Rect(view.right, oy, sw - view.right, self.height),
): # right
if band.w > 0 and band.h > 0: if band.w > 0 and band.h > 0:
overlay.fill(shade, band) overlay.fill(shade, band)
@@ -190,14 +194,16 @@ class Level:
pygame.draw.line(overlay, line, (0, k * t), (sw, k * t)) pygame.draw.line(overlay, line, (0, k * t), (sw, k * t))
label = (150, 162, 190) label = (150, 162, 190)
for k in range(sw // t): for k in range(sw // t):
overlay.blit(Level._grid_font.render(str(k - mx), True, label), overlay.blit(
(k * t + 2, 1)) Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
)
for k in range(sh // t): for k in range(sh // t):
overlay.blit(Level._grid_font.render(str(k - my), True, label), overlay.blit(
(1, k * t + 1)) Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
)
# Outline the actual runtime viewport (the level's true bounds). # Outline the actual runtime viewport (the level's true bounds).
if (mx or my): if mx or my:
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2) pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
surface.blit(overlay, (0, 0)) surface.blit(overlay, (0, 0))

View File

@@ -16,8 +16,8 @@ class InputState:
def __init__(self): def __init__(self):
self.left = self.right = self.down = False self.left = self.right = self.down = False
self.jump_pressed = False # edge: pressed this frame self.jump_pressed = False # edge: pressed this frame
self.jump_held = False # level: currently down self.jump_held = False # level: currently down
class Player: class Player:
@@ -78,8 +78,8 @@ class Player:
# jumping into the left side of a right-moving block) teleports us # jumping into the left side of a right-moving block) teleports us
# clear across it. Minimal displacement keeps the shove-along and # clear across it. Minimal displacement keeps the shove-along and
# shove-into-wall behaviours intact. # shove-into-wall behaviours intact.
pen_right = rect.right - p.left # displacement to exit rightward pen_right = rect.right - p.left # displacement to exit rightward
pen_left = p.right - rect.left # displacement to exit leftward pen_left = p.right - rect.left # displacement to exit leftward
if pen_right <= pen_left: if pen_right <= pen_left:
p.left = rect.right p.left = rect.right
else: else:
@@ -118,11 +118,15 @@ class Player:
pinned = any(p.colliderect(s) for s in solids) pinned = any(p.colliderect(s) for s in solids)
for r, dx, dy in movers: for r, dx, dy in movers:
if dy > 0 and bd and pinned and r.colliderect(up): # squished down onto floor if (
dy > 0 and bd and pinned and r.colliderect(up)
): # squished down onto floor
return True return True
if dy < 0 and bu and pinned and r.colliderect(down): # squished up into ceiling if (
dy < 0 and bu and pinned and r.colliderect(down)
): # squished up into ceiling
return True return True
if dx > 0 and br and r.colliderect(left): # pushed right into a wall if dx > 0 and br and r.colliderect(left): # pushed right into a wall
return True return True
if dx < 0 and bl and r.colliderect(right): # pushed left into a wall if dx < 0 and bl and r.colliderect(right): # pushed left into a wall
return True return True
@@ -139,9 +143,11 @@ class Player:
# If standing on a moving platform, inherit its motion this frame. # If standing on a moving platform, inherit its motion this frame.
self.carry = (0.0, 0.0) self.carry = (0.0, 0.0)
for rect, dx, dy in self.level.carriers(): for rect, dx, dy in self.level.carriers():
if (abs(self.rect.bottom - rect.top) <= 3 if (
and self.rect.right > rect.left + 1 abs(self.rect.bottom - rect.top) <= 3
and self.rect.left < rect.right - 1): and self.rect.right > rect.left + 1
and self.rect.left < rect.right - 1
):
self.fx += dx self.fx += dx
self.fy += dy self.fy += dy
self._sync_rect() self._sync_rect()
@@ -188,8 +194,11 @@ class Player:
self.drop_through_timer = 0.12 self.drop_through_timer = 0.12
# jump (buffered + coyote) # jump (buffered + coyote)
if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \ if (
and self.drop_through_timer <= 0: self.jump_buffer > 0
and (self.on_ground or self.coyote > 0)
and self.drop_through_timer <= 0
):
self.vy = -S.JUMP_SPEED self.vy = -S.JUMP_SPEED
self.on_ground = False self.on_ground = False
self.coyote = 0.0 self.coyote = 0.0
@@ -225,11 +234,11 @@ class Player:
# if the overlap is *more horizontal than vertical* — a block # if the overlap is *more horizontal than vertical* — a block
# sitting on top of us is a vertical collision; pushing us # sitting on top of us is a vertical collision; pushing us
# sideways out from under it would dodge a crush. # sideways out from under it would dodge a crush.
pen_left = self.rect.right - s.left # sank in from the left pen_left = self.rect.right - s.left # sank in from the left
pen_right = s.right - self.rect.left # sank in from the right pen_right = s.right - self.rect.left # sank in from the right
pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top) pen_y = min(self.rect.bottom - s.top, s.bottom - self.rect.top)
if min(pen_left, pen_right) > pen_y: if min(pen_left, pen_right) > pen_y:
continue # let the Y pass handle it continue # let the Y pass handle it
if pen_right <= pen_left: if pen_right <= pen_left:
self.rect.left = s.right self.rect.left = s.right
else: else:
@@ -241,8 +250,8 @@ class Player:
if self.rect.colliderect(s): if self.rect.colliderect(s):
# Resolve toward the nearer edge, not by velocity sign — so a # Resolve toward the nearer edge, not by velocity sign — so a
# block descending onto us can't pop us out its top. # block descending onto us can't pop us out its top.
overlap_top = self.rect.bottom - s.top # sank onto its top overlap_top = self.rect.bottom - s.top # sank onto its top
overlap_bottom = s.bottom - self.rect.top # rose into its underside overlap_bottom = s.bottom - self.rect.top # rose into its underside
if overlap_top <= overlap_bottom: if overlap_top <= overlap_bottom:
self.rect.bottom = s.top self.rect.bottom = s.top
self.on_ground = True self.on_ground = True
@@ -266,5 +275,6 @@ class Player:
# --- rendering ----------------------------------------------------------- # --- rendering -----------------------------------------------------------
def draw(self, surface, assets): def draw(self, surface, assets):
ox, oy = self.level.render_offset ox, oy = self.level.render_offset
surface.blit(assets.get("player", self.rect.w, self.rect.h), surface.blit(
self.rect.move(ox, oy)) assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy)
)

View File

@@ -1,33 +1,33 @@
"""Global tunables. Anything a level does not override falls back to these.""" """Global tunables. Anything a level does not override falls back to these."""
# --- Display ----------------------------------------------------------------- # --- Display -----------------------------------------------------------------
TILE = 32 # default tile size in pixels (levels may override) TILE = 32 # default tile size in pixels (levels may override)
FPS = 60 FPS = 60
CAPTION = "Dying Phone" CAPTION = "Dying Phone"
# Debug view only: how many extra tiles to reveal beyond the level on every side, # Debug view only: how many extra tiles to reveal beyond the level on every side,
# so off-screen geometry (e.g. an invisible catch-wall a step off the map) is # so off-screen geometry (e.g. an invisible catch-wall a step off the map) is
# visible while designing. The true play area is outlined within this margin. # visible while designing. The true play area is outlined within this margin.
DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug DEBUG_VIEW_MARGIN = 1 # tiles of overscan shown around the level in debug
# --- Physics (pixels / second, unless noted) --------------------------------- # --- Physics (pixels / second, unless noted) ---------------------------------
GRAVITY = 2200.0 # downward acceleration GRAVITY = 2200.0 # downward acceleration
MAX_FALL = 1400.0 # terminal velocity MAX_FALL = 1400.0 # terminal velocity
MOVE_SPEED = 320.0 # horizontal run speed MOVE_SPEED = 320.0 # horizontal run speed
ACCEL = 3200.0 # ground acceleration toward target speed ACCEL = 3200.0 # ground acceleration toward target speed
AIR_ACCEL = 2200.0 # weaker control in the air AIR_ACCEL = 2200.0 # weaker control in the air
FRICTION = 3600.0 # deceleration when no input on ground FRICTION = 3600.0 # deceleration when no input on ground
JUMP_SPEED = 760.0 # initial upward velocity of a jump JUMP_SPEED = 760.0 # initial upward velocity of a jump
JUMP_CUT = 0.45 # velocity retained when jump released early (variable height) JUMP_CUT = 0.45 # velocity retained when jump released early (variable height)
COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump COYOTE_TIME = 0.10 # seconds after leaving a ledge you can still jump
JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing
# --- Gameplay ---------------------------------------------------------------- # --- Gameplay ----------------------------------------------------------------
DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one DEFAULT_BATTERY = 45.0 # seconds of phone battery if a level doesn't set one
DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only; DEFAULT_BATTERY_PCT = 12.0 # how full the battery *looks* at the start (visual only;
# the phone is dying, so the bar reads near-empty) # the phone is dying, so the bar reads near-empty)
DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn DEATH_PAUSE = 0.5 # seconds the corpse lingers (traps frozen) before respawn
CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear CHARGE_TIME = 1.0 # seconds the battery-fill animation plays on level clear
FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition
# --- Colors (placeholder rendering) ------------------------------------------ # --- Colors (placeholder rendering) ------------------------------------------
COLOR_BG = (24, 26, 38) COLOR_BG = (24, 26, 38)
@@ -36,17 +36,17 @@ COLOR_HUD_WARN = (240, 90, 90)
# Fallback colors + labels for placeholder rectangles, keyed by sprite name. # Fallback colors + labels for placeholder rectangles, keyed by sprite name.
PLACEHOLDERS = { PLACEHOLDERS = {
"player": ((90, 200, 255), "P"), "player": ((90, 200, 255), "P"),
"player_dead": ((120, 40, 40), "X"), "player_dead": ((120, 40, 40), "X"),
"block": ((110, 120, 140), ""), "block": ((110, 120, 140), ""),
"goal": ((90, 230, 130), "GOAL"), "goal": ((90, 230, 130), "GOAL"),
"fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose "fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose
"spike": ((230, 80, 80), "^"), "spike": ((230, 80, 80), "^"),
"moving_block": ((150, 130, 90), ""), "moving_block": ((150, 130, 90), ""),
"patrol_block": ((130, 100, 170), ""), "patrol_block": ((130, 100, 170), ""),
"crumble_block": ((150, 120, 100), ""), "crumble_block": ((150, 120, 100), ""),
"arrow_shooter": ((80, 80, 95), ""), "arrow_shooter": ((80, 80, 95), ""),
"arrow": ((250, 220, 90), ">"), "arrow": ((250, 220, 90), ">"),
"spike_block": ((150, 156, 172), "*"), "spike_block": ((150, 156, 172), "*"),
"phase_block": ((90, 180, 210), ""), "phase_block": ((90, 180, 210), ""),
} }

View File

@@ -17,10 +17,12 @@ import pygame
from . import settings from . import settings
# --- helpers ----------------------------------------------------------------- # --- helpers -----------------------------------------------------------------
_DIRS = { _DIRS = {
"up": (0, -1), "down": (0, 1), "left": (-1, 0), "right": (1, 0), "up": (0, -1),
"down": (0, 1),
"left": (-1, 0),
"right": (1, 0),
} }
@@ -36,6 +38,7 @@ _DIRS = {
# trigger: { timer: { interval: 1.2, up_time: 0.7 } } # trigger: { timer: { interval: 1.2, up_time: 0.7 } }
# trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] } # trigger: { all: [ { within: 3 }, { dir: above, aligned: true } ] }
class _Always: class _Always:
def evaluate(self, trap, game, dt): def evaluate(self, trap, game, dt):
return True return True
@@ -46,6 +49,7 @@ class _Always:
class _Within: class _Within:
"""Player within N tiles of the trap centre (Euclidean radius).""" """Player within N tiles of the trap centre (Euclidean radius)."""
def __init__(self, n): def __init__(self, n):
self.n = float(n) self.n = float(n)
@@ -69,6 +73,7 @@ class _Directional:
left/right (same rows) or *directly* above/below (same columns). left/right (same rows) or *directly* above/below (same columns).
inclusive: specify if the trap tile itself counts in the given direction. inclusive: specify if the trap tile itself counts in the given direction.
""" """
def __init__(self, direction, rng, aligned, inclusive): def __init__(self, direction, rng, aligned, inclusive):
self.dir = direction self.dir = direction
self.rng = None if rng is None else float(rng) self.rng = None if rng is None else float(rng)
@@ -113,6 +118,7 @@ class _Directional:
class _Timer: class _Timer:
"""Cyclic: hidden for ``interval`` seconds, then active for ``up_time``.""" """Cyclic: hidden for ``interval`` seconds, then active for ``up_time``."""
def __init__(self, interval, up_time): def __init__(self, interval, up_time):
self.interval = float(interval) self.interval = float(interval)
self.up_time = float(up_time) self.up_time = float(up_time)
@@ -155,7 +161,9 @@ def make_condition(spec):
if spec == "always": if spec == "always":
return _Always() return _Always()
if not isinstance(spec, dict): if not isinstance(spec, dict):
raise ValueError(f"trigger must be 'always' or a condition object, got {spec!r}") raise ValueError(
f"trigger must be 'always' or a condition object, got {spec!r}"
)
if "all" in spec: if "all" in spec:
return _All([make_condition(s) for s in spec["all"]]) return _All([make_condition(s) for s in spec["all"]])
if "any" in spec: if "any" in spec:
@@ -166,7 +174,12 @@ def make_condition(spec):
if "within" in spec: if "within" in spec:
return _Within(spec["within"]) return _Within(spec["within"])
if "dir" in spec: if "dir" in spec:
return _Directional(spec["dir"], spec.get("range"), spec.get("aligned", False), spec.get("inclusive", False)) return _Directional(
spec["dir"],
spec.get("range"),
spec.get("aligned", False),
spec.get("inclusive", False),
)
raise ValueError(f"unrecognized trigger condition: {spec!r}") raise ValueError(f"unrecognized trigger condition: {spec!r}")
@@ -178,7 +191,7 @@ class Trap:
at = spec.get("at", [0, 0]) at = spec.get("at", [0, 0])
self.col, self.row = int(at[0]), int(at[1]) self.col, self.row = int(at[0]), int(at[1])
self.base_rect = level.cell_rect(self.col, self.row) self.base_rect = level.cell_rect(self.col, self.row)
self._mounted = False # set True on traps that ride another (a mount) self._mounted = False # set True on traps that ride another (a mount)
# Any trap can be made invisible (still functional; revealed in debug). # Any trap can be made invisible (still functional; revealed in debug).
self.invisible = bool(spec.get("invisible", False)) self.invisible = bool(spec.get("invisible", False))
@@ -274,13 +287,14 @@ class Trap:
def _follow(self, parent): def _follow(self, parent):
ox, oy = self._mount_off ox, oy = self._mount_off
pr = parent.current_rect() pr = parent.current_rect()
self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy, self.base_rect = pygame.Rect(
self.base_rect.w, self.base_rect.h) pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h
)
def tick(self, dt, game): def tick(self, dt, game):
self.update(dt, game) self.update(dt, game)
for c in self.mounts: for c in self.mounts:
c._follow(self) # reposition after we've moved this frame c._follow(self) # reposition after we've moved this frame
c.tick(dt, game) c.tick(dt, game)
def render(self, surface, assets): def render(self, surface, assets):
@@ -354,6 +368,7 @@ class Spike(Trap):
direction: which edge of the cell the spike sits on (up/down/left/right). direction: which edge of the cell the spike sits on (up/down/left/right).
See the trigger-condition docs at the top of this module. See the trigger-condition docs at the top of this module.
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.direction = spec.get("direction", "up") self.direction = spec.get("direction", "up")
@@ -369,13 +384,13 @@ class Spike(Trap):
t = self.tile t = self.tile
r = self.base_rect r = self.base_rect
dx, dy = _DIRS.get(self.direction, (0, -1)) dx, dy = _DIRS.get(self.direction, (0, -1))
if dy == -1: # up: bottom half is ground, tip points up if dy == -1: # up: bottom half is ground, tip points up
return pygame.Rect(r.x, r.y + t // 2, t, t // 2) return pygame.Rect(r.x, r.y + t // 2, t, t // 2)
if dy == 1: # down (ceiling spike) if dy == 1: # down (ceiling spike)
return pygame.Rect(r.x, r.y, t, t // 2) return pygame.Rect(r.x, r.y, t, t // 2)
if dx == -1: # left (from right wall pointing left) if dx == -1: # left (from right wall pointing left)
return pygame.Rect(r.x + t // 2, r.y, t // 2, t) return pygame.Rect(r.x + t // 2, r.y, t // 2, t)
return pygame.Rect(r.x, r.y, t // 2, t) # right return pygame.Rect(r.x, r.y, t // 2, t) # right
# CCW rotation to point the (up-facing) sprite the right way. # CCW rotation to point the (up-facing) sprite the right way.
_ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90} _ANGLE = {"up": 0, "left": 90, "down": 180, "right": -90}
@@ -415,6 +430,7 @@ class Block(Trap):
start extending and be clear for ``release`` seconds to start start extending and be clear for ``release`` seconds to start
retracting — hysteresis that stops boundary jitter. retracting — hysteresis that stops boundary jitter.
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
t = self.tile t = self.tile
@@ -429,15 +445,23 @@ class Block(Trap):
self.speed = float(spec.get("speed", 140.0)) self.speed = float(spec.get("speed", 140.0))
self.mode = spec.get("mode", "once") self.mode = spec.get("mode", "once")
self.deadly = bool(spec.get("deadly", False)) self.deadly = bool(spec.get("deadly", False))
self.fake = bool(spec.get("fake", False)) # looks solid, isn't self.fake = bool(spec.get("fake", False)) # looks solid, isn't
self.crumble = bool(spec.get("crumble", False)) # gives way when stood on self.crumble = bool(spec.get("crumble", False)) # gives way when stood on
self.crumble_delay = float(spec.get("crumble_delay", 0.4)) self.crumble_delay = float(spec.get("crumble_delay", 0.4))
self.respawn = float(spec.get("respawn", 2.5)) self.respawn = float(spec.get("respawn", 2.5))
self.sprite = spec.get("sprite", self.sprite = spec.get(
"spike_block" if self.deadly else "sprite",
"fake_block" if self.fake else (
"crumble_block" if self.crumble else "moving_block") "spike_block"
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis if self.deadly
else (
"fake_block"
if self.fake
else "crumble_block" if self.crumble else "moving_block"
)
),
)
self.release = float(spec.get("release", 0.1)) # once-mode retract hysteresis
# `home` (default): sense the trigger from the resting cell, so the block # `home` (default): sense the trigger from the resting cell, so the block
# moving away can't toggle its own trigger (no jitter). `current`: sense # moving away can't toggle its own trigger (no jitter). `current`: sense
# from the live position — for blocks the player rides (e.g. a dropper), # from the live position — for blocks the player rides (e.g. a dropper),
@@ -463,8 +487,9 @@ class Block(Trap):
# `home` sensing tracks the resting cell as it rides along the parent. # `home` sensing tracks the resting cell as it rides along the parent.
ox, oy = self._origin ox, oy = self._origin
offx, offy = self._mount_off offx, offy = self._mount_off
self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy), self.base_rect = pygame.Rect(
self.tile, self.tile) round(ox + offx), round(oy + offy), self.tile, self.tile
)
def reset(self): def reset(self):
self._reset_trigger() self._reset_trigger()
@@ -474,12 +499,12 @@ class Block(Trap):
# LOCAL — the live rect is always _origin + (x, y). # LOCAL — the live rect is always _origin + (x, y).
self._origin = (0.0, 0.0) self._origin = (0.0, 0.0)
self.prev = (self.x, self.y) self.prev = (self.x, self.y)
self.dir = 1 # pingpong direction self.dir = 1 # pingpong direction
self.phase = "rest" # once mode: rest|extending|extended|retracting self.phase = "rest" # once mode: rest|extending|extended|retracting
self._release_t = 0.0 self._release_t = 0.0
# index of the waypoint we're AT (once) / heading toward (patrol) # index of the waypoint we're AT (once) / heading toward (patrol)
self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0) self.idx = 0 if self.mode == "once" else (1 if len(self.points) > 1 else 0)
self.cstate = "solid" # crumble: solid|crumbling|gone self.cstate = "solid" # crumble: solid|crumbling|gone
self.ctimer = 0.0 self.ctimer = 0.0
self.shake = 0.0 self.shake = 0.0
self.emerge_kill = False self.emerge_kill = False
@@ -491,7 +516,7 @@ class Block(Trap):
# block executes its path/move relative to the parent it rides. # block executes its path/move relative to the parent it rides.
if not self._mounted: if not self._mounted:
self.prev = (self.x, self.y) self.prev = (self.x, self.y)
active = self.triggered(game, dt) # call every frame to keep the timer live active = self.triggered(game, dt) # call every frame to keep the timer live
if len(self.points) >= 2: if len(self.points) >= 2:
step = self.speed * dt step = self.speed * dt
if self.mode == "once": if self.mode == "once":
@@ -505,8 +530,9 @@ class Block(Trap):
self.emerge_kill = False self.emerge_kill = False
r = self._rect() r = self._rect()
p = game.player.rect p = game.player.rect
on_top = (abs(p.bottom - r.top) <= 4 on_top = (
and p.right > r.left + 2 and p.left < r.right - 2) abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2
)
if self.cstate == "solid": if self.cstate == "solid":
if on_top: if on_top:
self.cstate = "crumbling" self.cstate = "crumbling"
@@ -590,8 +616,7 @@ class Block(Trap):
def _rect(self): def _rect(self):
ox, oy = self._origin ox, oy = self._origin
return pygame.Rect(round(ox + self.x), round(oy + self.y), return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile)
self.tile, self.tile)
def current_rect(self): def current_rect(self):
return self._rect() return self._rect()
@@ -654,6 +679,7 @@ class ArrowShooter(Trap):
direction: up/down/left/right. speed: px/s. interval: seconds between shots. direction: up/down/left/right. speed: px/s. interval: seconds between shots.
trigger: only fires while the condition holds (default ``always``). trigger: only fires while the condition holds (default ``always``).
""" """
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.direction = spec.get("direction", "left") self.direction = spec.get("direction", "left")
@@ -676,10 +702,14 @@ class ArrowShooter(Trap):
rect = pygame.Rect(0, 0, w, h) rect = pygame.Rect(0, 0, w, h)
rect.center = r.center rect.center = r.center
# nudge the arrow to the emitting edge # nudge the arrow to the emitting edge
if dx == -1: rect.right = r.left if dx == -1:
elif dx == 1: rect.left = r.right rect.right = r.left
elif dy == -1: rect.bottom = r.top elif dx == 1:
elif dy == 1: rect.top = r.bottom rect.left = r.right
elif dy == -1:
rect.bottom = r.top
elif dy == 1:
rect.top = r.bottom
self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed)) self.arrows.append(Arrow(rect, dx * self.speed, dy * self.speed))
def update(self, dt, game): def update(self, dt, game):
@@ -704,8 +734,9 @@ class ArrowShooter(Trap):
return [a.rect for a in self.arrows] return [a.rect for a in self.arrows]
def draw(self, surface, assets): def draw(self, surface, assets):
surface.blit(assets.get("arrow_shooter", self.tile, self.tile), surface.blit(
self._rt(self.base_rect)) assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect)
)
for a in self.arrows: for a in self.arrows:
surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect)) surface.blit(assets.get("arrow", a.rect.w, a.rect.h), self._rt(a.rect))
@@ -717,7 +748,8 @@ class Warp(Trap):
destination and fades away, so the (otherwise invisible) teleport reads on destination and fades away, so the (otherwise invisible) teleport reads on
screen. The level's ``debug`` flag tints it (and draws a line to its screen. The level's ``debug`` flag tints it (and draws a line to its
destination) while designing.""" destination) while designing."""
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
_PULSE_TIME = 0.35 # seconds the activation aura takes to fade out
# Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn # Concentric rings of the aura: (radius x tile, alpha x, colour). Drawn
# largest-first so the bright core sits on top. # largest-first so the bright core sits on top.
@@ -735,8 +767,8 @@ class Warp(Trap):
self.reset() self.reset()
def reset(self): def reset(self):
self._armed = True # re-arms once the player has left the tile self._armed = True # re-arms once the player has left the tile
self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME self._pulse = 0.0 # 1.0 at activation, fades to 0 over _PULSE_TIME
def update(self, dt, game): def update(self, dt, game):
if self._pulse > 0.0: if self._pulse > 0.0:
@@ -749,7 +781,7 @@ class Warp(Trap):
p.vx = p.vy = 0.0 p.vx = p.vy = 0.0
p._sync_rect() p._sync_rect()
self._armed = False self._armed = False
self._pulse = 1.0 # flash at both ends this frame, then fade self._pulse = 1.0 # flash at both ends this frame, then fade
elif not inside: elif not inside:
self._armed = True self._armed = True
@@ -763,8 +795,9 @@ class Warp(Trap):
c.render(surface, assets) c.render(surface, assets)
def _dest_rect(self): def _dest_rect(self):
return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile, return pygame.Rect(
self.tile, self.tile) self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile
)
def _draw_aura(self, surface, rect): def _draw_aura(self, surface, rect):
# An expanding, fading glow centred on the cell. As the pulse decays the # An expanding, fading glow centred on the cell. As the pulse decays the
@@ -788,8 +821,13 @@ class Warp(Trap):
if self.level.debug: if self.level.debug:
self._debug_tint(surface, (210, 80, 235), alpha=90) self._debug_tint(surface, (210, 80, 235), alpha=90)
dest = self._dest_rect() dest = self._dest_rect()
pygame.draw.line(surface, (210, 80, 235), pygame.draw.line(
self._rt(self.base_rect).center, self._rt(dest).center, 1) surface,
(210, 80, 235),
self._rt(self.base_rect).center,
self._rt(dest).center,
1,
)
self._debug_tint(surface, (210, 80, 235), dest, 45) self._debug_tint(surface, (210, 80, 235), dest, 45)
@@ -799,6 +837,7 @@ class PhaseBlock(Trap):
a solid obstacle over ``fade`` seconds (and fades back out when the trigger a solid obstacle over ``fade`` seconds (and fades back out when the trigger
releases). If the player is standing in the cell the instant it *starts* releases). If the player is standing in the cell the instant it *starts*
appearing, they're killed.""" appearing, they're killed."""
def __init__(self, spec, level): def __init__(self, spec, level):
super().__init__(spec, level) super().__init__(spec, level)
self.fade = float(spec.get("fade", 0.3)) self.fade = float(spec.get("fade", 0.3))
@@ -814,14 +853,17 @@ class PhaseBlock(Trap):
# How deep the player may be into the cell and still be nudged clear rather # How deep the player may be into the cell and still be nudged clear rather
# than killed. A shallow clip (feet/shoulder in the cell) gets shoved out; # than killed. A shallow clip (feet/shoulder in the cell) gets shoved out;
# forming through their middle stays lethal. # forming through their middle stays lethal.
_EDGE_GRACE = 0.5 # fraction of a tile _EDGE_GRACE = 0.5 # fraction of a tile
def update(self, dt, game): def update(self, dt, game):
self.emerge_kill = False self.emerge_kill = False
active = self.triggered(game, dt) active = self.triggered(game, dt)
if active: if active:
if self.alpha == 0.0 and not self.solid \ if (
and game.player.rect.colliderect(self.base_rect): self.alpha == 0.0
and not self.solid
and game.player.rect.colliderect(self.base_rect)
):
# Forming into the player. If they're only clipping an edge, shove # Forming into the player. If they're only clipping an edge, shove
# them clear and let the block solidify behind them; only if it's # them clear and let the block solidify behind them; only if it's
# forming through their middle — or the shove would squish them # forming through their middle — or the shove would squish them
@@ -847,15 +889,15 @@ class PhaseBlock(Trap):
b = self.base_rect b = self.base_rect
# Distance to move the player to clear the block on each side. # Distance to move the player to clear the block on each side.
outs = { outs = {
"up": p.bottom - b.top, "up": p.bottom - b.top,
"down": b.bottom - p.top, "down": b.bottom - p.top,
"left": p.right - b.left, "left": p.right - b.left,
"right": b.right - p.left, "right": b.right - p.left,
} }
side = min(outs, key=outs.get) side = min(outs, key=outs.get)
dist = outs[side] dist = outs[side]
if dist > self.tile * self._EDGE_GRACE: if dist > self.tile * self._EDGE_GRACE:
return False # deep overlap — forming through them return False # deep overlap — forming through them
dx, dy = _DIRS[side] dx, dy = _DIRS[side]
moved = p.move(dx * dist, dy * dist) moved = p.move(dx * dist, dy * dist)
# The block isn't solid yet, so it's absent from solid_rects(); any hit # The block isn't solid yet, so it's absent from solid_rects(); any hit
@@ -891,8 +933,9 @@ class PhaseBlock(Trap):
self._debug_tint(surface, (120, 210, 240), alpha=45) self._debug_tint(surface, (120, 210, 240), alpha=45)
return return
img = assets.get("phase_block", self.tile, self.tile).copy() img = assets.get("phase_block", self.tile, self.tile).copy()
img.fill((255, 255, 255, int(255 * self.alpha)), img.fill(
special_flags=pygame.BLEND_RGBA_MULT) (255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT
)
surface.blit(img, self._rt(self.base_rect)) surface.blit(img, self._rt(self.base_rect))

View File

@@ -1,2 +1,3 @@
-r requirements.txt -r requirements.txt
pytest>=7.0 pytest>=7.0
black

View File

@@ -22,7 +22,7 @@ DT = 1 / 60.0
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def _pygame(): def _pygame():
pygame.init() pygame.init()
pygame.display.set_mode((64, 64)) # a display so convert_alpha() works pygame.display.set_mode((64, 64)) # a display so convert_alpha() works
yield yield
pygame.quit() pygame.quit()

View File

@@ -20,12 +20,12 @@ def test_stationary_is_solid():
def test_deadly_is_hazard_not_solid(): def test_deadly_is_hazard_not_solid():
b = block(at=[3, 3], deadly=True) b = block(at=[3, 3], deadly=True)
assert b.solid_rects() == [] assert b.solid_rects() == []
assert b.hazard_rects() # non-empty assert b.hazard_rects() # non-empty
def test_fake_is_drawn_but_not_solid(): def test_fake_is_drawn_but_not_solid():
b = block(at=[3, 3], fake=True) b = block(at=[3, 3], fake=True)
assert b.solid_rects() == [] # you fall through assert b.solid_rects() == [] # you fall through
assert b.sprite == "fake_block" # looks like a real block assert b.sprite == "fake_block" # looks like a real block
@@ -53,8 +53,8 @@ traps:
if g.deaths > d0: if g.deaths > d0:
killed = True killed = True
break break
assert "gone" in states # it crumbled away assert "gone" in states # it crumbled away
assert killed # re-formed onto the standing player assert killed # re-formed onto the standing player
def test_moving_block_that_crumbles(make_game): def test_moving_block_that_crumbles(make_game):
@@ -78,7 +78,7 @@ traps:
respawn: 1.0 respawn: 1.0
""") """)
b = g.level.traps[0] b = g.level.traps[0]
place(g, 2, 1) # ride the platform place(g, 2, 1) # ride the platform
moved = crumbled = False moved = crumbled = False
start = b.x start = b.x
for _ in range(200): for _ in range(200):
@@ -103,18 +103,23 @@ def test_patrol_loop_visits_all_waypoints():
def test_slider_once_no_jitter_and_retracts(): def test_slider_once_no_jitter_and_retracts():
# sense=home (default): a slider moving away can't toggle its own trigger. # sense=home (default): a slider moving away can't toggle its own trigger.
b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3}) b = block(at=[16, 6], move=[-3, 0], speed=260, trigger={"within": 3})
g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right g = FakeGame(pygame.Rect(17 * 32, 6 * 32, 23, 29)) # player parked to the right
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)] xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced
g.player.rect.x = 30 * 32 # leave g.player.rect.x = 30 * 32 # leave
xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)] xs = [(b.update(1 / 60, g), b.x)[1] for _ in range(120)]
assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base assert reversals(xs) == 0 and abs(xs[-1] - 16 * 32) < 2 # retracted to base
def test_sense_current_rides_down(): def test_sense_current_rides_down():
# A dropper (sense=current) commits to the bottom and holds while ridden. # A dropper (sense=current) commits to the bottom and holds while ridden.
b = block(at=[21, 9], move=[0, 4], speed=220, sense="current", b = block(
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]}) at=[21, 9],
move=[0, 4],
speed=220,
sense="current",
trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]},
)
# player standing on top of it # player standing on top of it
p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29) p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29)
g = FakeGame(p) g = FakeGame(p)
@@ -124,15 +129,15 @@ def test_sense_current_rides_down():
# keep the player riding the block top # keep the player riding the block top
p.bottom = b._rect().top p.bottom = b._rect().top
ys.append(b.y) ys.append(b.y)
assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob assert reversals(ys) == 0 and abs(ys[-1] - 13 * 32) < 2 # dropped fully, no bob
def test_carriers_reports_motion(): def test_carriers_reports_motion():
b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120) b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120)
g = FakeGame(pygame.Rect(0, 0, 4, 4)) g = FakeGame(pygame.Rect(0, 0, 4, 4))
b.update(1 / 60, g) b.update(1 / 60, g)
(rect, dx, dy), = b.carriers() ((rect, dx, dy),) = b.carriers()
assert dx != 0 and dy == 0 # moving horizontally assert dx != 0 and dy == 0 # moving horizontally
# --- array expansion (count / spacing) -------------------------------------- # --- array expansion (count / spacing) --------------------------------------
@@ -147,8 +152,12 @@ def test_expand_line():
def test_expand_grid_with_spacing(): def test_expand_grid_with_spacing():
ats = [s["at"] for s in expand_spec( ats = [
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})] s["at"]
for s in expand_spec(
{"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]}
)
]
assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]] assert ats == [[0, 0], [2, 0], [4, 0], [0, 3], [2, 3], [4, 3]]
# count/spacing are stripped from each expanded spec # count/spacing are stripped from each expanded spec
for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}): for s in expand_spec({"type": "block", "at": [0, 0], "count": [2, 1]}):
@@ -173,4 +182,9 @@ traps:
blocks = [t for t in lvl.traps if isinstance(t, Block)] blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 4 assert len(blocks) == 4
assert all(b.deadly for b in blocks) assert all(b.deadly for b in blocks)
assert sorted(b.current_rect().x for b in blocks) == [2 * 32, 3 * 32, 4 * 32, 5 * 32] assert sorted(b.current_rect().x for b in blocks) == [
2 * 32,
3 * 32,
4 * 32,
5 * 32,
]

View File

@@ -100,7 +100,7 @@ traps:
mode: pingpong mode: pingpong
speed: 160 speed: 160
""") """)
place(g, 4, 3) # on the floor, at the block's body level place(g, 4, 3) # on the floor, at the block's body level
floor_top = 4 * 32 floor_top = 4 * 32
start_x = g.player.rect.x start_x = g.player.rect.x
max_bottom = g.player.rect.bottom max_bottom = g.player.rect.bottom
@@ -109,8 +109,8 @@ traps:
max_bottom = max(max_bottom, g.player.rect.bottom) max_bottom = max(max_bottom, g.player.rect.bottom)
if g.state != "playing": if g.state != "playing":
break break
assert max_bottom <= floor_top + 1 # never pushed into the floor assert max_bottom <= floor_top + 1 # never pushed into the floor
assert g.player.rect.x < start_x # got shoved along assert g.player.rect.x < start_x # got shoved along
def test_ride_into_wall_stops_no_crush(make_game): def test_ride_into_wall_stops_no_crush(make_game):
@@ -137,7 +137,7 @@ traps:
""") """)
# stand the player on top of the platform at its left end (body in row 2) # stand the player on top of the platform at its left end (body in row 2)
place(g, 1, 2) place(g, 1, 2)
g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top g.player.fy = float(3 * 32 - g.player.h) # feet on the block's top
g.player._sync_rect() g.player._sync_rect()
wall_left = 8 * 32 wall_left = 8 * 32
hit_wall = False hit_wall = False
@@ -145,12 +145,12 @@ traps:
step(g) step(g)
if g.state != "playing": if g.state != "playing":
break break
assert not g.player.crushed # never crushed assert not g.player.crushed # never crushed
assert g.player.rect.right <= wall_left + 1 # stopped at the wall assert g.player.rect.right <= wall_left + 1 # stopped at the wall
if g.player.rect.right >= wall_left - 1: if g.player.rect.right >= wall_left - 1:
hit_wall = True hit_wall = True
assert g.state == "playing" # survived the whole time assert g.state == "playing" # survived the whole time
assert hit_wall # actually reached the wall assert hit_wall # actually reached the wall
def test_corner_clip_does_not_warp(make_game): def test_corner_clip_does_not_warp(make_game):
@@ -177,7 +177,7 @@ traps:
prev = g.player.rect.x prev = g.player.rect.x
for _ in range(120): for _ in range(120):
step(g, hold(left=True)) step(g, hold(left=True))
assert g.player.rect.x - prev <= 28 # no sudden rightward warp assert g.player.rect.x - prev <= 28 # no sudden rightward warp
prev = g.player.rect.x prev = g.player.rect.x
@@ -213,4 +213,4 @@ traps:
if not g.player.on_ground and g.player.rect.bottom > 3 * 32: if not g.player.on_ground and g.player.rect.bottom > 3 * 32:
left_at = reversalsN left_at = reversalsN
break break
assert left_at == 0 # fell before any block reversal assert left_at == 0 # fell before any block reversal

View File

@@ -5,7 +5,6 @@ from conftest import step, run, place, hold, DT
from game import settings as S from game import settings as S
from game.player import InputState from game.player import InputState
SIMPLE = """ SIMPLE = """
name: My Level name: My Level
tile_size: 32 tile_size: 32
@@ -23,7 +22,7 @@ def test_level_parse(make_level):
assert lvl.width == 5 * 32 and lvl.height == 3 * 32 assert lvl.width == 5 * 32 and lvl.height == 3 * 32
assert lvl.spawn == (1 * 32, 1 * 32) assert lvl.spawn == (1 * 32, 1 * 32)
assert lvl.goal_rect.topleft == (3 * 32, 1 * 32) assert lvl.goal_rect.topleft == (3 * 32, 1 * 32)
assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls assert len(lvl.solids) == 5 + 5 + 2 # top row + bottom row + side walls
def test_unknown_trap_type_is_skipped(make_level): def test_unknown_trap_type_is_skipped(make_level):
@@ -38,7 +37,7 @@ traps:
- type: not_a_real_trap - type: not_a_real_trap
at: [1, 1] at: [1, 1]
""") """)
assert lvl.traps == [] # skipped, no crash assert lvl.traps == [] # skipped, no crash
def test_death_pause_then_respawn(make_game): def test_death_pause_then_respawn(make_game):
@@ -62,6 +61,7 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
a.write_text(SIMPLE) a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two")) b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
def die(): def die():
@@ -69,24 +69,26 @@ def test_death_counters_per_level_and_total(make_game, tmp_path):
while g.state == "dying": while g.state == "dying":
step(g) step(g)
die(); die(); die() die()
die()
die()
assert g.level_deaths == 3 and g.deaths == 3 assert g.level_deaths == 3 and g.deaths == 3
g._advance() g._advance()
assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists assert g.level_deaths == 0 and g.deaths == 3 # per-level resets, total persists
die() die()
assert g.level_deaths == 1 and g.deaths == 4 assert g.level_deaths == 1 and g.deaths == 4
g._replay() g._replay()
assert g.index == 0 and g.deaths == 0 # replay resets the total assert g.index == 0 and g.deaths == 0 # replay resets the total
def test_reach_goal_charges_then_wins(make_game): def test_reach_goal_charges_then_wins(make_game):
g = make_game(SIMPLE) g = make_game(SIMPLE)
g._reach_goal() g._reach_goal()
assert g.state == "charging" assert g.state == "charging"
assert 0 <= g.charge_from < 0.2 # near-empty start assert 0 <= g.charge_from < 0.2 # near-empty start
for _ in range(70): for _ in range(70):
step(g) step(g)
assert g.state == "won_all" # single level -> won_all assert g.state == "won_all" # single level -> won_all
def test_fade_swaps_level_at_black(make_game, tmp_path): def test_fade_swaps_level_at_black(make_game, tmp_path):
@@ -95,6 +97,7 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
a.write_text(SIMPLE) a.write_text(SIMPLE)
b.write_text(SIMPLE.replace("My Level", "Two")) b.write_text(SIMPLE.replace("My Level", "Two"))
from game.game import Game from game.game import Game
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g.state = "won_level" g.state = "won_level"
g._start_fade(g._advance) g._start_fade(g._advance)
@@ -110,32 +113,45 @@ def test_fade_swaps_level_at_black(make_game, tmp_path):
done = i done = i
break break
assert swapped and done and g.index == 1 assert swapped and done and g.index == 1
assert done - swapped >= 15 # fade-in ~0.3s not skipped assert done - swapped >= 15 # fade-in ~0.3s not skipped
def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch): def test_window_sized_once_for_tallest_level(make_game, tmp_path, monkeypatch):
a = tmp_path / "a.yaml" a = tmp_path / "a.yaml"
b = tmp_path / "b.yaml" b = tmp_path / "b.yaml"
a.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") # 3 rows a.write_text(
b.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n") # 4 rows "name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
) # 3 rows
b.write_text(
"name: B\ntile_size: 32\nmap: |\n #####\n #...#\n #P.G#\n #####\n"
) # 4 rows
from game.game import Game from game.game import Game
calls = [] calls = []
orig = pygame.display.set_mode orig = pygame.display.set_mode
monkeypatch.setattr(pygame.display, "set_mode", monkeypatch.setattr(
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k)) pygame.display,
"set_mode",
lambda size, *a, **k: calls.append(size) or orig(size, *a, **k),
)
g = Game([str(a), str(b)], str(tmp_path / "noassets")) g = Game([str(a), str(b)], str(tmp_path / "noassets"))
g._advance(); g._replay() g._advance()
assert len(calls) == 1 # never recreated the window g._replay()
assert len(calls) == 1 # never recreated the window
# sized to the tallest level (4 rows + HUD) # sized to the tallest level (4 rows + HUD)
from game.game import HUD_H from game.game import HUD_H
assert g.win_h == 4 * 32 + HUD_H assert g.win_h == 4 * 32 + HUD_H
def test_debug_view_adds_overscan_margin(tmp_path): def test_debug_view_adds_overscan_margin(tmp_path):
from game.game import Game, HUD_H from game.game import Game, HUD_H
from game import settings as S from game import settings as S
p = tmp_path / "d.yaml" p = tmp_path / "d.yaml"
p.write_text("name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n") p.write_text(
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #P.G#\n #####\n"
)
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
m = S.DEBUG_VIEW_MARGIN * 32 m = S.DEBUG_VIEW_MARGIN * 32
assert m > 0 assert m > 0
@@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path):
def test_no_overscan_without_debug(tmp_path): def test_no_overscan_without_debug(tmp_path):
from game.game import Game from game.game import Game
p = tmp_path / "n.yaml" p = tmp_path / "n.yaml"
p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
@@ -169,39 +186,46 @@ def test_cli_debug_forces_all_levels(make_game):
def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path): def test_f5_hot_reload_picks_up_edits_and_counts_death(tmp_path):
from game.game import Game from game.game import Game
p = tmp_path / "hot.yaml" p = tmp_path / "hot.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
assert g.level.name == "A" and len(g.level.traps) == 0 assert g.level.name == "A" and len(g.level.traps) == 0
d0 = g.deaths d0 = g.deaths
# edit the file on disk, then F5 # edit the file on disk, then F5
p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n" p.write_text(
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n") "name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n"
"traps:\n - type: block\n at: [2, 1]\n deadly: true\n"
)
g._reload_level() g._reload_level()
assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter assert g.state == "dying" and g.deaths == d0 + 1 # death beat + counter
while g.state == "dying": while g.state == "dying":
step(g) step(g)
assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit assert g.level.name == "B" and len(g.level.traps) == 1 # picked up the edit
assert g.level_deaths == 1 # counted, not reset to 0 assert g.level_deaths == 1 # counted, not reset to 0
def test_f5_bad_yaml_does_not_crash(tmp_path, capsys): def test_f5_bad_yaml_does_not_crash(tmp_path, capsys):
from game.game import Game from game.game import Game
p = tmp_path / "bad.yaml" p = tmp_path / "bad.yaml"
p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n")
g = Game([str(p)], str(tmp_path / "noassets")) g = Game([str(p)], str(tmp_path / "noassets"))
p.write_text("name: A\n bad: [unclosed\n") # invalid YAML p.write_text("name: A\n bad: [unclosed\n") # invalid YAML
g._reload_level() g._reload_level()
while g.state == "dying": while g.state == "dying":
step(g) step(g)
assert g.state == "playing" # fell back, no crash assert g.state == "playing" # fell back, no crash
assert g.level.name == "A" # kept the old level assert g.level.name == "A" # kept the old level
def test_debug_grid_adds_pixels(make_level): def test_debug_grid_adds_pixels(make_level):
import pygame import pygame
from game.assets import AssetStore from game.assets import AssetStore
lvl = make_level("name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n")
lvl = make_level(
"name: t\ntile_size: 32\nmap: |\n #####\n #...#\n #...#\n #####\n"
)
a = AssetStore("noassets") a = AssetStore("noassets")
def painted(dbg): def painted(dbg):
@@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level):
w = pygame.Surface((lvl.width, lvl.height)) w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0)) w.fill((0, 0, 0))
lvl.draw(w, a) lvl.draw(w, a)
return sum(1 for y in range(lvl.height) for x in range(lvl.width) return sum(
if w.get_at((x, y))[:3] != (0, 0, 0)) 1
assert painted(True) > painted(False) # grid + labels add ink for y in range(lvl.height)
for x in range(lvl.width)
if w.get_at((x, y))[:3] != (0, 0, 0)
)
assert painted(True) > painted(False) # grid + labels add ink

View File

@@ -3,7 +3,6 @@
from conftest import step, DT from conftest import step, DT
from game.traps import Block, Spike from game.traps import Block, Spike
PLATFORM_WITH_SPIKE = """ PLATFORM_WITH_SPIKE = """
name: t name: t
tile_size: 32 tile_size: 32
@@ -74,8 +73,10 @@ traps:
for m in plat.mounts: for m in plat.mounts:
assert m._mounted and m.deadly assert m._mounted and m.deadly
# each deadly mount tracks the platform at its offset and is lethal # each deadly mount tracks the platform at its offset and is lethal
exp = (plat.current_rect().x + m._mount_off[0], exp = (
plat.current_rect().y + m._mount_off[1]) plat.current_rect().x + m._mount_off[0],
plat.current_rect().y + m._mount_off[1],
)
assert (m.current_rect().x, m.current_rect().y) == exp assert (m.current_rect().x, m.current_rect().y) == exp
assert m.hazard_rects() assert m.hazard_rects()

View File

@@ -18,10 +18,10 @@ map: |
def test_falls_and_lands_on_floor(make_game): def test_falls_and_lands_on_floor(make_game):
g = make_game(FLAT) g = make_game(FLAT)
place(g, 4, 1) # up in the air place(g, 4, 1) # up in the air
run(g, 60) run(g, 60)
assert g.player.on_ground assert g.player.on_ground
assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160) assert g.player.rect.bottom == 5 * 32 # floor is row 5's top (y=160)
def test_terminal_velocity(make_game): def test_terminal_velocity(make_game):
@@ -34,13 +34,13 @@ def test_terminal_velocity(make_game):
def test_jump_gains_height_then_returns(make_game): def test_jump_gains_height_then_returns(make_game):
g = make_game(FLAT) g = make_game(FLAT)
run(g, 30) # settle on floor run(g, 30) # settle on floor
ground = g.player.rect.bottom ground = g.player.rect.bottom
peak = ground peak = ground
for i in range(60): for i in range(60):
step(g, hold(jump_pressed=(i == 0), jump_held=True)) step(g, hold(jump_pressed=(i == 0), jump_held=True))
peak = min(peak, g.player.rect.bottom) peak = min(peak, g.player.rect.bottom)
assert peak < ground - 32 # rose at least a tile assert peak < ground - 32 # rose at least a tile
def test_variable_jump_height(make_game): def test_variable_jump_height(make_game):
@@ -55,6 +55,7 @@ def test_variable_jump_height(make_game):
step(g, hold(jump_pressed=(i == 0), jump_held=held)) step(g, hold(jump_pressed=(i == 0), jump_held=held))
hi = min(hi, g.player.rect.bottom) hi = min(hi, g.player.rect.bottom)
return ground - hi return ground - hi
assert peak(60) > peak(1) + 8 assert peak(60) > peak(1) + 8
@@ -71,22 +72,22 @@ map: |
#P#...# #P#...#
####### #######
""") """)
place(g, 1, 3) # standing on the little step at [1,3] top place(g, 1, 3) # standing on the little step at [1,3] top
# walk right off the step for a couple frames, then jump # walk right off the step for a couple frames, then jump
run(g, 4, lambda i: hold(right=True)) run(g, 4, lambda i: hold(right=True))
y_before = g.player.rect.bottom y_before = g.player.rect.bottom
step(g, hold(right=True, jump_pressed=True, jump_held=True)) step(g, hold(right=True, jump_pressed=True, jump_held=True))
step(g, hold(right=True, jump_held=True)) step(g, hold(right=True, jump_held=True))
assert g.player.vy < 0 # a jump actually started assert g.player.vy < 0 # a jump actually started
def test_walls_stop_horizontal_movement(make_game): def test_walls_stop_horizontal_movement(make_game):
g = make_game(FLAT) g = make_game(FLAT)
place(g, 1, 4) place(g, 1, 4)
run(g, 120, lambda i: hold(right=True)) run(g, 120, lambda i: hold(right=True))
assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288) assert g.player.rect.right <= 9 * 32 # right wall inner edge (x=288)
run(g, 120, lambda i: hold(left=True)) run(g, 120, lambda i: hold(left=True))
assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32) assert g.player.rect.left >= 1 * 32 # left wall inner edge (x=32)
def test_oneway_platform_land_from_above_pass_from_below(make_game): def test_oneway_platform_land_from_above_pass_from_below(make_game):
@@ -103,7 +104,7 @@ map: |
####### #######
""") """)
# Fall onto the one-way from above -> lands on it. # Fall onto the one-way from above -> lands on it.
place(g, 1, 1) # open air above the one-way (row 3) place(g, 1, 1) # open air above the one-way (row 3)
run(g, 60) run(g, 60)
assert g.player.on_ground and g.player.rect.bottom == 3 * 32 assert g.player.on_ground and g.player.rect.bottom == 3 * 32
@@ -119,12 +120,12 @@ map: |
#P...G# #P...G#
####### #######
""") """)
place(g2, 1, 4) # on the floor, below the one-way (row 2) place(g2, 1, 4) # on the floor, below the one-way (row 2)
run(g2, 5) # settle so on_ground is set before jumping run(g2, 5) # settle so on_ground is set before jumping
passed = False passed = False
for i in range(40): for i in range(40):
step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12))) step(g2, hold(jump_pressed=(i == 0), jump_held=(i < 12)))
if g2.player.rect.top < 2 * 32: # rose above the one-way row if g2.player.rect.top < 2 * 32: # rose above the one-way row
passed = True passed = True
assert passed assert passed
@@ -142,10 +143,10 @@ map: |
#....G# #....G#
####### #######
""") """)
place(g, 1, 2) # standing on the one-way at row 3 place(g, 1, 2) # standing on the one-way at row 3
run(g, 20) run(g, 20)
assert g.player.on_ground assert g.player.on_ground
# press down + jump to drop through # press down + jump to drop through
for i in range(30): for i in range(30):
step(g, hold(down=True, jump_pressed=(i == 0))) step(g, hold(down=True, jump_pressed=(i == 0)))
assert g.player.rect.top > 3 * 32 # fell below the one-way assert g.player.rect.top > 3 * 32 # fell below the one-way

View File

@@ -2,7 +2,7 @@
import hashlib import hashlib
import pygame import pygame
from conftest import (FakeGame, step, run, place, hold, DT) from conftest import FakeGame, step, run, place, hold, DT
from game.assets import AssetStore from game.assets import AssetStore
from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block from game.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block
@@ -51,7 +51,7 @@ traps:
def test_spike_sprite_rotates_per_direction(tmp_path): def test_spike_sprite_rotates_per_direction(tmp_path):
# A deliberately asymmetric sprite so each rotation is distinct. # A deliberately asymmetric sprite so each rotation is distinct.
surf = pygame.Surface((8, 8), pygame.SRCALPHA) surf = pygame.Surface((8, 8), pygame.SRCALPHA)
surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only
adir = tmp_path / "assets" adir = tmp_path / "assets"
adir.mkdir() adir.mkdir()
pygame.image.save(surf, str(adir / "spike.png")) pygame.image.save(surf, str(adir / "spike.png"))
@@ -62,7 +62,8 @@ def test_spike_sprite_rotates_per_direction(tmp_path):
def h(s): def h(s):
return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest() return hashlib.md5(pygame.image.tostring(s, "RGBA")).hexdigest()
assert h(up) != h(down) # rotation actually happened
assert h(up) != h(down) # rotation actually happened
assert left.get_size() == (16, 32) and up.get_size() == (32, 16) assert left.get_size() == (16, 32) and up.get_size() == (32, 16)
@@ -85,10 +86,10 @@ traps:
""") """)
sh = lvl.traps[0] sh = lvl.traps[0]
g = FakeGame(pygame.Rect(0, 0, 4, 4)) g = FakeGame(pygame.Rect(0, 0, 4, 4))
for _ in range(40): # ~0.66s -> at least one shot for _ in range(40): # ~0.66s -> at least one shot
sh.update(DT, g) sh.update(DT, g)
assert sh.arrows # spawned arrows assert sh.arrows # spawned arrows
assert sh.hazard_rects() # arrows are hazards assert sh.hazard_rects() # arrows are hazards
def test_arrow_shooter_trigger_gates_firing(make_level): def test_arrow_shooter_trigger_gates_firing(make_level):
@@ -108,10 +109,10 @@ traps:
trigger: { within: 1 } trigger: { within: 1 }
""") """)
sh = lvl.traps[0] sh = lvl.traps[0]
far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near far = FakeGame(pygame.Rect(0, 0, 4, 4)) # nowhere near
for _ in range(60): for _ in range(60):
sh.update(DT, far) sh.update(DT, far)
assert not sh.arrows # never fired while player out of range assert not sh.arrows # never fired while player out of range
# --- invisible wall, now an array of invisible blocks ------------------------ # --- invisible wall, now an array of invisible blocks ------------------------
@@ -132,10 +133,10 @@ traps:
count: [1, 2] count: [1, 2]
""") """)
blocks = [t for t in lvl.traps if isinstance(t, Block)] blocks = [t for t in lvl.traps if isinstance(t, Block)]
assert len(blocks) == 2 # one per cell assert len(blocks) == 2 # one per cell
assert all(b.invisible and b.solid_rects() for b in blocks) assert all(b.invisible and b.solid_rects() for b in blocks)
ys = sorted(b.current_rect().top for b in blocks) ys = sorted(b.current_rect().top for b in blocks)
assert ys == [1 * 32, 2 * 32] # stacked vertically assert ys == [1 * 32, 2 * 32] # stacked vertically
# --- warp -------------------------------------------------------------------- # --- warp --------------------------------------------------------------------
@@ -178,9 +179,9 @@ traps:
to: [7, 1] to: [7, 1]
""") """)
warp = g.level.traps[0] warp = g.level.traps[0]
assert warp._pulse == 0.0 # dormant: no aura assert warp._pulse == 0.0 # dormant: no aura
place(g, 4, 2) # stand on the warp tile place(g, 4, 2) # stand on the warp tile
step(g) # -> teleports, aura flashes on step(g) # -> teleports, aura flashes on
assert warp._pulse > 0.9 assert warp._pulse > 0.9
# The aura paints at BOTH the source and the destination — even though the # The aura paints at BOTH the source and the destination — even though the
@@ -218,9 +219,9 @@ traps:
pb = g.level.traps[0] pb = g.level.traps[0]
place(g, 1, 3) place(g, 1, 3)
step(g) step(g)
assert not pb.solid and pb.alpha == 0 # dormant far away assert not pb.solid and pb.alpha == 0 # dormant far away
run(g, 40, lambda i: hold(right=True)) run(g, 40, lambda i: hold(right=True))
assert pb.solid and pb.alpha > 0 # phased in solid on approach assert pb.solid and pb.alpha > 0 # phased in solid on approach
def test_phase_block_kills_if_inside_when_it_forms(make_game): def test_phase_block_kills_if_inside_when_it_forms(make_game):
@@ -239,7 +240,7 @@ traps:
trigger: { within: 3 } trigger: { within: 3 }
""") """)
pb = g.level.traps[0] pb = g.level.traps[0]
place(g, 3, 2) # standing right where it will form place(g, 3, 2) # standing right where it will form
killed, d0 = False, g.deaths killed, d0 = False, g.deaths
for _ in range(10): for _ in range(10):
step(g) step(g)
@@ -268,7 +269,7 @@ traps:
pb = g.level.traps[0] pb = g.level.traps[0]
b = pb.base_rect b = pb.base_rect
# Straddle the block's left edge: mostly outside, just clipping into it. # 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.fx = float(b.left - g.player.w + 5) # 5px of overlap
g.player.fy = float(b.top + 2) g.player.fy = float(b.top + 2)
g.player.vx = g.player.vy = 0.0 g.player.vx = g.player.vy = 0.0
g.player._sync_rect() g.player._sync_rect()
@@ -331,21 +332,23 @@ traps:
trigger: { within: 3 } trigger: { within: 3 }
""") """)
pb = g.level.traps[0] pb = g.level.traps[0]
place(g, 5, 2) # near enough to trigger, not on the cell place(g, 5, 2) # near enough to trigger, not on the cell
for _ in range(6): # let it partially fade in for _ in range(6): # let it partially fade in
step(g) step(g)
assert 0 < pb.alpha < 1 assert 0 < pb.alpha < 1
g._start_death() # die from something g._start_death() # die from something
assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze
def test_debug_overscan_reveals_offmap_trap(make_level): def test_debug_overscan_reveals_offmap_trap(make_level):
# In debug the play surface is enlarged and shifted (render_offset) so a trap # 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. # placed just off the map is drawn into the overscan instead of being clipped.
from game import settings as S from game import settings as S
lvl = make_level( lvl = make_level(
"name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n" "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 "traps:\n - type: block\n at: [5, 1]\n deadly: true\n"
) # col 5 = off-map
a = AssetStore("noassets") a = AssetStore("noassets")
m = S.DEBUG_VIEW_MARGIN * lvl.tile m = S.DEBUG_VIEW_MARGIN * lvl.tile
lvl.render_offset = (m, m) lvl.render_offset = (m, m)
@@ -354,7 +357,7 @@ def test_debug_overscan_reveals_offmap_trap(make_level):
lvl.draw(surf, a) lvl.draw(surf, a)
cx = 5 * lvl.tile + m + lvl.tile // 2 cx = 5 * lvl.tile + m + lvl.tile // 2
cy = 1 * 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 assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan
# --- generic invisible flag -------------------------------------------------- # --- generic invisible flag --------------------------------------------------
@@ -384,9 +387,12 @@ traps:
w = pygame.Surface((lvl.width, lvl.height)) w = pygame.Surface((lvl.width, lvl.height))
w.fill((0, 0, 0)) w.fill((0, 0, 0))
sp.render(w, a) sp.render(w, a)
return any(w.get_at((x, y))[:3] != (0, 0, 0) return any(
for y in range(32, 96) for x in range(32, 96)) 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(False) is False # invisible in play
assert painted(True) is True # revealed in debug assert painted(True) is True # revealed in debug
assert sp.hazard_rects() # still deadly either way assert sp.hazard_rects() # still deadly either way

View File

@@ -6,9 +6,12 @@ from game.traps import make_condition
def ev(spec, px, py, w=20, h=28, dt=0.0): def ev(spec, px, py, w=20, h=28, dt=0.0):
trap = type("T", (), {"tile": 32, trap = type(
"sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})() "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)}
)()
return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt) return make_condition(spec).evaluate(trap, FakeGame(pygame.Rect(px, py, w, h)), dt)
# trap centre = (116, 116); left100 right132 top100 bottom132 # trap centre = (116, 116); left100 right132 top100 bottom132
@@ -17,26 +20,26 @@ def test_always():
def test_within_radius(): def test_within_radius():
assert ev({"within": 2}, 106, 104) is True # ~10px away assert ev({"within": 2}, 106, 104) is True # ~10px away
assert ev({"within": 2}, 400, 116) is False # far assert ev({"within": 2}, 400, 116) is False # far
def test_dir_left_right(): def test_dir_left_right():
assert ev({"dir": "left"}, 40, 105) is True # to the left assert ev({"dir": "left"}, 40, 105) is True # to the left
assert ev({"dir": "left"}, 200, 105) is False # to the right assert ev({"dir": "left"}, 200, 105) is False # to the right
assert ev({"dir": "right"}, 200, 105) is True assert ev({"dir": "right"}, 200, 105) is True
def test_dir_range(): def test_dir_range():
assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left assert ev({"dir": "left", "range": 2}, 60, 105) is True # within 2 tiles left
assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left
def test_dir_aligned(): def test_dir_aligned():
# above + aligned requires horizontal overlap with the trap column # above + aligned requires horizontal overlap with the trap column
assert ev({"dir": "above", "aligned": True}, 108, 60) is True assert ev({"dir": "above", "aligned": True}, 108, 60) is True
assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above assert ev({"dir": "above", "aligned": True}, 108, 110) is False # not above
assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned assert ev({"dir": "above", "aligned": True}, 400, 60) is False # not aligned
# without aligned, any column counts # without aligned, any column counts
assert ev({"dir": "above"}, 400, 60) is True assert ev({"dir": "above"}, 400, 60) is True
@@ -44,8 +47,8 @@ def test_dir_aligned():
def test_dir_inclusive(): def test_dir_inclusive():
# trap column spans x 100..132 (centre 116). A player standing *inside* the # trap column spans x 100..132 (centre 116). A player standing *inside* the
# column (centre 116) is neither strictly left nor strictly right of it. # column (centre 116) is neither strictly left nor strictly right of it.
assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left assert ev({"dir": "left"}, 106, 105) is False # centre 116, inside -> not left
assert ev({"dir": "right"}, 106, 105) is False # inside -> not right assert ev({"dir": "right"}, 106, 105) is False # inside -> not right
# inclusive counts the trap's own tile as being on that side # inclusive counts the trap's own tile as being on that side
assert ev({"dir": "left", "inclusive": True}, 106, 105) is True assert ev({"dir": "left", "inclusive": True}, 106, 105) is True
assert ev({"dir": "right", "inclusive": True}, 106, 105) is True assert ev({"dir": "right", "inclusive": True}, 106, 105) is True
@@ -59,24 +62,26 @@ def test_dir_inclusive():
def test_all_and_any(): def test_all_and_any():
c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]} c = {"all": [{"within": 3}, {"dir": "above", "aligned": True}]}
assert ev(c, 108, 80) is True assert ev(c, 108, 80) is True
assert ev(c, 108, 110) is False # close but not above assert ev(c, 108, 110) is False # close but not above
c2 = {"any": [{"dir": "left"}, {"dir": "right"}]} c2 = {"any": [{"dir": "left"}, {"dir": "right"}]}
assert ev(c2, 40, 105) is True assert ev(c2, 40, 105) is True
assert ev(c2, 110, 40) is False # above-only satisfies neither assert ev(c2, 110, 40) is False # above-only satisfies neither
def test_timer_cycles(): def test_timer_cycles():
c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}}) c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}})
trap = type("T", (), {"tile": 32, trap = type(
"sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})() "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)}
)()
g = FakeGame(pygame.Rect(0, 0, 4, 4)) g = FakeGame(pygame.Rect(0, 0, 4, 4))
states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)] states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)]
assert states[0] is False # starts in the "off" interval assert states[0] is False # starts in the "off" interval
assert any(states) and not all(states) # cycles on and off assert any(states) and not all(states) # cycles on and off
def test_bad_condition_raises(): def test_bad_condition_raises():
import pytest import pytest
with pytest.raises(ValueError): with pytest.raises(ValueError):
make_condition({"nope": 1}) make_condition({"nope": 1})
@@ -97,10 +102,10 @@ traps:
delay: 0.25 delay: 0.25
""") """)
sp = lvl.traps[0] sp = lvl.traps[0]
g = FakeGame(sp.base_rect.copy()) # player right on it -> in range g = FakeGame(sp.base_rect.copy()) # player right on it -> in range
armed = None armed = None
for i in range(30): for i in range(30):
if sp.triggered(g, 1 / 60): if sp.triggered(g, 1 / 60):
armed = i armed = i
break break
assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames assert armed is not None and 13 <= armed <= 17 # ~0.25s = 15 frames

View File

@@ -21,11 +21,11 @@ def _s():
# --- individual sprites ------------------------------------------------------ # --- individual sprites ------------------------------------------------------
def draw_block(s): def draw_block(s):
s.fill((96, 104, 122)) # stone s.fill((96, 104, 122)) # stone
seam = (70, 76, 92) seam = (70, 76, 92)
pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border pygame.draw.rect(s, seam, (0, 0, TILE, TILE), 2) # border
pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam pygame.draw.line(s, seam, (0, 16), (TILE, 16), 2) # course seam
pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks pygame.draw.line(s, seam, (16, 2), (16, 16), 2) # offset bricks
pygame.draw.line(s, seam, (8, 16), (8, TILE - 2), 2) pygame.draw.line(s, seam, (8, 16), (8, TILE - 2), 2)
pygame.draw.line(s, seam, (24, 16), (24, TILE - 2), 2) pygame.draw.line(s, seam, (24, 16), (24, TILE - 2), 2)
pygame.draw.line(s, (132, 140, 158), (2, 2), (TILE - 3, 2), 1) # top highlight pygame.draw.line(s, (132, 140, 158), (2, 2), (TILE - 3, 2), 1) # top highlight
@@ -38,33 +38,34 @@ def draw_fake_block(s):
def draw_player(s): def draw_player(s):
body = pygame.Rect(6, 2, 20, 28) body = pygame.Rect(6, 2, 20, 28)
pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body pygame.draw.rect(s, (40, 44, 60), body, border_radius=5) # phone body
pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline pygame.draw.rect(s, (18, 20, 30), body, 2, border_radius=5) # outline
pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen pygame.draw.rect(s, (95, 205, 255), (9, 6, 14, 16)) # screen
pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes
pygame.draw.rect(s, (20, 30, 45), (18, 10, 3, 4)) pygame.draw.rect(s, (20, 30, 45), (18, 10, 3, 4))
pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile pygame.draw.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile
pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button
def draw_player_dead(s): def draw_player_dead(s):
body = pygame.Rect(6, 2, 20, 28) body = pygame.Rect(6, 2, 20, 28)
pygame.draw.rect(s, (62, 42, 46), body, border_radius=5) pygame.draw.rect(s, (62, 42, 46), body, border_radius=5)
pygame.draw.rect(s, (30, 20, 22), body, 2, border_radius=5) pygame.draw.rect(s, (30, 20, 22), body, 2, border_radius=5)
pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen
for ex in (11, 17): # X eyes for ex in (11, 17): # X eyes
pygame.draw.line(s, (225, 85, 85), (ex, 9), (ex + 4, 14), 2) pygame.draw.line(s, (225, 85, 85), (ex, 9), (ex + 4, 14), 2)
pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2) pygame.draw.line(s, (225, 85, 85), (ex + 4, 9), (ex, 14), 2)
pygame.draw.lines(s, (200, 205, 215), False, pygame.draw.lines(
[(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1
) # crack
def draw_goal(s): def draw_goal(s):
pad = pygame.Rect(4, 4, 24, 24) pad = pygame.Rect(4, 4, 24, 24)
pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad pygame.draw.rect(s, (36, 110, 66), pad, border_radius=5) # charger pad
pygame.draw.rect(s, (90, 230, 140), pad, 2, border_radius=5) pygame.draw.rect(s, (90, 230, 140), pad, 2, border_radius=5)
bolt = [(19, 5), (10, 18), (15, 18), (12, 27), (23, 13), (17, 13)] bolt = [(19, 5), (10, 18), (15, 18), (12, 27), (23, 13), (17, 13)]
pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt pygame.draw.polygon(s, (245, 240, 130), bolt) # lightning bolt
pygame.draw.polygon(s, (200, 190, 80), bolt, 1) pygame.draw.polygon(s, (200, 190, 80), bolt, 1)
@@ -77,43 +78,46 @@ def draw_spike(s):
pts = [(x, TILE), (x + w / 2, 3), (x + w, TILE)] pts = [(x, TILE), (x + w / 2, 3), (x + w, TILE)]
pygame.draw.polygon(s, base, pts) pygame.draw.polygon(s, base, pts)
pygame.draw.polygon(s, edge, pts, 1) pygame.draw.polygon(s, edge, pts, 1)
pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip pygame.draw.rect(s, edge, (0, TILE - 4, TILE, 4)) # base strip
def draw_moving_block(s): def draw_moving_block(s):
s.fill((172, 122, 72)) # crate s.fill((172, 122, 72)) # crate
pygame.draw.rect(s, (120, 80, 45), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (120, 80, 45), (0, 0, TILE, TILE), 2)
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]: for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts pygame.draw.circle(s, (92, 60, 35), (bx, by), 2) # bolts
plate = pygame.Rect(10, 10, 12, 12) plate = pygame.Rect(10, 10, 12, 12)
pygame.draw.rect(s, (152, 106, 60), plate) pygame.draw.rect(s, (152, 106, 60), plate)
pygame.draw.rect(s, (120, 80, 45), plate, 1) pygame.draw.rect(s, (120, 80, 45), plate, 1)
def draw_patrol_block(s): def draw_patrol_block(s):
s.fill((122, 102, 178)) # platform s.fill((122, 102, 178)) # platform
pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (80, 65, 125), (0, 0, TILE, TILE), 2)
pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight
for off in (0, 9): # motion chevrons for off in (0, 9): # motion chevrons
pygame.draw.lines(s, (92, 76, 142), False, pygame.draw.lines(
[(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2) s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2
)
def draw_crumble_block(s): def draw_crumble_block(s):
s.fill((166, 136, 96)) s.fill((166, 136, 96))
pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2)
cr = (112, 86, 56) cr = (112, 86, 56)
pygame.draw.lines(s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1) pygame.draw.lines(
s, cr, False, [(8, 2), (12, 10), (9, 16), (14, 24), (12, TILE)], 1
)
pygame.draw.lines(s, cr, False, [(22, 3), (19, 9), (24, 15), (20, 22)], 1) pygame.draw.lines(s, cr, False, [(22, 3), (19, 9), (24, 15), (20, 22)], 1)
pygame.draw.line(s, cr, (2, 14), (9, 16), 1) pygame.draw.line(s, cr, (2, 14), (9, 16), 1)
pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1) pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1)
def draw_arrow_shooter(s): def draw_arrow_shooter(s):
s.fill((72, 76, 92)) # turret block s.fill((72, 76, 92)) # turret block
pygame.draw.rect(s, (45, 48, 60), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (45, 48, 60), (0, 0, TILE, TILE), 2)
for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]: for bx, by in [(5, 5), (TILE - 6, 5), (5, TILE - 6), (TILE - 6, TILE - 6)]:
pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets pygame.draw.circle(s, (40, 42, 52), (bx, by), 2) # rivets
pygame.draw.circle(s, (22, 24, 30), (TILE // 2, TILE // 2), 6) # barrel pygame.draw.circle(s, (22, 24, 30), (TILE // 2, TILE // 2), 6) # barrel
pygame.draw.circle(s, (150, 64, 64), (TILE // 2, TILE // 2), 3) pygame.draw.circle(s, (150, 64, 64), (TILE // 2, TILE // 2), 3)
@@ -122,14 +126,18 @@ def draw_spike_block(s):
base, edge = (152, 158, 174), (84, 88, 104) base, edge = (152, 158, 174), (84, 88, 104)
m = TILE // 2 m = TILE // 2
tris = [ tris = [
[(m, 0), (m - 4, 11), (m + 4, 11)], # up [(m, 0), (m - 4, 11), (m + 4, 11)], # up
[(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down [(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down
[(0, m), (11, m - 4), (11, m + 4)], # left [(0, m), (11, m - 4), (11, m + 4)], # left
[(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right [(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right
[(2, 2), (13, 6), (6, 13)], # up-left [(2, 2), (13, 6), (6, 13)], # up-left
[(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right [(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right
[(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left [(2, TILE - 2), (13, TILE - 6), (6, TILE - 13)], # down-left
[(TILE - 2, TILE - 2), (TILE - 13, TILE - 6), (TILE - 6, TILE - 13)], # down-right [
(TILE - 2, TILE - 2),
(TILE - 13, TILE - 6),
(TILE - 6, TILE - 13),
], # down-right
] ]
for t in tris: for t in tris:
pygame.draw.polygon(s, base, t) pygame.draw.polygon(s, base, t)
@@ -144,8 +152,7 @@ def draw_phase_block(s):
pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2) pygame.draw.rect(s, (120, 210, 240), (0, 0, TILE, TILE), 2)
pygame.draw.line(s, (150, 230, 255), (4, 4), (TILE - 5, TILE - 5), 1) pygame.draw.line(s, (150, 230, 255), (4, 4), (TILE - 5, TILE - 5), 1)
pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1) pygame.draw.line(s, (150, 230, 255), (TILE - 5, 4), (4, TILE - 5), 1)
pygame.draw.polygon(s, (150, 230, 255), pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1)
[(16, 4), (28, 16), (16, 28), (4, 16)], 1)
def draw_arrow(s): def draw_arrow(s):