diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:46:21 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:46:21 +0800 |
| commit | 997d2ae917a04baf77f99349ed00ea5df8892cfc (patch) | |
| tree | 95ff5cc13cd4f1425d06782d4ef4bd2795511a72 | |
| parent | f8ef6b68167b122eaeaa400db41c5d8481c9baa9 (diff) | |
| download | living-village-997d2ae917a04baf77f99349ed00ea5df8892cfc.tar.gz | |
p45: 角色动画帧升级 — 四向x(走4+待机2)原创点阵内嵌图集, 行走/停止/idle 接入渲染
| -rw-r--r-- | docs/evidence/p45-analysis.txt | 16 | ||||
| -rw-r--r-- | docs/evidence/p45-char-a.png | bin | 0 -> 2003 bytes | |||
| -rw-r--r-- | docs/evidence/p45-char-b.png | bin | 0 -> 2012 bytes | |||
| -rw-r--r-- | docs/evidence/p45-char-c.png | bin | 0 -> 2038 bytes | |||
| -rw-r--r-- | scripts/analyze-p45-char.py | 118 | ||||
| -rw-r--r-- | scripts/make-p45-char-crop.py | 52 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P45CharacterAnimTests.fs | 112 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CharacterArt.fs | 242 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 70 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 4 |
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 Binary files differnew file mode 100644 index 0000000..d4062de --- /dev/null +++ b/docs/evidence/p45-char-a.png diff --git a/docs/evidence/p45-char-b.png b/docs/evidence/p45-char-b.png Binary files differnew file mode 100644 index 0000000..5425e50 --- /dev/null +++ b/docs/evidence/p45-char-b.png diff --git a/docs/evidence/p45-char-c.png b/docs/evidence/p45-char-c.png Binary files differnew file mode 100644 index 0000000..0c49a60 --- /dev/null +++ b/docs/evidence/p45-char-c.png 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 } |
