flatland3-gfx 0.2.132

Flatland3 Macroquad + egui graphical play client
#!/usr/bin/env python3
"""Render Flatland3 client icons (PNG + raw RGBA for miniquad)."""

from __future__ import annotations

from pathlib import Path

from PIL import Image, ImageDraw, ImageFilter, ImageFont

ROOT = Path(__file__).resolve().parent
FONT_CANDIDATES = (
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
    "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
    "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
    "C:\\Windows\\Fonts\\arialbd.ttf",
)
FONT = next((p for p in FONT_CANDIDATES if Path(p).is_file()), None)
if FONT is None:
    raise SystemExit("no bold sans font found for icon render")
DIM = (91, 33, 182, 255)
BRAND = (167, 139, 250, 255)
INK = (30, 16, 53, 255)
LIGHT = (196, 181, 253, 255)

# 16×16: `.` transparent, `b` fill, `f` glyph. Each row is 16 chars.
PIXEL_F3 = [
    "................",
    ".bbbbbbbbbbbbbb.",
    ".bbbbbbbbbbbbbb.",
    ".bbffffbbffffbb.",
    ".bbfbbbbbbbbfbb.",
    ".bbfbbbbbbbbfbb.",
    ".bbffffbbffffbb.",
    ".bbfbbbbbbbbfbb.",
    ".bbfbbbbbbbbfbb.",
    ".bbfbbbbbffffbb.",
    ".bbbbbbbbbbbbbb.",
    ".bbbbbbbbbbbbbb.",
    ".bbbbbbbbbbbbbb.",
    ".bbbbbbbbbbbbbb.",
    ".bbbbbbbbbbbbbb.",
    "................",
]


def lerp(a: int, b: int, t: float) -> int:
    return int(round(a + (b - a) * t))


def multiply_l(a: Image.Image, b: Image.Image) -> Image.Image:
    return Image.frombytes(
        "L",
        a.size,
        bytes(int(x * y / 255) for x, y in zip(a.tobytes(), b.tobytes())),
    )


def gradient_bg(size: int) -> Image.Image:
    img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    px = img.load()
    denom = max(size - 1, 1)
    for y in range(size):
        for x in range(size):
            t = (x + y) / (2 * denom)
            px[x, y] = (
                lerp(DIM[0], BRAND[0], t),
                lerp(DIM[1], BRAND[1], t),
                lerp(DIM[2], BRAND[2], t),
                255,
            )
    return img


def rounded_mask(size: int, radius: int) -> Image.Image:
    mask = Image.new("L", (size, size), 0)
    ImageDraw.Draw(mask).rounded_rectangle(
        (0, 0, size - 1, size - 1), radius=radius, fill=255
    )
    return mask


def sheen(size: int) -> Image.Image:
    overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    ImageDraw.Draw(overlay).ellipse(
        (-size * 0.15, -size * 0.55, size * 1.05, size * 0.55),
        fill=(255, 255, 255, 48),
    )
    return overlay.filter(ImageFilter.GaussianBlur(radius=max(size // 24, 1)))


def draw_f3(size: int) -> Image.Image:
    img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    draw = ImageDraw.Draw(img)
    font = ImageFont.truetype(FONT, int(size * 0.56))
    bbox = draw.textbbox((0, 0), "F3", font=font)
    tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
    x = (size - tw) / 2 - bbox[0]
    y = (size - th) / 2 - bbox[1] + size * 0.02
    draw.text((x, y), "F3", font=font, fill=INK)
    return img


def clip_to_mask(img: Image.Image, mask: Image.Image) -> Image.Image:
    r, g, b, a = img.split()
    return Image.merge("RGBA", (r, g, b, multiply_l(a, mask)))


def render_large(size: int) -> Image.Image:
    radius = max(int(round(size * 0.22)), 3)
    mask = rounded_mask(size, radius)
    base = gradient_bg(size)
    base.putalpha(mask)
    out = Image.alpha_composite(base, clip_to_mask(sheen(size), mask))
    return Image.alpha_composite(out, clip_to_mask(draw_f3(size), mask))


def render_16() -> Image.Image:
    if any(len(row) != 16 for row in PIXEL_F3) or len(PIXEL_F3) != 16:
        raise SystemExit("PIXEL_F3 must be 16x16")
    img = Image.new("RGBA", (16, 16), (0, 0, 0, 0))
    px = img.load()
    for y, row in enumerate(PIXEL_F3):
        for x, ch in enumerate(row):
            t = (x + y) / 30
            if ch == "f":
                px[x, y] = LIGHT
            elif ch == "b":
                px[x, y] = (
                    lerp(DIM[0], BRAND[0], t),
                    lerp(DIM[1], BRAND[1], t),
                    lerp(DIM[2], BRAND[2], t),
                    255,
                )
    for x, y in ((1, 1), (14, 1), (1, 14), (14, 14)):
        px[x, y] = (0, 0, 0, 0)
    return img


def save_rgba(img: Image.Image, path: Path) -> None:
    path.write_bytes(img.convert("RGBA").tobytes())


def main() -> None:
    sizes = (16, 32, 48, 64, 128, 256)
    for size in sizes:
        img = render_16() if size == 16 else render_large(size)
        img.save(ROOT / f"icon-{size}.png")
        if size in (16, 32, 64):
            dest = ROOT / f"icon-{size}.rgba"
            save_rgba(img, dest)
            expected = size * size * 4
            got = dest.stat().st_size
            if got != expected:
                raise SystemExit(f"{size} rgba size {got} != {expected}")
    print("wrote", ", ".join(f"icon-{s}.png" for s in sizes))


if __name__ == "__main__":
    main()