"""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. rows = [r for r in rows if r.strip("\n") != "" or True] rows = [r.rstrip("\n") for r in rows] 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 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 block = assets.get("block", t, t) for rect in self.solids: surface.blit(block, rect) # 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) surface.blit(assets.get("goal", self.goal_rect.w, self.goal_rect.h), self.goal_rect) 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.""" if Level._grid_font is None: Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10) overlay = pygame.Surface((self.width, self.height), pygame.SRCALPHA) line = (255, 255, 255, 26) for c in range(self.cols + 1): x = c * self.tile pygame.draw.line(overlay, line, (x, 0), (x, self.height)) for r in range(self.grid_rows + 1): y = r * self.tile pygame.draw.line(overlay, line, (0, y), (self.width, y)) label = (150, 162, 190) for c in range(self.cols): overlay.blit(Level._grid_font.render(str(c), True, label), (c * self.tile + 2, 1)) for r in range(self.grid_rows): overlay.blit(Level._grid_font.render(str(r), True, label), (1, r * self.tile + 1)) surface.blit(overlay, (0, 0))