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>
This commit is contained in:
type-two 2026-07-25 22:37:38 +10:00
parent bffe4e088a
commit e11ec402ff
29 changed files with 403 additions and 6 deletions

BIN
assets_src/map_arena.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
assets_src/map_detail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets_src/terrain/dirt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

View File

@ -60,9 +60,15 @@ Actors:
Location: 24,25
waxseed0: waxseed
Owner: Neutral
Location: 8,20
Location: 8,25
waxseed1: waxseed
Owner: Neutral
Location: 18,10
Location: 12,12
waxseed2: waxseed
Owner: Neutral
Location: 21,21
waxseed3: waxseed
Owner: Neutral
Location: 25,8
Rules: rules.yaml

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -37,6 +37,8 @@ player:
printfarm: 1
mastering: 1
algorithm: 1
launchramp: 1
droneport: 1
BuildingFractions:
pressplant: 30
hornpit: 8
@ -48,6 +50,8 @@ player:
printfarm: 1
mastering: 1
algorithm: 1
launchramp: 2
droneport: 2
UnitBuilderBotModule:
RequiresCondition: enable-wax-ai
UnitsToBuild:
@ -60,10 +64,20 @@ player:
bufferbot: 60
drmwalker: 45
scraper: 10
boombike: 20
crawler: 8
flyer45: 25
compressor: 25
selector: 5
curator: 5
UnitLimits:
digger: 4
scraper: 4
roadie: 2
boombike: 3
crawler: 2
selector: 1
curator: 1
SupportPowerBotModule:
RequiresCondition: enable-wax-ai
Decisions:
@ -79,6 +93,7 @@ player:
SquadManagerBotModule:
RequiresCondition: enable-wax-ai
SquadSize: 8
ExcludeFromSquadsTypes: digger, scraper, popupvan, streamvan, roadie
AirUnitsTypes: flyer45, compressor
ExcludeFromSquadsTypes: digger, scraper, popupvan, streamvan, roadie, crawler
ConstructionYardTypes: popupshop, streamhub
ProtectionTypes: digger, scraper, popupvan, streamvan, pressplant, transcoder, popupshop, streamhub, ampstack, datacentre, vault, merchtable, podfarm, garage, printfarm, mastering
ProtectionTypes: launchramp, droneport, digger, scraper, popupvan, streamvan, pressplant, transcoder, popupshop, streamhub, ampstack, datacentre, vault, merchtable, podfarm, garage, printfarm, mastering

View File

@ -284,7 +284,7 @@ waxseed:
Terrain: Wax
SeedsResource:
ResourceType: Wax
Interval: 90
Interval: 20
MaxRange: 60
MapEditorData:
Categories: Resource spawn

View File

@ -13,8 +13,88 @@ Terrain:
Templates:
Template@255:
Id: 255
Images: tilesets/vinylgod/vinylgod.png
Images: tilesets/vinylgod/asphalt.png
Size: 1,1
PickAny: True
Categories: Terrain
Tiles:
0: Clear
1: Clear
2: Clear
3: Clear
4: Clear
5: Clear
6: Clear
7: Clear
8: Clear
9: Clear
10: Clear
11: Clear
12: Clear
13: Clear
14: Clear
15: Clear
Template@1:
Id: 1
Images: tilesets/vinylgod/concrete.png
Size: 1,1
PickAny: True
Categories: Terrain
Tiles:
0: Clear
1: Clear
2: Clear
3: Clear
4: Clear
5: Clear
6: Clear
7: Clear
8: Clear
Template@2:
Id: 2
Images: tilesets/vinylgod/dirt.png
Size: 1,1
PickAny: True
Categories: Terrain
Tiles:
0: Clear
1: Clear
2: Clear
3: Clear
4: Clear
5: Clear
6: Clear
7: Clear
8: Clear
Template@3:
Id: 3
Images: tilesets/vinylgod/grooves.png
Size: 1,1
PickAny: True
Categories: Terrain
Tiles:
0: Clear
1: Clear
2: Clear
3: Clear
4: Clear
5: Clear
6: Clear
7: Clear
8: Clear
Template@4:
Id: 4
Images: tilesets/vinylgod/rubble.png
Size: 1,1
PickAny: True
Categories: Terrain
Tiles:
0: Clear
1: Clear
2: Clear
3: Clear
4: Clear
5: Clear
6: Clear
7: Clear
8: Clear

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

