summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--docs/维护说明.md36
-rw-r--r--scripts/make-animation-evidence.py160
3 files changed, 196 insertions, 1 deletions
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/维护说明.md b/docs/维护说明.md
index de5698a..6978a9a 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.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/<TS>/`(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()