#!/usr/bin/env python3
"""The HD pictures (assets/hd/, 3 x the retro ones, same names): python3 tools/art_hd.py   (after tools/art.py)
Flat-colour pixel art (sprites, icons) is redrawn with smooth round edges; skies, grounds, buttons and the logo
are drawn again at full resolution; soft layers are enlarged smoothly."""
import json, math, os
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
import art
from art import ROOT, W, H, THEMES, hexc, lerp, gradient, tile_noise

S = 3


def src(name):
    return Image.open(os.path.join(ROOT, name)).convert('RGBA')


def bleed(im, steps=8):
    """the colours of the edges spread into the fully transparent pixels (their alpha stays 0): a picture scaled smoothly
    (SDL in HD, the GL cards of the 3D view) mixes its edges with these colours instead of black, so no dark fringe"""
    if im.mode != 'RGBA':
        return im
    a = np.asarray(im, dtype=np.float32)
    rgb = a[..., :3].copy()
    known = (a[..., 3] > 0).astype(np.float32)
    if known.all() or not known.any():
        return im
    hole = known == 0
    acc = rgb * known[..., None]
    w = known.copy()
    for _ in range(steps):
        pa = np.pad(acc, ((1, 1), (1, 1), (0, 0)), mode='edge')
        pw = np.pad(w, 1, mode='edge')
        sa = sum(pa[1 + dy:pa.shape[0] - 1 + dy, 1 + dx:pa.shape[1] - 1 + dx] for dy in (-1, 0, 1) for dx in (-1, 0, 1))
        sw = sum(pw[1 + dy:pw.shape[0] - 1 + dy, 1 + dx:pw.shape[1] - 1 + dx] for dy in (-1, 0, 1) for dx in (-1, 0, 1))
        new = (w == 0) & (sw > 0)
        acc[new] = sa[new] / sw[new][:, None]
        w[new] = 1
    mean = (rgb * known[..., None]).sum(axis=(0, 1)) / known.sum()
    acc[w == 0] = mean
    rgb[hole] = acc[hole]
    out = np.concatenate([np.clip(rgb, 0, 255), a[..., 3:4]], axis=2).astype(np.uint8)
    return Image.fromarray(out, 'RGBA')


def save(im, name):
    path = os.path.join(ROOT, 'hd', name)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    bleed(im).save(path)


def smooth_up(im, wrap=False, ss=3):
    """pixel art -> S times bigger with round, anti-aliased edges: each output pixel takes the colour whose
    blurred mask is the strongest there"""
    pad = 4
    a = np.array(im)
    a = np.pad(a, ((pad, pad), (pad, pad), (0, 0)), mode='wrap' if wrap else 'constant')
    a[a[..., 3] == 0] = 0
    h, w = a.shape[:2]
    k = S * ss
    flat = a.reshape(-1, 4)
    colors, inv = np.unique(flat, axis=0, return_inverse=True)
    inv = inv.reshape(h, w)
    best = np.full((h * k, w * k), -1.0, dtype=np.float32)
    idx = np.zeros((h * k, w * k), dtype=np.int32)
    for i in range(len(colors)):
        m = Image.fromarray(((inv == i) * 255).astype(np.uint8)).resize((w * k, h * k), Image.NEAREST)
        v = np.asarray(m.filter(ImageFilter.GaussianBlur(k * 0.42)), dtype=np.float32)
        take = v > best
        best[take] = v[take]
        idx[take] = i
    big = colors[idx].astype(np.float32)
    big[..., :3] *= big[..., 3:4] / 255.0
    small = big.reshape(h * S, ss, w * S, ss, 4).mean(axis=(1, 3))
    alpha = small[..., 3:4]
    small[..., :3] = np.where(alpha > 0, small[..., :3] * 255.0 / np.maximum(alpha, 1e-3), 0)
    res = Image.fromarray(np.clip(small, 0, 255).astype(np.uint8), 'RGBA')
    return res.crop((pad * S, pad * S, (w - pad) * S, (h - pad) * S))


