summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 11:52:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 11:52:35 +0800
commitb2a67011ca7eef02c685794d887804e7e50dadf8 (patch)
treefe9b3d02391cd0baf991c144cab4b589b942f7a1
parent38496525403cefcbfefedcf8377100e662d9e6d4 (diff)
parentecb96a9626b3c5583a995f78b99d6db43ab2dd5b (diff)
downloadliving-village-b2a67011ca7eef02c685794d887804e7e50dadf8.tar.gz
merge: P28 民居第二变体强化 + 512 沿河加密(青灰挑檐B/竖长格栅窗/绿院门,2/3/4 宽分层 16 座外环)— Hermes 复验通过
-rw-r--r--.gitignore1
-rw-r--r--docs/维护说明.md66
-rw-r--r--scripts/make-jiangnan-art.py70
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs15
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs53
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin4468 -> 4492 bytes
-rw-r--r--src/LivingVillage.Desktop/Game.fs49
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs23
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs23
9 files changed, 247 insertions, 53 deletions
diff --git a/.gitignore b/.gitignore
index 3feeb98..fb5a05d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
index 11145a8..b67dd01 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
Binary files differ
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