293 lines
12 KiB
Python
Executable File
293 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Contact sheets for a sprite part library. Writes PNGs into preview/ (or $PREVIEW_OUT).
|
|
|
|
python preview.py [--library pixellab|classic] <command> ... # default library: pixellab
|
|
|
|
python preview.py bases # every base creature, bare and lightly varied
|
|
python preview.py body <name> # one base with markings and a few variations
|
|
python preview.py part <slot> <name> # a variation part on every compatible base
|
|
python preview.py slot <slot> # every part of a slot on a default base
|
|
python preview.py raw <slot> # parts of a slot at 8x: raw keys + markers, and recolored
|
|
python preview.py show body/base_fox tail/tail_leaf # chosen parts at 10x, raw + recolored
|
|
python preview.py zoom body=base_fox tail=tail_leaf + body=base_blob head_top=leaf_sprout # tight crops, 5x
|
|
python preview.py all # raw + slot sheets for every slot, plus bases
|
|
|
|
Options: --markings <kind> (default none), --scale N, --type <type> (type ramp)
|
|
Grid sheets are drawn in three test palettes (dark / light / near-gray), one row block each.
|
|
Cells with a red frame triggered "canvas expanded". Set PARTS_DIR to override the library's parts folder.
|
|
"""
|
|
import os
|
|
import random
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
import generate_sprite as gs
|
|
from sprite_parts import TYPES
|
|
|
|
OUT = Path(os.environ.get("PREVIEW_OUT", "preview")) # set PREVIEW_OUT to keep sheets apart
|
|
lib = None # registry module, set in main
|
|
PARTS = None
|
|
PALETTES = [("dark", ["#0D47A1", "#FFC107"]), ("light", ["#FFEB3B", "#5D4037"]), ("gray", ["#757575", "#E53935"])]
|
|
BG = (226, 226, 220, 255)
|
|
LABEL_H = 9
|
|
|
|
BY_NAME = {}
|
|
DEFAULT_BODY = None
|
|
|
|
|
|
def use_library(name):
|
|
"""Point the module state at a registry module."""
|
|
global lib, PARTS, BY_NAME, DEFAULT_BODY
|
|
lib = gs.load_library(name)
|
|
PARTS = os.environ.get("PARTS_DIR", lib.PARTS_DIR)
|
|
BY_NAME = {slot: {p["name"]: p for p in parts} for slot, parts in lib.SLOTS.items()}
|
|
names = [b["name"] for b in lib.BODY]
|
|
DEFAULT_BODY = "base_fox" if "base_fox" in names else (names[0] if names else None)
|
|
|
|
|
|
def P(slot, name):
|
|
return BY_NAME[slot][name]
|
|
|
|
|
|
def exists(slot, name):
|
|
return (Path(PARTS) / slot / f"{name}.png").exists()
|
|
|
|
|
|
def allowed(slot, name, chosen):
|
|
"""Same rules as generate_sprite.candidates, against an explicit selection."""
|
|
p = P(slot, name)
|
|
body = P("body", chosen["body"])
|
|
tags, excluded, denied = {f"plan:{body['plans'][0]}"}, set(), set()
|
|
for s, n in chosen.items():
|
|
if s != slot and not s.startswith("_") and n != "none":
|
|
tags |= set(P(s, n)["tags"])
|
|
excluded |= set(P(s, n)["excludes"])
|
|
denied |= set(P(s, n)["deny"])
|
|
if body["plans"][0] not in p["plans"] or name in denied:
|
|
return False
|
|
if p["requires"] and not set(p["requires"]) & tags:
|
|
return False
|
|
return not (set(p["tags"]) & excluded or set(p["excludes"]) & tags)
|
|
|
|
|
|
def top_type(slot, name):
|
|
p = P(slot, name)
|
|
return max(TYPES, key=lambda t: gs.part_weight(p, t))
|
|
|
|
|
|
def typed(chosen, ptype):
|
|
"""Type ramp to show: explicit, else the first type-palette part's best type, else the body's."""
|
|
if ptype:
|
|
return ptype
|
|
for s, n in chosen.items():
|
|
if s != "body" and not s.startswith("_") and n != "none" and P(s, n)["palette"] == "type":
|
|
return top_type(s, n)
|
|
return top_type("body", chosen["body"])
|
|
|
|
|
|
def compose(chosen, markings="none"):
|
|
chosen = dict(chosen)
|
|
m = chosen.pop("_markings", markings)
|
|
picks = {s: P(s, n) for s, n in chosen.items() if n and n != "none"}
|
|
return gs.compose(PARTS, picks, m, random.Random(1))
|
|
|
|
|
|
def colored(spr, colors, ptype):
|
|
main, accent = gs.pick_main_accent([(gs.hex_to_rgb(c), 1.0) for c in colors], ptype)
|
|
return gs.recolor(spr, gs.build_palette(main, accent, ptype))
|
|
|
|
|
|
def sheet(cells, path, ptype=None, markings="none", scale=4, cols=6):
|
|
"""cells: list of (label, chosen-dict). Renders each in all three palettes at 1x cells."""
|
|
if not cells:
|
|
print(f"nothing to render for {path}")
|
|
return
|
|
cols = min(cols, len(cells))
|
|
rows_per = (len(cells) + cols - 1) // cols
|
|
cw, ch = lib.CANVAS, lib.CANVAS + LABEL_H
|
|
block_h = rows_per * ch + 12
|
|
img = Image.new("RGBA", (cols * cw, block_h * len(PALETTES)), BG)
|
|
d = ImageDraw.Draw(img)
|
|
for bi, (pname, colors) in enumerate(PALETTES):
|
|
y0 = bi * block_h
|
|
d.text((2, y0 + 1), f"{pname}: {', '.join(colors)}", fill=(60, 60, 60, 255))
|
|
for i, (label, chosen) in enumerate(cells):
|
|
cx, cy = (i % cols) * cw, y0 + 12 + (i // cols) * ch
|
|
sprite = compose(chosen, markings)
|
|
expanded = max(sprite.width + 2, sprite.height + lib.GROUND_MARGIN + 1) > lib.CANVAS
|
|
spr = colored(gs.place_on_canvas(sprite, lib), colors, typed(chosen, ptype))
|
|
if spr.size[0] > cw:
|
|
spr = spr.crop(((spr.width - cw) // 2, spr.height - lib.CANVAS, (spr.width + cw) // 2, spr.height))
|
|
img.alpha_composite(spr, (cx, cy))
|
|
if expanded:
|
|
d.rectangle((cx, cy, cx + cw - 1, cy + lib.CANVAS - 1), outline=(220, 0, 0, 255))
|
|
label = f"{label} {sprite.width}x{sprite.height}!"
|
|
d.text((cx + 1, cy + lib.CANVAS - 1), label[:19], fill=(40, 40, 40, 255))
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
img.resize((img.width * scale, img.height * scale), Image.NEAREST).save(path)
|
|
print(path)
|
|
|
|
|
|
def zoom_sheet(groups, path, scale=5, markings="none", ptype=None):
|
|
"""Tight-cropped composites, one row per combo, one column per test palette."""
|
|
rows = []
|
|
for chosen in groups:
|
|
spr = compose(chosen, markings)
|
|
rows.append([colored(spr, colors, typed(chosen, ptype)) for _, colors in PALETTES])
|
|
cw = max(i.width for r in rows for i in r) + 4
|
|
ch = max(i.height for r in rows for i in r) + 4
|
|
img = Image.new("RGBA", (cw * len(PALETTES), ch * len(rows)), BG)
|
|
for yi, r in enumerate(rows):
|
|
for xi, im in enumerate(r):
|
|
img.alpha_composite(im, (xi * cw + (cw - im.width) // 2, yi * ch + ch - 2 - im.height))
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
img.resize((img.width * scale, img.height * scale), Image.NEAREST).save(path)
|
|
print(path)
|
|
|
|
|
|
def bodies():
|
|
return [b for b in BY_NAME["body"] if exists("body", b)]
|
|
|
|
|
|
def hosts_for(slot, name):
|
|
"""Every drawn base that can legally carry the part."""
|
|
cells = []
|
|
for b in bodies():
|
|
chosen = {"body": b, slot: name}
|
|
if slot == "body" or allowed(slot, name, chosen):
|
|
cells.append((b, chosen))
|
|
return cells
|
|
|
|
|
|
def raw_sheet(items, path, scale=8, cols=6):
|
|
"""items: [(slot, name)]. Each part drawn raw (with markers) and recolored in the dark palette."""
|
|
imgs = []
|
|
for slot, n in items:
|
|
if not exists(slot, n):
|
|
continue
|
|
raw = Image.open(Path(PARTS) / slot / f"{n}.png").convert("RGBA")
|
|
rec = colored(gs.load_part(PARTS, slot, n)[0], PALETTES[0][1], top_type(slot, n))
|
|
imgs.append((n, raw, rec))
|
|
if not imgs:
|
|
return
|
|
pad = 3
|
|
cw = max(i.width for _, i, _ in imgs) * 2 + pad * 3
|
|
ch = max(i.height for _, i, _ in imgs) + pad * 2
|
|
cols = min(cols, len(imgs))
|
|
rows = (len(imgs) + cols - 1) // cols
|
|
out = Image.new("RGBA", (cols * cw * scale, rows * (ch * scale + 12)), BG)
|
|
d = ImageDraw.Draw(out)
|
|
for k, (n, raw, rec) in enumerate(imgs):
|
|
x, y = (k % cols) * cw * scale, (k // cols) * (ch * scale + 12)
|
|
for j, im in enumerate((raw, rec)):
|
|
ox, oy = x + (pad + j * (raw.width + pad)) * scale, y + pad * scale
|
|
for yy in range(im.height): # checker shows the part canvas
|
|
for xx in range(im.width):
|
|
if (xx + yy) % 2:
|
|
d.rectangle((ox + xx * scale, oy + yy * scale, ox + (xx + 1) * scale - 1,
|
|
oy + (yy + 1) * scale - 1), fill=(205, 205, 200, 255))
|
|
out.alpha_composite(im.resize((im.width * scale, im.height * scale), Image.NEAREST), (ox, oy))
|
|
d.text((x + 2, y + ch * scale), f"{n} {raw.width}x{raw.height}", fill=(30, 30, 30, 255))
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
out.save(path)
|
|
print(path)
|
|
|
|
|
|
def variation(b):
|
|
"""A light, legal variation for a base: first allowed head_top, back and tail in a fixed list
|
|
(any part of the slot if the library has none of the listed names)."""
|
|
chosen = {"body": b}
|
|
prefs = {"head_top": ["leaf_sprout", "crest_flame", "gem_forehead", "horn_single"],
|
|
"back": ["wings_feather_small", "wings_bug", "spikes_back", "dorsal_fin"],
|
|
"tail": ["tail_flame_tip", "tail_leaf", "tail_fish_fin", "tail_stinger", "tail_wispy", "tail_rattle"]}
|
|
for slot, names in prefs.items():
|
|
known = BY_NAME.get(slot, {})
|
|
if not any(n in known for n in names):
|
|
names = [n for n in known if n != "none"]
|
|
for n in names:
|
|
if exists(slot, n) and allowed(slot, n, {**chosen, slot: n}):
|
|
chosen[slot] = n
|
|
break
|
|
return chosen
|
|
|
|
|
|
def parse_opts(args):
|
|
opts, rest, it = {"markings": "none", "scale": None, "type": None, "library": gs.DEFAULT_LIBRARY}, [], iter(args)
|
|
for a in it:
|
|
if a in ("--markings", "--scale", "--type", "--library"):
|
|
opts[a[2:]] = next(it)
|
|
else:
|
|
rest.append(a)
|
|
return opts, rest
|
|
|
|
|
|
def main(argv):
|
|
opts, args = parse_opts(argv)
|
|
if opts["library"] not in gs.LIBRARIES:
|
|
print(f"unknown library {opts['library']!r}, choose from {', '.join(gs.LIBRARIES)}")
|
|
return
|
|
use_library(opts["library"])
|
|
scale = int(opts["scale"]) if opts["scale"] else None
|
|
kw = {"markings": opts["markings"], "scale": scale or 4, "ptype": opts["type"]}
|
|
if not args:
|
|
print(__doc__)
|
|
return
|
|
cmd = args[0]
|
|
if cmd == "bases":
|
|
cells = []
|
|
for b in bodies():
|
|
cells += [(b, {"body": b}), (f"{b} +", variation(b))]
|
|
sheet(cells, OUT / "bases.png", **kw)
|
|
elif cmd == "body":
|
|
b = args[1]
|
|
cells = [("bare", {"body": b}), ("varied", variation(b))]
|
|
cells += [(m, {"body": b, "_markings": m}) for m in lib.MARKINGS if m != "none"]
|
|
sheet(cells, OUT / f"body_{b}.png", **kw)
|
|
elif cmd == "part":
|
|
slot, name = args[1], args[2]
|
|
sheet(hosts_for(slot, name), OUT / f"part_{slot}_{name}.png",
|
|
**{**kw, "ptype": kw["ptype"] or (top_type(slot, name) if slot != "body" else None)})
|
|
elif cmd == "slot":
|
|
slot = args[1]
|
|
cells = []
|
|
for n in BY_NAME[slot]:
|
|
if n == "none" or not exists(slot, n):
|
|
continue
|
|
hosts = hosts_for(slot, n)
|
|
pick = next((c for _, c in hosts if c["body"] == DEFAULT_BODY), hosts[0][1] if hosts else None)
|
|
if pick:
|
|
cells.append((n, pick))
|
|
sheet(cells, OUT / f"slot_{slot}.png", **kw)
|
|
elif cmd == "raw":
|
|
slot = args[1]
|
|
raw_sheet([(slot, n) for n in BY_NAME[slot] if n != "none"], OUT / f"raw_{slot}.png",
|
|
cols=3 if slot in ("aura", "body", "back") else 6)
|
|
elif cmd == "show":
|
|
raw_sheet([tuple(a.split("/", 1)) for a in args[1:]], OUT / "show.png", scale=scale or 10, cols=3)
|
|
elif cmd == "zoom":
|
|
groups, cur = [], {}
|
|
for a in args[1:]:
|
|
if a == "+":
|
|
groups.append(cur)
|
|
cur = {}
|
|
else:
|
|
k, v = a.split("=", 1)
|
|
cur[k] = v
|
|
groups.append(cur)
|
|
zoom_sheet(groups, OUT / "zoom.png", scale=scale or 5, markings=opts["markings"], ptype=opts["type"])
|
|
elif cmd == "all":
|
|
lflag = ["--library", opts["library"]]
|
|
main(lflag + ["bases"])
|
|
for slot in lib.SLOTS:
|
|
main(lflag + ["raw", slot])
|
|
main(lflag + ["slot", slot])
|
|
else:
|
|
print(__doc__)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|