#!/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')