diff --git a/game/assets.py b/game/assets.py index e1aebfe..37e5801 100644 --- a/game/assets.py +++ b/game/assets.py @@ -15,8 +15,8 @@ from . import settings class AssetStore: def __init__(self, assets_dir): self.assets_dir = assets_dir - self._raw = {} # name -> original loaded Surface (or None if missing) - self._cache = {} # (name, w, h) -> scaled Surface + self._raw = {} # name -> original loaded Surface (or None if missing) + self._cache = {} # (name, w, h) -> scaled Surface self._font = None def _font_for(self, h): diff --git a/game/game.py b/game/game.py index afd6f42..1f25dfc 100644 --- a/game/game.py +++ b/game/game.py @@ -9,8 +9,8 @@ from .assets import AssetStore from .level import Level from .player import Player, InputState -HUD_H = 46 # height of the status bar above the play area -MIN_W = 480 # keep the window wide enough for the HUD text +HUD_H = 46 # height of the status bar above the play area +MIN_W = 480 # keep the window wide enough for the HUD text class Game: @@ -18,7 +18,7 @@ class Game: pygame.init() pygame.display.set_caption(S.CAPTION) 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.clock = pygame.time.Clock() self.hud_font = pygame.font.SysFont("consolas,menlo,monospace", 22, bold=True) @@ -26,12 +26,12 @@ class Game: self.index = 0 self.running = True - self.state = "playing" # playing | dying | won_level | won_all - self.death_timer = 0.0 # counts down during the "dying" pause - self.death_rect = None # where to draw the corpse - self.deaths = 0 # total deaths this session - 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.state = "playing" # playing | dying | won_level | won_all + self.death_timer = 0.0 # counts down during the "dying" pause + self.death_rect = None # where to draw the corpse + self.deaths = 0 # total deaths this session + self.level_deaths = 0 # deaths on the current level (resets per level) + 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 — # calling set_mode again mid-session recreates the window (it flickers / @@ -62,7 +62,7 @@ class Game: def _load_current(self): self.level = Level(self.level_paths[self.index]) 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.battery = self.level.battery # In debug, grow the play surface by a margin and shift all drawing into @@ -78,7 +78,7 @@ class Game: self.state = "playing" self.death_timer = 0.0 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): # Begin the death pause: freeze everything, leave the corpse on screen. @@ -104,7 +104,7 @@ class Game: try: self._load_current() 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}") self.player.respawn() self.level.reset() @@ -112,7 +112,7 @@ class Game: else: self.player.respawn() 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" def _reload_level(self): @@ -129,7 +129,7 @@ class Game: self.state = "charging" self.charge_timer = 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) def _advance(self): @@ -157,8 +157,8 @@ class Game: if self.fade_timer < S.FADE_TIME: return if self.fade_phase == "out": - self.fade_action() # swap levels while the screen is black - self.state = "fading" # _load_current() flips to "playing"; undo it + self.fade_action() # swap levels while the screen is black + self.state = "fading" # _load_current() flips to "playing"; undo it self.fade_phase = "in" self.fade_timer = 0.0 else: @@ -195,7 +195,7 @@ class Game: def run(self): while self.running: 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() if self.state == "playing": @@ -224,8 +224,8 @@ class Game: # death conditions pr = self.player.rect died = self.battery <= 0 - 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 pr.top > self.level.height + 160 # fell out of the world + died = died or self.player.crushed # pinched by a moving block if not died: for hz in self.level.hazard_rects(): if pr.colliderect(hz): @@ -257,7 +257,9 @@ class Game: if self.state == "dying" and self.death_rect is not None: # The traps stay drawn in their moment-of-death state; the player is # 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)) else: self.player.draw(self.world, self.assets) @@ -278,13 +280,17 @@ class Game: if self.state == "won_level": plural = "death" if self.level_deaths == 1 else "deaths" - self._draw_win_splash("LEVEL COMPLETE — phone charged!", - f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level") + self._draw_win_splash( + "LEVEL COMPLETE — phone charged!", + f"{self.level_deaths} {plural} here · {self.deaths} total · ENTER for the next level", + ) elif self.state == "won_all": - self._draw_win_splash("YOU MADE IT! Phone fully charged.", - f"All levels cleared with {self.deaths} deaths. ENTER to replay") + self._draw_win_splash( + "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) overlay = pygame.Surface(self.screen.get_size()) overlay.fill((0, 0, 0)) @@ -300,16 +306,22 @@ class Game: r = max(2, rect.h // 6) inset = max(2, rect.h // 10) pygame.draw.rect(surf, (60, 60, 72), rect, border_radius=r) - pygame.draw.rect(surf, (28, 30, 40), - rect.inflate(-inset, -inset), border_radius=r) + pygame.draw.rect( + 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))) if fill_frac > 0: fw = max(inset, fw) - pygame.draw.rect(surf, col, (rect.x + inset, rect.y + inset, fw, rect.h - inset * 2), - border_radius=r) + pygame.draw.rect( + 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 - pygame.draw.rect(surf, (60, 60, 72), - (rect.right, rect.centery - nub_h // 2, nub_w, nub_h)) + pygame.draw.rect( + surf, (60, 60, 72), (rect.right, rect.centery - nub_h // 2, nub_w, nub_h) + ) def _draw_hud(self, hide_battery=False): w = self.screen.get_width() @@ -322,17 +334,24 @@ class Game: bar_w, bar_h = 180, 20 bx, by = 12, (HUD_H - bar_h) // 2 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) 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) label = self.hud_font.render(f"{secs:4.1f}s", True, S.COLOR_HUD) self.screen.blit(label, (bx + bar_w + 16, by - 1)) 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)) name = self.hud_font.render(self.level.name, True, S.COLOR_HUD) @@ -355,8 +374,8 @@ class Game: it fills to 100%.""" W, H = self.screen.get_size() 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% - fill_p = max(0.0, min(1.0, (t - 0.2) / 0.6)) # fill from 20%..80% + 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 = self.charge_from + (1.0 - self.charge_from) * fill_p 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) target = self._hero_battery() pos = home.lerp(pygame.Vector2(target.center), move_p) - rect = pygame.Rect(0, 0, - round(bar_w + (target.w - bar_w) * move_p), - round(bar_h + (target.h - bar_h) * move_p)) + rect = pygame.Rect( + 0, + 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)) 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) self.screen.blit(s, s.get_rect(center=(W // 2, rect.bottom + 34))) + def discover_levels(levels_dir): - paths = sorted(glob.glob(os.path.join(levels_dir, "*.yaml")) + - glob.glob(os.path.join(levels_dir, "*.yml"))) + paths = sorted( + glob.glob(os.path.join(levels_dir, "*.yaml")) + + glob.glob(os.path.join(levels_dir, "*.yml")) + ) return paths diff --git a/game/level.py b/game/level.py index af76730..9b92d9e 100644 --- a/game/level.py +++ b/game/level.py @@ -55,8 +55,8 @@ class Level: # by the Game). (0, 0) in normal play, so nothing moves. self.render_offset = (0, 0) - self.solids = [] # list[pygame.Rect] — full blocking - self.oneways = [] # list[pygame.Rect] — blocking only from above + self.solids = [] # list[pygame.Rect] — full blocking + self.oneways = [] # list[pygame.Rect] — blocking only from above self.spawn = (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: surface.blit(oneway_img, rect.move(ox, oy)) - surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h), - self.goal_rect.move(ox, oy)) + surface.blit( + assets.get("goal", self.goal_rect.w, self.goal_rect.h), + self.goal_rect.move(ox, oy), + ) for tr in self.traps: tr.render(surface, assets) @@ -173,10 +175,12 @@ class Level: # Shade the off-screen overscan so the real play area reads clearly. view = pygame.Rect(ox, oy, self.width, self.height) shade = (10, 12, 20, 130) - for band in (pygame.Rect(0, 0, sw, oy), # above - pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below - pygame.Rect(0, oy, ox, self.height), # left - pygame.Rect(view.right, oy, sw - view.right, self.height)): # right + for band in ( + pygame.Rect(0, 0, sw, oy), # above + pygame.Rect(0, view.bottom, sw, sh - view.bottom), # below + 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: overlay.fill(shade, band) @@ -190,14 +194,16 @@ class Level: pygame.draw.line(overlay, line, (0, k * t), (sw, k * t)) label = (150, 162, 190) for k in range(sw // t): - overlay.blit(Level._grid_font.render(str(k - mx), True, label), - (k * t + 2, 1)) + overlay.blit( + Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1) + ) for k in range(sh // t): - overlay.blit(Level._grid_font.render(str(k - my), True, label), - (1, k * t + 1)) + overlay.blit( + Level._grid_font.render(str(k - my), True, label), (1, k * t + 1) + ) # 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) surface.blit(overlay, (0, 0)) diff --git a/game/player.py b/game/player.py index 114a0fc..4aef5f1 100644 --- a/game/player.py +++ b/game/player.py @@ -16,8 +16,8 @@ class InputState: def __init__(self): self.left = self.right = self.down = False - self.jump_pressed = False # edge: pressed this frame - self.jump_held = False # level: currently down + self.jump_pressed = False # edge: pressed this frame + self.jump_held = False # level: currently down class Player: @@ -78,8 +78,8 @@ class Player: # jumping into the left side of a right-moving block) teleports us # clear across it. Minimal displacement keeps the shove-along and # shove-into-wall behaviours intact. - pen_right = rect.right - p.left # displacement to exit rightward - pen_left = p.right - rect.left # displacement to exit leftward + pen_right = rect.right - p.left # displacement to exit rightward + pen_left = p.right - rect.left # displacement to exit leftward if pen_right <= pen_left: p.left = rect.right else: @@ -118,11 +118,15 @@ class Player: pinned = any(p.colliderect(s) for s in solids) 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 - 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 - 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 if dx < 0 and bl and r.colliderect(right): # pushed left into a wall return True @@ -139,9 +143,11 @@ class Player: # If standing on a moving platform, inherit its motion this frame. self.carry = (0.0, 0.0) for rect, dx, dy in self.level.carriers(): - if (abs(self.rect.bottom - rect.top) <= 3 - and self.rect.right > rect.left + 1 - and self.rect.left < rect.right - 1): + if ( + abs(self.rect.bottom - rect.top) <= 3 + and self.rect.right > rect.left + 1 + and self.rect.left < rect.right - 1 + ): self.fx += dx self.fy += dy self._sync_rect() @@ -188,8 +194,11 @@ class Player: self.drop_through_timer = 0.12 # jump (buffered + coyote) - if self.jump_buffer > 0 and (self.on_ground or self.coyote > 0) \ - and self.drop_through_timer <= 0: + if ( + self.jump_buffer > 0 + and (self.on_ground or self.coyote > 0) + and self.drop_through_timer <= 0 + ): self.vy = -S.JUMP_SPEED self.on_ground = False self.coyote = 0.0 @@ -225,11 +234,11 @@ class Player: # if the overlap is *more horizontal than vertical* — a block # sitting on top of us is a vertical collision; pushing us # sideways out from under it would dodge a crush. - 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_left = self.rect.right - s.left # sank in from the left + 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) 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: self.rect.left = s.right else: @@ -241,8 +250,8 @@ class Player: if self.rect.colliderect(s): # Resolve toward the nearer edge, not by velocity sign — so a # block descending onto us can't pop us out 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_top = self.rect.bottom - s.top # sank onto its top + overlap_bottom = s.bottom - self.rect.top # rose into its underside if overlap_top <= overlap_bottom: self.rect.bottom = s.top self.on_ground = True @@ -266,5 +275,6 @@ class Player: # --- rendering ----------------------------------------------------------- def draw(self, surface, assets): ox, oy = self.level.render_offset - surface.blit(assets.get("player", self.rect.w, self.rect.h), - self.rect.move(ox, oy)) + surface.blit( + assets.get("player", self.rect.w, self.rect.h), self.rect.move(ox, oy) + ) diff --git a/game/settings.py b/game/settings.py index 1504565..7617d31 100644 --- a/game/settings.py +++ b/game/settings.py @@ -1,33 +1,33 @@ """Global tunables. Anything a level does not override falls back to these.""" # --- Display ----------------------------------------------------------------- -TILE = 32 # default tile size in pixels (levels may override) +TILE = 32 # default tile size in pixels (levels may override) FPS = 60 CAPTION = "Dying Phone" # 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 # 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) --------------------------------- -GRAVITY = 2200.0 # downward acceleration -MAX_FALL = 1400.0 # terminal velocity -MOVE_SPEED = 320.0 # horizontal run speed -ACCEL = 3200.0 # ground acceleration toward target speed -AIR_ACCEL = 2200.0 # weaker control in the air -FRICTION = 3600.0 # deceleration when no input on ground -JUMP_SPEED = 760.0 # initial upward velocity of a jump -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 -JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing +GRAVITY = 2200.0 # downward acceleration +MAX_FALL = 1400.0 # terminal velocity +MOVE_SPEED = 320.0 # horizontal run speed +ACCEL = 3200.0 # ground acceleration toward target speed +AIR_ACCEL = 2200.0 # weaker control in the air +FRICTION = 3600.0 # deceleration when no input on ground +JUMP_SPEED = 760.0 # initial upward velocity of a jump +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 +JUMP_BUFFER = 0.10 # seconds a jump press is remembered before landing # --- 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; - # the phone is dying, so the bar reads near-empty) -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 -FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition +# the phone is dying, so the bar reads near-empty) +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 +FADE_TIME = 0.3 # seconds for each half of the fade-to-black transition # --- Colors (placeholder rendering) ------------------------------------------ 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. PLACEHOLDERS = { - "player": ((90, 200, 255), "P"), - "player_dead": ((120, 40, 40), "X"), - "block": ((110, 120, 140), ""), - "goal": ((90, 230, 130), "GOAL"), - "fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose - "spike": ((230, 80, 80), "^"), - "moving_block": ((150, 130, 90), ""), - "patrol_block": ((130, 100, 170), ""), + "player": ((90, 200, 255), "P"), + "player_dead": ((120, 40, 40), "X"), + "block": ((110, 120, 140), ""), + "goal": ((90, 230, 130), "GOAL"), + "fake_block": ((110, 120, 140), ""), # looks identical to a real block on purpose + "spike": ((230, 80, 80), "^"), + "moving_block": ((150, 130, 90), ""), + "patrol_block": ((130, 100, 170), ""), "crumble_block": ((150, 120, 100), ""), "arrow_shooter": ((80, 80, 95), ""), - "arrow": ((250, 220, 90), ">"), - "spike_block": ((150, 156, 172), "*"), - "phase_block": ((90, 180, 210), ""), + "arrow": ((250, 220, 90), ">"), + "spike_block": ((150, 156, 172), "*"), + "phase_block": ((90, 180, 210), ""), } diff --git a/game/traps.py b/game/traps.py index 7e70900..cf8633b 100644 --- a/game/traps.py +++ b/game/traps.py @@ -17,10 +17,12 @@ import pygame from . import settings - # --- helpers ----------------------------------------------------------------- _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: { all: [ { within: 3 }, { dir: above, aligned: true } ] } + class _Always: def evaluate(self, trap, game, dt): return True @@ -46,6 +49,7 @@ class _Always: class _Within: """Player within N tiles of the trap centre (Euclidean radius).""" + def __init__(self, n): self.n = float(n) @@ -69,6 +73,7 @@ class _Directional: left/right (same rows) or *directly* above/below (same columns). inclusive: specify if the trap tile itself counts in the given direction. """ + def __init__(self, direction, rng, aligned, inclusive): self.dir = direction self.rng = None if rng is None else float(rng) @@ -113,6 +118,7 @@ class _Directional: class _Timer: """Cyclic: hidden for ``interval`` seconds, then active for ``up_time``.""" + def __init__(self, interval, up_time): self.interval = float(interval) self.up_time = float(up_time) @@ -155,7 +161,9 @@ def make_condition(spec): if spec == "always": return _Always() 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: return _All([make_condition(s) for s in spec["all"]]) if "any" in spec: @@ -166,7 +174,12 @@ def make_condition(spec): if "within" in spec: return _Within(spec["within"]) 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}") @@ -178,7 +191,7 @@ class Trap: at = spec.get("at", [0, 0]) self.col, self.row = int(at[0]), int(at[1]) 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). self.invisible = bool(spec.get("invisible", False)) @@ -274,13 +287,14 @@ class Trap: def _follow(self, parent): ox, oy = self._mount_off pr = parent.current_rect() - self.base_rect = pygame.Rect(pr.x + ox, pr.y + oy, - self.base_rect.w, self.base_rect.h) + self.base_rect = pygame.Rect( + pr.x + ox, pr.y + oy, self.base_rect.w, self.base_rect.h + ) def tick(self, dt, game): self.update(dt, game) 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) 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). See the trigger-condition docs at the top of this module. """ + def __init__(self, spec, level): super().__init__(spec, level) self.direction = spec.get("direction", "up") @@ -369,13 +384,13 @@ class Spike(Trap): t = self.tile r = self.base_rect 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) - if dy == 1: # down (ceiling spike) + if dy == 1: # down (ceiling spike) 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, 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. _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 retracting — hysteresis that stops boundary jitter. """ + def __init__(self, spec, level): super().__init__(spec, level) t = self.tile @@ -429,15 +445,23 @@ class Block(Trap): self.speed = float(spec.get("speed", 140.0)) self.mode = spec.get("mode", "once") 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_delay = float(spec.get("crumble_delay", 0.4)) self.respawn = float(spec.get("respawn", 2.5)) - self.sprite = spec.get("sprite", - "spike_block" 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 + self.sprite = spec.get( + "sprite", + ( + "spike_block" + 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 # 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), @@ -463,8 +487,9 @@ class Block(Trap): # `home` sensing tracks the resting cell as it rides along the parent. ox, oy = self._origin offx, offy = self._mount_off - self.base_rect = pygame.Rect(round(ox + offx), round(oy + offy), - self.tile, self.tile) + self.base_rect = pygame.Rect( + round(ox + offx), round(oy + offy), self.tile, self.tile + ) def reset(self): self._reset_trigger() @@ -474,12 +499,12 @@ class Block(Trap): # LOCAL — the live rect is always _origin + (x, y). self._origin = (0.0, 0.0) self.prev = (self.x, self.y) - self.dir = 1 # pingpong direction - self.phase = "rest" # once mode: rest|extending|extended|retracting + self.dir = 1 # pingpong direction + self.phase = "rest" # once mode: rest|extending|extended|retracting self._release_t = 0.0 # 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.cstate = "solid" # crumble: solid|crumbling|gone + self.cstate = "solid" # crumble: solid|crumbling|gone self.ctimer = 0.0 self.shake = 0.0 self.emerge_kill = False @@ -491,7 +516,7 @@ class Block(Trap): # block executes its path/move relative to the parent it rides. if not self._mounted: 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: step = self.speed * dt if self.mode == "once": @@ -505,8 +530,9 @@ class Block(Trap): self.emerge_kill = False r = self._rect() p = game.player.rect - on_top = (abs(p.bottom - r.top) <= 4 - and p.right > r.left + 2 and p.left < r.right - 2) + on_top = ( + abs(p.bottom - r.top) <= 4 and p.right > r.left + 2 and p.left < r.right - 2 + ) if self.cstate == "solid": if on_top: self.cstate = "crumbling" @@ -590,8 +616,7 @@ class Block(Trap): def _rect(self): ox, oy = self._origin - return pygame.Rect(round(ox + self.x), round(oy + self.y), - self.tile, self.tile) + return pygame.Rect(round(ox + self.x), round(oy + self.y), self.tile, self.tile) def current_rect(self): return self._rect() @@ -654,6 +679,7 @@ class ArrowShooter(Trap): direction: up/down/left/right. speed: px/s. interval: seconds between shots. trigger: only fires while the condition holds (default ``always``). """ + def __init__(self, spec, level): super().__init__(spec, level) self.direction = spec.get("direction", "left") @@ -676,10 +702,14 @@ class ArrowShooter(Trap): rect = pygame.Rect(0, 0, w, h) rect.center = r.center # nudge the arrow to the emitting edge - if dx == -1: rect.right = r.left - elif dx == 1: rect.left = r.right - elif dy == -1: rect.bottom = r.top - elif dy == 1: rect.top = r.bottom + if dx == -1: + rect.right = r.left + elif dx == 1: + 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)) def update(self, dt, game): @@ -704,8 +734,9 @@ class ArrowShooter(Trap): return [a.rect for a in self.arrows] def draw(self, surface, assets): - surface.blit(assets.get("arrow_shooter", self.tile, self.tile), - self._rt(self.base_rect)) + surface.blit( + assets.get("arrow_shooter", self.tile, self.tile), self._rt(self.base_rect) + ) for a in self.arrows: 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 screen. The level's ``debug`` flag tints it (and draws a line to its 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 # largest-first so the bright core sits on top. @@ -735,8 +767,8 @@ class Warp(Trap): self.reset() def reset(self): - 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._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 def update(self, dt, game): if self._pulse > 0.0: @@ -749,7 +781,7 @@ class Warp(Trap): p.vx = p.vy = 0.0 p._sync_rect() 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: self._armed = True @@ -763,8 +795,9 @@ class Warp(Trap): c.render(surface, assets) def _dest_rect(self): - return pygame.Rect(self.dest[0] * self.tile, self.dest[1] * self.tile, - self.tile, self.tile) + return pygame.Rect( + self.dest[0] * self.tile, self.dest[1] * self.tile, self.tile, self.tile + ) def _draw_aura(self, surface, rect): # An expanding, fading glow centred on the cell. As the pulse decays the @@ -788,8 +821,13 @@ class Warp(Trap): if self.level.debug: self._debug_tint(surface, (210, 80, 235), alpha=90) dest = self._dest_rect() - pygame.draw.line(surface, (210, 80, 235), - self._rt(self.base_rect).center, self._rt(dest).center, 1) + pygame.draw.line( + surface, + (210, 80, 235), + self._rt(self.base_rect).center, + self._rt(dest).center, + 1, + ) 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 releases). If the player is standing in the cell the instant it *starts* appearing, they're killed.""" + def __init__(self, spec, level): super().__init__(spec, level) 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 # than killed. A shallow clip (feet/shoulder in the cell) gets shoved out; # 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): self.emerge_kill = False active = self.triggered(game, dt) if active: - if self.alpha == 0.0 and not self.solid \ - and game.player.rect.colliderect(self.base_rect): + if ( + 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 # them clear and let the block solidify behind them; only if it's # forming through their middle — or the shove would squish them @@ -847,15 +889,15 @@ class PhaseBlock(Trap): b = self.base_rect # Distance to move the player to clear the block on each side. outs = { - "up": p.bottom - b.top, - "down": b.bottom - p.top, - "left": p.right - b.left, + "up": p.bottom - b.top, + "down": b.bottom - p.top, + "left": p.right - b.left, "right": b.right - p.left, } side = min(outs, key=outs.get) dist = outs[side] 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] moved = p.move(dx * dist, dy * dist) # 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) return img = assets.get("phase_block", self.tile, self.tile).copy() - img.fill((255, 255, 255, int(255 * self.alpha)), - special_flags=pygame.BLEND_RGBA_MULT) + img.fill( + (255, 255, 255, int(255 * self.alpha)), special_flags=pygame.BLEND_RGBA_MULT + ) surface.blit(img, self._rt(self.base_rect)) diff --git a/requirements-dev.txt b/requirements-dev.txt index 9573d48..d321011 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ -r requirements.txt pytest>=7.0 +black diff --git a/tests/conftest.py b/tests/conftest.py index 801bca1..5ac79c4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,7 @@ DT = 1 / 60.0 @pytest.fixture(scope="session", autouse=True) def _pygame(): 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 pygame.quit() diff --git a/tests/test_block.py b/tests/test_block.py index efb215d..c978168 100644 --- a/tests/test_block.py +++ b/tests/test_block.py @@ -20,12 +20,12 @@ def test_stationary_is_solid(): def test_deadly_is_hazard_not_solid(): b = block(at=[3, 3], deadly=True) assert b.solid_rects() == [] - assert b.hazard_rects() # non-empty + assert b.hazard_rects() # non-empty def test_fake_is_drawn_but_not_solid(): 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 @@ -53,8 +53,8 @@ traps: if g.deaths > d0: killed = True break - assert "gone" in states # it crumbled away - assert killed # re-formed onto the standing player + assert "gone" in states # it crumbled away + assert killed # re-formed onto the standing player def test_moving_block_that_crumbles(make_game): @@ -78,7 +78,7 @@ traps: respawn: 1.0 """) b = g.level.traps[0] - place(g, 2, 1) # ride the platform + place(g, 2, 1) # ride the platform moved = crumbled = False start = b.x for _ in range(200): @@ -103,18 +103,23 @@ def test_patrol_loop_visits_all_waypoints(): def test_slider_once_no_jitter_and_retracts(): # 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}) - 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)] - assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced - g.player.rect.x = 30 * 32 # leave + assert reversals(xs) == 0 and abs(xs[-1] - 13 * 32) < 2 # committed to displaced + g.player.rect.x = 30 * 32 # leave 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(): # A dropper (sense=current) commits to the bottom and holds while ridden. - b = block(at=[21, 9], move=[0, 4], speed=220, sense="current", - trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]}) + b = block( + at=[21, 9], + move=[0, 4], + speed=220, + sense="current", + trigger={"all": [{"within": 5}, {"dir": "above", "aligned": True}]}, + ) # player standing on top of it p = pygame.Rect(21 * 32 + 4, 9 * 32 - 29, 23, 29) g = FakeGame(p) @@ -124,15 +129,15 @@ def test_sense_current_rides_down(): # keep the player riding the block top p.bottom = b._rect().top 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(): b = block(at=[0, 5], path=[[0, 5], [5, 5]], mode="pingpong", speed=120) g = FakeGame(pygame.Rect(0, 0, 4, 4)) b.update(1 / 60, g) - (rect, dx, dy), = b.carriers() - assert dx != 0 and dy == 0 # moving horizontally + ((rect, dx, dy),) = b.carriers() + assert dx != 0 and dy == 0 # moving horizontally # --- array expansion (count / spacing) -------------------------------------- @@ -147,8 +152,12 @@ def test_expand_line(): def test_expand_grid_with_spacing(): - ats = [s["at"] for s in expand_spec( - {"type": "block", "at": [0, 0], "count": [3, 2], "spacing": [2, 3]})] + ats = [ + 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]] # count/spacing are stripped from each expanded spec 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)] assert len(blocks) == 4 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, + ] diff --git a/tests/test_crush.py b/tests/test_crush.py index e948b8f..13d3b49 100644 --- a/tests/test_crush.py +++ b/tests/test_crush.py @@ -100,7 +100,7 @@ traps: mode: pingpong 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 start_x = g.player.rect.x max_bottom = g.player.rect.bottom @@ -109,8 +109,8 @@ traps: max_bottom = max(max_bottom, g.player.rect.bottom) if g.state != "playing": break - assert max_bottom <= floor_top + 1 # never pushed into the floor - assert g.player.rect.x < start_x # got shoved along + assert max_bottom <= floor_top + 1 # never pushed into the floor + assert g.player.rect.x < start_x # got shoved along 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) 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() wall_left = 8 * 32 hit_wall = False @@ -145,12 +145,12 @@ traps: step(g) if g.state != "playing": break - assert not g.player.crushed # never crushed - assert g.player.rect.right <= wall_left + 1 # stopped at the wall + assert not g.player.crushed # never crushed + assert g.player.rect.right <= wall_left + 1 # stopped at the wall if g.player.rect.right >= wall_left - 1: hit_wall = True - assert g.state == "playing" # survived the whole time - assert hit_wall # actually reached the wall + assert g.state == "playing" # survived the whole time + assert hit_wall # actually reached the wall def test_corner_clip_does_not_warp(make_game): @@ -177,7 +177,7 @@ traps: prev = g.player.rect.x for _ in range(120): 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 @@ -213,4 +213,4 @@ traps: if not g.player.on_ground and g.player.rect.bottom > 3 * 32: left_at = reversalsN break - assert left_at == 0 # fell before any block reversal + assert left_at == 0 # fell before any block reversal diff --git a/tests/test_flow.py b/tests/test_flow.py index 806bfec..340714f 100644 --- a/tests/test_flow.py +++ b/tests/test_flow.py @@ -5,7 +5,6 @@ from conftest import step, run, place, hold, DT from game import settings as S from game.player import InputState - SIMPLE = """ name: My Level 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.spawn == (1 * 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): @@ -38,7 +37,7 @@ traps: - type: not_a_real_trap at: [1, 1] """) - assert lvl.traps == [] # skipped, no crash + assert lvl.traps == [] # skipped, no crash 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) b.write_text(SIMPLE.replace("My Level", "Two")) from game.game import Game + g = Game([str(a), str(b)], str(tmp_path / "noassets")) def die(): @@ -69,24 +69,26 @@ def test_death_counters_per_level_and_total(make_game, tmp_path): while g.state == "dying": step(g) - die(); die(); die() + die() + die() + die() assert g.level_deaths == 3 and g.deaths == 3 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() assert g.level_deaths == 1 and g.deaths == 4 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): g = make_game(SIMPLE) g._reach_goal() 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): 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): @@ -95,6 +97,7 @@ def test_fade_swaps_level_at_black(make_game, tmp_path): a.write_text(SIMPLE) b.write_text(SIMPLE.replace("My Level", "Two")) from game.game import Game + g = Game([str(a), str(b)], str(tmp_path / "noassets")) g.state = "won_level" g._start_fade(g._advance) @@ -110,32 +113,45 @@ def test_fade_swaps_level_at_black(make_game, tmp_path): done = i break 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): a = tmp_path / "a.yaml" b = tmp_path / "b.yaml" - a.write_text("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 + a.write_text( + "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 + calls = [] orig = pygame.display.set_mode - monkeypatch.setattr(pygame.display, "set_mode", - lambda size, *a, **k: calls.append(size) or orig(size, *a, **k)) + monkeypatch.setattr( + 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._advance(); g._replay() - assert len(calls) == 1 # never recreated the window + g._advance() + g._replay() + assert len(calls) == 1 # never recreated the window # sized to the tallest level (4 rows + HUD) from game.game import HUD_H + assert g.win_h == 4 * 32 + HUD_H def test_debug_view_adds_overscan_margin(tmp_path): from game.game import Game, HUD_H from game import settings as S + 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")) m = S.DEBUG_VIEW_MARGIN * 32 assert m > 0 @@ -148,6 +164,7 @@ def test_debug_view_adds_overscan_margin(tmp_path): def test_no_overscan_without_debug(tmp_path): from game.game import Game + p = tmp_path / "n.yaml" p.write_text("name: t\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") 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): from game.game import Game + p = tmp_path / "hot.yaml" p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") g = Game([str(p)], str(tmp_path / "noassets")) assert g.level.name == "A" and len(g.level.traps) == 0 d0 = g.deaths # edit the file on disk, then F5 - p.write_text("name: B\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n" - "traps:\n - type: block\n at: [2, 1]\n deadly: true\n") + p.write_text( + "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() - 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": step(g) - 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.name == "B" and len(g.level.traps) == 1 # picked up the edit + assert g.level_deaths == 1 # counted, not reset to 0 def test_f5_bad_yaml_does_not_crash(tmp_path, capsys): from game.game import Game + p = tmp_path / "bad.yaml" p.write_text("name: A\ntile_size: 32\nmap: |\n #####\n #P.G#\n #####\n") 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() while g.state == "dying": step(g) - assert g.state == "playing" # fell back, no crash - assert g.level.name == "A" # kept the old level + assert g.state == "playing" # fell back, no crash + assert g.level.name == "A" # kept the old level def test_debug_grid_adds_pixels(make_level): import pygame 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") def painted(dbg): @@ -209,6 +233,11 @@ def test_debug_grid_adds_pixels(make_level): w = pygame.Surface((lvl.width, lvl.height)) w.fill((0, 0, 0)) lvl.draw(w, a) - return sum(1 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 + return sum( + 1 + 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 diff --git a/tests/test_mounting.py b/tests/test_mounting.py index 8826f82..ffaf249 100644 --- a/tests/test_mounting.py +++ b/tests/test_mounting.py @@ -3,7 +3,6 @@ from conftest import step, DT from game.traps import Block, Spike - PLATFORM_WITH_SPIKE = """ name: t tile_size: 32 @@ -74,8 +73,10 @@ traps: for m in plat.mounts: assert m._mounted and m.deadly # each deadly mount tracks the platform at its offset and is lethal - exp = (plat.current_rect().x + m._mount_off[0], - plat.current_rect().y + m._mount_off[1]) + exp = ( + 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.hazard_rects() diff --git a/tests/test_physics.py b/tests/test_physics.py index 4d8cb66..a48dee3 100644 --- a/tests/test_physics.py +++ b/tests/test_physics.py @@ -18,10 +18,10 @@ map: | def test_falls_and_lands_on_floor(make_game): g = make_game(FLAT) - place(g, 4, 1) # up in the air + place(g, 4, 1) # up in the air run(g, 60) 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): @@ -34,13 +34,13 @@ def test_terminal_velocity(make_game): def test_jump_gains_height_then_returns(make_game): g = make_game(FLAT) - run(g, 30) # settle on floor + run(g, 30) # settle on floor ground = g.player.rect.bottom peak = ground for i in range(60): step(g, hold(jump_pressed=(i == 0), jump_held=True)) 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): @@ -55,6 +55,7 @@ def test_variable_jump_height(make_game): step(g, hold(jump_pressed=(i == 0), jump_held=held)) hi = min(hi, g.player.rect.bottom) return ground - hi + assert peak(60) > peak(1) + 8 @@ -71,22 +72,22 @@ map: | #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 run(g, 4, lambda i: hold(right=True)) y_before = g.player.rect.bottom step(g, hold(right=True, jump_pressed=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): g = make_game(FLAT) place(g, 1, 4) 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)) - 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): @@ -103,7 +104,7 @@ map: | ####### """) # 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) assert g.player.on_ground and g.player.rect.bottom == 3 * 32 @@ -119,12 +120,12 @@ map: | #P...G# ####### """) - place(g2, 1, 4) # on the floor, below the one-way (row 2) - run(g2, 5) # settle so on_ground is set before jumping + place(g2, 1, 4) # on the floor, below the one-way (row 2) + run(g2, 5) # settle so on_ground is set before jumping passed = False for i in range(40): 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 assert passed @@ -142,10 +143,10 @@ map: | #....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) assert g.player.on_ground # press down + jump to drop through for i in range(30): 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 diff --git a/tests/test_traps.py b/tests/test_traps.py index d26dbc5..affea4d 100644 --- a/tests/test_traps.py +++ b/tests/test_traps.py @@ -2,7 +2,7 @@ import hashlib 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.traps import Spike, ArrowShooter, Warp, PhaseBlock, Block @@ -51,7 +51,7 @@ traps: def test_spike_sprite_rotates_per_direction(tmp_path): # A deliberately asymmetric sprite so each rotation is distinct. surf = pygame.Surface((8, 8), pygame.SRCALPHA) - surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only + surf.fill((255, 0, 0, 255), (0, 0, 8, 2)) # red bar along the top only adir = tmp_path / "assets" adir.mkdir() pygame.image.save(surf, str(adir / "spike.png")) @@ -62,7 +62,8 @@ def test_spike_sprite_rotates_per_direction(tmp_path): def h(s): 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) @@ -85,10 +86,10 @@ traps: """) sh = lvl.traps[0] 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) - assert sh.arrows # spawned arrows - assert sh.hazard_rects() # arrows are hazards + assert sh.arrows # spawned arrows + assert sh.hazard_rects() # arrows are hazards def test_arrow_shooter_trigger_gates_firing(make_level): @@ -108,10 +109,10 @@ traps: trigger: { within: 1 } """) 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): 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 ------------------------ @@ -132,10 +133,10 @@ traps: count: [1, 2] """) 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) 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 -------------------------------------------------------------------- @@ -178,9 +179,9 @@ traps: to: [7, 1] """) warp = g.level.traps[0] - assert warp._pulse == 0.0 # dormant: no aura - place(g, 4, 2) # stand on the warp tile - step(g) # -> teleports, aura flashes on + assert warp._pulse == 0.0 # dormant: no aura + place(g, 4, 2) # stand on the warp tile + step(g) # -> teleports, aura flashes on assert warp._pulse > 0.9 # The aura paints at BOTH the source and the destination — even though the @@ -218,9 +219,9 @@ traps: pb = g.level.traps[0] place(g, 1, 3) 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)) - 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): @@ -239,7 +240,7 @@ traps: trigger: { within: 3 } """) 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 for _ in range(10): step(g) @@ -268,7 +269,7 @@ traps: pb = g.level.traps[0] b = pb.base_rect # Straddle the block's left edge: mostly outside, just clipping into it. - g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap + g.player.fx = float(b.left - g.player.w + 5) # 5px of overlap g.player.fy = float(b.top + 2) g.player.vx = g.player.vy = 0.0 g.player._sync_rect() @@ -331,21 +332,23 @@ traps: trigger: { within: 3 } """) pb = g.level.traps[0] - place(g, 5, 2) # near enough to trigger, not on the cell - for _ in range(6): # let it partially fade in + place(g, 5, 2) # near enough to trigger, not on the cell + for _ in range(6): # let it partially fade in step(g) assert 0 < pb.alpha < 1 - g._start_death() # die from something - assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze + g._start_death() # die from something + assert pb.alpha == 1.0 and pb.solid # snapped fully visible for the freeze def test_debug_overscan_reveals_offmap_trap(make_level): # In debug the play surface is enlarged and shifted (render_offset) so a trap # placed just off the map is drawn into the overscan instead of being clipped. from game import settings as S + lvl = make_level( "name: t\ntile_size: 32\ndebug: true\nmap: |\n #####\n #...#\n #####\n" - "traps:\n - type: block\n at: [5, 1]\n deadly: true\n") # col 5 = off-map + "traps:\n - type: block\n at: [5, 1]\n deadly: true\n" + ) # col 5 = off-map a = AssetStore("noassets") m = S.DEBUG_VIEW_MARGIN * lvl.tile lvl.render_offset = (m, m) @@ -354,7 +357,7 @@ def test_debug_overscan_reveals_offmap_trap(make_level): lvl.draw(surf, a) cx = 5 * lvl.tile + m + lvl.tile // 2 cy = 1 * lvl.tile + m + lvl.tile // 2 - assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan + assert surf.get_at((cx, cy))[:3] != (0, 0, 0) # off-map block painted in overscan # --- generic invisible flag -------------------------------------------------- @@ -384,9 +387,12 @@ traps: w = pygame.Surface((lvl.width, lvl.height)) w.fill((0, 0, 0)) sp.render(w, a) - return any(w.get_at((x, y))[:3] != (0, 0, 0) - for y in range(32, 96) for x in range(32, 96)) + return any( + w.get_at((x, y))[:3] != (0, 0, 0) + for y in range(32, 96) + for x in range(32, 96) + ) - assert painted(False) is False # invisible in play - assert painted(True) is True # revealed in debug - assert sp.hazard_rects() # still deadly either way + assert painted(False) is False # invisible in play + assert painted(True) is True # revealed in debug + assert sp.hazard_rects() # still deadly either way diff --git a/tests/test_triggers.py b/tests/test_triggers.py index 8634bcb..78444fa 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -6,9 +6,12 @@ from game.traps import make_condition def ev(spec, px, py, w=20, h=28, dt=0.0): - trap = type("T", (), {"tile": 32, - "sensor_rect": lambda self: pygame.Rect(100, 100, 32, 32)})() + trap = type( + "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) + + # trap centre = (116, 116); left100 right132 top100 bottom132 @@ -17,26 +20,26 @@ def test_always(): def test_within_radius(): - assert ev({"within": 2}, 106, 104) is True # ~10px away - assert ev({"within": 2}, 400, 116) is False # far + assert ev({"within": 2}, 106, 104) is True # ~10px away + assert ev({"within": 2}, 400, 116) is False # far def test_dir_left_right(): - 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"}, 40, 105) is True # to the left + assert ev({"dir": "left"}, 200, 105) is False # to the right assert ev({"dir": "right"}, 200, 105) is True def test_dir_range(): - 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}, 60, 105) is True # within 2 tiles left + assert ev({"dir": "left", "range": 2}, 10, 105) is False # too far left def test_dir_aligned(): # 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, 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 assert ev({"dir": "above"}, 400, 60) is True @@ -44,8 +47,8 @@ def test_dir_aligned(): def test_dir_inclusive(): # 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. - 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": "left"}, 106, 105) is False # centre 116, inside -> not left + assert ev({"dir": "right"}, 106, 105) is False # inside -> not right # 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": "right", "inclusive": True}, 106, 105) is True @@ -59,24 +62,26 @@ def test_dir_inclusive(): def test_all_and_any(): c = {"all": [{"within": 3}, {"dir": "above", "aligned": 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"}]} 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(): c = make_condition({"timer": {"interval": 0.3, "up_time": 0.2}}) - trap = type("T", (), {"tile": 32, - "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)})() + trap = type( + "T", (), {"tile": 32, "sensor_rect": lambda self: pygame.Rect(0, 0, 32, 32)} + )() g = FakeGame(pygame.Rect(0, 0, 4, 4)) states = [c.evaluate(trap, g, 1 / 60) for _ in range(30)] - assert states[0] is False # starts in the "off" interval - assert any(states) and not all(states) # cycles on and off + assert states[0] is False # starts in the "off" interval + assert any(states) and not all(states) # cycles on and off def test_bad_condition_raises(): import pytest + with pytest.raises(ValueError): make_condition({"nope": 1}) @@ -97,10 +102,10 @@ traps: delay: 0.25 """) 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 for i in range(30): if sp.triggered(g, 1 / 60): armed = i 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 diff --git a/tools/gen_sprites.py b/tools/gen_sprites.py index 449ab29..51324e0 100644 --- a/tools/gen_sprites.py +++ b/tools/gen_sprites.py @@ -21,11 +21,11 @@ def _s(): # --- individual sprites ------------------------------------------------------ def draw_block(s): - s.fill((96, 104, 122)) # stone + s.fill((96, 104, 122)) # stone seam = (70, 76, 92) - 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, (16, 2), (16, 16), 2) # offset bricks + 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, (16, 2), (16, 16), 2) # offset bricks 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, (132, 140, 158), (2, 2), (TILE - 3, 2), 1) # top highlight @@ -38,33 +38,34 @@ def draw_fake_block(s): def draw_player(s): body = pygame.Rect(6, 2, 20, 28) - 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, (95, 205, 255), (9, 6, 14, 16)) # screen - pygame.draw.rect(s, (20, 30, 45), (12, 10, 3, 4)) # eyes + 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, (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), (18, 10, 3, 4)) - 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.line(s, (20, 30, 45), (12, 17), (20, 17), 2) # smile + pygame.draw.rect(s, (120, 126, 140), (14, 25, 4, 2)) # home button def draw_player_dead(s): body = pygame.Rect(6, 2, 20, 28) 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, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen - for ex in (11, 17): # X eyes + pygame.draw.rect(s, (72, 76, 86), (9, 6, 14, 16)) # dead grey screen + 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 + 4, 9), (ex, 14), 2) - pygame.draw.lines(s, (200, 205, 215), False, - [(9, 8), (14, 13), (12, 18), (21, 21)], 1) # crack + pygame.draw.lines( + s, (200, 205, 215), False, [(9, 8), (14, 13), (12, 18), (21, 21)], 1 + ) # crack def draw_goal(s): 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) 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) @@ -77,43 +78,46 @@ def draw_spike(s): pts = [(x, TILE), (x + w / 2, 3), (x + w, TILE)] pygame.draw.polygon(s, base, pts) 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): - s.fill((172, 122, 72)) # crate + s.fill((172, 122, 72)) # crate 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)]: - 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) pygame.draw.rect(s, (152, 106, 60), plate) pygame.draw.rect(s, (120, 80, 45), plate, 1) 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, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight - for off in (0, 9): # motion chevrons - pygame.draw.lines(s, (92, 76, 142), False, - [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2) + pygame.draw.rect(s, (162, 148, 208), (2, 2, TILE - 4, 4)) # top highlight + for off in (0, 9): # motion chevrons + pygame.draw.lines( + s, (92, 76, 142), False, [(10, 15 + off), (16, 19 + off), (22, 15 + off)], 2 + ) def draw_crumble_block(s): s.fill((166, 136, 96)) pygame.draw.rect(s, (120, 95, 60), (0, 0, TILE, TILE), 2) 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.line(s, cr, (2, 14), (9, 16), 1) pygame.draw.line(s, cr, (24, 15), (TILE, 13), 1) 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) 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, (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) m = TILE // 2 tris = [ - [(m, 0), (m - 4, 11), (m + 4, 11)], # up - [(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down - [(0, m), (11, m - 4), (11, m + 4)], # left - [(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right - [(2, 2), (13, 6), (6, 13)], # up-left - [(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right - [(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 + [(m, 0), (m - 4, 11), (m + 4, 11)], # up + [(m, TILE), (m - 4, TILE - 11), (m + 4, TILE - 11)], # down + [(0, m), (11, m - 4), (11, m + 4)], # left + [(TILE, m), (TILE - 11, m - 4), (TILE - 11, m + 4)], # right + [(2, 2), (13, 6), (6, 13)], # up-left + [(TILE - 2, 2), (TILE - 13, 6), (TILE - 6, 13)], # up-right + [(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 ] for t in tris: 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.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.polygon(s, (150, 230, 255), - [(16, 4), (28, 16), (16, 28), (4, 16)], 1) + pygame.draw.polygon(s, (150, 230, 255), [(16, 4), (28, 16), (16, 28), (4, 16)], 1) def draw_arrow(s):