summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 19:46:21 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 19:46:21 +0800
commit997d2ae917a04baf77f99349ed00ea5df8892cfc (patch)
tree95ff5cc13cd4f1425d06782d4ef4bd2795511a72
parentf8ef6b68167b122eaeaa400db41c5d8481c9baa9 (diff)
downloadliving-village-997d2ae917a04baf77f99349ed00ea5df8892cfc.tar.gz
p45: 角色动画帧升级 — 四向x(走4+待机2)原创点阵内嵌图集, 行走/停止/idle 接入渲染
-rw-r--r--docs/evidence/p45-analysis.txt16
-rw-r--r--docs/evidence/p45-char-a.pngbin0 -> 2003 bytes
-rw-r--r--docs/evidence/p45-char-b.pngbin0 -> 2012 bytes
-rw-r--r--docs/evidence/p45-char-c.pngbin0 -> 2038 bytes
-rw-r--r--scripts/analyze-p45-char.py118
-rw-r--r--scripts/make-p45-char-crop.py52
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs112
-rw-r--r--src/LivingVillage.Desktop/CharacterArt.fs242
-rw-r--r--src/LivingVillage.Desktop/Game.fs70
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs4
12 files changed, 615 insertions, 1 deletions
diff --git a/docs/evidence/p45-analysis.txt b/docs/evidence/p45-analysis.txt
new file mode 100644
index 0000000..8f2e8cf
--- /dev/null
+++ b/docs/evidence/p45-analysis.txt
@@ -0,0 +1,16 @@
+P45 character animation analysis (LV_P45_SHOT=1, 4x NEAREST crops)
+
+[p45-char-a] facing=SouthFacing moving=False frame=IdleFrame IdleOne dest=(608,312,32x48) palette_hits=10176
+[p45-char-b] facing=SouthFacing moving=True frame=WalkFrame FrameTwo dest=(608,312,32x48) palette_hits=10368
+[p45-char-c] facing=EastFacing moving=True frame=WalkFrame FrameThree dest=(608,312,32x48) palette_hits=9472
+
+inter-frame delta (4x crop, aligned on the same avatar cell):
+ p45-char-a vs p45-char-b: changed_px(>16)=640 mean_abs_diff=0.47
+ p45-char-a vs p45-char-c: changed_px(>16)=3008 mean_abs_diff=4.44
+ p45-char-b vs p45-char-c: changed_px(>16)=3072 mean_abs_diff=4.58
+
+deterministic frame sequence (source cell x offsets, StrawHat variant):
+ SouthFacing: walk1..4+idle1..2 -> [3264, 3296, 3328, 3360, 3392, 3424]
+ EastFacing: walk1..4+idle1..2 -> [3648, 3680, 3712, 3744, 3776, 3808]
+
+verdict = PASS
diff --git a/docs/evidence/p45-char-a.png b/docs/evidence/p45-char-a.png
new file mode 100644
index 0000000..d4062de
--- /dev/null
+++ b/docs/evidence/p45-char-a.png
Binary files differ
diff --git a/docs/evidence/p45-char-b.png b/docs/evidence/p45-char-b.png
new file mode 100644
index 0000000..5425e50
--- /dev/null
+++ b/docs/evidence/p45-char-b.png
Binary files differ
diff --git a/docs/evidence/p45-char-c.png b/docs/evidence/p45-char-c.png
new file mode 100644
index 0000000..0c49a60
--- /dev/null
+++ b/docs/evidence/p45-char-c.png
Binary files differ
diff --git a/scripts/analyze-p45-char.py b/scripts/analyze-p45-char.py
new file mode 100644
index 0000000..bdfd9a5
--- /dev/null
+++ b/scripts/analyze-p45-char.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+"""P45 character-animation analysis.
+
+Reads the 4x `p45-char-{a,b,c}.png` crops (real LV_P45_SHOT screenshots, cropped
+around the avatar cell) + the shot log and writes docs/evidence/p45-analysis.txt:
+
+ 1. character source cell + on-screen dest rect + facing/frame per frame;
+ 2. pairwise inter-frame difference: Δchanged_px (max-channel > 16) and mean |Δ|;
+ 3. the deterministic frame sequence table (frame index -> source cell) per facing;
+ 4. a palette check that the original pixel-art character really is in each crop.
+
+Exit 0 when the differences are significant, 1 otherwise, 2 on usage error.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+CELL_W = 32
+CELL_H = 48
+VARIANT_STRAW_HAT = 4
+DIR_SOUTH = 1
+DIR_EAST = 3
+CHANGED_MIN = 400
+LINE_RE = re.compile(r"p45-shot=(\S+) tick=(\d+) facing=(\S+) moving=(\w+) dest=(\d+),(\d+),(\d+),(\d+) frame=(.+)")
+SKIN = np.array([234, 194, 152])
+ROBE = np.array([146, 96, 70])
+STRAW = np.array([216, 190, 120])
+
+
+def source_x(variant, direction, frame):
+ return ((variant * 4 + direction) * 6 + frame) * CELL_W
+
+
+def load(path):
+ return np.asarray(Image.open(path).convert("RGB"), dtype=int)
+
+
+def palette_hits(img):
+ def near(ref):
+ return (np.abs(img - ref).max(axis=2) <= 18).sum()
+ return int(near(SKIN) + near(ROBE) + near(STRAW))
+
+
+def main(argv):
+ if len(argv) != 4:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ log_text = Path(argv[2]).read_text(encoding="utf-8")
+ out_txt = Path(argv[3])
+ shots = []
+ for line in log_text.splitlines():
+ m = LINE_RE.search(line)
+ if m:
+ shots.append(
+ {
+ "name": m.group(1),
+ "tick": int(m.group(2)),
+ "facing": m.group(3),
+ "moving": m.group(4) == "true",
+ "dest": tuple(int(m.group(i)) for i in range(5, 9)),
+ "frame": m.group(9),
+ }
+ )
+ if len(shots) < 3:
+ sys.stderr.write("need three p45-shot frames\n")
+ return 1
+
+ images = {s["name"]: load(record_dir / f"{s['name']}.png") for s in shots}
+ lines = ["P45 character animation analysis (LV_P45_SHOT=1, 4x NEAREST crops)", ""]
+ failures = []
+ for s in shots:
+ x, y, w, h = s["dest"]
+ hits = palette_hits(images[s["name"]])
+ lines.append(f"[{s['name']}] facing={s['facing']} moving={s['moving']} frame={s['frame']} "
+ f"dest=({x},{y},{w}x{h}) palette_hits={hits}")
+ if w != CELL_W or h != CELL_H:
+ failures.append(f"{s['name']} dest cell is not 32x48")
+ if hits < 50:
+ failures.append(f"{s['name']} character palette not found ({hits})")
+
+ lines.append("")
+ lines.append("inter-frame delta (4x crop, aligned on the same avatar cell):")
+ names = [s["name"] for s in shots]
+ for i in range(len(names)):
+ for j in range(i + 1, len(names)):
+ a, b = images[names[i]], images[names[j]]
+ d = np.abs(a - b)
+ changed = int((d.max(axis=2) > 16).sum())
+ mean = float(d.mean())
+ lines.append(f" {names[i]} vs {names[j]}: changed_px(>16)={changed} mean_abs_diff={mean:.2f}")
+ if changed < CHANGED_MIN:
+ failures.append(f"{names[i]}-{names[j]} changed_px {changed} < {CHANGED_MIN}")
+
+ lines.append("")
+ lines.append("deterministic frame sequence (source cell x offsets, StrawHat variant):")
+ for label, direction in (("SouthFacing", DIR_SOUTH), ("EastFacing", DIR_EAST)):
+ offsets = [source_x(VARIANT_STRAW_HAT, direction, f) for f in range(6)]
+ lines.append(f" {label}: walk1..4+idle1..2 -> {offsets}")
+
+ lines.append("")
+ lines.append("verdict = " + ("PASS" if not failures else "FAIL"))
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ sys.stdout.write("\n".join(lines) + "\n")
+ if failures:
+ for f in failures:
+ sys.stderr.write(f"FAIL {f}\n")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/scripts/make-p45-char-crop.py b/scripts/make-p45-char-crop.py
new file mode 100644
index 0000000..430bc72
--- /dev/null
+++ b/scripts/make-p45-char-crop.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""P45 evidence: crop the avatar cell from the real LV_P45_SHOT frames and upscale 4x.
+
+Reads the shot log for the character destination rect printed by the game
+(`dest=x,y,w,h`), crops that region (plus a small margin) from the full 1280x720
+screenshot and upscales exactly 4x with NEAREST. Pure function of real frames.
+
+Usage:
+ python3 scripts/make-p45-char-crop.py <record-dir> <shot-log> <out-dir>
+"""
+
+import re
+import sys
+from pathlib import Path
+
+from PIL import Image
+
+MARGIN = 8
+SCALE = 4
+DEST_RE = re.compile(r"p45-shot=(\S+) tick=(\d+) facing=(\S+) moving=(\w+) dest=(\d+),(\d+),(\d+),(\d+) frame=(.+)")
+
+
+def main(argv):
+ if len(argv) != 4:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ log_text = Path(argv[2]).read_text(encoding="utf-8")
+ out_dir = Path(argv[3])
+ out_dir.mkdir(parents=True, exist_ok=True)
+ count = 0
+ for line in log_text.splitlines():
+ m = DEST_RE.search(line)
+ if not m:
+ continue
+ name = m.group(1)
+ x, y, w, h = (int(m.group(i)) for i in range(5, 9))
+ box = (x - MARGIN, y - MARGIN, x + w + MARGIN, y + h + MARGIN)
+ source = Image.open(record_dir / f"{name}.png").convert("RGBA")
+ crop = source.crop(box)
+ doubled = crop.resize((crop.width * SCALE, crop.height * SCALE), Image.NEAREST)
+ doubled.save(out_dir / f"{name}.png")
+ print(f"ok {name}.png {doubled.width}x{doubled.height} from {box} (facing={m.group(3)} moving={m.group(4)} frame={m.group(9)})")
+ count += 1
+ if count == 0:
+ sys.stderr.write("no p45-shot dest lines found\n")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 68ec033..dd51f36 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -24,6 +24,7 @@
<Compile Include="P42LaunchFxTests.fs" />
<Compile Include="P43HudTests.fs" />
<Compile Include="P44DialogTests.fs" />
+ <Compile Include="P45CharacterAnimTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs b/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs
new file mode 100644
index 0000000..eba03aa
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs
@@ -0,0 +1,112 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+
+/// P45 角色动画帧升级回归:四向 x 6 帧原创点阵、帧序列确定性、行走推进、停止回 idle。
+[<TestClass>]
+type P45CharacterAnimTests () =
+
+ let assertEqualMsg (expected: 'a) (actual: 'a) (message: string) =
+ Assert.IsTrue((expected = actual), sprintf "%s (expected %A, actual %A)" message expected actual)
+
+ [<TestMethod>]
+ member _.EveryCharacterFrameIsAWellFormedDotMatrix () =
+ for variant in 0 .. CharacterArt.variantCount - 1 do
+ for direction in 0 .. CharacterArt.directionCount - 1 do
+ for frame in 0 .. CharacterArt.frameCount - 1 do
+ let rows = CharacterArt.frameLetters variant direction frame
+ assertEqualMsg CharacterArt.sourceHeight rows.Length (sprintf "v%d d%d f%d 行数" variant direction frame)
+ for row in rows do
+ assertEqualMsg CharacterArt.sourceWidth row.Length (sprintf "v%d d%d f%d 行宽" variant direction frame)
+ Assert.IsTrue(rows |> List.exists (fun r -> r.Contains "S"), "角色须有皮肤像素")
+
+ [<TestMethod>]
+ member _.WalkFramesAndIdleFramesDifferVisibly () =
+ 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))
+ for variant in 0 .. CharacterArt.variantCount - 1 do
+ for direction in 0 .. CharacterArt.directionCount - 1 do
+ let frames = [ 0 .. CharacterArt.frameCount - 1 ] |> List.map (CharacterArt.frameLetters variant direction)
+ // 四帧行走两两相邻必须有可见差异。
+ for i in 0 .. CharacterArt.walkFrameCount - 2 do
+ Assert.IsTrue(cellDiff frames.[i] frames.[i + 1] >= 4, sprintf "走帧 %d->%d 差异不足" i (i + 1))
+ // 待机两帧(呼吸)必须不同。
+ let idle1 = CharacterArt.frameLetters variant direction (CharacterArt.walkFrameCount)
+ let idle2 = CharacterArt.frameLetters variant direction (CharacterArt.walkFrameCount + 1)
+ Assert.IsTrue(cellDiff idle1 idle2 >= 4, "待机两帧差异不足")
+ // 行走帧与待机帧必须不同。
+ Assert.IsTrue(cellDiff frames.[0] idle1 >= 4, "行走帧与待机帧相同")
+
+ [<TestMethod>]
+ member _.FourDirectionsAreVisuallyDistinct () =
+ for variant in 0 .. CharacterArt.variantCount - 1 do
+ for frame in 0 .. CharacterArt.frameCount - 1 do
+ let heads =
+ [ 0 .. CharacterArt.directionCount - 1 ]
+ |> List.map (fun d -> (CharacterArt.frameLetters variant d frame) |> List.take 10)
+ Assert.IsTrue(heads.[0] <> heads.[1], "North 与 South 头部须不同")
+ Assert.IsTrue(heads.[2] <> heads.[3], "West 与 East 头部须不同")
+ Assert.IsTrue(heads.[0] <> heads.[2], "North 与 West 头部须不同")
+
+ [<TestMethod>]
+ member _.FiveVariantsHaveDistinctPalettes () =
+ let palettes = [ 0 .. CharacterArt.variantCount - 1 ] |> List.map CharacterArt.variantColors
+ assertEqualMsg CharacterArt.variantCount (palettes |> List.distinct |> List.length) "5 个变体配色须互不相同"
+
+ [<TestMethod>]
+ member _.AtlasDimensionsMatchTheSourceRectangleContract () =
+ assertEqualMsg 32 CharacterArt.cellWidth "cell 宽"
+ assertEqualMsg 48 CharacterArt.cellHeight "cell 高"
+ assertEqualMsg VillageArt.characterAtlasWidth CharacterArt.atlasWidth "图集总宽须与既有契约一致"
+
+ [<TestMethod>]
+ member _.DirectionMapsToDistinctDeterministicSourceCells () =
+ let rect variant direction frame =
+ VillageArt.characterSourceRectangle
+ { Variant = variant; Direction = direction; Frame = frame }
+ for direction in [ VillageArt.NorthFacing; VillageArt.SouthFacing; VillageArt.WestFacing; VillageArt.EastFacing ] do
+ let a = rect VillageArt.IndigoRobe direction (VillageArt.WalkFrame VillageArt.FrameOne)
+ let b = rect VillageArt.IndigoRobe direction (VillageArt.WalkFrame VillageArt.FrameOne)
+ assertEqualMsg a b "同一输入的源矩形必须确定"
+ let xs =
+ [ 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 帧源格逐一不同且按序排列。
+ let frameXs =
+ [ VillageArt.WalkFrame VillageArt.FrameOne
+ VillageArt.WalkFrame VillageArt.FrameTwo
+ VillageArt.WalkFrame VillageArt.FrameThree
+ VillageArt.WalkFrame VillageArt.FrameFour
+ 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 frameXs (List.sort frameXs) "帧源格须按帧序递增"
+
+ [<TestMethod>]
+ member _.WalkingAdvancesFramesAndStoppingReturnsToIdle () =
+ // 行走:帧随 tick 按 WalkWindow 推进并循环。
+ assertEqualMsg (VillageArt.WalkFrame VillageArt.FrameOne) (VillageArt.characterFrame true 0L) "走 0"
+ 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 循环"
+ // 停止:无论 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)
+ let north = framesFor VillageArt.NorthFacing
+ assertEqualMsg [ VillageArt.WalkFrame VillageArt.FrameOne
+ VillageArt.WalkFrame VillageArt.FrameTwo
+ VillageArt.WalkFrame VillageArt.FrameThree
+ VillageArt.WalkFrame VillageArt.FrameFour ] north "方向->帧序确定"
+ for direction in [ VillageArt.SouthFacing; VillageArt.WestFacing; VillageArt.EastFacing ] do
+ assertEqualMsg north (framesFor direction) "四向帧序一致"
diff --git a/src/LivingVillage.Desktop/CharacterArt.fs b/src/LivingVillage.Desktop/CharacterArt.fs
new file mode 100644
index 0000000..9d02baf
--- /dev/null
+++ b/src/LivingVillage.Desktop/CharacterArt.fs
@@ -0,0 +1,242 @@
+namespace LivingVillage.Desktop
+
+open Microsoft.Xna.Framework
+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)。
+module CharacterArt =
+
+ let sourceWidth = 16
+ let sourceHeight = 24
+ let pixelScale = 2
+ let cellWidth = sourceWidth * pixelScale
+ let cellHeight = sourceHeight * pixelScale
+ let variantCount = 5
+ let directionCount = 4
+ let frameCount = 6
+ let walkFrameCount = 4
+ let idleFrameCount = 2
+ let atlasWidth = cellWidth * variantCount * directionCount * frameCount
+
+ // ---- 点阵字母:. 透明,其余为调色板索引 ----
+ // H 头发 S 皮肤 E 眼睛 K 描边 T 草帽 R 外衣 r 外衣暗部 B 腰带 F 鞋
+
+ /// 头部(10 行)按朝向:North 后脑、South 正面、West 左侧脸、East 右侧脸(镜像)。
+ let private headNorth : string list =
+ [ "................"
+ "....HHHHHHHH...."
+ "...HHHHHHHHHH..."
+ "...HHHHHHHHHH..."
+ "...HHHHHHHHHH..."
+ "...HHHHHHHHHH..."
+ "....HHHHHHHH...."
+ "....HHHHHHHH...."
+ ".....HHHHHH....."
+ "......KKKK......" ]
+
+ let private headSouth : string list =
+ [ "................"
+ "....HHHHHHHH...."
+ "...HHHHHHHHHH..."
+ "...HSSSSSSSSH..."
+ "...HSSESSESSH..."
+ "...HSSSSSSSSH..."
+ "...HSSSSSSSSH..."
+ "....SSSSSSSS...."
+ ".....SSSSSS....."
+ "......KKKK......" ]
+
+ let private headWest : string list =
+ [ "................"
+ "....HHHHHHHH...."
+ "...HHHHHHHHHH..."
+ "...HHHSSSSHHH..."
+ "...HHSESSSHHH..."
+ "...HHSSSSSHHH..."
+ "....HHSSSSHH...."
+ "....HSSSSSSH...."
+ ".....SSSSS......"
+ "......KKKK......" ]
+
+ /// 躯干(8 行,行 10..17):走/待机的左右手臂高度不同,形成摆臂。
+ let private torsoWalk0 : string list =
+ [ "...RRRRRRRRRS..."
+ "...RRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRR..."
+ "...SRRRRRRRRR..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...." ]
+
+ let private torsoWalk1 : string list =
+ [ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...." ]
+
+ let private torsoWalk2 : string list =
+ [ "...SRRRRRRRRR..."
+ "...SRRRRRRRRR..."
+ "...SRRRRRRRRS..."
+ "...RRRRRRRRRS..."
+ "...RRRRRRRRRS..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...." ]
+
+ let private torsoWalk3 : string list =
+ [ "...SRRRRRRRRR..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...RRRRRRRRRS..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...." ]
+
+ let private torsoIdle1 : string list =
+ [ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...." ]
+
+ /// 待机第二帧:手臂收短、腰带整体上提 2 行,读作呼吸起伏。
+ let private torsoIdle2 : string list =
+ [ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "...SRRRRRRRRS..."
+ "....RRRRRRRR...."
+ "....BBBBBBBB...."
+ "....RRRRRRRR...."
+ "....RRRRRRRR...."
+ "....RRRRRRRR...." ]
+
+ /// 腿(6 行,行 18..23):四帧行走跨步姿态各不相同,待机两帧双脚并拢。
+ let private legsWalk0 : string list =
+ [ "....RRRRRRRR...."
+ "...RRRRRRRRRR..."
+ "..RRR......RRR.."
+ "..RRR......RRR.."
+ "..FFF......FFF.."
+ "..FFF......FFF.." ]
+
+ let private legsWalk1 : string list =
+ [ "....RRRRRRRR...."
+ "....RRRRRRRR...."
+ ".....RRRRRR....."
+ ".....RR.RRR....."
+ ".....FF.RRR....."
+ "........FFF....." ]
+
+ let private legsWalk2 : string list =
+ [ "....RRRRRRRR...."
+ "...RRRRRRRRR...."
+ "...RRR....RRR..."
+ "...RRR....RR...."
+ "...FFF....FF...."
+ "...FFF.........." ]
+
+ let private legsWalk3 : string list =
+ [ "....RRRRRRRR...."
+ "....RRRRRRRR...."
+ ".....RRRRRR....."
+ ".....RRR.RR....."
+ ".....RRR.FF....."
+ ".....FFF........" ]
+
+ let private legsIdle : string list =
+ [ "....RRRRRRRR...."
+ "....RRRRRRRR...."
+ ".....RRRRRR....."
+ ".....RRR.RR....."
+ ".....FFF.FF....."
+ ".....FFF.FF....." ]
+
+ let private torsoByFrame : string list list =
+ [ torsoWalk0; torsoWalk1; torsoWalk2; torsoWalk3; 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)
+
+ let private headByDirection : string list list =
+ [ headNorth; headSouth; headWest; headWest |> List.map mirrorRow ]
+
+ /// 某个 (变体, 朝向, 帧) 的 24 行点阵字母;草帽变体在头部叠加草帽。
+ let frameLetters (variantIndex: int) (directionIndex: int) (frameIndex: int) : string list =
+ let head = headByDirection.[((directionIndex % directionCount) + directionCount) % directionCount]
+ let torso = torsoByFrame.[((frameIndex % frameCount) + frameCount) % frameCount]
+ let legs = legsByFrame.[((frameIndex % frameCount) + frameCount) % frameCount]
+ let composed = head @ torso @ legs
+ if (variantIndex % variantCount + variantCount) % variantCount = 4 then
+ composed
+ |> List.mapi (fun row line ->
+ if row = 1 || row = 2 then
+ line |> Seq.map (fun c -> if c = '.' then '.' else 'T') |> System.String.Concat
+ elif row = 3 then
+ line.ToCharArray()
+ |> Array.mapi (fun column c -> if column >= 2 && column <= 13 then 'T' else c)
+ |> System.String
+ else line)
+ else composed
+
+ /// 变体外衣主色 / 暗部 / 腰带;决定 5 个村民的观感差异。
+ let variantColors (variantIndex: int) : Color * Color * Color =
+ match ((variantIndex % variantCount) + variantCount) % variantCount with
+ | 0 -> Color(66, 84, 148), Color(44, 58, 110), Color(214, 198, 150) // 靛蓝长衫
+ | 1 -> Color(178, 132, 66), Color(128, 92, 44), Color(70, 60, 50) // 赭黄马甲
+ | 2 -> Color(74, 150, 128), Color(48, 110, 92), Color(232, 214, 160) // 青碧束带
+ | 3 -> Color(118, 124, 134), Color(84, 90, 100), Color(58, 62, 70) // 灰帽
+ | _ -> Color(146, 96, 70), Color(108, 70, 52), Color(90, 74, 48) // 斗笠农夫
+
+ let private colorOf (variantIndex: int) (letter: char) : Color option =
+ let robe, robeShade, belt = variantColors variantIndex
+ match letter with
+ | 'H' -> Some(Color(44, 36, 34))
+ | 'S' -> Some(Color(234, 194, 152))
+ | 'E' -> Some(Color(24, 22, 28))
+ | 'K' -> Some(Color(28, 30, 38))
+ | 'T' -> Some(Color(216, 190, 120))
+ | 'R' -> Some robe
+ | 'r' -> Some robeShade
+ | 'B' -> Some belt
+ | 'F' -> Some(Color(58, 42, 34))
+ | _ -> None
+
+ let private cellIndex (variantIndex: int) (directionIndex: int) (frameIndex: int) : int =
+ (variantIndex * directionCount + directionIndex) * frameCount + frameIndex
+
+ /// 像素点阵 -> 直通 alpha 图集纹理。仅用 SetData,无外部素材。
+ let buildAtlas (device: GraphicsDevice) : Texture2D =
+ let pixels = Array.zeroCreate<Color> (atlasWidth * cellHeight)
+ for variant in 0 .. variantCount - 1 do
+ for direction in 0 .. directionCount - 1 do
+ for frame in 0 .. frameCount - 1 do
+ let originX = cellIndex variant direction frame * cellWidth
+ let rows = frameLetters variant direction frame
+ for y in 0 .. sourceHeight - 1 do
+ let row = rows.[y]
+ for x in 0 .. sourceWidth - 1 do
+ match colorOf variant row.[x] with
+ | Some color ->
+ for sy in 0 .. pixelScale - 1 do
+ for sx in 0 .. pixelScale - 1 do
+ pixels.[(y * pixelScale + sy) * atlasWidth + originX + x * pixelScale + sx] <- color
+ | None -> ()
+ let texture = new Texture2D(device, atlasWidth, cellHeight)
+ texture.SetData pixels
+ texture
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 00ae04b..3e39b09 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -316,6 +316,11 @@ type LivingVillageGame() as this =
let mutable p44Step = 0
let mutable p44PendingName = ""
let mutable p44Pending = false
+ // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。
+ let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1"
+ let mutable p45Step = 0
+ let mutable p45PendingName = ""
+ let mutable p45Pending = false
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -894,6 +899,61 @@ type LivingVillageGame() as this =
(m5View.Prompt |> Option.defaultValue "<none>")
p44Step <- p44Step + 1
+ member private this.PrepareP45Shot () =
+ let baseTick = M6Presentation.resolveStartTick (Some 12.0) false
+ let setTick (tick: int64) =
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ if not p45Pending then
+ match p45Step with
+ | 0 ->
+ setTick baseTick
+ world <-
+ { world with
+ Avatar = { world.Avatar with Pos = { X = float32 (100 * Sim.tilePixels); Y = float32 (100 * Sim.tilePixels) } } }
+ avatarFacing <- VillageArt.SouthFacing
+ avatarMoving <- false
+ this.CenterCamera()
+ p45PendingName <- "p45-char-a"
+ p45Pending <- true
+ | 1 ->
+ setTick (baseTick + 4L)
+ avatarFacing <- VillageArt.SouthFacing
+ avatarMoving <- true
+ this.CenterCamera()
+ p45PendingName <- "p45-char-b"
+ p45Pending <- true
+ | 2 ->
+ setTick (baseTick + 8L)
+ avatarFacing <- VillageArt.EastFacing
+ avatarMoving <- true
+ this.CenterCamera()
+ p45PendingName <- "p45-char-c"
+ p45Pending <- true
+ | _ ->
+ printfn "p45-shot=done"
+ this.Exit()
+
+ /// P45 证据:在 `Draw` 末尾落盘,并打印角色目标矩形 / 朝向 / 帧,供裁剪与帧差分析。
+ member private this.CaptureP45Shot () =
+ if p45Pending then
+ p45Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p45PendingName)
+ let dest = VillageArt.characterDestination world.Avatar.Pos camera
+ let spec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick
+ printfn
+ "p45-shot=%s tick=%d facing=%s moving=%b dest=%d,%d,%d,%d frame=%s"
+ p45PendingName
+ world.Tick
+ (sprintf "%A" avatarFacing)
+ avatarMoving
+ dest.X
+ dest.Y
+ dest.Width
+ dest.Height
+ (sprintf "%A" spec.Frame)
+ p45Step <- p45Step + 1
+
/// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。
member private this.RunRiverscapeTour () =
if not riverscapeTourWatchStarted then
@@ -1254,6 +1314,14 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP44Shot()
+ elif p45ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ if menu.Selected <> 0 then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP45Shot()
elif menu.Page = Playing then
this.UpdatePlaying gameTime kb pressed pressedAny
elif menuShotMode then
@@ -1661,6 +1729,8 @@ type LivingVillageGame() as this =
this.CaptureP43Shot()
if p44ShotMode && menu.Page = Playing then
this.CaptureP44Shot()
+ if p45ShotMode && menu.Page = Playing then
+ this.CaptureP45Shot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index 76e9b06..51fb32e 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -10,6 +10,7 @@
<Compile Include="ProceduralMap.fs" />
<Compile Include="MapGen.fs" />
<Compile Include="WorldBootstrap.fs" />
+ <Compile Include="CharacterArt.fs" />
<Compile Include="VillageArt.fs" />
<Compile Include="ChineseText.fs" />
<Compile Include="CjkGlyphAtlas.fs" />
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 0f176aa..98e4cdf 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -764,7 +764,9 @@ module VillageArt =
selectedGroundAtlasRelativePath ()
|> Option.map (fun path -> loadTexture device path cc0TileAtlasWidth cc0TileAtlasHeight)
{ TileAtlas = loadTexture device worldAtlasRelativePath worldAtlasWidth worldAtlasHeight
- CharacterAtlas = loadTexture device (selectedCharacterAtlasRelativePath ()) cc0CharacterAtlasWidth cc0CharacterCellHeight
+ // P45:角色改用原创点阵像素代码内嵌生成的动画图集(5 变体 x 4 向 x 6 帧),
+ // 不再采样任何外部/占位角色素材;尺寸与既有 characterSourceRectangle 完全一致。
+ CharacterAtlas = CharacterArt.buildAtlas device
InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight
Cc0TileAtlas = cc0 }