#!/usr/bin/env python3 """WARDROBEGOD 2D tier — the paper-doll wardrobe. The flat cut-out garments are a first-class product, not a fallback: they're the cheapest content we can make and they feed the biggest existing consumer (90sDJsim, which already ships 441 of them). This module is deliberately a THIN wrapper over 90sDJsim's shipped compositor — `doll_composite.SLOT_DEF` holds anchors measured by hand off the real base body, and `place()` is the code those 441 sprites went through. Re-deriving either would just be a worse copy. What's ours: staging into wardrobegod's own library/doll/ instead of writing straight into a live game directory, and the export step that pushes a finished layer over to djsim. stage keyed RGBA -> library/doll/_i_.png compose ... stack base + layers in draw order -> one preview slugify the exact slug djsim's item_art() will look for Slots: hat | top | bottom | shoes | bag (canvas 704x1408) Draw order: base -> bottom -> shoes -> top -> hat, bag last. """ import ast, json, os, re, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) _SRC = os.path.join(HERE, 'doll_composite.py') def _consts_from_source(): """Read CANVAS and SLOT_DEF straight out of 90sDJsim's compositor. We deliberately do NOT `import doll_composite`: it pulls numpy at module level for its keyer and an HSV recolour helper, and we need neither (the farm's RMBG-2.0 does our keying). Parsing the literals keeps ONE source of truth for anchors that were measured by hand off the real base body — copying them here would let them drift silently. """ tree = ast.parse(open(_SRC).read()) found = {} for node in tree.body: if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name): n = node.targets[0].id if n in ('CANVAS', 'SLOT_DEF'): found[n] = ast.literal_eval(node.value) missing = {'CANVAS', 'SLOT_DEF'} - set(found) if missing: raise SystemExit(f'{_SRC} no longer defines {missing} — the doll contract moved') return found['CANVAS'], found['SLOT_DEF'] CANVAS, SLOT_DEF = _consts_from_source() def place(raw_rgba, slot, x=0, y=0, s=1.0): """Scale a keyed garment to its slot width and paste onto a fresh doll canvas. Pure-PIL port of doll_composite.place (that module's numpy import is what we're avoiding); behaviour is identical — crop to content, scale to the slot's target width, centre on the slot anchor, then nudge by x/y/s. """ bbox = raw_rgba.getbbox() g = raw_rgba.crop(bbox) if bbox else raw_rgba cx, top_y, tw = SLOT_DEF[slot] scale = (tw / g.width) * s g = g.resize((max(1, round(g.width * scale)), max(1, round(g.height * scale))), Image.LANCZOS) canvas = Image.new('RGBA', CANVAS, (0, 0, 0, 0)) canvas.alpha_composite(g, (round(cx - g.width / 2 + x), round(top_y + y))) return canvas ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DOLL_DIR = os.path.join(ROOT, 'library', 'doll') LAYOUT = os.path.join(DOLL_DIR, 'doll_layout.json') # djsim stacks these bottom-up; `bag` rides last so a strap crosses the torso. ORDER = ['base', 'bottom', 'shoes', 'top', 'hat', 'bag'] def slugify(name): """djsim's item_art() resolver: _art_slug = re.sub(r'[^a-z0-9]+','_',name.lower()).strip('_'). Reproduced EXACTLY — art only auto-wires if our filename slug matches theirs character for character, and a near-miss silently falls back to the generic slot art. """ return re.sub(r'[^a-z0-9]+', '_', (name or '').lower()).strip('_') def stage(cutout_path, slot, itemkey, x=0, y=0, s=1.0): """Crop-scale-place an already-keyed RGBA cutout onto the 704x1408 doll canvas.""" if slot not in SLOT_DEF: raise SystemExit(f'unknown slot {slot!r} (want {list(SLOT_DEF)})') key = slugify(itemkey) im = Image.open(cutout_path).convert('RGBA') stem = f'{slot}_i_{key}' os.makedirs(DOLL_DIR, exist_ok=True) # Bake the placement into the PNG and keep the layout entry neutral — that's what the # 432/433 shipped overlays do. A baked nudge PLUS a live layout nudge would double up. place(im, slot, x, y, s).save(os.path.join(DOLL_DIR, stem + '.png')) lay = json.load(open(LAYOUT)) if os.path.exists(LAYOUT) else {} lay[stem] = {'x': 0, 'y': 0, 's': 1.0} json.dump(lay, open(LAYOUT, 'w'), indent=2, sort_keys=True) return os.path.join(DOLL_DIR, stem + '.png') def compose(out_path, stems, base=None): """Stack staged layers in draw order onto one canvas — the dressed paper doll.""" canvas = Image.new('RGBA', CANVAS, (0, 0, 0, 0)) if base and os.path.exists(base): canvas.alpha_composite(Image.open(base).convert('RGBA')) rank = {s: i for i, s in enumerate(ORDER)} for stem in sorted(stems, key=lambda st: rank.get(st.split('_i_')[0], 99)): p = os.path.join(DOLL_DIR, stem + '.png') if os.path.exists(p): canvas.alpha_composite(Image.open(p).convert('RGBA')) canvas.save(out_path) return out_path def catalogue(): """Every staged layer, grouped by slot — the 2D wardrobe as the UI sees it.""" out = {} if not os.path.isdir(DOLL_DIR): return out for f in sorted(os.listdir(DOLL_DIR)): if not f.endswith('.png') or '_i_' not in f: continue slot, key_ = f[:-4].split('_i_', 1) out.setdefault(slot, []).append({'key': key_, 'stem': f[:-4], 'path': os.path.join(DOLL_DIR, f)}) return out if __name__ == '__main__': c = sys.argv[1] if len(sys.argv) > 1 else 'list' if c == 'stage': print(stage(sys.argv[2], sys.argv[3], sys.argv[4])) elif c == 'compose': print(compose(sys.argv[2], sys.argv[3:])) elif c == 'slugify': print(slugify(sys.argv[2])) else: for slot, items in catalogue().items(): print(f'{slot}: {len(items)}')