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/ 输出目录 --- .gitignore | 1 + ...273\264\346\212\244\350\257\264\346\230\216.md" | 36 ++++- scripts/make-animation-evidence.py | 160 +++++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 scripts/make-animation-evidence.py diff --git a/.gitignore b/.gitignore index b98e50f..3feeb98 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ obj/ .idea/ *.user artifacts/ +evidence/ TestResults/ *.DotSettings.user scripts/m3_logs/ diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index de5698a..6978a9a 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" @@ -1,6 +1,6 @@ # 维护说明 -结论先行:主分支保持 0 警告 0 错误 + Desktop 69/69、Kernel 50/50 全绿;性能基线 +结论先行:主分支保持 0 警告 0 错误 + Desktop 75/75、Kernel 79/79 全绿;性能基线 final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C。 本文记录模块职责、不变量与逐字可复制的验收命令。 @@ -33,6 +33,22 @@ final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F 4. **地图 512×384 大世界**:`ProceduralMap.generate` 同 seed 两次生成逐字节一致; 村核矩形(24..38 × 16..34)由生成器不变式保证可通行。 +## 动画语义(P13,全部为 `tick → 帧` 纯函数) + +- 角色图集 `Assets/jiangnan-characters.png`:3840×48,每个 (变体,方向) 占 **6** 格 + (4 走路 + 2 待机呼吸),格宽 32 高 48。 + - `cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex`; + 方向序 North/South/West/East = 0/1/2/3,变体序 Indigo/Ochre/Jade/Grey/StrawHat = 0..4。 + - frameIndex:Walk 0..3 = 0..3,IdleOne/IdleTwo = 4/5。 + - `animationFrame = (tick / 4) % 4`(16 tick 一循环);`idleFrame = (tick / 24) % 2`(48 tick 一循环)。 + - `characterFrame isMoving tick`:移动取走路帧,静止取呼吸帧。Avatar 用 `Game.fs` 的 + `avatarMoving`(比较前后位置);NPC 用 `npcSpriteSpecAtTarget`(目标向量 ≥0.5px 视为移动)。 +- 世界图集 `Assets/jiangnan-world.png`:800×32,25 格。水面 3 帧在 slot 1/13/21, + 炊烟 3 帧在 slot 22/23/24。 + - `waterFrameTick = (tick / 32) % 3` → Water / WaterSpriteB / WaterSpriteC; + - `smokeFrameTick = (tick / 40) % 3` → SmokeSpriteA/B/C,画在民居屋顶上方 `offsetTile pos -2 -3`。 +- 以上帧选择只用 `tick` 与「是否移动」,无墙钟/`System.Random`;同 tick 必得同帧。 + ## 性能基线命令 ```bash @@ -65,12 +81,30 @@ pkill -f "Xvfb :99"; Xvfb :99 -screen 0 1280x720x24 & sleep 2 ffmpeg -y -f x11grab -video_size 1280x720 -framerate 10 -i :99 -pix_fmt yuv420p out.mp4 LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll + +# P13 动画证据(确定性接触表 + 真实运行帧) +python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等 +# 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖) +timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ + LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ + LV_RECORD_DIR="$PWD/evidence/day-run" LV_RECORD_EVERY=2 \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll +timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ + LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 \ + LV_RECORD_DIR="$PWD/evidence/night-run" LV_RECORD_EVERY=2 \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll ``` +- `LV_RECORD_DIR` 默认 `/tmp/lv-p5-prompt`,`LV_RECORD_EVERY` 默认 25(每 N 次绘制存一帧)。 + 跑完 `sample result=ok`;连续移动帧(如 day-run 的 0136..0146)即走路逐帧采样。 + ## 取证快照位置(近期) - `/tmp/lv-p2-ui/`、`/tmp/lv-p3-fixes/`(标题/键位/P5 提示与修) - `/tmp/lv-p6-art/`(昼夜光照/四帧行走/标题入场) - `/tmp/lv-p7-map/`(512×384 大世界巡游帧 + 夜灯对照) - `/tmp/lv-p8-cc0/`(CC0 同 seed 对照帧) +- `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 + `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 + `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) - `artifacts/perf-independent//`(digest 3 联测回读) 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