Brandling Safari: LAN safari-zone creature catching on the HD-2D engine

- 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
This commit is contained in:
Gabe
2026-10-02 15:20:27 -05:00
commit 238cd9c6c3
3409 changed files with 100493 additions and 0 deletions

485
tools/character_art.py Executable file
View File

@@ -0,0 +1,485 @@
#!/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:])