diff options
| -rw-r--r-- | docs/evidence/p64-analysis.txt | 24 | ||||
| -rw-r--r-- | docs/evidence/p64-bridge.png | bin | 0 -> 111350 bytes | |||
| -rw-r--r-- | docs/evidence/p64-interior.png | bin | 0 -> 73380 bytes | |||
| -rw-r--r-- | docs/evidence/p64-overview.png | bin | 0 -> 110787 bytes | |||
| -rw-r--r-- | docs/evidence/p64-run-log.txt | 14 | ||||
| -rw-r--r-- | docs/evidence/p64-verification.txt | 61 | ||||
| -rw-r--r-- | scripts/analyze-p64.py | 187 | ||||
| -rw-r--r-- | scripts/make-jiangnan-art.py | 62 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P64EvidenceTests.fs | 56 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 8 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-interior.png | bin | 1613 -> 1906 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-world.png | bin | 4492 -> 4512 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 98 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 65 |
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 Binary files differnew file mode 100644 index 0000000..5ce6060 --- /dev/null +++ b/docs/evidence/p64-bridge.png diff --git a/docs/evidence/p64-interior.png b/docs/evidence/p64-interior.png Binary files differnew file mode 100644 index 0000000..8806462 --- /dev/null +++ b/docs/evidence/p64-interior.png diff --git a/docs/evidence/p64-overview.png b/docs/evidence/p64-overview.png Binary files differnew file mode 100644 index 0000000..775bcb6 --- /dev/null +++ b/docs/evidence/p64-overview.png 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 Binary files differindex d23f6b7..027d036 100644 --- a/src/LivingVillage.Desktop/Assets/jiangnan-interior.png +++ b/src/LivingVillage.Desktop/Assets/jiangnan-interior.png diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png Binary files differindex b67dd01..90d21e3 100644 --- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png +++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png 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) |
