UK
games/open-lemmings / tools / art_hd.py · commit 04e2e2a
#!/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 logoare drawn again at full resolution; soft layers are enlarged smoothly."""import json, math, osimport numpy as npfrom PIL import Image, ImageDraw, ImageFilter, ImageFontimport artfrom art import ROOT, W, H, THEMES, hexc, lerp, gradient, tile_noiseS = 3def 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 rgbdef 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 rgbdef 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 skydef 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 * 4INK = '#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 imdef 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')