- Authoritative 30 Hz server (ENet, SQLite): accounts, characters, wild AI, tall grass / water / wandering spawns, fishing, captures, follower chains, clock + weather, LAN beacon - Client: title / join / character creator / play screens, prediction + reconciliation, interpolated players and parties, HD-2D UI (dialogue with Professor Henry, HUD, collection) - Shared deterministic zone generator, movement, protocol, roster, appearance - Paper-doll character art pipeline (PixelLab base walk + per-item animation edits) - Brandling roster from the company list (logo colors, jev types), Blender safari props - Headless test bot
486 lines
21 KiB
Python
Executable File
486 lines
21 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Player and NPC art pipeline (PixelLab).
|
|
|
|
Architecture (see docs/CHARACTER_ART.md):
|
|
|
|
1. One base body per gender: `create-character-with-4-directions` (bald, plain white tee, grey
|
|
shorts, barefoot, light skin), then `walking-4-frames` template animation for south, east and
|
|
north. West is east mirrored at runtime. Every body has the same 15-frame layout:
|
|
3 directions x [standing, walk 0..3].
|
|
2. Every clothing / hair item is ONE `edit-animation-v2` call per body on all 15 base frames at
|
|
once ("...in bright pure green..."). The edit keeps the motion, so every edited frame lines
|
|
up pixel-for-pixel with its base frame.
|
|
3. `build` extracts each item from its edit by the stand-in green hue: those pixels become a
|
|
recolorable shade layer (shade index 1..4), the dark outline and small details around them
|
|
become fixed-color pixels. The base body is split the same way into skin / tee / shorts
|
|
shade layers plus fixed pixels (outline, eyes).
|
|
4. The game composites body + items per frame and maps shade indices to the chosen colors, so
|
|
skin tone, colors and every combination of items cost no further API calls.
|
|
|
|
Usage:
|
|
python tools/character_art.py bases # base bodies + walk animations (cached)
|
|
python tools/character_art.py items [ids..] # missing item edits (20 generations each per body)
|
|
python tools/character_art.py henry # Professor Henry NPC + walk + portrait
|
|
python tools/character_art.py build # extract layers -> assets/characters/, manifest
|
|
python tools/character_art.py preview # contact sheets in art_src/preview/
|
|
"""
|
|
import json
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import pixellab_client as plc # noqa: E402
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = ROOT / "art_src" / "characters"
|
|
OUT = ROOT / "assets" / "characters"
|
|
PREVIEW = ROOT / "art_src" / "preview"
|
|
|
|
CANVAS = 68 # PixelLab canvas for 48 px characters
|
|
CELL = 64 # stored frame size (crop keeps edit calls in the 16-frame tier)
|
|
CROP = (2, 2) # top-left of the 64x64 crop inside the 68x68 canvas
|
|
DIRS = ["south", "east", "north"]
|
|
WALK_FRAMES = 4
|
|
FRAMES_PER_DIR = 1 + WALK_FRAMES # standing + walk cycle
|
|
SHADES = 4 # recolorable shade levels (1 = darkest)
|
|
|
|
BODIES = {
|
|
"male": {
|
|
"label": "Masculine",
|
|
"description": ("young adult man, bald shaved head, kind smiling face with big friendly eyes, plain white "
|
|
"t-shirt, plain grey shorts, barefoot, light skin, pokemon trainer overworld sprite style"),
|
|
"seed": 1202, "pron": "his", "obj": "him",
|
|
},
|
|
"female": {
|
|
"label": "Feminine",
|
|
"description": ("young adult woman, bald shaved head, kind smiling face with big friendly eyes and eyelashes, "
|
|
"plain white t-shirt, plain grey shorts, barefoot, light skin, pokemon trainer overworld "
|
|
"sprite style"),
|
|
"seed": 2202, "pron": "her", "obj": "her",
|
|
},
|
|
}
|
|
|
|
GREEN = "bright pure green"
|
|
KEEP = "; keep the face, body, pose and everything else exactly identical"
|
|
|
|
# slot -> z order (higher draws later). The base body is z 0.
|
|
SLOTS = {"shoes": 10, "bottom": 20, "top": 30, "hair": 40, "hat": 50}
|
|
|
|
# Items generated with PixelLab. `prompt` is formatted with pron/obj; `color` is the default.
|
|
ITEMS = {
|
|
"hair_short": ("hair", "Short & Messy", "#5a3a22", "give {obj} short messy hair colored " + GREEN),
|
|
"hair_spiky": ("hair", "Spiky", "#2b2b33", "give {obj} spiky anime-style hair colored " + GREEN),
|
|
"hair_long": ("hair", "Long", "#7a4a26", "give {obj} long straight hair down past the shoulders, colored " + GREEN),
|
|
"hair_ponytail": ("hair", "Ponytail", "#c8902e", "give {obj} hair tied back in a high ponytail, colored " + GREEN),
|
|
"hair_curly": ("hair", "Curly", "#3a2418", "give {obj} big round curly afro hair colored " + GREEN),
|
|
"hat_cap": ("hat", "Cap", "#c0392b", "put a " + GREEN + " baseball cap on {pron} head, brim facing forward"),
|
|
"hat_bucket": ("hat", "Safari Hat", "#b89a5e", "put a " + GREEN + " safari bucket hat with a wide round brim on {pron} head"),
|
|
"hat_beanie": ("hat", "Beanie", "#2e6da4", "put a " + GREEN + " knit beanie on {pron} head"),
|
|
"top_hoodie": ("top", "Hoodie", "#4a6fa5", "change {pron} t-shirt into a " + GREEN + " hoodie sweatshirt with long sleeves, hood down"),
|
|
"top_jacket": ("top", "Jacket", "#a33b3b", "dress {obj} in an open " + GREEN + " zip-up jacket with long sleeves over the white t-shirt"),
|
|
"top_vest": ("top", "Explorer Vest", "#8a7a4a", "dress {obj} in a " + GREEN + " safari explorer vest with pockets over the white t-shirt"),
|
|
"top_sweater": ("top", "Sweater", "#6b4c8a", "change {pron} t-shirt into a " + GREEN + " long-sleeved knit sweater"),
|
|
"bottom_pants": ("bottom", "Long Pants", "#34495e", "change {pron} grey shorts into " + GREEN + " long straight pants that reach the ankles"),
|
|
"bottom_cargo": ("bottom", "Cargo Pants", "#6d6447", "change {pron} grey shorts into baggy " + GREEN + " cargo pants with side pockets that reach the ankles"),
|
|
"bottom_skirt": ("bottom", "Skirt", "#9b3d6b", "change {pron} grey shorts into a " + GREEN + " pleated knee-length skirt"),
|
|
"shoes_sneakers": ("shoes", "Sneakers", "#e8e8e8", "put " + GREEN + " sneakers with white soles on {pron} bare feet"),
|
|
"shoes_boots": ("shoes", "Boots", "#5b3a1e", "put " + GREEN + " ankle-high hiking boots on {pron} bare feet"),
|
|
"shoes_sandals": ("shoes", "Sandals", "#a0703c", "put " + GREEN + " strappy sandals on {pron} bare feet"),
|
|
}
|
|
|
|
# Free items carved out of the base body itself (no API calls).
|
|
BASE_ITEMS = {
|
|
"top_tee": ("top", "T-Shirt", "#f2f2f2"),
|
|
"bottom_shorts": ("bottom", "Shorts", "#6b6b6b"),
|
|
}
|
|
|
|
SKIN_TONES = ["#ffdcc2", "#f1c27d", "#e0ac69", "#c68642", "#8d5524", "#5c3a21"]
|
|
PALETTE = ["#f2f2f2", "#2b2b33", "#6b6b6b", "#c0392b", "#e67e22", "#f1c40f", "#27ae60", "#16a085", "#2e6da4",
|
|
"#4a6fa5", "#6b4c8a", "#9b3d6b", "#e07aa5", "#5a3a22", "#7a4a26", "#c8902e", "#b89a5e", "#6d6447",
|
|
"#3a2418", "#d9d0b0"]
|
|
|
|
HENRY = {
|
|
"description": ("kindly elderly professor named Henry, neat grey hair and a short grey beard, round glasses, "
|
|
"long white lab coat over a green sweater vest, brown trousers, brown shoes, holding nothing, "
|
|
"pokemon professor overworld sprite style"),
|
|
"seed": 4242,
|
|
"portrait": ("portrait bust of a kindly elderly professor with neat grey hair, short grey beard, round glasses, "
|
|
"white lab coat over a green sweater vest, warm smile, facing the viewer, detailed pixel art, "
|
|
"transparent background"),
|
|
}
|
|
|
|
|
|
# ----------------------------------------------------------------- helpers
|
|
|
|
def save_json(path, obj):
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(obj, indent=1))
|
|
|
|
|
|
def load(path):
|
|
return Image.open(path).convert("RGBA")
|
|
|
|
|
|
def frame_img(im):
|
|
return {"image": plc.b64(im), "size": {"width": im.width, "height": im.height}}
|
|
|
|
|
|
def crop(im):
|
|
x, y = CROP
|
|
return im.crop((x, y, x + CELL, y + CELL))
|
|
|
|
|
|
def create_character(client, name, description, seed):
|
|
d = SRC / name
|
|
meta = d / "character.json"
|
|
if meta.exists():
|
|
return json.loads(meta.read_text())["character_id"]
|
|
payload = {"description": description, "image_size": {"width": 48, "height": 48}, "view": "low top-down",
|
|
"proportions": {"type": "preset", "name": "chibi"}, "outline": "single color black outline",
|
|
"shading": "basic shading", "detail": "medium detail", "seed": seed}
|
|
_, res = client.run("create-character-with-4-directions", payload, f"{name}/character")
|
|
r = res[0]
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
for dirn in ["south", "east", "north", "west"]:
|
|
plc.decode(r["images"][dirn]).save(d / f"rot_{dirn}.png")
|
|
save_json(meta, {"character_id": r["character_id"], "payload": payload})
|
|
print(f"[ok] {name}: character {r['character_id']}")
|
|
return r["character_id"]
|
|
|
|
|
|
def animate_walk(client, name, char_id):
|
|
d = SRC / name / "walk4"
|
|
if all((d / f"{dirn}_{WALK_FRAMES - 1}.png").exists() for dirn in DIRS):
|
|
return
|
|
missing = [dirn for dirn in DIRS if not (d / f"{dirn}_{WALK_FRAMES - 1}.png").exists()]
|
|
_, res = client.run("characters/animations", {"character_id": char_id, "mode": "template",
|
|
"template_animation_id": "walking-4-frames",
|
|
"animation_name": "walk", "directions": missing}, f"{name}/walk4")
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
for r in res:
|
|
frames = plc.images_in(r["images"])
|
|
if len(frames) != WALK_FRAMES:
|
|
raise plc.ApiError(f"{name} {r['direction']}: expected {WALK_FRAMES} frames, got {len(frames)}")
|
|
for i, im in enumerate(frames):
|
|
im.save(d / f"{r['direction']}_{i}.png")
|
|
print(f"[ok] {name}: walk {missing}")
|
|
|
|
|
|
def base_frames(name):
|
|
"""The 15 base frames (68x68) in sheet order: per direction [standing, walk0..3]."""
|
|
out = []
|
|
for dirn in DIRS:
|
|
out.append(load(SRC / name / f"rot_{dirn}.png"))
|
|
out += [load(SRC / name / "walk4" / f"{dirn}_{i}.png") for i in range(WALK_FRAMES)]
|
|
return out
|
|
|
|
|
|
def edit_item(client, body, item_id):
|
|
d = SRC / body / "items" / item_id
|
|
if (d / "frame_14.png").exists():
|
|
return False
|
|
spec = BODIES[body]
|
|
_, _, _, prompt = ITEMS[item_id]
|
|
desc = prompt.format(pron=spec["pron"], obj=spec["obj"]) + KEEP
|
|
frames = [crop(f) for f in base_frames(body)]
|
|
_, res = client.run("edit-animation-v2", {"description": desc, "frames": [frame_img(f) for f in frames],
|
|
"image_size": {"width": CELL, "height": CELL}, "no_background": True,
|
|
"seed": abs(hash(item_id)) % 100000},
|
|
f"{body}/{item_id}")
|
|
r = res[0]
|
|
imgs = [plc.decode(x) for x in r.get("images", [])]
|
|
if len(imgs) != len(frames):
|
|
raise plc.ApiError(f"{body}/{item_id}: expected {len(frames)} frames, got {len(imgs)}")
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
for i, im in enumerate(imgs):
|
|
im.save(d / f"frame_{i}.png")
|
|
save_json(d / "edit.json", {"description": desc, "seed": r.get("seed")})
|
|
print(f"[ok] {body}/{item_id}")
|
|
return True
|
|
|
|
|
|
# ----------------------------------------------------------------- extraction
|
|
|
|
def hsv(a):
|
|
rgb = a[..., :3] / 255.0
|
|
mx, mn = rgb.max(-1), rgb.min(-1)
|
|
d = mx - mn
|
|
h = np.zeros_like(mx)
|
|
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
|
m = d > 1e-6
|
|
hr = (mx == r) & m
|
|
hg = (mx == g) & m & ~hr
|
|
hb = m & ~hr & ~hg
|
|
h[hr] = ((g - b)[hr] / d[hr]) % 6
|
|
h[hg] = ((b - r)[hg] / d[hg]) + 2
|
|
h[hb] = ((r - g)[hb] / d[hb]) + 4
|
|
return h * 60.0, np.where(mx > 0, d / np.maximum(mx, 1e-6), 0.0), mx
|
|
|
|
|
|
def luma(a):
|
|
return 0.299 * a[..., 0] + 0.587 * a[..., 1] + 0.114 * a[..., 2]
|
|
|
|
|
|
def dilate(m, it=1):
|
|
for _ in range(it):
|
|
p = np.pad(m, 1)
|
|
o = np.zeros_like(m)
|
|
for dy in range(3):
|
|
for dx in range(3):
|
|
o |= p[dy:dy + m.shape[0], dx:dx + m.shape[1]]
|
|
m = o
|
|
return m
|
|
|
|
|
|
def shade_levels(lum_values, mask):
|
|
"""Quantize luminance inside `mask` to SHADES levels (1..SHADES).
|
|
|
|
`lum_values`/`mask` are (frames, h, w). Each frame's luminance is divided by its own median,
|
|
so the most common tone becomes the base shade (3) and frames the AI rendered a little
|
|
brighter or darker still get the same bands. Fixed thresholds (not quantiles) keep large flat
|
|
areas stable."""
|
|
out = np.zeros(mask.shape, np.uint8)
|
|
if not mask.any():
|
|
return out
|
|
norm = np.zeros(lum_values.shape, float)
|
|
for i in range(mask.shape[0]):
|
|
if mask[i].any():
|
|
norm[i] = lum_values[i] / max(np.median(lum_values[i][mask[i]]), 1.0)
|
|
lv = 1 + np.searchsorted(np.array([0.70, 0.88, 1.08]), norm, side="right")
|
|
out[mask] = np.clip(lv[mask], 1, SHADES)
|
|
return out
|
|
|
|
|
|
def extract_item(base, edit):
|
|
"""(shade mask uint8, fixed RGBA) for one frame. base/edit are 64x64 RGBA arrays."""
|
|
h, s, v = hsv(edit.astype(float))
|
|
ea, ba = edit[..., 3] > 127, base[..., 3] > 127
|
|
changed = ea & (~ba | (np.abs(edit[..., :3].astype(int) - base[..., :3].astype(int)).sum(-1) > 40))
|
|
tint = ea & (h >= 65) & (h <= 170) & (s > 0.25) & (v > 0.12)
|
|
# drop specks: tint pixels with no tint neighbour
|
|
p = np.pad(tint, 1)
|
|
neigh = sum(p[dy:dy + 64, dx:dx + 64].astype(int) for dy in range(3) for dx in range(3)) - tint
|
|
tint &= neigh >= 1
|
|
lum = luma(edit.astype(float))
|
|
outline = changed & dilate(tint, 1) & ~tint & (lum < 70)
|
|
detail = changed & dilate(tint, 1) & ~tint & ~outline & ea
|
|
fixed = np.zeros_like(edit)
|
|
for m in (outline, detail):
|
|
fixed[m] = edit[m]
|
|
fixed[m, 3] = 255
|
|
return tint, lum, fixed
|
|
|
|
|
|
def classify_body(frame):
|
|
"""Split a base frame into skin / tee / shorts shade masks and fixed pixels."""
|
|
h, s, v = hsv(frame.astype(float))
|
|
a = frame[..., 3] > 127
|
|
lum = luma(frame.astype(float))
|
|
skin = a & (((h <= 45) | (h >= 340)) & (s > 0.12) & (v > 0.35))
|
|
neutral = a & (s < 0.12) & ~skin
|
|
ys = np.arange(64)[:, None].repeat(64, 1)
|
|
tee = neutral & (lum >= 150)
|
|
shorts = neutral & (lum >= 55) & (lum < 150)
|
|
# shorts sit below the shirt; grey shading pixels inside the shirt belong to the shirt
|
|
if tee.any():
|
|
tee_rows = ys[tee]
|
|
hem = np.percentile(tee_rows, 92)
|
|
shorts_upper = shorts & (ys < hem - 1)
|
|
tee |= shorts_upper & dilate(tee, 1)
|
|
shorts &= ~tee
|
|
fixed = frame.copy()
|
|
fixed[skin | tee | shorts] = 0
|
|
fixed[~a] = 0
|
|
return {"skin": skin, "top_tee": tee, "bottom_shorts": shorts}, lum, fixed
|
|
|
|
|
|
def sheet(frames):
|
|
cols = FRAMES_PER_DIR
|
|
out = np.zeros((CELL * len(DIRS), CELL * cols, 4), np.uint8)
|
|
for i, f in enumerate(frames):
|
|
y, x = divmod(i, cols)
|
|
out[y * CELL:(y + 1) * CELL, x * CELL:(x + 1) * CELL] = f
|
|
return out
|
|
|
|
|
|
def shade_png(levels):
|
|
"""Shade index stored in R (index * 60), alpha 255 where tinted."""
|
|
out = np.zeros(levels.shape + (4,), np.uint8)
|
|
m = levels > 0
|
|
out[m, 0] = levels[m] * 60
|
|
out[m, 3] = 255
|
|
return out
|
|
|
|
|
|
def build_body(name):
|
|
frames = [np.asarray(crop(f)) for f in base_frames(name)]
|
|
masks = {k: [] for k in ("skin", "top_tee", "bottom_shorts")}
|
|
lums, fixed = [], []
|
|
for f in frames:
|
|
m, lum, fx = classify_body(f)
|
|
for k in masks:
|
|
masks[k].append(m[k])
|
|
lums.append(lum)
|
|
fixed.append(fx)
|
|
d = OUT / name
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
Image.fromarray(sheet(fixed)).save(d / "body.png")
|
|
for k, ms in masks.items():
|
|
stack_m, stack_l = np.stack(ms), np.stack(lums)
|
|
lv = shade_levels(stack_l, stack_m)
|
|
Image.fromarray(sheet([shade_png(lv[i]) for i in range(len(frames))])).save(
|
|
d / ("skin_shade.png" if k == "skin" else f"{k}_shade.png"))
|
|
if k != "skin":
|
|
Image.fromarray(np.zeros((CELL * len(DIRS), CELL * FRAMES_PER_DIR, 4), np.uint8)).save(d / f"{k}.png")
|
|
return frames
|
|
|
|
|
|
def build_item(body, item_id, base):
|
|
d = SRC / body / "items" / item_id
|
|
if not (d / "frame_14.png").exists():
|
|
return False
|
|
edits = [np.asarray(load(d / f"frame_{i}.png")) for i in range(len(base))]
|
|
tints, lums, fixed = [], [], []
|
|
clip_tops = ITEMS[item_id][0] == "top"
|
|
for b, e in zip(base, edits):
|
|
t, lum, fx = extract_item(b, e)
|
|
if clip_tops:
|
|
# Tops end at the waist: the edit model sometimes paints the shorts / legs too
|
|
# (a sweater turning into a robe). Drop anything 2+ rows below the shorts' top edge.
|
|
shorts = classify_body(b)[0]["bottom_shorts"]
|
|
if shorts.any():
|
|
waist = int(np.nonzero(shorts.any(axis=1))[0].min()) + 2
|
|
t[waist:] = False
|
|
fx[waist:] = 0
|
|
tints.append(t)
|
|
lums.append(lum)
|
|
fixed.append(fx)
|
|
lv = shade_levels(np.stack(lums), np.stack(tints))
|
|
out = OUT / body
|
|
Image.fromarray(sheet(fixed)).save(out / f"{item_id}.png")
|
|
Image.fromarray(sheet([shade_png(lv[i]) for i in range(len(base))])).save(out / f"{item_id}_shade.png")
|
|
return True
|
|
|
|
|
|
def anchor_row(name):
|
|
"""Lowest opaque row (from the top of a 64 px cell) over every standing/walk frame."""
|
|
body = np.asarray(Image.open(OUT / name / "body.png"))
|
|
skin = np.asarray(Image.open(OUT / name / "skin_shade.png"))
|
|
a = (body[..., 3] > 127) | (skin[..., 3] > 127)
|
|
rows = np.nonzero(a.reshape(len(DIRS), CELL, FRAMES_PER_DIR, CELL).any(axis=(0, 2, 3)))[0]
|
|
return int(rows.max())
|
|
|
|
|
|
def build():
|
|
manifest = {
|
|
"cell": CELL, "directions": DIRS, "framesPerDirection": FRAMES_PER_DIR, "walkFrames": WALK_FRAMES,
|
|
"shades": SHADES, "note": "row = direction, column 0 = standing, 1.. = walk; west = east mirrored",
|
|
"slots": SLOTS, "skinTones": SKIN_TONES, "palette": PALETTE, "bodies": {}, "items": {},
|
|
}
|
|
for name, spec in BODIES.items():
|
|
if not (SRC / name / "walk4" / f"north_{WALK_FRAMES - 1}.png").exists():
|
|
print(f"[skip] {name}: no base art yet")
|
|
continue
|
|
base = build_body(name)
|
|
have = [k for k in BASE_ITEMS]
|
|
for item_id in ITEMS:
|
|
if build_item(name, item_id, base):
|
|
have.append(item_id)
|
|
manifest["bodies"][name] = {"label": spec["label"], "anchorRow": anchor_row(name), "items": have}
|
|
for item_id, (slot, label, color) in BASE_ITEMS.items():
|
|
manifest["items"][item_id] = {"slot": slot, "label": label, "color": color, "fromBody": True}
|
|
for item_id, (slot, label, color, _) in ITEMS.items():
|
|
manifest["items"][item_id] = {"slot": slot, "label": label, "color": color}
|
|
if (OUT / "henry" / "henry.png").exists():
|
|
manifest["npcs"] = {"henry": {"sheet": "henry/henry.png", "portrait": "henry/portrait.png",
|
|
"anchorRow": henry_anchor()}}
|
|
save_json(OUT / "manifest.json", manifest)
|
|
print(f"manifest: {', '.join(manifest['bodies'])} -> {OUT / 'manifest.json'}")
|
|
|
|
|
|
# ----------------------------------------------------------------- Professor Henry
|
|
|
|
def henry(client):
|
|
cid = create_character(client, "henry", HENRY["description"], HENRY["seed"])
|
|
animate_walk(client, "henry", cid)
|
|
d = SRC / "henry"
|
|
if not (d / "portrait.png").exists():
|
|
_, res = client.run("create-image-pixen", {"description": HENRY["portrait"],
|
|
"image_size": {"width": 128, "height": 128},
|
|
"no_background": True, "seed": 77}, "henry/portrait")
|
|
plc.images_in(res[0])[0].save(d / "portrait.png")
|
|
print("[ok] henry portrait")
|
|
out = OUT / "henry"
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
frames = [np.asarray(crop(f)) for f in base_frames("henry")]
|
|
Image.fromarray(sheet(frames)).save(out / "henry.png")
|
|
load(d / "portrait.png").save(out / "portrait.png")
|
|
|
|
|
|
def henry_anchor():
|
|
a = np.asarray(Image.open(OUT / "henry" / "henry.png"))[..., 3] > 127
|
|
rows = np.nonzero(a.reshape(len(DIRS), CELL, FRAMES_PER_DIR, CELL).any(axis=(0, 2, 3)))[0]
|
|
return int(rows.max())
|
|
|
|
|
|
# ----------------------------------------------------------------- preview
|
|
|
|
def preview():
|
|
"""Contact sheet: each item over the base body (shade layer shown in its default color)."""
|
|
sys.path.insert(0, str(ROOT / "tools"))
|
|
from character_compose import compose_sheet # noqa: E402
|
|
manifest = json.loads((OUT / "manifest.json").read_text())
|
|
PREVIEW.mkdir(parents=True, exist_ok=True)
|
|
for body, info in manifest["bodies"].items():
|
|
rows = []
|
|
for item_id in info["items"]:
|
|
slot = manifest["items"][item_id]["slot"]
|
|
look = {"body": body, "skin": 1, "items": {"top": "top_tee", "bottom": "bottom_shorts"}, "colors": {}}
|
|
look["items"][slot] = item_id
|
|
rows.append(compose_sheet(manifest, look))
|
|
h = sum(r.height for r in rows)
|
|
out = Image.new("RGBA", (rows[0].width, h), (88, 132, 84, 255))
|
|
y = 0
|
|
for r in rows:
|
|
out.alpha_composite(r, (0, y))
|
|
y += r.height
|
|
out = out.resize((out.width * 2, out.height * 2), Image.NEAREST)
|
|
out.save(PREVIEW / f"{body}_items.png")
|
|
print(f"preview -> {PREVIEW / f'{body}_items.png'}")
|
|
|
|
|
|
def main(argv):
|
|
cmd = argv[0] if argv else "build"
|
|
if cmd == "bases":
|
|
client = plc.Client()
|
|
for name, spec in BODIES.items():
|
|
cid = create_character(client, name, spec["description"], spec["seed"])
|
|
animate_walk(client, name, cid)
|
|
elif cmd == "items":
|
|
client = plc.Client()
|
|
wanted = argv[1:] or list(ITEMS)
|
|
jobs = [(b, i) for i in wanted for b in BODIES]
|
|
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
futs = {pool.submit(edit_item, client, b, i): (b, i) for b, i in jobs}
|
|
for f in as_completed(futs):
|
|
try:
|
|
f.result()
|
|
except Exception as e: # keep the other jobs running
|
|
print(f"[fail] {futs[f]}: {e}")
|
|
elif cmd == "henry":
|
|
henry(plc.Client())
|
|
elif cmd == "build":
|
|
build()
|
|
elif cmd == "preview":
|
|
preview()
|
|
else:
|
|
sys.exit(__doc__)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|