Python 1199 lines
import math
import random
import zlib
from collections import deque
import pygame
import audio
import level_catalog
import save
import theme
import tileset
from effects import FadeState, ParticleField
from interactables import Gate, KeyItem, Lever, PressurePlate, Spikes
from settings import (
ABILITY_COLOR_ELF,
ABILITY_COLOR_PENGUIN,
ABILITY_COLOR_SHIGGY,
ABILITY_COLOR_WIZARD,
ABILITY_COLOR_WOLF,
BOSS_TOUCH_DAMAGE,
FADE_IN_TIME,
FADE_OUT_TIME,
HIT_PAUSE_BOSS_DEATH,
HIT_PAUSE_BOSS_HIT,
HIT_PAUSE_PLAYER_DEATH,
HIT_PAUSE_PLAYER_HIT,
LEVER_REACH,
PARTICLES_ABILITY,
PARTICLES_BOSS_DEATH,
PARTICLES_BOSS_HIT,
PARTICLES_ENEMY_DEATH,
PARTICLES_ENEMY_HIT,
PARTICLES_PLAYER_HIT,
PLAYER_INVULN_TIME,
SLOW_SCALE,
SPIKE_DAMAGE,
TILE_SIZE,
)
from static_objects import Prop, Tile, TileTextures
from tiles import PROP_CHARS
from units import (
BOSS_ROSTER,
CHARACTER_INFO,
ENEMY_INFO,
Boss,
Elf,
Penguin,
Shiggy,
Wizard,
Wolf,
)
# Per-character ability burst colour, keyed by player class.
_ABILITY_COLORS = {
Wizard: ABILITY_COLOR_WIZARD,
Penguin: ABILITY_COLOR_PENGUIN,
Elf: ABILITY_COLOR_ELF,
Shiggy: ABILITY_COLOR_SHIGGY,
Wolf: ABILITY_COLOR_WOLF,
}
# Boss state -> badge colour. One named set instead of inline tuples
# scattered through draw_boss_health (FAIL = imminent hit, ACCENT =
# ranged tell, INK = neutral).
_BOSS_BADGE = {
'windup': ("!! WINDUP !!", theme.FAIL),
'dash': ("DASH", theme.FAIL),
'aim': ("AIMING", theme.ACCENT),
'shoot': ("FIRE", theme.ACCENT),
'chase': ("PURSUIT", theme.INK),
'recover': ("stagger", theme.MUTED),
}
# CHARACTERS comes from the units catalogue so adding a character is a
# one-liner in units.py.
CHARACTERS = {key: cls for key, cls, _label, _tagline in CHARACTER_INFO}
# Level token char -> enemy class (parallel to CHARACTERS).
ENEMIES = {char: cls for char, cls, _label in ENEMY_INFO}
# Built-in + custom levels come from ``level_catalog`` — the single
# source of truth for "what levels exist". The full LegendMD prop /
# letter table lives in :mod:`tiles` (``REGISTRY``); ``PROP_CHARS`` is
# the back-compat view used by the load_level switch below.
def _split_cells(line):
"""A map row is either dense — one character per cell, the legacy
format — or whitespace-separated tokens, which lets a cell carry a
variant (``T3``). A row with any internal spaces is tokenised."""
parts = line.split()
if len(parts) == 1 and parts[0] == line.strip():
return list(parts[0]) # dense single-char cells (no variants)
return parts # tokenised cells
def _cell_variant(cell):
"""Trailing digits of a token are the 1-based variant; default 1."""
digits = cell[1:]
return int(digits) if digits.isdigit() else 1
def _pair_id(cell):
"""Explicit trigger/gate pair id from a token's trailing digits
(``L2``→2, ``G3``→3), or ``None`` when the token has no digit — in
which case the legacy reading-order pairing is used.
Distinct from :func:`_cell_variant` (which defaults to 1) because
pairing must tell a bare ``L`` from an explicit ``L1``."""
digits = cell[1:]
return int(digits) if digits.isdigit() else None
class Camera:
"""Scrolls the world so the player stays centred, clamped to the
level bounds so the view never leaves the map.
Also owns the screen-shake offset: any system that wants a punch
of feedback (player hit, boss death, ...) calls :meth:`shake` and
the camera adds a decaying jitter to its effective offset.
The follow is intentionally *not* 1:1. It models the camera used by
well-regarded top-down action games (Zelda, Hyper Light Drifter,
Death's Door): the view leads slightly toward the direction the
player is moving so you see what you walk into, and the whole thing
is eased with frame-rate-independent exponential smoothing so the
camera trails softly instead of being glued to the sprite. It
recenters when the player stands still. No dead zone — that is a
platformer device and reads wrong for free 2D movement.
"""
# Exponential smoothing rate (per second). Higher = snappier,
# lower = floatier. ~6 reads as a soft trail without feeling sluggish.
FOLLOW_SPEED = 6.0
# How far the camera leads ahead of the player in the movement
# direction, as a fraction of the screen size.
LOOKAHEAD = 0.14
def __init__(self, screen_w, screen_h):
self.screen_w = screen_w
self.screen_h = screen_h
self.level_w = screen_w
self.level_h = screen_h
self.offset = pygame.math.Vector2(0, 0)
self.shake_offset = pygame.math.Vector2(0, 0)
self._shake_amount = 0.0
self._shake_time = 0.0
self._shake_total = 0.0
def set_level_size(self, level_w, level_h):
self.level_w = level_w
self.level_h = level_h
def follow(self, target, dt=None):
# Lead the view toward where the player is heading so they see
# what they walk into. When idle (direction == 0) the lead is
# zero and the camera eases back to centred.
lead_x = lead_y = 0.0
d = getattr(target, "direction", None)
if d is not None and d.magnitude() != 0:
lead_x = d.x * self.screen_w * self.LOOKAHEAD
lead_y = d.y * self.screen_h * self.LOOKAHEAD
# Offset that centres the target, plus the lead.
tx = target.rect.centerx - self.screen_w // 2 + lead_x
ty = target.rect.centery - self.screen_h // 2 + lead_y
if dt is None:
# Level load / teleport: snap so we never start mid-pan.
ox, oy = tx, ty
else:
# Ease toward that target. 1 - e^(-k·dt) is the same curve
# regardless of frame rate, unlike a raw lerp factor which
# speeds up / stutters when dt varies.
t = 1.0 - math.exp(-self.FOLLOW_SPEED * dt)
ox = self.offset.x + (tx - self.offset.x) * t
oy = self.offset.y + (ty - self.offset.y) * t
max_x = max(0, self.level_w - self.screen_w)
max_y = max(0, self.level_h - self.screen_h)
self.offset.x = max(0, min(ox, max_x))
self.offset.y = max(0, min(oy, max_y))
def shake(self, amount, duration):
"""Stack a shake event. Stronger / longer events take precedence
over weaker ones still in flight."""
if amount > self._shake_amount or duration > self._shake_time:
self._shake_amount = max(self._shake_amount, amount)
self._shake_time = max(self._shake_time, duration)
self._shake_total = max(self._shake_total, self._shake_time)
def update_shake(self, dt):
if self._shake_time <= 0:
self.shake_offset.update(0, 0)
self._shake_amount = 0.0
self._shake_total = 0.0
return
self._shake_time = max(0.0, self._shake_time - dt)
# Linear decay over the full duration so the jolt rings out
# rather than cutting off abruptly.
decay = (self._shake_time / self._shake_total
if self._shake_total > 0 else 0)
amp = self._shake_amount * decay
# Random direction each tick — coherent noise would be nicer
# but for short shakes pure random looks great.
self.shake_offset.update(
random.uniform(-amp, amp), random.uniform(-amp, amp))
def world_to_screen(self, rect):
return rect.move(
-int(self.offset.x + self.shake_offset.x),
-int(self.offset.y + self.shake_offset.y))
class LevelManager:
def __init__(self, width, height):
self.width = width
self.height = height
self.display_surface = pygame.display.get_surface()
# Walls live here for collision only; they are *not* drawn one by
# one — the whole static map is baked into ``map_surface`` once.
self.obstacle_sprites = pygame.sprite.Group()
self.entities = pygame.sprite.Group() # player + boss
self.player_sprites = pygame.sprite.Group() # just the player; boss aims here
self.enemy_sprites = pygame.sprite.Group()
self.projectile_sprites = pygame.sprite.Group()
self.interactable_sprites = pygame.sprite.Group() # spikes/levers/...
self.camera = Camera(width, height)
self.map_surface = None
# Identity of the loaded level. ``level_id`` is the stable
# string id from the catalog (used by save.py); ``level_title``
# and ``level_tagline`` come from the same entry and are now
# surfaced by ``loading_screen.LoadingScreen`` before the load.
# None until a level is loaded.
self.level_id = ""
self.level_title = ""
self.level_tagline = ""
self.boss_name = ""
self.boss_asset = None
self.boss_tint = None
self.player = None
self.boss = None
self.exit_rect = None
self.completed = False
self.failed = False
self.time = 0.0
# No contact damage during the fade-in window — the player can't
# react yet. Lifted from the retired in-level intro card; tied to
# the R8 fade so the grace lasts exactly as long as the visual
# transition.
self._first_frame_grace = 0.0
self._saved = False
# Escape-room state.
self.spikes = []
self.levers = []
self.plates = []
self.gates = []
self.triggers = [] # ordered union of levers+plates for ID assignment
self.props = []
self.key_item = None
self.needs_key = False
self.has_key = False
# Boss is spawned lazily: only once the player steps into the
# final hall, so it can't be whittled down through a doorway.
self.has_boss = False
self.boss_defeated = False
self.boss_spawn_pos = None
self.arena_rect = None
self._e_was_down = False
# Damage-edge tracking for screen shake.
self._last_player_hp = 0
self._last_boss_hp = None
self._last_ability_active = False
# Game-feel: particle bursts, transition fades, hit-pause clock.
self.particles = ParticleField()
self.fade = FadeState()
self._hit_pause = 0.0
self.title_font = theme.font(90)
self.hint_font = theme.font(36)
self.label_font = theme.font(28)
self.banner_font = theme.font(34)
self._vignette = self._build_vignette()
self._shadow_cache = {}
# --- setup -------------------------------------------------------
def _build_vignette(self):
"""Screen-sized darkened-edges overlay, built once."""
vig = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
cx, cy = self.width / 2, self.height / 2
max_d = (cx ** 2 + cy ** 2) ** 0.5
step = 8 # coarse blocks; cheap and the gradient still reads
vc = theme.shade(theme.BG, -12) # one tint below the map floor
for y in range(0, self.height, step):
for x in range(0, self.width, step):
d = (((x - cx) ** 2 + (y - cy) ** 2) ** 0.5) / max_d
a = int(150 * max(0.0, d - 0.55) / 0.45)
if a > 0:
vig.fill((*vc, min(160, a)),
(x, y, step, step))
return vig
def _bake_map(self, grid, cols, rows, floor_tile, wall_tile):
"""Render background + every floor/wall cell into one big
surface. Floor/wall use the named tileset PNGs (per-level
override, else the ``tileset`` defaults); if a name is missing
we fall back to the old procedural stone so a bad tile name
never blanks the level."""
level_w, level_h = cols * TILE_SIZE, rows * TILE_SIZE
surf = pygame.Surface((level_w, level_h)).convert()
# Base gradient so any gap reads as deep dungeon, not a void.
top = theme.shade(theme.BG, -2)
bot = theme.shade(theme.BG, -10)
for y in range(rows):
t = y / max(1, rows - 1)
col = tuple(int(top[i] + (bot[i] - top[i]) * t) for i in range(3))
surf.fill(col, (0, y * TILE_SIZE, level_w, TILE_SIZE))
wall_img = tileset.tile(wall_tile)
floor_img = tileset.tile(floor_tile)
wall_tex = TileTextures.get('wall')
floor_tex = TileTextures.get('floor')
floor_alt = TileTextures.get('floor_alt')
for r, row in enumerate(grid):
for c, cell in enumerate(row):
pos = (c * TILE_SIZE, r * TILE_SIZE)
if cell[0] == 'W':
surf.blit(wall_img or wall_tex, pos)
elif floor_img is not None:
surf.blit(floor_img, pos)
else:
surf.blit(floor_alt if (r + c) % 2 else floor_tex, pos)
return surf
def load_level(self, entry_or_id, char_type="c_wiz"):
"""Load a level from a :class:`level_catalog.LevelEntry` or its
id string. Returns True on success, False if the level could
not be loaded (unknown id, missing or empty file) — the caller
must not switch into the game state on a False return, since
``self.player`` stays None and ``update`` would then crash."""
entry = (entry_or_id if hasattr(entry_or_id, 'file')
else level_catalog.find(entry_or_id))
if entry is None:
print(f"the-way-out: unknown level {entry_or_id!r}")
return False
self.obstacle_sprites.empty()
self.entities.empty()
self.player_sprites.empty()
self.enemy_sprites.empty()
self.projectile_sprites.empty()
self.interactable_sprites.empty()
self.player = None
self.boss = None
self.exit_rect = None
self.completed = False
self.failed = False
self.time = 0.0
self._first_frame_grace = FADE_IN_TIME
self.level_id = entry.id
self.level_title = entry.title
self.level_tagline = entry.tagline
self._saved = False
self.spikes = []
self.levers = []
self.plates = []
self.gates = []
self.triggers = []
self.props = []
self.key_item = None
self.needs_key = False
self.has_key = False
self.has_boss = False
self.boss_defeated = False
self.boss_spawn_pos = None
self.arena_rect = None
self._e_was_down = False
self._last_boss_hp = None
self._last_ability_active = False
self.particles.clear()
self._hit_pause = 0.0
self.fade.start_in(FADE_IN_TIME)
# Pick the general for this level deterministically from the
# level id. zlib.crc32 (not Python's built-in hash) so the
# choice is stable across game restarts, not just retries —
# PYTHONHASHSEED randomises hash() per process.
seed = zlib.crc32(entry.id.encode("utf-8"))
self.boss_name, self.boss_asset, self.boss_tint = \
BOSS_ROSTER[seed % len(BOSS_ROSTER)]
try:
with open(entry.file) as f:
raw = [line.rstrip('\n') for line in f if line.strip()]
except FileNotFoundError:
print(f"Level file {entry.file} not found!")
return False
# An empty or all-whitespace file would make ``cols = max(...)``
# below raise ValueError; bail the same way as a missing file so
# a stray empty .txt in custom_levels can't crash the game.
if not raw:
print(f"Level file {entry.file} is empty!")
return False
# Each row is a list of cell tokens: a single char in legacy
# dense rows, or a letter (+ optional variant digits) in spaced
# rows. Short rows pad out with wall.
grid = [_split_cells(line) for line in raw]
rows = len(grid)
cols = max(len(r) for r in grid)
for row in grid:
row.extend('W' * (cols - len(row)))
level_w, level_h = cols * TILE_SIZE, rows * TILE_SIZE
self.camera.set_level_size(level_w, level_h)
# Per-level tileset override, else the global default.
floor_tile = entry.floor_tile or tileset.FLOOR_TILE
wall_tile = entry.wall_tile or tileset.WALL_TILE
self.map_surface = self._bake_map(
grid, cols, rows, floor_tile, wall_tile)
player_pos = (TILE_SIZE, TILE_SIZE)
gate_cells = []
for r, row in enumerate(grid):
for c, cell in enumerate(row):
x, y = c * TILE_SIZE, r * TILE_SIZE
ch = cell[0]
if ch == 'W':
# Collision only — never individually drawn.
Tile((x, y), [self.obstacle_sprites], 'wall')
elif ch == 'P':
player_pos = (x, y)
elif ch == 'X':
self.exit_rect = pygame.Rect(
x, y, TILE_SIZE, TILE_SIZE)
elif ch == 'B':
self.boss_spawn_pos = (x, y)
self.has_boss = True
elif ch == 'S':
self.spikes.append(
Spikes((x, y), [self.interactable_sprites]))
elif ch == 'L':
# gate_group filled in once all triggers are known;
# _pair_id is the explicit digit (or None = order).
lever = Lever(
(x, y), [self.interactable_sprites], None)
lever._pair_id = _pair_id(cell)
self.levers.append(lever)
self.triggers.append(lever)
elif ch == 'Y':
plate = PressurePlate(
(x, y), [self.interactable_sprites], None)
plate._pair_id = _pair_id(cell)
self.plates.append(plate)
self.triggers.append(plate)
elif ch == 'G':
gate_cells.append((r, c, _pair_id(cell)))
elif ch == 'K':
self.key_item = KeyItem(
(x, y), [self.interactable_sprites])
self.needs_key = True
elif ch in ENEMIES:
# Generic enemies spawn now (the boss alone stays
# lazy). target wired once the player exists.
enemy = ENEMIES[ch](x, y, self.obstacle_sprites)
self.enemy_sprites.add(enemy)
self.entities.add(enemy)
elif ch in PROP_CHARS:
category = PROP_CHARS[ch]
solid = tileset.is_solid(category)
self.props.append(Prop(
(x, y),
tileset.sprite(category, _cell_variant(cell)),
solid,
self.obstacle_sprites if solid else None))
# Flood-fill connected 'G' cells into gate panels. Panels and
# triggers (levers + plates, in reading order) get matching
# group ids — the i-th trigger opens the i-th panel. Author
# them in the order you want paired.
self._build_gates(gate_cells)
# Triggers with an explicit digit pair by that digit
# (namespaced so it can never collide with the reading-order
# fallback); the rest keep the legacy sequential pairing, so a
# level with no digits at all behaves exactly as before.
seq = 0
for trig in self.triggers:
if trig._pair_id is None:
trig.gate_group = ('seq', seq)
seq += 1
else:
trig.gate_group = ('pair', trig._pair_id)
player_class = CHARACTERS.get(char_type, Wizard)
self.player = player_class(
player_pos[0], player_pos[1], self.obstacle_sprites)
self.entities.add(self.player)
self.player_sprites.add(self.player)
self.player.projectile_group = self.projectile_sprites
self.player.projectile_targets = self.enemy_sprites
# Only generic enemies are in the group now (boss is lazy).
for enemy in self.enemy_sprites:
enemy.target = self.player
self._last_player_hp = self.player.hp
if self.has_boss:
self.arena_rect = self._compute_arena_rect(grid)
self.camera.follow(self.player)
# Per-level track (manifest "music" / "default" for custom);
# play_music degrades to silence if the file is absent.
audio.play_music(entry.music)
return True
def _build_gates(self, gate_cells):
"""Group adjacent gate cells (4-connectivity) into panels. A
panel with an explicit digit on any of its cells (``G2``) pairs
by that digit; panels with no digit fall back to reading order,
so a level using no digits is grouped exactly as the legacy
code did (the key is a tuple now, but ``_open_gates_for`` only
ever compares for equality)."""
pid_by_cell = {(r, c): pid for r, c, pid in gate_cells}
coords = [(r, c) for r, c, _ in gate_cells]
remaining = set(coords)
panels = []
for cell in coords: # already in r,c reading order
if cell not in remaining:
continue
comp, q = [], deque([cell])
remaining.discard(cell)
while q:
r, c = q.popleft()
comp.append((r, c))
for nr, nc in ((r + 1, c), (r - 1, c),
(r, c + 1), (r, c - 1)):
if (nr, nc) in remaining:
remaining.discard((nr, nc))
q.append((nr, nc))
panels.append(comp)
seq = 0
for comp in panels:
digits = [pid_by_cell[cell] for cell in comp
if pid_by_cell[cell] is not None]
if digits:
group = ('pair', digits[0])
else:
group = ('seq', seq)
seq += 1
for r, c in comp:
self.gates.append(Gate(
(c * TILE_SIZE, r * TILE_SIZE),
self.interactable_sprites, self.obstacle_sprites,
group))
def _compute_arena_rect(self, grid):
"""Bounding box of the room containing the boss, found by
flood-fill from the boss tile (walls *and* shut gates block it).
Stepping into this box is what triggers the boss to spawn."""
bx, by = self.boss_spawn_pos
start = (by // TILE_SIZE, bx // TILE_SIZE)
seen = {start}
q = deque([start])
while q:
r, c = q.popleft()
for nr, nc in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
if (0 <= nr < len(grid) and 0 <= nc < len(grid[nr])
and (nr, nc) not in seen
and grid[nr][nc][0] not in ('W', 'G')):
seen.add((nr, nc))
q.append((nr, nc))
cs = [c for _, c in seen]
rs = [r for r, _ in seen]
return pygame.Rect(
min(cs) * TILE_SIZE, min(rs) * TILE_SIZE,
(max(cs) - min(cs) + 1) * TILE_SIZE,
(max(rs) - min(rs) + 1) * TILE_SIZE)
# --- update ------------------------------------------------------
def update(self, dt):
self.time += dt
if self._first_frame_grace > 0:
self._first_frame_grace = max(
0.0, self._first_frame_grace - dt)
self.camera.update_shake(dt)
self.particles.update(dt)
self.fade.update(dt)
if self._hit_pause > 0:
self._hit_pause = max(0.0, self._hit_pause - dt)
if self.completed or self.failed:
return
# Hit-pause freezes only the gameplay actors — the camera, the
# particles, the fade and the timers all kept ticking above so
# the impact still reads as a snap, not a hang.
if self._hit_pause > 0:
return
# Wizard's Slow ability scales the per-frame dt of every enemy,
# the boss and enemy projectiles. self.entities is exactly
# {player} ∪ enemy_sprites, so dispatch per actor instead of one
# group update — the player and his own shots keep raw dt.
slow = (isinstance(self.player, Wizard)
and self.player.ability_active)
dt_eff = dt * SLOW_SCALE if slow else dt
self.player.update(dt)
for enemy in self.enemy_sprites: # generic enemies + boss
enemy.update(dt_eff)
for proj in self.projectile_sprites:
proj.update(dt if proj.owner == 'player' else dt_eff)
# Spikes are a world clock — the Wizard slows enemies, not the
# dungeon — so interactables keep raw dt.
self.interactable_sprites.update(dt)
self.camera.follow(self.player, dt)
# Ability rising edge — one particle burst at activation.
if (self.player is not None and self.player.ability_active
and not self._last_ability_active):
self._emit_ability_burst()
if self.player is not None:
self._last_ability_active = self.player.ability_active
# Boss only materialises once you actually enter the final hall.
if (self.has_boss and self.boss is None and not self.boss_defeated
and self.arena_rect is not None
and self.player.hitbox.colliderect(self.arena_rect)):
bx, by = self.boss_spawn_pos
self.boss = Boss(
bx, by, self.obstacle_sprites, target=self.player,
projectile_group=self.projectile_sprites,
projectile_targets=self.player_sprites,
display_name=self.boss_name,
asset_folder=self.boss_asset,
identity_tint=self.boss_tint)
self.entities.add(self.boss)
self.enemy_sprites.add(self.boss)
self._last_boss_hp = self.boss.hp
self._handle_levers()
self._handle_plates(dt)
self._handle_hazards()
self._handle_key()
if (self.boss is not None and self.boss.hp > 0
and self._first_frame_grace <= 0
and self.player.invuln_timer <= 0
and self.boss.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(BOSS_TOUCH_DAMAGE)
self.player.invuln_timer = PLAYER_INVULN_TIME
# Generic enemies: clear the dead, then apply contact damage.
# The boss keeps its own separate touch/death path (above and
# below) — the two are intentionally not merged.
for en in [e for e in self.enemy_sprites if e is not self.boss]:
if en.hp <= 0:
self._emit_enemy_death(en)
audio.play("enemy_death")
en.kill()
continue
# Edge-detect each enemy's HP so a projectile hit puffs even
# if it doesn't kill.
prev = getattr(en, '_last_hp_for_fx', en.max_hp)
if en.hp < prev:
self._emit_enemy_hit(en)
en._last_hp_for_fx = en.hp
if (self._first_frame_grace <= 0
and self.player.invuln_timer <= 0
and en.hitbox.colliderect(self.player.hitbox)):
self.player.take_damage(en.touch_damage)
self.player.invuln_timer = PLAYER_INVULN_TIME
# Damage / death feedback — compare to last frame so spikes,
# projectiles and contact damage all shake the camera uniformly.
if (self.player is not None
and self.player.hp < self._last_player_hp):
self.camera.shake(4, 0.18)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_PLAYER_HIT)
self._emit_player_hit()
if self.player is not None:
self._last_player_hp = self.player.hp
if self.boss is not None:
if (self._last_boss_hp is not None
and self.boss.hp < self._last_boss_hp):
self.camera.shake(2, 0.08)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_BOSS_HIT)
self._emit_boss_hit()
audio.play("boss_hit")
self._last_boss_hp = self.boss.hp
if self.boss is not None and self.boss.hp <= 0:
self.camera.shake(12, 0.7)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_BOSS_DEATH)
self._emit_boss_death()
audio.play("boss_death")
self.boss.kill()
self.boss = None
self.boss_defeated = True
if self.player is not None and self.player.hp <= 0:
self.camera.shake(8, 0.4)
self._hit_pause = max(self._hit_pause, HIT_PAUSE_PLAYER_DEATH)
self.fade.start_out(FADE_OUT_TIME)
audio.stop_music()
audio.play("player_death")
self.failed = True
return
boss_clear = (not self.has_boss) or self.boss_defeated
have_key = (not self.needs_key) or self.has_key
if (self.exit_rect is not None and boss_clear and have_key
and self.player is not None
and self.player.hitbox.colliderect(self.exit_rect)):
self.completed = True
if not self._saved:
save.mark_complete(self.level_id)
save.record_time(self.level_id, self.time)
audio.stop_music()
audio.play("level_complete")
self.fade.start_out(FADE_OUT_TIME)
self._saved = True
# --- effect emitters --------------------------------------------
def _emit_player_hit(self):
cx, cy = self.player.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_PLAYER_HIT, (255, 80, 80),
speed=320, life=0.40, size=4, drag=3.0)
def _emit_enemy_hit(self, enemy):
cx, cy = enemy.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ENEMY_HIT, (255, 255, 255),
speed=260, life=0.30, size=3, drag=3.5)
def _emit_enemy_death(self, enemy):
cx, cy = enemy.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ENEMY_DEATH, (240, 240, 240),
speed=300, life=0.55, size=4, drag=2.5)
def _emit_boss_hit(self):
cx, cy = self.boss.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_BOSS_HIT, (255, 220, 120),
speed=260, life=0.35, size=5, drag=3.0)
def _emit_boss_death(self):
cx, cy = self.boss.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_BOSS_DEATH, (255, 210, 90),
speed=520, life=0.95, size=6, size_jitter=3, drag=1.8)
# White core puff in the same place.
self.particles.burst(
cx, cy, PARTICLES_BOSS_DEATH // 2, (255, 255, 255),
speed=320, life=0.7, size=4, drag=2.5)
def _emit_ability_burst(self):
color = _ABILITY_COLORS.get(type(self.player), (255, 255, 255))
cx, cy = self.player.hitbox.center
self.particles.burst(
cx, cy, PARTICLES_ABILITY, color,
speed=360, life=0.55, size=4, drag=2.2)
def _handle_levers(self):
"""Edge-detected 'E' near a lever pulls it and opens its gate."""
e_down = pygame.key.get_pressed()[pygame.K_e]
pressed = e_down and not self._e_was_down
self._e_was_down = e_down
if not pressed:
return
pc = pygame.math.Vector2(self.player.hitbox.center)
for lever in self.levers:
if lever.activated:
continue
if pc.distance_to(lever.hitbox.center) <= LEVER_REACH:
if lever.use():
self._open_gates_for(lever.gate_group)
break
def _handle_plates(self, dt):
"""Plates fire when the player has stood on them for the
trigger delay; until then the charge bleeds off the moment the
player steps off, so you can't sneak by with a quick brush."""
for plate in self.plates:
if plate.activated:
continue
if plate.hitbox.colliderect(self.player.hitbox):
if plate.step_on(dt):
self._open_gates_for(plate.gate_group)
else:
plate.step_off()
def _open_gates_for(self, group_id):
for gate in self.gates:
if gate.group_id == group_id:
gate.open()
def _handle_hazards(self):
# No damage during the fade-in — the player can't react yet.
if self._first_frame_grace > 0 or self.player.invuln_timer > 0:
return
for sp in self.spikes:
if sp.deadly and sp.hitbox.colliderect(self.player.hitbox):
self.player.take_damage(SPIKE_DAMAGE)
self.player.invuln_timer = PLAYER_INVULN_TIME
break
def _handle_key(self):
if (self.key_item is not None
and self.player.hitbox.colliderect(self.key_item.hitbox)):
self.has_key = True
self.key_item.kill()
self.key_item = None
audio.play("key_pickup")
# --- draw --------------------------------------------------------
def _shadow(self, w):
if w not in self._shadow_cache:
s = pygame.Surface((w, w // 3), pygame.SRCALPHA)
pygame.draw.ellipse(s, (0, 0, 0, 90), s.get_rect())
self._shadow_cache[w] = s
return self._shadow_cache[w]
def _blit_world(self, screen, image, rect):
"""Blit a world-space sprite at the camera offset, skipping it
entirely when it is off screen."""
r = self.camera.world_to_screen(rect)
if (r.right < 0 or r.left > self.width
or r.bottom < 0 or r.top > self.height):
return None
screen.blit(image, r)
return r
def _draw_interactables(self, screen):
# Tileset furniture/decoration, then floor/wall props — all
# drawn under the entities so the player walks visually on top
# of spikes and in front of furniture.
for pr in self.props:
self._blit_world(screen, pr.image, pr.rect)
for sp in self.spikes:
self._blit_world(screen, sp.image, sp.rect)
for plate in self.plates:
self._blit_world(screen, plate.image, plate.rect)
for gate in self.gates:
self._blit_world(screen, gate.image, gate.rect)
pc = pygame.math.Vector2(self.player.hitbox.center) \
if self.player is not None else None
for lever in self.levers:
r = self._blit_world(screen, lever.image, lever.rect)
if (r is not None and not lever.activated and pc is not None
and pc.distance_to(lever.hitbox.center) <= LEVER_REACH):
self._draw_key_prompt(screen, r.centerx, r.top - 14, "E")
if self.key_item is not None:
bob = int(math.sin(self.key_item.t * 3.0) * 6)
r = self.camera.world_to_screen(self.key_item.rect)
r.y += bob
if not (r.right < 0 or r.left > self.width):
pulse = 0.5 + 0.5 * abs((self.time * 1.8) % 2 - 1)
gr = int(TILE_SIZE * (0.5 + 0.25 * pulse))
glow = pygame.Surface((gr * 2, gr * 2), pygame.SRCALPHA)
pygame.draw.circle(glow, (250, 210, 90,
int(60 + 70 * pulse)),
(gr, gr), gr)
screen.blit(glow, glow.get_rect(center=r.center))
screen.blit(self.key_item.image, r)
def _draw_key_prompt(self, screen, cx, cy, text):
surf = self.label_font.render(text, True, theme.INK)
box = surf.get_rect(center=(cx, cy)).inflate(20, 12)
panel = pygame.Surface(box.size, pygame.SRCALPHA)
panel.fill((*theme.BG, 210))
pygame.draw.rect(panel, theme.LINE_C,
panel.get_rect(), 2, border_radius=6)
screen.blit(panel, box)
screen.blit(surf, surf.get_rect(center=box.center))
def _draw_world_sprite(self, screen, sprite):
r = self.camera.world_to_screen(sprite.rect)
sh = self._shadow(int(sprite.hitbox.width * 1.2))
screen.blit(sh, sh.get_rect(
center=(r.centerx, self.camera.world_to_screen(
sprite.hitbox).bottom - 6)))
screen.blit(sprite.image, r)
def draw(self, screen):
if self.map_surface is None:
screen.fill(theme.BG)
return
sox = self.camera.offset.x + self.camera.shake_offset.x
soy = self.camera.offset.y + self.camera.shake_offset.y
screen.blit(self.map_surface, (0, 0),
(int(sox), int(soy), self.width, self.height))
self._draw_interactables(screen)
self._draw_exit(screen)
# Y-sort so the player walks correctly in front of / behind the
# boss and any overlap reads right.
for sprite in sorted(self.entities, key=lambda s: s.hitbox.bottom):
self._draw_world_sprite(screen, sprite)
# Penguin's shield has no sprite change of its own — ring the
# player while it holds.
if (isinstance(self.player, Penguin)
and self.player.ability_active):
self._draw_shield_aura(screen)
for proj in self.projectile_sprites:
screen.blit(proj.image, self.camera.world_to_screen(proj.rect))
# Particles sit on the playfield (under HUD, under vignette so
# the edges don't look painted on top of darkness).
self.particles.draw(
screen,
(self.camera.offset.x + self.camera.shake_offset.x,
self.camera.offset.y + self.camera.shake_offset.y),
self.width, self.height)
screen.blit(self._vignette, (0, 0))
if self.player is not None and not self.completed:
self.draw_player_health(screen)
self.draw_ability_meter(screen)
if self.needs_key:
self.draw_key_status(screen)
if self.boss is not None:
self.draw_boss_health(screen)
self._draw_objective(screen)
if self.completed:
self.draw_end_overlay(
screen, "You found the way out!", theme.SUCCESS)
elif self.failed:
self.draw_end_overlay(
screen, "You were defeated...", theme.FAIL)
# Transition fade covers everything (HUD + end overlay too) so
# the screen reads as a single sealed frame at the moment of cut.
self.fade.draw(screen, self.width, self.height)
def _draw_exit(self, screen):
if self.exit_rect is None:
return
r = self.camera.world_to_screen(self.exit_rect)
if r.right < 0 or r.left > self.width:
return
open_ = (((not self.has_boss) or self.boss_defeated)
and ((not self.needs_key) or self.has_key))
pulse = 0.5 + 0.5 * abs((self.time * 1.6) % 2 - 1)
frame = theme.SUCCESS if open_ else theme.FAIL
glow_c = theme.shade(frame, +10)
# Soft glow halo around the doorway.
gr = int(TILE_SIZE * (0.9 + 0.4 * pulse))
glow = pygame.Surface((gr * 2, gr * 2), pygame.SRCALPHA)
pygame.draw.circle(glow, (*glow_c, int(70 + 80 * pulse)),
(gr, gr), gr)
screen.blit(glow, glow.get_rect(center=r.center))
# Door panel.
pygame.draw.rect(screen, theme.shade(theme.BG, +6),
r.inflate(-6, -6), border_radius=6)
pygame.draw.rect(screen, frame, r.inflate(-6, -6), 4,
border_radius=6)
if open_:
inner = r.inflate(-22, -18)
a = int(120 + 110 * pulse)
s = pygame.Surface(inner.size, pygame.SRCALPHA)
s.fill((*glow_c, a))
screen.blit(s, inner)
else:
for i in range(1, 4): # bars -> "sealed"
bx = r.left + i * r.width // 4
pygame.draw.line(screen, frame,
(bx, r.top + 10), (bx, r.bottom - 10), 5)
# --- HUD ---------------------------------------------------------
def _bar(self, screen, x, y, w, h, ratio, color):
theme.draw_bar(screen, pygame.Rect(x, y, w, h), ratio, color)
def draw_player_health(self, screen):
w, h = 460, 34
x, y = 40, self.height - h - 40
self._bar(screen, x, y, w, h,
self.player.hp / self.player.max_hp, theme.ACCENT)
label = self.label_font.render(
f"HP {int(self.player.hp)}/{self.player.max_hp}",
True, theme.INK)
screen.blit(label, (x, y - 36))
def draw_ability_meter(self, screen):
"""Small ring next to the HP showing the character's signature
ability. Filled = ready (Shift fires it); bright = active right
now; shrinking arc = cooldown left."""
if self.player is None:
return
radius = 22
cx = 40 + 460 + 36 + radius
cy = self.height - 40 - 34 // 2 - 6
active = self.player.ability_active
ready = (not active and self.player.ability_cooldown_timer == 0)
# Backplate
pygame.draw.circle(screen, theme.shade(theme.BG, -10),
(cx, cy), radius + 4)
pygame.draw.circle(screen, theme.LINE_C, (cx, cy), radius)
if active:
pygame.draw.circle(screen, theme.SUCCESS, (cx, cy), radius - 4)
elif ready:
pygame.draw.circle(screen, theme.ACCENT, (cx, cy), radius - 4)
else:
ratio = 1.0 - (self.player.ability_cooldown_timer
/ max(0.001, self.player.ABILITY_COOLDOWN))
# Draw filled wedge from -pi/2 sweeping clockwise.
ring = pygame.Surface((radius * 2 + 4, radius * 2 + 4),
pygame.SRCALPHA)
rc = (radius + 2, radius + 2)
# Approximate wedge with a polygon for cheap rendering.
pts = [rc]
steps = max(2, int(36 * ratio))
for i in range(steps + 1):
ang = -math.pi / 2 + (2 * math.pi) * (i / 36)
pts.append((rc[0] + math.cos(ang) * (radius - 4),
rc[1] + math.sin(ang) * (radius - 4)))
if len(pts) >= 3:
pygame.draw.polygon(ring, theme.MUTED, pts)
screen.blit(ring, (cx - radius - 2, cy - radius - 2))
pygame.draw.circle(screen, theme.shade(theme.BG, -6),
(cx, cy), radius, 2)
lit = active or ready
col = theme.INK if lit else theme.MUTED
self._draw_ability_glyph(
screen, cx, cy, self.player.ABILITY_GLYPH, col)
cap = self.label_font.render(
self.player.ABILITY_NAME if lit else "...", True, col)
screen.blit(cap, (cx + radius + 14, cy - 14))
def _draw_ability_glyph(self, screen, cx, cy, kind, col):
"""Tiny vector icon for the ability meter — one per glyph kind
used by the playable roster."""
if kind == "slow": # hourglass
pygame.draw.polygon(screen, col, [
(cx - 7, cy - 9), (cx + 7, cy - 9), (cx, cy)])
pygame.draw.polygon(screen, col, [
(cx - 7, cy + 9), (cx + 7, cy + 9), (cx, cy)])
elif kind == "shield":
pygame.draw.polygon(screen, col, [
(cx, cy - 10), (cx + 8, cy - 5), (cx + 8, cy + 2),
(cx, cy + 10), (cx - 8, cy + 2), (cx - 8, cy - 5)], 2)
elif kind == "rapid": # double chevron
for dx in (-4, 2):
pygame.draw.lines(screen, col, False, [
(cx + dx - 3, cy - 7), (cx + dx + 4, cy),
(cx + dx - 3, cy + 7)], 2)
elif kind == "sprint": # wedge + speed lines
pygame.draw.polygon(screen, col, [
(cx + 1, cy - 8), (cx + 9, cy), (cx + 1, cy + 8)])
for i, dy in enumerate((-5, 0, 5)):
pygame.draw.line(screen, col,
(cx - 9, cy + dy),
(cx - 2 - i, cy + dy), 2)
else: # "dash" — lightning bolt
pygame.draw.polygon(screen, col, [
(cx - 6, cy - 9), (cx + 3, cy - 2),
(cx - 1, cy - 1), (cx + 6, cy + 9),
(cx - 3, cy + 2), (cx + 1, cy + 1),
])
def _draw_shield_aura(self, screen):
"""Pulsing ring around the player while Penguin's shield holds."""
r = self.camera.world_to_screen(self.player.hitbox)
pulse = 0.5 + 0.5 * abs((self.time * 2.4) % 2 - 1)
radius = int(self.player.hitbox.width * 0.7 + 8 * pulse)
aura = pygame.Surface((radius * 2, radius * 2), pygame.SRCALPHA)
pygame.draw.circle(aura, (*theme.ACCENT, int(40 + 50 * pulse)),
(radius, radius), radius)
pygame.draw.circle(aura, (*theme.ACCENT, int(120 + 80 * pulse)),
(radius, radius), radius, 3)
screen.blit(aura, aura.get_rect(center=r.center))
def draw_key_status(self, screen):
"""Small chip by the HP bar: dim when the key is still out
there, lit gold once it's in hand."""
x, y = 40, self.height - 34 - 40 - 64
got = self.has_key
col = theme.ACCENT if got else theme.MUTED
cx = x + 18
pygame.draw.circle(screen, col, (cx, y + 14), 11)
pygame.draw.circle(screen, theme.BG, (cx, y + 14), 5)
pygame.draw.rect(screen, col, (cx - 4, y + 22, 8, 22))
pygame.draw.rect(screen, col, (cx + 4, y + 34, 9, 5))
label = self.label_font.render(
"KEY" if got else "KEY ?", True,
theme.ACCENT if got else theme.MUTED)
screen.blit(label, (x + 44, y + 8))
def draw_boss_health(self, screen):
w, h = 900, 40
x = self.width // 2 - w // 2
y = 56
# Two-tone fill: phase-2 portion overlays in a brighter shade,
# so you read at a glance how close you are to the next phase.
ratio = self.boss.hp / self.boss.max_hp
self._bar(screen, x, y, w, h, ratio, theme.ACCENT)
# Phase divider line at 50%
div_x = x + w // 2
pygame.draw.line(screen, theme.LINE_C,
(div_x, y - 2), (div_x, y + h + 2), 2)
# State badge — useful during dev, fun for the player too.
state_text, badge_col = _BOSS_BADGE.get(
self.boss.state, ("", theme.INK))
if state_text:
badge = self.label_font.render(state_text, True, badge_col)
screen.blit(badge, badge.get_rect(
center=(self.width // 2, y + h + 24)))
label = self.label_font.render(
self.boss_name.upper(), True, theme.INK)
screen.blit(label, label.get_rect(center=(self.width // 2, y - 24)))
def _draw_objective(self, screen):
if self.completed or self.failed:
return
if self.boss is not None:
text, color = f"Defeat {self.boss_name}!", theme.FAIL
elif self.has_boss and not self.boss_defeated:
if any(not lv.activated for lv in self.levers):
text, color = ("Pull the levers — the way is sealed",
theme.ACCENT)
elif any(not p.activated for p in self.plates):
text, color = ("Step on the plates — the way is sealed",
theme.ACCENT)
else:
text, color = (f"{self.boss_name} guards the final hall",
theme.ACCENT)
elif any(not p.activated for p in self.plates):
text, color = ("Step on the pressure plates",
theme.ACCENT)
elif self.needs_key and not self.has_key:
text, color = ("Find the key to the way out",
theme.ACCENT)
else:
text, color = "The way out is open — escape!", theme.SUCCESS
surf = self.banner_font.render(text, True, color)
rect = surf.get_rect(center=(self.width // 2, self.height - 70))
bg = rect.inflate(40, 20)
panel = pygame.Surface(bg.size, pygame.SRCALPHA)
panel.fill((*theme.BG, 150))
screen.blit(panel, bg)
screen.blit(surf, rect)
def draw_end_overlay(self, screen, text, color):
overlay = pygame.Surface(screen.get_size())
overlay.set_alpha(210)
overlay.fill(theme.BG)
screen.blit(overlay, (0, 0))
cx = screen.get_width() // 2
cy = screen.get_height() // 2
# Caps title + thin centred separator — same language as the
# menu screens' theme.draw_title, but kept in the state colour
# (SUCCESS / FAIL) and centred rather than pinned to the top.
title = self.title_font.render(text.upper(), True, color)
t_rect = title.get_rect(center=(cx, cy - 60))
screen.blit(title, t_rect)
ly = t_rect.bottom + 16
pygame.draw.line(screen, theme.LINE_C,
(cx - 170, ly), (cx + 170, ly), 2)
d = theme.HINT_DOT
hint = self.hint_font.render(
f"R retry {d} Enter or Esc back to menu",
True, theme.MUTED)
screen.blit(hint, hint.get_rect(center=(cx, cy + 50)))