117
tools/build_tiles.py Normal file
View File

@ -0,0 +1,117 @@
#!/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)

23
tools/jitter_wax.py Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
"""Jitter wax density per cell so neighbouring cells pick different sprite
frames without it, equal-density cells draw the identical blob and the field
reads as a regular grid."""
import os, struct, glob, random
for i, m in enumerate(sorted(glob.glob(os.path.join(os.path.dirname(__file__), '..', 'mods', 'vinylgod', 'maps', '*')))):
p = os.path.join(m, 'map.bin')
if not os.path.exists(p):
continue
d = bytearray(open(p, 'rb').read())
w, h = struct.unpack_from('<HH', d, 1)
_, _, res_off = struct.unpack_from('<III', d, 5)
rng = random.Random(7000 + i)
n = 0
for x in range(w):
for y in range(h):
o = res_off + 2 * (x * h + y)
if d[o]:
d[o + 1] = max(1, min(12, d[o + 1] + rng.randint(-3, 3)))
n += 1
open(p, 'wb').write(d)
print(f'{os.path.basename(m):14s} jittered {n} wax cells')

109
tools/paint_terrain.py Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Repaint the tile plane of every map.bin with varied VINYLGOD terrain.
Surgical: only the tile plane is touched resources, size and header are left
exactly as they are, so it is safe to re-run on any map at any time.
Layout logic (all tiles are terrain type Clear, so pathing is unchanged):
* asphalt base everywhere, random variant per cell
* concrete slabs a few rectangular pads (old foundations / car parks)
* dirt organic blobs
* grooves concentric rings radiating out of each wax field, as if
the Black Wax etched the ground on its way up
* rubble scatter, denser toward the map edges
"""
import os, struct, glob, random
TPL = {'asphalt': (255, 16), 'concrete': (1, 9), 'dirt': (2, 9), 'grooves': (3, 9), 'rubble': (4, 9)}
MAPS = sorted(glob.glob(os.path.join(os.path.dirname(__file__), '..', 'mods', 'vinylgod', 'maps', '*')))
def paint(path, seed):
d = bytearray(open(path, 'rb').read())
fmt, w, h = d[0], *struct.unpack_from('<HH', d, 1)
tiles_off, heights_off, res_off = struct.unpack_from('<III', d, 5)
assert fmt == 2 and heights_off == 0
rng = random.Random(seed)
surface = {} # (x, y) -> template name
# wax field centres, for the groove rings
wax = [(x, y) for x in range(w) for y in range(h)
if d[res_off + 2 * (x * h + y)] != 0]
seeds = []
if wax:
seen = set()
for s in wax:
if s in seen:
continue
stack, blob = [s], []
seen.add(s)
while stack:
cx, cy = stack.pop()
blob.append((cx, cy))
for n in ((cx+1, cy), (cx-1, cy), (cx, cy+1), (cx, cy-1)):
if n in set(wax) - seen if False else (n in wax and n not in seen):
seen.add(n)
stack.append(n)
if len(blob) >= 6:
seeds.append((sum(p[0] for p in blob) / len(blob),
sum(p[1] for p in blob) / len(blob)))
# concrete pads
for _ in range(max(1, w // 30)):
px, py = rng.randrange(w), rng.randrange(h)
pw, ph = rng.randint(3, 6), rng.randint(3, 6)
for x in range(px, min(w, px + pw)):
for y in range(py, min(h, py + ph)):
surface[(x, y)] = 'concrete'
# dirt blobs
for _ in range(max(2, w // 18)):
cx, cy, r = rng.randrange(w), rng.randrange(h), rng.randint(3, 7)
for x in range(cx - r, cx + r + 1):
for y in range(cy - r, cy + r + 1):
if 0 <= x < w and 0 <= y < h and (x - cx) ** 2 + (y - cy) ** 2 <= r * r + rng.randint(-3, 3):
surface[(x, y)] = 'dirt'
# groove patches: etched ground sitting just off each wax field.
# (tried concentric rings first — arcs don't line up across tiles and it
# reads as random squares, so coherent blobs it is)
import math as _m
for cx, cy in seeds:
for _ in range(2):
a = rng.random() * 2 * _m.pi
dist = rng.randint(6, 10)
bx, by = int(cx + dist * _m.cos(a)), int(cy + dist * _m.sin(a))
r = rng.randint(2, 3)
for x in range(bx - r, bx + r + 1):
for y in range(by - r, by + r + 1):
if 0 <= x < w and 0 <= y < h and (x - bx) ** 2 + (y - by) ** 2 <= r * r:
surface[(x, y)] = 'grooves'
# rubble: a few coherent clumps (never per-cell scatter — that checkerboards)
for _ in range(max(2, w // 16)):
cx, cy, r = rng.randrange(w), rng.randrange(h), rng.randint(2, 4)
for x in range(cx - r, cx + r + 1):
for y in range(cy - r, cy + r + 1):
if 0 <= x < w and 0 <= y < h and (x - cx) ** 2 + (y - cy) ** 2 <= r * r:
surface[(x, y)] = 'rubble'
for x in range(w):
for y in range(h):
tid, n = TPL[surface.get((x, y), 'asphalt')]
o = tiles_off + 3 * (x * h + y)
struct.pack_into('<HB', d, o, tid, rng.randrange(n))
open(path, 'wb').write(d)
counts = {}
for v in surface.values():
counts[v] = counts.get(v, 0) + 1
return w, h, len(seeds), counts
for i, m in enumerate(MAPS):
p = os.path.join(m, 'map.bin')
if not os.path.exists(p):
continue
w, h, ns, c = paint(p, seed=1000 + i)
print(f'{os.path.basename(m):14s} {w}x{h} seeps={ns} {c}')

47
tools/render_map.py Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Render a map.bin to a PNG using the real tile atlases + wax sprites.
Offline view of exactly what the engine will draw, so terrain can be checked
without a display. Usage: render_map.py <mapname> [out.png]
"""
import os, struct, sys
from PIL import Image
ROOT = os.path.join(os.path.dirname(__file__), '..')
MOD = os.path.join(ROOT, 'mods', 'vinylgod')
TPL = {255: 'asphalt', 1: 'concrete', 2: 'dirt', 3: 'grooves', 4: 'rubble'}
TILE = 32
name = sys.argv[1] if len(sys.argv) > 1 else 'arena'
out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(ROOT, 'assets_src', f'map_{name}.png')
atlas = {}
for tid, n in TPL.items():
im = Image.open(os.path.join(MOD, 'tilesets', 'vinylgod', f'{n}.png')).convert('RGBA')
atlas[tid] = [im.crop((i * TILE, 0, (i + 1) * TILE, TILE)) for i in range(im.width // TILE)]
wax_sheet = Image.open(os.path.join(MOD, 'sequences', 'assets', 'wax.png')).convert('RGBA')
wax = [wax_sheet.crop((i * 32, 0, (i + 1) * 32, 32)) for i in range(wax_sheet.width // 32)]
d = open(os.path.join(MOD, 'maps', name, 'map.bin'), 'rb').read()
fmt, w, h = d[0], *struct.unpack_from('<HH', d, 1)
t_off, ht_off, res_off = struct.unpack_from('<III', d, 5)
img = Image.new('RGBA', (w * TILE, h * TILE))
for x in range(w):
for y in range(h):
tid, idx = struct.unpack_from('<HB', d, t_off + 3 * (x * h + y))
frames = atlas.get(tid) or atlas[255]
img.paste(frames[idx % len(frames)], (x * TILE, y * TILE))
for x in range(w):
for y in range(h):
rt, dens = d[res_off + 2 * (x * h + y)], d[res_off + 2 * (x * h + y) + 1]
if rt:
s = wax[min(dens, len(wax) - 1)]
img.paste(s, (x * TILE, y * TILE), s)
scale = max(1, min(2, 2600 // (w * TILE)))
img = img.resize((w * TILE * scale // 2 or w * TILE, h * TILE * scale // 2 or h * TILE), Image.LANCZOS)
img.convert('RGB').save(out)
print(f'{name}: {w}x{h} -> {out} ({img.width}x{img.height})')