import pygame import audio import level_catalog import save import theme # Palette, font cache and the shared title / back-hint / hover # primitives. Bound to module-private aliases to match the internal # naming used by the screens below. from theme import ( ACCENT, BG, DONE_C, INK, LINE_C, MUTED, SEL_C, TITLE_C, measure, ) from theme import draw_back_hint as _draw_back_hint from theme import draw_title as _draw_title from theme import hover_marker as _hover_marker from units import CHARACTER_INFO from version import VERSION class MainMenu: def __init__(self, width, height): self.width = width self.height = height self.font = theme.font(46) self.title_font = theme.font(78) self.small_font = theme.font(24) # Set by main.py's update flow; drawn as a toast under the # title. status_until is the wall-clock time (seconds, from # pygame.time.get_ticks) at which main.py should clear the # status — animated phases (the dots) set it to None to opt out # of the auto-dismiss, results set it to ~4 s out. self.status = "" self.status_until = None self.buttons = [ {"text": "Levels", "rect": None, "action": "lvls"}, {"text": "Editor", "rect": None, "action": "editor"}, {"text": "Characters", "rect": None, "action": "chars"}, {"text": "Settings", "rect": None, "action": "settings"}, {"text": "Update", "rect": None, "action": "update"}, {"text": "Quit", "rect": None, "action": "quit"} ] center_x = width // 2 start_y = height // 2 - 100 for i, btn in enumerate(self.buttons): rect = measure(self.font, btn["text"]) rect.center = (center_x, start_y + i * 90) btn["rect"] = rect self.title_center_y = height // 2 - 210 # Toast sits between title-bottom and first-button-top so it # cannot collide with QUIT or the tip line at any resolution. title_bottom = (self.title_center_y + self.title_font.get_height() // 2) first_btn_top = self.buttons[0]["rect"].top self._toast_y = (title_bottom + first_btn_top) // 2 # Ambient background: scrolling floor + wandering sprites + # vignette. Replaces the prior PixelDust on the title screen. self.scene = theme.MenuScene(width, height, seed=7) # Playable avatar overlay (AC-style loading screen). The # wandering MenuScene actors stay non-interactive — they have no # hitbox and aren't in any group, so the player walks through # them and shots can't hit them. Bounds are enforced by a # screen-rect clamp in update(); walls/targets are empty groups. self._character_classes = { key: cls for key, cls, _label, _tag in CHARACTER_INFO} self.world_obstacles = pygame.sprite.Group() self.projectile_targets = pygame.sprite.Group() self.projectile_group = pygame.sprite.Group() self.player = None self._spawn_player("c_wiz") def _spawn_player(self, key): cls = self._character_classes.get(key) if cls is None: return # Bottom-center, well clear of the title and buttons. The clamp # in update() keeps the player on screen no matter the spawn. spawn_x = self.width // 2 spawn_y = self.height - 220 self.player = cls(spawn_x, spawn_y, self.world_obstacles) # Center the sprite on the requested spawn point. self.player.rect.center = (spawn_x, spawn_y) self.player.pos.update(self.player.rect.topleft) self.player.hitbox.center = self.player.rect.center self.player.projectile_group = self.projectile_group self.player.projectile_targets = self.projectile_targets # Left mouse must stay reserved for clicking menu buttons. self.player.attack_mouse_enabled = False self.current_character_key = key def set_character(self, key): """Rebuild the menu avatar when CharacterMenu picks a new one.""" if key == getattr(self, "current_character_key", None): return for shot in list(self.projectile_group): shot.kill() self._spawn_player(key) def update(self, dt): if self.player is None: return self.player.update(dt) self.projectile_group.update(dt) # Clamp the player to the screen rect. The level's wall-collide # path is unavailable here (no walls), so cap pos/rect/hitbox # together to keep the sprite, draw rect and shot-spawn point in # sync. rect = self.player.rect max_x = self.width - rect.width max_y = self.height - rect.height if self.player.pos.x < 0: self.player.pos.x = 0 elif self.player.pos.x > max_x: self.player.pos.x = max_x if self.player.pos.y < 0: self.player.pos.y = 0 elif self.player.pos.y > max_y: self.player.pos.y = max_y rect.topleft = (round(self.player.pos.x), round(self.player.pos.y)) self.player.hitbox.center = rect.center # Prune shots that left the screen; Projectile.update would # eventually drop them via PROJECTILE_LIFETIME, but clearing # off-screen orbs early keeps the group tight. screen_rect = pygame.Rect(0, 0, self.width, self.height) for shot in list(self.projectile_group): if not screen_rect.colliderect(shot.rect): shot.kill() def set_status(self, text, ttl=4.0): """Set the toast text. ``ttl`` is seconds until main.py clears it; pass ``None`` for a status that should persist (animated phases overwrite themselves every frame instead).""" self.status = text if ttl is None: self.status_until = None else: self.status_until = pygame.time.get_ticks() / 1000.0 + ttl def clear_status(self): self.status = "" self.status_until = None def draw(self, screen): # No screen.fill(BG): MenuScene.draw immediately overdraws the # whole screen with 4 opaque slab blits, so the fill is dead # work (submenus keep theirs — PixelDust is sparse, does not # cover the screen). self.scene.draw(screen) # AC-style loading-screen overlay: shots under the player, both # above the scene and below the title/buttons so the UI stays # readable and clickable. self.projectile_group.draw(screen) if self.player is not None: screen.blit(self.player.image, self.player.rect) mouse_pos = pygame.mouse.get_pos() title = theme.text_surface(self.title_font, "THE WAY OUT", TITLE_C) screen.blit(title, title.get_rect( center=(self.width // 2, self.title_center_y))) if self.status: theme.draw_toast( screen, self.status, self.small_font, center_x=self.width // 2, center_y=self._toast_y) for btn in self.buttons: is_hovered = btn["rect"].collidepoint(mouse_pos) # Thin separator above the last item (Quit) to set it apart. if btn["action"] == "quit": ly = btn["rect"].top - 22 pygame.draw.line(screen, LINE_C, (self.width // 2 - 90, ly), (self.width // 2 + 90, ly), 2) color = ACCENT if is_hovered else INK text_surf = theme.text_surface(self.font, btn["text"], color) screen.blit(text_surf, btn["rect"]) if is_hovered: _hover_marker(screen, btn["rect"]) d = theme.HINT_DOT tip = theme.text_surface( self.small_font, f"WASD/Arrows move + aim {d} Space shoot {d} " f"Shift ability {d} E use", MUTED) screen.blit(tip, tip.get_rect( center=(self.width // 2, self.height - 58))) if VERSION: ver = theme.text_surface(self.small_font, VERSION, MUTED) screen.blit(ver, ver.get_rect( bottomleft=(16, self.height - 12))) def handle_input(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mouse_pos = pygame.mouse.get_pos() for btn in self.buttons: if btn["rect"].collidepoint(mouse_pos): audio.play("menu_confirm") return btn["action"] return None class SettingsMenu: def __init__(self, width, height): self.width = width self.height = height self.font = theme.font(46) self.title_font = theme.font(56) self.small_font = theme.font(24) # Persisted preference; main.py applies it to the audio module # at startup so it holds before Settings is ever opened. _prefs = save.load_settings() self.sound_on = _prefs.get("sound", True) # Five-step bed level (0 / 25 / 50 / 75 / 100 %) — coarse on # purpose so a click cycles through them clearly. Volume is # independent of the sound toggle: muting kills audio outright, # the slider sets the music level when audio is on. raw_vol = _prefs.get("music_vol", 1.0) self.music_vol = max(0.0, min(1.0, float(raw_vol) if isinstance(raw_vol, (int, float)) else 1.0)) # Fullscreen vs. bordered window only — no resolution picker. # The game always boots fullscreen at the monitor's own size # (main.py); this toggle is session-only, never persisted. self.toggle_screen = True # Same idle motion as the title screen but quieter — a # different seed gives each submenu its own pattern. self.dust = theme.PixelDust(width, height, seed=11, count=35) self.update_buttons() def update_buttons(self): sound_text = f"Sound: {'ON' if self.sound_on else 'OFF'}" music_text = f"Music: {int(round(self.music_vol * 100))}/100" screen_text = ( f"Screen: {'FULLSCREEN' if self.toggle_screen else 'BORDERED'}") self.buttons = [ {"text": sound_text, "rect": None, "action": "toggle_sound"}, {"text": music_text, "rect": None, "action": "cycle_music"}, {"text": screen_text, "rect": None, "action": "toggle_fs_w"}, ] center_x = self.width // 2 start_y = self.height // 2 - 100 for i, btn in enumerate(self.buttons): rect = measure(self.font, btn["text"]) rect.center = (center_x, start_y + i * 100) btn["rect"] = rect def draw(self, screen): screen.fill(BG) self.dust.draw(screen) _draw_title(screen, self.title_font, "Settings", self.width) _draw_back_hint(screen, self.small_font) mouse_pos = pygame.mouse.get_pos() for btn in self.buttons: is_hovered = btn["rect"].collidepoint(mouse_pos) color = ACCENT if is_hovered else INK screen.blit(theme.text_surface( self.font, btn["text"], color), btn["rect"]) if is_hovered: _hover_marker(screen, btn["rect"]) def handle_input(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mouse_pos = pygame.mouse.get_pos() for btn in self.buttons: if btn["rect"].collidepoint(mouse_pos): if btn["action"] == "toggle_sound": self.sound_on = not self.sound_on audio.set_enabled(self.sound_on) save.set_setting("sound", self.sound_on) self.update_buttons() elif btn["action"] == "cycle_music": # Cycle 0 → 25 → 50 → 75 → 100 → 0. Snap any # off-step saved value to the next step up. steps = (0.0, 0.25, 0.5, 0.75, 1.0) cur = round(self.music_vol * 4) / 4 idx = (steps.index(cur) + 1) % len(steps) \ if cur in steps else 0 self.music_vol = steps[idx] audio.set_music_volume(self.music_vol) save.set_setting("music_vol", self.music_vol) self.update_buttons() elif btn["action"] == "toggle_fs_w": self.toggle_screen = not self.toggle_screen pygame.display.toggle_fullscreen() self.update_buttons() audio.play("menu_confirm") return btn["action"] return None class LevelMenu: """Level select with completion checkmarks read from ``save.py``. Entries are rebuilt from ``level_catalog`` on every ``refresh()`` so: * freshly-beaten levels light up the next time you back to the menu * a custom level the player just saved in the editor appears without restarting the game Built-in levels are listed first (manifest order); user-built levels follow, visually marked as ``Custom``. """ def __init__(self, width, height): self.width = width self.height = height self.font = theme.font(46) self.title_font = theme.font(56) self.small_font = theme.font(24) self.tag_font = theme.font(22) self.best_font = theme.font(20) self.times = {} self.entries = [] # Idle motion, kept thinner than the title because this screen # is text-dense (rows of titles, taglines and best times). self.dust = theme.PixelDust(width, height, seed=13, count=25) self.refresh() def _layout(self): """Stack entries vertically, auto-shrinking spacing when the catalog grows so custom levels still fit on screen.""" if not self.entries: return count = len(self.entries) # 130 px per row up to 5 entries, then tighten so 10 still fit. gap = max(60, min(130, (self.height - 240) // max(count, 1))) center_x = self.width // 2 start_y = self.height // 2 - (count - 1) * gap // 2 for i, btn in enumerate(self.entries): rect = measure(self.font, btn["text"]) rect.center = (center_x, start_y + i * gap) btn["rect"] = rect def refresh(self): """Rebuild entries from the catalog + reread completed ids and best times.""" self.completed = save.load_completed() self.times = save.load_times() self.entries = [] for entry in level_catalog.load_catalog(): self.entries.append({ "text": entry.title, "action": entry.id, "tagline": entry.tagline, "custom": entry.custom, "rect": None, }) self._layout() def draw(self, screen): screen.fill(BG) self.dust.draw(screen) _draw_title(screen, self.title_font, "Levels", self.width) _draw_back_hint(screen, self.small_font) if not self.entries: empty = theme.text_surface( self.small_font, "No levels found — check assets/levels/manifest.json", MUTED) screen.blit(empty, empty.get_rect( center=(self.width // 2, self.height // 2))) return mouse_pos = pygame.mouse.get_pos() for btn in self.entries: is_hovered = btn["rect"].collidepoint(mouse_pos) is_done = btn["action"] in self.completed if is_hovered: color = ACCENT elif is_done: color = DONE_C else: color = INK text_surf = theme.text_surface(self.font, btn["text"], color) screen.blit(text_surf, btn["rect"]) if is_hovered: _hover_marker(screen, btn["rect"]) tag = btn["tagline"] if btn["custom"]: # No pill — a quiet prefix keeps the row flat. tag = f"custom | {tag}" tag_surf = theme.text_surface( self.tag_font, tag, MUTED if not is_done else theme.shade(DONE_C, -30)) screen.blit(tag_surf, tag_surf.get_rect( center=(btn["rect"].centerx, btn["rect"].bottom + 16))) best = self.times.get(btn["action"]) if best is not None: m, s = divmod(int(best), 60) # INK, not ACCENT: a persistent 20px label in the gold # accent is too low-contrast to read (same reason the # update status line uses INK). bt = theme.text_surface( self.best_font, f"best {m}:{s:02d}", INK) screen.blit(bt, bt.get_rect( center=(btn["rect"].centerx, btn["rect"].bottom + 42))) if is_done: # Minimal check: a thin tick, no filled circle. tx = btn["rect"].left - 60 ty = btn["rect"].centery pygame.draw.lines(screen, DONE_C, False, [ (tx - 9, ty), (tx - 2, ty + 8), (tx + 11, ty - 8)], 3) def handle_input(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mouse_pos = pygame.mouse.get_pos() for btn in self.entries: if btn["rect"] and btn["rect"].collidepoint(mouse_pos): audio.play("menu_confirm") return btn["action"] return None class CharacterMenu: """Character select with the stat block of the currently-hovered (or, if none, currently-selected) character shown alongside.""" def __init__(self, width, height): self.width = width self.height = height self.title_font = theme.font(56) self.card_font = theme.font(44) self.name_font = theme.font(46) self.small_font = theme.font(24) self.stat_font = theme.font(24) self.tagline_font = theme.font(22) # Build entries from the units catalogue. self.character = [] for key, cls, label, tagline in CHARACTER_INFO: self.character.append({ "text": label, "action": key, "tagline": tagline, "cls": cls, "rect": None, }) # Left-align every name on a single vertical line so the column # doesn't zigzag with each name's width. self.name_x = width // 2 - 320 start_y = height // 2 - 200 for i, btn in enumerate(self.character): rect = measure(self.name_font, btn["text"]) rect.midleft = (self.name_x, start_y + i * 100) btn["rect"] = rect # Two scaled idle-frame lists per character: # * ``previews``: 220 px hero, used by the focused row only. # * ``thumbs``: 64 px badge, drawn next to every row so the # whole list animates instead of standing # still everywhere except the focus. self.previews = {} self.thumbs = {} for key, cls, _label, _tagline in CHARACTER_INFO: self.previews[key] = self._load_idle_frames(cls, 220) self.thumbs[key] = self._load_idle_frames(cls, 72) # Idle motion — quieter than the title screen so it doesn't # compete with the stat block on the right. self.dust = theme.PixelDust(width, height, seed=17, count=30) def _load_idle_frames(self, cls, target_h): """Every idle frame, scaled to ``target_h`` px tall. Used twice per character: once for the focused-row hero preview, once for the per-row thumbnail so every figure in the list loops its idle instead of sitting on a static name. Returns ``None`` if the sheet is missing — callers skip the blit, and the row just shows the name.""" try: sheet = pygame.image.load( f"assets/units/{cls.asset_folder}/D_Idle.png").convert_alpha() except (pygame.error, FileNotFoundError): return None _, count = cls.SPRITE_SHEETS['idle_down'] fw = sheet.get_width() // count fh = sheet.get_height() scale = target_h / fh size = (int(fw * scale), int(fh * scale)) return [ pygame.transform.scale( sheet.subsurface(pygame.Rect(i * fw, 0, fw, fh)), size) for i in range(count) ] def draw(self, screen, current_selected): screen.fill(BG) self.dust.draw(screen) _draw_title(screen, self.title_font, "Select Character", self.width) _draw_back_hint(screen, self.small_font) mouse_pos = pygame.mouse.get_pos() # Choose which character's stats to show: hovered first, # otherwise the current selection. focus = None for btn in self.character: if btn["rect"].collidepoint(mouse_pos): focus = btn break if focus is None: for btn in self.character: if btn["action"] == current_selected: focus = btn break ticks = pygame.time.get_ticks() for i, btn in enumerate(self.character): is_hovered = btn["rect"].collidepoint(mouse_pos) if btn["action"] == current_selected: color = SEL_C elif is_hovered: color = ACCENT else: color = INK text_surf = theme.text_surface(self.name_font, btn["text"], color) screen.blit(text_surf, btn["rect"]) if is_hovered: _hover_marker(screen, btn["rect"]) tag = theme.text_surface( self.tagline_font, btn["tagline"], MUTED) screen.blit(tag, tag.get_rect( topleft=(btn["rect"].left, btn["rect"].bottom + 4))) # Per-row idle thumbnail — every character animates. Skip # the focused row: it gets the bigger hero sprite drawn # below, and a duplicate thumb beside the name would compete # with the stat-card column. is_focus = focus is not None and btn["action"] == focus["action"] if not is_focus: thumbs = self.thumbs.get(btn["action"]) if thumbs: # Stagger frame index per row so they don't blink in # sync. ~7 fps idle loop. idx = (ticks // 140 + i * 2) % len(thumbs) frame = thumbs[idx] screen.blit(frame, frame.get_rect( center=(self.name_x - 60, btn["rect"].centery))) if focus is not None: frames = self.previews.get(focus["action"]) if frames: # ~7 fps idle loop, timed off the wall clock so this # screen doesn't need a dt plumbed in just for the sprite. frame = frames[(ticks // 140) % len(frames)] pcx = self.name_x - 170 pcy = self.height // 2 screen.blit(frame, frame.get_rect(center=(pcx, pcy))) self._draw_stat_card(screen, focus) def _draw_stat_card(self, screen, btn): cls = btn["cls"] # No box: a flat column with one thin separator under the name. card_w = 520 cx = self.width // 2 + 360 left = cx - card_w // 2 top = self.height // 2 - 220 name = theme.text_surface(self.card_font, btn["text"], TITLE_C) screen.blit(name, name.get_rect(center=(cx, top))) tag = theme.text_surface(self.tagline_font, btn["tagline"], MUTED) screen.blit(tag, tag.get_rect(center=(cx, top + 44))) pygame.draw.line(screen, LINE_C, (left + 20, top + 78), (left + card_w - 20, top + 78), 2) stats = [ ("HP", cls.max_hp, 200), ("SPEED", cls.speed, 900), ("DAMAGE", cls.attack_damage, 25), ("FIRE RATE", 1.0 / max(0.01, cls.attack_cooldown), 6.0), ] # Label column width from font metrics (codebase idiom — cf. # theme.draw_toast, MainMenu._toast_y) so the widest label # ("FIRE RATE") can't overrun a hardcoded 110 px column into # its bar. bar_right reproduces the old right edge # (left+130)+(card_w-170) so the value-number column is byte- # stable; the max(60, …) is a defensive floor (B10-class) that # never triggers with the current font/labels. label_w = max(self.stat_font.size(s)[0] for s, _, _ in stats) bar_right = left + card_w - 40 bar_x = left + 20 + label_w + 18 bar_w = max(60, bar_right - bar_x) bar_h = 10 y = top + 130 for label, val, vmax in stats: text = theme.text_surface(self.stat_font, label, MUTED) screen.blit(text, text.get_rect(midleft=(left + 20, y + 5))) ratio = max(0.05, min(1.0, val / vmax)) theme.draw_bar(screen, pygame.Rect(bar_x, y, bar_w, bar_h), ratio, ACCENT, border=False) num = theme.text_surface( self.stat_font, f"{val:.1f}" if isinstance(val, float) else str(val), INK) screen.blit(num, num.get_rect(midleft=(bar_x + bar_w + 12, y + 5))) y += 56 # Signature ability — a fifth line below the stat bars so the # differentiator reads before the character is picked. if getattr(cls, "ABILITY_NAME", ""): pygame.draw.line(screen, LINE_C, (left + 20, y - 6), (left + card_w - 20, y - 6), 2) y += 14 name = theme.text_surface( self.stat_font, f"ABILITY {cls.ABILITY_NAME}", TITLE_C) screen.blit(name, name.get_rect(midleft=(left + 20, y + 5))) y += 34 desc = theme.text_surface( self.tagline_font, cls.ABILITY_DESC, MUTED) screen.blit(desc, desc.get_rect(midleft=(left + 20, y + 5))) def handle_input(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mouse_pos = pygame.mouse.get_pos() for btn in self.character: if btn["rect"].collidepoint(mouse_pos): audio.play("menu_confirm") return btn["action"] return None class PauseMenu: """Translucent overlay over the live game. The level keeps its state — ``main.py`` simply stops calling ``LevelManager.update`` while paused, so the next Resume picks up exactly where you froze. """ def __init__(self, width, height): self.width = width self.height = height self.font = theme.font(50) self.title_font = theme.font(76) self.hint_font = theme.font(24) self.buttons = [ {"text": "Resume", "action": "resume"}, {"text": "Restart Level", "action": "restart"}, {"text": "Quit to Menu", "action": "quit"}, ] cx = width // 2 start_y = height // 2 - 30 for i, btn in enumerate(self.buttons): rect = measure(self.font, btn["text"]) rect.center = (cx, start_y + i * 110) btn["rect"] = rect def draw(self, screen): overlay = pygame.Surface(screen.get_size(), pygame.SRCALPHA) overlay.fill((*BG, 210)) screen.blit(overlay, (0, 0)) title = theme.text_surface(self.title_font, "PAUSED", TITLE_C) t_rect = title.get_rect( center=(self.width // 2, self.height // 2 - 200)) screen.blit(title, t_rect) ly = t_rect.bottom + 16 pygame.draw.line(screen, LINE_C, (self.width // 2 - 150, ly), (self.width // 2 + 150, ly), 2) mp = pygame.mouse.get_pos() for btn in self.buttons: hov = btn["rect"].collidepoint(mp) col = ACCENT if hov else INK screen.blit(theme.text_surface( self.font, btn["text"], col), btn["rect"]) if hov: _hover_marker(screen, btn["rect"]) hint = theme.text_surface(self.hint_font, "Esc to resume", MUTED) screen.blit(hint, hint.get_rect( center=(self.width // 2, self.height - 88))) def handle_input(self, event): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: mp = pygame.mouse.get_pos() for btn in self.buttons: if btn["rect"].collidepoint(mp): audio.play("menu_confirm") return btn["action"] return None