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

View 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)