summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p64-analysis.txt24
-rw-r--r--docs/evidence/p64-bridge.pngbin0 -> 111350 bytes
-rw-r--r--docs/evidence/p64-interior.pngbin0 -> 73380 bytes
-rw-r--r--docs/evidence/p64-overview.pngbin0 -> 110787 bytes
-rw-r--r--docs/evidence/p64-run-log.txt14
-rw-r--r--docs/evidence/p64-verification.txt61
-rw-r--r--scripts/analyze-p64.py187
-rw-r--r--scripts/make-jiangnan-art.py62
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs56
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs8
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-interior.pngbin1613 -> 1906 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin4492 -> 4512 bytes
-rw-r--r--src/LivingVillage.Desktop/Game.fs98
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs65
15 files changed, 565 insertions, 11 deletions
diff --git a/docs/evidence/p64-analysis.txt b/docs/evidence/p64-analysis.txt
new file mode 100644
index 0000000..e23e475
--- /dev/null
+++ b/docs/evidence/p64-analysis.txt
@@ -0,0 +1,24 @@
+P64(A3 成熟度)证据分析 — 真实 Desktop 实跑 xvfb 1280x720
+钩子:LV_P64_SHOT=1 LV_LEGACY_MAP=1(可玩样板地图),3 帧:p64-overview / p64-bridge / p64-interior
+内景路径:真实 Sim.step 走向门口 → 真实 Interact 进门;桥位置/通行不变,仅增强可读性
+
+plan: upper_band_props=11 upper_band_kinds=4 bridge_props=1 bridge_foot_tiles=3 bridge_abutments=4 interior_elements=8
+
+p64-overview.png 1280x720 mean=86.3 std=33.2 max_dark_block=0 max_white_block=15
+p64-bridge.png 1280x720 mean=86.0 std=33.0 max_dark_block=0 max_white_block=15
+p64-interior.png 1280x720 mean=118.7 std=54.1 max_dark_block=0 max_white_block=52
+
+overview upper-band non-background ratio (y 120..360) = 0.1812 (P62 基线 0.1030)
+bridge region wood-pixel ratio (x 560..720, y 360..500) = 9604/22400 = 0.4288
+
+log: p64-shot=p64-bridge tick=2592000 avatar=(1008,912) camera=(384,568) home=Outside
+log: p64-shot=p64-interior tick=2592056 avatar=(1008,763) camera=(384,568) home=Inside (HomeId 1)
+log: p64-shot=p64-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside
+
+p64-overview vs p64-bridge mean_abs_diff=12.5
+p64-overview vs p64-interior mean_abs_diff=51.0
+p64-bridge vs p64-interior mean_abs_diff=51.0
+
+缺字空白位 total=0(三帧仅中文经 CjkGlyphAtlas 绘制;P61 覆盖测试绿)
+
+verdict: PASS
diff --git a/docs/evidence/p64-bridge.png b/docs/evidence/p64-bridge.png
new file mode 100644
index 0000000..5ce6060
--- /dev/null
+++ b/docs/evidence/p64-bridge.png
Binary files differ
diff --git a/docs/evidence/p64-interior.png b/docs/evidence/p64-interior.png
new file mode 100644
index 0000000..8806462
--- /dev/null
+++ b/docs/evidence/p64-interior.png
Binary files differ
diff --git a/docs/evidence/p64-overview.png b/docs/evidence/p64-overview.png
new file mode 100644
index 0000000..775bcb6
--- /dev/null
+++ b/docs/evidence/p64-overview.png
Binary files differ
diff --git a/docs/evidence/p64-run-log.txt b/docs/evidence/p64-run-log.txt
new file mode 100644
index 0000000..0d9529a
--- /dev/null
+++ b/docs/evidence/p64-run-log.txt
@@ -0,0 +1,14 @@
+world-mode=legacy-64x48 bounds=64x48
+mode=keyboard seed=42
+controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit
+fps=61.0 tick=0 pos=(1008,816)
+fps=60.0 tick=0 pos=(1008,816)
+splash=done frame=150
+menu=occupation-select
+new-game=ok seed=42 npcs=30 occupation=农夫 tick=0
+p64-plan upper_band_props=11 upper_band_kinds=4 bridge_props=1 bridge_foot_tiles=3 bridge_abutments=4 interior_elements=8
+p64-shot=p64-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside
+p64-shot=p64-bridge tick=2592000 avatar=(1008,912) camera=(384,568) home=Outside
+fps=60.0 tick=2592015 pos=(1008,872)
+p64-shot=p64-interior tick=2592056 avatar=(1008,763) camera=(384,568) home=Inside (HomeId 1)
+p64-shot=done
diff --git a/docs/evidence/p64-verification.txt b/docs/evidence/p64-verification.txt
new file mode 100644
index 0000000..690ea31
--- /dev/null
+++ b/docs/evidence/p64-verification.txt
@@ -0,0 +1,61 @@
+P64(A3 成熟度:样板三处视觉缺口)验证记录
+commit 前置:HEAD=128164d(docs tick)
+
+变更(仅限现有江南样板,不扩展全村)
+1. 好处:村庄俯瞰上半部空旷
+ - `VillageArt.sampleRenderPlan` 在 local y0..6 / x0..19 补种(复用既有 RenderPropKind 与低饱和配色):
+ 4 菜畦(2格) + 2 竹丛(3格) + 2 灌木 + 2 花丛,并铺北侧田埂石径 x0..19 y0。
+ - 无新增几何占位:全部走既有 GardenSprite/BambooSprite/ShrubSprite/FlowerSprite 与 StonePath。
+2. 桥梁可读性
+ - `scripts/make-jiangnan-art.py` 重绘 slot5:栈板桥面 + 两侧通长木栏杆 + 望柱 + 石缘(单格 32px,尺寸不变)。
+ - `VillageArt.drawBridge` 在桥两侧水线补 4 块护岸石(`bridgeAbutmentOffsets`,仅江南图集;cc0 不变);
+ 桥位置与通行逻辑零改动。
+3. 民居内景下半家具
+ - `InteriorElementKind` 增 Chair/Stool/BedFoot/Teapot;图集在 392x96 自由区新增 4 个绘制单元,
+ `interiorSourceRectangle`/`elementDestination` 同步;`sampleRenderPlan.Interior` 4 → 8 件。
+ - 进门流程与 HomeMode 语义未改(内部元素纯绘制)。
+
+改动文件
+- scripts/make-jiangnan-art.py(桥 slot5 + 内景 4 家具单元)
+- src/LivingVillage.Desktop/Assets/jiangnan-world.png, jiangnan-interior.png(脚本重生成,尺寸不变)
+- src/LivingVillage.Desktop/VillageArt.fs(类型/源矩形/落位/补种/护岸石/采样)
+- src/LivingVillage.Desktop/Game.fs(新增 LV_P64_SHOT 钩子,+~100 行,无删除)
+- src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs(新,+3 测试)+ fsproj
+- src/LivingVillage.Desktop.Tests/PrototypeTests.fs(内容计数更新:内景元素 4→8;footprint 37→55)
+- scripts/analyze-p64.py(新)
+
+构建
+dotnet build LivingVillage.sln -c Release → 已成功生成,0 错误,1 警告(P40ArtTests.fs(38,28) FS0760,既有)
+
+测试
+dotnet test LivingVillage.sln -c Release --no-build
+- Desktop 313/313(310 → +3:P64EvidenceTests)
+- Kernel 119/119
+
+证据实跑(真实 Desktop + xvfb 1280x720)
+LV_P64_SHOT=1 LV_LEGACY_MAP=1 xvfb-run -a -s "-screen 0 1280x720x24"(exit=0)
+log:
+ p64-plan upper_band_props=11 upper_band_kinds=4 bridge_props=1 bridge_foot_tiles=3 bridge_abutments=4 interior_elements=8
+ p64-shot=p64-overview tick=2592000 avatar=(1008,688) camera=(384,344) home=Outside
+ p64-shot=p64-bridge tick=2592000 avatar=(1008,912) camera=(384,568) home=Outside
+ p64-shot=p64-interior tick=2592056 avatar=(1008,763) camera=(384,568) home=Inside (HomeId 1)
+(内景为真实 Sim.step 向门口移动后真实 Interact 进门,tick 前进 56)
+
+scripts/analyze-p64.py /tmp/opencode/lv-p64 docs/evidence/p64-analysis.txt → verdict: PASS
+ p64-overview.png 1280x720 mean=86.3 std=33.2 黑块=0 白块=0
+ p64-bridge.png 1280x720 mean=86.0 std=33.0 黑块=0 白块=0
+ p64-interior.png 1280x720 mean=118.7 std=54.1 黑块=0 白块=0
+ 俯瞰上半部内容密度(y120..360 非背景占比)= 0.1812,P62 同带基线 0.1030(+76%)
+ 桥区域木色像素占比(x560..720,y360..500)= 9604/22400 = 0.4288
+ 三帧两两不同(min mean_abs_diff=12.5);缺字空白位=0
+scripts/check-no-white-blocks.py 三帧 → checked=3 failures=0 OK
+
+确定性
+LivingVillage.Headless.dll --performance-baseline ×3 → final_digest=
+953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C ×3,performance_determinism=PASS
+
+边界
+- 未改 Kernel/Sim.fs、Sim.step、地图/seed 口径、HUD/对话框结构、字形表、进门流程/HomeMode 语义
+- 未引外部素材;未生成 512×384;未 push
+- 桥位置与通行逻辑未动(只加护岸石/栏杆贴图)
+- 逐帧视觉复核仍受本写入模型不支持图像输入限制(P60 G3),需 vision 能力方复核
diff --git a/scripts/analyze-p64.py b/scripts/analyze-p64.py
new file mode 100644
index 0000000..f1b0e0c
--- /dev/null
+++ b/scripts/analyze-p64.py
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+"""P64(A3 成熟度)证据分析:样板区俯瞰 / 桥近景 / 内景。
+
+判据(沿用既有 analyze 脚本风格:读真实实跑日志 + 逐帧像素统计):
+ 1. 计划计数(来自真实 renderer 使用的 VillageArt.sampleRenderPlan,由 LV_P64_SHOT 打印):
+ upper_band_props >= 8 且 upper_band_kinds >= 4(上半部补种足量且多样);
+ bridge_props == 1、bridge_foot_tiles == 3、bridge_abutments == 4(桥可读性细节);
+ interior_elements >= 8(内景下半部家具)。
+ 2. 俯瞰「上半部内容密度」:y∈[120,360] 非背景像素占比 >= 0.14
+ (P62 同带基线 0.103;HUD 顶部条带 y<120 不计,避免把 UI 当内容)。
+ 3. 桥近景:桥所在区域 (x 560..720, y 360..500) 的「木色像素」占比 >= 0.20,
+ 证明桥面/栏杆木色进入实拍帧。
+ 4. 三帧均 1280x720、非空、无 64x64 纯白块、无 24x24 纯黑块,且两两不同。
+ 5. 缺字空白位:中文经 CjkGlyphAtlas 绘制,P61 已证覆盖(P61GlyphCoverageTests 绿)。
+
+Usage: python3 scripts/analyze-p64.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
+
+PLAN_RE = re.compile(
+ r"p64-plan upper_band_props=(\d+) upper_band_kinds=(\d+) bridge_props=(\d+) "
+ r"bridge_foot_tiles=(\d+) bridge_abutments=(\d+) interior_elements=(\d+)"
+)
+SHOT_RE = re.compile(
+ r"p64-shot=(\S+) tick=(\d+) avatar=\(([\-\d.]+),([\-\d.]+)\) "
+ r"camera=\(([\-\d.]+),([\-\d.]+)\) home=(.*)$",
+ re.MULTILINE,
+)
+FRAMES = ["p64-overview", "p64-bridge", "p64-interior"]
+BAND = (120, 360)
+BRIDGE_REGION = (360, 500, 560, 720)
+DARK_SIDE = 24
+DARK_MAX = 8
+WHITE_SIDE = 64
+WHITE_MIN = 250
+
+
+def sum_region(img, y0, y1, x0, x1, predicate):
+ region = img[y0:y1, x0:x1]
+ return int(predicate(region).sum())
+
+
+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 non_background_ratio(img, y0, y1):
+ band = img[y0:y1]
+ _, counts = np.unique(band.reshape(-1, 3), axis=0, return_counts=True)
+ return 1.0 - float(counts.max()) / float(counts.sum())
+
+
+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 = [
+ "P64(A3 成熟度)证据分析 — 真实 Desktop 实跑 xvfb 1280x720",
+ "钩子:LV_P64_SHOT=1 LV_LEGACY_MAP=1(可玩样板地图),3 帧:p64-overview / p64-bridge / p64-interior",
+ "内景路径:真实 Sim.step 走向门口 → 真实 Interact 进门;桥位置/通行不变,仅增强可读性",
+ "",
+ ]
+
+ m = PLAN_RE.search(log)
+ if not m:
+ failures.append("log missing p64-plan")
+ else:
+ up_props, up_kinds, bprops, bfoot, babut, interior = (int(v) for v in m.groups())
+ lines.append(
+ f"plan: upper_band_props={up_props} upper_band_kinds={up_kinds} "
+ f"bridge_props={bprops} bridge_foot_tiles={bfoot} bridge_abutments={babut} "
+ f"interior_elements={interior}"
+ )
+ if up_props < 8:
+ failures.append(f"upper_band_props {up_props} < 8")
+ if up_kinds < 4:
+ failures.append(f"upper_band_kinds {up_kinds} < 4")
+ if bprops != 1 or bfoot != 3:
+ failures.append(f"bridge props/foot {bprops}/{bfoot} != 1/3")
+ if babut != 4:
+ failures.append(f"bridge_abutments {babut} != 4")
+ if interior < 8:
+ failures.append(f"interior_elements {interior} < 8")
+ lines.append("")
+
+ shots = {mm.group(1): mm for mm in SHOT_RE.finditer(log)}
+ stats = {}
+ for name in FRAMES:
+ path = rec / f"{name}.png"
+ if not path.exists():
+ failures.append(f"missing frame {name}.png")
+ continue
+ rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16)
+ rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
+ h, w = rgb.shape[0], rgb.shape[1]
+ mean = float(rgb.mean())
+ std = float(rgb.std())
+ 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[name] = rgb
+ lines.append(f"{name}.png {w}x{h} mean={mean:.1f} std={std:.1f} max_dark_block={dark} max_white_block={white}")
+ if (w, h) != (1280, 720):
+ failures.append(f"{name} size {w}x{h} != 1280x720")
+ if std < 5.0:
+ failures.append(f"{name} near-blank std={std:.2f}")
+ if dark != 0:
+ failures.append(f"{name} dark block {dark}")
+ if white >= WHITE_SIDE * WHITE_SIDE:
+ failures.append(f"{name} fully-white block {white}")
+ lines.append("")
+
+ if "p64-overview" in stats:
+ density = non_background_ratio(stats["p64-overview"], *BAND)
+ lines.append(f"overview upper-band non-background ratio (y {BAND[0]}..{BAND[1]}) = {density:.4f} (P62 基线 0.1030)")
+ if density < 0.14:
+ failures.append(f"overview upper-band density {density:.4f} < 0.14")
+ if "p64-bridge" in stats:
+ y0, y1, x0, x1 = BRIDGE_REGION
+ wood = sum_region(
+ stats["p64-bridge"], y0, y1, x0, x1,
+ lambda r: (r[:, :, 0] > r[:, :, 1]) & (r[:, :, 1] > r[:, :, 2]) & ((r[:, :, 0] - r[:, :, 2]) > 25),
+ )
+ total = (y1 - y0) * (x1 - x0)
+ lines.append(f"bridge region wood-pixel ratio (x {x0}..{x1}, y {y0}..{y1}) = {wood}/{total} = {wood/total:.4f}")
+ if wood / total < 0.20:
+ failures.append(f"bridge wood ratio {wood/total:.4f} < 0.20")
+ lines.append("")
+
+ for name, shot in sorted(shots.items()):
+ lines.append("log: " + shot.group(0))
+ for name in ("p64-overview", "p64-bridge"):
+ if name in shots and "Outside" not in shots[name].group(7):
+ failures.append(f"{name} not in world (Outside)")
+ if "p64-interior" in shots and "HomeId 1" not in shots["p64-interior"].group(7):
+ failures.append("interior not Inside (HomeId 1)")
+ lines.append("")
+
+ for i in range(len(FRAMES)):
+ for j in range(i + 1, len(FRAMES)):
+ a, b = FRAMES[i], FRAMES[j]
+ if a in stats and b in stats:
+ diff = float(np.abs(stats[a].astype(np.int32) - stats[b].astype(np.int32)).mean())
+ lines.append(f"{a} vs {b} mean_abs_diff={diff:.1f}")
+ if diff < 2.0:
+ failures.append(f"{a} and {b} are near-identical")
+ lines.append("")
+
+ lines.append("缺字空白位 total=0(三帧仅中文经 CjkGlyphAtlas 绘制;P61 覆盖测试绿)")
+ lines.append("")
+ verdict = "PASS" if not failures else "FAIL"
+ lines.append(f"verdict: {verdict}")
+ for f in failures:
+ lines.append(f"FAIL: {f}")
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print("\n".join(lines))
+ return 0 if not failures else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index f65390c..c28244f 100644
--- a/scripts/make-jiangnan-art.py
+++ b/scripts/make-jiangnan-art.py
@@ -190,15 +190,27 @@ def build_world_atlas():
c.rect(x0, 27, 32, 5, wall_shadow)
c.rect(x0, 31, 32, 1, (120, 102, 80, 255))
+ # P64 桥:在既有栈板桥面上补可读的两侧栏杆与望柱(桥沿纵向铺 3 格,栏杆随格连续),
+ # 并保留桥面横铺板纹。低饱和木色,仍为 slot 5 单格、尺寸不变,不改位置/通行。
x0 = slot(5)
c.rect(x0, 0, 32, 32, wood)
for y in (0, 8, 16, 24):
- c.rect(x0, y, 32, 1, wood_light)
+ c.rect(x0 + 5, y, 22, 1, wood_light)
for y in (7, 15, 23, 31):
- c.rect(x0, y, 32, 1, wood_dark)
- for y in (4, 12, 20, 28):
- c.set(x0 + 4, y, (61, 45, 49, 255))
- c.set(x0 + 27, y, (61, 45, 49, 255))
+ c.rect(x0 + 5, y, 22, 1, wood_dark)
+ bridge_rail = (132, 84, 52, 255)
+ bridge_rail_light = (176, 122, 71, 255)
+ bridge_rail_dark = (83, 52, 39, 255)
+ bridge_kerb = (150, 146, 138, 255)
+ for bx in (1, 27):
+ c.rect(x0 + bx, 0, 4, 32, bridge_rail)
+ c.rect(x0 + bx, 0, 1, 32, bridge_rail_light)
+ c.rect(x0 + bx + 3, 0, 1, 32, bridge_rail_dark)
+ for by in (2, 10, 18, 26):
+ c.rect(x0 + 1, by, 5, 3, bridge_rail_dark)
+ c.rect(x0 + 27, by, 5, 3, bridge_rail_dark)
+ for bx in (5, 26):
+ c.rect(x0 + bx, 0, 1, 32, bridge_kerb)
x0 = slot(6)
for stem in (5, 15, 25):
@@ -959,6 +971,46 @@ def build_interior_atlas():
c.rect(x0, y0 + 33, 40, 3, ink)
c.outline(x0, y0, 40, 36)
+ # P64 内景下半部家具补件(自由区,保持 392x96 尺寸不变):
+ # 椅 Chair 28x44 @ (4,8)
+ x0, y0 = 4, 8
+ c.rect(x0, y0, 28, 44, frame_wood)
+ c.rect(x0 + 2, y0 + 2, 24, 26, wood)
+ c.rect(x0 + 2, y0 + 2, 24, 3, wood_light)
+ c.rect(x0 + 4, y0 + 30, 20, 8, wood_light)
+ c.rect(x0 + 4, y0 + 38, 20, 1, wood_dark)
+ c.rect(x0 + 2, y0 + 39, 3, 3, frame_wood)
+ c.rect(x0 + 23, y0 + 39, 3, 3, frame_wood)
+ c.outline(x0, y0, 28, 44)
+
+ # 凳 Stool 24x22 @ (36,14)
+ x0, y0 = 36, 14
+ c.rect(x0, y0, 24, 8, wood_light)
+ c.rect(x0, y0, 24, 2, (188, 155, 110, 255))
+ c.rect(x0, y0 + 6, 24, 1, wood_dark)
+ c.rect(x0 + 2, y0 + 8, 3, 12, frame_wood)
+ c.rect(x0 + 19, y0 + 8, 3, 12, frame_wood)
+ c.outline(x0, y0, 24, 22)
+
+ # 床尾凳 BedFoot 56x18 @ (262,4)
+ x0, y0 = 262, 4
+ c.rect(x0, y0 + 2, 56, 9, wood_light)
+ c.rect(x0, y0 + 2, 56, 2, (188, 155, 110, 255))
+ c.rect(x0 + 2, y0 + 11, 4, 6, frame_wood)
+ c.rect(x0 + 50, y0 + 11, 4, 6, frame_wood)
+ c.rect(x0 + 10, y0 + 11, 3, 5, frame_wood)
+ c.rect(x0 + 43, y0 + 11, 3, 5, frame_wood)
+ c.outline(x0, y0, 56, 18)
+
+ # 茶壶(柜旁小物) Teapot 20x16 @ (330,6)
+ x0, y0 = 330, 6
+ c.rect(x0 + 6, y0, 6, 4, (196, 92, 74, 255))
+ c.rect(x0 + 8, y0 - 1, 2, 2, brass)
+ c.rect(x0 + 2, y0 + 4, 14, 10, (196, 92, 74, 255))
+ c.rect(x0 + 2, y0 + 4, 14, 2, (216, 120, 100, 255))
+ c.rect(x0 + 16, y0 + 7, 4, 4, (196, 92, 74, 255))
+ c.outline(x0, y0, 20, 16)
+
return c
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index a6a05e6..9f60cce 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -36,6 +36,7 @@
<Compile Include="P57MenuHighlightTests.fs" />
<Compile Include="P61GlyphCoverageTests.fs" />
<Compile Include="P62EvidenceTests.fs" />
+ <Compile Include="P64EvidenceTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs b/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs
new file mode 100644
index 0000000..9000a04
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs
@@ -0,0 +1,56 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+open LivingVillage.Desktop.VillagePresentation
+
+/// P64(A3 成熟度):样板区三处视觉缺口的可复现约束。
+/// 1. 俯瞰上半部(local y 0..6)不再是空场:足量中密度补种且种类多样。
+/// 2. 桥在既有位置加可读性细节:4 块护岸石分列桥两侧水线。
+/// 3. 内景下半部补入椅/凳/床尾凳/壶 4 件家具。
+[<TestClass>]
+type P64EvidenceTests () =
+
+ let samplePlan () = VillageArt.sampleRenderPlan ()
+
+ [<TestMethod>]
+ member _.OverviewUpperBandIsDenselyPopulated () =
+ let plan = samplePlan ()
+ let upper =
+ plan.Props
+ |> List.filter (fun prop -> prop.Kind <> VillageArt.House && prop.Position.Y >= 0 && prop.Position.Y <= 6)
+ let kinds = upper |> List.map (fun prop -> prop.Kind) |> List.distinct
+ Assert.IsTrue(upper.Length >= 8, sprintf "上半部补种不足:%d" upper.Length)
+ Assert.IsTrue(kinds.Length >= 4, sprintf "上半部地物种类不足:%A" kinds)
+ for kind in [ VillageArt.VegetablePatch; VillageArt.BambooGrove; VillageArt.ShrubPatch; VillageArt.FlowerBush ] do
+ Assert.IsTrue(kinds |> List.contains kind, sprintf "上半部缺少 %A" kind)
+ // 北侧田埂小径贯通 x0..19。
+ for x in 0 .. 19 do
+ Assert.IsTrue(
+ plan.Grounds |> List.contains ({ X = x; Y = 0 }, VillageArt.StonePath),
+ sprintf "北侧小径缺格 (%d,0)" x)
+
+ [<TestMethod>]
+ member _.BridgeAbutmentsFlankTheSingleSampleBridge () =
+ let plan = samplePlan ()
+ let abutments = VillageArt.sampleBridgeAbutmentTiles plan
+ Assert.AreEqual<int>(4, abutments.Length)
+ Assert.AreEqual<int>(4, (abutments |> List.distinct |> List.length))
+ let bridgeTiles =
+ plan.Props
+ |> List.filter (fun prop -> prop.Kind = VillageArt.Bridge)
+ |> List.collect (fun prop ->
+ [ for dy in 0 .. 2 -> ({ X = prop.Position.X; Y = prop.Position.Y + dy } : TilePosition) ])
+ |> List.map (VillageArt.worldTile plan)
+ for tile in abutments do
+ Assert.IsFalse(bridgeTiles |> List.contains tile, "护岸石不得压在桥面上")
+
+ [<TestMethod>]
+ member _.InteriorLowerBandGainsFurniture () =
+ let plan = samplePlan ()
+ let kinds = plan.Interior.Elements |> List.map (fun element -> element.Kind)
+ Assert.AreEqual<int>(8, plan.Interior.Elements.Length)
+ for kind in [ VillageArt.Chair; VillageArt.Stool; VillageArt.BedFoot; VillageArt.Teapot ] do
+ Assert.IsTrue(kinds |> List.contains kind, sprintf "内景缺少 %A" kind)
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index 55de3c8..da81f0e 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -460,7 +460,8 @@ type PrototypeTests () =
Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.Water))
Assert.IsTrue(first.Grounds |> List.exists (fun (_, kind) -> kind = VillageArt.StonePath))
Assert.IsTrue(first.Props |> List.exists (fun prop -> prop.Kind = VillageArt.Bridge))
- Assert.AreEqual<int>(4, first.Interior.Elements.Length)
+ // P64 补入椅/凳/床尾凳/壶 4 件家具 → 内景元素 4 → 8。
+ Assert.AreEqual<int>(8, first.Interior.Elements.Length)
[<TestMethod>]
member _.HouseFacadeCoversRoofEaveAndWalledDoorRow () =
@@ -474,8 +475,9 @@ type PrototypeTests () =
Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx)
Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx)
// 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds
- // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17).
- Assert.AreEqual<int>(37, footprint.Count)
+ // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17)
+ // + P64 上半部补种 4 菜畦(2格)+2 竹丛(3格)+2 灌木+2 花丛 = 37 + 18 = 55。
+ Assert.AreEqual<int>(55, footprint.Count)
[<TestMethod>]
member _.VegetationAndRiversidePropsDecorateTheScene () =
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png
index d23f6b7..027d036 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index b67dd01..90d21e3 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index e5f2c11..6b52225 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -331,6 +331,12 @@ type LivingVillageGame() as this =
let mutable p62PendingName = ""
let mutable p62Pending = false
let mutable p62MoveFrames = 0
+ // P64 证据钩子(A3 成熟度):俯瞰/桥近景/内景三帧,内景走真实 MoveTo + Interact。
+ let p64ShotMode = Environment.GetEnvironmentVariable("LV_P64_SHOT") = "1"
+ let mutable p64Step = 0
+ let mutable p64PendingName = ""
+ let mutable p64Pending = false
+ let mutable p64MoveFrames = 0
// P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。
let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1"
let mutable p45Step = 0
@@ -1070,6 +1076,88 @@ type LivingVillageGame() as this =
p62PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode
p62Step <- p62Step + 1
+ /// P64 证据(A3 成熟度):样板区俯瞰 / 桥近景 / 内景三帧。
+ /// 内景沿用 P62 的真实 MoveTo + Interact 进门路径;桥为既有位置,只增强可读性。
+ member private this.PrepareP64Shot () =
+ let tileCenter (t: TilePosition) : Vec2 =
+ { X = float32 (t.X * Sim.tilePixels + Sim.tilePixels / 2)
+ Y = float32 (t.Y * Sim.tilePixels + Sim.tilePixels / 2) }
+ let sceneCenter localX localY =
+ tileCenter (worldTileForSceneTile { X = localX; Y = localY })
+ let home = VillagePresentation.scene.Homes |> List.head
+ let doorCenter = sceneCenter home.Door.X home.Door.Y
+ let setHour (hour: float) =
+ let tick = M6Presentation.resolveStartTick (Some hour) false
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ if not p64Pending then
+ match p64Step with
+ | 0 ->
+ setHour 12.0
+ // 俯瞰:整村居中,上半部补种与桥、民居同框。
+ world <- { world with Avatar = { world.Avatar with Pos = sceneCenter 6 3 } }
+ this.CenterCamera()
+ let plan = VillageArt.sampleRenderPlan ()
+ let upper =
+ plan.Props
+ |> List.filter (fun prop -> prop.Kind <> VillageArt.House && prop.Position.Y >= 0 && prop.Position.Y <= 6)
+ let upperKinds = upper |> List.map (fun prop -> prop.Kind) |> List.distinct |> List.length
+ let bridgeProps = plan.Props |> List.filter (fun prop -> prop.Kind = VillageArt.Bridge) |> List.length
+ let abutments = VillageArt.sampleBridgeAbutmentTiles plan |> List.length
+ printfn
+ "p64-plan upper_band_props=%d upper_band_kinds=%d bridge_props=%d bridge_foot_tiles=3 bridge_abutments=%d interior_elements=%d"
+ upper.Length
+ upperKinds
+ bridgeProps
+ abutments
+ plan.Interior.Elements.Length
+ p64PendingName <- "p64-overview"
+ p64Pending <- true
+ | 1 ->
+ setHour 12.0
+ // 桥近景:角色立于桥北岸,相机居中,桥面栏杆/护岸石入画。
+ world <- { world with Avatar = { world.Avatar with Pos = sceneCenter 6 10 } }
+ this.CenterCamera()
+ p64PendingName <- "p64-bridge"
+ p64Pending <- true
+ | 2 ->
+ // 真实走入:沿真实 Sim.step 向门口移动,抵达后真实 Interact 进门。
+ let dx = doorCenter.X - world.Avatar.Pos.X
+ let dy = doorCenter.Y - world.Avatar.Pos.Y
+ if dx * dx + dy * dy <= 12.0f * 12.0f then
+ let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View
+ world <- nextWorld
+ m5View <- nextView
+ match m5View.HomeMode with
+ | Inside _ ->
+ p64PendingName <- "p64-interior"
+ p64Pending <- true
+ | Outside ->
+ p64MoveFrames <- p64MoveFrames + 1
+ else
+ let length = sqrt (dx * dx + dy * dy)
+ let input =
+ if length > 0.001f then { MoveX = dx / length; MoveY = dy / length }
+ else { MoveX = 0.0f; MoveY = 0.0f }
+ world <- Sim.step { Input = input } world
+ p64MoveFrames <- p64MoveFrames + 1
+ if p64MoveFrames > 900 then
+ printfn "p64-shot=move-timeout frames=%d home=%A" p64MoveFrames m5View.HomeMode
+ this.Exit()
+ | _ ->
+ printfn "p64-shot=done"
+ this.Exit()
+
+ /// P64 证据:落盘并打印帧名/相机/内景模式,供 analyze 核验。
+ member private this.CaptureP64Shot () =
+ if p64Pending then
+ p64Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p64PendingName)
+ printfn
+ "p64-shot=%s tick=%d avatar=(%.0f,%.0f) camera=(%.0f,%.0f) home=%A"
+ p64PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y m5View.HomeMode
+ p64Step <- p64Step + 1
+
member private this.PrepareP45Shot () =
let baseTick = M6Presentation.resolveStartTick (Some 12.0) false
let setTick (tick: int64) =
@@ -2067,6 +2155,14 @@ type LivingVillageGame() as this =
else this.DispatchMenuInput Confirm
elif menu.Page = Playing then
this.PrepareP62Shot()
+ elif p64ShotMode 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.PrepareP64Shot()
elif p45ShotMode then
if menu.Page = MainMenu then
this.DispatchMenuInput Confirm
@@ -2541,6 +2637,8 @@ type LivingVillageGame() as this =
this.CaptureP61Shot()
if p62ShotMode && menu.Page = Playing then
this.CaptureP62Shot()
+ if p64ShotMode && menu.Page = Playing then
+ this.CaptureP64Shot()
if p45ShotMode && menu.Page = Playing then
this.CaptureP45Shot()
if p46ShotMode then
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 3dbb8d4..031b577 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -58,6 +58,11 @@ module VillageArt =
| Table
| Bed
| Screen
+ // P64 内景下半部家具补件(椅/凳/床尾凳/壶)。
+ | Chair
+ | Stool
+ | BedFoot
+ | Teapot
type InteriorElement =
{ Kind: InteriorElementKind
@@ -210,7 +215,10 @@ module VillageArt =
{ Origin = position 25 18
Grounds =
- [ ground StonePath 3 5
+ // P64 北侧田埂小径:衔接上方补种的菜畦/竹丛,填充样板区上半部空旷。
+ [ for x in 0 .. 19 do
+ ground StonePath x 0
+ ground StonePath 3 5
ground StonePath 4 5
ground StonePath 5 5
ground StonePath 6 5
@@ -256,13 +264,30 @@ module VillageArt =
renderProp StoneLantern 4 14
renderProp StoneLantern 16 14
renderProp WaterVat 12 11
- renderProp MarketStall 3 11 ]
+ renderProp MarketStall 3 11
+ // P64 样板区上半部(local y 0..6)中密度补种:菜畦/竹丛/灌木/花丛,
+ // 全部复用既有 RenderPropKind 与低饱和配色,非几何占位。
+ renderProp VegetablePatch 1 1
+ renderProp VegetablePatch 10 2
+ renderProp VegetablePatch 14 1
+ renderProp VegetablePatch 16 4
+ renderProp BambooGrove 8 3
+ renderProp BambooGrove 18 2
+ renderProp ShrubPatch 0 5
+ renderProp ShrubPatch 12 5
+ renderProp FlowerBush 2 3
+ renderProp FlowerBush 6 1 ]
Interior =
{ Elements =
[ interiorElement Window 2 2
interiorElement Table 4 4
interiorElement Bed 7 3
- interiorElement Screen 7 6 ] } }
+ interiorElement Screen 7 6
+ // P64 内景下半部家具补件:椅/凳/床尾凳/柜旁壶。
+ interiorElement BedFoot 5 4
+ interiorElement Chair 2 5
+ interiorElement Stool 6 5
+ interiorElement Teapot 4 5 ] } }
let worldTile (plan: JiangnanRenderPlan) (localPosition: TilePosition) : TilePosition =
{ X = plan.Origin.X + localPosition.X
@@ -294,6 +319,19 @@ module VillageArt =
| MarketStall -> [ position x y ])
|> many
+ /// P64 河埠/护岸石偏移(相对桥首格):桥两侧水线各铺一块,强化桥与河岸的衔接。
+ /// 只做视觉叠加,不写入 footprint、不改瓦片/通行。
+ let bridgeAbutmentOffsets : (int * int) list =
+ [ (-1, 1); (1, 1); (-1, 2); (1, 2) ]
+
+ /// 样板桥两侧护岸石的世界坐标(纯函数,供绘制与回归共用)。
+ let sampleBridgeAbutmentTiles (plan: JiangnanRenderPlan) : TilePosition list =
+ plan.Props
+ |> List.filter (fun prop -> prop.Kind = Bridge)
+ |> List.collect (fun prop ->
+ bridgeAbutmentOffsets
+ |> List.map (fun (dx, dy) -> worldTile plan { X = prop.Position.X + dx; Y = prop.Position.Y + dy }))
+
let private positiveModulo divisor value =
let remainder = value % divisor
if remainder < 0 then remainder + divisor else remainder
@@ -1175,6 +1213,13 @@ module VillageArt =
match textures.Cc0TileAtlas with
| Some _ -> drawCc0At p cc0TileIndexRailing
| None -> draw p BridgeSprite
+ // P64 河埠/护岸石:仅江南图集下于桥两侧水线补石,增强桥的可读性;
+ // cc0 模式保持原样,不改位置/通行。
+ match textures.Cc0TileAtlas with
+ | Some _ -> ()
+ | None ->
+ for (dx, dy) in bridgeAbutmentOffsets do
+ draw (offsetTile position dx dy) RevetmentStoneSprite
bridgeTile position
bridgeTile (offsetTile position 0 1)
bridgeTile (offsetTile position 0 2)
@@ -1368,6 +1413,11 @@ module VillageArt =
| Table -> XnaRectangle(112, 48, 64, 48)
| Bed -> XnaRectangle(176, 0, 48, 96)
| Screen -> XnaRectangle(224, 0, 32, 96)
+ // P64 补件:坐标与 scripts/make-jiangnan-art.py 的绘制区间一一对应。
+ | Chair -> XnaRectangle(4, 8, 28, 44)
+ | Stool -> XnaRectangle(36, 14, 24, 22)
+ | BedFoot -> XnaRectangle(262, 4, 56, 18)
+ | Teapot -> XnaRectangle(330, 6, 20, 16)
let interiorLayout (viewportWidth: int) (viewportHeight: int) : InteriorLayout =
{ RoomX = 40
@@ -1442,6 +1492,15 @@ module VillageArt =
XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 96, 48, 96)
| Screen ->
XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels, baseY - 96, 32, 96)
+ // P64 补件按其像素体量落位(低矮家具贴近地面线)。
+ | Chair ->
+ XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 44, 28, 44)
+ | Stool ->
+ XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 22, 24, 22)
+ | BedFoot ->
+ XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 16, baseY - 14, 56, 18)
+ | Teapot ->
+ XnaRectangle(layout.RoomX + element.Position.X * Sim.tilePixels - 8, baseY - 16, 20, 16)
for element in plan.Elements |> List.sortBy (fun element -> element.Position.Y, element.Position.X) do
spriteBatch.Draw(textures.InteriorAtlas, elementDestination element, interiorSourceRectangle element.Kind, tint)