diff --git a/assets/packs/Mini-Medieval-Documented-8x8/.zip b/assets/packs/Mini-Medieval-Documented-8x8/.zip new file mode 100644 index 0000000..9b579c0 Binary files /dev/null and b/assets/packs/Mini-Medieval-Documented-8x8/.zip differ diff --git a/assets/tilesets/entities-template.png b/assets/tilesets/entities-template.png index b020768..17478c4 100644 Binary files a/assets/tilesets/entities-template.png and b/assets/tilesets/entities-template.png differ diff --git a/assets/tilesets/entities.png b/assets/tilesets/entities.png index b020768..5fc0ed6 100644 Binary files a/assets/tilesets/entities.png and b/assets/tilesets/entities.png differ diff --git a/assets/tilesets/entities.toml b/assets/tilesets/entities.toml index e10f717..6e17e75 100644 --- a/assets/tilesets/entities.toml +++ b/assets/tilesets/entities.toml @@ -27,90 +27,92 @@ dead = [16, 2] # name = row (0-based). Order is free, rows must be unique. [entities] -player = 0 +player = 0 -ghost1 = 1 -ghost2 = 2 -mutant_rat = 3 -slime1 = 4 -slime2 = 5 -slime3 = 6 -ent = 7 -shroom = 8 -floating_eye = 9 +ghost1 = 1 +ghost2 = 2 +mutant_rat = 3 +slime1 = 4 +slime2 = 5 +slime3 = 6 +ent = 7 +shroom = 8 +floating_eye = 9 -chick = 10 -hen = 11 -rooster = 12 -fox_kit = 13 -fox = 14 -rabbit_kit = 15 -rabbit = 16 -mouse_pup = 17 -mouse = 18 -kitten1 = 19 -cat1 = 20 -kitten2 = 21 -cat2 = 22 -puppy = 23 -dog = 24 -foal1 = 25 -horse1 = 26 -foal2 = 27 -horse2 = 28 -foal3 = 29 -horse3 = 30 -foal4 = 31 -horse4 = 32 +chick = 10 +hen = 11 +rooster = 12 +fox_kit = 13 +fox = 14 +rabbit_kit = 15 +rabbit = 16 +mouse_pup = 17 +mouse = 18 +kitten1 = 19 +cat1 = 20 +kitten2 = 21 +cat2 = 22 +puppy = 23 +dog = 24 +foal1 = 25 +horse1 = 26 +foal2 = 27 +horse2 = 28 +foal3 = 29 +horse3 = 30 +foal4 = 31 +horse4 = 32 +foal5 = 33 +horse5 = 34 -baby_bird1 = 33 -bird1 = 34 -baby_bird2 = 35 -bird2 = 36 -cardinal_chick = 37 -cardinal = 38 -baby_eagle = 39 -eagle = 40 -baby_crow = 41 -crow = 42 +baby_bird1 = 35 +bird1 = 36 +baby_bird2 = 37 +bird2 = 38 +cardinal_chick = 39 +cardinal = 40 +baby_eagle = 41 +eagle = 42 +baby_crow = 43 +crow = 44 -piglet = 43 -pig = 44 -calf = 45 -cattle = 46 -kid = 47 -goat = 48 -lamb = 49 -sheep = 50 -duckling = 51 -duck = 52 -wolf_pup = 53 -wolf = 54 -doe = 55 -buck = 56 -boar_piglet = 57 -boar = 58 -bear_cub = 59 -bear = 60 -squirrel_kit = 61 -squirrel = 62 -turkey_chick = 63 -turkey = 64 -opossum_joey = 65 -opossum = 66 -skunk_kit = 67 -skunk = 68 -raccoon_cub = 69 -raccoon = 70 +piglet = 45 +pig = 46 +calf = 47 +cattle = 48 +kid = 49 +goat = 50 +lamb = 51 +sheep = 52 +duckling = 53 +duck = 54 +wolf_pup = 55 +wolf = 56 +doe = 57 +buck = 58 +boar_piglet = 59 +boar = 60 +bear_cub = 61 +bear = 62 +squirrel_kit = 63 +squirrel = 64 +turkey_chick = 65 +turkey = 66 +opossum_joey = 67 +opossum = 68 +skunk_kit = 69 +skunk = 70 +raccoon_cub = 71 +raccoon = 72 -fish1 = 71 -fish2 = 72 -fish3 = 73 -fish4 = 74 -bees = 75 -bugs1 = 76 -bugs2 = 77 -butterfly1 = 78 -butterfly2 = 79 -butterfly3 = 80 -butterfly4 = 81 +fish1 = 73 +fish2 = 74 +fish3 = 75 +fish4 = 76 +bees = 77 +bugs1 = 78 +bugs2 = 79 +butterfly1 = 80 +butterfly2 = 81 +butterfly3 = 82 +butterfly4 = 83 diff --git a/assets/tilesets/entities.xcf b/assets/tilesets/entities.xcf index 3bcd6d8..8e6e2a1 100755 Binary files a/assets/tilesets/entities.xcf and b/assets/tilesets/entities.xcf differ diff --git a/assets/work/Animals-aligned.png b/assets/work/Animals-aligned.png new file mode 100644 index 0000000..54c8d36 Binary files /dev/null and b/assets/work/Animals-aligned.png differ diff --git a/assets/work/Monsters-aligned.png b/assets/work/Monsters-aligned.png new file mode 100644 index 0000000..46628bb Binary files /dev/null and b/assets/work/Monsters-aligned.png differ diff --git a/game/src/sprites.rs b/game/src/sprites.rs index a6c7ef7..8588ba1 100644 --- a/game/src/sprites.rs +++ b/game/src/sprites.rs @@ -125,21 +125,33 @@ mod tests { } #[test] - fn manifest_names_resolve_and_bootstrap_sprites_have_idle() { + fn frames_are_read_off_the_sheet() { let s = sprites(); - for name in ["player", "hen", "fox"] { - let id = s.id(name); - let idle = s.frame(id, State::Idle, 0).unwrap(); - assert_ne!(idle.mask, 0, "{name}"); - // Only one idle frame so far: index 1 wraps to it. No walk yet: falls back. - assert_eq!(s.frame(id, State::Idle, 1).unwrap().px, idle.px); - assert_eq!(s.frame(id, State::Walk, 0).unwrap().px, idle.px, "{name}"); - } + let hen = s.id("hen"); + let idle0 = s.frame(hen, State::Idle, 0).unwrap(); + let idle1 = s.frame(hen, State::Idle, 1).unwrap(); + assert_ne!(idle0.mask, 0); + assert_ne!(idle0.px, idle1.px, "hen has two distinct idle frames"); + assert_eq!(s.frame(hen, State::Idle, 2).unwrap().px, idle0.px, "wraps"); + // 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] - fn empty_rows_have_nothing() { + fn missing_state_falls_back_to_idle() { 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); } } diff --git a/notes/roadmap.md b/notes/roadmap.md index 4abfa00..54aca05 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -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` 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 -is: edit the manifest, rerun the tool, paste, save as `entities.png`. Game code refers -to sprites by name (`sprites.id("hen")`); the id doubles as the entity's `type_id`. +is: edit the manifest, rerun the tool, paste, save as `entities.png`. +`tools/realign_sheet.py ` turns a documented Animals/Monsters sheet into +`assets/work/-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 server, thin client with prediction). The scope was cut to single player in September 2026; diff --git a/tools/__pycache__/realign_sheet.cpython-314.pyc b/tools/__pycache__/realign_sheet.cpython-314.pyc new file mode 100644 index 0000000..fa39765 Binary files /dev/null and b/tools/__pycache__/realign_sheet.cpython-314.pyc differ diff --git a/tools/realign_sheet.py b/tools/realign_sheet.py new file mode 100644 index 0000000..5b7642a --- /dev/null +++ b/tools/realign_sheet.py @@ -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/-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)