Realign pack sheets into the entities layout and fill the sheet

tools/realign_sheet.py strips the blank spacer rows from a documented
Animals/Monsters sheet, drops the dark-box preview copies and moves the
light frames into the manifest's slot columns, so a sheet pastes into the
template as one block. Both sheets are pasted; the manifest gains the
fifth foal/horse pair the Animals sheet actually has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 20:46:02 +02:00
co-authored by Claude Opus 5
parent 11de9b0205
commit 4b4e779b3e
11 changed files with 220 additions and 95 deletions
Binary file not shown.
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Rearrange a documented Mini Medieval sheet into the entities.png slot layout.
The packs' Animals/Monsters sheets share our state order (idle, sit, walk, action 1,
action 2, hit, dead) but pad groups with blank rows and show every frame twice (on
light and dark preview boxes). This writes assets/work/<Name>-aligned.png: the label
column, then per row the light frames of each state placed in the manifest's slot
columns, blank rows removed. That block pastes into the template in one go.
python3 tools/realign_sheet.py assets/packs/Mini-Medieval-Documented-8x8/Animals.png
"""
import os
import sys
import tomllib
from PIL import Image
MANIFEST = "assets/tilesets/entities.toml"
HEADER = 16 # y of the first sprite row in the source
CELL = 8
LABEL_W = 91 # source label column (up to the first separator)
BLOCK_X = 96 # where the realigned frames start in the output
def is_dark(p):
return p[3] > 0 and p[0] + p[1] + p[2] < 120
def separators(px, w, h):
"""x of the vertical rule lines (dark for most of the sheet's height)."""
xs = [x for x in range(LABEL_W, w) if sum(is_dark(px[x, y]) for y in range(h)) > h * 0.9]
# Rules are 2 px wide; keep one x per rule.
return [x for i, x in enumerate(xs) if i == 0 or x != xs[i - 1] + 1]
def region_frames(px, x0, x1, h):
"""Cell columns of the light frames in a region: from the first content column
(past the rule line, snapped to the 8 px grid) up to the first cell column where
a preview box (a fully opaque cell in some row) starts."""
def is_rule(x):
return sum(is_dark(px[x, y]) for y in range(h)) > h * 0.9
ys = range(HEADER, h)
start = next((x for x in range(x0, x1)
if not is_rule(x) and any(px[x, y][3] > 0 for y in ys)), None)
if start is None:
return None, 0
start -= start % CELL
n = 0
x = start
while x + CELL <= x1:
boxed = any(all(px[x + dx, y + dy][3] > 0 for dx in range(CELL) for dy in range(CELL))
for y in range(HEADER, h - CELL + 1, CELL))
if boxed:
break
n += 1
x += CELL
return start, n
def main(path):
m = tomllib.load(open(MANIFEST, "rb"))
slots = list(m["layout"]["states"].values()) # [(col, max)] in state order
total_cols = max(c + n for c, n in slots)
img = Image.open(path).convert("RGBA")
px = img.load()
w, h = img.size
# Non-empty source rows (labels or sprites, ignoring rules and boxes).
def band_empty(y0):
for y in range(y0, y0 + CELL):
for x in range(w):
p = px[x, y]
if p[3] == 0 or (p[:3] == (18, 14, 35)) or (is_dark(p) and x > LABEL_W - 4):
continue
return False
return True
src_rows = [y for y in range(HEADER, h - CELL + 1, CELL) if not band_empty(y)]
seps = separators(px, w, h)
regions = list(zip(seps, seps[1:] + [w]))
if len(regions) < len(slots):
sys.exit(f"{path}: found {len(regions)} state columns, manifest has {len(slots)}")
out = Image.new("RGBA", (BLOCK_X + total_cols * CELL, HEADER + len(src_rows) * CELL), (0, 0, 0, 0))
out.paste(img.crop((0, 0, LABEL_W, HEADER)), (0, 0))
for (x0, x1), (slot_col, slot_max) in zip(regions, slots):
start, n = region_frames(px, x0, x1, h)
n = min(n, slot_max)
for i, sy in enumerate(src_rows):
dy = HEADER + i * CELL
out.paste(img.crop((0, sy, LABEL_W, sy + CELL)), (0, dy))
if n:
frames = img.crop((start, sy, start + n * CELL, sy + CELL))
out.paste(frames, (BLOCK_X + slot_col * CELL, dy), frames)
name = os.path.splitext(os.path.basename(path))[0]
dst = f"assets/work/{name}-aligned.png"
out.save(dst)
print(f"wrote {dst}: {len(src_rows)} rows; paste the block ({BLOCK_X}, {HEADER})–"
f"({BLOCK_X + total_cols * CELL}, {out.size[1]}) at (64, 16 + 8·row)")
if __name__ == "__main__":
for p in sys.argv[1:]:
main(p)