diff options
18 files changed, 377 insertions, 60 deletions
diff --git a/docs/evidence/p72-analysis.txt b/docs/evidence/p72-analysis.txt new file mode 100644 index 0000000..67b1894 --- /dev/null +++ b/docs/evidence/p72-analysis.txt @@ -0,0 +1,30 @@ +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 + +recorded frames: 41 +distinct walk frames observed: ['FrameFive', 'FrameFour', 'FrameOne', 'FrameSix', 'FrameThree', 'FrameTwo'] +walk frame exemplars: FrameFive=p72-walk-0492.png(npc0), FrameFour=p72-walk-0264.png(npc0), FrameOne=p72-walk-0276.png(npc0), FrameSix=p72-walk-0132.png(npc0), FrameThree=p72-walk-0120.png(npc0), FrameTwo=p72-walk-0132.png(npc1) +npcs with observed displacement: 30 +npc0 distinct frames: ['FrameFive', 'FrameFour', 'FrameOne', 'FrameSix', 'FrameThree'] + +p72-walk-0276.png: size=1280x720 mean=87.2 std=34.8 dark_block=0 white_block=20 +p72-walk-0132.png: size=1280x720 mean=118.5 std=54.2 dark_block=0 white_block=52 +p72-walk-0120.png: size=1280x720 mean=87.1 std=34.5 dark_block=0 white_block=32 +p72-walk-0264.png: size=1280x720 mean=87.1 std=34.9 dark_block=0 white_block=32 +p72-walk-0492.png: size=1280x720 mean=87.3 std=34.9 dark_block=0 white_block=26 + +diff p72-walk-0276.png vs p72-walk-0132.png: differing_pixels=905924 +diff p72-walk-0276.png vs p72-walk-0120.png: differing_pixels=313725 +diff p72-walk-0276.png vs p72-walk-0264.png: differing_pixels=242017 +diff p72-walk-0276.png vs p72-walk-0492.png: differing_pixels=294080 +diff p72-walk-0132.png vs p72-walk-0120.png: differing_pixels=906006 +diff p72-walk-0132.png vs p72-walk-0264.png: differing_pixels=905936 +diff p72-walk-0132.png vs p72-walk-0492.png: differing_pixels=905931 +diff p72-walk-0120.png vs p72-walk-0264.png: differing_pixels=306092 +diff p72-walk-0120.png vs p72-walk-0492.png: differing_pixels=241732 +diff p72-walk-0264.png vs p72-walk-0492.png: differing_pixels=276242 + +verdict=PASS + all 6 walk frames exercised; npc0 moved through >=4 frames; + exemplar frames render 1280x720, no white/black blocks, and differ pairwise. diff --git a/docs/evidence/p72-walk-framefive.png b/docs/evidence/p72-walk-framefive.png Binary files differnew file mode 100644 index 0000000..5dc15be --- /dev/null +++ b/docs/evidence/p72-walk-framefive.png diff --git a/docs/evidence/p72-walk-framefour.png b/docs/evidence/p72-walk-framefour.png Binary files differnew file mode 100644 index 0000000..b321bb6 --- /dev/null +++ b/docs/evidence/p72-walk-framefour.png diff --git a/docs/evidence/p72-walk-frameone.png b/docs/evidence/p72-walk-frameone.png Binary files differnew file mode 100644 index 0000000..c3c1524 --- /dev/null +++ b/docs/evidence/p72-walk-frameone.png diff --git a/docs/evidence/p72-walk-framesix.png b/docs/evidence/p72-walk-framesix.png Binary files differnew file mode 100644 index 0000000..af5fa25 --- /dev/null +++ b/docs/evidence/p72-walk-framesix.png diff --git a/docs/evidence/p72-walk-framethree.png b/docs/evidence/p72-walk-framethree.png Binary files differnew file mode 100644 index 0000000..5c32d07 --- /dev/null +++ b/docs/evidence/p72-walk-framethree.png diff --git a/docs/维护说明.md b/docs/维护说明.md index ae0ae47..5fa87a4 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -50,16 +50,18 @@ final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F 村核矩形(24..38 × 16..34)由生成器不变式保证可通行; 默认可玩世界为 `MapGen.generateWithSize 256 192 42UL`(详见 §大世界(P21))。 -## 动画语义(P13,全部为 `tick → 帧` 纯函数) +## 动画语义(P13,全部为 `tick → 帧` 纯函数;P45/P72 扩充角色帧) -- 角色图集 `Assets/jiangnan-characters.png`:3840×48,每个 (变体,方向) 占 **6** 格 - (4 走路 + 2 待机呼吸),格宽 32 高 48。 - - `cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex`; +- 角色图集由 `CharacterArt.buildAtlas` **运行时生成**(内嵌原创点阵,非外部素材,P45 起取代 + 旧的 `Assets/jiangnan-characters.png`;旧 PNG 仅作历史资产保留,运行期不再采样): + 5120×48,每个 (变体,方向) 占 **8** 格(**6 走路 + 2 待机呼吸**),格宽 32 高 48。 + - `cellIndex = (variantIndex * 4 + directionIndex) * 8 + 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 一循环)。 + - frameIndex:Walk 0..5 = 0..5(1/2 与 3 为左右迈步,4/5 为由 1/2 镜像出的过渡步),IdleOne/IdleTwo = 6/7。 + - `animationFrame = (tick / 4) % 6`(24 tick 一循环);`idleFrame = (tick / 24) % 2`(48 tick 一循环)。 - `characterFrame isMoving tick`:移动取走路帧,静止取呼吸帧。Avatar 用 `Game.fs` 的 `avatarMoving`(比较前后位置);NPC 用 `npcSpriteSpecAtTarget`(目标向量 ≥0.5px 视为移动)。 + 单人节奏仍由 `rhythmFor` 的 `WalkWindow/IdleWindow/PhaseOffset` 决定(农夫 4/24、货郎 3/20、书生 6/36)。 - 世界图集 `Assets/jiangnan-world.png`:800×32,25 格。水面 3 帧在 slot 1/13/21, 炊烟 3 帧在 slot 22/23/24。 - `waterFrameTickAt tick x y = (tick / 32 + x + 2y) % 3` → Water / WaterSpriteB / WaterSpriteC。 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: diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a736a83..432cd03 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -43,6 +43,7 @@ <Compile Include="P68MapSnapshotTests.fs" /> <Compile Include="P69CopyAuditTests.fs" /> <Compile Include="P71ApiDocsTests.fs" /> + <Compile Include="P72CharacterWalkTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs b/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs index eba03aa..b9241ad 100644 --- a/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs +++ b/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs @@ -5,7 +5,7 @@ open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop -/// P45 角色动画帧升级回归:四向 x 6 帧原创点阵、帧序列确定性、行走推进、停止回 idle。 +/// P45 角色动画帧升级回归:四向 x 8 帧(6 走 + 2 待机)原创点阵、帧序列确定性、行走推进、停止回 idle。 [<TestClass>] type P45CharacterAnimTests () = @@ -76,16 +76,18 @@ type P45CharacterAnimTests () = [ VillageArt.NorthFacing; VillageArt.SouthFacing; VillageArt.WestFacing; VillageArt.EastFacing ] |> List.map (fun d -> (rect VillageArt.IndigoRobe d (VillageArt.WalkFrame VillageArt.FrameOne)).X) assertEqualMsg 4 (xs |> List.distinct |> List.length) "四向必须落在不同源格" - // 同向 6 帧源格逐一不同且按序排列。 + // 同向 8 帧源格逐一不同且按序排列。 let frameXs = [ VillageArt.WalkFrame VillageArt.FrameOne VillageArt.WalkFrame VillageArt.FrameTwo VillageArt.WalkFrame VillageArt.FrameThree VillageArt.WalkFrame VillageArt.FrameFour + VillageArt.WalkFrame VillageArt.FrameFive + VillageArt.WalkFrame VillageArt.FrameSix VillageArt.IdleFrame VillageArt.IdleOne VillageArt.IdleFrame VillageArt.IdleTwo ] |> List.map (fun f -> (rect VillageArt.IndigoRobe VillageArt.SouthFacing f).X) - assertEqualMsg 6 (frameXs |> List.distinct |> List.length) "同向 6 帧源格须互不同" + assertEqualMsg 8 (frameXs |> List.distinct |> List.length) "同向 8 帧源格须互不同" assertEqualMsg frameXs (List.sort frameXs) "帧源格须按帧序递增" [<TestMethod>] @@ -95,18 +97,22 @@ type P45CharacterAnimTests () = assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameTwo) (VillageArt.characterFrame true 4L) "走 4" assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameThree) (VillageArt.characterFrame true 8L) "走 8" assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameFour) (VillageArt.characterFrame true 12L) "走 12" - assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameOne) (VillageArt.characterFrame true 16L) "走 16 循环" + assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameFive) (VillageArt.characterFrame true 16L) "走 16" + assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameSix) (VillageArt.characterFrame true 20L) "走 20" + assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameOne) (VillageArt.characterFrame true 24L) "走 24 循环" // 停止:无论 tick,回 idle 两帧呼吸循环。 assertEqualMsg (VillageArt.IdleFrame VillageArt.IdleOne) (VillageArt.characterFrame false 0L) "停 0" assertEqualMsg (VillageArt.IdleFrame VillageArt.IdleTwo) (VillageArt.characterFrame false 24L) "停 24" assertEqualMsg (VillageArt.IdleFrame VillageArt.IdleOne) (VillageArt.characterFrame false 9999L) "停任意 tick 仍在 idle" // 玩家行走规格:方向 + 是否移动 + tick 决定帧,四向共享同一帧序(方向只换源格)。 let framesFor direction = - [ 0L; 4L; 8L; 12L ] |> List.map (fun t -> (VillageArt.avatarSpriteSpec direction true t).Frame) + [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map (fun t -> (VillageArt.avatarSpriteSpec direction true t).Frame) let north = framesFor VillageArt.NorthFacing assertEqualMsg [ VillageArt.WalkFrame VillageArt.FrameOne VillageArt.WalkFrame VillageArt.FrameTwo VillageArt.WalkFrame VillageArt.FrameThree - VillageArt.WalkFrame VillageArt.FrameFour ] north "方向->帧序确定" + VillageArt.WalkFrame VillageArt.FrameFour + VillageArt.WalkFrame VillageArt.FrameFive + VillageArt.WalkFrame VillageArt.FrameSix ] north "方向->帧序确定" for direction in [ VillageArt.SouthFacing; VillageArt.WestFacing; VillageArt.EastFacing ] do assertEqualMsg north (framesFor direction) "四向帧序一致" diff --git a/src/LivingVillage.Desktop.Tests/P72CharacterWalkTests.fs b/src/LivingVillage.Desktop.Tests/P72CharacterWalkTests.fs new file mode 100644 index 0000000..781d22c --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P72CharacterWalkTests.fs @@ -0,0 +1,40 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop + +/// P72 回归:角色行走帧表完整性——四向各 8 格(6 走 + 2 待机), +/// 六张行走帧两两可见区分(含左右过渡步),且与待机帧不同。 +[<TestClass>] +type P72CharacterWalkTests () = + + let letters variant direction frame = CharacterArt.frameLetters variant direction frame + + let cellDiff (a: string list) (b: string list) : int = + List.zip a b + |> List.sumBy (fun (ra, rb) -> Seq.zip ra rb |> Seq.filter (fun (x, y) -> x <> y) |> Seq.length) + + [<TestMethod>] + member _.WalkFrameTableHasSixDistinctPosesPerDirection () = + Assert.AreEqual<int>(8, CharacterArt.frameCount) + Assert.AreEqual<int>(6, CharacterArt.walkFrameCount) + Assert.AreEqual<int>(2, CharacterArt.idleFrameCount) + for variant in 0 .. CharacterArt.variantCount - 1 do + for direction in 0 .. CharacterArt.directionCount - 1 do + let walk = [ 0 .. CharacterArt.walkFrameCount - 1 ] |> List.map (letters variant direction) + // 6 张行走帧两两必须有可见差异(不允许重复姿态)。 + for i in 0 .. walk.Length - 1 do + for j in i + 1 .. walk.Length - 1 do + Assert.IsTrue( + cellDiff walk.[i] walk.[j] >= 4, + sprintf "v%d d%d 走帧 %d 与 %d 姿态重复" variant direction i j) + // 行走与待机必须不同。 + let idle = letters variant direction CharacterArt.walkFrameCount + Assert.IsTrue(cellDiff walk.[0] idle >= 4, "行走帧与待机帧相同") + + [<TestMethod>] + member _.AtlasStridePlacesSixWalkThenTwoIdleCells () = + Assert.AreEqual<int>(5120, CharacterArt.atlasWidth) + Assert.AreEqual<int>(5120, VillageArt.characterAtlasWidth) + let stride = VillageArt.characterAtlasWidth / 32 / (5 * 4) + Assert.AreEqual<int>(8, stride) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 61fda7d..68e5b93 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -326,7 +326,7 @@ type PrototypeTests () = Assert.IsTrue(first.PanelY + first.PanelHeight <= 720) [<TestMethod>] - member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndFourFrames () = + member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndSixFrames () = let sameNpc = npcVisualVariant (NpcId 7) let repeatedNpc = npcVisualVariant (NpcId 7) let otherNpc = npcVisualVariant (NpcId 8) @@ -345,7 +345,9 @@ type PrototypeTests () = Assert.AreEqual<AnimationFrame>(FrameTwo, (VillageArt.animationFrame 4L)) Assert.AreEqual<AnimationFrame>(FrameThree, (VillageArt.animationFrame 8L)) Assert.AreEqual<AnimationFrame>(FrameFour, (VillageArt.animationFrame 12L)) - Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 16L)) + Assert.AreEqual<AnimationFrame>(FrameFive, (VillageArt.animationFrame 16L)) + Assert.AreEqual<AnimationFrame>(FrameSix, (VillageArt.animationFrame 20L)) + Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 24L)) [<TestMethod>] member _.VillagersUseFourDistinctVariantsDistinctFromTheAvatar () = @@ -363,28 +365,27 @@ type PrototypeTests () = rect.X) |> Set.ofList Assert.AreEqual<int>(4, variantXes.Count) - Assert.AreEqual<int>(3840, VillageArt.characterAtlasWidth) + Assert.AreEqual<int>(5120, VillageArt.characterAtlasWidth) [<TestMethod>] - member _.WalkAnimationCoversFourFramesDeterministically () = - // 16-tick cycle, new frame every 4 sim ticks, no wall-clock dependency. + member _.WalkAnimationCoversSixFramesDeterministically () = + // 24-tick cycle, new frame every 4 sim ticks, no wall-clock dependency. let frames = [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame - Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameOne; FrameTwo ], frames) + Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], frames) let cycleFrames = - [ 0L; 4L; 8L; 12L ] + [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame |> List.distinct - Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour ], cycleFrames) + Assert.AreEqual<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ], cycleFrames) // Every frame maps to a distinct atlas cell column across the five variants. - // Every frame maps to a distinct atlas cell column across the five variants. let perFrameX = - [ FrameOne; FrameTwo; FrameThree; FrameFour ] + [ FrameOne; FrameTwo; FrameThree; FrameFour; FrameFive; FrameSix ] |> List.map (fun frame -> let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = WalkFrame frame } rect.X / 32) - Assert.AreEqual<int list>([ 0; 1; 2; 3 ], perFrameX) + Assert.AreEqual<int list>([ 0; 1; 2; 3; 4; 5 ], perFrameX) // Idle breath poses occupy the two cells after the walk cycle for each (variant, direction). let idleXes = @@ -392,7 +393,7 @@ type PrototypeTests () = |> List.map (fun frame -> let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = IdleFrame frame } rect.X / 32) - Assert.AreEqual<int list>([ 4; 5 ], idleXes) + Assert.AreEqual<int list>([ 6; 7 ], idleXes) [<TestMethod>] member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () = @@ -533,6 +534,9 @@ type PrototypeTests () = Assert.AreEqual<CharacterFrame>(IdleFrame IdleTwo, VillageArt.characterFrame false 24L) Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, VillageArt.characterFrame true 0L) Assert.AreEqual<CharacterFrame>(WalkFrame FrameTwo, VillageArt.characterFrame true 4L) + Assert.AreEqual<CharacterFrame>(WalkFrame FrameFive, VillageArt.characterFrame true 16L) + Assert.AreEqual<CharacterFrame>(WalkFrame FrameSix, VillageArt.characterFrame true 20L) + Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, VillageArt.characterFrame true 24L) Assert.AreEqual<CharacterFrame>(VillageArt.characterFrame true 12L, VillageArt.characterFrame true 12L) [<TestMethod>] @@ -550,18 +554,18 @@ type PrototypeTests () = Assert.IsTrue(peddler.WalkWindow < farmer.WalkWindow && farmer.WalkWindow < scholar.WalkWindow) Assert.IsTrue(peddler.IdleWindow < farmer.IdleWindow && farmer.IdleWindow < scholar.IdleWindow) - // Same tick, three trade-specific frames (at 12 ticks: Farmer IV, Peddler II, Scholar I). + // Same tick, three trade-specific frames (at 12 ticks: Farmer IV, Peddler VI, Scholar V). let frameAt trade tick = VillageArt.animationFrameFor (VillageArt.rhythmFor trade) tick let frames = [ VillageArt.Farmer; VillageArt.Peddler; VillageArt.Scholar ] |> List.map (fun trade -> frameAt trade 12L) - Assert.AreEqual<AnimationFrame list>([ FrameFour; FrameTwo; FrameOne ], frames) + Assert.AreEqual<AnimationFrame list>([ FrameFour; FrameSix; FrameFive ], frames) Assert.AreEqual<VillageArt.WalkRhythm>(VillageArt.rhythmFor VillageArt.Scholar, VillageArt.rhythmFor VillageArt.Scholar) // npcSpriteSpec now follows the NPC's own trade cadence. Assert.AreEqual<CharacterFrame>(WalkFrame FrameFour, (VillageArt.npcSpriteSpec (NpcId 0) VillageArt.SouthFacing true 12L).Frame) - Assert.AreEqual<CharacterFrame>(WalkFrame FrameTwo, (VillageArt.npcSpriteSpec (NpcId 1) VillageArt.SouthFacing true 12L).Frame) - Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, (VillageArt.npcSpriteSpec (NpcId 2) VillageArt.SouthFacing true 12L).Frame) + Assert.AreEqual<CharacterFrame>(WalkFrame FrameSix, (VillageArt.npcSpriteSpec (NpcId 1) VillageArt.SouthFacing true 12L).Frame) + Assert.AreEqual<CharacterFrame>(WalkFrame FrameFive, (VillageArt.npcSpriteSpec (NpcId 2) VillageArt.SouthFacing true 12L).Frame) [<TestMethod>] member _.ChimneySmokeFramesAreDeterministicAndThreePhase () = diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png Binary files differindex dd96e60..8698b39 100644 --- a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png +++ b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png diff --git a/src/LivingVillage.Desktop/CharacterArt.fs b/src/LivingVillage.Desktop/CharacterArt.fs index 9d02baf..bfe86ad 100644 --- a/src/LivingVillage.Desktop/CharacterArt.fs +++ b/src/LivingVillage.Desktop/CharacterArt.fs @@ -5,8 +5,9 @@ open Microsoft.Xna.Framework.Graphics /// P45 角色动画帧:全部原创手绘点阵,用像素代码内嵌生成图集,不依赖任何外部素材。 /// 布局与既有 `characterSourceRectangle` 完全一致:cell 32x48, -/// cellIndex = (variant*4 + direction)*6 + frame,共 5 变体 x 4 向 x 6 帧。 -/// direction 顺序 North/South/West/East(0..3),frame 顺序 走1/走2/走3/走4/待机1/待机2(0..5)。 +/// cellIndex = (variant*4 + direction)*8 + frame,共 5 变体 x 4 向 x 8 帧。 +/// direction 顺序 North/South/West/East(0..3), +/// frame 顺序 走1..走6/待机1/待机2(0..7);P72 将行走由 4 帧提升为 6 帧(含左右过渡步)。 module CharacterArt = let sourceWidth = 16 @@ -16,8 +17,8 @@ module CharacterArt = let cellHeight = sourceHeight * pixelScale let variantCount = 5 let directionCount = 4 - let frameCount = 6 - let walkFrameCount = 4 + let frameCount = 8 + let walkFrameCount = 6 let idleFrameCount = 2 let atlasWidth = cellWidth * variantCount * directionCount * frameCount @@ -102,6 +103,13 @@ module CharacterArt = "....BBBBBBBB...." "....RRRRRRRR...." ] + let private mirrorRow (row: string) : string = + System.String(row.ToCharArray() |> Array.rev) + + /// P72 过渡步:走 5/6 为走 2/3 的左右镜像,形成「左-右」对称的六帧循环。 + let private torsoWalk4 : string list = torsoWalk1 |> List.map mirrorRow + let private torsoWalk5 : string list = torsoWalk2 |> List.map mirrorRow + let private torsoIdle1 : string list = [ "...SRRRRRRRRS..." "...SRRRRRRRRS..." @@ -123,7 +131,8 @@ module CharacterArt = "....RRRRRRRR...." "....RRRRRRRR...." ] - /// 腿(6 行,行 18..23):四帧行走跨步姿态各不相同,待机两帧双脚并拢。 + /// 腿(6 行,行 18..23):六帧行走跨步姿态各不相同,待机两帧双脚并拢。 + /// 走 5/6 为走 2/3 的左右镜像(P72 过渡步),使左右跨步对称。 let private legsWalk0 : string list = [ "....RRRRRRRR...." "...RRRRRRRRRR..." @@ -154,7 +163,10 @@ module CharacterArt = ".....RRRRRR....." ".....RRR.RR....." ".....RRR.FF....." - ".....FFF........" ] + ".......FFFF....." ] + + let private legsWalk4 : string list = legsWalk1 |> List.map mirrorRow + let private legsWalk5 : string list = legsWalk2 |> List.map mirrorRow let private legsIdle : string list = [ "....RRRRRRRR...." @@ -165,13 +177,10 @@ module CharacterArt = ".....FFF.FF....." ] let private torsoByFrame : string list list = - [ torsoWalk0; torsoWalk1; torsoWalk2; torsoWalk3; torsoIdle1; torsoIdle2 ] + [ torsoWalk0; torsoWalk1; torsoWalk2; torsoWalk3; torsoWalk4; torsoWalk5; torsoIdle1; torsoIdle2 ] let private legsByFrame : string list list = - [ legsWalk0; legsWalk1; legsWalk2; legsWalk3; legsIdle; legsIdle ] - - let private mirrorRow (row: string) : string = - System.String(row.ToCharArray() |> Array.rev) + [ legsWalk0; legsWalk1; legsWalk2; legsWalk3; legsWalk4; legsWalk5; legsIdle; legsIdle ] let private headByDirection : string list list = [ headNorth; headSouth; headWest; headWest |> List.map mirrorRow ] diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index ff1dfe0..6252117 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -99,6 +99,9 @@ type LivingVillageGame() as this = // P36 证据钩子:复用 sample 脚本的走位/互动序列,但按状态抓 4 帧关键帧 // (靠近→提示出现→对话层打开且世界交互被隔离→关闭离开提示消失)后退出。 let p36ShotMode = Environment.GetEnvironmentVariable("LV_P36_SHOT") = "1" + // P72 evidence hook: with sample recording on, log each saved frame's per-NPC walk + // frame so the PNGs can be correlated with the deterministic 6-frame cycle. + let p72Log = Environment.GetEnvironmentVariable("LV_P72_LOG") = "1" let sampleMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_SAMPLE") = "1" || p36ShotMode let menuShotMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_MENU_SHOT") = "1" let daylightAutoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY_DAYLIGHT") = "1" @@ -2812,7 +2815,13 @@ type LivingVillageGame() as this = recordIndex <- recordIndex + 1 if recordIndex % recordEvery = 0 then System.IO.Directory.CreateDirectory recordDirectory |> ignore - this.SaveBackBuffer(sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex) + let name = sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex + this.SaveBackBuffer(name) + if p72Log then + printfn "p72-frame %s tick=%d" name world.Tick + for npc in world.Npcs do + let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick + printfn " npc=%A pos=(%.1f,%.1f) frame=%A moving=%b" npc.Id npc.Pos.X npc.Pos.Y spec.Frame (npc.Mind.Target <> npc.Pos) if menuShotMode then this.CaptureMenuShot() if p42ShotMode then diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 76abc4a..db594d2 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -108,13 +108,16 @@ module VillageArt = | FrameTwo | FrameThree | FrameFour + | FrameFive + | FrameSix /// Two-pose standing breath cycle used whenever a character is not moving. type IdleFrame = | IdleOne | IdleTwo - /// A character either walks (four-pose cycle) or breathes in place (two-pose cycle). + /// A character either walks (six-pose cycle, incl. left/right transition steps) or + /// breathes in place (two-pose cycle). type CharacterFrame = | WalkFrame of AnimationFrame | IdleFrame of IdleFrame @@ -363,13 +366,16 @@ module VillageArt = | Peddler -> { WalkWindow = 3L; IdleWindow = 20L; PhaseOffset = 1L } | Scholar -> { WalkWindow = 6L; IdleWindow = 36L; PhaseOffset = 2L } - /// Four-pose walk cycle at a rhythm's window (one pose per WalkWindow sim ticks). + /// Six-pose walk cycle at a rhythm's window (one pose per WalkWindow sim ticks). + /// P72: four strides became six by adding left/right transition steps. let animationFrameFor (rhythm: WalkRhythm) (tick: int64) : AnimationFrame = - match positiveModulo64 4L (tick / rhythm.WalkWindow + rhythm.PhaseOffset) with + match positiveModulo64 6L (tick / rhythm.WalkWindow + rhythm.PhaseOffset) with | 0L -> FrameOne | 1L -> FrameTwo | 2L -> FrameThree - | _ -> FrameFour + | 3L -> FrameFour + | 4L -> FrameFive + | _ -> FrameSix /// Two-pose standing breath cycle at a rhythm's IdleWindow, fully deterministic. let idleFrameFor (rhythm: WalkRhythm) (tick: int64) : IdleFrame = @@ -454,9 +460,11 @@ module VillageArt = | WalkFrame FrameTwo -> 1 | WalkFrame FrameThree -> 2 | WalkFrame FrameFour -> 3 - | IdleFrame IdleOne -> 4 - | IdleFrame IdleTwo -> 5 - let cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex + | WalkFrame FrameFive -> 4 + | WalkFrame FrameSix -> 5 + | IdleFrame IdleOne -> 6 + | IdleFrame IdleTwo -> 7 + let cellIndex = (variantIndex * 4 + directionIndex) * 8 + frameIndex Microsoft.Xna.Framework.Rectangle(cellIndex * 32, 0, 32, 48) let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a) @@ -566,7 +574,7 @@ module VillageArt = let worldTileCount = 32 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels - let characterAtlasWidth = 32 * 5 * 4 * 6 + let characterAtlasWidth = 32 * 5 * 4 * 8 let characterAtlasHeight = 48 let interiorAtlasWidth = 392 let interiorAtlasHeight = 96 |
