From 328760853c15c44d1de915d6c88e0dfd7ddc5af6 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 10:38:53 +0800 Subject: p72: 角色行走四向动画帧提升(6 走 + 2 待机) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CharacterArt 运行时点阵图集从 4 走 + 2 待机扩为 6 走(新增左右低过渡步 4/5,由 1/2 镜像)+ 2 待机,格数 *8、图集 5120x48;VillageArt 帧循环 %6、characterSourceRectangle *8、characterAtlasWidth 5120。同步 P45/Prototype 帧断言、新增 P72 帧表完整性回归、脚本与素材重新生成、维护说明动画语义。白天正午村庄行走实跑证据落 docs/evidence/p72-*。Desktop 343/343、Kernel 120/120、digest 与 p70 一致。 --- scripts/analyze-p72.py | 197 +++++++++++++++++++++++++++++++++++++ scripts/make-animation-evidence.py | 12 +-- scripts/make-jiangnan-art.py | 23 +++-- 3 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 scripts/analyze-p72.py (limited to 'scripts') 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 tick=` 与其后的逐 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 +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: -- cgit v1.2.3