Files
battery/game/level.py

171 lines
6.3 KiB
Python
Raw Normal View History

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.
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))