ajhahn.de
← the-way-out commits

Commit

the-way-out

v0.2.0

ajhahnde · May 2026 · 4d145c070bef065c26ac8f65224beddb03a483f1 · view on GitHub →

modified .gitignore
@@ -5,8 +5,7 @@ __pycache__/
# MacOS Bin
.DS_Store
# Python virtualenvs (arm64 build env -> build_mac.sh;
# x86_64 cross-build env .venv-intel -> build_mac_intel.sh)
.ruff_cache
venv/
.venv/
.venv-intel/
@@ -21,7 +20,6 @@ assets/background/test_bg_2.png
ajhahnde/
# PyInstaller build output (the self-updating Mac launcher)
build/
dist/
*.spec
modified README.md
@@ -4,7 +4,7 @@ A top-down pixel-art escape-room shooter. Pick a character, fight your
way through locked rooms, work the levers and pressure plates, and find
the way out.
**Version:** v0.1.0 — see [Release Notes](RELEASE_NOTES.md)
**Version:** v0.2.0 — see [Release Notes](RELEASE_NOTES.md)
## Play
modified RELEASE_NOTES.md
@@ -1,5 +1,41 @@
# Release Notes
## v0.2.0
A content and polish update. No save-file format changes; v0.1.0 saves
load as-is.
### Gameplay
- Boss roster: each level now picks one of five generals — Mr. Green,
Mr. Orange, Gen. Frost, The Archer, Mr. Shadow — deterministically
from the level id, so a given level always fights the same boss
across restarts. Mechanics are unchanged; identity is conveyed by
sprite and a subtle colour overlay.
- Boss UI updates: the health-bar label and objective text reflect the
active general's name.
### UI
- New animated title scene: a slowly scrolling floor, a small crowd of
idle characters wandering the screen, and a soft vignette — the
static dust field has been retired for the main menu.
- Update status is now a toast under the title. Result messages
auto-dismiss after a few seconds; pressing Esc to return to the
title also clears any stale status.
- Character select animates every row, not just the focused one, with
staggered idle frames so the list never feels static.
- The mouse cursor is hidden during active gameplay (combat is fully
keyboard-driven) and visible everywhere else.
### App
- Custom app icon: a wizard standing in a glowing doorway, shipped as
a macOS `.icns` bundled into the app and as the runtime pygame
window icon. The icon is regenerated by `scripts/make_icon.py`.
### Internal
- `theme.py` gains a reusable `draw_toast` primitive and a
`MenuScene` background, so future screens can opt into the same
ambient look without re-implementing it.
## v0.1.0
Initial release.
added assets/icon.icns
binary file — no preview
added assets/icon_1024.png
binary file — no preview
modified build_mac.sh
@@ -22,6 +22,7 @@ rm -rf build dist "$APPNAME.spec"
"$PY" -m PyInstaller --noconfirm --windowed --clean \
--name "$APPNAME" \
--osx-bundle-identifier de.ajhahn.thewayout \
--icon assets/icon.icns \
--collect-all pygame \
--add-data "$SEED:_seed" \
launcher.py
modified build_mac_intel.sh
@@ -55,6 +55,7 @@ rm -rf build dist "$APPNAME.spec"
arch -x86_64 "$PY" -m PyInstaller --noconfirm --windowed --clean \
--name "$APPNAME" \
--osx-bundle-identifier de.ajhahn.thewayout \
--icon assets/icon.icns \
--collect-all pygame \
--target-architecture x86_64 \
--add-data "$SEED:_seed" \
modified launcher.py
@@ -53,6 +53,14 @@ def _error_screen(lines):
import pygame
pygame.init()
sw, sh = 720, 360
# set_icon must run BEFORE set_mode so macOS picks it up for the
# actual window. Best-effort: the bundled seed may be missing.
try:
seed_icon = os.path.join(
_bundle_seed(), "assets", "icon_1024.png")
pygame.display.set_icon(pygame.image.load(seed_icon))
except (pygame.error, FileNotFoundError, OSError):
pass
screen = pygame.display.set_mode((sw, sh))
pygame.display.set_caption("The Way Out")
# settings.FONT == "assets/gui/font/main_font.otf"; the frozen
modified levels.py
@@ -1,5 +1,6 @@
import math
import random
import zlib
from collections import deque
import pygame
@@ -7,7 +8,7 @@ from settings import (
TILE_SIZE, BOSS_TOUCH_DAMAGE, PLAYER_INVULN_TIME,
SPIKE_DAMAGE, LEVER_REACH, DASH_COOLDOWN,
)
from units import Wizard, Boss, CHARACTER_INFO, ENEMY_INFO
from units import Wizard, Boss, BOSS_ROSTER, CHARACTER_INFO, ENEMY_INFO
from static_objects import Tile, TileTextures, Prop
from interactables import Spikes, Lever, Gate, KeyItem, PressurePlate
from tiles import PROP_CHARS
@@ -193,6 +194,9 @@ class LevelManager:
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
@@ -331,6 +335,13 @@ class LevelManager:
self.arena_rect = None
self._e_was_down = False
self._last_boss_hp = None
# 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, 'r') as f:
@@ -546,7 +557,10 @@ class LevelManager:
self.boss = Boss(
bx, by, self.obstacle_sprites, target=self.player,
projectile_group=self.projectile_sprites,
projectile_targets=self.player_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
@@ -914,14 +928,15 @@ class LevelManager:
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("MR. GREEN", True, theme.INK)
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 = "Defeat Mr. Green!", theme.FAIL
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",
@@ -930,7 +945,7 @@ class LevelManager:
text, color = ("Step on the plates — the way is sealed",
theme.ACCENT)
else:
text, color = ("Mr. Green guards the final hall",
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",
modified main.py
@@ -13,6 +13,13 @@ import audio
# Setup & Initalisation
pygame.init()
# set_icon must happen BEFORE set_mode for the icon to take effect on
# the actual macOS window (not just the Dock).
try:
pygame.display.set_icon(
pygame.image.load(os.path.join("assets", "icon_1024.png")))
except (pygame.error, FileNotFoundError, OSError):
pass
# Always boot fullscreen at the monitor's own resolution — there is no
# in-game resolution picker. settings.WIDTH/HEIGHT is only the fallback
# if the desktop size can't be read.
@@ -177,6 +184,10 @@ while running:
# a filename without nuking the session.
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
if game_state in ("lvls", "settings", "char_select"):
# Returning to the title screen — drop any stale update
# toast so it doesn't reappear long after the user has
# moved on.
main_menu.clear_status()
game_state = "menu"
elif game_state == "paused":
game_state = "game"
@@ -201,13 +212,17 @@ while running:
if game_state == "menu":
action = main_menu.handle_input(event)
if action == "lvls":
main_menu.clear_status()
_to_level_menu()
elif action == "editor":
main_menu.clear_status()
editor.reset_pointer_state()
game_state = "editor"
elif action == "settings":
main_menu.clear_status()
game_state = "settings"
elif action == "chars":
main_menu.clear_status()
game_state = "char_select"
elif action == "update":
# Hand the work off to a thread so the event loop can
@@ -216,7 +231,7 @@ while running:
update_state["phase"] = "checking"
update_state["result"] = None
update_anim_t = 0.0
main_menu.status = ""
main_menu.clear_status()
threading.Thread(
target=_run_update, daemon=True).start()
game_state = "updating"
@@ -276,6 +291,21 @@ while running:
if _bgm is not None:
audio.play_music(_bgm)
# Mouse cursor: hidden during live gameplay (combat is keyboard +
# 4-way facing — no aim cursor); visible everywhere else, including
# the keyboard-driven level-end screen so the player can still see
# the cursor land in pause/menu/editor cleanly.
in_active_game = (game_state == "game"
and not level_manager.completed
and not level_manager.failed)
pygame.mouse.set_visible(not in_active_game)
# Auto-dismiss the main-menu status toast once its TTL elapses so a
# stale "Already up to date." doesn't sit on screen forever.
if (main_menu.status_until is not None
and pygame.time.get_ticks() / 1000.0 > main_menu.status_until):
main_menu.clear_status()
# Draw & Update --------------------------------------------------
if game_state == "menu":
main_menu.draw(screen)
@@ -283,7 +313,7 @@ while running:
update_anim_t += dt
result = update_state["result"]
if result == "done":
main_menu.status = "Updated - restarting..."
main_menu.set_status("Updated - restarting...", ttl=None)
main_menu.draw(screen)
pygame.display.flip()
pygame.time.delay(900)
@@ -294,15 +324,16 @@ while running:
os.execv(sys.executable,
[sys.executable, os.path.abspath(__file__)])
elif result is not None:
main_menu.status = _UPDATE_RESULT_TEXT.get(
result, "Update failed - try again later.")
main_menu.set_status(_UPDATE_RESULT_TEXT.get(
result, "Update failed - try again later."))
game_state = "menu"
main_menu.draw(screen)
else:
phase = update_state["phase"] or "checking"
dots = "." * (1 + int(update_anim_t * 2) % 3)
main_menu.status = (
f"{_UPDATE_PHASE_TEXT.get(phase, 'Updating')}{dots}")
main_menu.set_status(
f"{_UPDATE_PHASE_TEXT.get(phase, 'Updating')}{dots}",
ttl=None)
main_menu.draw(screen)
elif game_state == "settings":
settings_menu.draw(screen)
modified menu.py
@@ -22,8 +22,13 @@ class MainMenu:
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 under the tip line.
# 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"},
@@ -42,18 +47,45 @@ class MainMenu:
rect.center = (center_x, start_y + i * 90)
btn["rect"] = rect
# Slow pixel-dust drifting upward — keeps the title screen alive
# without competing with the menu (matches the pixel aesthetic).
self.dust = theme.PixelDust(width, height, seed=7, count=60)
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)
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):
screen.fill(BG)
self.dust.draw(screen)
self.scene.draw(screen)
mouse_pos = pygame.mouse.get_pos()
title = self.title_font.render("THE WAY OUT", True, TITLE_C)
screen.blit(title, title.get_rect(
center=(self.width // 2, self.height // 2 - 210)))
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)
@@ -77,13 +109,6 @@ class MainMenu:
screen.blit(tip, tip.get_rect(
center=(self.width // 2, self.height - 58)))
if self.status:
# INK, not ACCENT: the gold accent is too low-contrast for a
# full line of small text on the dark background.
status_surf = self.small_font.render(self.status, True, INK)
screen.blit(status_surf, status_surf.get_rect(
center=(self.width // 2, self.height - 98)))
def handle_input(self, event):
if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mouse_pos = pygame.mouse.get_pos()
@@ -352,18 +377,29 @@ class CharacterMenu:
rect.midleft = (self.name_x, start_y + i * 100)
btn["rect"] = rect
# One idle sprite per character, shown left of the list on hover.
# 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_preview(cls)
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_preview(self, cls):
"""Every idle frame, scaled — the hovered character loops its
idle animation instead of standing on a single frame."""
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()
@@ -372,7 +408,6 @@ class CharacterMenu:
_, count = cls.SPRITE_SHEETS['idle_down']
fw = sheet.get_width() // count
fh = sheet.get_height()
target_h = 220
scale = target_h / fh
size = (int(fw * scale), int(fh * scale))
return [
@@ -402,7 +437,8 @@ class CharacterMenu:
focus = btn
break
for btn in self.character:
ticks = pygame.time.get_ticks()
for i, btn in enumerate(self.character):
is_hovered = btn["rect"].collidepoint(mouse_pos)
if btn["action"] == current_selected:
@@ -421,12 +457,27 @@ class CharacterMenu:
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[(pygame.time.get_ticks() // 140) % len(frames)]
frame = frames[(ticks // 140) % len(frames)]
pcx = self.name_x - 170
pcy = self.height // 2
screen.blit(frame, frame.get_rect(center=(pcx, pcy)))
added scripts/make_icon.py
@@ -0,0 +1,193 @@
"""Generate the macOS app icon (1024x1024 PNG + .icns).
Composites the existing wizard and big-door sprites onto a black canvas
with a soft vignette and a warm light pool under the door — the visual
shorthand for "the way out". Pixel-art scaling is nearest-neighbour
(``pygame.transform.scale``), no smoothing.
Run from the repo root:
.venv/bin/python scripts/make_icon.py
Outputs:
assets/icon_1024.png master PNG (used at runtime by set_icon)
assets/icon.icns macOS bundle icon (used by PyInstaller --icon)
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
import pygame # noqa: E402
REPO = Path(__file__).resolve().parent.parent
ASSETS = REPO / "assets"
OUT_PNG = ASSETS / "icon_1024.png"
OUT_ICNS = ASSETS / "icon.icns"
CANVAS = 1024
DOOR_SHEET = ASSETS / "tileset" / "interactables" / "BigDoor_D.png"
WIZARD_SHEET = ASSETS / "units" / "wizard" / "D_Idle.png"
def _first_frame(sheet: pygame.Surface) -> pygame.Surface:
"""Slice the first square frame off a horizontal sprite sheet."""
w, h = sheet.get_size()
return sheet.subsurface(pygame.Rect(0, 0, h, h)).copy()
def _radial_gradient(
size: int,
cx: int, cy: int,
inner_color: tuple[int, int, int, int],
outer_color: tuple[int, int, int, int],
inner_frac: float = 0.0,
outer_frac: float = 1.0,
power: float = 1.0,
seed_size: int = 96,
) -> pygame.Surface:
"""Smooth radial gradient. Built per-pixel on a small seed surface,
then smoothscaled up — avoids the banding that draw.circle produces
on an SRCALPHA target (which replaces alpha rather than blending)."""
seed = pygame.Surface((seed_size, seed_size), pygame.SRCALPHA)
scx = cx * seed_size / size
scy = cy * seed_size / size
max_r = ((max(scx, seed_size - scx)) ** 2
+ (max(scy, seed_size - scy)) ** 2) ** 0.5
ir, ig, ib, ia = inner_color
or_, og, ob, oa = outer_color
for y in range(seed_size):
for x in range(seed_size):
r = ((x - scx) ** 2 + (y - scy) ** 2) ** 0.5 / max_r
if r <= inner_frac:
t = 0.0
elif r >= outer_frac:
t = 1.0
else:
t = ((r - inner_frac) / (outer_frac - inner_frac)) ** power
seed.set_at((x, y), (
int(ir + (or_ - ir) * t),
int(ig + (og - ig) * t),
int(ib + (ob - ib) * t),
int(ia + (oa - ia) * t),
))
return pygame.transform.smoothscale(seed, (size, size))
def _vignette(size: int) -> pygame.Surface:
"""Black overlay: transparent at centre, opaque near corners."""
return _radial_gradient(
size, size // 2, size // 2,
inner_color=(0, 0, 0, 0),
outer_color=(0, 0, 0, 235),
inner_frac=0.30, outer_frac=1.0, power=1.6,
)
def _integer_scale(surf: pygame.Surface, factor: int) -> pygame.Surface:
w, h = surf.get_size()
return pygame.transform.scale(surf, (w * factor, h * factor))
def build_icon() -> None:
pygame.init()
pygame.display.set_mode((1, 1)) # dummy driver still needs this for convert_alpha
# Pure-black backdrop ("Schwarz mit Vignette"). The vignette layer
# at the end darkens corners; the glow layer brightens the centre.
canvas = pygame.Surface((CANVAS, CANVAS), pygame.SRCALPHA)
canvas.fill((0, 0, 0, 255))
# Warm glow behind the door — single soft layer with normal alpha
# blending (NOT additive — stacked ADDs blow out to white).
glow = _radial_gradient(
CANVAS, CANVAS // 2, 540,
inner_color=(255, 200, 110, 255),
outer_color=(20, 8, 0, 0),
inner_frac=0.0, outer_frac=0.55, power=1.7,
)
canvas.blit(glow, (0, 0))
# Door (36x36 frame x22 = 792 px), rotated 90° so it reads as an
# upright doorway rather than a top-down floor tile.
door_sheet = pygame.image.load(str(DOOR_SHEET)).convert_alpha()
door = _first_frame(door_sheet)
door = pygame.transform.rotate(door, 90)
door = _integer_scale(door, 22)
dw, dh = door.get_size()
door_x = CANVAS // 2 - dw // 2
door_y = 90
canvas.blit(door, (door_x, door_y))
# Warm floor pool right at the door threshold — small additive
# accent so the wizard reads as standing in the doorway's light.
pool = _radial_gradient(
CANVAS, CANVAS // 2, 920,
inner_color=(255, 215, 140, 120),
outer_color=(255, 160, 60, 0),
inner_frac=0.0, outer_frac=0.25, power=1.4,
)
canvas.blit(pool, (0, 0), special_flags=pygame.BLEND_RGBA_ADD)
# Wizard (32x32 frame x18 = 576 px) in front of the door.
wiz_sheet = pygame.image.load(str(WIZARD_SHEET)).convert_alpha()
wiz = _first_frame(wiz_sheet)
wiz = _integer_scale(wiz, 18)
ww, wh = wiz.get_size()
canvas.blit(wiz, (CANVAS // 2 - ww // 2, 940 - wh))
# Vignette: corners fade to pure black, centre untouched.
canvas.blit(_vignette(CANVAS), (0, 0))
OUT_PNG.parent.mkdir(parents=True, exist_ok=True)
pygame.image.save(canvas, str(OUT_PNG))
print(f"wrote {OUT_PNG.relative_to(REPO)}")
def build_icns() -> None:
"""Convert the master PNG to .icns via macOS-native sips + iconutil."""
if sys.platform != "darwin":
print("skipping .icns (not macOS)")
return
if not shutil.which("sips") or not shutil.which("iconutil"):
print("skipping .icns (sips/iconutil missing)")
return
iconset = ASSETS / "icon.iconset"
if iconset.exists():
shutil.rmtree(iconset)
iconset.mkdir()
# Apple's required iconset entries.
entries = [
(16, "icon_16x16.png"),
(32, "[email protected]"),
(32, "icon_32x32.png"),
(64, "[email protected]"),
(128, "icon_128x128.png"),
(256, "[email protected]"),
(256, "icon_256x256.png"),
(512, "[email protected]"),
(512, "icon_512x512.png"),
(1024, "[email protected]"),
]
for size, name in entries:
subprocess.run(
["sips", "-z", str(size), str(size),
str(OUT_PNG), "--out", str(iconset / name)],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["iconutil", "-c", "icns", str(iconset), "-o", str(OUT_ICNS)],
check=True)
shutil.rmtree(iconset)
print(f"wrote {OUT_ICNS.relative_to(REPO)}")
if __name__ == "__main__":
build_icon()
build_icns()
modified theme.py
@@ -9,10 +9,11 @@ Centralising this keeps exactly one copy of every RGB tuple and of the
title / back-hint / hover primitives, so the screens cannot drift apart.
"""
import os
import random
import pygame
from settings import FONT
from settings import FONT, TILE_SIZE
# --- palette ---------------------------------------------------------
BG = (18, 18, 24) # every screen fills this
@@ -117,6 +118,213 @@ def draw_bar(screen, rect, ratio, color, *, border=True):
border_radius=4)
def draw_toast(screen, text, font_, *, center_x, center_y,
fg=None, bg=None, border=None, pad_x=22, pad_y=10):
"""Draw a small rounded toast box centred at ``(center_x, center_y)``.
Layout is computed from the font metrics so the toast can never
collide with siblings at other resolutions. Returns the drawn
``pygame.Rect`` so the caller can place anything else relative to
it. ``fg`` defaults to ``ACCENT`` (the toast is the only screen
element using the gold accent for body text — short, high-priority,
transient).
"""
if fg is None:
fg = ACCENT
if bg is None:
bg = shade(BG, +30)
if border is None:
border = LINE_C
surf = font_.render(text, True, fg)
box = surf.get_rect()
box.width += pad_x * 2
box.height += pad_y * 2
box.center = (center_x, center_y)
pygame.draw.rect(screen, bg, box, border_radius=6)
pygame.draw.rect(screen, border, box, 2, border_radius=6)
screen.blit(surf, surf.get_rect(center=box.center))
return box
def _load_idle_frames(folder, scale):
"""Split ``assets/units/<folder>/D_Idle.png`` into scaled frames.
Standalone of ``Character.load_assets`` so the menu scene can draw
ambient sprites without pulling the whole Character class (HP /
dash / projectile machinery). Returns ``[]`` if the sheet is
missing — caller skips the actor."""
path = os.path.join("assets", "units", folder, "D_Idle.png")
try:
sheet = pygame.image.load(path).convert_alpha()
except (pygame.error, FileNotFoundError):
return []
count = 4 # every unit sheet uses 4 idle frames (units.SPRITE_SHEETS)
fw = sheet.get_width() // count
fh = sheet.get_height()
out = []
for i in range(count):
sub = sheet.subsurface(pygame.Rect(i * fw, 0, fw, fh))
out.append(pygame.transform.scale(
sub, (int(fw * scale), int(fh * scale))))
return out
class _MenuActor:
"""Single wandering sprite used by :class:`MenuScene`.
No collision, no AI — just a position, a velocity that reflects at
screen edges, and an idle-loop frame index driven off the wall
clock (with a per-actor phase so the crowd doesn't blink in sync).
Right-facing frames are mirrored from left-facing at construction.
"""
def __init__(self, frames, x, y, vx, vy, phase_ms):
self._frames_l = [pygame.transform.flip(f, True, False)
for f in frames]
self._frames_r = list(frames)
self.x = float(x)
self.y = float(y)
self.vx = float(vx)
self.vy = float(vy)
self.phase_ms = int(phase_ms)
def update(self, dt, w, h):
self.x += self.vx * dt
self.y += self.vy * dt
frame_w = self._frames_r[0].get_width()
frame_h = self._frames_r[0].get_height()
if self.x < frame_w * 0.5:
self.x = frame_w * 0.5
self.vx = abs(self.vx)
elif self.x > w - frame_w * 0.5:
self.x = w - frame_w * 0.5
self.vx = -abs(self.vx)
if self.y < frame_h * 0.5:
self.y = frame_h * 0.5
self.vy = abs(self.vy)
elif self.y > h - frame_h * 0.5:
self.y = h - frame_h * 0.5
self.vy = -abs(self.vy)
def draw(self, screen, ticks_ms):
frames = self._frames_l if self.vx < 0 else self._frames_r
idx = ((ticks_ms + self.phase_ms) // 160) % len(frames)
frame = frames[idx]
screen.blit(frame, frame.get_rect(center=(int(self.x), int(self.y))))
class MenuScene:
"""Ambient background for menu screens.
* A scrolling floor: one tile pre-baked into a slab the size of the
screen + one tile of overscan, blitted with a wrapped (ox, oy)
offset so the scroll is one blit per frame (not a per-tile grid).
* A handful of wandering character sprites (idle loop) that bounce
off the screen edges — keeps the title alive without lockstep
motion.
* A dark vignette on top so menu text stays readable regardless of
the underlying art.
Cheap enough to throw on every menu screen, but :class:`MainMenu`
is the primary user — submenus that need their stat card / list to
read clearly should stay on the quieter :class:`PixelDust`.
"""
# Diagonal scroll velocity (px/s) for the floor slab.
SCROLL_VX = 18
SCROLL_VY = 12
# Vignette darkness — alpha over BG. Tuned so ACCENT-gold title text
# still pops; raise toward 160 if a particular screen needs more.
VIGNETTE_ALPHA = 110
_FOLDERS = ("wizard", "penguin", "elf", "shiggy", "wolf", "mrgreen",
"orange")
def __init__(self, width, height, *, actor_count=6, seed=23,
floor_tile="Tile_42", actor_scale=2):
self.width = width
self.height = height
self._rng = random.Random(seed)
self._t0 = pygame.time.get_ticks()
self._last_ms = self._t0
self._slab = self._build_slab(floor_tile)
self._vignette = self._build_vignette()
self.actors = self._build_actors(actor_count, actor_scale)
def _build_slab(self, floor_tile):
"""Pre-render one screen-plus-overscan slab of the floor tile.
Falls back to a flat BG fill if the tile asset is missing — the
scene still works (vignette + actors over a flat panel) instead
of crashing on the title screen."""
ts = TILE_SIZE
cols = self.width // ts + 2
rows = self.height // ts + 2
slab = pygame.Surface((cols * ts, rows * ts))
slab.fill(BG)
path = os.path.join("assets", "tileset", "tiles",
floor_tile + ".png")
try:
raw = pygame.image.load(path).convert_alpha()
tile_img = pygame.transform.scale(raw, (ts, ts))
except (pygame.error, FileNotFoundError):
return slab
# Tone the tile down so it sits behind the UI rather than
# competing with it: blit the tile, then a dark wash over the
# whole slab.
for r in range(rows):
for c in range(cols):
slab.blit(tile_img, (c * ts, r * ts))
wash = pygame.Surface(slab.get_size(), pygame.SRCALPHA)
wash.fill((*BG, 90))
slab.blit(wash, (0, 0))
return slab
def _build_vignette(self):
v = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
v.fill((*BG, self.VIGNETTE_ALPHA))
return v
def _build_actors(self, count, scale):
actors = []
folders = list(self._FOLDERS)
self._rng.shuffle(folders)
for i in range(count):
folder = folders[i % len(folders)]
frames = _load_idle_frames(folder, scale)
if not frames:
continue
x = self._rng.uniform(80, self.width - 80)
y = self._rng.uniform(80, self.height - 80)
angle = self._rng.uniform(0, 6.2831853)
speed = self._rng.uniform(30, 70)
vx = speed * pygame.math.Vector2(1, 0).rotate_rad(angle).x
vy = speed * pygame.math.Vector2(1, 0).rotate_rad(angle).y
phase = self._rng.randint(0, 600)
actors.append(_MenuActor(frames, x, y, vx, vy, phase))
return actors
def draw(self, screen):
now = pygame.time.get_ticks()
dt = min(0.1, (now - self._last_ms) / 1000.0)
self._last_ms = now
t = (now - self._t0) / 1000.0
slab_w = self._slab.get_width()
slab_h = self._slab.get_height()
ox = int(t * self.SCROLL_VX) % slab_w
oy = int(t * self.SCROLL_VY) % slab_h
# Blit slab tiled with wrap: one base copy, then three offset
# copies cover any gap from the modulo offset.
screen.blit(self._slab, (-ox, -oy))
screen.blit(self._slab, (slab_w - ox, -oy))
screen.blit(self._slab, (-ox, slab_h - oy))
screen.blit(self._slab, (slab_w - ox, slab_h - oy))
for actor in self.actors:
actor.update(dt, self.width, self.height)
actor.draw(screen, now)
screen.blit(self._vignette, (0, 0))
class PixelDust:
"""Slow upward-drifting pixel particles for idle backgrounds.
modified tiles.py
@@ -108,8 +108,9 @@ def _build_registry():
# --- enemies --------------------------------------------------------
reg['B'] = TileSpec(
'B', "Boss (Mr. Green)", 'enemy',
"Spawns lazily the first time the player enters its arena.")
'B', "Boss (random general)", 'enemy',
"Spawns lazily the first time the player enters its arena. "
"Identity is picked deterministically from the level id.")
# Generic enemies are derived from units.ENEMY_INFO so adding one
# there makes it appear in the editor palette automatically.
for ch, _cls, label in ENEMY_INFO:
modified units.py
@@ -428,6 +428,22 @@ CHARACTER_INFO = [
]
# Pool of "generals" the level picks one of as its boss. Mechanics are
# identical (one Boss class) — only the name, sprite folder and a
# subtle identity overlay change so each level feels distinct. The
# fourth tuple element is an optional RGBA tint baked into every loaded
# frame once at boss init, so the telegraph (red/gold) still sits on
# top during attack windups without fighting a per-frame identity
# overlay.
BOSS_ROSTER = [
("Mr. Green", "mrgreen", None),
("Mr. Orange", "orange", (180, 90, 30, 70)),
("Gen. Frost", "penguin", (60, 120, 210, 90)),
("The Archer", "elf", (70, 180, 90, 90)),
("Mr. Shadow", "shiggy", (90, 40, 140, 90)),
]
# --- boss --------------------------------------------------------------
class Boss(Character):
@@ -446,7 +462,16 @@ class Boss(Character):
max_hp = BOSS_MAX_HP
def __init__(self, x, y, obstacle_sprites=None, target=None,
projectile_group=None, projectile_targets=None):
projectile_group=None, projectile_targets=None,
*, display_name="Mr. Green", asset_folder=None,
identity_tint=None):
# Set instance asset_folder + identity_tint BEFORE super so
# Character.load_assets reads the chosen sprite folder and the
# overridden load_assets below bakes the tint in.
if asset_folder is not None:
self.asset_folder = asset_folder
self.identity_tint = identity_tint
self.display_name = display_name
super().__init__(x, y, obstacle_sprites)
self.target = target
# The level wires these so phase 2 can spawn boss projectiles
@@ -459,6 +484,19 @@ class Boss(Character):
BOSS_CHASE_TIME_MIN, BOSS_CHASE_TIME_MAX)
self.dash_dir = pygame.math.Vector2(0, 1)
def load_assets(self):
# Bake identity_tint into every frame once at init so the
# telegraph tint (red/gold during windup/aim) still overlays
# cleanly on top without a per-frame swap that pops between
# neutral and tinted between attacks.
super().load_assets()
if self.identity_tint is None:
return
for status, frames in self.animations.items():
self.animations[status] = [
self._apply_overlay(f, self.identity_tint) for f in frames
]
def get_input(self):
if self.target is None or self.target.hp <= 0 or self.hp <= 0:
self.direction.update(0, 0)