ajhahn.de
← the-way-out
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()