diff options
| -rw-r--r-- | docs/evidence/p40-boat-closeup-2x.png | bin | 0 -> 5343 bytes | |||
| -rw-r--r-- | docs/evidence/p40-boat.png | bin | 0 -> 86001 bytes | |||
| -rw-r--r-- | docs/evidence/p40-day.png | bin | 0 -> 85437 bytes | |||
| -rw-r--r-- | docs/evidence/p40-night.png | bin | 0 -> 163315 bytes | |||
| -rw-r--r-- | docs/维护说明.md | 41 | ||||
| -rw-r--r-- | scripts/make-p40-closeups.py | 41 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/AssetPackTests.fs | 6 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P40ArtTests.fs | 104 |
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 Binary files differnew file mode 100644 index 0000000..2ed0c54 --- /dev/null +++ b/docs/evidence/p40-boat-closeup-2x.png diff --git a/docs/evidence/p40-boat.png b/docs/evidence/p40-boat.png Binary files differnew file mode 100644 index 0000000..dd43e03 --- /dev/null +++ b/docs/evidence/p40-boat.png diff --git a/docs/evidence/p40-day.png b/docs/evidence/p40-day.png Binary files differnew file mode 100644 index 0000000..d6e3d27 --- /dev/null +++ b/docs/evidence/p40-day.png diff --git a/docs/evidence/p40-night.png b/docs/evidence/p40-night.png Binary files differnew file mode 100644 index 0000000..6f4dc8d --- /dev/null +++ b/docs/evidence/p40-night.png 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) |
