"""Level loading: YAML -> geometry + traps. A level file has an ASCII ``map`` for static geometry and a ``traps`` list for the interactive/nasty bits. Grid coordinates are ``[col, row]`` (0-based, from the top-left of the map). Map legend: # solid block - one-way platform (stand on top, jump up through it) P player spawn G goal (the phone charger) . or space empty """ import os import pygame import yaml from . import settings from . import traps as traps_mod class Level: def __init__(self, path): with open(path, "r") as fh: data = yaml.safe_load(fh) or {} self.path = path self.name = data.get("name", os.path.splitext(os.path.basename(path))[0]) self.tile = int(data.get("tile_size", settings.TILE)) self.battery = float(data.get("battery_seconds", settings.DEFAULT_BATTERY)) # How full the battery bar *looks* at the start (percent). Visual only — # the actual time limit is battery_seconds. self.battery_pct = float(data.get("battery_pct", settings.DEFAULT_BATTERY_PCT)) # Debug view: reveal everything normally hidden — one-way platforms, # invisible walls, warps, dormant phase blocks, fake blocks, and # not-yet-sprung spikes. Traps read this via ``self.level.debug``. self.debug = bool(data.get("debug", False)) rows = data.get("map", "").splitlines() # Strip a leading blank line from block-scalar formatting, keep shape. if rows and rows[0] == "": rows = rows[1:] self.cols = max((len(r) for r in rows), default=0) self.grid_rows = len(rows) self.width = self.cols * self.tile self.height = self.grid_rows * self.tile # Draw-time translation (pixels): everything visible is shifted by this # when blitted. Physics stays in true level coordinates — this is purely # so the debug view can reveal a margin of overscan around the level (set # 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.spawn = (self.tile, self.tile) self.goal_rect = pygame.Rect(self.width - self.tile, 0, self.tile, self.tile) for r, line in enumerate(rows): for c, ch in enumerate(line): rect = self.cell_rect(c, r) if ch == "#": self.solids.append(rect) elif ch == "-": self.oneways.append(rect) elif ch == "P": self.spawn = (rect.x, rect.y) elif ch == "G": self.goal_rect = rect # Build traps from the config via the factory. `count`/`spacing` on a # spec expands into a line/grid of copies first. self.traps = [] for spec in data.get("traps", []) or []: for one in traps_mod.expand_spec(spec): trap = traps_mod.make_trap(one, self) if trap is not None: self.traps.append(trap) # Snap any mounted traps onto their parent's starting position so their # geometry is correct even before the first update tick. self.reset() # --- helpers ------------------------------------------------------------- def cell_rect(self, col, row): return pygame.Rect(col * self.tile, row * self.tile, self.tile, self.tile) def reset(self): """Reset every trap to its initial state (called on player death).""" for t in self.traps: t.reset_all() def update(self, dt, game): for t in self.traps: t.tick(dt, game) # Rects that block movement this frame: static solids + any trap-provided # solids (moving/patrol blocks). One-way platforms are handled separately. # The all_* wrappers fold in anything mounted on a trap. def solid_rects(self): rects = list(self.solids) for t in self.traps: rects.extend(t.all_solid_rects()) return rects def oneway_rects(self): rects = list(self.oneways) for t in self.traps: rects.extend(t.all_oneway_rects()) return rects # Moving platforms the player can ride: (rect, dx, dy) moved this frame. def carriers(self): out = [] for t in self.traps: out.extend(t.all_carriers()) return out # Rects that kill the player on contact. def hazard_rects(self): rects = [] for t in self.traps: rects.extend(t.all_hazard_rects()) return rects def draw(self, surface, assets): t = self.tile ox, oy = self.render_offset block = assets.get("block", t, t) for rect in self.solids: surface.blit(block, rect.move(ox, oy)) # One-way platforms look identical to solid blocks — you only discover # them by falling through. `debug` fades them so a level designer can # see which is which. oneway_img = block if self.debug: oneway_img = block.copy() oneway_img.fill((255, 255, 255, 110), special_flags=pygame.BLEND_RGBA_MULT) 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), ) for tr in self.traps: tr.render(surface, assets) if self.debug: self._draw_grid(surface) _grid_font = None def _draw_grid(self, surface): """A faint tile grid with col/row labels, so `at: [col,row]` placement is eyeballable while designing. In the debug view the drawing surface is larger than the level (a margin of overscan; see ``render_offset``). The grid extends across the whole surface — labels go negative / past the map in the margin — the off-map region is shaded, and the true play area is outlined so it's clear what's actually on screen at runtime.""" if Level._grid_font is None: Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10) t = self.tile ox, oy = self.render_offset sw, sh = surface.get_size() overlay = pygame.Surface((sw, sh), pygame.SRCALPHA) # 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 if band.w > 0 and band.h > 0: overlay.fill(shade, band) # Tile grid across the whole surface; offsets are whole tiles so lines # stay aligned to the level grid. mx/my = margin width in tiles. mx, my = ox // t, oy // t line = (255, 255, 255, 26) for k in range(sw // t + 1): pygame.draw.line(overlay, line, (k * t, 0), (k * t, sh)) for k in range(sh // t + 1): 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) ) for k in range(sh // t): 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: pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2) surface.blit(overlay, (0, 0))