mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
G2 support
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Blockset explorer: what is in a tileset's blockset, what is free, and what
|
||||
new 32x32 blocks can be built out of the 8x8 tiles it already owns.
|
||||
|
||||
Developer-only, like tools/build_data.py. Reads the generated cache
|
||||
(data/generated/tilesets.lua, data/generated/maps.lua and the tileset PNGs
|
||||
under assets/generated/tilesets/); writes nothing back into it.
|
||||
|
||||
A block is 4x4 = 16 tiles of 8x8, matching pokered's blockdata (16 bytes per
|
||||
block, see data/tilesets/*_blockset.bst). Map blockdata stores a block index
|
||||
in one byte, so a tileset can never hold more than 256 blocks: the interesting
|
||||
budget is the free slots between the blocks a tileset ships and that ceiling,
|
||||
not the raw combination count. Exhaustive enumeration is not a thing you can
|
||||
run -- OVERWORLD has 93 distinct tiles, so 93^16 is about 3.3e31 blocks -- so
|
||||
the generator recombines under constraints learned from the real blocks
|
||||
instead. --mode exhaustive exists to show the wall rather than to hit it.
|
||||
|
||||
Usage:
|
||||
python3 tools/blockgen.py --list
|
||||
python3 tools/blockgen.py --tileset OVERWORLD
|
||||
python3 tools/blockgen.py --tileset OVERWORLD --sheet /tmp/blocks.png
|
||||
python3 tools/blockgen.py --tileset CAVERN --mode exhaustive
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TILESETS_LUA = os.path.join(ROOT, "data/generated/tilesets.lua")
|
||||
MAPS_LUA = os.path.join(ROOT, "data/generated/maps.lua")
|
||||
|
||||
BLOCK_TILES = 4 # a block is 4x4 tiles
|
||||
TILE_PX = 8
|
||||
# The engine has no block ceiling: src/world/Map.lua indexes tilesetDef.blocks
|
||||
# as a plain Lua array and never masks the id, and mods register tilesets with
|
||||
# arbitrary block lists (src/mods/Schemas.lua). 256 is the ROM's limit, not
|
||||
# ours -- map blockdata on the cart is one byte per block, so only blocks
|
||||
# IMPORTED from a ROM are bound by it. Kept here to report parity headroom.
|
||||
ROM_BLOCK_LIMIT = 256
|
||||
BLOCK_LIMIT = ROM_BLOCK_LIMIT
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- lua parsing
|
||||
# The generated caches are plain nested table literals (numbers, strings,
|
||||
# booleans, `key = value`), so a small recursive-descent reader beats shelling
|
||||
# out to luajit and keeps this runnable with no interpreter installed.
|
||||
|
||||
_TOKEN = re.compile(r"""
|
||||
(?P<ws>\s+|--[^\n]*)
|
||||
| (?P<str>"(?:[^"\\]|\\.)*")
|
||||
| (?P<num>-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)
|
||||
| (?P<name>[A-Za-z_][A-Za-z_0-9]*)
|
||||
| (?P<punct>[{}\[\]=,;])
|
||||
""", re.X)
|
||||
|
||||
|
||||
def _tokenize(text):
|
||||
pos, n, out = 0, len(text), []
|
||||
while pos < n:
|
||||
m = _TOKEN.match(text, pos)
|
||||
if not m:
|
||||
raise ValueError("lua parse: stuck at %r" % text[pos:pos + 40])
|
||||
pos = m.end()
|
||||
if m.lastgroup != "ws":
|
||||
out.append((m.lastgroup, m.group()))
|
||||
return out
|
||||
|
||||
|
||||
def parse_lua(text):
|
||||
"""Parse `return { ... }`. Array parts come back as lists, keyed parts as
|
||||
dicts; a table with both is returned as a dict with integer keys."""
|
||||
toks = _tokenize(text)
|
||||
i = 0
|
||||
if toks and toks[0] == ("name", "return"):
|
||||
i = 1
|
||||
|
||||
def value(i):
|
||||
kind, tok = toks[i]
|
||||
if kind == "num":
|
||||
return (float(tok) if ("." in tok or "e" in tok.lower()) else int(tok)), i + 1
|
||||
if kind == "str":
|
||||
return tok[1:-1].encode().decode("unicode_escape"), i + 1
|
||||
if kind == "name":
|
||||
if tok == "true":
|
||||
return True, i + 1
|
||||
if tok == "false":
|
||||
return False, i + 1
|
||||
if tok == "nil":
|
||||
return None, i + 1
|
||||
raise ValueError("lua parse: bare name %r" % tok)
|
||||
if tok == "{":
|
||||
return table(i + 1)
|
||||
raise ValueError("lua parse: unexpected %r" % tok)
|
||||
|
||||
def table(i):
|
||||
arr, dct = [], {}
|
||||
while True:
|
||||
kind, tok = toks[i]
|
||||
if tok == "}":
|
||||
return (dct if dct else arr) if not (dct and arr) else _merge(arr, dct), i + 1
|
||||
if tok in (",", ";"):
|
||||
i += 1
|
||||
continue
|
||||
# `key = v` or `["key"] = v`
|
||||
if kind == "name" and toks[i + 1][1] == "=":
|
||||
k = tok
|
||||
v, i = value(i + 2)
|
||||
dct[k] = v
|
||||
continue
|
||||
if tok == "[":
|
||||
k, i = value(i + 1)
|
||||
assert toks[i][1] == "]" and toks[i + 1][1] == "="
|
||||
v, i = value(i + 2)
|
||||
dct[k] = v
|
||||
continue
|
||||
v, i = value(i)
|
||||
arr.append(v)
|
||||
|
||||
def _merge(arr, dct):
|
||||
out = dict(dct)
|
||||
for n, v in enumerate(arr, 1):
|
||||
out[n] = v
|
||||
return out
|
||||
|
||||
v, _ = value(i)
|
||||
return v
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ tile art
|
||||
def load_tiles(record):
|
||||
"""Slice a tileset PNG into 8x8 tiles, indexed the way the block data
|
||||
indexes them (row-major, tilesPerRow per row). Returns None when Pillow is
|
||||
missing, so the structural half of this script still runs."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return None
|
||||
path = os.path.join(ROOT, record["image"])
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
img = Image.open(path).convert("RGB")
|
||||
per_row = record["tilesPerRow"]
|
||||
rows = img.height // TILE_PX
|
||||
tiles = {}
|
||||
for idx in range(rows * per_row):
|
||||
tx, ty = (idx % per_row) * TILE_PX, (idx // per_row) * TILE_PX
|
||||
tiles[idx] = img.crop((tx, ty, tx + TILE_PX, ty + TILE_PX))
|
||||
return tiles
|
||||
|
||||
|
||||
def tile_hash(tile):
|
||||
return hashlib.blake2b(tile.tobytes(), digest_size=8).hexdigest()
|
||||
|
||||
|
||||
def edges(tile):
|
||||
"""(top, bottom, left, right) pixel-row/column signatures. Two tiles butt
|
||||
together seamlessly when the facing edges match, which is what makes a
|
||||
recombined block read as deliberate rather than as noise."""
|
||||
px = tile.load()
|
||||
w, h = tile.size
|
||||
top = bytes(v for x in range(w) for v in px[x, 0])
|
||||
bot = bytes(v for x in range(w) for v in px[x, h - 1])
|
||||
left = bytes(v for y in range(h) for v in px[0, y])
|
||||
right = bytes(v for y in range(h) for v in px[w - 1, y])
|
||||
return top, bot, left, right
|
||||
|
||||
|
||||
# ------------------------------------------------------------- categorisation
|
||||
def categorise(record, tiles):
|
||||
"""Tag every tile id the blockset actually uses. Roles come from the
|
||||
tileset metadata the extractor already carries (walkable / grass / door /
|
||||
warp / counter); everything else is scenery. Visual duplicates are folded
|
||||
into one class so 'make unique' means unique art, not unique index."""
|
||||
used = sorted({t for blk in record["blocks"] for t in blk})
|
||||
walkable = set(record.get("walkable") or [])
|
||||
doors = set(record.get("doorTiles") or [])
|
||||
warps = set(record.get("warpTiles") or [])
|
||||
counters = set(record.get("counterTiles") or [])
|
||||
grass = record.get("grassTile")
|
||||
|
||||
role = {}
|
||||
for t in used:
|
||||
if t == grass:
|
||||
role[t] = "grass"
|
||||
elif t in doors:
|
||||
role[t] = "door"
|
||||
elif t in warps:
|
||||
role[t] = "warp"
|
||||
elif t in counters:
|
||||
role[t] = "counter"
|
||||
elif t in walkable:
|
||||
role[t] = "walkable"
|
||||
else:
|
||||
role[t] = "scenery"
|
||||
|
||||
# visual identity classes: distinct ids whose art is byte-identical
|
||||
art_class, alias = {}, {}
|
||||
if tiles:
|
||||
for t in used:
|
||||
if t in tiles:
|
||||
art_class.setdefault(tile_hash(tiles[t]), []).append(t)
|
||||
for ids in art_class.values():
|
||||
for t in ids:
|
||||
alias[t] = ids[0]
|
||||
else:
|
||||
alias = {t: t for t in used}
|
||||
|
||||
return used, role, alias, art_class
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- analysis
|
||||
def blocks_in_use(maps, tileset_name):
|
||||
"""Block indices any map on this tileset actually places. A block that no
|
||||
map references is already a free slot in everything but name."""
|
||||
seen = set()
|
||||
maps_on = 0
|
||||
for rec in maps.values():
|
||||
if not isinstance(rec, dict) or rec.get("tileset") != tileset_name:
|
||||
continue
|
||||
maps_on += 1
|
||||
for b in rec.get("blocks") or []:
|
||||
seen.add(b)
|
||||
return seen, maps_on
|
||||
|
||||
|
||||
def canon(block, alias):
|
||||
"""Identity of a block by art, not by tile index."""
|
||||
return tuple(alias.get(t, t) for t in block)
|
||||
|
||||
|
||||
def learn_constraints(blocks, alias):
|
||||
"""What the real blocks permit: which tiles appear at each of the 16
|
||||
positions, and which tiles ever sit directly right of / below which."""
|
||||
pos_vocab = [set() for _ in range(16)]
|
||||
right_of = collections.defaultdict(set)
|
||||
below = collections.defaultdict(set)
|
||||
for blk in blocks:
|
||||
a = [alias.get(t, t) for t in blk]
|
||||
for i, t in enumerate(a):
|
||||
pos_vocab[i].add(t)
|
||||
r, c = divmod(i, BLOCK_TILES)
|
||||
if c + 1 < BLOCK_TILES:
|
||||
right_of[t].add(a[i + 1])
|
||||
if r + 1 < BLOCK_TILES:
|
||||
below[t].add(a[i + BLOCK_TILES])
|
||||
return pos_vocab, right_of, below
|
||||
|
||||
|
||||
def fill_tiles(blocks, alias, share=0.04):
|
||||
"""The tiles a blockset uses as background. Anything holding more than
|
||||
`share` of all tile slots across the real blocks is fill: grass, path,
|
||||
interior floor. Used to score structure, since a block made only of these
|
||||
is a texture swatch, not a block worth a slot."""
|
||||
counts = collections.Counter(alias.get(t, t) for blk in blocks for t in blk)
|
||||
total = sum(counts.values())
|
||||
return {t for t, n in counts.items() if n / total > share}
|
||||
|
||||
|
||||
def render_block(block, tiles):
|
||||
"""A block's 32x32 pixels, for comparing what a block LOOKS like rather
|
||||
than which indices it happens to name. Two blocks built from different
|
||||
ids whose art is identical render identical here, which index-space
|
||||
distance cannot see."""
|
||||
from PIL import Image
|
||||
px = BLOCK_TILES * TILE_PX
|
||||
img = Image.new("RGB", (px, px))
|
||||
for i, t in enumerate(block):
|
||||
art = tiles.get(t)
|
||||
if art is None:
|
||||
continue
|
||||
r, c = divmod(i, BLOCK_TILES)
|
||||
img.paste(art, (c * TILE_PX, r * TILE_PX))
|
||||
return img
|
||||
|
||||
|
||||
def score_block(block, alias, fill):
|
||||
"""How much structure a block carries. Distinct art plus non-fill tiles,
|
||||
with a hard floor: a block that is almost entirely one tile is a swatch."""
|
||||
a = [alias.get(t, t) for t in block]
|
||||
distinct = len(set(a))
|
||||
non_fill = sum(1 for t in a if t not in fill)
|
||||
dominant = collections.Counter(a).most_common(1)[0][1]
|
||||
if distinct < 3 or non_fill == 0 or dominant >= 14:
|
||||
return 0
|
||||
return non_fill * 2 + distinct
|
||||
|
||||
|
||||
def tile_diff_table(tiles, ids):
|
||||
"""Pixel differences between every pair of tiles, once.
|
||||
|
||||
Two blocks differ by exactly the sum of their per-slot tile differences, so
|
||||
this turns a 1024-pixel comparison per block pair into 16 lookups. That is
|
||||
the difference between this finishing and not: 400 blocks needs millions of
|
||||
pair comparisons, and pure Python cannot walk 1024 pixels that many times."""
|
||||
raw = {t: tiles[t].tobytes() for t in ids if t in tiles}
|
||||
table = {}
|
||||
for a in raw:
|
||||
ra = raw[a]
|
||||
row = table.setdefault(a, {})
|
||||
for b in raw:
|
||||
if b in row:
|
||||
continue
|
||||
rb = raw[b]
|
||||
d = sum(1 for i in range(0, len(ra), 3) if ra[i:i + 3] != rb[i:i + 3])
|
||||
row[b] = d
|
||||
table.setdefault(b, {})[a] = d
|
||||
return table
|
||||
|
||||
|
||||
def too_close(cand, kept, table, budget):
|
||||
"""True when `cand` is within `budget` differing pixels of anything kept.
|
||||
Accumulates per slot and bails the moment a block is provably far enough,
|
||||
so the common case costs a handful of lookups rather than 16."""
|
||||
for other in kept:
|
||||
total = 0
|
||||
for x, y in zip(cand, other):
|
||||
if x != y:
|
||||
total += table.get(x, {}).get(y, TILE_PX * TILE_PX)
|
||||
if total >= budget:
|
||||
break
|
||||
if total < budget:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def generate(blocks, alias, limit, tiles=None, seed=0, min_distance=6,
|
||||
attempts_per=200, oversample=8, min_pixel_diff=0.12):
|
||||
"""Sample blocks the real blockset's own rules permit: a tile may only sit
|
||||
at a position it is observed at, and only next to / below tiles it is
|
||||
observed next to / below.
|
||||
|
||||
Sampling is randomised rather than depth-first on purpose. A sorted DFS
|
||||
walks the lexicographically first prefix to exhaustion, so its first
|
||||
hundred results are one corner with the last tile wiggling -- technically
|
||||
unique, useless as art. Random restarts spread over the space, and
|
||||
min_distance (tiles differing from every block already kept, the blockset's
|
||||
included) is what stops near-duplicates coming back under a new index."""
|
||||
import random
|
||||
rng = random.Random(seed)
|
||||
pos_vocab, right_of, below = learn_constraints(blocks, alias)
|
||||
fill = fill_tiles(blocks, alias)
|
||||
existing = [canon(b, alias) for b in blocks]
|
||||
existing_set = set(existing)
|
||||
out, seen = [], set()
|
||||
kept = list(existing)
|
||||
|
||||
def far_enough(cand):
|
||||
for other in kept:
|
||||
if sum(1 for a, b in zip(cand, other) if a != b) < min_distance:
|
||||
return False
|
||||
return True
|
||||
|
||||
def sample():
|
||||
"""One randomised walk with backtracking; None if it paints itself in.
|
||||
`tried` mirrors `cur` so a position never re-picks a tile that already
|
||||
led to a dead end on this walk."""
|
||||
cur, tried = [], []
|
||||
while len(cur) < 16:
|
||||
i = len(cur)
|
||||
r, c = divmod(i, BLOCK_TILES)
|
||||
cand = set(pos_vocab[i])
|
||||
if c > 0:
|
||||
cand &= right_of.get(cur[i - 1], set())
|
||||
if r > 0:
|
||||
cand &= below.get(cur[i - BLOCK_TILES], set())
|
||||
if i == len(tried):
|
||||
tried.append(set())
|
||||
cand -= tried[i]
|
||||
if not cand:
|
||||
if i == 0:
|
||||
return None
|
||||
tried.pop()
|
||||
cur.pop()
|
||||
continue
|
||||
pick = rng.choice(sorted(cand))
|
||||
tried[i].add(pick)
|
||||
cur.append(pick)
|
||||
return tuple(cur)
|
||||
|
||||
# Oversample into a pool, rank by structure, then take the best that are
|
||||
# visually far enough apart. Filling greedily in sample order instead is
|
||||
# what produced a sheet of grass swatches: every one of them cleared a
|
||||
# 6-tile index distance by swapping interchangeable background, which is
|
||||
# not a difference anybody can see.
|
||||
pool, tries = [], 0
|
||||
want = limit * oversample
|
||||
while len(pool) < want and tries < want * attempts_per:
|
||||
tries += 1
|
||||
cand = sample()
|
||||
if cand is None or cand in existing_set or cand in seen:
|
||||
continue
|
||||
seen.add(cand)
|
||||
pool.append(cand)
|
||||
|
||||
scored = [(score_block(c, alias, fill), c) for c in pool]
|
||||
scored = [(s, c) for s, c in scored if s > 0]
|
||||
scored.sort(key=lambda sc: -sc[0])
|
||||
|
||||
table = tile_diff_table(tiles, {t for b in pool for t in b} |
|
||||
{t for b in existing for t in b}) if tiles else None
|
||||
budget = int(min_pixel_diff * (BLOCK_TILES * TILE_PX) ** 2)
|
||||
stats = {"pool": len(pool), "swatches": len(pool) - len(scored),
|
||||
"too_similar": 0, "budget_px": budget, "asked": limit}
|
||||
for _score, cand in scored:
|
||||
if len(out) >= limit:
|
||||
break
|
||||
if table is not None:
|
||||
if too_close(cand, kept, table, budget):
|
||||
stats["too_similar"] += 1
|
||||
continue
|
||||
elif not far_enough(cand):
|
||||
stats["too_similar"] += 1
|
||||
continue
|
||||
kept.append(cand)
|
||||
out.append(list(cand))
|
||||
stats["short_by"] = max(0, limit - len(out))
|
||||
return out, stats
|
||||
|
||||
|
||||
def exhaustive_estimate(used_by_role):
|
||||
"""The user's original plan, priced. Not run: printed."""
|
||||
rows = []
|
||||
for role, ids in sorted(used_by_role.items()):
|
||||
k = len(ids)
|
||||
rows.append((role, k, k ** 16))
|
||||
return rows
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- output
|
||||
def write_sheet(path, blocks, tiles, cols=8, scale=3, number=True):
|
||||
"""Contact sheet, numbered the way the blockset viewer numbers the real
|
||||
blocks so a block can be named in conversation. Indices are into the
|
||||
generated run, not into the tileset: they only mean anything for the same
|
||||
--seed."""
|
||||
from PIL import Image, ImageDraw
|
||||
if not blocks:
|
||||
return False
|
||||
px = BLOCK_TILES * TILE_PX
|
||||
rows = (len(blocks) + cols - 1) // cols
|
||||
sheet = Image.new("RGB", (cols * (px + 2), rows * (px + 2)), (24, 24, 24))
|
||||
for n, blk in enumerate(blocks):
|
||||
bx, by = (n % cols) * (px + 2), (n // cols) * (px + 2)
|
||||
for i, t in enumerate(blk):
|
||||
art = tiles.get(t)
|
||||
if art is None:
|
||||
continue
|
||||
r, c = divmod(i, BLOCK_TILES)
|
||||
sheet.paste(art, (bx + c * TILE_PX, by + r * TILE_PX))
|
||||
if scale > 1:
|
||||
sheet = sheet.resize((sheet.width * scale, sheet.height * scale), Image.NEAREST)
|
||||
if number:
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for n in range(len(blocks)):
|
||||
bx = (n % cols) * (px + 2) * scale
|
||||
by = (n // cols) * (px + 2) * scale
|
||||
label = str(n)
|
||||
w = 6 * len(label) + 2
|
||||
draw.rectangle([bx, by, bx + w, by + 10], fill=(20, 20, 20))
|
||||
draw.text((bx + 2, by + 1), label, fill=(255, 255, 255))
|
||||
sheet.save(path)
|
||||
return True
|
||||
|
||||
|
||||
def report(name, record, maps, args):
|
||||
blocks = record["blocks"]
|
||||
tiles = load_tiles(record)
|
||||
used, role, alias, art_class = categorise(record, tiles)
|
||||
placed, maps_on = blocks_in_use(maps, name)
|
||||
|
||||
by_role = collections.defaultdict(list)
|
||||
for t in used:
|
||||
by_role[role[t]].append(t)
|
||||
|
||||
dup_ids = sum(len(v) - 1 for v in art_class.values()) if tiles else 0
|
||||
distinct_art = len(art_class) if tiles else len(used)
|
||||
|
||||
print("%s (%s)" % (name, record.get("image", "no image")))
|
||||
print(" blocks shipped %d (%d under the ROM's 256-block addressing"
|
||||
% (len(blocks), ROM_BLOCK_LIMIT - len(blocks)))
|
||||
print(" limit; the engine itself has no ceiling)")
|
||||
print(" 8x8 tiles referenced %d (%d distinct by art, %d are duplicate ids)"
|
||||
% (len(used), distinct_art, dup_ids))
|
||||
print(" categories " + ", ".join(
|
||||
"%s=%d" % (r, len(v)) for r, v in sorted(by_role.items())))
|
||||
print(" maps on this tileset %d, placing %d distinct blocks"
|
||||
% (maps_on, len(placed)))
|
||||
|
||||
never = [i for i in range(len(blocks)) if i not in placed]
|
||||
print(" blocks no map places %d%s" % (
|
||||
len(never), (" -> " + ", ".join(map(str, never[:16])) + ("..." if len(never) > 16 else ""))
|
||||
if never else ""))
|
||||
|
||||
if args.mode == "exhaustive":
|
||||
print("\n exhaustive enumeration, per category (this is the wall):")
|
||||
for r, k, total in exhaustive_estimate(by_role):
|
||||
print(" %-9s %2d tiles -> %d^16 = %.3g blocks" % (r, k, k, total))
|
||||
print(" at 1e9 blocks/sec the smallest of those still outlives the sun.")
|
||||
return
|
||||
|
||||
budget_n = args.limit if args.limit else (BLOCK_LIMIT - len(blocks))
|
||||
made, gstats = generate(blocks, alias, budget_n, tiles=tiles, seed=args.seed,
|
||||
min_distance=args.min_distance,
|
||||
min_pixel_diff=args.min_pixel_diff,
|
||||
oversample=args.oversample)
|
||||
print("\n generated %d of the %d asked for, from a pool of %d"
|
||||
% (len(made), gstats["asked"], gstats["pool"]))
|
||||
print(" %d rejected as texture swatches (no structure)" % gstats["swatches"])
|
||||
print(" %d rejected as within %d differing pixels of a block already kept"
|
||||
% (gstats["too_similar"], gstats["budget_px"]))
|
||||
if gstats["short_by"]:
|
||||
print(" SHORT BY %d: the pool ran out before the quota filled. Raise"
|
||||
% gstats["short_by"])
|
||||
print(" --oversample, or lower --min-pixel-diff to accept closer blocks.")
|
||||
# Reproducibility: same cache + same flags must give the same blocks, so
|
||||
# print a digest of exactly that rather than asking anyone to trust it.
|
||||
digest = hashlib.sha256(
|
||||
("%s|%d|%d|%.4f|%d|" % (name, args.seed, budget_n, args.min_pixel_diff,
|
||||
args.oversample)
|
||||
+ ";".join(",".join(map(str, b)) for b in made)).encode()).hexdigest()
|
||||
print(" digest %s (seed %d, oversample %d, min-pixel-diff %.2f)"
|
||||
% (digest[:16], args.seed, args.oversample, args.min_pixel_diff))
|
||||
if made:
|
||||
print(" first three, as tile indices:")
|
||||
for blk in made[:3]:
|
||||
for r in range(BLOCK_TILES):
|
||||
print(" " + " ".join("%3d" % t for t in blk[r * 4:r * 4 + 4]))
|
||||
print()
|
||||
if args.sheet and tiles and made:
|
||||
if write_sheet(args.sheet, made[:args.sheet_count], tiles, cols=args.cols):
|
||||
print(" contact sheet: %s (%d blocks)" % (args.sheet, min(len(made), args.sheet_count)))
|
||||
elif args.sheet and not tiles:
|
||||
print(" (no contact sheet: Pillow or the tileset PNG is missing)")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--tileset", help="tileset id, e.g. OVERWORLD")
|
||||
ap.add_argument("--list", action="store_true", help="list tilesets and exit")
|
||||
ap.add_argument("--mode", choices=["adjacency", "exhaustive"], default="adjacency")
|
||||
ap.add_argument("--limit", type=int, default=0,
|
||||
help="cap generated blocks (default: the free slots up to 256)")
|
||||
ap.add_argument("--sheet", help="write a PNG contact sheet of generated blocks")
|
||||
ap.add_argument("--sheet-count", type=int, default=64)
|
||||
ap.add_argument("--cols", type=int, default=8)
|
||||
ap.add_argument("--seed", type=int, default=0,
|
||||
help="fixed by default: the same cache and flags always give the same blocks")
|
||||
ap.add_argument("--oversample", type=int, default=8,
|
||||
help="candidates sampled per block kept")
|
||||
ap.add_argument("--min-pixel-diff", type=float, default=0.12,
|
||||
help="fraction of the 32x32 pixels that must differ from every kept block")
|
||||
ap.add_argument("--min-distance", type=int, default=6,
|
||||
help="tiles that must differ from every block already kept")
|
||||
args = ap.parse_args()
|
||||
|
||||
for p in (TILESETS_LUA, MAPS_LUA):
|
||||
if not os.path.exists(p):
|
||||
sys.exit("missing %s -- import a ROM first (scripts/setup.sh --rom ...)" % p)
|
||||
|
||||
tilesets = parse_lua(open(TILESETS_LUA).read())
|
||||
maps = parse_lua(open(MAPS_LUA).read())
|
||||
|
||||
if args.list or not args.tileset:
|
||||
print("%-14s %7s %7s %7s" % ("tileset", "blocks", "free", "tiles"))
|
||||
for name in sorted(tilesets):
|
||||
rec = tilesets[name]
|
||||
used = {t for blk in rec["blocks"] for t in blk}
|
||||
print("%-14s %7d %7d %7d"
|
||||
% (name, len(rec["blocks"]), BLOCK_LIMIT - len(rec["blocks"]), len(used)))
|
||||
return
|
||||
|
||||
name = args.tileset.upper()
|
||||
if name not in tilesets:
|
||||
sys.exit("unknown tileset %r (try --list)" % args.tileset)
|
||||
report(name, tilesets[name], maps, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user