ajhahn.de
← the-way-out commits

Commit

the-way-out

v0.2.2

ajhahnde · May 2026 · dab487687e980a6c270840cc4d701eac419372eb · view on GitHub →

renamed RELEASE_NOTES.md → CHANGELOG.md
@@ -1,4 +1,50 @@
# Release Notes
# CHANGELOG
## v0.2.2
A polish release: a new app icon plus editor and input fixes. No
save-file format changes; existing saves load as-is.
### App
- New app icon: a typographic "two" wordmark — short for The Way Out —
set in the game's own font and palette, replacing the wizard-in-a-
doorway icon. Regenerated by `scripts/make_icon.py` as the bundled
macOS `.icns` and the runtime pygame window icon.
### Tools
- Level editor: the toolbar **Test** button now launches a test
session. It previously saved silently but never started the level —
only the F5 shortcut worked.
### Gameplay
- Level-end input no longer leaks: Enter / Space / R on the win or
fail screen of an editor-launched test is consumed, so it can't fall
through into the editor and commit or extend a half-typed level name.
### Docs
- Release history moved from `RELEASE_NOTES.md` into this
`CHANGELOG.md`; the former is removed.
## v0.2.1
A bug-fix patch. No save-file format changes; existing saves load
as-is.
### Fixes
- Audio: a missing music track is remembered so `play_music`'s
unchanged-name guard engages, instead of re-statting the filesystem
and re-fading the bed every frame.
- Levels: boss and enemy contact damage no longer applies during the
level intro — no hits before the room is live.
- Editor: Esc on a finished editor-launched test is consumed, so it
returns to the editor canvas instead of bouncing to the main menu.
- Units: a missing or renamed sprite sheet degrades to a visible
magenta placeholder frame instead of crashing with `IndexError`.
## v0.2.0
@@ -6,6 +52,7 @@ 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
@@ -15,6 +62,7 @@ load as-is.
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.
@@ -27,11 +75,13 @@ load as-is.
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.
@@ -41,6 +91,7 @@ load as-is.
Initial release.
### Gameplay
- Top-down pixel-art escape-room shooter with 4-directional aim,
ranged combat, and a Shift dash with i-frames.
- Five playable characters with distinct HP, speed, damage, and
@@ -49,16 +100,19 @@ Initial release.
gates, spike hazards, and a two-phase boss (Mr. Green).
### Tools
- In-game level editor for the text-based map format.
- Data-driven levels (`assets/levels/`, `manifest.json`) — new rooms
require no code changes.
### App
- Self-updating macOS build; save data is stored outside the app
bundle and is preserved across updates.
- Accurate connectivity detection: the updater distinguishes "no
internet" from "update server unreachable / rate-limited".
### UI
- Shared theme across all screens (single palette and font set).
- Readable status and stat text; animated character-select preview.
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.2.0 — see [Release Notes](RELEASE_NOTES.md)
**Version:** v0.2.2 — see [Changelog](CHANGELOG.md)
## Play
@@ -59,3 +59,8 @@ in-game editor (`editor.py`) edits them live.
The app self-updates from this repository on launch via `updater.py`;
save data lives outside the app bundle and is never touched by updates.
## Versioning
Semantic versioning (`vMAJOR.MINOR.PATCH`); each release is a single
annotated git tag. See [`CHANGELOG.md`](CHANGELOG.md) for the history.
modified assets/icon.icns
binary file — no preview
modified assets/icon_1024.png
binary file — no preview
modified editor.py
@@ -302,7 +302,9 @@ class LevelEditor:
self._box_start = cell
self._box_erase = False
return None
self._click_left(mx, my)
result = self._click_left(mx, my)
if result is not None:
return result
elif event.button == 3:
self._mouse_buttons[2] = True
if shift and self.canvas_rect.collidepoint(mx, my):
@@ -364,7 +366,12 @@ class LevelEditor:
if name == 'save':
self._do_save()
elif name == 'test':
self._do_test()
# Propagate 'test' out so handle_input can
# return it to main — without this the
# toolbar Test button silently saves but
# never launches a test session (F5 worked
# because it returned _do_test() directly).
return self._do_test()
elif name == 'clear':
self.new_level(self.cols, self.rows, self.name)
self._flash("Cleared")
modified main.py
@@ -211,10 +211,17 @@ while running:
and event.type == pygame.KEYDOWN):
if event.key in (pygame.K_RETURN, pygame.K_SPACE):
_leave_game()
# Same reasoning as the Esc-finished branch above:
# consume the key so it can't also drive
# editor.handle_input on a return_state == "editor"
# session (Enter would commit a half-typed level
# name, R would append 'r' to it).
continue
elif event.key == pygame.K_r:
if not level_manager.load_level(
level_manager.level_id, current_character):
_leave_game()
continue
# Main menu
if game_state == "menu":
modified scripts/make_icon.py
@@ -25,6 +25,10 @@ os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
import pygame # noqa: E402
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO)) # run from anywhere: find settings/theme
import settings # noqa: E402
import theme # noqa: E402
ASSETS = REPO / "assets"
OUT_PNG = ASSETS / "icon_1024.png"
OUT_ICNS = ASSETS / "icon.icns"
@@ -102,49 +106,75 @@ def build_icon() -> None:
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).
# Warm glow centred on the canvas — the wordmark sits in the light.
# Single soft layer, normal alpha blend (NOT additive — stacked ADDs
# blow out to white).
glow = _radial_gradient(
CANVAS, CANVAS // 2, 540,
CANVAS, CANVAS // 2, CANVAS // 2,
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))
# Wordmark: lowercase "two" (short for The Way Out) in the game
# font, rotated 90° clockwise so it reads top->bottom (t, w, o).
# Each glyph is rendered, trimmed to its inked pixels (optical
# centering — not the font's line box), rotated, then stacked with
# explicit tracking. Separate glyphs + a real gap keep the letters
# distinct when the icon is shrunk to ~32 px (font-kerned "two"
# merges into a bar at that size) and match the spaced sketch.
font = pygame.font.Font(str(REPO / settings.FONT), 600)
glyphs = []
for ch_ in "two":
g = font.render(ch_, True, theme.TITLE_C)
g = g.subsurface(g.get_bounding_rect()).copy()
# -90 deg = clockwise in pygame: a glyph's left edge goes to the
# top, so the row t,w,o stacks top->bottom. 90 deg multiples
# rotate exactly (no blur).
glyphs.append(pygame.transform.rotate(g, -90))
gap = round(0.22 * sum(g.get_height() for g in glyphs) / len(glyphs))
stack_w = max(g.get_width() for g in glyphs)
stack_h = sum(g.get_height() for g in glyphs) + gap * (len(glyphs) - 1)
mark = pygame.Surface((stack_w, stack_h), pygame.SRCALPHA)
y = 0
for g in glyphs:
mark.blit(g, ((stack_w - g.get_width()) // 2, y))
y += g.get_height() + gap
# Fit inside the squircle-mask safe area: cap height, and width too
# so a wide render can't overflow either edge.
mw, mh = mark.get_size()
scale = (CANVAS * 0.62) / mh
if mw * scale > CANVAS * 0.5:
scale = (CANVAS * 0.5) / mw
mark = pygame.transform.smoothscale(
mark, (max(1, round(mw * scale)), max(1, round(mh * scale))))
center = (CANVAS // 2, CANVAS // 2)
# Seating halo: a soft dark silhouette behind the cream so it keeps
# contrast on the bright glow even shrunk to ~32 px. RGBA_MULT by
# (0,0,0,90) zeros RGB and drops alpha to ~35% in one pass; the
# 1.04x upscale + smoothscale softens it into a halo.
halo = mark.copy()
halo.fill((0, 0, 0, 90), special_flags=pygame.BLEND_RGBA_MULT)
halo = pygame.transform.smoothscale(
halo, (round(halo.get_width() * 1.04),
round(halo.get_height() * 1.04)))
canvas.blit(halo, halo.get_rect(center=center))
canvas.blit(mark, mark.get_rect(center=center))
# Vignette: corners fade to pure black, centre untouched.
canvas.blit(_vignette(CANVAS), (0, 0))
# Flatten onto an opaque surface — Apple icons must carry no alpha.
out = pygame.Surface((CANVAS, CANVAS))
out.fill((0, 0, 0))
out.blit(canvas, (0, 0))
OUT_PNG.parent.mkdir(parents=True, exist_ok=True)
pygame.image.save(canvas, str(OUT_PNG))
pygame.image.save(out, str(OUT_PNG))
print(f"wrote {OUT_PNG.relative_to(REPO)}")