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

331
tools/safari_textures.py Executable file
View File

@@ -0,0 +1,331 @@
#!/usr/bin/env python3
"""Paints the high-resolution, hand-painted-style textures for the Brandling
Safari 3D props (fences, gate, dock, tent, crates...). Output:
assets/models/src/*.png.
Same approach as the engine's Vibe-HD-2D/tools/scenery_textures.py: tileable
FFT noise mapped through warm colour ramps, no baked lighting (the engine
lights everything; ambient occlusion goes into the models' vertex colours).
python3 tools/safari_textures.py (needs numpy + Pillow)
Run tools/blender_safari.py afterwards to rebuild assets/models/safari.glb.
"""
import math
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "assets" / "models" / "src"
# ----------------------------------------------------------------- helpers --
def fft_noise(h, w, power=2.0, stretch=(1.0, 1.0), seed=0):
"""Tileable fractal noise in [0, 1] (1/f^power spectrum); stretch[0] > 1
elongates features along y (vertical fibres)."""
rng = np.random.default_rng(seed)
fy = np.fft.fftfreq(h)[:, None] * stretch[0]
fx = np.fft.fftfreq(w)[None, :] * stretch[1]
f = np.sqrt(fx * fx + fy * fy)
f[0, 0] = 1.0
spectrum = (rng.normal(size=(h, w)) + 1j * rng.normal(size=(h, w))) / f**power
spectrum[0, 0] = 0
n = np.real(np.fft.ifft2(spectrum))
n -= n.min()
return n / n.max()
def lerp_colors(t, stops):
"""Maps t in [0,1] (array) through a list of (pos, (r,g,b)) stops."""
t = np.clip(t, 0.0, 1.0)
out = np.zeros(t.shape + (3,))
pos = [p for p, _ in stops]
for c in range(3):
out[..., c] = np.interp(t, pos, [col[c] for _, col in stops])
return out
def smoothstep(a, b, x):
t = np.clip((x - a) / (b - a), 0.0, 1.0)
return t * t * (3 - 2 * t)
def rgb(col):
return Image.fromarray(np.clip(col, 0, 255).astype(np.uint8), "RGB")
def wrap_dist(a, b, period):
d = np.abs(a - b) % period
return np.minimum(d, period - d)
# ------------------------------------------------------------------- wood --
WOOD_RAMP = [(0.0, (70, 44, 28)), (0.3, (112, 74, 46)), (0.6, (150, 106, 68)), (0.85, (182, 140, 96)),
(1.0, (206, 170, 124))]
def grain_field(h, w, seed, lines=18, warp=2.2):
"""Wood grain along v: thin, gently wavy, tileable dark grain lines in
[0, 1] plus the warp noise used to drive them."""
xx = np.arange(w)[None, :] / w
wn = fft_noise(h, w, power=2.6, stretch=(4.0, 1.0), seed=seed)
phase = (xx * lines + (wn - 0.5) * warp) * math.tau
s = np.sin(phase)
fine = np.sin(phase * 3.0 + wn * 5.0)
return np.clip(smoothstep(0.55, 0.98, s) + 0.45 * smoothstep(0.7, 1.0, fine), 0, 1), wn
def add_knots(col, rng, count, h, w, size=(10, 18), dark=(70, 42, 26)):
"""Small elliptical knots with a few dark rings, wrapped so the tile stays seamless."""
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
for _ in range(count):
cx, cy = rng.uniform(0, w), rng.uniform(0, h)
r = rng.uniform(*size)
dx, dy = wrap_dist(xx, cx, w), wrap_dist(yy, cy, h)
d = np.sqrt((dx / r) ** 2 + (dy / (r * 1.8)) ** 2)
ring = smoothstep(0.55, 0.95, np.sin(d * 7.0))[..., None] * smoothstep(2.4, 1.0, d)[..., None] * 0.55
core = smoothstep(0.7, 0.3, d)[..., None] * 0.75
col = col * (1 - ring) + np.array(dark) * ring
col = col * (1 - core) + np.array(dark) * core
return col
def wood(w=512, h=1024, seed=21):
"""Weathered, sun-warmed timber with the grain along v (posts, rails, beams, deck boards)."""
rng = np.random.default_rng(seed)
rings, wn = grain_field(h, w, seed, lines=11)
fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1)
broad = fft_noise(h, w, power=2.2, seed=seed + 2)
v = 0.5 + 0.2 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.26 * rings
col = lerp_colors(v, WOOD_RAMP)
# Silvery sun-bleached streaks: weathered, not polished.
bleach = smoothstep(0.62, 0.85, fft_noise(h, w, power=1.4, stretch=(6.0, 1.0), seed=seed + 3))[..., None] * 0.3
col = col * (1 - bleach) + np.array([176, 160, 136]) * bleach
cracks = smoothstep(0.1, 0.0, fibre)[..., None] * 0.7 # long dark checks along the grain
col = col * (1 - cracks * 0.6)
col = add_knots(col, rng, 2, h, w)
return rgb(col)
def planks(size=1024, seed=31, boards=4, nails=True):
"""Boards running along v with dark gaps and nail heads (crates, barrel staves)."""
rng = np.random.default_rng(seed)
h = w = size
rings, wn = grain_field(h, w, seed, lines=16)
fibre = fft_noise(h, w, power=1.0, stretch=(10.0, 1.0), seed=seed + 1)
broad = fft_noise(h, w, power=2.0, seed=seed + 2)
xx = np.arange(w)[None, :].repeat(h, 0).astype(np.float32)
bw = w / boards
b = (xx // bw).astype(int)
lx = xx - b * bw
tone = rng.uniform(0.82, 1.12, boards)[b]
v = (0.5 + 0.18 * (broad - 0.5) * 2 + 0.16 * (fibre - 0.5) * 2 - 0.24 * rings) * tone
col = lerp_colors(v, WOOD_RAMP)
edge = np.minimum(lx, bw - lx)
gap = smoothstep(7.0, 2.5, edge)[..., None]
col = col * (1 - gap) + np.array([34, 22, 14]) * gap
rim = smoothstep(22.0, 6.0, edge)[..., None] * (1 - gap)
col = col * (1 - 0.28 * rim)
col = add_knots(col, rng, 3, h, w, size=(8, 14))
img = rgb(col)
if nails:
d = ImageDraw.Draw(img)
for i in range(boards):
for y in (0.08, 0.58):
for k in (0.3, 0.7):
cx = (i + k) * bw + rng.uniform(-6, 6)
cy = (y + rng.uniform(-0.02, 0.02)) * h
r = 7
d.ellipse([cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2], fill=(52, 36, 24))
d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=(78, 70, 64))
d.ellipse([cx - r + 3, cy - r + 2, cx + 1, cy - 1], fill=(120, 108, 96))
return img
def painted(size=1024, seed=41, paint=(222, 196, 140)):
"""Sun-faded painted boards with chipped paint showing the wood (sign boards)."""
base = np.asarray(planks(size, seed, boards=3, nails=False)).astype(np.float64)
brush = fft_noise(size, size, power=1.1, stretch=(1.0, 8.0), seed=seed + 1) # horizontal brush strokes
fade = fft_noise(size, size, power=2.0, seed=seed + 2)
pcol = np.array(paint) * (0.86 + 0.18 * brush[..., None] + 0.08 * (fade[..., None] - 0.5))
chip_n = fft_noise(size, size, power=1.5, seed=seed + 3)
chip = smoothstep(0.66, 0.72, chip_n)[..., None]
gaps = (base.mean(axis=2) < 50)[..., None]
col = pcol * (1 - chip) + base * chip
col = np.where(gaps, base, col)
edge = smoothstep(0.62, 0.66, chip_n)[..., None] * (1 - chip) # dark lip around chips
col = col * (1 - 0.25 * edge)
return rgb(col)
def log_bark(w=512, h=1024, seed=51):
"""Chunky warm bark plates for log pillars, benches and bollards (tiles horizontally)."""
n = fft_noise(h, w, power=1.35, stretch=(5.0, 1.0), seed=seed)
fine = fft_noise(h, w, power=0.9, stretch=(3.0, 1.0), seed=seed + 1)
plates = fft_noise(h, w, power=1.8, stretch=(2.5, 1.0), seed=seed + 2)
v = n * 0.55 + fine * 0.2 + plates * 0.25
col = lerp_colors(v, [(0.0, (40, 26, 18)), (0.35, (78, 52, 34)), (0.62, (118, 82, 54)), (1.0, (164, 124, 86))])
crev = smoothstep(0.36, 0.22, n)[..., None]
col = col * (1 - 0.7 * crev)
lichen = smoothstep(0.8, 0.9, fft_noise(h, w, power=1.2, seed=seed + 3))[..., None] * 0.3
col = col * (1 - lichen) + np.array([128, 140, 92]) * lichen
return rgb(col)
def end_grain(size=512, seed=61):
"""Sawn log end: growth rings around the centre, radial checks, bark rim at
the edge. Mapped planar onto log caps with the circle touching the borders."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
c = size / 2
dx, dy = (xx - c) / c, (yy - c) / c
r = np.sqrt(dx * dx + dy * dy)
ang = np.arctan2(dy, dx)
wob = fft_noise(size, size, power=2.0, seed=seed)
rr = r + (wob - 0.5) * 0.08
rings = 0.5 + 0.5 * np.sin(rr * 46.0)
fine = fft_noise(size, size, power=0.9, seed=seed + 1)
v = 0.62 - 0.22 * rings ** 2 + 0.18 * (fine - 0.5) - 0.12 * r
col = lerp_colors(v, [(0.0, (92, 60, 36)), (0.4, (150, 108, 66)), (0.7, (198, 156, 104)), (1.0, (226, 192, 140))])
rng = np.random.default_rng(seed)
for a in rng.uniform(-math.pi, math.pi, 5): # radial drying checks
da = np.abs((ang - a + math.pi) % math.tau - math.pi)
crack = smoothstep(0.035, 0.0, da * np.maximum(r, 0.05) * 4) * (r > 0.25) * (r < 0.85)
col = col * (1 - 0.6 * crack[..., None])
pith = smoothstep(0.06, 0.0, r)[..., None]
col = col * (1 - 0.4 * pith)
bark = smoothstep(0.86, 0.92, rr)[..., None]
col = col * (1 - bark) + np.array([62, 40, 26]) * bark
return rgb(col)
# ----------------------------------------------------------------- fabric --
def weave(size, seed, strength=0.08):
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
k = size / 4 # 256 threads per tile at 1024
t = 0.5 + 0.25 * np.sin(xx / size * k * math.tau) + 0.25 * np.sin(yy / size * k * math.tau)
return 1.0 + (t - 0.5) * strength * 2
def canvas(size=1024, seed=71, color=(224, 206, 166), seams=4, dirt=(150, 124, 86)):
"""Cotton duck canvas: fine weave, stitched panel seams along v, soft stains."""
blot = fft_noise(size, size, power=2.0, seed=seed)
mottle = fft_noise(size, size, power=1.2, seed=seed + 1)
col = np.array(color) * (0.9 + 0.12 * blot[..., None] + 0.06 * (mottle[..., None] - 0.5))
col = col * weave(size, seed)[..., None]
stain = smoothstep(0.7, 0.86, fft_noise(size, size, power=1.8, seed=seed + 2))[..., None] * 0.35
col = col * (1 - stain) + np.array(dirt) * stain
img = rgb(col)
d = ImageDraw.Draw(img)
sw = size / seams
for i in range(seams):
x = i * sw
d.rectangle([x - 5, 0, x + 5, size], fill=tuple(int(c * 0.8) for c in color)) # overlapped panel edge
d.line([(x - 5, 0), (x - 5, size)], fill=tuple(int(c * 0.6) for c in color), width=2)
for y in range(0, size, 18): # stitches
d.line([(x + 1, y), (x + 1, y + 9)], fill=tuple(int(c * 0.55) for c in color), width=2)
return img.filter(ImageFilter.SMOOTH)
def canvas_stripes(size=1024, seed=81, colors=((224, 206, 166), (58, 112, 74)), stripes=4):
"""Awning canvas with broad vertical stripes (stripes along v)."""
base = np.asarray(canvas(size, seed, color=(255, 255, 255), seams=1)).astype(np.float64) / 255.0
xx = np.arange(size)[None, :].repeat(size, 0)
s = (xx // (size // (stripes * 2))) % 2
col = np.where(s[..., None] == 0, np.array(colors[0]), np.array(colors[1])) * base
return rgb(col)
def rope(size=256, seed=91):
"""Twisted three-strand rope, strands winding diagonally (length along v)."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
phase = (xx / size * 6 + yy / size * 12) * math.tau
strand = 0.5 + 0.5 * np.sin(phase)
fibre = fft_noise(size, size, power=0.8, stretch=(1.0, 3.0), seed=seed)
v = 0.1 + 0.8 * strand ** 0.45 + 0.2 * (fibre - 0.5)
col = lerp_colors(v, [(0.0, (60, 44, 26)), (0.45, (150, 120, 76)), (1.0, (216, 190, 138))])
return rgb(col)
def hay(size=1024, seed=101, count=4200):
"""Packed golden straw: thousands of short strokes, mostly along u, wrapped
at ±size so the tile is seamless."""
rng = np.random.default_rng(seed)
base = fft_noise(size, size, power=1.6, seed=seed)
col = lerp_colors(base, [(0.0, (120, 92, 38)), (0.5, (170, 138, 62)), (1.0, (204, 174, 92))])
img = rgb(col * 0.8)
d = ImageDraw.Draw(img)
pal = [(150, 116, 48), (188, 154, 70), (214, 184, 98), (232, 206, 128), (130, 104, 46), (176, 160, 84)]
for _ in range(count):
x, y = rng.uniform(0, size), rng.uniform(0, size)
a = rng.normal(0, 0.35)
L = rng.uniform(40, 140)
c = pal[rng.integers(len(pal))]
wdt = int(rng.integers(3, 7))
dx, dy = math.cos(a) * L, math.sin(a) * L
for ox in (-size, 0, size):
for oy in (-size, 0, size):
p0 = (x + ox, y + oy)
p1 = (x + ox + dx, y + oy + dy)
d.line([p0, p1], fill=tuple(int(k * 0.78) for k in c), width=wdt + 2)
d.line([p0, p1], fill=c, width=wdt)
return img.filter(ImageFilter.SMOOTH)
def stone(size=1024, seed=131):
"""Warm grey field stone for the boulders at the gate's feet (moss comes from vertex colour)."""
base = fft_noise(size, size, power=1.8, seed=seed)
fine = fft_noise(size, size, power=0.9, seed=seed + 1)
ridge = 1.0 - np.abs(fft_noise(size, size, power=1.5, seed=seed + 2) * 2 - 1)
v = np.clip((base * 0.6 + fine * 0.4 - 0.5) * 1.5 + 0.5, 0, 1)
col = lerp_colors(v, [(0.0, (66, 60, 56)), (0.4, (118, 110, 100)), (0.7, (158, 148, 132)), (1.0, (202, 190, 170))])
cracks = smoothstep(0.96, 0.99, ridge)[..., None]
col = col * (1 - 0.5 * cracks)
return rgb(col)
# ------------------------------------------------------------------ metal --
def iron(size=512, seed=111):
v = fft_noise(size, size, power=1.3, seed=seed)
col = lerp_colors(v, [(0.0, (26, 24, 24)), (0.6, (56, 52, 50)), (1.0, (96, 76, 58))]) # rust in highlights
rust = smoothstep(0.7, 0.85, fft_noise(size, size, power=1.6, seed=seed + 1))[..., None] * 0.5
col = col * (1 - rust) + np.array([118, 64, 34]) * rust
return rgb(col)
def lantern_glass(size=256, seed=121):
"""Emissive lantern pane (mapped 0..1 per pane): hot flame-yellow centre
fading to amber at the rim, with faint soot at the top."""
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) / size
dx, dy = (xx - 0.5) / 0.5, (yy - 0.55) / 0.6
r = np.sqrt(dx * dx + dy * dy)
flick = fft_noise(size, size, power=2.0, seed=seed)
v = np.clip(1.0 - r * 0.85 + (flick - 0.5) * 0.12, 0, 1)
col = lerp_colors(v, [(0.0, (150, 60, 14)), (0.4, (232, 128, 38)), (0.75, (255, 196, 92)), (1.0, (255, 240, 190))])
soot = smoothstep(0.82, 1.0, 1 - yy)[..., None] * 0.5 # image row 0 = top of pane (v = 1)
col = col * (1 - soot)
return rgb(col)
def main():
OUT.mkdir(parents=True, exist_ok=True)
wood().save(OUT / "wood.png")
planks().save(OUT / "planks.png")
planks(seed=35, nails=False).save(OUT / "staves.png")
painted().save(OUT / "painted_wood.png")
log_bark().save(OUT / "log_bark.png")
end_grain().save(OUT / "end_grain.png")
canvas().save(OUT / "canvas.png")
canvas(seed=75, color=(70, 112, 70), dirt=(52, 64, 40)).save(OUT / "canvas_green.png")
canvas_stripes().save(OUT / "canvas_stripes.png")
rope().save(OUT / "rope.png")
hay().save(OUT / "hay.png")
iron().save(OUT / "iron.png")
stone().save(OUT / "stone.png")
lantern_glass().save(OUT / "lantern_glass.png")
print("wrote", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))
if __name__ == "__main__":
main()