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:
@@ -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)
|
||||
Reference in New Issue
Block a user