""" Part library generated with PixelLab (64x64 sprites). Same interface as sprite_parts.py. The library is described by one JSON spec per base creature in pixellab_library/.json (schema in pixellab_library.py). The spec says how the art is generated (prompts, seeds), how parts are extracted, and how they are picked (types, plans, tags). This module turns the specs into the picking tables generate_sprite.py uses. Only parts that have been extracted (parts_pl//.png exists) are registered. Differences from the classic library: - Every body is available to every type (`other` defaults to 1); `types` only boosts. - Parts with a fixed-color ramp ("fixed": "fire", ...) are type-locked: they must list `types` and have other=0. - `fits()` keeps every random pick inside the 64x64 canvas, so parts from any body can be offered to every body without hand-written size deny lists. """ import json from pathlib import Path from sprite_parts import MOTIF_PAIRS as CLASSIC_MOTIF_PAIRS from sprite_parts import PLAN_WEIGHTS as CLASSIC_PLAN_WEIGHTS from sprite_parts import PLANS, TYPES, TYPE_COLORS, none, part # noqa: F401 (TYPES/TYPE_COLORS re-exported) NAME = "pixellab" CANVAS = 64 GROUND_MARGIN = 3 PARTS_DIR = "parts_pl" SPEC_DIR = Path(__file__).with_name("pixellab_library") ALL = PLANS FEATURE_SLOTS = ("head_top", "back", "tail") DISABLED_SLOTS = {"aura"} MAX_FEATURES = 2 MOTIF_SAME, MOTIF_CLASH = 2.5, 0.08 # Detailed generated art doesn't take painted-on spots well; markings stay off. MARKINGS = {"none": {"_other": 1}} # Weight of an empty slot, per slot. With MAX_FEATURES = 2 and head_top/back picked before tail, # these give about: head_top 69%, back 55%, tail 51% filled, 2% plain (simulated over all types # with 31 bodies / 111 parts). Re-tune when the library grows a lot. NONE_WEIGHT = {"head_top": 10.0, "back": 16.0, "tail": 1.0} PLAN_FLOOR = 1.0 # every plan stays possible for every type class SpecError(Exception): pass def load_specs(spec_dir=SPEC_DIR): specs = [] for path in sorted(Path(spec_dir).glob("*.json")): spec = json.loads(path.read_text()) spec["_path"] = str(path) specs.append(spec) return specs def _plans(value): return list(ALL) if value in (None, "all") else list(value) def _build(specs, parts_dir=PARTS_DIR): body, slots, motifs = [], {s: [] for s in FEATURE_SLOTS}, {} deny_from_parts = {} for s in specs: name = s["name"] for p in s.get("parts", []): if p.get("skip"): continue if p.get("fixed") and (p.get("other", 0) or not p.get("types")): raise SpecError(f"{s['_path']}: {p['name']} uses fixed colors '{p['fixed']}' and must be " f"type-locked: give it 'types' and other=0") if not (Path(parts_dir) / p["slot"] / f"{p['name']}.png").exists(): continue palette = "fixed" if p.get("fixed") else p.get("palette", "main") entry = part( p["name"], _plans(p.get("plans")), p.get("types"), p.get("other", 0.3), tags=list(p.get("tags", [])) + [f"_slot_{p['slot']}"], requires=p.get("requires", ()), excludes=p.get("excludes", ()), palette=palette, z=p.get("z"), desc=p.get("desc", p.get("edit", "")) + f" (from {name})") entry["source"] = name # the body it was generated on (see attached()) slots[p["slot"]].append(entry) if p.get("motif"): motifs[p["name"]] = p["motif"] for b in p.get("deny_bodies", []): deny_from_parts.setdefault(b, []).append(p["name"]) if not (Path(parts_dir) / "body" / f"{name}.png").exists(): continue heads = [s["head"]] if isinstance(s.get("head"), str) else list(s.get("head", [])) closed = [f"_slot_{slot}" for slot in FEATURE_SLOTS if slot not in s.get("slots", FEATURE_SLOTS)] body.append(part( name, [s["plan"]], s.get("types"), s.get("other", 1.0), tags=heads + list(s.get("tags", [])), excludes=list(s.get("excludes", [])) + closed, deny=list(s.get("deny", [])), desc=s.get("desc", ""))) for b in body: b["deny"] += deny_from_parts.get(b["name"], []) table = {"body": body, "aura": []} for slot in FEATURE_SLOTS: table[slot] = [none(other=NONE_WEIGHT[slot])] + slots[slot] return table, motifs SLOTS, MOTIFS = _build(load_specs()) BODY = SLOTS["body"] MOTIF_PAIRS = set(CLASSIC_MOTIF_PAIRS) _present = {b["plans"][0] for b in BODY} PLAN_WEIGHTS = {t: {plan: CLASSIC_PLAN_WEIGHTS[t].get(plan, 0) + PLAN_FLOOR for plan in PLANS if plan in _present} for t in TYPES} # ------------------------------------------------------------------ canvas fit + attachment _extents = {} _touch = {} def _extent(slot, name): """(anchors, opaque bbox, opaque mask without marker pixels) of a compiled part, cached.""" key = (slot, name) if key not in _extents: import numpy as np from PIL import Image import generate_sprite as gs # lazy: generate_sprite imports this module by name img, anchors = gs.load_part(PARTS_DIR, slot, name) raw = np.array(Image.open(Path(PARTS_DIR) / slot / f"{name}.png").convert("RGBA")) mask = raw[..., 3] > 0 for col in gs.MARKERS.values(): mask &= ~np.all(raw[..., :3] == col, axis=-1) _extents[key] = (anchors, img.getbbox(), mask) return _extents[key] MIN_COMPONENT = 6 # smaller specks are ignored by the attachment check def _components(mask): """8-connected components of a mask with at least MIN_COMPONENT pixels, as (ys, xs) arrays.""" import numpy as np seen = np.zeros_like(mask) h, w = mask.shape out = [] for y0, x0 in zip(*np.nonzero(mask)): if seen[y0, x0]: continue seen[y0, x0] = True stack, comp = [(y0, x0)], [] while stack: y, x = stack.pop() comp.append((y, x)) for ny in (y - 1, y, y + 1): for nx in (x - 1, x, x + 1): if 0 <= ny < h and 0 <= nx < w and mask[ny, nx] and not seen[ny, nx]: seen[ny, nx] = True stack.append((ny, nx)) if len(comp) >= MIN_COMPONENT: out.append(tuple(np.array(comp).T)) return out _comps = {} def contacts(body_name, slot, part_name): """For each sizeable piece of the part (placed at the body's anchor): does it touch the body (overlap or 8-adjacent)? Tuple of bools, one per piece, in a fixed order.""" key = (body_name, slot, part_name) if key not in _touch: import numpy as np banch, _, bmask = _extent("body", body_name) panch, _, pmask = _extent(slot, part_name) if (slot, part_name) not in _comps: _comps[(slot, part_name)] = _components(pmask) grown = bmask.copy() for dy in (-1, 0, 1): for dx in (-1, 0, 1): grown |= np.roll(np.roll(np.pad(bmask, 1), dy, 0), dx, 1)[1:-1, 1:-1] ox, oy = banch[slot][0] - panch["pivot"][0], banch[slot][1] - panch["pivot"][1] hits = [] for ys, xs in _comps[(slot, part_name)]: xs, ys = xs + ox, ys + oy inside = (xs >= 0) & (ys >= 0) & (xs < bmask.shape[1]) & (ys < bmask.shape[0]) hits.append(bool(grown[ys[inside], xs[inside]].any())) _touch[key] = tuple(hits) return _touch[key] _bodies = {b["name"] for b in BODY} def attached(body, slot, p): """Every piece of the part that touches its own source body must touch this body too. A pair of wings needs both wings attached; a stemless tail or back spikes can't hang in mid-air; pieces that float free by design (halo, sparks, orbs) may float anywhere.""" here = contacts(body["name"], slot, p["name"]) source = p.get("source") home = contacts(source, slot, p["name"]) if source in _bodies else (True,) * len(here) return all(h or not s for h, s in zip(here, home)) def fits(body, picks): """True if `body` plus `picks` ({slot: part}) composes within CANVAS, using the same placement as generate_sprite.compose + place_on_canvas, and every part is attached to the body.""" banch, bb, _ = _extent("body", body["name"]) bx, by = banch["pivot"] boxes = [(bb[0] - bx, bb[1] - by, bb[2] - bx, bb[3] - by)] for slot, p in picks.items(): if p["name"] == "none": continue if not attached(body, slot, p): return False anch, pb, _ = _extent(slot, p["name"]) ax, ay = banch[slot] ox, oy = ax - bx - anch["pivot"][0], ay - by - anch["pivot"][1] boxes.append((pb[0] + ox, pb[1] + oy, pb[2] + ox, pb[3] + oy)) w = max(b[2] for b in boxes) - min(b[0] for b in boxes) h = max(b[3] for b in boxes) - min(b[1] for b in boxes) return w + 2 <= CANVAS and h + GROUND_MARGIN + 1 <= CANVAS