#!/usr/bin/env python3 """ Contact sheets for the sprite part library. Writes PNGs into preview/ (or $PREVIEW_OUT). python preview.py bases # every base creature, bare and lightly varied python preview.py body # one base with markings and a few variations python preview.py part # a variation part on every compatible base python preview.py slot # every part of a slot on a default base python preview.py raw # 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 (default none), --scale N, --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 preview another library. """ import os import random import sys from pathlib import Path from PIL import Image, ImageDraw import generate_sprite as gs import sprite_parts as cfg OUT = Path(os.environ.get("PREVIEW_OUT", "preview")) # set PREVIEW_OUT to keep sheets apart PARTS = os.environ.get("PARTS_DIR", "parts") PALETTES = [("dark", ["#0D47A1", "#FFC107"]), ("light", ["#FFEB3B", "#5D4037"]), ("gray", ["#757575", "#E53935"])] BG = (226, 226, 220, 255) LABEL_H = 9 BY_NAME = {slot: {p["name"]: p for p in parts} for slot, parts in cfg.SLOTS.items()} DEFAULT_BODY = "base_fox" 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(cfg.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 = gs.CANVAS, gs.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 + gs.GROUND_MARGIN + 1) > gs.CANVAS spr = colored(gs.place_on_canvas(sprite), colors, typed(chosen, ptype)) if spr.size[0] > cw: spr = spr.crop(((spr.width - cw) // 2, spr.height - gs.CANVAS, (spr.width + cw) // 2, spr.height)) img.alpha_composite(spr, (cx, cy)) if expanded: d.rectangle((cx, cy, cx + cw - 1, cy + gs.CANVAS - 1), outline=(220, 0, 0, 255)) label = f"{label} {sprite.width}x{sprite.height}!" d.text((cx + 1, cy + gs.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.""" 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(): 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}, [], iter(args) for a in it: if a in ("--markings", "--scale", "--type"): opts[a[2:]] = next(it) else: rest.append(a) return opts, rest def main(argv): opts, args = parse_opts(argv) 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 cfg.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": main(["bases"]) for slot in cfg.SLOTS: main(["raw", slot]) main(["slot", slot]) else: print(__doc__) if __name__ == "__main__": main(sys.argv[1:])