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