summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p39-closeup-2x.pngbin0 -> 12965 bytes
-rw-r--r--docs/evidence/p39-closeup-lantern-2x.pngbin0 -> 16382 bytes
-rw-r--r--docs/evidence/p39-day.pngbin0 -> 88876 bytes
-rw-r--r--docs/evidence/p39-lantern-night.pngbin0 -> 98017 bytes
-rw-r--r--docs/evidence/p39-night.pngbin0 -> 169151 bytes
-rw-r--r--docs/维护说明.md45
-rw-r--r--scripts/make-p39-closeups.py42
-rw-r--r--src/LivingVillage.Desktop.Tests/AssetPackTests.fs11
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P39ArtTests.fs139
10 files changed, 236 insertions, 2 deletions
diff --git a/docs/evidence/p39-closeup-2x.png b/docs/evidence/p39-closeup-2x.png
new file mode 100644
index 0000000..611b75a
--- /dev/null
+++ b/docs/evidence/p39-closeup-2x.png
Binary files 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
--- /dev/null
+++ b/docs/evidence/p39-closeup-lantern-2x.png
Binary files differ
diff --git a/docs/evidence/p39-day.png b/docs/evidence/p39-day.png
new file mode 100644
index 0000000..b57a5f7
--- /dev/null
+++ b/docs/evidence/p39-day.png
Binary files differ
diff --git a/docs/evidence/p39-lantern-night.png b/docs/evidence/p39-lantern-night.png
new file mode 100644
index 0000000..5685e7f
--- /dev/null
+++ b/docs/evidence/p39-lantern-night.png
Binary files differ
diff --git a/docs/evidence/p39-night.png b/docs/evidence/p39-night.png
new file mode 100644
index 0000000..e26565d
--- /dev/null
+++ b/docs/evidence/p39-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index fb4164f..8ea81ec 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.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 <record-dir> <out-dir>
+"""
+
+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 () =
[<TestMethod>]
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 @@
<Compile Include="P36PromptTests.fs" />
<Compile Include="P37ProfessionTests.fs" />
<Compile Include="P38HudIconTests.fs" />
+ <Compile Include="P39ArtTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
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.
+[<TestClass>]
+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 ";"
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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)
+
+ [<TestMethod>]
+ 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<int>(map.MainRoadCenter.[x], y)
+ Assert.AreEqual<int>(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<string>(keyOf lanterns, keyOf (cc0LanternTiles map 12))
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ 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<int>((0: int), x % spacing)
+ for (x, _) in stalls do
+ Assert.AreEqual<int>(spacing / 2, x % spacing)
+
+ [<TestMethod>]
+ 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<int>(0, lanternCount)
+ Assert.AreEqual<int>(0, stallCount)
+
+ [<TestMethod>]
+ 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<string>(keyOf reeds, keyOf (cc0ReedTiles map 4))
+
+ [<TestMethod>]
+ 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")
+
+ [<TestMethod>]
+ member _.SameSeedPlacesTheSameElementsAcrossCalls () =
+ // Byte-identical placement under a repeated generation of the same seed.
+ let map2 = MapGen.generateWithSize 256 192 seed
+ Assert.AreEqual<string>(keyOf3 (cc0BridgeTiles map), keyOf3 (cc0BridgeTiles map2))
+ Assert.AreEqual<(int * int) option>(cc0BoatTile map, cc0BoatTile map2)
+ Assert.AreEqual<string>(keyOf (cc0LanternTiles map 12), keyOf (cc0LanternTiles map2 12))
+ Assert.AreEqual<string>(keyOf (cc0StallTiles map 12), keyOf (cc0StallTiles map2 12))
+ Assert.AreEqual<string>(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<string>(MapGen.serialize map, MapGen.serialize map3)