2026-07-21 19:36:02 -04:00
|
|
|
"""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
|
|
|
|
|
|
2026-07-22 11:56:21 -04:00
|
|
|
# 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)
|
|
|
|
|
|
2026-08-01 12:19:46 -04:00
|
|
|
self.solids = [] # list[pygame.Rect] — full blocking
|
|
|
|
|
self.oneways = [] # list[pygame.Rect] — blocking only from above
|
2026-07-21 19:36:02 -04:00
|
|
|
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
|
2026-07-22 11:56:21 -04:00
|
|
|
ox, oy = self.render_offset
|
2026-07-21 19:36:02 -04:00
|
|
|
block = assets.get("block", t, t)
|
|
|
|
|
for rect in self.solids:
|
2026-07-22 11:56:21 -04:00
|
|
|
surface.blit(block, rect.move(ox, oy))
|
2026-07-21 19:36:02 -04:00
|
|
|
|
|
|
|
|
# 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:
|
2026-07-22 11:56:21 -04:00
|
|
|
surface.blit(oneway_img, rect.move(ox, oy))
|
2026-07-21 19:36:02 -04:00
|
|
|
|
2026-08-01 12:19:46 -04:00
|
|
|
surface.blit(
|
|
|
|
|
assets.get("goal", self.goal_rect.w, self.goal_rect.h),
|
|
|
|
|
self.goal_rect.move(ox, oy),
|
|
|
|
|
)
|
2026-07-21 19:36:02 -04:00
|
|
|
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
|
2026-07-22 11:56:21 -04:00
|
|
|
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."""
|
2026-07-21 19:36:02 -04:00
|
|
|
if Level._grid_font is None:
|
|
|
|
|
Level._grid_font = pygame.font.SysFont("consolas,menlo,monospace", 10)
|
2026-07-22 11:56:21 -04:00
|
|
|
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)
|
2026-08-01 12:19:46 -04:00
|
|
|
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
|
2026-07-22 11:56:21 -04:00
|
|
|
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
|
2026-07-21 19:36:02 -04:00
|
|
|
line = (255, 255, 255, 26)
|
2026-07-22 11:56:21 -04:00
|
|
|
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))
|
2026-07-21 19:36:02 -04:00
|
|
|
label = (150, 162, 190)
|
2026-07-22 11:56:21 -04:00
|
|
|
for k in range(sw // t):
|
2026-08-01 12:19:46 -04:00
|
|
|
overlay.blit(
|
|
|
|
|
Level._grid_font.render(str(k - mx), True, label), (k * t + 2, 1)
|
|
|
|
|
)
|
2026-07-22 11:56:21 -04:00
|
|
|
for k in range(sh // t):
|
2026-08-01 12:19:46 -04:00
|
|
|
overlay.blit(
|
|
|
|
|
Level._grid_font.render(str(k - my), True, label), (1, k * t + 1)
|
|
|
|
|
)
|
2026-07-22 11:56:21 -04:00
|
|
|
|
|
|
|
|
# Outline the actual runtime viewport (the level's true bounds).
|
2026-08-01 12:19:46 -04:00
|
|
|
if mx or my:
|
2026-07-22 11:56:21 -04:00
|
|
|
pygame.draw.rect(overlay, (95, 210, 235, 200), view, 2)
|
|
|
|
|
|
2026-07-21 19:36:02 -04:00
|
|
|
surface.blit(overlay, (0, 0))
|