146 lines
6 KiB
Python
146 lines
6 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate UI icons for item designs that must match js/items.js.
|
|
|
|
The game is buildless, but these icons are generated snapshots of the field
|
|
appearance. Do not invent separate tool-UI-only designs.
|
|
"""
|
|
from __future__ import annotations
|
|
from pathlib import Path
|
|
import json, math, re
|
|
from PIL import Image, ImageDraw
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
OUT = ROOT / "assets" / "ui"
|
|
REGISTRY = ROOT / "js" / "item_registry.js"
|
|
|
|
|
|
def visual_registry() -> dict:
|
|
text = REGISTRY.read_text(encoding="utf-8")
|
|
match = re.search(r"String\.raw`([\s\S]*?)`", text)
|
|
if not match:
|
|
raise RuntimeError("Could not find JSON visual registry in js/item_registry.js")
|
|
return json.loads(match.group(1))
|
|
|
|
|
|
def save_icon(name: str, draw_fn) -> None:
|
|
im = Image.new("RGBA", (96, 96), (0, 0, 0, 0))
|
|
d = ImageDraw.Draw(im, "RGBA")
|
|
draw_fn(d)
|
|
im.save(OUT / name, "WEBP", lossless=True, quality=95)
|
|
|
|
|
|
def draw_drop(fill, stroke, highlight):
|
|
def fn(d: ImageDraw.ImageDraw) -> None:
|
|
d.ellipse([18, 60, 78, 74], fill=(60, 45, 35, 32))
|
|
# Same horizontal oval as fallen water/zunda/mercury item sprites; rain overlay is separate diagonal lines.
|
|
d.ellipse([12, 33, 84, 63], fill=fill, outline=stroke, width=4)
|
|
d.ellipse([24, 38, 45, 48], fill=highlight)
|
|
return fn
|
|
|
|
def rgba(value, fallback):
|
|
if not value:
|
|
return fallback
|
|
if value.startswith("#"):
|
|
value = value.lstrip("#")
|
|
if len(value) == 6:
|
|
return tuple(int(value[i:i+2], 16) for i in (0, 2, 4)) + (255,)
|
|
match = re.match(r"rgba?\(([^)]+)\)", value.replace(" ", ""))
|
|
if match:
|
|
parts = match.group(1).split(",")
|
|
r, g, b = (int(float(parts[i])) for i in range(3))
|
|
a = int(float(parts[3]) * 255) if len(parts) > 3 else 255
|
|
return (r, g, b, a)
|
|
return fallback
|
|
|
|
|
|
def draw_powder(fill, stroke, highlight):
|
|
def fn(d: ImageDraw.ImageDraw) -> None:
|
|
d.ellipse([18, 62, 78, 76], fill=(60, 45, 35, 36))
|
|
for i in range(20):
|
|
x = 48 + math.cos(i * 1.9) * (8 + (i % 5) * 4)
|
|
y = 56 - abs(math.sin(i * 1.3)) * 24 + (i % 3) * 3
|
|
r = 5 + (i % 4)
|
|
d.ellipse([x - r, y - r * 0.65, x + r, y + r * 0.65], fill=highlight if i % 4 == 0 else fill, outline=stroke, width=1)
|
|
d.ellipse([31, 31, 52, 39], fill=rgba("rgba(255,255,255,0.62)", (255, 255, 255, 158)))
|
|
return fn
|
|
|
|
|
|
def draw_pill(fill, stroke, highlight, scale=1.0):
|
|
def fn(d: ImageDraw.ImageDraw) -> None:
|
|
cx, cy = 48, 48
|
|
w, h = 66 * scale, 28 * scale
|
|
box = [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]
|
|
d.rounded_rectangle(box, radius=int(h / 2), fill=fill, outline=stroke, width=4)
|
|
d.line([(cx, cy - h / 2 + 2), (cx, cy + h / 2 - 2)], fill=highlight, width=4)
|
|
d.ellipse([cx - w * 0.32, cy - h * 0.24, cx - w * 0.08, cy - h * 0.04], fill=(255, 255, 255, 110))
|
|
return fn
|
|
|
|
|
|
def draw_laxative(d: ImageDraw.ImageDraw) -> None:
|
|
d.rounded_rectangle([14, 28, 82, 68], radius=15, fill=(244, 212, 154, 255), outline=(98, 61, 29, 235), width=4)
|
|
# Zunchi mound motif inside the tablet.
|
|
for box in ([26, 47, 44, 60], [38, 36, 60, 53], [54, 47, 74, 61], [46, 29, 61, 41]):
|
|
d.ellipse(box, fill=(96, 70, 38, 230), outline=(56, 39, 22, 130), width=2)
|
|
d.arc([22, 27, 72, 50], 205, 330, fill=(255, 255, 255, 135), width=3)
|
|
|
|
def star_points(cx: float, cy: float, r1: float, r2: float, n: int = 5, rot: float = -math.pi / 2):
|
|
return [(cx + math.cos(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2), cy + math.sin(rot + i * math.pi / n) * (r1 if i % 2 == 0 else r2)) for i in range(n * 2)]
|
|
|
|
|
|
def draw_mystery(d: ImageDraw.ImageDraw) -> None:
|
|
d.ellipse([18, 23, 78, 73], fill=(243, 236, 255, 255), outline=(91, 55, 150, 240), width=5)
|
|
try:
|
|
from PIL import ImageFont
|
|
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 44)
|
|
except Exception:
|
|
font = None
|
|
d.text((48, 48), "?", anchor="mm", fill=(91, 55, 150, 245), font=font)
|
|
d.arc([25, 24, 74, 54], 205, 330, fill=(255, 255, 255, 135), width=3)
|
|
|
|
def draw_bullet(d: ImageDraw.ImageDraw) -> None:
|
|
d.ellipse([20, 62, 76, 77], fill=(60, 45, 35, 38))
|
|
pts = [(76, 48), (60, 28), (27, 28), (16, 35), (16, 61), (27, 68), (60, 68)]
|
|
d.polygon(pts, fill=(244, 196, 106, 255), outline=(87, 51, 22, 240))
|
|
d.line(pts + [pts[0]], fill=(87, 51, 22, 240), width=4)
|
|
d.rectangle([16, 35, 29, 61], fill=(176, 111, 35, 255), outline=(87, 51, 22, 220), width=3)
|
|
d.line([(34, 36), (64, 42)], fill=(255, 237, 176, 170), width=4)
|
|
d.line([(34, 60), (58, 56)], fill=(142, 82, 30, 110), width=3)
|
|
|
|
|
|
def draw_sleep(d: ImageDraw.ImageDraw) -> None:
|
|
d.ellipse([17, 28, 79, 68], fill=(235, 228, 255, 255), outline=(74, 60, 144, 230), width=4)
|
|
d.ellipse([41, 31, 65, 56], fill=(112, 91, 188, 220))
|
|
d.ellipse([52, 27, 74, 52], fill=(235, 228, 255, 255))
|
|
d.arc([24, 27, 74, 57], 200, 330, fill=(255, 255, 255, 190), width=3)
|
|
|
|
|
|
def main() -> None:
|
|
registry = visual_registry()
|
|
renderer_map = {
|
|
"laxative_tablet": draw_laxative,
|
|
"mystery_tablet": draw_mystery,
|
|
"bullet": draw_bullet,
|
|
"sleep_tablet": draw_sleep,
|
|
}
|
|
for item_id, visual in registry.items():
|
|
if item_id == "ball":
|
|
continue
|
|
palette = visual.get("palette") or {}
|
|
renderer = visual.get("renderer")
|
|
fill = rgba(palette.get("fill"), (255, 250, 241, 255))
|
|
stroke = rgba(palette.get("stroke"), (141, 111, 72, 220))
|
|
highlight = rgba(palette.get("highlight") or palette.get("accent"), (255, 255, 255, 150))
|
|
if renderer == "oval_droplet":
|
|
draw_fn = draw_drop(fill, stroke, highlight)
|
|
elif renderer == "powder_pile":
|
|
draw_fn = draw_powder(fill, stroke, highlight)
|
|
elif renderer == "split_pill":
|
|
draw_fn = draw_pill(fill, stroke, highlight, visual.get("uiPreviewScale") or visual.get("scale") or 1.0)
|
|
else:
|
|
draw_fn = renderer_map.get(renderer)
|
|
if draw_fn:
|
|
save_icon(f"tool_{item_id}.webp", draw_fn)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|