ajhahn.de
← the-way-out commits

Commit

the-way-out

v0.2.4

ajhahnde · May 2026 · ae78528a0f0604d5f078bc66dd05ea504a26f230 · parent: 6700f29 · view on GitHub →

modified editor.py
@@ -414,8 +414,11 @@ class LevelEditor:
def _screen_to_cell(self, sx, sy):
if not self.canvas_rect.collidepoint(sx, sy):
return None
wx = sx - self.canvas_rect.left + self.cam_x
wy = sy - self.canvas_rect.top + self.cam_y
# int() to match _cell_to_screen and the canvas draw loop
# (both use int(self.cam_*)); a float cam here would shift the
# hit-test off the drawn grid by the sub-pixel camera fraction.
wx = sx - self.canvas_rect.left + int(self.cam_x)
wy = sy - self.canvas_rect.top + int(self.cam_y)
c = int(wx // TILE_SIZE)
r = int(wy // TILE_SIZE)
if 0 <= r < self.rows and 0 <= c < self.cols:
modified main.py
@@ -124,9 +124,18 @@ def _start_level(level_id, return_to="lvls"):
global game_state, return_state
if not level_manager.load_level(level_id, current_character):
# Bad/empty/missing level file — don't strand the state machine
# in "game" with player=None (update() would crash). Bounce
# back to the level select instead.
_to_level_menu()
# in "game" with player=None (update() would crash). Route the
# failure to wherever this launch came from, mirroring
# _leave_game(): an editor Test that can't load must land back
# in the editor canvas, not the level menu, or the user is
# bounced past the editor — the same B17/B19/B20 "editor Test
# returns to editor" contract. return_state isn't set until the
# success path below, so key off the return_to parameter here.
if return_to == "editor":
editor.reset_pointer_state()
game_state = "editor"
else:
_to_level_menu()
return
game_state = "game"
return_state = return_to
modified menu.py
@@ -74,11 +74,14 @@ class MainMenu:
self.status_until = None
def draw(self, screen):
screen.fill(BG)
# 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)
mouse_pos = pygame.mouse.get_pos()
title = self.title_font.render("THE WAY OUT", True, TITLE_C)
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)))
@@ -96,16 +99,17 @@ class MainMenu:
(self.width // 2 - 90, ly),
(self.width // 2 + 90, ly), 2)
color = ACCENT if is_hovered else INK
text_surf = self.font.render(btn["text"], True, color)
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 = self.small_font.render(
tip = theme.text_surface(
self.small_font,
f"WASD/Arrows move & aim {d} Space shoot {d} "
f"Shift dash {d} E use",
True, MUTED)
MUTED)
screen.blit(tip, tip.get_rect(
center=(self.width // 2, self.height - 58)))
@@ -177,8 +181,8 @@ class SettingsMenu:
for btn in self.buttons:
is_hovered = btn["rect"].collidepoint(mouse_pos)
color = ACCENT if is_hovered else INK
screen.blit(self.font.render(
btn["text"], True, color), btn["rect"])
screen.blit(theme.text_surface(
self.font, btn["text"], color), btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
@@ -280,9 +284,10 @@ class LevelMenu:
_draw_back_hint(screen, self.small_font)
if not self.entries:
empty = self.small_font.render(
empty = theme.text_surface(
self.small_font,
"No levels found — check assets/levels/manifest.json",
True, MUTED)
MUTED)
screen.blit(empty, empty.get_rect(
center=(self.width // 2, self.height // 2)))
return
@@ -298,7 +303,7 @@ class LevelMenu:
color = DONE_C
else:
color = INK
text_surf = self.font.render(btn["text"], True, color)
text_surf = theme.text_surface(self.font, btn["text"], color)
screen.blit(text_surf, btn["rect"])
if is_hovered:
_hover_marker(screen, btn["rect"])
@@ -307,8 +312,8 @@ class LevelMenu:
if btn["custom"]:
# No pill — a quiet prefix keeps the row flat.
tag = f"custom · {tag}"
tag_surf = self.tag_font.render(
tag, True,
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)))
@@ -319,8 +324,8 @@ class LevelMenu:
# 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 = self.best_font.render(
f"best {m}:{s:02d}", True, 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)))
@@ -448,12 +453,12 @@ class CharacterMenu:
else:
color = INK
text_surf = self.name_font.render(btn["text"], True, color)
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 = self.tagline_font.render(
btn["tagline"], True, MUTED)
tag = theme.text_surface(
self.tagline_font, btn["tagline"], MUTED)
screen.blit(tag, tag.get_rect(
topleft=(btn["rect"].left, btn["rect"].bottom + 4)))
@@ -491,9 +496,9 @@ class CharacterMenu:
left = cx - card_w // 2
top = self.height // 2 - 220
name = self.card_font.render(btn["text"], True, TITLE_C)
name = theme.text_surface(self.card_font, btn["text"], TITLE_C)
screen.blit(name, name.get_rect(center=(cx, top)))
tag = self.tagline_font.render(btn["tagline"], True, MUTED)
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),
@@ -505,20 +510,30 @@ class CharacterMenu:
("DAMAGE", cls.attack_damage, 25),
("FIRE RATE", 1.0 / max(0.01, cls.attack_cooldown), 6.0),
]
bar_x = left + 130
bar_w = card_w - 170
# 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 = self.stat_font.render(label, True, MUTED)
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 = self.stat_font.render(
num = theme.text_surface(
self.stat_font,
f"{val:.1f}" if isinstance(val, float) else str(val),
True, INK)
INK)
screen.blit(num, num.get_rect(midleft=(bar_x + bar_w + 12, y + 5)))
y += 56
@@ -564,7 +579,7 @@ class PauseMenu:
overlay.fill((*BG, 210))
screen.blit(overlay, (0, 0))
title = self.title_font.render("PAUSED", True, TITLE_C)
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)
@@ -577,11 +592,12 @@ class PauseMenu:
for btn in self.buttons:
hov = btn["rect"].collidepoint(mp)
col = ACCENT if hov else INK
screen.blit(self.font.render(btn["text"], True, col), btn["rect"])
screen.blit(theme.text_surface(
self.font, btn["text"], col), btn["rect"])
if hov:
_hover_marker(screen, btn["rect"])
hint = self.hint_font.render("Esc to resume", True, MUTED)
hint = theme.text_surface(self.hint_font, "Esc to resume", MUTED)
screen.blit(hint, hint.get_rect(
center=(self.width // 2, self.height - 88)))
modified theme.py
@@ -66,6 +66,34 @@ def font(px):
return f
_text_cache = {}
def text_surface(font_, s, color):
"""Cached ``font_.render(s, True, color)`` for static UI labels.
Every menu/title primitive re-renders the same constant strings
each frame (only the hover *colour* toggles); the surface a fresh
render produces is fully determined by ``(font, string, colour)``
at the fixed ``True`` antialias, so memoise it. Keyed by
``id(font_)`` — safe because every Font is held in ``_font_cache``
for the whole process and never GC'd, so an id can't be recycled.
The key set is statically bounded the same way ``_font_cache`` is
(a fixed label set × the fixed palette × the size ladder), so a
plain dict needs no eviction. Blitting never mutates the source
surface, so one shared copy is safe to blit every frame.
Not named ``text`` so it cannot shadow the ``text`` parameter of
``draw_title`` / ``draw_toast`` when they call it.
"""
key = (id(font_), s, color)
surf = _text_cache.get(key)
if surf is None:
surf = font_.render(s, True, color)
_text_cache[key] = surf
return surf
# --- helpers ---------------------------------------------------------
def measure(font_, text):
"""Size a string for a layout-only placement that is never blitted."""
@@ -74,7 +102,7 @@ def measure(font_, text):
def draw_title(screen, font_, text, width, y=96):
"""Screen title in caps with a thin centred underline."""
surf = font_.render(text.upper(), True, TITLE_C)
surf = text_surface(font_, text.upper(), TITLE_C)
rect = surf.get_rect(center=(width // 2, y))
screen.blit(surf, rect)
ly = rect.bottom + 16
@@ -83,7 +111,7 @@ def draw_title(screen, font_, text, width, y=96):
def draw_back_hint(screen, font_):
surf = font_.render("ESC BACK", True, MUTED)
surf = text_surface(font_, "ESC BACK", MUTED)
screen.blit(surf, surf.get_rect(topleft=(48, 44)))
@@ -135,7 +163,7 @@ def draw_toast(screen, text, font_, *, center_x, center_y,
bg = shade(BG, +30)
if border is None:
border = LINE_C
surf = font_.render(text, True, fg)
surf = text_surface(font_, text, fg)
box = surf.get_rect()
box.width += pad_x * 2
box.height += pad_y * 2
@@ -281,8 +309,14 @@ class MenuScene:
return slab
def _build_vignette(self):
v = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
v.fill((*BG, self.VIGNETTE_ALPHA))
# Per-surface uniform alpha (SDL's fast blit path), pixel-
# identical to a uniform (*BG, A) SRCALPHA blit: both resolve
# to dst = BG·(A/255) + dst·(1 − A/255). A plain Surface +
# set_alpha blits far cheaper than a full-screen per-pixel
# SRCALPHA surface (the single heaviest menu op).
v = pygame.Surface((self.width, self.height))
v.fill(BG)
v.set_alpha(self.VIGNETTE_ALPHA)
return v
def _build_actors(self, count, scale):