summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/维护说明.md58
-rw-r--r--scripts/make-jiangnan-art.py77
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs24
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs47
-rw-r--r--src/LivingVillage.Desktop.Tests/P16UiTests.fs21
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs48
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin3847 -> 4468 bytes
-rw-r--r--src/LivingVillage.Desktop/Game.fs16
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs22
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs12
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs91
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs60
12 files changed, 449 insertions, 27 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 43b8fc4..cc3a698 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -421,6 +421,44 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
`p25-zoom-village.png` 放大含 >=8 村民、`p25-tour-0{1,2,3,4}.png` tour 页、`tour/` 全量帧),
白块回归 241 张 0 命中。
+## 江南水乡第 4 档:512×384 美术可读性与质感(P26)
+
+- **① 民居立面第二变体**:世界图集由 28 格扩到 **32 格**(尺寸 896x32→1024x32),新增
+ slot 28 屋檐挑出 / 29 木格栅窗 / 30 院门;`scripts/make-jiangnan-art.py` 仍为确定性管线,
+ 二次运行 byte-identical。`VillageArt.houseFacadeVariant (building) = (Left+Top) % 2` 为纯函数,
+ 沿河沿路确定性交替:变体 A 保持 P24 的黛瓦脊 + 木墙/窗 + 门;变体 B 用 `RoofEaveOverhangSprite`
+ 向两侧各挑出一格,墙身用 `WallLatticeSprite`/`CourtyardDoorSprite`。只改绘制分支,
+ 门可达不变量与 `MapGen` 瓦片零改动(64x48=6042052、256x192=1284656962 不变)。
+- **② 河岸细节(仅 512)**:`MapGen.Params.RiverDecorations`(默认 false;`paramsForSize` 仅对
+ `width>256 || height>192` 置 true)。`MapGen.Result.Decorations: RiverDecoration list` 三类——
+ 护岸石垒 `RevetmentStone` / 芦苇 `Reeds` / 垂柳 `Willow`(占上下两格)。**纯视觉层,不写入任何
+ 瓦片**,故沿用 `DecorativeStones` 参数化先例的同时不产孤立石板;装饰只落在紧邻水面的岸格,
+ 并避开路网/桥面/民居/核心区。回归:`RiverDecorationsOnlyEnabledOnLargeMaps`、
+ `RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges`(岸格相邻水 + 不压路/桥/核心)、
+ `LargeMapRiverDecorationsAreDeterministic`。
+- **③ 夜晚光照二次整改(仅 Desktop 渲染层)**:新增纯函数 `M6Presentation.moonlightVeilAlpha`
+ (0..72,仅 deep night 有值,暖色晨昏窗口被 `warmth` 压制)与 `moonlightRgb = (120,170,235)`;
+ `Game.DrawWorldView` 在金辉 veil 之后叠加预乘冷色整屏 veil,`premultiply` 保证 alpha<250 不可能
+ 饱和成白块。灯笼光晕复核回归 `LanternGlowIsRadialWithoutSquareCorners`:逐像素累积同心圆 alpha,
+ 四角恒为 0、中心行沿半径单调递减,确认无残留方斑(图像级 145+142 帧白块回归 0 命中)。
+- **④ HUD 微调**:`HudLayout.textOutlineOffsets`(上下左右 4 偏移)、`HudLayout.segmentBackdrop`
+ 与 `Palette.SegmentBackdrop/Outline`;`DrawHudBar` 每个常驻片段(时段/时间/倍速/体力/钱币)
+ 先铺半透明深色底药丸,再以 1px 深色描边文字,昼/夜两套调色板均不透明描边。回归
+ `HudPersistentSegmentsGetBackdropAndOutlineInBothPeriods`。
+- **回归**:Desktop **136 → 143**,Kernel 92 不变(未触 Kernel/Sim 数值路径,未动 M3)。
+- **门槛**:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
+ 3 次一致、`performance_determinism=PASS`;默认世界仍 256×192。
+- **取证** `/tmp/opencode/lv-p26-art/`(沙箱把规格路径 `/tmp/lv-p26-art` 映射到此,不放 git):
+ `p26-map-512x384-seed4242.png`(512 整图真实渲染 3072x2304)、`p26-zoom-480x360-2x.png`
+ (村庄局部 480×360 裁切 → 2× 最近邻放大,含 30/30 可辨村民)、`p26-day-village.png`、
+ `p26-night-village.png`(夜景:冷月光 + 桥头灯笼 + HUD)、`p26-hud-day.png`/`p26-hud-night.png`
+ (HUD 双时段裁切 2×)、`villager-visibility.txt`(autorun 巡游复跑可见村民统计)、
+ `white-block-day.txt`/`white-block-night.txt`(0 命中)。
+- **范围外遗留(另开一单,本单不做)**:① 512 生成耗时因河岸装饰的 O(n²) 间距检查与民居加密从
+ ~80ms 升到 ~151ms(一次性、进入世界前,无逐帧开销),可改空间网格索引优化;② 变体 B 挑檐向两侧
+ 各出一格,相邻民居极近时挑檐可能视觉叠压,可在布点时加 1 格间隔;③ 巡游 40s 档的 `visible>=8`
+ 占比仅 13%(P25 150s 档为 74%),属取证时长差异,非可见性回归。
+
## 已验证命令
```bash
@@ -528,6 +566,26 @@ timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \
LV_RECORD_DIR=/tmp/opencode/lv-p25-art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+# P26 512 整图真实渲染(含两种民居立面 + 河岸装饰;产物 p20b-map-*.png,改名为 p26-map-*)
+timeout 150 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_RECORD_DIR=/tmp/opencode/lv-p26-art/map \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+
+# P26 昼夜村庄巡游录帧(村民可见性日志 + 夜景月光/灯笼/HUD 取证)
+for pair in day:12.0 night:23.5; do name=${pair%%:*}; hour=${pair##*:}; \
+ timeout 200 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
+ LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_VILLAGE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 \
+ LV_RIVERSCAPE_HEIGHT=384 LV_RIVERSCAPE_SECONDS=40 LV_AUTOPLAY_START_HOUR=$hour \
+ LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=30 \
+ LV_RECORD_NAME=p26-$name- LV_RECORD_DIR=/tmp/opencode/lv-p26-art/$name \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
+# 局部 480x360 锚定村民+民居质心裁切后 2x 最近邻放大 -> p26-zoom-480x360-2x.png
+# HUD 双时段:裁顶部 y 0..44 左 640px 再 2x -> p26-hud-day.png / p26-hud-night.png
+
+# P26 夜景白块回归(方斑核对,0 命中)
+python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p26-art/night
+
# 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 \
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index 9abb4b8..f537942 100644
--- a/scripts/make-jiangnan-art.py
+++ b/scripts/make-jiangnan-art.py
@@ -2,7 +2,7 @@
"""Deterministic pixel-art atlases for LivingVillage.Desktop.
Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets:
- jiangnan-world.png 800x32 (25 ground/prop tiles, 32px each)
+ jiangnan-world.png 1024x32 (32 ground/prop tiles, 32px each)
jiangnan-characters.png 3840x48 (5 variants x 4 directions x 6 frames, 32x48 cells)
jiangnan-interior.png 392x96 (variable-size bottom-aligned interior cells)
@@ -99,7 +99,7 @@ def verify_png(path, expected_w, expected_h):
def build_world_atlas():
- c = Canvas(896, 32)
+ c = Canvas(1024, 32)
tile = 32
def slot(index):
@@ -547,6 +547,77 @@ 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.
+ # Same baked-atlas pipeline as every other tile; the renderer only blits these cells.
+
+ # slot(28) 屋檐挑出 (projecting eave): tiled roof band over an exposed wooden lip.
+ x0 = slot(28)
+ c.rect(x0, 0, 32, 13, roof_dark)
+ for x in range(0, 32, 8):
+ c.rect(x0 + x, 0, 1, 13, roof)
+ 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)
+ 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))
+
+ # slot(29) 木格栅窗 (lattice window): wooden posts + fine grid muntins.
+ x0 = slot(29)
+ 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)
+ for post_x in (0, 29):
+ 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 + 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.
+ 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 + 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 + 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))
+
+ # slot(31) 护岸石垒 (revetment): edge-laid cobbles shoring up the riverbank.
+ x0 = slot(31)
+ c.rect(x0 + 1, 10, 30, 20, (96, 96, 92, 255))
+ revetment = [(2, 11, 8, 5), (11, 10, 9, 6), (21, 11, 8, 5),
+ (4, 17, 9, 6), (14, 17, 9, 6), (24, 18, 7, 5),
+ (2, 24, 8, 5), (11, 24, 9, 5), (21, 24, 8, 5)]
+ for sx, sy, sw, sh in revetment:
+ c.rect(x0 + sx, sy, sw, sh, stone)
+ c.rect(x0 + sx, sy, sw, 1, stone_light)
+ c.rect(x0 + sx, sy + sh - 1, sw, 1, stone_dark)
+ c.rect(x0 + 1, 29, 30, 2, (96, 96, 92, 255))
+
return c
@@ -884,7 +955,7 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 896, 32),
+ ("jiangnan-world.png", build_world_atlas(), 1024, 32),
("jiangnan-characters.png", build_character_atlas(), 3840, 48),
("jiangnan-interior.png", build_interior_atlas(), 392, 96),
]
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
index b78f4e0..7e4a849 100644
--- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs
+++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
@@ -160,6 +160,30 @@ type DesktopTests () =
Assert.IsTrue(noon.Background.B > noon.Background.R)
[<TestMethod>]
+ member _.MoonlightVeilIsCoolAndOnlyAtNight () =
+ let noonTick = 12L * Sim.ticksPerDay / 24L
+ let midnightTick = 0L
+ let deepNightTick = 23L * Sim.ticksPerDay / 24L
+ Assert.IsTrue(M6Presentation.moonlightVeilAlpha noonTick = 0, "daytime must have no moonlight")
+
+ let moon = M6Presentation.moonlightVeilAlpha midnightTick
+ Assert.IsTrue(moon > 0, "deep night must get a cool moonlight wash")
+ Assert.AreEqual<int>(moon, M6Presentation.moonlightVeilAlpha deepNightTick)
+ Assert.IsTrue(moon <= 72, "moonlight veil stays a low alpha so the scene is not washed out")
+
+ let r, g, b = M6Presentation.moonlightRgb
+ Assert.IsTrue(b > g && g > r, "moonlight must be cool: blue-dominant channels")
+
+ // Dusk is owned by the warm golden veil, so moonlight is suppressed there.
+ let duskTick = M6Presentation.startTickForHour 20.75
+ Assert.IsTrue(M6Presentation.moonlightVeilAlpha duskTick < moon, "dusk is warmed, not moonlit")
+
+ // Premultiplied premultiply of this colour never saturates to white.
+ let veiled = VillageArt.premultiply r g b moon
+ Assert.IsTrue(int veiled.B <= int veiled.A)
+ Assert.IsTrue(int (max veiled.R (max veiled.G veiled.B)) < 250)
+
+ [<TestMethod>]
member _.DaylightAutoplayBootstrapStartsAtDawnAndPreservesDefaultStart () =
let dawnTick = 6L * Sim.ticksPerDay / 24L
let daylightWorld = WorldBootstrap.initialWorldForDaylight true 42UL Sim.npcCount
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
index f426a8a..6486dd7 100644
--- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -298,6 +298,53 @@ type MapGenTests () =
Assert.AreEqual<int64>(6042052L, checksum (MapGen.generateWithSize 64 48 (uint64 4242)))
Assert.AreEqual<int64>(1284656962L, checksum (MapGen.generateWithSize 256 192 (uint64 4242)))
+ // ---- P26:河岸装饰(护岸石垒/芦苇/垂柳)仅大图、落在岸格且不挡通行 ----
+
+ [<TestMethod>]
+ member _.RiverDecorationsOnlyEnabledOnLargeMaps () =
+ Assert.IsFalse((MapGen.paramsForSize 64 48 (uint64 1)).RiverDecorations)
+ Assert.IsFalse((MapGen.paramsForSize 256 192 (uint64 1)).RiverDecorations)
+ Assert.IsTrue((MapGen.paramsForSize 512 384 (uint64 1)).RiverDecorations)
+ Assert.AreEqual<int>(0, (MapGen.generateWithSize 64 48 (uint64 4242)).Decorations.Length)
+ Assert.AreEqual<int>(0, (MapGen.generateWithSize 256 192 (uint64 4242)).Decorations.Length)
+
+ [<TestMethod>]
+ member _.RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(map.Decorations.Length > 0, "512x384 must place riverbank decorations")
+ let pathSet = System.Collections.Generic.HashSet<int>(map.Paths |> List.map (fun (x, y) -> y * map.Width + x))
+ let bridgeSet = System.Collections.Generic.HashSet<int>(map.Bridges |> List.map (fun (x, y) -> y * map.Width + x))
+ let water x y = map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water
+ let inCore x y = x >= map.Core.MinX && x <= map.Core.MaxX && y >= map.Core.MinY && y <= map.Core.MaxY
+ for decoration in map.Decorations do
+ let footprint =
+ match decoration.Kind with
+ | MapGen.Willow -> [ decoration.Y; decoration.Y + 1 ]
+ | _ -> [ decoration.Y ]
+ for y in footprint do
+ let i = y * map.Width + decoration.X
+ Assert.AreNotEqual<int>(int MapGen.GroundTile.Water, map.Tiles.[i], "decoration must sit on land")
+ Assert.IsFalse(pathSet.Contains i, "decoration must not block a path tile")
+ Assert.IsFalse(bridgeSet.Contains i, "decoration must not block a bridge tile")
+ Assert.IsFalse(inCore decoration.X y, "decoration must stay outside the core")
+ // Bank adjacency: at least one footprint tile must touch the river orthogonally.
+ let touchesWater =
+ footprint
+ |> List.exists (fun y ->
+ [ (decoration.X + 1, y); (decoration.X - 1, y); (decoration.X, y + 1); (decoration.X, y - 1) ]
+ |> List.exists (fun (nx, ny) ->
+ nx >= 0 && nx < map.Width && ny >= 0 && ny < map.Height && water nx ny))
+ Assert.IsTrue(touchesWater, "decoration must line the riverbank, not float inland")
+
+ [<TestMethod>]
+ member _.LargeMapRiverDecorationsAreDeterministic () =
+ let a = MapGen.generateWithSize 512 384 (uint64 4242)
+ let b = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(a.Decorations = b.Decorations)
+ Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b)
+ let c = MapGen.generateWithSize 512 384 (uint64 7)
+ Assert.AreNotEqual<string>(MapGen.serialize a, MapGen.serialize c)
+
[<TestMethod>]
member _.VisibleRangeClampsAtLegacyMapEdges () =
let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536
diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
index 2e474ac..b68bbd3 100644
--- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs
+++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
@@ -128,6 +128,27 @@ type P16HudTests () =
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "体力")
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "钱币")
+ [<TestMethod>]
+ member _.HudPersistentSegmentsGetBackdropAndOutlineInBothPeriods () =
+ // P26:白天/夜景两套调色板都要有半透明底色 + 不透明深色描边,保证常驻元素可读。
+ for mode in [ Day; Night ] do
+ let palette = HudLayout.palette mode
+ Assert.IsTrue(palette.SegmentBackdrop.A > 0uy, "segment backdrop must be translucent, not opaque")
+ Assert.IsTrue(palette.SegmentBackdrop.A < 255uy, "segment backdrop must keep the scene visible")
+ Assert.AreEqual<byte>(255uy, palette.Outline.A)
+ Assert.IsTrue(palette.Outline.R < 40uy && palette.Outline.G < 40uy && palette.Outline.B < 40uy)
+ let outlineSum = int palette.Outline.R + int palette.Outline.G + int palette.Outline.B
+ let valueSum = int palette.Value.R + int palette.Value.G + int palette.Value.B
+ Assert.IsTrue(outlineSum < valueSum, "outline must contrast with the bright value text")
+ Assert.AreEqual<int>(4, HudLayout.textOutlineOffsets.Length)
+ for (dx, dy) in HudLayout.textOutlineOffsets do
+ Assert.IsTrue(dx <> 0 || dy <> 0, "outline offset must be non-zero")
+ let placed = HudLayout.layout (HudLayout.segments world SimulationControl.initial)
+ for (x, width, _) in placed do
+ let rect = HudLayout.segmentBackdrop x width
+ Assert.IsTrue(rect.X <= x && rect.X + rect.Width >= x + width, "backdrop must cover the segment text")
+ Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= HudLayout.barHeight, "backdrop must stay inside the bar")
+
// ---- P16-FIX 菜单截图帧表 ----
[<TestClass>]
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index e58139c..bc3425a 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -641,6 +641,28 @@ type PrototypeTests () =
Assert.IsFalse(covers (centerX - radius - 5) centerY)
[<TestMethod>]
+ member _.LanternGlowIsRadialWithoutSquareCorners () =
+ // P26 复核:光晕为同心圆叠加,逐像素累积 alpha;边缘/四角不得出现方斑。
+ let centerX, centerY, radius, rings, peakAlpha = 80, 80, 40, 6, 140
+ let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha
+ let size = radius * 2 + 3
+ let origin = centerX - radius - 1
+ let alpha = Array2D.zeroCreate<int> size size
+ for (rect, color) in layers do
+ for y in rect.Y .. rect.Y + rect.Height - 1 do
+ for x in rect.X .. rect.X + rect.Width - 1 do
+ alpha.[y - origin, x - origin] <- alpha.[y - origin, x - origin] + int color.A
+ let center = alpha.[radius + 1, radius + 1]
+ Assert.IsTrue(center > 0, "the core must be lit")
+ let corner = [ (0, 0); (size - 1, 0); (0, size - 1); (size - 1, size - 1) ]
+ for (cx, cy) in corner do
+ Assert.IsTrue(alpha.[cy, cx] = 0, "glow corner must stay empty: no square patch")
+ // Radial falloff along the centre row: alpha never rises as distance from the core grows.
+ let row = [ for dx in 0 .. radius -> alpha.[radius + 1, radius + 1 + dx] ]
+ Assert.IsTrue(row |> List.pairwise |> List.forall (fun (a, b) -> a >= b), "glow must fade radially, not form a flat block")
+ Assert.IsTrue(row.[0] = center && row.[radius] < center, "core is brightest, rim is dimmer")
+
+ [<TestMethod>]
member _.CharacterFeetSitOnTheTileGroundLine () =
Assert.AreEqual<int>(16, VillageArt.characterFeetOffset)
let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f))
@@ -1104,6 +1126,23 @@ type PrototypeTests () =
// 953775FA 硬对齐由 --performance-baseline 于 finalWorld 支线证据。
[<TestMethod>]
+ member _.HouseFacadeVariantsAlternateAlongTheVillage () =
+ // P26:512 大图上两种民居立面都要出现,且按位置确定性交替(纯函数)。
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let variants = map.Buildings |> List.map VillageArt.houseFacadeVariant
+ Assert.IsTrue(Set.ofList variants |> Set.contains 0, "variant A must appear")
+ Assert.IsTrue(Set.ofList variants |> Set.contains 1, "variant B must appear")
+ let repeated = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.AreEqual<(int list)>(variants, repeated.Buildings |> List.map VillageArt.houseFacadeVariant)
+ // Neighbouring buildings along the same river bank must flip variant, i.e. not one solid run.
+ let flipped =
+ variants
+ |> List.pairwise
+ |> List.filter (fun (a, b) -> a <> b)
+ |> List.length
+ Assert.IsTrue(flipped > 0, "facades must alternate, not stay one variant")
+
+ [<TestMethod>]
member _.JiangnanArtAssetsArePackagedAsRealPngFiles () =
let requiredAssets =
[ "Assets/jiangnan-world.png"
@@ -1116,9 +1155,10 @@ type PrototypeTests () =
Assert.AreEqual<string list>([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing))
- // The baked world atlas must match the 28-slot layout the renderer indexes into
- // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27).
- Assert.AreEqual<int>(28, VillageArt.worldTileCount)
+ // The baked world atlas must match the 32-slot layout the renderer indexes into
+ // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27; P26 added the variant-B house
+ // parts 屋檐挑出/木格栅窗/院门 as slots 28..30 and 护岸石垒 as slot 31).
+ Assert.AreEqual<int>(32, VillageArt.worldTileCount)
let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png")
let header = File.ReadAllBytes worldPath
let be32 offset =
@@ -1128,7 +1168,7 @@ type PrototypeTests () =
||| int header.[offset + 3]
let atlasWidth : int = be32 16
let atlasHeight : int = be32 20
- Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 28 x 32px slots")
+ 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")
[<TestMethod>]
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index 6e97728..11145a8 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 c6da443..591d1b9 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -1186,6 +1186,14 @@ type LivingVillageGame() as this =
let veilAlpha = M6Presentation.goldenVeilAlpha world.Tick
if veilAlpha > 0 then
spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply 255 150 70 veilAlpha)
+
+ // Cool moonlight wash on top of the night scene: a premultiplied blue veil whose
+ // alpha is a pure tick function (zero by day, suppressed through dusk/dawn warmth).
+ // Like the golden veil it adds light and can never saturate to white.
+ let moonAlpha = M6Presentation.moonlightVeilAlpha world.Tick
+ if moonAlpha > 0 then
+ let mr, mg, mb = M6Presentation.moonlightRgb
+ spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply mr mg mb moonAlpha)
spriteBatch.End()
// Contextual floating prompt over the unique target; same CJK atlas font path as the HUD.
@@ -1222,9 +1230,13 @@ type LivingVillageGame() as this =
spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop)
spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom)
spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent)
- for (x, _, segment) in placed do
- PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label
+ for (x, width, segment) in placed do
+ spriteBatch.Draw(pixel, HudLayout.segmentBackdrop x width, palette.SegmentBackdrop)
let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale
+ for (dx, dy) in HudLayout.textOutlineOffsets do
+ PixelText.draw spriteBatch pixel cjkAtlas (x + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Label
+ PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Value
+ PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label
PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value
spriteBatch.End()
diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs
index d943cec..f3e4c12 100644
--- a/src/LivingVillage.Desktop/HudLayout.fs
+++ b/src/LivingVillage.Desktop/HudLayout.fs
@@ -44,14 +44,26 @@ module HudLayout =
(paddingX, [])
List.rev placed
+ /// P26:常驻片段文本的 1px 描边偏移(上/下/左/右),保证白天/夜景下都可读。
+ let textOutlineOffsets : (int * int) list = [ (-1, 0); (1, 0); (0, -1); (0, 1) ]
+
+ /// P26:常驻片段的半透明底色药丸,贴合文字高度并在左右各留 6px;纯函数便于回归。
+ let segmentBackdrop (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle =
+ let height = 8 * textScale + 10
+ let y = (barHeight - height) / 2
+ Microsoft.Xna.Framework.Rectangle(x - 6, y, width + 12, height)
+
type Palette =
{ BarTop: Color
BarBottom: Color
Accent: Color
Label: Color
- Value: Color }
+ Value: Color
+ SegmentBackdrop: Color
+ Outline: Color }
/// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。
+ /// 每个常驻片段再叠一层半透明底色 + 1px 深色描边,双时段都能读清。
let palette (mode: M6DayNight) : Palette =
match mode with
| Day ->
@@ -59,10 +71,14 @@ module HudLayout =
BarBottom = Color(22, 34, 40, 200)
Accent = Color(212, 168, 96)
Label = Color(206, 220, 208)
- Value = Color(242, 248, 234) }
+ Value = Color(242, 248, 234)
+ SegmentBackdrop = Color(8, 14, 20, 150)
+ Outline = Color(6, 10, 14, 255) }
| Night ->
{ BarTop = Color(22, 30, 48, 205)
BarBottom = Color(13, 19, 33, 205)
Accent = Color(120, 168, 196)
Label = Color(176, 196, 214)
- Value = Color(228, 238, 246) }
+ Value = Color(228, 238, 246)
+ SegmentBackdrop = Color(4, 8, 16, 160)
+ Outline = Color(3, 6, 12, 255) }
diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs
index 90d2e65..825847a 100644
--- a/src/LivingVillage.Desktop/M6Presentation.fs
+++ b/src/LivingVillage.Desktop/M6Presentation.fs
@@ -65,6 +65,18 @@ module M6Presentation =
let goldenVeilAlpha (tick: int64) : int =
int (150.0f * transitionWarmth tick)
+ /// Cool moonlight wash applied as a premultiplied veil over the finished night scene.
+ /// Multiplicative night tint can only darken, so this additive cool layer is what makes deep
+ /// night read moon-blue. It is zero whenever the sun lights the scene (blend 0) and is
+ /// suppressed through the warm dawn/dusk windows (warmth), so it never competes with the
+ /// golden veil. Purely desktop render state; the Kernel never sees it.
+ let moonlightRgb : int * int * int = (120, 170, 235)
+
+ let moonlightVeilAlpha (tick: int64) : int =
+ let blend = lightingBlend tick
+ let warmth = transitionWarmth tick
+ int (72.0f * blend * (1.0f - warmth))
+
let private lerp (a: int) (b: int) (blend: float32) : int =
a + int (float32 (b - a) * blend)
diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
index 63b14bc..ce9fb3a 100644
--- a/src/LivingVillage.Desktop/MapGen.fs
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -34,7 +34,9 @@ module MapGen =
/// P24:大图额外沿河民居数量(沿现有路网泊松散布)。默认 0,64x48/256x192 不受影响。
ExtraRiversideHouses: int
/// P25:是否放置孤立装饰石块。默认 true;大图关闭,避免草地上出现游离石板 tile。
- DecorativeStones: bool }
+ DecorativeStones: bool
+ /// P26:是否沿海河岸放置河岸装饰(护岸石垒/芦苇/垂柳)。默认 false;仅大图开启。
+ RiverDecorations: bool }
/// P20 第二步:一条贯穿全图的东西向河道(每列恰好一段,宽度下限 RiverWidth)。
type River =
@@ -50,6 +52,18 @@ module MapGen =
DoorX: int
DoorY: int }
+ /// P26 河岸装饰种类:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格)。
+ type RiverDecorationKind =
+ | RevetmentStone
+ | Reeds
+ | Willow
+
+ /// P26 河岸装饰:纯视觉,不写入瓦片、不影响通行;仅大图沿海河岸生成。
+ type RiverDecoration =
+ { Kind: RiverDecorationKind
+ X: int
+ Y: int }
+
type Result =
{ Width: int
Height: int
@@ -61,6 +75,7 @@ module MapGen =
Bridges: (int * int) list
Paths: (int * int) list
Buildings: Building list
+ Decorations: RiverDecoration list
ReachableTiles: int
ReachabilityOk: bool
BridgeCrossingsOk: bool }
@@ -76,7 +91,8 @@ module MapGen =
SpawnColumns = 5
SpawnRows = 6
ExtraRiversideHouses = 0
- DecorativeStones = true }
+ DecorativeStones = true
+ RiverDecorations = false }
/// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。
/// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。
@@ -89,7 +105,8 @@ module MapGen =
Height = height
RiverCount = max base_.RiverCount (max 1 (height / 96))
ExtraRiversideHouses = (if large then 10 else 0)
- DecorativeStones = not large }
+ DecorativeStones = not large
+ RiverDecorations = large }
// ---- splitmix64 与 value noise(无外部依赖、无时钟) ----
@@ -330,6 +347,63 @@ module MapGen =
addPath building.DoorX building.DoorY
extraPlaced <- extraPlaced + 1
+ // 5c) P26:河岸装饰(护岸石垒/芦苇/垂柳),仅大图启用。纯视觉层,不写入任何瓦片,
+ // 故 64x48/256x192 的 tiles checksum 与默认世界完全不受影响;装饰只落在紧邻水面的
+ // 岸格,且避开路网/桥面/民居/核心区,保证不遮挡通行。
+ let mutable decorations: RiverDecoration list = []
+ if p.RiverDecorations then
+ let mutable decorRng = p.Seed ^^^ 0xD3C0DEUL
+ let nextDecor () =
+ let nextState, raw = splitmix decorRng
+ decorRng <- nextState
+ raw
+ let bridgeLookup = System.Collections.Generic.HashSet<int>(bridges |> List.map (fun (x, y) -> idx x y))
+ let inBuildingFootprint x y =
+ buildings
+ |> List.exists (fun b ->
+ x >= b.Left - 1 && x <= b.Left + b.Width && y >= b.Top && y <= b.DoorY + 1)
+ let blocked x y =
+ not (inMap x y)
+ || tiles.[idx x y] = int GroundTile.Water
+ || pathSet.Contains(idx x y)
+ || bridgeLookup.Contains(idx x y)
+ || inCore x y
+ || inBuildingFootprint x y
+ let occupies (kind: RiverDecorationKind) (y: int) =
+ if kind = Willow then [ y; y + 1 ] else [ y ]
+ let tooClose (kind: RiverDecorationKind) x y =
+ decorations
+ |> List.exists (fun d ->
+ let dyList = occupies d.Kind d.Y
+ occupies kind y
+ |> List.exists (fun cy ->
+ dyList
+ |> List.exists (fun oy ->
+ let dx = d.X - x
+ let dy = oy - cy
+ dx * dx + dy * dy < 9)))
+ let tryPlace (kind: RiverDecorationKind) x y =
+ let tilesFree =
+ if kind = Willow then not (blocked x y) && not (blocked x (y + 1))
+ else not (blocked x y)
+ if tilesFree && not (tooClose kind x y) then
+ decorations <- { Kind = kind; X = x; Y = y } :: decorations
+ let kindOf (roll: uint64) =
+ match int (roll % 3UL) with
+ | 1 -> Reeds
+ | 2 -> Willow
+ | _ -> RevetmentStone
+ for river in riverBands do
+ for x in 2 .. w - 3 do
+ let lowerRoll = nextDecor ()
+ if lowerRoll % 3UL <> 0UL then
+ tryPlace (kindOf lowerRoll) x (river.CenterY + river.Width)
+ if nextDecor () % 4UL = 0UL then
+ let upperRoll = nextDecor ()
+ let kind = kindOf upperRoll
+ let anchorY = if kind = Willow then river.CenterY - 2 else river.CenterY - 1
+ tryPlace kind x anchorY
+
// 6) 30 个 spawn:核心区内的规则网格。
let sc = max 2 p.SpawnColumns
let sr = max 2 p.SpawnRows
@@ -385,6 +459,7 @@ module MapGen =
Bridges = bridges
Paths = paths
Buildings = buildings
+ Decorations = decorations
ReachableTiles = reachableTiles
ReachabilityOk = spawnsOk && doorsOk && bridgesOk
BridgeCrossingsOk = crossingsOk }
@@ -414,6 +489,16 @@ module MapGen =
.Append(building.Width).Append(':').Append(building.Height).Append(':')
.Append(building.DoorX).Append(':').Append(building.DoorY).Append(';')
|> ignore
+ builder.Append('|') |> ignore
+ let decorCode = function
+ | RevetmentStone -> 0
+ | Reeds -> 1
+ | Willow -> 2
+ for decoration in map.Decorations do
+ builder
+ .Append(decorCode decoration.Kind).Append(':')
+ .Append(decoration.X).Append(':').Append(decoration.Y).Append(';')
+ |> ignore
builder.ToString()
/// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 12c17ba..d2fcc1a 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -154,6 +154,10 @@ module VillageArt =
| StoneLanternSprite
| WaterVatSprite
| StallSprite
+ | RoofEaveOverhangSprite
+ | WallLatticeSprite
+ | CourtyardDoorSprite
+ | RevetmentStoneSprite
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -496,7 +500,7 @@ module VillageArt =
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldTileCount = 28
+ let worldTileCount = 32
let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
let characterAtlasWidth = 32 * 5 * 4 * 6
@@ -570,6 +574,10 @@ module VillageArt =
| StoneLanternSprite -> 25
| WaterVatSprite -> 26
| StallSprite -> 27
+ | RoofEaveOverhangSprite -> 28
+ | WallLatticeSprite -> 29
+ | CourtyardDoorSprite -> 30
+ | RevetmentStoneSprite -> 31
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -603,6 +611,13 @@ module VillageArt =
let private fillRect (spriteBatch: SpriteBatch) (pixel: Texture2D) (color: XnaColor) (x: int) (y: int) (w: int) (h: int) =
spriteBatch.Draw(pixel, XnaRectangle(x, y, max 1 w, max 1 h), color)
+ /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。
+ /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 挑檐挑出 + 木格栅窗 + 院门。
+ /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。
+ let houseFacadeVariant (building: MapGen.Building) : int =
+ let remainder = (building.Left + building.Top) % 2
+ if remainder < 0 then remainder + 2 else remainder
+
/// 生成器世界里的桥面与沿河民居(白墙黑瓦色块 + 门);坐标由调用方按相机换算。
let private drawMapStructures
(spriteBatch: SpriteBatch)
@@ -613,23 +628,44 @@ module VillageArt =
(cell: int)
(map: MapGen.Result) =
let bridgeSource = tileSourceRectangle BridgeSprite
+ let drawCell sprite x y =
+ spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle sprite, XnaColor.White)
for (x, y) in map.Bridges do
spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), bridgeSource, XnaColor.White)
for building in map.Buildings do
let left = building.Left
let right = building.Left + building.Width - 1
- // P24:改用 256x192 同款民宅部件——黛瓦(脊/檐)+ 木墙/窗 + 门。
- for row in building.Top .. building.DoorY - 1 do
- let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite
- let source = tileSourceRectangle roofSprite
+ match houseFacadeVariant building with
+ | 0 ->
+ // 变体 A(P24):黛瓦(脊/檐)+ 木墙/窗 + 门。
+ for row in building.Top .. building.DoorY - 1 do
+ let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite
+ for x in left .. right do
+ drawCell roofSprite x row
+ for x in left .. right do
+ let wallSprite =
+ if x = building.DoorX then DoorSprite
+ elif (x - left) % 2 = 0 then WallWindowSprite
+ else WallWoodSprite
+ drawCell wallSprite x building.DoorY
+ | _ ->
+ // 变体 B(P26):挑檐向两侧各出一格 + 木格栅窗 + 院门。
+ for x in (left - 1) .. (right + 1) do
+ drawCell RoofEaveOverhangSprite x building.Top
for x in left .. right do
- spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY row, cell, cell), source, XnaColor.White)
- for x in left .. right do
- let wallSprite =
- if x = building.DoorX then DoorSprite
- elif (x - left) % 2 = 0 then WallWindowSprite
- else WallWoodSprite
- spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY building.DoorY, cell, cell), tileSourceRectangle wallSprite, XnaColor.White)
+ let wallSprite =
+ if x = building.DoorX then CourtyardDoorSprite
+ elif (x - left) % 2 = 0 then WallLatticeSprite
+ else WallWoodSprite
+ drawCell wallSprite x building.DoorY
+ // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。
+ for decoration in map.Decorations do
+ match decoration.Kind with
+ | MapGen.RiverDecorationKind.RevetmentStone -> drawCell RevetmentStoneSprite decoration.X decoration.Y
+ | MapGen.RiverDecorationKind.Reeds -> drawCell ReedSprite decoration.X decoration.Y
+ | MapGen.RiverDecorationKind.Willow ->
+ drawCell WillowTopSprite decoration.X decoration.Y
+ drawCell WillowBaseSprite decoration.X (decoration.Y + 1)
/// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。
let bridgeLanternTiles (map: MapGen.Result) : (int * int) list =