summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/analyze-p72.py197
-rw-r--r--scripts/make-animation-evidence.py12
-rw-r--r--scripts/make-jiangnan-art.py23
3 files changed, 220 insertions, 12 deletions
diff --git a/scripts/analyze-p72.py b/scripts/analyze-p72.py
new file mode 100644
index 0000000..a9171e0
--- /dev/null
+++ b/scripts/analyze-p72.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+"""P72(角色行走四向 8 帧)证据分析:真实 Desktop xvfb 白天(正午)村庄行走帧。
+
+判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计):
+ 1. 日志里有 `p72-frame <png> tick=<t>` 与其后的逐 NPC `npc=NpcId n pos=(x,y) frame=WalkFrame F moving=...`。
+ 2. 全部 6 张行走帧 FrameOne..FrameSix 都真实出现(证明 24-tick 循环被驱动)。
+ 3. 至少一个 NPC 在录制期间被观察到 >=4 个不同行走帧,且有位移(moving=true)。
+ 4. 抽帧 PNG:1280x720、非空白(std>=10)、无 24x24 纯黑块(正午 HUD 的纯白面板属预期,不作为失败)。
+ 5. 抽帧两两不同(画面在动)。
+ 6. 「哪几个帧、哪几角色」的 vision 复核由 Hermes 侧完成,非本脚本判据。
+
+Usage: python3 scripts/analyze-p72.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+FRAME_RE = re.compile(r"^p72-frame (\S+) tick=(\d+)$")
+NPC_RE = re.compile(
+ r"^\s+npc=NpcId (\d+) pos=\(([-\d.]+),([-\d.]+)\) frame=WalkFrame (\w+) moving=(true|false)$"
+)
+ALL_FRAMES = ["FrameOne", "FrameTwo", "FrameThree", "FrameFour", "FrameFive", "FrameSix"]
+DARK_SIDE = 24
+DARK_MAX = 8
+WHITE_SIDE = 64
+WHITE_MIN = 250
+
+
+def max_block(rgba, side, predicate):
+ h, w = rgba.shape[0], rgba.shape[1]
+ if h < side or w < side:
+ return 0
+ mask = predicate(rgba).astype(np.int64)
+ integral = np.zeros((h + 1, w + 1), dtype=np.int64)
+ integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1)
+ best = 0
+ for y in range(0, h - side + 1):
+ for x in range(0, w - side + 1):
+ total = (
+ integral[y + side, x + side]
+ - integral[y, x + side]
+ - integral[y + side, x]
+ + integral[y, x]
+ )
+ best = max(best, int(total))
+ return best
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
+ failures = []
+ lines = [
+ "P72(角色行走四向 8 帧:6 走 + 2 待机)证据分析 — 真实 Desktop xvfb 1280x720 正午",
+ "钩子:LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 LV_P72_LOG=1 LV_AUTOPLAY_START_HOUR=12 LV_LEGACY_MAP=1",
+ "路径:world.Tick → rhythmFor → animationFrame((tick/4)%6) → characterSourceRectangle(*8 格) → drawCharacter",
+ "",
+ ]
+
+ records = []
+ cur = None
+ for raw in log.splitlines():
+ m = FRAME_RE.match(raw.strip())
+ if m:
+ cur = {"png": m.group(1), "tick": int(m.group(2)), "npcs": []}
+ records.append(cur)
+ continue
+ m = NPC_RE.match(raw)
+ if m and cur is not None:
+ cur["npcs"].append(
+ {
+ "npc": int(m.group(1)),
+ "pos": (float(m.group(2)), float(m.group(3))),
+ "frame": m.group(4),
+ "moving": m.group(5) == "true",
+ }
+ )
+
+ if not records:
+ failures.append("log has no p72-frame lines (LV_P72_LOG=1 recording)")
+ lines.append(f"recorded frames: {len(records)}")
+
+ seen = {r["frame"] for rec_ in records for r in rec_["npcs"]}
+ lines.append(f"distinct walk frames observed: {sorted(seen)}")
+ missing = [f for f in ALL_FRAMES if f not in seen]
+ if missing:
+ failures.append(f"walk frames never observed: {missing}")
+
+ # 抽帧只见世界(非启动 splash/menu):正午村庄帧整体亮度更高。
+ world_pngs = set()
+ for rec_ in records:
+ path = Path(rec_["png"])
+ if not path.exists():
+ continue
+ mean = float(np.asarray(Image.open(path).convert("RGB"), dtype=np.int16).mean())
+ if mean > 75.0:
+ world_pngs.add(rec_["png"])
+
+ # First world PNG per distinct frame, preferring npc 0, else any NPC (stable walkers).
+ exemplar = {}
+ for want_npc in (0, -1):
+ for rec_ in records:
+ if rec_["png"] not in world_pngs:
+ continue
+ for r in rec_["npcs"]:
+ if r["frame"] in exemplar:
+ continue
+ if want_npc == 0 and r["npc"] != 0:
+ continue
+ exemplar[r["frame"]] = (rec_["png"], r["npc"])
+ lines.append(
+ "walk frame exemplars: "
+ + ", ".join(f"{k}={Path(v[0]).name}(npc{v[1]})" for k, v in sorted(exemplar.items()))
+ )
+ absent = [f for f in ALL_FRAMES if f not in exemplar]
+ if absent:
+ failures.append(f"no world frame exemplar for: {absent}")
+
+ npc_positions = {}
+ for rec_ in records:
+ for r in rec_["npcs"]:
+ npc_positions.setdefault(r["npc"], set()).add(r["pos"])
+ movers = {n: len(p) for n, p in npc_positions.items() if len(p) > 1}
+ lines.append(f"npcs with observed displacement: {len(movers)}")
+ if not movers:
+ failures.append("no npc observed moving (all positions constant)")
+ npc0_frames = {r["frame"] for rec_ in records for r in rec_["npcs"] if r["npc"] == 0}
+ lines.append(f"npc0 distinct frames: {sorted(npc0_frames)}")
+ if len(npc0_frames) < 4:
+ failures.append(f"npc0 only showed {len(npc0_frames)} distinct walk frames (<4)")
+ lines.append("")
+
+ picks = list(dict.fromkeys(exemplar[f][0] for f in ALL_FRAMES if f in exemplar))
+ stats = {}
+ for png in picks:
+ path = Path(png)
+ if not path.exists():
+ failures.append(f"missing frame {path}")
+ continue
+ rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
+ rgb = rgba[:, :, :3].astype(np.int16)
+ h, w = rgb.shape[0], rgb.shape[1]
+ dark = max_block(
+ rgba, DARK_SIDE,
+ lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250),
+ )
+ white = max_block(
+ rgba, WHITE_SIDE,
+ lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250),
+ )
+ stats[png] = {"size": (w, h), "mean": float(rgb.mean()), "std": float(rgb.std()), "dark": dark, "white": white}
+ lines.append(f"{path.name}: size={w}x{h} mean={stats[png]['mean']:.1f} std={stats[png]['std']:.1f} dark_block={dark} white_block={white}")
+ if (w, h) != (1280, 720):
+ failures.append(f"{path.name} size {w}x{h} != 1280x720")
+ # 正午村庄画面含 HUD/面板的纯白区域,属预期;此处只要求非空白且无纯黑大块。
+ if stats[png]["std"] < 10.0:
+ failures.append(f"{path.name} looks blank (std={stats[png]['std']:.1f})")
+ if dark > 0:
+ failures.append(f"{path.name} has {DARK_SIDE}x{DARK_SIDE} pure-black block")
+ lines.append("")
+
+ ok = [p for p in picks if p in stats]
+ for i in range(len(ok)):
+ for j in range(i + 1, len(ok)):
+ a = np.asarray(Image.open(ok[i]).convert("RGB"), dtype=np.int16)
+ b = np.asarray(Image.open(ok[j]).convert("RGB"), dtype=np.int16)
+ differing = int((np.abs(a - b).sum(axis=2) > 0).sum())
+ lines.append(f"diff {Path(ok[i]).name} vs {Path(ok[j]).name}: differing_pixels={differing}")
+ if differing == 0:
+ failures.append(f"{Path(ok[i]).name} identical to {Path(ok[j]).name}")
+
+ lines.append("")
+ if failures:
+ lines.append("verdict=FAIL")
+ for f in failures:
+ lines.append(" - " + f)
+ else:
+ lines.append("verdict=PASS")
+ lines.append(" all 6 walk frames exercised; npc0 moved through >=4 frames;")
+ lines.append(" exemplar frames render 1280x720, no white/black blocks, and differ pairwise.")
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/make-animation-evidence.py b/scripts/make-animation-evidence.py
index 09c35f0..7bb2b26 100644
--- a/scripts/make-animation-evidence.py
+++ b/scripts/make-animation-evidence.py
@@ -9,8 +9,8 @@ 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
+ cellIndex = (variantIndex * 4 + directionIndex) * 8 + frameIndex
+ frameIndex: WalkFrame 0..5 -> 0..5, IdleFrame IdleOne/IdleTwo -> 6/7
rect = (cellIndex * 32, 0, 32, 48)
tileSourceRectangle:
Grass=0 Water=1 StonePath=2 ... Lantern=20 WaterSpriteC=21
@@ -19,7 +19,7 @@ Mirrored math (keep in sync with VillageArt.fs):
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
+ animationFrame = (tick / 4) % 6 -> FrameOne..FrameSix
"""
import hashlib
@@ -78,7 +78,7 @@ def make_walk_sheet(sheet, path):
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)")
+ label(draw, (pad, 6), "walk frames 0..5 + idle 0..1 (variant StrawHat)")
for r, (name, di) in enumerate(DIRECTIONS):
y = header + r * (ch + pad + 16)
label(draw, (pad, y), name)
@@ -115,7 +115,7 @@ def main():
world_sheet = Image.open(WORLD).convert("RGBA")
char_sheet = Image.open(CHARS).convert("RGBA")
- assert char_sheet.size == (3840, 48), char_sheet.size
+ assert char_sheet.size == (5120, 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"))
@@ -131,7 +131,7 @@ def main():
# Deterministic tick -> frame table for the report.
def walk(t):
- return (t // 4) % 4
+ return (t // 4) % 6
def idle(t):
return (t // 24) % 2
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index caf0c01..0c01bd0 100644
--- a/scripts/make-jiangnan-art.py
+++ b/scripts/make-jiangnan-art.py
@@ -3,7 +3,7 @@
Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets:
jiangnan-world.png 1024x32 (32 ground/prop tiles, 32px each)
- jiangnan-characters.png 3840x48 (5 variants x 4 directions x 6 frames, 32x48 cells)
+ jiangnan-characters.png 5120x48 (5 variants x 4 directions x 8 frames, 32x48 cells)
jiangnan-interior.png 392x96 (variable-size bottom-aligned interior cells)
Cell geometry is mirrored in VillageArt.fs; keep both in sync.
@@ -660,7 +660,8 @@ DIRECTIONS = ["north", "south", "west", "east"]
def draw_character_cell(c, ox, variant, direction, frame):
- # frame: 0 neutral stand, 1 left stride, 2 neutral stand (higher torso bob), 3 right stride
+ # frame: 0 neutral stand, 1 left stride, 2 neutral stand (higher torso bob),
+ # 3 right stride, 4 left-low transition, 5 right-low transition.
robe = variant["robe"]
light = variant["light"]
dark = variant["dark"]
@@ -682,11 +683,21 @@ def draw_character_cell(c, ox, variant, direction, frame):
c.rect(ox + 9, 42, 6, 3, SHOE)
c.rect(ox + 18, 36, 3, 5, dark)
c.rect(ox + 18, 39, 6, 3, SHOE)
- else:
+ elif frame == 3:
c.rect(ox + 18, 36, 4, 7, dark)
c.rect(ox + 18, 42, 6, 3, SHOE)
c.rect(ox + 12, 36, 3, 5, dark)
c.rect(ox + 10, 39, 6, 3, SHOE)
+ elif frame == 4:
+ c.rect(ox + 8, 37, 5, 6, dark)
+ c.rect(ox + 7, 43, 6, 2, SHOE)
+ c.rect(ox + 19, 36, 4, 6, dark)
+ c.rect(ox + 19, 41, 6, 3, SHOE)
+ else:
+ c.rect(ox + 12, 36, 4, 6, dark)
+ c.rect(ox + 11, 41, 6, 3, SHOE)
+ c.rect(ox + 7, 37, 4, 6, dark)
+ c.rect(ox + 6, 43, 6, 2, SHOE)
c.rect(ox + 7, 22, 18, 14, robe)
c.rect(ox + 23, 22, 2, 14, dark)
@@ -837,11 +848,11 @@ def draw_character_idle_cell(c, ox, variant, direction, phase):
def build_character_atlas():
- c = Canvas(3840, 48)
+ c = Canvas(5120, 48)
cell = 0
for variant in VARIANTS:
for direction in DIRECTIONS:
- for frame in range(4):
+ for frame in range(6):
draw_character_cell(c, cell * 32, variant, direction, frame)
cell += 1
for phase in range(2):
@@ -1023,7 +1034,7 @@ def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
("jiangnan-world.png", build_world_atlas(), 1024, 32),
- ("jiangnan-characters.png", build_character_atlas(), 3840, 48),
+ ("jiangnan-characters.png", build_character_atlas(), 5120, 48),
("jiangnan-interior.png", build_interior_atlas(), 392, 96),
]
for name, canvas, w, h in targets: