diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | docs/维护说明.md | 66 | ||||
| -rw-r--r-- | scripts/make-jiangnan-art.py | 70 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/MapGenTests.fs | 15 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 53 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-world.png | bin | 4468 -> 4492 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 49 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MapGen.fs | 23 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 23 |
9 files changed, 247 insertions, 53 deletions
@@ -17,3 +17,4 @@ scripts/__pycache__/ opencode.json.before-luna scripts/m3_state.json.corrupt_* src/LivingVillage.Desktop/_asset-source/ +art/ diff --git a/docs/维护说明.md b/docs/维护说明.md index dbbf2ac..469dbc6 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -510,6 +510,39 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll 谣言子系统占比很小;P27 的收益集中在 Rumor 工作集路径,符合「隔离测量」的基准口径。 逐 tick NPC 快照分配属另一个课题(会触及不可变快照不变量),留给后续单。 +## P28:512 大图民居观感整改(P26 遗留) + +- **① 第二变体辨识度(确定性图集 + 纯绘制分支)**:P26 变体 B 在 512 整图 ~2px/tile 下不可辨, + 根因是挑檐与变体 A 同为近黑黛瓦、格栅窗与 A 横向窗差异在 2px 内被抹平。P28 给 B 独立 + 配色/几何:`scripts/make-jiangnan-art.py` slot28 挑檐改为亮青灰瓦色阶(base 84,106,110 / + light 134,158,158,A 仍是黛瓦脊 slot8),slot29 木格栅窗改竖长比例(内窗 10x18,A 为 18x16 + 横向),slot30 院门改绿双扇 + 青灰门楣;`VillageArt` 变体 B 再向上叠一层挑檐(该格为水面时 + 不叠,避免屋顶压水)且向两侧各出一格,墙身统一为格栅窗。图集二次运行 byte-identical + (`jiangnan-world.png` md5 `e5aca186a25b8f8b78d37c0d3ef167b5`)。1 tile≈2px 实测: + A 屋脊 `[46,60,54]` vs B 挑檐 `[68,101,82]`(ΔG≈40),墙线 A 暖 `[154,125,97]` vs + B 冷 `[118,140,115]`,肉眼可分(对照 4x NEAREST 裁剪)。回归 + `VariantBRoofPaletteIsClearlyLighterThanVariantA`:解码 world 图集断言 slot28 平均亮度 + > slot8 + 15。 +- **② 密度/聚落层次(仅大图 mapgen 参数)**:`MapGen.Params.ExtraRiversideHouses` 10 -> 16, + 额外民居宽度按 2/3/4 加权(2=附属小筑 / 3=民居 / 4=大宅)。512x384 seed4242 由 18 -> 24 座 + (变体 A 11 / B 13;宽度 2×8、3×10、4×6),不再是单一等距 3x2。默认图仍为 0 且不进入 + 大图分支、不消耗 `structureRng`,故 **64x48=6042052、256x192=1284656962 逐字节不变** + (`DefaultSizeChecksumsArePinned`)。回归 `LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps`。 +- **③ 取证钩子(仅证据路径,不改语义)**:`LV_MAPGEN_FIT_WIDTH/HEIGHT` 把整图按指定像素目标 + 再做一次真实离屏渲染(1024x768 对 512x384 恰为 2px/tile);`LV_MAPGEN_NIGHT=1` 时以 23:30 的 + 真实 `WorldTint` + golden/moonlight veil 出夜景整图,与 `DrawWorldView` 共用同一套纯函数。 + 全部为真实渲染帧,非编辑器合成/外表图像。 +- **回归/门槛**:Kernel **92** / Desktop **145** 全绿;Release build 0 警告 0 错误; + `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` + 3 次一致、`performance_determinism=PASS`。 +- **取证** `/tmp/opencode/lv-p28/art/`:`p28-map-512x384-seed4242-day.png`(512 整图真实渲染 + 3072x2304)、`p28-map-512x384-seed4242-2px-day.png`(1024x768 真实帧,1 tile=2px)、 + `p28-variant-pair-ab-4x-day.png`(A/B 相邻民居 4x NEAREST 对照,裁 tile x184..214 y186..196)、 + `p28-village-core-4x-day.png`(村核 4x)、`p28-village-density-2x-day.png`(密度总览), + 以及同名 `*-night.png`(夜景:整图 + 2px + A/B 对照)与 `p28-night-village.png` + (512 夜巡真实帧,HUD 在屏、民居/石板路清晰)。白块回归 4 张夜景整图 + 571 张夜巡帧 + 0 命中。 + ## 已验证命令 ```bash @@ -641,6 +674,34 @@ for pair in day:12.0 night:23.5; do name=${pair%%:*}; hour=${pair##*:}; \ # P26 夜景白块回归(方斑核对,0 命中) python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p26-art/night +# P28 512 整图真实渲染(3072 整图 + 1024x768 恰 2px/tile;night=1 走 23:30 tint+月光 veil) +for night in 0 1; do extra=""; [ "$night" = 1 ] && extra="LV_MAPGEN_NIGHT=1"; \ + dir=/tmp/opencode/lv-p28/art/map; [ "$night" = 1 ] && dir=/tmp/opencode/lv-p28/art/map-night; \ + timeout 180 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \ + LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \ + LV_MAPGEN_FIT_WIDTH=1024 LV_MAPGEN_FIT_HEIGHT=768 $extra LV_RECORD_DIR=$dir \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done +# 两个变体 4x NEAREST 裁剪对照(从 1024x768 真实帧按 2px/tile 裁 tile x184..214 y186..196) +python3 - <<'PY' +from PIL import Image +for tag, p in (("day","/tmp/opencode/lv-p28/art/map"), ("night","/tmp/opencode/lv-p28/art/map-night")): + im = Image.open(f"{p}/p28-fit-1024x768-seed4242.png").convert("RGB") + c = im.crop((184*2, 186*2, 214*2+2, 196*2+2)) + c.resize((c.width*4, c.height*4), Image.NEAREST).save(f"/tmp/opencode/lv-p28/art/p28-variant-pair-ab-4x-{tag}.png") +PY +# P28 夜景白块回归(4 张整图 + 571 张夜巡帧,0 命中) +python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/map-night +python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/night-raw + +# P28 512 夜巡真实帧(seed 42;默认桥/门巡游路点,夜景 23:30) +timeout 220 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ + LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \ + LV_RIVERSCAPE_SECONDS=60 LV_AUTOPLAY_START_HOUR=23.5 LV_NPC_VISIBLE_LOG=1 \ + LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p28-night- \ + LV_RECORD_DIR=/tmp/opencode/lv-p28/art/night-raw \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll +# 代表帧 p28-night-10100.png -> art/p28-night-village.png + # P20 第三步:默认生成器世界的昼夜巡游录制(真走位,含桥面行走帧;p20c-* 命名) for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ @@ -724,4 +785,9 @@ python3 scripts/check-no-white-blocks.py evidence/night-run - `/tmp/opencode/lv-p13-evidence/`(P14 修复后重录:`day-panorama.png`、`night-lantern.png`、 `day-night-compare.png`、`chimney-closeup.png`、`night-lantern-closeup.png`、 `white-block-check.txt`、`day-run/`、`night-run/`;同步一份到 `evidence/`) +- `/tmp/opencode/lv-p28/art/`(P28:`p28-map-512x384-seed4242-day.png`/`-night.png` 512 整图、 + `p28-map-512x384-seed4242-2px-day.png`/`-night.png` 1024x768 恰 2px/tile、 + `p28-variant-pair-ab-4x-day.png`/`-night.png` A/B 4x 对照、 + `p28-village-core-4x-day.png`/`-night.png`、`p28-village-density-2x-day.png`/`-night.png`、 + `p28-night-village.png` 夜巡代表帧、`night-raw/` 571 帧;工作区内不入 git) - `artifacts/perf-independent/<TS>/`(digest 3 联测回读) diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py index f537942..f65390c 100644 --- a/scripts/make-jiangnan-art.py +++ b/scripts/make-jiangnan-art.py @@ -547,26 +547,34 @@ def build_world_atlas(): c.rect(x0 + 3, 23, 26, 6, (83, 52, 39, 255)) c.outline(x0, 0, 32, 32) - # --- P26 house facade variant B: projecting eave, lattice window, courtyard gate. + # --- P26/P28 house facade variant B: projecting eave, tall lattice window, courtyard gate. # Same baked-atlas pipeline as every other tile; the renderer only blits these cells. + # P28: variant B gets its own lighter blue-grey roof colour scale (variant A keeps the + # near-black 黛瓦脊), a portrait lattice window (A keeps a landscape window) and a green + # double-leaf courtyard gate, so the two facades stay legible when the whole 512 map is + # scaled to roughly 2px per tile. - # slot(28) 屋檐挑出 (projecting eave): tiled roof band over an exposed wooden lip. + roof_b = (84, 106, 110, 255) + roof_b_light = (134, 158, 158, 255) + roof_b_dark = (56, 74, 80, 255) + + # slot(28) 屋檐挑出 (projecting eave): light blue-grey tiled roof band + exposed wooden lip. x0 = slot(28) - c.rect(x0, 0, 32, 13, roof_dark) + c.rect(x0, 0, 32, 14, roof_b) for x in range(0, 32, 8): - c.rect(x0 + x, 0, 1, 13, roof) + c.rect(x0 + x, 0, 1, 14, roof_b_dark) for x in range(4, 32, 8): - c.rect(x0 + x, 0, 1, 13, roof) - c.rect(x0, 2, 32, 1, roof_light) - c.rect(x0, 12, 32, 1, roof) - c.rect(x0, 13, 32, 3, wood) - c.rect(x0, 13, 32, 1, wood_light) - c.rect(x0, 15, 32, 1, wood_dark) + c.rect(x0 + x, 0, 1, 14, roof_b) + c.rect(x0, 2, 32, 2, roof_b_light) + c.rect(x0, 13, 32, 1, roof_b_dark) + c.rect(x0, 14, 32, 3, wood) + c.rect(x0, 14, 32, 1, wood_light) + c.rect(x0, 16, 32, 1, wood_dark) for rx in (3, 10, 17, 24): - c.rect(x0 + rx, 13, 2, 5, wood_dark) - c.rect(x0, 18, 32, 4, (23, 31, 34, 80)) + c.rect(x0 + rx, 14, 2, 4, wood_dark) + c.rect(x0, 17, 32, 4, (23, 31, 34, 80)) - # slot(29) 木格栅窗 (lattice window): wooden posts + fine grid muntins. + # slot(29) 木格栅窗 (lattice window): portrait frame with fine grid muntins. x0 = slot(29) c.rect(x0, 0, 32, 32, wall) c.rect(x0, 0, 32, 3, wood) @@ -576,32 +584,34 @@ def build_world_atlas(): c.rect(x0 + post_x, 0, 3, 32, wood) c.rect(x0 + post_x, 0, 1, 32, wood_light) c.rect(x0 + post_x + 2, 0, 1, 32, wood_dark) - c.rect(x0 + 6, 7, 20, 18, (94, 66, 40, 255)) - c.rect(x0 + 8, 9, 16, 14, (61, 45, 49, 255)) - for lx in (12, 16, 20): - c.rect(x0 + lx, 9, 1, 14, wall_seam) - for ly in (14, 18): - c.rect(x0 + 8, ly, 16, 1, wall_seam) - c.rect(x0 + 6, 7, 20, 1, wood_light) + c.rect(x0 + 9, 5, 14, 22, (94, 66, 40, 255)) + c.rect(x0 + 11, 7, 10, 18, (61, 45, 49, 255)) + for lx in (13, 16, 19): + c.rect(x0 + lx, 7, 1, 18, wall_seam) + for ly in (11, 15, 19, 23): + c.rect(x0 + 11, ly, 10, 1, wall_seam) + c.rect(x0 + 9, 5, 14, 1, wood_light) c.rect(x0 + 3, 28, 26, 4, (128, 128, 126, 255)) c.rect(x0 + 3, 28, 26, 1, (150, 150, 146, 255)) - # slot(30) 院门 (courtyard gate): tiled lintel cap, double doors, brass rings. + # slot(30) 院门 (courtyard gate): blue-grey lintel cap, green double doors, brass rings. + gate = (54, 88, 66, 255) + gate_light = (86, 128, 92, 255) + gate_dark = (36, 62, 46, 255) x0 = slot(30) c.rect(x0, 0, 32, 32, wall) c.rect(x0, 0, 32, 3, wood) c.rect(x0, 0, 32, 1, wood_light) c.rect(x0, 3, 32, 1, wood_dark) - c.rect(x0 + 3, 4, 26, 4, roof_dark) - c.rect(x0 + 3, 4, 26, 1, roof_light) - c.rect(x0 + 1, 8, 30, 2, roof) - c.rect(x0 + 1, 9, 30, 1, roof_dark) + c.rect(x0 + 3, 4, 26, 4, roof_b) + c.rect(x0 + 3, 4, 26, 1, roof_b_light) + c.rect(x0 + 1, 8, 30, 2, roof_b_dark) c.rect(x0 + 5, 10, 22, 18, (94, 66, 40, 255)) - c.rect(x0 + 7, 12, 18, 16, wood) - c.rect(x0 + 7, 12, 18, 1, wood_light) - c.rect(x0 + 15, 12, 2, 16, wood_dark) - c.rect(x0 + 9, 15, 5, 4, wood_dark) - c.rect(x0 + 18, 15, 5, 4, wood_dark) + c.rect(x0 + 7, 12, 18, 16, gate) + c.rect(x0 + 7, 12, 18, 1, gate_light) + c.rect(x0 + 15, 12, 2, 16, gate_dark) + c.rect(x0 + 8, 11, 7, 3, gate_dark) + c.rect(x0 + 18, 11, 6, 3, gate_light) c.rect(x0 + 13, 20, 2, 2, (196, 158, 74, 255)) c.rect(x0 + 17, 20, 2, 2, (196, 158, 74, 255)) c.rect(x0 + 6, 28, 20, 3, (83, 52, 39, 255)) diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index 6486dd7..b737707 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -256,6 +256,21 @@ type MapGenTests () = Assert.IsTrue(a.Buildings = b.Buildings) Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b) + /// P28:512 沿河民居由 10 -> 16 增密(+6),并出现 2/3/4 三档宽度(附属小筑/民居/大宅), + /// 形成聚落层次;默认图仍为 0,不消耗 RNG,故 64/256 checksum 锁定不变。 + [<TestMethod>] + member _.LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps () = + Assert.AreEqual<int>(16, (MapGen.paramsForSize 512 384 (uint64 1)).ExtraRiversideHouses) + Assert.AreEqual<int>(0, (MapGen.paramsForSize 64 48 (uint64 1)).ExtraRiversideHouses) + Assert.AreEqual<int>(0, (MapGen.paramsForSize 256 192 (uint64 1)).ExtraRiversideHouses) + let map = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.IsTrue(map.Buildings.Length >= 22, sprintf "P28 512 must place >=22 houses, saw %d" map.Buildings.Length) + let widths = map.Buildings |> List.map (fun b -> b.Width) |> Set.ofList + Assert.IsTrue(widths.Contains 2, "outbuilding width 2 must appear") + Assert.IsTrue(widths.Contains 3, "house width 3 must appear") + Assert.IsTrue(widths.Contains 4, "hall width 4 must appear") + Assert.AreEqual<int>(24, map.Buildings.Length) + [<TestMethod>] member _.DefaultSizeHouseCountUnchanged () = Assert.AreEqual<int>(4, (MapGen.generateWithSize 64 48 (uint64 4242)).Buildings.Length) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index bc3425a..7f67280 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1171,6 +1171,59 @@ type PrototypeTests () = Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 32 x 32px slots") Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px") + /// P28:防复发。第二变体(P26)在 512 整图 ~2px/tile 下不可辨,根因是挑檐与第一变体同为 + /// 近黑黛瓦。此回归直接解码 world 图集,断言变体 B 的挑檐(slot 28)平均亮度显著高于 + /// 变体 A 的屋脊(slot 8),保证「配色差异」不会在后续编辑中被悄悄抹平。 + [<TestMethod>] + member _.VariantBRoofPaletteIsClearlyLighterThanVariantA () = + let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") + let bytes = File.ReadAllBytes worldPath + let be32 offset = + (int bytes.[offset] <<< 24) + ||| (int bytes.[offset + 1] <<< 16) + ||| (int bytes.[offset + 2] <<< 8) + ||| int bytes.[offset + 3] + Assert.AreEqual<int>(0x89504E47, be32 0) + let width = be32 16 + let height = be32 20 + Assert.AreEqual<int>(VillageArt.worldAtlasWidth, width) + Assert.AreEqual<int>(VillageArt.worldAtlasHeight, height) + // Collect IDAT and inflate; the baked atlas is 8-bit RGBA, non-interlaced, filter 0. + let mutable offset = 8 + use idat = new MemoryStream() + while offset < bytes.Length do + let length = be32 offset + let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4) + if tag = "IDAT" then idat.Write(bytes, offset + 8, length) + offset <- offset + 12 + length + idat.Position <- 0L + use inflater = new System.IO.Compression.ZLibStream(idat, System.IO.Compression.CompressionMode.Decompress) + use raw = new MemoryStream() + inflater.CopyTo raw + let data = raw.ToArray() + let stride = width * 4 + 1 + let pixel (x: int) (y: int) = + let rowStart = y * stride + Assert.AreEqual<byte>(0uy, data.[rowStart]) + let p = rowStart + 1 + x * 4 + (int data.[p], int data.[p + 1], int data.[p + 2], int data.[p + 3]) + let slotMeanBrightness (slot: int) = + let mutable sum = 0.0 + let mutable count = 0 + for y in 0 .. height - 1 do + for x in slot * 32 .. slot * 32 + 31 do + let (r, g, b, a) = pixel x y + if a > 0 then + sum <- sum + float (r + g + b) / 3.0 + count <- count + 1 + Assert.IsTrue(count > 0, sprintf "slot %d must have opaque pixels" slot) + sum / float count + let roofA = slotMeanBrightness 8 // RoofRidgeSprite: variant A near-black 黛瓦 + let roofB = slotMeanBrightness 28 // RoofEaveOverhangSprite: variant B light blue-grey 瓦 + Assert.IsTrue( + roofB > roofA + 15.0, + sprintf "variant B roof must be clearly lighter than variant A (A=%.1f B=%.1f)" roofA roofB) + [<TestMethod>] member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () = // 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一, diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png Binary files differindex 11145a8..b67dd01 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 591d1b9..38d9e94 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -296,6 +296,18 @@ type LivingVillageGame() as this = match Environment.GetEnvironmentVariable("LV_MAPGEN_SEED") with | null | "" -> 4242UL | v -> match UInt64.TryParse v with (true, value) -> value | _ -> 4242UL + // P28 证据钩子:额外把整图按指定像素目标渲染一次(用于 1 tile≈2px 的缩放可读性取证)。 + let mapGenFitWidth = + match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_WIDTH") with + | null | "" -> 0 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 + let mapGenFitHeight = + match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_HEIGHT") with + | null | "" -> 0 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 + // P28 夜景取证:LV_MAPGEN_NIGHT=1 时整图按 23:30 的真实 tint + 月光 veil 渲染。 + let mapGenNight = Environment.GetEnvironmentVariable("LV_MAPGEN_NIGHT") = "1" + let mapGenNightTick = if mapGenNight then M6Presentation.resolveStartTick (Some 23.5) false else 0L let mutable mapGenResult = Unchecked.defaultof<MapGen.Result> let mutable mapGenGenMs = 0.0 @@ -862,16 +874,28 @@ type LivingVillageGame() as this = this.Exit() /// P20 证据:把一次 MapGen 绘制渲染到指定尺寸离屏 target,返回绘制瓦片数与纹理。 - member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) : int * Texture2D = + /// P28:可传入世界 tint 与可选 veil tick(夜景取证用)。 + member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) (tint: Color) (veilTick: int64 option) : int * Texture2D = use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight) this.GraphicsDevice.SetRenderTarget target this.GraphicsDevice.Clear(Color(18, 26, 20)) spriteBatch.Begin() let drawn = if drawFitted then - VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult Color.White + VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult tint else - VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L Color.White + VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L tint + // 与 DrawWorldView 同一套纯函数 veil,保证取证夜景=真实渲染夜景。 + match veilTick with + | Some tick -> + let golden = M6Presentation.goldenVeilAlpha tick + if golden > 0 then + spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply 255 150 70 golden) + let moon = M6Presentation.moonlightVeilAlpha tick + if moon > 0 then + let mr, mg, mb = M6Presentation.moonlightRgb + spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply mr mg mb moon) + | None -> () spriteBatch.End() this.GraphicsDevice.SetRenderTarget null let data = Array.zeroCreate<Color> (targetWidth * targetHeight) @@ -885,16 +909,27 @@ type LivingVillageGame() as this = System.IO.Directory.CreateDirectory recordDirectory |> ignore let viewport = this.GraphicsDevice.Viewport let mapCell = 6 - let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true + let mapTint = if mapGenNight then (M6Presentation.profileAtTick mapGenNightTick).WorldTint else Color.White + let mapVeil = if mapGenNight then Some mapGenNightTick else None + let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true mapTint mapVeil use _fitted = fittedTexture use stream = System.IO.File.Create(sprintf "%s/p20b-map-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) fittedTexture.SaveAsPng(stream, fittedTexture.Width, fittedTexture.Height) - printfn "mapgen-fitted drawn=%d png=%dx%d" fittedDrawn fittedTexture.Width fittedTexture.Height + printfn "mapgen-fitted drawn=%d png=%dx%d night=%b" fittedDrawn fittedTexture.Width fittedTexture.Height mapGenNight + + // P28:整图按指定像素目标再渲染一次(真实帧)。1024x768 -> 512x384 图恰为 2px/tile, + // 用于证明两民居变体在「整图 1 tile≈2px」缩放下肉眼可分。 + if mapGenFitWidth > 0 && mapGenFitHeight > 0 then + let fitDrawn, fitTexture = this.RenderMapGen mapGenFitWidth mapGenFitHeight 0 0 true mapTint mapVeil + use _fit = fitTexture + use fitStream = System.IO.File.Create(sprintf "%s/p28-fit-%dx%d-seed%d.png" recordDirectory mapGenFitWidth mapGenFitHeight (int mapGenResult.Seed)) + fitTexture.SaveAsPng(fitStream, mapGenFitWidth, mapGenFitHeight) + printfn "mapgen-fit drawn=%d png=%dx%d night=%b" fitDrawn mapGenFitWidth mapGenFitHeight mapGenNight let clampRange maximum value = max 0 (min (max 0 maximum) value) let camX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) ((mapGenResult.Width * Sim.tilePixels - viewport.Width) / 2) let camY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) ((mapGenResult.Height * Sim.tilePixels - viewport.Height) / 2) - let culledDrawn, culledTexture = this.RenderMapGen viewport.Width viewport.Height camX camY false + let culledDrawn, culledTexture = this.RenderMapGen viewport.Width viewport.Height camX camY false Color.White None use _culled = culledTexture use culledStream = System.IO.File.Create(sprintf "%s/p20b-culled-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) culledTexture.SaveAsPng(culledStream, viewport.Width, viewport.Height) @@ -906,7 +941,7 @@ type LivingVillageGame() as this = | (bx, by) :: _ -> let detailCamX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) (bx * Sim.tilePixels - viewport.Width / 2) let detailCamY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) (by * Sim.tilePixels - viewport.Height / 2) - let detailDrawn, detailTexture = this.RenderMapGen viewport.Width viewport.Height detailCamX detailCamY false + let detailDrawn, detailTexture = this.RenderMapGen viewport.Width viewport.Height detailCamX detailCamY false Color.White None use _detail = detailTexture use detailStream = System.IO.File.Create(sprintf "%s/p20b-detail-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) detailTexture.SaveAsPng(detailStream, detailTexture.Width, detailTexture.Height) diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs index ce9fb3a..3705635 100644 --- a/src/LivingVillage.Desktop/MapGen.fs +++ b/src/LivingVillage.Desktop/MapGen.fs @@ -97,6 +97,7 @@ module MapGen = /// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。 /// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。 /// P24:仅对大于 256x192 的图追加沿河民居(泊松散布),保证既有 checksum 不变。 + /// P28:大图沿河民居由 10 增至 16(+6 座,宽度 2/3/4 变化),默认图仍为 0、不消耗 RNG。 let paramsForSize (width: int) (height: int) (seed: uint64) : Params = let base_ = defaultParams seed let large = width > 256 || height > 192 @@ -104,7 +105,7 @@ module MapGen = Width = width Height = height RiverCount = max base_.RiverCount (max 1 (height / 96)) - ExtraRiversideHouses = (if large then 10 else 0) + ExtraRiversideHouses = (if large then 16 else 0) DecorativeStones = not large RiverDecorations = large } @@ -307,18 +308,20 @@ module MapGen = // 5b) P24:大图沿河民居加密。从既有路网瓦片出发,向四邻定向落位 3x2 白墙黛瓦民居, // 门开向相邻路格并接入路网(BFS 可达);仅在 ExtraRiversideHouses>0 时执行, // 且只在核心区外的草地落位,故 64x48/256x192 输出逐字节不变。 + // P28:宽度按 2/3/4 加权变化(2=附属小筑、4=大宅),形成聚落层次而非等距重复; + // 数量 10 -> 16(沿河再增 6 座)。仍只在大图分支执行,默认图 RNG 不消耗,checksum 不变。 if p.ExtraRiversideHouses > 0 then - let footprintFree (left: int) (top: int) = - left >= 1 && left + 2 <= w - 2 && top >= 1 && top + 1 <= h - 2 - && [ for dx in 0 .. 2 do + let footprintFree (left: int) (top: int) (width: int) = + left >= 1 && left + width - 1 <= w - 2 && top >= 1 && top + 1 <= h - 2 + && [ for dx in 0 .. width - 1 do for dy in 0 .. 1 -> (left + dx, top + dy) ] |> List.forall (fun (x, y) -> tiles.[idx x y] = int GroundTile.Grass && not (inCore x y)) - let overlaps (left: int) (top: int) = + let overlaps (left: int) (top: int) (width: int) = buildings |> List.exists (fun b -> - left < b.Left + b.Width && b.Left < left + 3 + left < b.Left + b.Width && b.Left < left + width && top < b.Top + b.Height && b.Top < top + 2) let directions = [ (0, -1); (0, 1); (-1, 0); (1, 0) ] let mutable extraPlaced = 0 @@ -331,15 +334,17 @@ module MapGen = if paths.Length > 0 then let pathX, pathY = List.item (int (raw % uint64 paths.Length)) paths let dirX, dirY = directions.[int ((raw >>> 32) % 4UL)] + let widthRoll = int ((raw >>> 40) % 10UL) + let buildingWidth = if widthRoll < 5 then 3 elif widthRoll < 8 then 2 else 4 let doorX = pathX + dirX let doorY = pathY + dirY - let left = doorX - 1 + let left = doorX - buildingWidth / 2 let top = doorY - 1 - if footprintFree left top && not (overlaps left top) then + if footprintFree left top buildingWidth && not (overlaps left top buildingWidth) then let building = { Left = left Top = top - Width = 3 + Width = buildingWidth Height = 2 DoorX = doorX DoorY = doorY } diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index d2fcc1a..9a56de4 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -612,7 +612,8 @@ module VillageArt = spriteBatch.Draw(pixel, XnaRectangle(x, y, max 1 w, max 1 h), color) /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。 - /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 挑檐挑出 + 木格栅窗 + 院门。 + /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 亮青灰挑檐(多叠一层、向两侧各出一格)+ + /// 竖长木格栅窗 + 绿院门(P28 强化配色与轮廓,保证 512 整图 ~2px/tile 下两变体可分)。 /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。 let houseFacadeVariant (building: MapGen.Building) : int = let remainder = (building.Left + building.Top) % 2 @@ -649,14 +650,22 @@ module VillageArt = else WallWoodSprite drawCell wallSprite x building.DoorY | _ -> - // 变体 B(P26):挑檐向两侧各出一格 + 木格栅窗 + 院门。 - for x in (left - 1) .. (right + 1) do - drawCell RoofEaveOverhangSprite x building.Top + // 变体 B(P26/P28):亮青灰挑檐向两侧各出一格 + 竖长木格栅窗 + 绿院门。 + // P28 再向上叠一层挑檐,使 B 的轮廓比 A 高一格;水面之上不叠,避免屋顶压水。 + let isWater x y = + x >= 0 && x < map.Width && y >= 0 && y < map.Height + && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water + for row in building.Top .. building.DoorY - 1 do + for x in (left - 1) .. (right + 1) do + drawCell RoofEaveOverhangSprite x row + let upper = building.Top - 1 + if upper >= 0 then + for x in (left - 1) .. (right + 1) do + if not (isWater x upper) then + drawCell RoofEaveOverhangSprite x upper for x in left .. right do let wallSprite = - if x = building.DoorX then CourtyardDoorSprite - elif (x - left) % 2 = 0 then WallLatticeSprite - else WallWoodSprite + if x = building.DoorX then CourtyardDoorSprite else WallLatticeSprite drawCell wallSprite x building.DoorY // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。 for decoration in map.Decorations do |
