From ceb45d4006426d616aef099aaf16f5c1d827e816 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 15:58:43 +0800 Subject: test(p39): 摆放/光晕/奇偶 spacing 回归 + 运行时证据帧 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P39ArtTests 9 例:桥/船/灯笼/摊位/芦苇地形与确定性、NightLightTilesIncludeRoadLanterns、 StallsAndLanternsDoNotOverlapForOddAndEvenSpacing(奇 {3,5,7,11,13}/偶 {2,4,6,8,12})、 DegenerateSpacingPlacesNothing({0,1,-3} 返回空集) - AssetPackTests 更新为 19 格 + 新索引;fsproj 注册 P39ArtTests - scripts/make-p39-closeups.py:2x NEAREST 特写 - docs/维护说明.md 追加 P39 小节;证据 5 帧白块检查 0 命中,重复运行 byte-identical - Release 全量 Desktop 195/195、Kernel 119/119 --- docs/evidence/p39-closeup-2x.png | Bin 0 -> 12965 bytes docs/evidence/p39-closeup-lantern-2x.png | Bin 0 -> 16382 bytes docs/evidence/p39-day.png | Bin 0 -> 88876 bytes docs/evidence/p39-lantern-night.png | Bin 0 -> 98017 bytes docs/evidence/p39-night.png | Bin 0 -> 169151 bytes ...273\264\346\212\244\350\257\264\346\230\216.md" | 45 +++++++ scripts/make-p39-closeups.py | 42 +++++++ src/LivingVillage.Desktop.Tests/AssetPackTests.fs | 11 +- .../LivingVillage.Desktop.Tests.fsproj | 1 + src/LivingVillage.Desktop.Tests/P39ArtTests.fs | 139 +++++++++++++++++++++ 10 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/p39-closeup-2x.png create mode 100644 docs/evidence/p39-closeup-lantern-2x.png create mode 100644 docs/evidence/p39-day.png create mode 100644 docs/evidence/p39-lantern-night.png create mode 100644 docs/evidence/p39-night.png create mode 100644 scripts/make-p39-closeups.py create mode 100644 src/LivingVillage.Desktop.Tests/P39ArtTests.fs diff --git a/docs/evidence/p39-closeup-2x.png b/docs/evidence/p39-closeup-2x.png new file mode 100644 index 0000000..611b75a Binary files /dev/null and b/docs/evidence/p39-closeup-2x.png differ diff --git a/docs/evidence/p39-closeup-lantern-2x.png b/docs/evidence/p39-closeup-lantern-2x.png new file mode 100644 index 0000000..ab6429b Binary files /dev/null and b/docs/evidence/p39-closeup-lantern-2x.png differ diff --git a/docs/evidence/p39-day.png b/docs/evidence/p39-day.png new file mode 100644 index 0000000..b57a5f7 Binary files /dev/null and b/docs/evidence/p39-day.png differ diff --git a/docs/evidence/p39-lantern-night.png b/docs/evidence/p39-lantern-night.png new file mode 100644 index 0000000..5685e7f Binary files /dev/null and b/docs/evidence/p39-lantern-night.png differ diff --git a/docs/evidence/p39-night.png b/docs/evidence/p39-night.png new file mode 100644 index 0000000..e26565d Binary files /dev/null and b/docs/evidence/p39-night.png differ diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index fb4164f..8ea81ec 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" @@ -1242,3 +1242,48 @@ late pass),故「灯笼光晕 + 水面」以生成器夜帧为准,「炊 ``` - **范围**:本单只做 HUD 图标化(审计选出的最大差距);启动动画与开始界面已达标不再改动; 更细的 HUD 动效/主题换肤留待后续单。 + +## P39 美术横向扩展切片(cc0 图集补绘中式元素 + 确定性摆放 + 夜间光晕) + +在 P33/P34 竖直素材切片之后,为 `LV_ASSET_PACK=cc0` 图集补绘五类中式景观元素,并按确定性规则 +接入真实场景(摆放不写瓦片、不改可达性),让 cc0 模式昼夜帧都有可辨的中式村庄细节。 + +- **图集扩展**:`scripts/make-cc0-art.py` 由 384x32(12 格)扩到 **608x32(19 格)**;新增 7 个自绘 + 函数(`draw_bridge_center` / `draw_bridge_end` / `draw_boat` / `draw_stone_lantern` / `draw_stall` / + `draw_reeds`)。旧索引 0-11 **逐字节不变**(用 `git show HEAD:...` 校验通过);产物 608x32 / 1523 bytes / + sha256 `ef86ced2…6adcb`,二次生成 byte-identical。图集 `NEAREST` 放大 2x 无混色。 +- **新格索引契约**:12 石桥中段 / 13 石桥桥台 / 14 乌篷船左半 / 15 乌篷船右半 / 16 石灯笼 / + 17 路摊 / 18 芦苇(见 `docs/素材来源.md` 第 4 节)。 +- **确定性摆放**(`src/LivingVillage.Desktop/VillageArt.fs`,纯函数、仅 cc0 生效): + - `cc0BridgeTiles`:每座桥列最上一格铺中段(12),再上一格铺桥台(13)。 + - `cc0BoatTile`:开阔水面泊 2 格船,优先靠近最近的桥(曼哈顿距离),平局按 `hash2 … 0x39B0A7UL`。 + - `cc0LanternTiles map spacing`:`x % spacing = 0`,y = `MainRoadCenter.[x]`,避开水面。 + - `cc0StallTiles map spacing`:`x % spacing = spacing / 2`(与灯笼错开)。 + - `cc0ReedTiles map spacing`:陆地格且上下邻格为水,`x % spacing = 0`。 +- **奇偶 spacing 边界**(本单自检并修复):灯笼余数类 `{0}`、摊位余数类 `{spacing/2}`,`spacing >= 2` + 时两同余类恒不相交(奇偶皆成立);唯一退化点是 `spacing = 1`(`spacing/2 = 0` 与灯笼同余 → 重叠)。 + 两个摆放函数现**守卫 `spacing < 2` 返回空集**,从源头消除重叠;`spacing = 0`/负值同样返回空集。 +- **夜间光晕接入**:新增 `nightLightTilesWith (includeRoadLanterns: bool) map`;`nightLightTiles map = + nightLightTilesWith false map`(保持旧断言不变);`drawNightGlows` 末尾新增 `includeRoadLanterns` 参数, + VillageArt 内部三处 offscreen 调用传 `false`,`Game.fs` riverscape 分支传 `artTextures.Cc0TileAtlas.IsSome`。 + 夜帧石灯笼复用 P29/P32 径向光晕路径,真实可见。fallback 模式不摆放新元素。 +- **回归测试**:`P39ArtTests.fs` 由 7 例增至 **9 例**(新增 `StallsAndLanternsDoNotOverlapForOddAndEvenSpacing` + 覆盖奇 `{3,5,7,11,13}`/偶 `{2,4,6,8,12}` 两组,`DegenerateSpacingPlacesNothing` 覆盖 `{0,1,-3}`); + `AssetPackTests` 更新为 19 格 + 新索引断言。Release 全量 Desktop **195/195**、Kernel **119/119**。 +- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行 byte-identical): + - `p39-day.png` / `p39-night.png`:cc0 模式村庄真实运行昼 / 夜帧(同 seed)。 + - `p39-lantern-night.png`:夜间石灯笼光晕可见帧。 + - `p39-closeup-2x.png`(640x480)/ `p39-closeup-lantern-2x.png`(640x360):桥/船与灯笼 2x NEAREST 特写。 +- **复现**: + + ```bash + DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll + timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" \ + env LV_P39_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL" + python3 scripts/make-p39-closeups.py docs/evidence docs/evidence + python3 scripts/check-no-white-blocks.py docs/evidence/p39-*.png + ``` + `LV_P39_SHOT=1` 在 `Update` 里 `PrepareP39Shot` 设置 hour/camera 并置 `p39Pending`,`Draw` 末尾 + `CaptureP39Shot` 落盘(避免软件渲染每帧多跑导致缓冲相同),须配合 `LV_ASSET_PACK=cc0`。 +- **范围**:本单只做横向美术扩展与确定性摆放 + 夜光接入;不写瓦片、不改可达性、不动 `Sim.step` + (digest 不变),P36/P37/P38 均未受影响。 diff --git a/scripts/make-p39-closeups.py b/scripts/make-p39-closeups.py new file mode 100644 index 0000000..750b181 --- /dev/null +++ b/scripts/make-p39-closeups.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""P39 evidence: NEAREST 2x close-ups of the new Jiangnan elements. + +Takes the real 1280x720 game frames captured by the `LV_P39_SHOT=1` hook and +crops the new-element regions, upscaling exactly 2x with NEAREST so pixel edges +stay sharp (no mixed colours). Deterministic: fixed crop boxes, pure function of +the input PNGs, so running twice yields byte-identical output. + +Usage: + python3 scripts/make-p39-closeups.py +""" + +import sys +from pathlib import Path + +from PIL import Image + +# (source frame, crop box x0,y0,x1,y1, output name) +CROPS = [ + ("p39-day.png", (500, 180, 820, 420), "p39-closeup-2x.png"), + ("p39-lantern-night.png", (150, 270, 470, 450), "p39-closeup-lantern-2x.png"), +] + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_dir = Path(argv[2]) + out_dir.mkdir(parents=True, exist_ok=True) + for source_name, box, out_name in CROPS: + source = Image.open(record_dir / source_name).convert("RGBA") + crop = source.crop(box) + doubled = crop.resize((crop.width * 2, crop.height * 2), Image.NEAREST) + doubled.save(out_dir / out_name) + print(f"ok {out_name} {doubled.width}x{doubled.height} from {source_name} box={box}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs index 46513f9..85fc99c 100644 --- a/src/LivingVillage.Desktop.Tests/AssetPackTests.fs +++ b/src/LivingVillage.Desktop.Tests/AssetPackTests.fs @@ -43,7 +43,7 @@ type AssetPackTests () = [] member _.Cc0StructureTileIndicesFitTheAtlasAndWaterHasTwoPhases () = - // Every P34 self-drawn structure cell must sit inside the 12-tile atlas band. + // Every P34/P39 self-drawn cell must sit inside the 19-tile atlas band. let indices = [ cc0TileIndexWaterA cc0TileIndexWaterB @@ -52,7 +52,14 @@ type AssetPackTests () = cc0TileIndexWallWood cc0TileIndexWallWindow cc0TileIndexRailing - cc0TileIndexDoor ] + cc0TileIndexDoor + cc0TileIndexBridgeCenter + cc0TileIndexBridgeEnd + cc0TileIndexBoatLeft + cc0TileIndexBoatRight + cc0TileIndexStoneLantern + cc0TileIndexStall + cc0TileIndexReeds ] for index in indices do Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "cc0 tile index %d out of atlas" index) let width, _ = pngDimensions (assetPath cc0TileAtlasRelativePath) diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 8142917..aae83a1 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -19,6 +19,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P39ArtTests.fs b/src/LivingVillage.Desktop.Tests/P39ArtTests.fs new file mode 100644 index 0000000..831c336 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P39ArtTests.fs @@ -0,0 +1,139 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillageArt + +/// P39 Jiangnan element slice: the self-drawn CC0 elements appended after P34 +/// (bridge / boat / lantern / stall / reeds) must place deterministically on the +/// generated world, must not cover water with land props (boat excluded), and +/// must go through the same night-glow path as the P29/P32 lanterns. +[] +type P39ArtTests () = + + let seed = 4242UL + let map = MapGen.generateWithSize 256 192 seed + + let keyOf (tiles: (int * int) list) = + tiles |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";" + + let keyOf3 (tiles: (int * int * int) list) = + tiles |> List.sort |> List.map (fun (x, y, i) -> sprintf "%d:%d:%d" x y i) |> String.concat ";" + + [] + member _.BridgeTilesComeFromBridgeColumnsAndKeepApproachOffWater () = + // Every bridge column contributes a centre tile on the bridge and an end + // tile one row above; the end tile is the approach, never inside the river. + Assert.IsTrue(map.Bridges.Length > 0, "seed 4242 generated no bridge") + let placed = cc0BridgeTiles map + let bridgeColumns = map.Bridges |> List.map fst |> List.distinct |> List.sort + let placedColumns = placed |> List.map (fun (x, _, _) -> x) |> List.distinct |> List.sort + Assert.AreEqual<(int list)>(bridgeColumns, placedColumns) + let indices = placed |> List.map (fun (_, _, i) -> i) |> Set.ofList + Assert.IsTrue(Set.isSubset (Set.ofList [ cc0TileIndexBridgeCenter; cc0TileIndexBridgeEnd ]) indices) + for (x, _, _) in placed do + Assert.IsTrue(map.Bridges |> List.exists (fun (bx, _) -> bx = x), "bridge tile must sit on a bridge column") + + [] + member _.BoatSitsOnOpenWaterAndIsDeterministic () = + match cc0BoatTile map with + | None -> Assert.Fail "seed 4242 should place a boat" + | Some (x, y) -> + let water (tx: int) (ty: int) = map.Tiles.[ty * map.Width + tx] = int MapGen.GroundTile.Water + Assert.IsTrue(water x y && water (x + 1) y, "boat hull must float on water") + Assert.IsTrue(x >= 1 && x < map.Width - 2, "boat must leave room for its right half") + Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map) + + [] + member _.LanternsFollowTheMainRoadAtFixedSpacing () = + let lanterns = cc0LanternTiles map 12 + Assert.IsTrue(lanterns.Length > 0, "road lanterns should appear along the main road") + for (x, y) in lanterns do + Assert.AreEqual(map.MainRoadCenter.[x], y) + Assert.AreEqual(0, x % 12) + Assert.IsTrue(map.Tiles.[y * map.Width + x] <> int MapGen.GroundTile.Water) + // Deterministic across calls (same seed/map -> byte-identical). + Assert.AreEqual(keyOf lanterns, keyOf (cc0LanternTiles map 12)) + + [] + member _.StallsAndLanternsDoNotShareTiles () = + let lanterns = cc0LanternTiles map 12 |> Set.ofList + let stalls = cc0StallTiles map 12 + Assert.IsTrue(stalls.Length > 0, "road stalls should appear along the main road") + for stall in stalls do + Assert.IsFalse(Set.contains stall lanterns, "a stall must not overlap a lantern") + + [] + member _.StallsAndLanternsDoNotOverlapForOddAndEvenSpacing () = + // The stall column is offset by `spacing / 2` from the lanterns. For any + // spacing >= 2 the two residue classes (0 and spacing/2) are distinct, so + // they never collide — this must hold for both even and odd spacing, since + // integer truncation of spacing/2 on odd values was a suspected edge case. + let oddSpacings = [ 3; 5; 7; 11; 13 ] + let evenSpacings = [ 2; 4; 6; 8; 12 ] + for spacing in oddSpacings @ evenSpacings do + let lanterns = cc0LanternTiles map spacing |> Set.ofList + let stalls = cc0StallTiles map spacing + Assert.IsTrue(lanterns.Count > 0, sprintf "spacing %d should place lanterns" spacing) + Assert.IsTrue(stalls.Length > 0, sprintf "spacing %d should place stalls" spacing) + for stall in stalls do + let message = sprintf "spacing %d: stall at (%d,%d) overlapped a lantern" spacing (fst stall) (snd stall) + Assert.IsFalse(Set.contains stall lanterns, message) + // The residue classes are exactly {0} and {spacing/2}, so no even/odd + // spacing can put a stall and a lantern on the same column. + for (x, _) in lanterns do + Assert.AreEqual((0: int), x % spacing) + for (x, _) in stalls do + Assert.AreEqual(spacing / 2, x % spacing) + + [] + member _.DegenerateSpacingPlacesNothing () = + // spacing = 1 would make spacing/2 = 0, putting stalls on the same residue + // class as lanterns (a real overlap). Both helpers guard spacing < 2 and + // return an empty set instead of overlapping. + for spacing in [ 0; 1; -3 ] do + let lanternCount: int = (cc0LanternTiles map spacing).Length + let stallCount: int = (cc0StallTiles map spacing).Length + Assert.AreEqual(0, lanternCount) + Assert.AreEqual(0, stallCount) + + [] + member _.ReedsHugTheWaterBank () = + let reeds = cc0ReedTiles map 4 + Assert.IsTrue(reeds.Length > 0, "reeds should line the banks") + let water (tx: int) (ty: int) = + tx >= 0 && tx < map.Width && ty >= 0 && ty < map.Height + && map.Tiles.[ty * map.Width + tx] = int MapGen.GroundTile.Water + for (x, y) in reeds do + Assert.IsFalse(water x y, "reeds are placed on land") + Assert.IsTrue(water x (y - 1) || water x (y + 1), "reeds must touch the water bank") + Assert.AreEqual(keyOf reeds, keyOf (cc0ReedTiles map 4)) + + [] + member _.NightLightTilesIncludeRoadLanterns () = + // The P39 stone lanterns reuse the P29/P32 night-glow path, so every road + // lantern must be a light source in the night frame (cc0 mode). The + // backwards-compatible `nightLightTiles` keeps the old light set. + let cc0Lights = nightLightTilesWith true map |> Set.ofList + let fallbackLights = nightLightTiles map |> Set.ofList + for lantern in cc0LanternTiles map 12 do + Assert.IsTrue(Set.contains lantern cc0Lights, "road lantern must contribute night light in cc0 mode") + Assert.IsFalse(Set.contains lantern fallbackLights, "fallback light set must stay unchanged") + + [] + member _.SameSeedPlacesTheSameElementsAcrossCalls () = + // Byte-identical placement under a repeated generation of the same seed. + let map2 = MapGen.generateWithSize 256 192 seed + Assert.AreEqual(keyOf3 (cc0BridgeTiles map), keyOf3 (cc0BridgeTiles map2)) + Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map2) + Assert.AreEqual(keyOf (cc0LanternTiles map 12), keyOf (cc0LanternTiles map2 12)) + Assert.AreEqual(keyOf (cc0StallTiles map 12), keyOf (cc0StallTiles map2 12)) + Assert.AreEqual(keyOf (cc0ReedTiles map 4), keyOf (cc0ReedTiles map2 4)) + // Bridge/boat/lantern/stall/reed placement only depends on the map layout + // and fixed spacing (road lanterns are geometric, not seeded), so two seeds + // that happen to carve the same river still place the same elements. What + // must hold is that placement is a pure function of the map: regenerating + // the map from its own seed reproduces every position byte-for-byte. + let map3 = MapGen.generateWithSize map.Width map.Height map.Seed + Assert.AreEqual(MapGen.serialize map, MapGen.serialize map3) -- cgit v1.2.3