From 19f07747112cb8797596e862771b794ae739cdff Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 20:22:31 +0800 Subject: docs: 记录 P13 动画语义与确定性证据命令 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 维护说明更新为 Desktop 75/75、Kernel 79/79 - 新增动画语义小节(图集布局、cellIndex、各帧 tick 窗口) - 新增 P13 证据命令(Xvfb 白天/夜晚录帧 + 接触表脚本) - 新增 scripts/make-animation-evidence.py(镜像渲染端帧数学, 从真实图集生成确定性接触表),忽略 evidence/ 输出目录 --- scripts/make-animation-evidence.py | 160 +++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 scripts/make-animation-evidence.py (limited to 'scripts/make-animation-evidence.py') 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() -- cgit v1.2.3