Python 221 lines
"""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
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"
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 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, 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))
# Wordmark: lowercase "two" (short for The Way Out) in the game
# font, set vertically the way Mandarin is written — each glyph
# upright (NOT rotated), stacked top->bottom (t, w, o). Each glyph
# is rendered, trimmed to its inked pixels (optical centering — not
# the font's line box), 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)
glyphs.append(g.subsurface(g.get_bounding_rect()).copy())
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(out, 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()