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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 44 KiB

+51 -49
View File
@@ -62,55 +62,57 @@ foal3 = 29
horse3 = 30 horse3 = 30
foal4 = 31 foal4 = 31
horse4 = 32 horse4 = 32
foal5 = 33
horse5 = 34
baby_bird1 = 33 baby_bird1 = 35
bird1 = 34 bird1 = 36
baby_bird2 = 35 baby_bird2 = 37
bird2 = 36 bird2 = 38
cardinal_chick = 37 cardinal_chick = 39
cardinal = 38 cardinal = 40
baby_eagle = 39 baby_eagle = 41
eagle = 40 eagle = 42
baby_crow = 41 baby_crow = 43
crow = 42 crow = 44
piglet = 43 piglet = 45
pig = 44 pig = 46
calf = 45 calf = 47
cattle = 46 cattle = 48
kid = 47 kid = 49
goat = 48 goat = 50
lamb = 49 lamb = 51
sheep = 50 sheep = 52
duckling = 51 duckling = 53
duck = 52 duck = 54
wolf_pup = 53 wolf_pup = 55
wolf = 54 wolf = 56
doe = 55 doe = 57
buck = 56 buck = 58
boar_piglet = 57 boar_piglet = 59
boar = 58 boar = 60
bear_cub = 59 bear_cub = 61
bear = 60 bear = 62
squirrel_kit = 61 squirrel_kit = 63
squirrel = 62 squirrel = 64
turkey_chick = 63 turkey_chick = 65
turkey = 64 turkey = 66
opossum_joey = 65 opossum_joey = 67
opossum = 66 opossum = 68
skunk_kit = 67 skunk_kit = 69
skunk = 68 skunk = 70
raccoon_cub = 69 raccoon_cub = 71
raccoon = 70 raccoon = 72
fish1 = 71 fish1 = 73
fish2 = 72 fish2 = 74
fish3 = 73 fish3 = 75
fish4 = 74 fish4 = 76
bees = 75 bees = 77
bugs1 = 76 bugs1 = 78
bugs2 = 77 bugs2 = 79
butterfly1 = 78 butterfly1 = 80
butterfly2 = 79 butterfly2 = 81
butterfly3 = 80 butterfly3 = 82
butterfly4 = 81 butterfly4 = 83
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+23 -11
View File
@@ -125,21 +125,33 @@ mod tests {
} }
#[test] #[test]
fn manifest_names_resolve_and_bootstrap_sprites_have_idle() { fn frames_are_read_off_the_sheet() {
let s = sprites(); let s = sprites();
for name in ["player", "hen", "fox"] { let hen = s.id("hen");
let id = s.id(name); let idle0 = s.frame(hen, State::Idle, 0).unwrap();
let idle = s.frame(id, State::Idle, 0).unwrap(); let idle1 = s.frame(hen, State::Idle, 1).unwrap();
assert_ne!(idle.mask, 0, "{name}"); assert_ne!(idle0.mask, 0);
// Only one idle frame so far: index 1 wraps to it. No walk yet: falls back. assert_ne!(idle0.px, idle1.px, "hen has two distinct idle frames");
assert_eq!(s.frame(id, State::Idle, 1).unwrap().px, idle.px); assert_eq!(s.frame(hen, State::Idle, 2).unwrap().px, idle0.px, "wraps");
assert_eq!(s.frame(id, State::Walk, 0).unwrap().px, idle.px, "{name}"); // The hen walks with 4 frames; index 4 wraps back to the first.
} let walk0 = s.frame(hen, State::Walk, 0).unwrap();
assert_eq!(s.frame(hen, State::Walk, 4).unwrap().px, walk0.px);
assert_ne!(s.frame(hen, State::Walk, 3).unwrap().px, walk0.px);
} }
#[test] #[test]
fn empty_rows_have_nothing() { fn missing_state_falls_back_to_idle() {
let s = sprites(); let s = sprites();
assert!(s.frame(s.id("butterfly4"), State::Idle, 0).is_none()); let ghost = s.id("ghost1"); // ghosts never sit
assert_eq!(s.frame(ghost, State::Sit, 0).unwrap().px,
s.frame(ghost, State::Idle, 0).unwrap().px);
}
#[test]
fn player_row_has_only_a_bootstrap_idle() {
let s = sprites();
let p = s.id("player");
assert!(s.frame(p, State::Idle, 0).is_some());
assert_eq!(s.frame(p, State::Walk, 0).unwrap().px, s.frame(p, State::Idle, 0).unwrap().px);
} }
} }
+7 -2
View File
@@ -23,8 +23,13 @@ many frames a row has, the loader (`game/src/sprites.rs`) reads off the pixels
(leading non-empty cells; empty slot → falls back to idle). `tools/mktemplate.py` (leading non-empty cells; empty slot → falls back to idle). `tools/mktemplate.py`
renders the paste-in template `entities-template.png` (labels, header, slot guides at renders the paste-in template `entities-template.png` (labels, header, slot guides at
alpha 48 — invisible to the loader) and carries existing sprites over, so adding rows alpha 48 — invisible to the loader) and carries existing sprites over, so adding rows
is: edit the manifest, rerun the tool, paste, save as `entities.png`. Game code refers is: edit the manifest, rerun the tool, paste, save as `entities.png`.
to sprites by name (`sprites.id("hen")`); the id doubles as the entity's `type_id`. `tools/realign_sheet.py <pack sheet>` turns a documented Animals/Monsters sheet into
`assets/work/<Name>-aligned.png`: blank spacer rows removed, only the light frames,
already in the slot columns — one block paste per sheet (Monsters → rows 1–9, Animals
→ rows 10–83; the manifest order follows the sheets, including five foal/horse
pairs). Game code refers to sprites by name (`sprites.id("hen")`); the id doubles as
the entity's `type_id`.
History: this fork started from a client/server multiplayer architecture (authoritative UDP History: this fork started from a client/server multiplayer architecture (authoritative UDP
server, thin client with prediction). The scope was cut to single player in September 2026; server, thin client with prediction). The scope was cut to single player in September 2026;
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)