def filled_rgb(im):
    """the colours spread into the transparent pixels around them (no dark fringe when enlarging)"""
    a = np.asarray(im, dtype=np.float32)
    al = a[..., 3:4] / 255.0
    pm = Image.fromarray(np.clip(a[..., :3] * al, 0, 255).astype(np.uint8), 'RGB').filter(ImageFilter.GaussianBlur(3))
    bl = Image.fromarray((al[..., 0] * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(3))
    f = np.asarray(pm, dtype=np.float32) / np.maximum(np.asarray(bl, dtype=np.float32)[..., None] / 255.0, 1e-3)
    rgb = np.where(al > 0.999, a[..., :3], np.clip(f, 0, 255))
    return Image.fromarray(rgb.astype(np.uint8), 'RGB')


def soft_up(im):
    """a soft layer (glow, stars) enlarged smoothly"""
    size = (im.width * S, im.height * S)
    rgb = filled_rgb(im).resize(size, Image.BICUBIC)
    alpha = im.split()[3].resize(size, Image.BICUBIC)
    rgb.putalpha(alpha)
    return rgb


def shape_up(im):
    """a silhouette layer: smooth colours inside, a crisp anti-aliased outline"""
    size = (im.width * S, im.height * S)
    rgb = filled_rgb(im).resize(size, Image.BICUBIC)
    k = S * 3
    m = im.split()[3].resize((im.width * k, im.height * k), Image.NEAREST).filter(ImageFilter.GaussianBlur(k * 0.45))
    m = m.point(lambda v: 255 if v > 127 else 0).resize(size, Image.BOX)
    rgb.putalpha(m)
    return rgb


def hd_sky(name, t):
    """the sky painted at full resolution: gradient, soft clouds of light, round stars, sun / moon, aurora"""
    w, h = W * S, H * S
    sky = gradient(w, h, t['sky'], 0.5)
    r = art.random.Random(sum(map(ord, name)))
    # drifting veils of light
    n = (tile_noise(512, 4, 5) * 0.6 + tile_noise(512, 9, 6) * 0.4)
    veil = Image.fromarray((np.clip(n - 0.45, 0, 1) * 300).astype(np.uint8)).resize((w, h), Image.BICUBIC)
    fog = tuple(t['fog'])
    sky.paste(Image.new('RGBA', (w, h), fog + (255,)), (0, 0), veil.point(lambda v: v // 5))
    d = ImageDraw.Draw(sky, 'RGBA')
    dark = name in ('forest', 'night', 'cave', 'jungle', 'sky', 'desert')
    if dark:
        tint = (150, 240, 255) if name in ('cave', 'jungle') else (255, 255, 255)
        stars = Image.new('RGBA', (w, h), (0, 0, 0, 0))
        sd = ImageDraw.Draw(stars)
        for _ in range(260 if name not in ('cave', 'jungle', 'desert') else 90):
            x, y = r.uniform(0, w), r.random() ** 1.7 * h * 0.8
            rad = r.choice([0.8] * 7 + [1.1] * 5 + [1.6] * 3 + [2.4])
            a = r.randint(90, 255)
            sd.ellipse([x - rad, y - rad, x + rad, y + rad], fill=tint + (a,))
            if rad > 2:
                for len_, wd in ((9, 1),):
                    sd.line([x - len_, y, x + len_, y], fill=tint + (a // 2,), width=wd); sd.line([x, y - len_, x, y + len_], fill=tint + (a // 2,), width=wd)
        sky.alpha_composite(stars.filter(ImageFilter.GaussianBlur(3)).point(lambda v: min(255, int(v * 1.6))))
        sky.alpha_composite(stars)

    def disc(cx, cy, rad, color, halo, power=1.5, strength=0.8):
        sky.alpha_composite(art.glow(int(rad * 9), halo, power, strength), (int(cx - rad * 4.5), int(cy - rad * 4.5)))
        big = Image.new('RGBA', (int(rad * 8), int(rad * 8)), (0, 0, 0, 0))
        ImageDraw.Draw(big).ellipse([0, 0, rad * 8 - 1, rad * 8 - 1], fill=hexc(color))
        return big.resize((int(rad * 2), int(rad * 2)), Image.LANCZOS)
    if name == 'forest':
        sky.alpha_composite(disc(410 * S, 206 * S, 28 * S, '#fff0c8', '#ffd9a0'), (382 * S, 178 * S))
    if name == 'desert':
        sky.alpha_composite(disc(190 * S, 200 * S, 28 * S, '#fff8d8', '#fff0b0'), (162 * S, 172 * S))
    if name == 'ice':
        sky.alpha_composite(disc(400 * S, 60 * S, 12 * S, '#ffffff', '#ffffff', 1.7, 0.7), (388 * S, 48 * S))
    if name == 'sky':
        sky.alpha_composite(art.glow(900, '#ffd8a0', 1.5, 0.7), (90 * S, 60 * S))
    if name == 'forge':
        sky.alpha_composite(art.glow(1260, '#ff8a2c', 1.3, 0.55), (30 * S, 110 * S))
    if name == 'night':
        aur = Image.new('RGBA', (w // 2, h // 2), (0, 0, 0, 0))
        px = aur.load()
        for x in range(w // 2):
            xr = x * 2 / S
            top = (40 + 18 * math.sin(xr / 37) + 9 * math.sin(xr / 11 + 2)) * S / 2
            for k in range(int(52 * S / 2)):
                kk = k / (52 * S / 2)
                a = int(120 * (1 - kk) ** 1.5 * (0.6 + 0.4 * math.sin(xr / 5.0 + kk * 5)))
                if a > 0 and int(top + k) < h // 2:
                    px[x, int(top + k)] = (int(80 + 90 * kk), 255, int(190 + 40 * kk), a)
        sky.alpha_composite(aur.resize((w, h), Image.BICUBIC).filter(ImageFilter.GaussianBlur(2)))
        moon = disc(406 * S, 62 * S, 18 * S, '#f4f6ff', '#b8c8ff', 1.8, 0.6)
        cut = Image.new('L', moon.size, 0)
        ImageDraw.Draw(cut).ellipse([11 * S, -5 * S, 11 * S + 32 * S, 27 * S], fill=255)
        alpha = moon.split()[3]
        alpha.paste(0, (0, 0), cut.filter(ImageFilter.GaussianBlur(1)))
        moon.putalpha(alpha)
        sky.alpha_composite(moon, (388 * S, 44 * S))
    art.sky_extra(name, sky, S)   # the newer worlds' rays, bubbles, sun
    return sky


def lit(layer, t, far):
    """a silhouette layer with light: a bright rim where it faces the sky, haze rising from its foot"""
    a = np.asarray(layer, dtype=np.float32)
    alpha = a[..., 3]
    up = np.zeros_like(alpha)
    n = 4 * S if far else 2 * S
    up[n:] = alpha[:-n]
    rim = np.clip(alpha - up, 0, 255) / 255.0                      # lit top edges
    rim = np.asarray(Image.fromarray((rim * 255).astype(np.uint8)).filter(ImageFilter.GaussianBlur(S * 0.8)), dtype=np.float32) / 255.0
    fog = np.array(t['fog'], dtype=np.float32)
    rgb = a[..., :3]
    rgb = rgb + (fog - rgb) * (rim * (0.55 if far else 0.4))[..., None]
    if far:
        y = np.linspace(0, 1, a.shape[0])[:, None]
        haze = np.clip((y - 0.55) * 1.6, 0, 0.55)
        rgb = rgb + (fog - rgb) * haze[..., None] * 0.6
    out = np.dstack([np.clip(rgb, 0, 255), alpha]).astype(np.uint8)
    return Image.fromarray(out, 'RGBA')


def sky_gl(sky, name):
    """the sky again as a PNG at twice the retro size: GD reads no JPEG in the runtimes, the 3D view needs this one"""
    sky.resize((W * 2, H * 2), Image.LANCZOS).save(os.path.join(ROOT, 'hd', 'bg', f'{name}-sky-gl.png'), optimize=True)


def backgrounds():
    for name, t in THEMES.items():
        os.makedirs(os.path.join(ROOT, 'hd', 'bg'), exist_ok=True)
        sky = hd_sky(name, t).convert('RGB')
        sky.save(os.path.join(ROOT, 'hd', 'bg', f'{name}-sky.jpg'), quality=90)
        sky_gl(sky, name)
        for layer in ('far', 'near'):
            save(lit(shape_up(src(f'bg/{name}-{layer}.png')), t, layer == 'far'), f'bg/{name}-{layer}.png')
        # the ground: the same recipe as the retro tile, finer and unquantised, with soft pebbles
        base, dark, light, speck = t['earth']
        seed = sum(map(ord, name)) % 97 + 3
        n = (tile_noise(192, 4, seed) * 0.5 + tile_noise(192, 8, seed + 1) * 0.28 + tile_noise(192, 16, seed + 2) * 0.14 + tile_noise(192, 48, seed + 3) * 0.08)[..., None]
        b, d, l = (np.array(hexc(c)[:3], dtype=np.float32) for c in (base, dark, light))
        img = np.where(n < 0.5, d + (b - d) * (n / 0.5), b + (l - b) * ((n - 0.5) / 0.5))
        img += (tile_noise(192, 96, seed + 9)[..., None] - 0.5) * 16
        tile = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
        peb = Image.new('RGBA', (192 * 2, 192 * 2), (0, 0, 0, 0))
        pd = ImageDraw.Draw(peb)
        r = art.random.Random(seed)
        for _ in range(34):
            x, y, rx, ry = r.randrange(384), r.randrange(384), r.randint(3, 8), r.randint(2, 5)
            for ox in (-384, 0, 384):
                for oy in (-384, 0, 384):
                    pd.ellipse([x + ox - rx, y + oy - ry + 3, x + ox + rx, y + oy + ry + 3], fill=hexc(dark, 150))
                    pd.ellipse([x + ox - rx, y + oy - ry, x + ox + rx, y + oy + ry], fill=hexc(speck, 235))
        tile.alpha_composite(peb.resize((192, 192), Image.LANCZOS))
        save(tile, f'tiles/{name}-earth.png')
    save(smooth_up(src('tiles/steel.png'), wrap=True), 'tiles/steel.png')


# --- the Lumikin, drawn as shapes (units = retro pixels, feet on y = 16), 4 x oversampled ------------------------
Q = S * 4
INK = '#1c1230'


class Fig:
    def __init__(self, size=(16, 16)):
        self.size = size
        self.im = Image.new('RGBA', (size[0] * Q, size[1] * Q), (0, 0, 0, 0))
        self.d = ImageDraw.Draw(self.im)

    def e(self, cx, cy, rx, ry, c):
        self.d.ellipse([(cx - rx) * Q, (cy - ry) * Q, (cx + rx) * Q, (cy + ry) * Q], fill=hexc(c))

    def rr(self, x0, y0, x1, y1, rad, c):
        self.d.rounded_rectangle([x0 * Q, y0 * Q, x1 * Q, y1 * Q], rad * Q, fill=hexc(c))

    def poly(self, pts, c):
        self.d.polygon([(x * Q, y * Q) for x, y in pts], fill=hexc(c))

    def limb(self, x0, y0, x1, y1, wd, c):
        self.d.line([x0 * Q, y0 * Q, x1 * Q, y1 * Q], fill=hexc(c), width=int(wd * Q))
        self.e(x0, y0, wd / 2, wd / 2, c); self.e(x1, y1, wd / 2, wd / 2, c)

    def done(self, shade=True, glow_at=None):
        im = self.im
        if shade:   # light from the upper left
            a = np.asarray(im, dtype=np.float32)
            h, w = a.shape[:2]
            yy, xx = np.mgrid[0:h, 0:w]
            k = 1.1 - 0.22 * (xx / w) - 0.12 * (yy / h)
            a[..., :3] = np.clip(a[..., :3] * k[..., None], 0, 255)
            im = Image.fromarray(a.astype(np.uint8), 'RGBA')
        alpha = im.split()[3]
        edge = alpha.filter(ImageFilter.MaxFilter(2 * int(Q * 0.42) + 1)).filter(ImageFilter.GaussianBlur(Q * 0.12))
        out = Image.new('RGBA', im.size, (0, 0, 0, 0))
        out.paste(Image.new('RGBA', im.size, hexc(INK)), (0, 0), edge)
        out.alpha_composite(im)
        if glow_at:
            g = art.glow(int(5 * Q), glow_at[2], 2.0, 0.9)
            out.alpha_composite(g, (int(glow_at[0] * Q - g.width / 2), int(glow_at[1] * Q - g.height / 2)))
        return out.resize((self.size[0] * S, self.size[1] * S), Image.LANCZOS)


def lumi_hd(dy=0.0, legs=0, arms='none', flame='f', tool=None, swing=0, lean=0.0, small=0):
    f = Fig()
    y = dy
    bx = 8 + lean
    ph = (legs if isinstance(legs, int) else 0) * math.pi / 4
    # legs
    if legs == 'dangle':
        f.limb(7, 13.5 + y, 6.8 - 0.3 * (swing % 2), 15.3 + y, 1.1, '#3c2440'); f.limb(9, 13.5 + y, 9.2 + 0.3 * (swing % 2), 15.3 + y, 1.1, '#3c2440')
    elif legs == 'wide':
        f.limb(6.8, 13.5 + y, 5.8, 15.4 + y, 1.2, '#3c2440'); f.limb(9.2, 13.5 + y, 10.2, 15.4 + y, 1.2, '#3c2440')
    else:
        s1 = math.sin(ph)
        f.limb(7.3, 13.4 + y, 7.3 + 1.5 * s1, 15.4 + y - max(0, -math.cos(ph)) * 0.9, 1.2, '#3c2440')
        f.limb(8.7, 13.4 + y, 8.7 - 1.5 * s1, 15.4 + y - max(0, math.cos(ph)) * 0.9, 1.2, '#2c1830')
    # the back arm (behind the cloak)
    hand, cloak, cloak2 = '#ffe9c4', '#2ee0c4', '#159c9c'
    if arms == 'walk':
        f.limb(bx - 2.4, 10.6 + y, bx - 3.2 - 0.9 * math.sin(ph), 12.4 + y, 1.1, cloak2)
    # cloak: a little bell with a scalloped hem
    f.rr(bx - 3.1, 9.4 + y, bx + 3.1, 14.2 + y, 1.5, cloak)
    f.poly([(bx - 3.1, 12 + y), (bx - 3.6, 14.3 + y), (bx + 3.6, 14.3 + y), (bx + 3.1, 12 + y)], cloak)
    for i in range(4):
        f.e(bx - 2.7 + i * 1.8, 14.3 + y, 0.9, 0.55, cloak)
    f.poly([(bx + 1.2, 9.6 + y), (bx + 3.1, 9.9 + y), (bx + 3.6, 14.3 + y), (bx + 1.9, 14.7 + y)], cloak2)   # shaded side
    f.e(bx - 0.6, 11.2 + y, 0.55, 0.55, '#ffd23c')
    # head
    f.e(bx + 0.1, 7.3 + y, 3.1, 2.9, '#ffe9c4')
    f.e(bx + 0.3, 9.3 + y, 2.6, 0.9, '#f4cfa0')
    f.e(bx - 1.6, 8.3 + y, 0.7, 0.4, '#ffb4a0'); f.e(bx + 2.3, 8.3 + y, 0.6, 0.4, '#ffb4a0')
    for ex in (bx - 0.5, bx + 1.6):
        f.e(ex, 7.3 + y, 0.48, 0.72, '#2a1c3c'); f.e(ex + 0.15, 7.05 + y, 0.17, 0.2, '#ffffff')
    # flame: a drop of fire
    if tool != 'leaf' and not small:
        fy = 2.9 + y
        tip = bx + 0.25 * math.sin(swing * 2.1)
        outer, inner = ('#ff8a1c', '#ffd23c') if flame == 'f' else ('#e01c2c', '#ff7c4c')
        f.e(bx, fy + 0.5, 1.45, 1.45, outer); f.poly([(bx - 1.3, fy), (tip, fy - 2.7 - 0.3 * (swing % 2)), (bx + 1.3, fy)], outer)
        f.e(bx, fy + 0.7, 0.95, 1.0, inner); f.poly([(bx - 0.8, fy + 0.4), (tip, fy - 1.5), (bx + 0.8, fy + 0.4)], inner)
        f.e(bx, fy + 1.0, 0.45, 0.5, '#fffbe8')
    # front arm and tools
    sh = (bx + 2.4, 10.6 + y)
    if arms == 'walk':
        f.limb(sh[0], sh[1], sh[0] + 0.8 + 0.9 * math.sin(ph), 12.4 + y, 1.1, cloak); f.e(sh[0] + 0.8 + 0.9 * math.sin(ph), 12.6 + y, 0.6, 0.6, hand)
    elif arms == 'up':
        for sx, hx in ((bx - 2.6, bx - 3.4), (bx + 2.6, bx + 3.6)):
            f.limb(sx, 10.2 + y, hx, 7.2 + y, 1.1, cloak); f.e(hx, 6.8 + y, 0.65, 0.65, hand)
    elif arms == 'out':
        up = 0.5 * (swing % 2)
        for sx, hx in ((bx - 2.6, bx - 6.2), (bx + 2.6, bx + 6.4)):
            f.limb(sx, 10.6 + y, hx, 10.4 + y - up, 1.2, cloak); f.e(hx, 10.3 + y - up, 0.75, 0.75, hand)
    elif arms == 'climb':
        a = swing % 4
        for k, base in enumerate((8.4, 11.2)):
            yy = base + y - (0.9 if (a in (0, 1)) == (k == 0) else 0)
            f.limb(bx + 2.2, yy + 0.6, bx + 4.6, yy, 1.1, cloak); f.e(bx + 5.0, yy, 0.65, 0.65, hand)
    elif arms == 'punch':
        rch = [0.3, 2.2, 3.4, 1.4][swing % 4]
        f.limb(sh[0], sh[1], sh[0] + rch, 10.4 + y, 1.3, cloak); f.e(sh[0] + rch + 0.6, 10.4 + y, 1.15, 1.15, hand)
        if swing % 4 == 2:
            for ang in (-0.7, 0, 0.7):
                f.limb(sh[0] + rch + 2.0 * math.cos(ang) + 0.3, 10.4 + y + 2.0 * math.sin(ang), sh[0] + rch + 2.8 * math.cos(ang) + 0.3, 10.4 + y + 2.8 * math.sin(ang), 0.35, '#ffffff')
    elif arms == 'pick':
        ang = [-1.0, -0.35, 0.75, 0.1][swing % 4]
        hx, hy = sh[0] + 1.2, 10.6 + y
        f.limb(sh[0], sh[1], hx, hy, 1.1, cloak)
        tx, ty = hx + 3.6 * math.cos(ang), hy + 3.6 * math.sin(ang)
        f.limb(hx - 0.8 * math.cos(ang), hy - 0.8 * math.sin(ang), tx, ty, 0.6, '#8c5a34')
        nx, ny = -math.sin(ang), math.cos(ang)
        f.poly([(tx - nx * 2.3, ty - ny * 2.3), (tx + 0.7 * math.cos(ang), ty + 0.7 * math.sin(ang)), (tx + nx * 2.3, ty + ny * 2.3), (tx - 0.2 * math.cos(ang), ty - 0.2 * math.sin(ang))], '#c8d4e8')
        f.e(hx, hy, 0.65, 0.65, hand)
    elif arms == 'shovel':
        dd = [0, 0.8, 1.7, 0.8][swing % 4]
        f.limb(bx - 2.6, 10.6 + y, bx - 3.2, 12.2 + y, 1.1, cloak2)
        f.limb(bx + 3.3, 8.2 + y + dd, bx + 3.3, 13.2 + y + dd, 0.6, '#8c5a34')
        f.rr(bx + 2.2, 13 + y + dd, bx + 4.4, 15.4 + y + dd, 0.7, '#c8d4e8')
        f.limb(sh[0], sh[1], bx + 3.3, 9.6 + y + dd, 1.1, cloak); f.e(bx + 3.3, 9.6 + y + dd, 0.65, 0.65, hand)
    elif arms == 'brick':
        dd = [0, 0.3, 2.0, 3.0][swing % 4]
        f.limb(sh[0], sh[1], bx + 3.6, 11.2 + y + dd * 0.7, 1.1, cloak)
        f.rr(bx + 3.0, 11.3 + y + dd, bx + 7.2, 13.1 + y + dd, 0.3, '#e8b46c'); f.rr(bx + 3.0, 12.4 + y + dd, bx + 7.2, 13.1 + y + dd, 0.3, '#b07a3c')
        f.e(bx + 3.6, 11.3 + y + dd * 0.7, 0.65, 0.65, hand)
    if tool == 'leaf':
        k = 0.5 * (swing % 2)
        f.limb(bx, 3.6 + k, bx, 6.2 + y, 0.45, '#2c9c4c')
        f.d.pieslice([1.0 * Q, (0.2 + k) * Q, 15.0 * Q, (8.6 + k) * Q], 180, 360, fill=hexc('#5ce06c'))
        f.d.pieslice([8.0 * Q, (0.2 + k) * Q, 15.0 * Q, (8.6 + k) * Q], 270, 360, fill=hexc('#3cb85c'))
        for i in range(5):
            f.e(2.4 + i * 2.8, 4.4 + k, 1.4, 0.7, '#5ce06c' if i < 3 else '#3cb85c')
        for vx in (4.2, 8.0, 11.8):
            f.limb(8, 1.0 + k, vx, 4.2 + k, 0.3, '#2c9c4c')
    glow_at = None if tool == 'leaf' or small else (bx, 3.2 + y, '#ffd23c' if flame == 'f' else '#ff5c3c')
    im = f.done(True, glow_at)
    if small:   # leaving: it shrinks into a spark and rises
        k = [0.8, 0.55, 0.3, 0.12][small - 1]
        sm = im.resize((max(1, int(im.width * k)), max(1, int(im.height * k))), Image.LANCZOS)
        out = Image.new('RGBA', im.size, (0, 0, 0, 0))
        g = art.glow(int(im.width * min(0.92, 0.45 + 0.16 * small)), '#fff8d8', 1.6, 0.9)
        cy = int(im.height * (0.62 - 0.11 * small))
        out.alpha_composite(g, ((im.width - g.width) // 2, cy - g.height // 2))
        out.alpha_composite(sm, ((im.width - sm.width) // 2, cy - sm.height // 2))
        return out
    return im


def lumi_sheet():
    rows = [
        [lumi_hd(legs=i, arms='walk', swing=i, dy=-0.35 * abs(math.sin(i * math.pi / 4))) for i in range(8)],
        [lumi_hd(legs='dangle', arms='up', swing=i) for i in range(4)],
        [lumi_hd(dy=1, legs='dangle', arms='up', tool='leaf', swing=i) for i in range(4)],
        [lumi_hd(legs=i * 2, arms='climb', swing=i, lean=0.6) for i in range(4)],
        [lumi_hd(legs='wide', arms='out', flame='r' if i % 2 else 'f', swing=i) for i in range(4)],
        [lumi_hd(legs='wide', arms='brick', swing=i, dy=0.6 if i >= 2 else 0) for i in range(4)],
        [lumi_hd(legs=i * 2, arms='punch', swing=i, lean=[0, 0.4, 0.8, 0.3][i]) for i in range(4)],
        [lumi_hd(legs='wide', arms='pick', swing=i) for i in range(4)],
        [lumi_hd(legs='wide', arms='shovel', swing=i, dy=[0, 0, 0.7, 0][i]) for i in range(4)],
        [lumi_hd(small=i + 1) for i in range(4)],
    ]
    sheet = Image.new('RGBA', (8 * 16 * S, len(rows) * 16 * S), (0, 0, 0, 0))
    for r, frames in enumerate(rows):
        for i, fr in enumerate(frames):
            sheet.paste(fr, (i * 16 * S, r * 16 * S))
    save(sheet, 'sprites/lumi.png')


def exit_gate():
    """a stone arch around a door of swirling light, 6 frames of 40 x 44"""
    w, h = 40 * S, 44 * S
    sheet = Image.new('RGBA', (w * 6, h), (0, 0, 0, 0))
    yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
    cx, cy = w / 2, 28 * S
    for fr in range(6):
        fg = Fig((40, 44))
        fg.d.pieslice([3 * Q, 2 * Q, 37 * Q, 40 * Q], 180, 360, fill=hexc('#7c6cb0')); fg.rr(3, 21, 37, 44, 0.5, '#7c6cb0')
        for i in range(9):   # stones of the arch
            a = math.pi + i * math.pi / 8
            fg.limb(20 + 12.2 * math.cos(a), 21 + 13.8 * math.sin(a), 20 + 16.6 * math.cos(a), 21 + 18.6 * math.sin(a), 0.35, '#4c3c7c')
        for ry in (27, 33, 39):
            fg.limb(3.4, ry, 8.6, ry, 0.35, '#4c3c7c'); fg.limb(31.4, ry, 36.6, ry, 0.35, '#4c3c7c')
        fg.d.pieslice([9 * Q, 8 * Q, 31 * Q, 36 * Q], 180, 360, fill=hexc('#fff8d8')); fg.rr(9, 22, 31, 44, 0.2, '#fff8d8')
        fg.rr(17, 0.4, 23, 6, 1.2, '#ffd23c'); fg.e(20, 3.2, 1.6, 1.6, '#fffbe8')
        im = fg.done(True)
        door = Image.new('L', (40 * Q, 44 * Q), 0)
        dd = ImageDraw.Draw(door)
        dd.pieslice([9 * Q, 8 * Q, 31 * Q, 36 * Q], 180, 360, fill=255); dd.rectangle([9 * Q, 22 * Q, 31 * Q, 44 * Q], fill=255)
        door = door.resize((w, h), Image.LANCZOS)
        ang = np.arctan2(yy - cy, xx - cx) + fr * math.pi / 3
        rad = np.hypot(xx - cx, yy - cy) / S
        v = 0.5 + 0.5 * np.sin(ang * 2 + rad * 0.45)
        core = np.clip(1 - rad / 9, 0, 1) ** 2
        rgb = np.stack([255 * np.ones_like(v), 210 + 41 * v + 0 * core, 60 + 172 * np.clip(v * 0.9 + core, 0, 1)], axis=-1)
        swirl = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8), 'RGB').convert('RGBA')
        im.paste(swirl, (0, 0), door)
        sheet.paste(im, (fr * w, 0))
    save(sheet, 'sprites/exit.png')


def hatch():
    fg = Fig((48, 26))
    fg.rr(0, 0, 48, 8, 1.5, '#4c3878'); fg.rr(0, 0, 48, 2.2, 1.0, '#8c74c4'); fg.rr(1, 6.2, 47, 8, 0.6, '#2c1c50')
    fg.poly([(8, 8.6), (22.5, 8.6), (10, 20.5)], '#6c54a4'); fg.poly([(40, 8.6), (25.5, 8.6), (38, 20.5)], '#5c4690')
    for x in (4, 44):
        fg.limb(x, 8, x, 17.5, 0.6, '#8c74c4'); fg.rr(x - 1.7, 17, x + 1.7, 23.5, 0.8, '#ffd23c'); fg.e(x, 20, 0.8, 1.3, '#fffbe8')
    for x in range(6, 43, 6):
        fg.e(x, 4.2, 0.7, 0.7, '#ffd23c')
    im = fg.done(True)
    for x in (4, 44):
        g = art.glow(14 * S, '#ffd23c', 1.8, 0.8)
        im.alpha_composite(g, (int(x * S - g.width / 2), int(20 * S - g.height / 2)))
    save(im, 'sprites/hatch.png')


def icon(name):
    f = Fig((16, 16))
    W_, G, B, T = '#ffffff', '#c8d4e8', '#8c5a34', '#e8b46c'
    if name == 'climb':
        f.rr(10.5, 1, 15, 15, 1, '#8c7cc0'); f.limb(10.8, 5.5, 14.8, 5.5, 0.4, '#5c4c94'); f.limb(10.8, 10.5, 14.8, 10.5, 0.4, '#5c4c94')
        f.poly([(5, 2), (9, 7), (6.4, 7), (6.4, 14), (3.6, 14), (3.6, 7), (1, 7)], '#2ee0c4')
    elif name == 'glide':
        f.limb(8, 7, 8, 14, 0.7, '#2c9c4c'); f.e(8, 14.2, 1.3, 1.3, '#ffe9c4')
        f.d.pieslice([1 * Q, 1.5 * Q, 15 * Q, 13.5 * Q], 180, 360, fill=hexc('#5ce06c')); f.d.pieslice([8 * Q, 1.5 * Q, 15 * Q, 13.5 * Q], 270, 360, fill=hexc('#3cb85c'))
        for i in range(4):
            f.e(2.7 + i * 3.5, 7.5, 1.75, 1.0, '#5ce06c' if i < 2 else '#3cb85c')
        for vx in (4, 8, 12):
            f.limb(8, 2.4, vx, 7, 0.35, '#2c9c4c')
    elif name == 'boom':
        f.e(7, 10, 5, 5, '#3c2c5c'); f.e(5.2, 8.2, 1.5, 1.2, '#7c6cb0'); f.rr(8.6, 3.6, 11.4, 6.4, 0.5, '#5c4c94')
        f.limb(10.4, 3.8, 12.4, 2.2, 0.6, T)
        pts = [(13 + (2.6 if i % 2 == 0 else 1.1) * math.cos(i * math.pi / 5), 2.6 + (2.6 if i % 2 == 0 else 1.1) * math.sin(i * math.pi / 5)) for i in range(10)]
        f.poly(pts, '#ffd23c'); f.e(13, 2.6, 0.8, 0.8, W_)
    elif name == 'stop':
        f.e(8, 8, 6.6, 6.6, '#e8404c'); f.e(8, 8, 5.2, 5.2, '#ff6c5c'); f.rr(3.6, 6.6, 12.4, 9.4, 0.8, W_)
    elif name == 'bridge':
        for i in range(4):
            f.rr(1 + i * 3.4, 11.4 - i * 3.0, 5.6 + i * 3.4, 14 - i * 3.0, 0.4, T); f.rr(1 + i * 3.4, 13 - i * 3.0, 5.6 + i * 3.4, 14 - i * 3.0, 0.3, '#b07a3c')
    elif name == 'bash':
        f.rr(1, 6.4, 8, 9.6, 1.2, '#2ee0c4'); f.rr(6.4, 4.6, 13, 11.4, 2.2, '#ffe9c4')
        for ky in (6.6, 8.2, 9.8):
            f.limb(9.4, ky, 12.2, ky, 0.35, '#e0b488')
        for ang in (-0.6, 0, 0.6):
            f.limb(14 + 0.2 * math.cos(ang), 8 + 3.2 * math.sin(ang), 15.3, 8 + 4.6 * math.sin(ang), 0.5, W_)
    elif name == 'mine':
        f.limb(3, 14, 11.5, 4.5, 1.2, B)
        f.poly([(5.5, 2.2), (10.5, 1.6), (14.4, 5.4), (13.8, 10.6), (12.2, 6.6), (9.4, 3.8)], G)
    elif name == 'dig':
        f.limb(8, 1.6, 8, 8, 1.2, B); f.rr(5.6, 0.8, 10.4, 2.6, 0.8, B)
        f.poly([(4.2, 7.4), (11.8, 7.4), (11.8, 11), (8, 15.2), (4.2, 11)], G); f.poly([(8, 7.4), (11.8, 7.4), (11.8, 11), (8, 15.2)], '#9cacc8')
    elif name == 'minus':
        f.rr(2.5, 6.4, 13.5, 9.6, 1.4, W_)
    elif name == 'plus':
        f.rr(2.5, 6.4, 13.5, 9.6, 1.4, W_); f.rr(6.4, 2.5, 9.6, 13.5, 1.4, W_)
    elif name == 'pause':
        f.rr(3.4, 3, 6.8, 13, 1, W_); f.rr(9.2, 3, 12.6, 13, 1, W_)
    elif name == 'fast':
        f.poly([(2, 3), (8, 8), (2, 13)], W_); f.poly([(8.4, 3), (14.4, 8), (8.4, 13)], W_)
    elif name == 'nuke':
        pts = [(8 + (7 if i % 2 == 0 else 3.6) * math.cos(i * math.pi / 8), 8 + (7 if i % 2 == 0 else 3.6) * math.sin(i * math.pi / 8)) for i in range(16)]
        f.poly(pts, '#ff8a1c'); f.e(8, 8, 3.4, 3.4, '#ffd23c'); f.e(8, 8, 1.6, 1.6, W_)
    elif name == 'play':
        f.poly([(4, 2.5), (13.5, 8), (4, 13.5)], W_)
    elif name == 'lock':
        f.d.arc([4.6 * Q, 1.6 * Q, 11.4 * Q, 9.4 * Q], 180, 360, fill=hexc(G), width=int(1.5 * Q))
        f.limb(5.3, 5.5, 5.3, 7.5, 1.5, G); f.limb(10.7, 5.5, 10.7, 7.5, 1.5, G)
        f.rr(3, 7, 13, 14.4, 1.4, '#ffd23c'); f.e(8, 10.2, 1.2, 1.2, '#5c2c0c'); f.rr(7.5, 10.4, 8.5, 12.6, 0.3, '#5c2c0c')
    elif name == 'star':
        pts = [(8 + (7.2 if i % 2 == 0 else 3.1) * math.sin(i * math.pi / 5), 8.4 - (7.2 if i % 2 == 0 else 3.1) * math.cos(i * math.pi / 5)) for i in range(10)]
        f.poly(pts, '#ffd23c'); f.poly([pts[0], pts[1], (8, 8.4), pts[9]], '#fff0a0')
    elif name in ('turn_left', 'turn_right'):
        # a curved arrow: the camera swings round the level (drawn for the left, mirrored for the right)
        g = Fig((16, 16))
        g.d.arc([3 * Q, 4.4 * Q, 15 * Q, 18 * Q], 200, 330, fill=hexc('#5cf2e0'), width=int(2.2 * Q))
        g.poly([(1.6, 7.8), (6.8, 3.4), (7.0, 11.6)], '#5cf2e0')
        im = g.done(True)
        return im if name == 'turn_left' else im.transpose(Image.FLIP_LEFT_RIGHT)
    return f.done(True)


def icons():
    """the bar's icons, coloured and shaded; the retro set gets them at a third of the size"""
    names = json.load(open(os.path.join(ROOT, 'icons.json')))
    sheet = Image.new('RGBA', (len(names) * 16 * S, 16 * S), (0, 0, 0, 0))
    for i, n in enumerate(names):
        sheet.paste(icon(n), (i * 16 * S, 0))
    a = np.asarray(sheet, dtype=np.float32)
    grey = a.copy()
    lum = a[..., :3].mean(axis=2, keepdims=True)
    grey[..., :3] = lum * 0.42 + np.array([30, 24, 56], dtype=np.float32)
    dim = Image.fromarray(np.clip(grey, 0, 255).astype(np.uint8), 'RGBA')
    for variant, im in (('', sheet), ('-gold', sheet), ('-dim', dim)):
        save(im, f'sprites/icons{variant}.png')
        small = im.resize((im.width // S, im.height // S), Image.LANCZOS)
        small.save(os.path.join(ROOT, 'sprites', f'icons{variant}.png'))


def sprites():
    lumi_sheet(); exit_gate(); hatch(); icons()
    for name in ('glow-amber', 'glow-cyan', 'glow-white', 'glow-red', 'glow-violet', 'glow-green', 'flash'):
        save(soft_up(src(f'sprites/{name}.png')), f'sprites/{name}.png')
    save(art.glow(48, '#000000', 1.2, 0.55).resize((48, 16), Image.LANCZOS), 'sprites/shadow.png')


def rounded(w, h, top, bottom, rim, radius):
    k = 2
    g = gradient(w * k, h * k, [(0, top), (0.5, lerp(hexc(top), hexc(bottom), 0.55) and '#%02x%02x%02x' % lerp(hexc(top), hexc(bottom), 0.55)), (1, bottom)], 0.5)
    mask = Image.new('L', (w * k, h * k), 0)
    ImageDraw.Draw(mask).rounded_rectangle([0, 0, w * k - 1, h * k - 1], radius * k, fill=255)
    d = ImageDraw.Draw(g)
    d.rounded_rectangle([1, 1, w * k - 2, h * k - 2], radius * k, outline=hexc(rim), width=2 * k)
    hi = Image.new('RGBA', (w * k, h * k), (0, 0, 0, 0))
    ImageDraw.Draw(hi).rounded_rectangle([3 * k, 3 * k, w * k - 3 * k, h * k * 0.45], radius * k * 0.7, fill=(255, 255, 255, 34))
    g.alpha_composite(hi)
    g.putalpha(mask)
    return g.resize((w, h), Image.LANCZOS)


def ui():
    for name, (w, h, top, bottom, rim) in {
        'btn': (32, 32, '#44327a', '#1c1438', '#6c58a8'), 'btn-on': (32, 32, '#ffc45c', '#c8501c', '#fff0b0'), 'btn-off': (32, 32, '#231a40', '#120c24', '#2c2250'),
        'btn-small': (24, 15, '#44327a', '#1c1438', '#6c58a8'), 'btn-small-on': (24, 15, '#ffc45c', '#c8501c', '#fff0b0'),
        'btn-wide': (200, 22, '#44327a', '#1c1438', '#6c58a8'), 'btn-wide-on': (200, 22, '#ffc45c', '#c8501c', '#fff0b0'),
    }.items():
        save(rounded(w * S, h * S, top, bottom, rim, 5 * S if h > 20 else 4 * S), f'sprites/{name}.png')
    bar = gradient(480 * S, 38 * S, [(0, '#2c2050'), (0.12, '#1c1438'), (1, '#0c0818')], 0.5)
    d = ImageDraw.Draw(bar)
    d.rectangle([0, 0, 480 * S, S - 1], fill=hexc('#7c64b0')); d.rectangle([0, S, 480 * S, 2 * S - 1], fill=hexc('#3c2c6c'))
    save(bar.convert('RGB'), 'sprites/hud.png')
    y, x = np.mgrid[0:270 * 2, 0:480 * 2] / 2.0
    d2 = np.sqrt(((x - 240) / 300) ** 2 + ((y - 125) / 190) ** 2)
    v = np.zeros((540, 960, 4), dtype=np.uint8)
    v[..., 2] = 16
    v[..., 3] = np.clip((d2 - 0.55) * 1.5, 0, 1) ** 1.6 * 200
    save(Image.fromarray(v, 'RGBA'), 'sprites/vignette.png')

    # the logo, drawn again at full size: same place and size as the retro one, smooth letters
    base = src('sprites/logo.png')
    w, h = base.width * S, base.height * S
    font = ImageFont.truetype(os.path.join(ROOT, 'fonts', 'ui.ttf'), 30 * 2 * S)
    small = ImageFont.truetype(os.path.join(ROOT, 'fonts', 'ui.ttf'), 30)
    mask = Image.new('L', (w, h), 0)
    d = ImageDraw.Draw(mask)
    x = 6
    for ch in 'LUMIKINS':
        d.text(((x * 2 + 40) * S, (2 * 2 + 30) * S), ch, font=font, fill=255)
        x += ImageDraw.Draw(Image.new('L', (1, 1))).textlength(ch, font=small) + 2
    grad = gradient(w, h, [(0, '#fffbe0'), (0.12, '#fffbe0'), (0.42, '#ffd23c'), (0.68, '#ff8a1c'), (0.9, '#e0428c'), (1, '#e0428c')], 0.5)
    edge = mask.filter(ImageFilter.GaussianBlur(5 * S / 2)).point(lambda v: 255 if v > 40 else 0).filter(ImageFilter.GaussianBlur(1))
    im = Image.new('RGBA', (w, h), (0, 0, 0, 0))
    halo = Image.new('RGBA', (w, h), (0, 0, 0, 0))
    halo.paste(Image.new('RGBA', (w, h), (255, 170, 60, 170)), (0, 0), edge)
    im.alpha_composite(halo.filter(ImageFilter.GaussianBlur(12 * S)))
    im.paste(Image.new('RGBA', (w, h), hexc('#1c1230')), (0, 4 * S), edge)
    im.paste(Image.new('RGBA', (w, h), hexc('#3c1c5c')), (0, 0), edge)
    im.paste(grad, (0, 0), mask)
    shine = Image.new('L', (w, h), 0)
    shine.paste(mask, (0, 2 * S))
    top = Image.composite(mask, Image.new('L', (w, h), 0), Image.eval(shine, lambda v: 255 - v))
    im.paste(Image.new('RGBA', (w, h), (255, 255, 255, 255)), (0, 0), top)
    save(im, 'sprites/logo.png')


if __name__ == '__main__':
    backgrounds(); sprites(); ui()
    print('HD assets drawn')
