summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/make-animation-evidence.py160
1 files changed, 160 insertions, 0 deletions
diff --git a/scripts/make-animation-evidence.py b/scripts/make-animation-evidence.py
new file mode 100644
index 0000000..09c35f0
--- /dev/null
+++ b/scripts/make-animation-evidence.py
@@ -0,0 +1,160 @@
+#!/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()