Replaces the single flat grey tile. Adds tools/build_tiles.py (atlas builder with edge-blending + tone matching), paint_terrain.py (surgical tile-plane repaint: asphalt base, concrete pads, dirt blobs, groove patches near wax, rubble clumps), jitter_wax.py, render_map.py (offline map preview), and check_assets.py. Also fixes the arena harness being rigged: both wax fields sat ~12 cells from BotA and ~18 from BotB, so BotB starved for map reasons, not balance reasons. Arena wax is now mirrored (7.1/18/18/19.8 from both starts). Seep interval 90 -> 20 after telemetry showed the slower rate still drained both economies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
3.8 KiB
Python
118 lines
3.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the VINYLGOD terrain tile atlases from the flux textures.
|
|
|
|
One atlas PNG per surface, each holding N 32x32 variants as frames (FrameSize
|
|
metadata). Tiles are cut from a downscaled texture and edge-blended against
|
|
their own wrap so random placement does not show hard seams.
|
|
|
|
All tiles stay terrain type `Clear` — this is a pure visual upgrade, no
|
|
pathing/placement changes.
|
|
"""
|
|
import os, struct, zlib
|
|
from PIL import Image, ImageChops
|
|
|
|
SRC = os.path.join(os.path.dirname(__file__), '..', 'assets_src', 'terrain')
|
|
DST = os.path.join(os.path.dirname(__file__), '..', 'mods', 'vinylgod', 'tilesets', 'vinylgod')
|
|
os.makedirs(DST, exist_ok=True)
|
|
TILE = 32
|
|
|
|
# surface -> (grid, target average brightness nudge)
|
|
SURFACES = {
|
|
'asphalt': 4, # 16 base variants
|
|
'concrete': 3, # 9
|
|
'dirt': 3,
|
|
'grooves': 3,
|
|
'rubble': 3,
|
|
}
|
|
|
|
|
|
def seamless(im):
|
|
"""Cheap wrap-blend so tile edges meet: cross-fade with a half-offset copy."""
|
|
w, h = im.size
|
|
off = ImageChops.offset(im, w // 2, h // 2)
|
|
mask = Image.new('L', (w, h), 0)
|
|
px = mask.load()
|
|
fw, fh = w // 6, h // 6
|
|
for y in range(h):
|
|
for x in range(w):
|
|
ex = min(x, w - 1 - x)
|
|
ey = min(y, h - 1 - y)
|
|
v = 255
|
|
if ex < fw:
|
|
v = min(v, int(255 * ex / fw))
|
|
if ey < fh:
|
|
v = min(v, int(255 * ey / fh))
|
|
px[x, y] = 255 - v # strong at edges
|
|
return Image.composite(off, im, mask)
|
|
|
|
|
|
def chunk(tag, data):
|
|
return struct.pack('>I', len(data)) + tag + data + struct.pack('>I', zlib.crc32(tag + data))
|
|
|
|
|
|
def save_frames(path, frames):
|
|
w, h = TILE * len(frames), TILE
|
|
sheet = Image.new('RGB', (w, h))
|
|
for i, f in enumerate(frames):
|
|
sheet.paste(f, (i * TILE, 0))
|
|
raw = sheet.convert('RGBA').tobytes()
|
|
rows = b''.join(b'\x00' + raw[y * w * 4:(y + 1) * w * 4] for y in range(h))
|
|
out = b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
|
|
out += chunk(b'tEXt', b'FrameSize\x00' + f'{TILE},{TILE}'.encode())
|
|
out += chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b'')
|
|
open(path, 'wb').write(out)
|
|
|
|
|
|
def stats(im):
|
|
px = list(im.getdata())
|
|
n = len(px)
|
|
out = []
|
|
for c in range(3):
|
|
vals = [p[c] for p in px]
|
|
m = sum(vals) / n
|
|
sd = (sum((v - m) ** 2 for v in vals) / n) ** 0.5 or 1.0
|
|
out.append((m, sd))
|
|
return out
|
|
|
|
|
|
def harmonize(im, target, strength=1.0):
|
|
"""Pull a surface toward the base asphalt's tone so surfaces read as one
|
|
material family instead of five clashing ones."""
|
|
src = stats(im)
|
|
px = im.load()
|
|
w, h = im.size
|
|
for y in range(h):
|
|
for x in range(w):
|
|
r, g, b = px[x, y][:3]
|
|
new = []
|
|
for c, v in enumerate((r, g, b)):
|
|
sm, ssd = src[c]
|
|
tm, tsd = target[c]
|
|
z = (v - sm) / ssd
|
|
nv = tm + z * tsd
|
|
nv = v + (nv - v) * strength
|
|
new.append(max(0, min(255, int(nv))))
|
|
px[x, y] = tuple(new)
|
|
return im
|
|
|
|
|
|
base = Image.open(os.path.join(SRC, 'asphalt.png')).convert('RGB')
|
|
TARGET = stats(base.resize((128, 128), Image.LANCZOS))
|
|
|
|
counts = {}
|
|
for name, grid in SURFACES.items():
|
|
src = Image.open(os.path.join(SRC, f'{name}.png')).convert('RGB')
|
|
src = src.resize((TILE * grid, TILE * grid), Image.LANCZOS)
|
|
if name != 'asphalt':
|
|
src = harmonize(src, TARGET)
|
|
frames = []
|
|
for gy in range(grid):
|
|
for gx in range(grid):
|
|
t = src.crop((gx * TILE, gy * TILE, (gx + 1) * TILE, (gy + 1) * TILE))
|
|
frames.append(seamless(t))
|
|
save_frames(os.path.join(DST, f'{name}.png'), frames)
|
|
counts[name] = len(frames)
|
|
print(f'{name}: {len(frames)} tiles')
|
|
|
|
print('counts =', counts)
|