RECORDANDRONCO/tools/build_tiles.py
type-two e11ec402ff Terrain overhaul: 52-tile atlas from 5 generated surfaces, tone-harmonized so they read as one material family
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>
2026-07-25 22:37:38 +10:00

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)