The flat cut-outs were the cheapest content on the board and the only tier with a shipping consumer, but wardrobegod couldn't show one: scan() filtered PNGs out of garments/ (fixed in Phase 0) and there was no compositor, no slots, no preview. Now there is a working dress-up. · library/doll/ seeded with 90sDJsim's 441 shipped sprites (165 top, 110 bottom, 60 shoes, 57 hat, base body) — an instant starter wardrobe rather than a cold start. · tools/doll.py wraps djsim's compositor. It deliberately does NOT import doll_composite: that module pulls numpy at import time for a keyer and an HSV recolour we never use (the farm's RMBG-2.0 does our keying), and numpy isn't installable here under PEP 668. Instead the measured anchors are parsed out of its source with ast, so there is still exactly one source of truth and they cannot drift; place() is a pure-PIL port. · slugify() reproduces djsim's item_art() _art_slug character for character, verified against it. That equality IS the drop-in contract — a near-miss silently falls back to generic slot art instead of erroring, so it's the kind of bug you'd ship without noticing. · /api/doll/gen runs the proven pipeline: flux_local on SOLID GREEN -> farm RMBG-2.0 -> staged to 704x1408. Green is not a preference: grey and checkerboard backgrounds eat garments in the key, and flux can't spell, so no text ever goes in the prompt. · /api/doll/compose composes server-side with PIL, so the browser preview and the exported PNG are the same bytes rather than two renderers that drift. · /api/doll/export writes into a LIVE game directory, so it defaults to a dry run and refuses any stem without _i_ — overwriting a generic slot fallback would restyle every unarted item in a shipping game. · UI: a doll 2D tab with per-slot pickers, randomise, strip, and export. Verified: composed a dressed doll from real sprites (correct base->bottom->shoes->top->hat order), catalogue reports 392 layers, export dry-run and the generic-art guard both behave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
144 lines
6.0 KiB
Python
144 lines
6.0 KiB
Python
#!/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 <cutout.png> <slot> <key> keyed RGBA -> library/doll/<slot>_i_<key>.png
|
|
compose <out.png> <stem>... stack base + layers in draw order -> one preview
|
|
slugify <name> 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)}')
|