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>
BIN
assets_src/map_arena.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
assets_src/map_detail.png
Normal file
|
After Width: | Height: | Size: 480 KiB |
BIN
assets_src/round5_roster.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets_src/terrain/asphalt.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
assets_src/terrain/concrete.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
assets_src/terrain/dirt.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
assets_src/terrain/grooves.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets_src/terrain/preview_asphalt.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
assets_src/terrain/rubble.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -284,7 +284,7 @@ waxseed:
|
||||
Terrain: Wax
|
||||
SeedsResource:
|
||||
ResourceType: Wax
|
||||
Interval: 90
|
||||
Interval: 20
|
||||
MaxRange: 60
|
||||
MapEditorData:
|
||||
Categories: Resource spawn
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
mods/vinylgod/tilesets/vinylgod/asphalt.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
mods/vinylgod/tilesets/vinylgod/concrete.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mods/vinylgod/tilesets/vinylgod/dirt.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
mods/vinylgod/tilesets/vinylgod/grooves.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mods/vinylgod/tilesets/vinylgod/rubble.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
117
tools/build_tiles.py
Normal 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
@ -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
@ -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
@ -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})')
|
||||