"""Reference compositor for player sprites (the C++ client mirrors this in client/CharacterSprite.cpp). look = {"body": "male", "skin": 0..5, "items": {slot: item_id or None}, "colors": {slot: "#rrggbb"}} """ from pathlib import Path import numpy as np from PIL import Image ROOT = Path(__file__).resolve().parent.parent CHAR_DIR = ROOT / "assets" / "characters" def hex_rgb(h): h = h.lstrip("#") return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) def ramp(hex_color, shades=4): """Shade 1 (darkest) .. 4 (highlight) for one base color. Must match colorRamp() in client/CharacterSprite.cpp. Multiplicative per channel (cool, slightly saturated shadows, warm highlights), so white, black and very light colors all shade sensibly.""" c = hex_rgb(hex_color) table = [(0.50, (0.96, 0.90, 1.06)), (0.73, (0.99, 0.95, 1.03)), (1.0, (1.0, 1.0, 1.0))] out = [] for k, tint in table: out.append(tuple(max(0, min(255, round(ch * k * t))) for ch, t in zip(c, tint))) warm = (255, 250, 232) out.append(tuple(round(ch + (w - ch) * 0.34) for ch, w in zip(c, warm))) return out[:shades] def _layer(path): return np.asarray(Image.open(path).convert("RGBA")) def _paint_shade(canvas, shade, color, shades): lv = (shade[..., 0].astype(int) + 30) // 60 m = shade[..., 3] > 127 pal = ramp(color, shades) for i in range(1, shades + 1): sel = m & (lv == i) canvas[sel, :3] = pal[i - 1] canvas[sel, 3] = 255 def _paint_fixed(canvas, fixed): m = fixed[..., 3] > 127 canvas[m] = fixed[m] def compose_sheet(manifest, look): body = look["body"] d = CHAR_DIR / body shades = manifest["shades"] items = manifest["items"] chosen = look.get("items", {}) colors = look.get("colors", {}) body_img = _layer(d / "body.png") canvas = np.zeros_like(body_img) _paint_shade(canvas, _layer(d / "skin_shade.png"), manifest["skinTones"][look.get("skin", 0)], shades) for base_item, slot in (("top_tee", "top"), ("bottom_shorts", "bottom")): color = colors.get(slot) if chosen.get(slot) == base_item else None _paint_shade(canvas, _layer(d / f"{base_item}_shade.png"), color or items[base_item]["color"], shades) _paint_fixed(canvas, body_img) for slot, _z in sorted(manifest["slots"].items(), key=lambda kv: kv[1]): item_id = chosen.get(slot) if not item_id or items.get(item_id, {}).get("fromBody"): continue _paint_shade(canvas, _layer(d / f"{item_id}_shade.png"), colors.get(slot) or items[item_id]["color"], shades) _paint_fixed(canvas, _layer(d / f"{item_id}.png")) return Image.fromarray(canvas)