#!/usr/bin/env python3 """Deterministic animation evidence sheets for the P13 art pass. This script does not touch the game: it crops the *real generated atlases* using the same source-rectangle math the renderer uses in `src/LivingVillage.Desktop/VillageArt.fs`, then lays the frames out as labelled contact sheets. Because the inputs are the shipped PNGs and the layout is a pure function, running it twice yields byte-identical output. Mirrored math (keep in sync with VillageArt.fs): characterSourceRectangle: cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex frameIndex: WalkFrame 0..3 -> 0..3, IdleFrame IdleOne/IdleTwo -> 4/5 rect = (cellIndex * 32, 0, 32, 48) tileSourceRectangle: Grass=0 Water=1 StonePath=2 ... Lantern=20 WaterSpriteC=21 SmokeSpriteA/B/C = 22/23/24 rect = (index * 32, 0, 32, 32) waterFrameTick = (tick / 32) % 3 -> WaterSprite / WaterSpriteB / WaterSpriteC smokeFrameTick = (tick / 40) % 3 -> SmokeSpriteA / SmokeSpriteB / SmokeSpriteC idleFrame = (tick / 24) % 2 -> IdleOne / IdleTwo animationFrame = (tick / 4) % 4 -> FrameOne..FrameFour """ import hashlib import os from PIL import Image, ImageDraw ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WORLD = os.path.join(ROOT, "src/LivingVillage.Desktop/Assets/jiangnan-world.png") CHARS = os.path.join(ROOT, "src/LivingVillage.Desktop/Assets/jiangnan-characters.png") OUT = os.path.join(ROOT, "evidence") TILE = 32 CHAR_W = 32 CHAR_H = 48 DIRECTIONS = [("North", 0), ("South", 1), ("West", 2), ("East", 3)] VARIANT = ("StrawHat", 4) TILE_NAMES = {1: "Water(0)", 13: "Water(1)", 21: "Water(2)", 22: "Smoke(0)", 23: "Smoke(1)", 24: "Smoke(2)"} def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(65536), b""): h.update(chunk) return h.hexdigest() def char_cell(sheet, variant, direction, frame): cell = (variant * 4 + direction) * 6 + frame return sheet.crop((cell * CHAR_W, 0, cell * CHAR_W + CHAR_W, CHAR_H)) def tile_cell(sheet, index): return sheet.crop((index * TILE, 0, index * TILE + TILE, TILE)) def zoom(img, factor): return img.resize((img.width * factor, img.height * factor), Image.NEAREST) def label(draw, xy, text): draw.text(xy, text, fill=(20, 20, 20)) def make_walk_sheet(sheet, path): scale = 4 cw, ch = CHAR_W * scale, CHAR_H * scale cols = 6 rows = len(DIRECTIONS) pad = 8 header = 26 width = pad + cols * (cw + pad) height = header + rows * (ch + pad + 16) canvas = Image.new("RGB", (width, height), (245, 243, 236)) draw = ImageDraw.Draw(canvas) label(draw, (pad, 6), "walk frames 0..3 + idle 0..1 (variant StrawHat)") for r, (name, di) in enumerate(DIRECTIONS): y = header + r * (ch + pad + 16) label(draw, (pad, y), name) for f in range(6): x = pad + f * (cw + pad) img = zoom(char_cell(sheet, VARIANT[1], di, f), scale) canvas.paste(img, (x, y + 16)) tag = f"walk{f}" if f < 4 else f"idle{f - 4}" label(draw, (x, y + 16 + ch + 2), tag) canvas.save(path) def make_phase_sheet(world_sheet, indices, title, path): scale = 6 size = TILE * scale pad = 12 header = 26 n = len(indices) width = pad + n * (size + pad) height = header + size + pad + 18 canvas = Image.new("RGB", (width, height), (245, 243, 236)) draw = ImageDraw.Draw(canvas) label(draw, (pad, 6), title) for i, idx in enumerate(indices): x = pad + i * (size + pad) img = zoom(tile_cell(world_sheet, idx), scale) canvas.paste(img, (x, header)) label(draw, (x, header + size + 2), TILE_NAMES[idx]) canvas.save(path) def main(): os.makedirs(OUT, exist_ok=True) world_sheet = Image.open(WORLD).convert("RGBA") char_sheet = Image.open(CHARS).convert("RGBA") assert char_sheet.size == (3840, 48), char_sheet.size assert world_sheet.size == (800, 32), world_sheet.size make_walk_sheet(char_sheet, os.path.join(OUT, "walk-idle-frames.png")) make_phase_sheet(world_sheet, [1, 13, 21], "water ripple: 3 phases", os.path.join(OUT, "water-3phase.png")) make_phase_sheet(world_sheet, [22, 23, 24], "chimney smoke: 3 phases", os.path.join(OUT, "smoke-3phase.png")) # Two-frame comparisons the brief asks for. make_phase_sheet(world_sheet, [1, 13], "water: frame 0 vs frame 1", os.path.join(OUT, "water-2frame.png")) make_phase_sheet(world_sheet, [22, 23], "smoke: frame 0 vs frame 1", os.path.join(OUT, "smoke-2frame.png")) # Deterministic tick -> frame table for the report. def walk(t): return (t // 4) % 4 def idle(t): return (t // 24) % 2 def water(t): return (t // 32) % 3 def smoke(t): return (t // 40) % 3 report = [] report.append("P13 animation evidence") report.append(f"jiangnan-world.png sha256={sha256(WORLD)} size={world_sheet.size}") report.append(f"jiangnan-characters.png sha256={sha256(CHARS)} size={char_sheet.size}") report.append("") report.append("tick -> walkFrame / idleFrame / waterFrame / smokeFrame") for t in [0, 4, 8, 12, 16, 24, 32, 40, 48, 64, 80, 96, 128, 160]: report.append(f" tick={t:>4} walk={walk(t)} idle={idle(t)} water={water(t)} smoke={smoke(t)}") text = "\n".join(report) + "\n" with open(os.path.join(OUT, "animation-evidence.txt"), "w") as f: f.write(text) print(text, end="") if __name__ == "__main__": main()