summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p40-boat-closeup-2x.pngbin0 -> 5343 bytes
-rw-r--r--docs/evidence/p40-boat.pngbin0 -> 86001 bytes
-rw-r--r--docs/evidence/p40-day.pngbin0 -> 85437 bytes
-rw-r--r--docs/evidence/p40-night.pngbin0 -> 163315 bytes
-rw-r--r--docs/维护说明.md41
-rw-r--r--scripts/make-p40-closeups.py41
-rw-r--r--src/LivingVillage.Desktop.Tests/AssetPackTests.fs6
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P40ArtTests.fs104
9 files changed, 191 insertions, 2 deletions
diff --git a/docs/evidence/p40-boat-closeup-2x.png b/docs/evidence/p40-boat-closeup-2x.png
new file mode 100644
index 0000000..2ed0c54
--- /dev/null
+++ b/docs/evidence/p40-boat-closeup-2x.png
Binary files differ
diff --git a/docs/evidence/p40-boat.png b/docs/evidence/p40-boat.png
new file mode 100644
index 0000000..dd43e03
--- /dev/null
+++ b/docs/evidence/p40-boat.png
Binary files differ
diff --git a/docs/evidence/p40-day.png b/docs/evidence/p40-day.png
new file mode 100644
index 0000000..d6e3d27
--- /dev/null
+++ b/docs/evidence/p40-day.png
Binary files differ
diff --git a/docs/evidence/p40-night.png b/docs/evidence/p40-night.png
new file mode 100644
index 0000000..6f4dc8d
--- /dev/null
+++ b/docs/evidence/p40-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 8ea81ec..ae0ae47 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -1287,3 +1287,44 @@ late pass),故「灯笼光晕 + 水面」以生成器夜帧为准,「炊�
`CaptureP39Shot` 落盘(避免软件渲染每帧多跑导致缓冲相同),须配合 `LV_ASSET_PACK=cc0`。
- **范围**:本单只做横向美术扩展与确定性摆放 + 夜光接入;不写瓦片、不改可达性、不动 `Sim.step`
(digest 不变),P36/P37/P38 均未受影响。
+
+## P40 美术深化切片(石板路衔接 / 乌篷船特征 / 石灯笼与红灯笼区分)
+
+承接 P39 验收的两处观察点(day 帧孤立石板摆位、乌篷船特征较小),做美术深化,范围严格限定:
+
+- **石板路衔接**:新增纯函数 `cc0PavingTiles map`(`VillageArt.fs`)=「本身或四邻属于
+ `map.Paths`(主路/桥引道/门口)或 `map.Bridges`(桥面)」的石板格索引集合。cc0 模式下
+ `drawTerrainTile` 的 code 2 只用该集合铺**连贯石板带**(新自绘索引 19),稀疏
+ `DecorativeStones` 孤立散布石板**改画草地**——seed 42/256x192 实测:全图 782 石板格中
+ 502 连贯、280 孤立被抑制。fallback(无 cc0 图集)与无 `mapGen` 的大世界保持原样。
+- **乌篷船特征**:重绘索引 14/15,船体放大到满 64x32 足迹、船帮加高,**拱形竹篷**高拱 +
+ 竹骨 + 脊线显著,桥上/岸上一眼可辨(仍是自绘像素格,不引外部素材)。
+- **石灯笼 vs 红灯笼形态区分**:石灯笼(16)重绘为明确三段石结构(宽石座 / 石室暖芯 /
+ 外挑石顶盖 + 宝顶);新增红灯笼(20)红纸圆身 + 竹骨 + 金穗 + 木顶盖,门口一盏。两者
+ **复用同一条 P14 预乘 alpha 径向光晕路径**(`nightLightTilesWith` 纳入 `cc0RedLanternTiles`),
+ 不新增光照系统。
+- **图集**:`make-cc0-art.py` 由 608x32(19 格)扩到 **672x32(21 格)**,索引 19 石板 / 20 红灯笼;
+ 重绘 14/15/16;**索引 0-13、17-18 逐字节不变**(`git show HEAD:...` 复核)。二次生成 byte-identical。
+- **确定性**:全部摆放为 map 纯函数;同 seed 昼/夜帧位置一致(`P40ArtTests` 断言),重生成逐字节
+ 相同;`Sim.step` 未改,`--performance-baseline` digest 不变。
+- **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P40ArtTests.fs`(6 例:连贯带覆盖路网且
+ 抑制孤立散布、摆放确定性、红灯笼落门口且确定、红/石灯笼同光晕路径、同 seed 重生成一致、
+ P40 索引在界内);`AssetPackTests` 索引列表加入 19/20。Release 全量 Desktop **201/201**、
+ Kernel **119/119** exit 0。
+- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行
+ byte-identical):
+ - `p40-day.png` / `p40-night.png`:cc0 模式村庄昼 / 夜整帧(相机对准乌篷船河段;昼帧红灯笼
+ 与石板带真实可见)。
+ - `p40-boat-closeup-2x.png`(400x240):乌篷船 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_P40_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL"
+ python3 scripts/make-p40-closeups.py docs/evidence docs/evidence
+ python3 scripts/check-no-white-blocks.py docs/evidence/p40-day.png docs/evidence/p40-night.png \
+ docs/evidence/p40-boat-closeup-2x.png
+ ```
+- **范围**:本单只做上述三处美术深化 + 对应确定性摆放/光晕接入;不写瓦片、不改可达性、
+ 不动 `Sim.step`,玩法/重构/新模块未触碰。
diff --git a/scripts/make-p40-closeups.py b/scripts/make-p40-closeups.py
new file mode 100644
index 0000000..1add13e
--- /dev/null
+++ b/scripts/make-p40-closeups.py
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+"""P40 evidence: NEAREST 1x->2x close-up of the Wu Peng boat at a doorway cluster.
+
+Takes the real 1280x720 game frames captured by the `LV_P40_SHOT=1` hook and
+crops the boat region, upscaling exactly 2x with NEAREST so pixel edges stay
+sharp (no mixed colours). Deterministic: fixed crop box, pure function of the
+input PNG, so running twice yields byte-identical output.
+
+Usage:
+ python3 scripts/make-p40-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 = [
+ ("p40-boat.png", (524, 288, 724, 408), "p40-boat-closeup-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 85fc99c..2a61255 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/P39 self-drawn cell must sit inside the 19-tile atlas band.
+ // Every P34/P39/P40 self-drawn cell must sit inside the atlas band.
let indices =
[ cc0TileIndexWaterA
cc0TileIndexWaterB
@@ -59,7 +59,9 @@ type AssetPackTests () =
cc0TileIndexBoatRight
cc0TileIndexStoneLantern
cc0TileIndexStall
- cc0TileIndexReeds ]
+ cc0TileIndexReeds
+ cc0TileIndexPaving
+ cc0TileIndexRedLantern ]
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 aae83a1..46bede8 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -20,6 +20,7 @@
<Compile Include="P37ProfessionTests.fs" />
<Compile Include="P38HudIconTests.fs" />
<Compile Include="P39ArtTests.fs" />
+ <Compile Include="P40ArtTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P40ArtTests.fs b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs
new file mode 100644
index 0000000..549640e
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs
@@ -0,0 +1,104 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel.Sim
+open LivingVillage.Desktop
+open LivingVillage.Desktop.VillageArt
+
+/// P40 art-deepening slice:
+/// * paving slabs follow the traffic network (paths / doorways / bridges) and the
+/// isolated decorative scatter is dropped, so cc0 ground reads as coherent bands;
+/// * red door lanterns complement the stone road lanterns with a distinct form,
+/// both feeding the existing P14 premultiplied-alpha night-glow path;
+/// * every placement stays a pure function of the map, so the same seed yields
+/// byte-identical positions across day and night frames.
+[<TestClass>]
+type P40ArtTests () =
+
+ let seed = 4242UL
+ let map = MapGen.generateWithSize 256 192 seed
+
+ let indexOf (x: int) (y: int) = y * map.Width + x
+ let isStone (x: int) (y: int) =
+ x >= 0 && x < map.Width && y >= 0 && y < map.Height
+ && map.Tiles.[indexOf x y] = int MapGen.GroundTile.Stone
+
+ [<TestMethod>]
+ member _.PavingCoversTrafficNetworkAndDropsIsolatedScatter () =
+ let paving = cc0PavingTiles map
+ Assert.IsTrue(paving.Count > 0, "seed 4242 should pave something")
+ // Every path / bridge anchor tile that is stone must be paved.
+ for (x, y) in map.Paths @ map.Bridges do
+ if isStone x y then
+ Assert.IsTrue(paving.Contains(indexOf x y), sprintf "path/bridge stone (%d,%d) must be paved" x y)
+ // Every paved tile must be stone and touch the network (itself or a 4-neighbour).
+ let anchors = (map.Paths @ map.Bridges) |> List.map (fun (x, y) -> indexOf x y) |> Set.ofList
+ let anchorAt (x: int) (y: int) =
+ x >= 0 && x < map.Width && y >= 0 && y < map.Height && Set.contains (indexOf x y) anchors
+ for i in paving do
+ let x = i % map.Width
+ let y = i / map.Width
+ Assert.IsTrue(isStone x y, "paving must only replace stone tiles")
+ Assert.IsTrue(
+ anchorAt x y || anchorAt (x - 1) y || anchorAt (x + 1) y || anchorAt x (y - 1) || anchorAt x (y + 1),
+ sprintf "paved tile (%d,%d) is disconnected from the traffic network" x y)
+ // The isolated decorative scatter must NOT be paved (that is the whole point).
+ Assert.IsTrue(paving.Count < (map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.Stone) |> Array.length),
+ "paving must be a strict subset of all stone (scatter dropped)")
+
+ [<TestMethod>]
+ member _.PavingIsDeterministicForTheSameSeed () =
+ let map2 = MapGen.generateWithSize 256 192 seed
+ let a = cc0PavingTiles map |> Set.toList |> List.sort |> List.map (fun i -> sprintf "%d" i) |> String.concat ";"
+ let b = cc0PavingTiles map2 |> Set.toList |> List.sort |> List.map (fun i -> sprintf "%d" i) |> String.concat ";"
+ Assert.AreEqual<string>(a, b)
+
+ [<TestMethod>]
+ member _.RedLanternsHangAtDoorwaysAndAreDeterministic () =
+ let lanterns = cc0RedLanternTiles map
+ let doors =
+ (map.Buildings @ map.Farmhouses)
+ |> List.map (fun b -> (b.DoorX, b.DoorY))
+ |> List.distinct
+ Assert.IsTrue(lanterns.Length > 0, "seed 4242 should hang red lanterns")
+ Assert.AreEqual<int>(doors.Length, lanterns.Length)
+ for (x, y) in lanterns do
+ Assert.IsTrue(List.contains (x, y) doors, "a red lantern hangs exactly at a doorway")
+ Assert.AreEqual<string>(
+ (lanterns |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"),
+ (cc0RedLanternTiles map |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"))
+
+ [<TestMethod>]
+ member _.RedAndStoneLanternsFeedTheSameNightGlowPath () =
+ let cc0Lights = nightLightTilesWith true map |> Set.ofList
+ let fallbackLights = nightLightTilesWith false map |> Set.ofList
+ // cc0 mode adds both the road stone lanterns and the red door lanterns.
+ for t in cc0RedLanternTiles map do
+ Assert.IsTrue(Set.contains t cc0Lights, "red lantern must be a cc0 night light")
+ for t in cc0LanternTiles map 12 do
+ Assert.IsTrue(Set.contains t cc0Lights, "road lantern must be a cc0 night light")
+ // Red lanterns hang at doorways, which are already house lights, so the
+ // fallback set may legitimately contain that tile; what must NOT leak is
+ // the road stone lantern set (a cc0-only placement).
+ for t in cc0LanternTiles map 12 do
+ Assert.IsFalse(Set.contains t fallbackLights, "road lantern must not leak into the fallback light set")
+ Assert.IsTrue(nightLightTiles map = nightLightTilesWith false map, "nightLightTiles keeps the old set")
+ Assert.IsTrue(Set.isSubset fallbackLights cc0Lights, "cc0 light set is a superset of the fallback set")
+
+ [<TestMethod>]
+ member _.SameSeedPlacementsAreStableAcrossGeneration () =
+ // Day and night frames reuse the same map; re-generating it must reproduce
+ // every P40 placement byte-for-byte (no clock, no hidden randomness).
+ let map2 = MapGen.generateWithSize 256 192 seed
+ Assert.AreEqual<string>(
+ (cc0PavingTiles map |> Set.toList |> List.sort |> List.map string |> String.concat ";"),
+ (cc0PavingTiles map2 |> Set.toList |> List.sort |> List.map string |> String.concat ";"))
+ Assert.AreEqual<string>(
+ (cc0RedLanternTiles map |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"),
+ (cc0RedLanternTiles map2 |> List.sort |> List.map (fun (x, y) -> sprintf "%d:%d" x y) |> String.concat ";"))
+
+ [<TestMethod>]
+ member _.P40AtlasIndicesAreInRange () =
+ for index in [ cc0TileIndexPaving; cc0TileIndexRedLantern ] do
+ Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "P40 tile index %d out of atlas" index)
+ Assert.AreEqual<int>(21, cc0TileCount)