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:
72
tools/character_compose.py
Normal file
72
tools/character_compose.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user