summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 12:10:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 12:10:42 +0800
commita70a057fce7afa5787b2d4a037d7016f1120ef6a (patch)
treece5a24cf07dbbfd46cee48cbec561cc23349f48e
parent3cd5a635d12474647c4cf288a5cc4acb4e0eb1b5 (diff)
downloadliving-village-a70a057fce7afa5787b2d4a037d7016f1120ef6a.tar.gz
feat(desktop): P29 512 聚落组团 + 夜景灯光径向光晕
- MapGen:仅大图新增 ClusterSeed(group seeding)与组团布屋——512 沿横向石板路 (主街+河岸路)成排成组:组内 2-4 座/1 格间隙、组间 4-6 格空档,宽度 2/3/4 分层; 垂柳改为确定性成丛(2-3 株/丛、丛间 3-5 格空档)。独立 clusterRng 不触碰 structureRng,默认图仍不消费 RNG,64x48=6042052 / 256x192=1284656962 锁定不变。 - VillageArt:新增 lanternFalloff(二次 1-(d/r)^2,半径 0 处最大、边缘 0、严格单调无平台); lanternGlowLayers 按该衰减逐环累积 alpha;drawNightGlows 对桥头灯笼+民居门窗画半径 4 tile 的柔和径向光晕,整图取证与实机 DrawWorldView 共用同一套纯函数。 - 回归 +6:组团行/成丛/衰减/光源确定性;Desktop 151、Kernel 92 全绿,Release 0/0, final_digest=953775FAEB2F… 3 次一致。证据帧见 art/p29-*。
-rw-r--r--docs/维护说明.md53
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs79
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs30
-rw-r--r--src/LivingVillage.Desktop/Game.fs50
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs165
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs85
6 files changed, 378 insertions, 84 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 469dbc6..2997f62 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -543,6 +543,37 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
(512 夜巡真实帧,HUD 在屏、民居/石板路清晰)。白块回归 4 张夜景整图 + 571 张夜巡帧
0 命中。
+## P29:512 聚落组团 + 夜景灯光径向光晕(P28 观感遗留)
+
+- **① 聚落组团(仅大图 mapgen 分支 + 独立 group seed)**:`MapGen.Params` 新增 `ClusterSeed`
+ (`paramsForSize` 仅大图时置 `seed ^^^ 0xC1A57E2`,默认 0 且非大图不进入分支)。512 沿河民居
+ 不再沿路随机散点,改为沿横向石板路(主街 + 河岸路)成排成组:组内 2-4 座、组内 1 格间隙、
+ 组间留 4-6 格空档,宽度仍按 2/3/4 加权;垂柳改为确定性成丛(2-3 株一丛、丛间 3-5 格空档),
+ 与成排民居呼应。组发牌走独立 `clusterRng`,不触碰既有 `structureRng`,桥/路/灯笼坐标不变;
+ 民居/树仍只落核心区外草地并做重叠检测,BFS 可达性保持。默认图仍 0 额外民居、不消费 RNG,
+ 故 **64x48=6042052、256x192=1284656962 逐字节不变**(`DefaultSizeChecksumsArePinned`)。
+- **② 夜景灯光径向衰减(渲染层纯函数)**:新增 `VillageArt.lanternFalloff distance radius`
+ (半径 0 处 =1,边缘 =0,中间 `1-(d/r)^2` 严格单调、无平台);`lanternGlowLayers` 改为按该衰减
+ 的逐环增量累积 alpha,`drawNightGlows` 对「桥头灯笼 + 民居门窗暖光」(`nightLightTiles`)
+ 画半径 4 tile、8 环的柔和径向光晕。整图取证(`drawMapFitted`/`drawMapViewport`)与实机
+ `DrawWorldView` 共用同一套函数;无硬方斑、无白块(`check-no-white-blocks` 11 帧 0 命中)。
+- **③ 取证钩子**:复用 `LV_MAPGEN_SHOT` + `LV_MAPGEN_FIT_WIDTH/HEIGHT`(2/4/8 px per tile)与
+ `LV_MAPGEN_NIGHT=1`(23:30 真实 `WorldTint` + golden/moonlight veil);夜景帧在原整图流程上
+ 叠加与实机一致的灯光光晕。
+- **回归**:Kernel 92 / Desktop 151(+6:`ClusterSeedOnlyEnabledForLargeMaps`、
+ `LargeMapHousesFormClusteredRowsWithControlledGaps`、`LargeMapClusterLayoutChecksumIsEmptyDiffAcrossRuns`、
+ `LargeMapWillowsGrowInGroves`、`LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau`、
+ `NightLightTilesCombineBridgeLanternsAndHouseWindows`)全绿;Release 0/0;
+ `final_digest=953775FAEB2F…` 3 次一致、`performance_determinism=PASS`。
+- **取证** `/tmp/opencode/lv-p29/art/`:`p29-map-512x384-seed4242-{day,night}.png`(512 整图
+ 3072x2304 真实渲染)、`p29-village-cluster-4x-{day,night}.png`(512x256,4px/tile 桥头沿河组团
+ 局部,含成排民居/桥/水面)、`p29-lantern-halo-8x-night.png`(192x192,单灯 8px/tile 特写;
+ 径向剖面 191→189→183→167→137→113→96→84 平滑递减)、`p29-variant-pair-ab-4x-{day,night}.png`
+ (512x256,A/B 对照);
+ 源帧 `p29-day/`、`p29-night/`、`p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git。
+- **遗留**:民居总数与 P28 相同(24 座),本轮只做「组团化」未继续提高总密度;河岸垂柳成丛后
+ 仍为纯视觉层,不参与通行;默认 64x48/256x192 观感不变。
+
## 已验证命令
```bash
@@ -702,6 +733,23 @@ timeout 220 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 代表帧 p28-night-10100.png -> art/p28-night-village.png
+# P29 512 组团 + 夜景光晕取证(4/8 px per tile;night 走 23:30 tint+veil+径向光晕)
+run_p29 () { # $1=fit_w $2=fit_h $3=night(0/1) $4=out_dir
+ local extra=""; [ "$3" = 1 ] && extra="LV_MAPGEN_NIGHT=1"
+ timeout 300 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=$1 LV_MAPGEN_FIT_HEIGHT=$2 $extra LV_RECORD_DIR=$4 \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+}
+run_p29 2048 1536 0 /tmp/opencode/lv-p29/art/p29-day
+run_p29 2048 1536 1 /tmp/opencode/lv-p29/art/p29-night
+run_p29 4096 3072 0 /tmp/opencode/lv-p29/art/p29-day8
+run_p29 4096 3072 1 /tmp/opencode/lv-p29/art/p29-night8
+# 裁剪(PIL):整图 p20b-map-*.png -> p29-map-*;组团 4x = 2048 帧裁 tile x278..406 y82..146;
+# 单灯 8x = 4096 帧以桥灯 (113,180) 为中心裁 192x192;
+# A/B 对照 = 2048 帧裁 tile x228..260 y170..186 后 4x NEAREST -> 512x256
+python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p29/art/p29-night /tmp/opencode/lv-p29/art/p29-night8
+
# 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 \
@@ -790,4 +838,9 @@ python3 scripts/check-no-white-blocks.py evidence/night-run
`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)
+- `/tmp/opencode/lv-p29/art/`(P29:`p29-map-512x384-seed4242-{day,night}.png` 512 整图、
+ `p29-village-cluster-4x-{day,night}.png` 512x256 桥头沿河组团 4px/tile、
+ `p29-lantern-halo-8x-night.png` 192x192 单灯 8px/tile 光晕特写、
+ `p29-variant-pair-ab-4x-{day,night}.png` 512x256 A/B 对照;源帧 `p29-day/`、`p29-night/`、
+ `p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git)
- `artifacts/perf-independent/<TS>/`(digest 3 联测回读)
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
index b737707..d83a144 100644
--- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -276,6 +276,85 @@ type MapGenTests () =
Assert.AreEqual<int>(4, (MapGen.generateWithSize 64 48 (uint64 4242)).Buildings.Length)
Assert.AreEqual<int>(4, (MapGen.generateWithSize 256 192 (uint64 4242)).Buildings.Length)
+ // ---- P29:512 聚落组团(group seeding 仅大图启用;行内成组、组间留档) ----
+
+ [<TestMethod>]
+ member _.ClusterSeedOnlyEnabledForLargeMaps () =
+ Assert.AreEqual<uint64>(0UL, (MapGen.paramsForSize 64 48 (uint64 4242)).ClusterSeed)
+ Assert.AreEqual<uint64>(0UL, (MapGen.paramsForSize 256 192 (uint64 4242)).ClusterSeed)
+ Assert.AreNotEqual<uint64>(0UL, (MapGen.paramsForSize 512 384 (uint64 4242)).ClusterSeed)
+ Assert.AreEqual<uint64>(0UL, (MapGen.defaultParams (uint64 4242)).ClusterSeed)
+
+ [<TestMethod>]
+ member _.LargeMapHousesFormClusteredRowsWithControlledGaps () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let rowClusters (row: MapGen.Building list) =
+ let sorted = row |> List.sortBy (fun b -> b.Left)
+ let clusters = ResizeArray<MapGen.Building list>()
+ let mutable current: MapGen.Building list = []
+ let mutable previousEnd = System.Int32.MinValue
+ for building in sorted do
+ if current <> [] && building.Left - previousEnd <= 2 then
+ current <- building :: current
+ else
+ if current <> [] then clusters.Add(List.rev current)
+ current <- [ building ]
+ previousEnd <- building.Left + building.Width - 1
+ if current <> [] then clusters.Add(List.rev current)
+ List.ofSeq clusters
+ let clusteredRows = map.Buildings |> List.groupBy (fun b -> b.Top) |> List.map (snd >> rowClusters)
+ let clusterSizes = clusteredRows |> List.collect (List.map List.length)
+ Assert.IsTrue(clusterSizes |> List.exists (fun n -> n >= 3), "at least one row-sized house cluster")
+ // 行内出现多个簇 => 组间留有可控空档,而不是一整排等距。
+ let rowCount = map.Buildings |> List.map (fun b -> b.Top) |> List.distinct |> List.length
+ Assert.IsTrue(clusterSizes.Length > rowCount, "rows must split into separated groups")
+ for clustersInRow in clusteredRows do
+ match clustersInRow with
+ | first :: rest ->
+ let mutable previous = first
+ for cluster in rest do
+ let prevEnd = previous |> List.map (fun b -> b.Left + b.Width - 1) |> List.max
+ let nextStart = cluster |> List.map (fun b -> b.Left) |> List.min
+ Assert.IsTrue(nextStart - prevEnd >= 3, "groups must be separated by a controlled gap")
+ previous <- cluster
+ | [] -> ()
+
+ /// 组团参数下,512 world 同 seed 两次生成逐字节一致(checksum 且序列化 diff 为空)。
+ [<TestMethod>]
+ member _.LargeMapClusterLayoutChecksumIsEmptyDiffAcrossRuns () =
+ let a = MapGen.generateWithSize 512 384 (uint64 4242)
+ let b = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(a.Tiles = b.Tiles, "tiles must be byte-identical across runs")
+ Assert.AreEqual<int64>(checksum a, checksum b)
+ Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b)
+ Assert.IsTrue(a.Buildings = b.Buildings)
+ Assert.IsTrue(a.Decorations = b.Decorations)
+
+ /// P29 垂柳成丛:岸边出现相邻 2-3 株的柳丛,且不再是均匀逐格散布。
+ [<TestMethod>]
+ member _.LargeMapWillowsGrowInGroves () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let willows = map.Decorations |> List.filter (fun d -> d.Kind = MapGen.Willow)
+ Assert.IsTrue(willows.Length > 0, "large map must place willows")
+ let groveSizes =
+ willows
+ |> List.groupBy (fun d -> d.Y)
+ |> List.collect (fun (_, row) ->
+ let xs = row |> List.map (fun d -> d.X) |> List.sort
+ let sizes = ResizeArray<int>()
+ let mutable current = 0
+ let mutable previous = System.Int32.MinValue
+ for x in xs do
+ if previous >= 0 && x - previous = 1 then
+ current <- current + 1
+ else
+ if current > 0 then sizes.Add current
+ current <- 1
+ previous <- x
+ if current > 0 then sizes.Add current
+ List.ofSeq sizes)
+ Assert.IsTrue(groveSizes |> List.exists (fun n -> n >= 2), "willows must form groves of adjacent trunks")
+
// ---- P25:大图不产孤立石板 tile(装饰石块默认开启仅限小图) ----
[<TestMethod>]
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index 7f67280..7b35837 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -663,6 +663,36 @@ type PrototypeTests () =
Assert.IsTrue(row.[0] = center && row.[radius] < center, "core is brightest, rim is dimmer")
[<TestMethod>]
+ member _.LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau () =
+ // P29:夜景灯光衰减函数——半径 0 处最大(=1),向半径边缘严格单调递减至 0,无平台。
+ let radius = 96.0f
+ Assert.AreEqual<float32>(1.0f, VillageArt.lanternFalloff 0.0f radius)
+ Assert.AreEqual<float32>(0.0f, VillageArt.lanternFalloff radius radius)
+ Assert.AreEqual<float32>(0.0f, VillageArt.lanternFalloff (radius + 7.0f) radius)
+ Assert.IsTrue(abs (VillageArt.lanternFalloff 0.0f 0.0f) < 1e-6f, "zero radius must contribute no light")
+ let samples = [ for i in 0 .. int radius -> VillageArt.lanternFalloff (float32 i) radius ]
+ for value in samples do
+ Assert.IsTrue(value >= 0.0f && value <= 1.0f, "falloff must stay within [0,1]")
+ samples
+ |> List.pairwise
+ |> List.iter (fun (a, b) -> Assert.IsTrue(b < a, sprintf "falloff must strictly decrease (no plateau): %f then %f" a b))
+ // 二次衰减:一半半径处约为 0.75,且严格高于线性 0.5。
+ Assert.IsTrue(abs (VillageArt.lanternFalloff (radius / 2.0f) radius - 0.75f) < 1e-4f)
+
+ [<TestMethod>]
+ member _.NightLightTilesCombineBridgeLanternsAndHouseWindows () =
+ // P29:夜景光源 = 桥头灯笼 + 民居门窗暖光,确定性纯函数。
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let lights = VillageArt.nightLightTiles map
+ Assert.IsTrue(lights.Length > 0, "large map must emit night lights")
+ Assert.IsTrue(lights.Length > map.Buildings.Length, "windows add light sources beyond the houses themselves")
+ Assert.AreEqual<(int * int) list>(lights, VillageArt.nightLightTiles map)
+ for lantern in VillageArt.bridgeLanternTiles map do
+ Assert.IsTrue(lights |> List.contains lantern, sprintf "bridge lantern (%d,%d) must be a light source" (fst lantern) (snd lantern))
+ for building in map.Buildings do
+ Assert.IsTrue(lights |> List.contains (building.DoorX, building.DoorY), "every house door must carry warm light")
+
+ [<TestMethod>]
member _.CharacterFeetSitOnTheTileGroundLine () =
Assert.AreEqual<int>(16, VillageArt.characterFeetOffset)
let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 38d9e94..5f6e99e 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -880,11 +880,16 @@ type LivingVillageGame() as this =
this.GraphicsDevice.SetRenderTarget target
this.GraphicsDevice.Clear(Color(18, 26, 20))
spriteBatch.Begin()
+ // P29:夜景取证帧与真实渲染同一套灯光衰减——按 veil tick 的 LanternGlow 叠加柔和径向光晕。
+ let glowScale =
+ match veilTick with
+ | Some tick -> (M6Presentation.profileAtTick tick).LanternGlow
+ | None -> 0.0f
let drawn =
if drawFitted then
- VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult tint
+ VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult tint glowScale
else
- VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L tint
+ VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L tint glowScale
// 与 DrawWorldView 同一套纯函数 veil,保证取证夜景=真实渲染夜景。
match veilTick with
| Some tick ->
@@ -958,7 +963,7 @@ type LivingVillageGame() as this =
this.GraphicsDevice.SetRenderTarget frameTarget
for _ in 1 .. 30 do
spriteBatch.Begin()
- VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) viewport.Width viewport.Height mapGenResult 0L Color.White |> ignore
+ VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) viewport.Width viewport.Height mapGenResult 0L Color.White 0.0f |> ignore
spriteBatch.End()
this.GraphicsDevice.SetRenderTarget null
frameWatch.Stop()
@@ -1181,29 +1186,28 @@ type LivingVillageGame() as this =
world.Tick
profile.WorldTint
- // Warm lantern glows strengthen as night falls (pure function of tick phase).
- // Concentric premultiplied circles fall off radially from each lantern tile, so
- // the core stays a soft warm disc instead of saturating into a white block, and
- // no glow is painted where there is no lantern entity. 生成器世界用桥头灯笼。
+ // Warm lantern / window glows strengthen as night falls (pure function of tick phase).
+ // Concentric premultiplied circles follow a quadratic radial falloff (radius 3-4 tiles),
+ // so the core stays a soft warm disc instead of saturating to a white block and no glow
+ // is painted where there is no light entity. 生成器世界用桥头灯笼 + 民居门窗。
if profile.LanternGlow > 0.3f then
let glow = profile.LanternGlow
let peakAlpha = int (140.0f * glow)
- let lanternTiles =
- match riverscapeMap with
- | Some map -> VillageArt.bridgeLanternTiles map
- | None ->
- renderPlan.Props
- |> List.choose (fun prop ->
- match prop.Kind with
- | VillageArt.RenderPropKind.RiverLantern ->
- let position = VillageArt.worldTile renderPlan prop.Position
- Some(position.X, position.Y)
- | _ -> None)
- for (tileX, tileY) in lanternTiles do
- let centerX = tileX * Sim.tilePixels - int camera.X + Sim.tilePixels / 2
- let centerY = tileY * Sim.tilePixels - int camera.Y + Sim.tilePixels / 2
- for (rect, color) in VillageArt.lanternGlowLayers centerX centerY 44 6 255 214 150 peakAlpha do
- spriteBatch.Draw(pixel, rect, color)
+ let tileX x = x * Sim.tilePixels - int camera.X
+ let tileY y = y * Sim.tilePixels - int camera.Y
+ match riverscapeMap with
+ | Some map ->
+ VillageArt.drawNightGlows spriteBatch pixel map (4 * Sim.tilePixels) (int (150.0f * glow)) tileX tileY Sim.tilePixels
+ | None ->
+ for prop in renderPlan.Props do
+ match prop.Kind with
+ | VillageArt.RenderPropKind.RiverLantern ->
+ let position = VillageArt.worldTile renderPlan prop.Position
+ let centerX = tileX position.X + Sim.tilePixels / 2
+ let centerY = tileY position.Y + Sim.tilePixels / 2
+ for (rect, color) in VillageArt.lanternGlowLayers centerX centerY (3 * Sim.tilePixels) 8 255 214 150 peakAlpha do
+ spriteBatch.Draw(pixel, rect, color)
+ | _ -> ()
VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos
let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick
diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
index 3705635..a9d6c9f 100644
--- a/src/LivingVillage.Desktop/MapGen.fs
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -36,7 +36,10 @@ module MapGen =
/// P25:是否放置孤立装饰石块。默认 true;大图关闭,避免草地上出现游离石板 tile。
DecorativeStones: bool
/// P26:是否沿海河岸放置河岸装饰(护岸石垒/芦苇/垂柳)。默认 false;仅大图开启。
- RiverDecorations: bool }
+ RiverDecorations: bool
+ /// P29:大图聚落组团发牌种子(group seeding)。默认 0 且在非大图分支不使用,
+ /// 故 64x48/256x192 的 structureRng 流与输出逐字节不变。
+ ClusterSeed: uint64 }
/// P20 第二步:一条贯穿全图的东西向河道(每列恰好一段,宽度下限 RiverWidth)。
type River =
@@ -92,12 +95,14 @@ module MapGen =
SpawnRows = 6
ExtraRiversideHouses = 0
DecorativeStones = true
- RiverDecorations = false }
+ RiverDecorations = false
+ ClusterSeed = 0UL }
/// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。
/// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。
/// P24:仅对大于 256x192 的图追加沿河民居(泊松散布),保证既有 checksum 不变。
/// P28:大图沿河民居由 10 增至 16(+6 座,宽度 2/3/4 变化),默认图仍为 0、不消耗 RNG。
+ /// P29:大图额外启用聚落组团发牌种子(独立 clusterRng),默认图保持 0、不进入组团分支。
let paramsForSize (width: int) (height: int) (seed: uint64) : Params =
let base_ = defaultParams seed
let large = width > 256 || height > 192
@@ -107,7 +112,8 @@ module MapGen =
RiverCount = max base_.RiverCount (max 1 (height / 96))
ExtraRiversideHouses = (if large then 16 else 0)
DecorativeStones = not large
- RiverDecorations = large }
+ RiverDecorations = large
+ ClusterSeed = (if large then seed ^^^ 0xC1A57E2UL else 0UL) }
// ---- splitmix64 与 value noise(无外部依赖、无时钟) ----
@@ -305,11 +311,12 @@ module MapGen =
buildings <- building :: buildings
addPath building.DoorX building.DoorY
- // 5b) P24:大图沿河民居加密。从既有路网瓦片出发,向四邻定向落位 3x2 白墙黛瓦民居,
- // 门开向相邻路格并接入路网(BFS 可达);仅在 ExtraRiversideHouses>0 时执行,
- // 且只在核心区外的草地落位,故 64x48/256x192 输出逐字节不变。
- // P28:宽度按 2/3/4 加权变化(2=附属小筑、4=大宅),形成聚落层次而非等距重复;
- // 数量 10 -> 16(沿河再增 6 座)。仍只在大图分支执行,默认图 RNG 不消耗,checksum 不变。
+ // 5b) P24/P28/P29:大图沿河聚落组团。仅在 ExtraRiversideHouses>0 时执行,只落在核心区外
+ // 草地并避免与既有民居重叠,故 64x48/256x192 输出逐字节不变。
+ // P29:不改「沿路随机散点」,而是沿横向石板路(主街 + 河岸路)成排成组落位:
+ // 每组 2-4 座、组内 1 格间隙、组间留 4-6 格空档,形成「沿河成排 / 桥头小广场」的
+ // 聚落肌理;组发牌使用独立 clusterRng(group seeding),不触碰既有 structureRng,
+ // 桥/路/灯笼等基础结构坐标不受影响。宽度仍按 2/3/4 加权(附属小筑/民居/大宅)。
if p.ExtraRiversideHouses > 0 then
let footprintFree (left: int) (top: int) (width: int) =
left >= 1 && left + width - 1 <= w - 2 && top >= 1 && top + 1 <= h - 2
@@ -323,34 +330,63 @@ module MapGen =
|> List.exists (fun b ->
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) ]
+ // 横向路段的连续格(主街/河岸路),先按 (y,x) 排序再按 group seed 打散成确定性布线顺序。
+ let horizontalRuns =
+ [ for y in 1 .. h - 2 do
+ let mutable x = 1
+ while x <= w - 2 do
+ if pathSet.Contains(idx x y) then
+ let x0 = x
+ while x <= w - 2 && pathSet.Contains(idx x y) do
+ x <- x + 1
+ if x - x0 >= 4 then yield (y, x0, x - 1)
+ else
+ x <- x + 1 ]
+ |> List.sortBy (fun (y, x0, _) -> (hash2 y x0 p.ClusterSeed))
+ let mutable clusterRng = p.ClusterSeed
+ let nextCluster () =
+ let nextState, raw = splitmix clusterRng
+ clusterRng <- nextState
+ raw
let mutable extraPlaced = 0
- let mutable extraAttempts = 0
- let maxAttempts = p.ExtraRiversideHouses * 400
- while extraPlaced < p.ExtraRiversideHouses && extraAttempts < maxAttempts && paths.Length > 0 do
- extraAttempts <- extraAttempts + 1
- let nextState, raw = splitmix structureRng
- structureRng <- nextState
- 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 - buildingWidth / 2
- let top = doorY - 1
- if footprintFree left top buildingWidth && not (overlaps left top buildingWidth) then
- let building =
- { Left = left
- Top = top
- Width = buildingWidth
- Height = 2
- DoorX = doorX
- DoorY = doorY }
- buildings <- building :: buildings
- addPath building.DoorX building.DoorY
- extraPlaced <- extraPlaced + 1
+ for (runY, runStart, runEnd) in horizontalRuns do
+ if extraPlaced < p.ExtraRiversideHouses then
+ let runLength = runEnd - runStart + 1
+ // 主街长路段多放,河岸短路各放一小簇。
+ let quota = min (p.ExtraRiversideHouses - extraPlaced) (if runLength >= 64 then 6 else 3)
+ // 默认屋在路北(门贴路南缘);北侧容不下时改南侧(沿用既有岸屋「门在顶排」口径)。
+ let sideFits above =
+ let top = if above then runY - 2 else runY + 1
+ [ runStart .. min runEnd (runStart + 4) ]
+ |> List.exists (fun x -> footprintFree x top 3)
+ let above = if sideFits true then true else not (sideFits false)
+ let mutable x = runStart + int (nextCluster () % 2UL)
+ let mutable placedHere = 0
+ while placedHere < quota && x + 1 <= runEnd do
+ let groupSize = 2 + int (nextCluster () % 3UL)
+ for _ in 1 .. groupSize do
+ if placedHere < quota && x + 1 <= runEnd then
+ let widthRoll = int (nextCluster () % 10UL)
+ let buildingWidth = if widthRoll < 5 then 3 elif widthRoll < 8 then 2 else 4
+ let doorX = x + buildingWidth / 2
+ let doorY = if above then runY - 1 else runY + 1
+ let top = if above then doorY - 1 else doorY
+ let left = doorX - buildingWidth / 2
+ if footprintFree left top buildingWidth && not (overlaps left top buildingWidth) then
+ let building =
+ { Left = left
+ Top = top
+ Width = buildingWidth
+ Height = 2
+ DoorX = doorX
+ DoorY = doorY }
+ buildings <- building :: buildings
+ addPath building.DoorX building.DoorY
+ extraPlaced <- extraPlaced + 1
+ placedHere <- placedHere + 1
+ x <- x + buildingWidth + 1
+ // 组间空档:保持可控行距与组团边界。
+ x <- x + 4 + int (nextCluster () % 3UL)
// 5c) P26:河岸装饰(护岸石垒/芦苇/垂柳),仅大图启用。纯视觉层,不写入任何瓦片,
// 故 64x48/256x192 的 tiles checksum 与默认世界完全不受影响;装饰只落在紧邻水面的
@@ -376,7 +412,7 @@ module MapGen =
|| inBuildingFootprint x y
let occupies (kind: RiverDecorationKind) (y: int) =
if kind = Willow then [ y; y + 1 ] else [ y ]
- let tooClose (kind: RiverDecorationKind) x y =
+ let tooClose (minimumSq: int) (kind: RiverDecorationKind) x y =
decorations
|> List.exists (fun d ->
let dyList = occupies d.Kind d.Y
@@ -386,28 +422,55 @@ module MapGen =
|> List.exists (fun oy ->
let dx = d.X - x
let dy = oy - cy
- dx * dx + dy * dy < 9)))
- let tryPlace (kind: RiverDecorationKind) x y =
+ dx * dx + dy * dy < minimumSq)))
+ let tryPlaceAt (minimumSq: int) (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
+ if tilesFree && not (tooClose minimumSq 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
+ // P26 单体点缀保持 3 格最小间距;P29 垂柳成丛,组内允许相邻 1 格。
+ let tryPlace = tryPlaceAt 9
+ let tryGrove = tryPlaceAt 1
+ // 零星点缀只出芦苇/护岸石;垂柳完全交给成丛逻辑,避免再次退化为逐格等距。
+ let scatterKindOf (roll: uint64) =
+ if roll % 2UL = 0UL then Reeds else RevetmentStone
+ // P29 聚落肌理:垂柳确定性成丛(2-3 株一丛),丛间留 3-5 格空档,与沿线成排的
+ // 民居呼应;芦苇/护岸石仍零星点缀。仅大图分支执行,默认图不受影响。
for river in riverBands do
+ let lowerY = river.CenterY + river.Width
+ let upperY = river.CenterY - 2
+ let mutable lowerGrove = 0
+ let mutable lowerCooldown = 0
+ let mutable upperGrove = 0
+ let mutable upperCooldown = 0
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
+ if lowerGrove > 0 then
+ tryGrove Willow x lowerY
+ lowerGrove <- lowerGrove - 1
+ if lowerGrove = 0 then lowerCooldown <- 2 + int (nextDecor () % 3UL)
+ elif lowerCooldown > 0 then
+ lowerCooldown <- lowerCooldown - 1
+ else
+ let lowerRoll = nextDecor ()
+ if lowerRoll % 24UL = 0UL then
+ lowerGrove <- 1 + int (nextDecor () % 2UL)
+ tryGrove Willow x lowerY
+ elif lowerRoll % 3UL <> 0UL then
+ tryPlace (scatterKindOf lowerRoll) x lowerY
+ if upperGrove > 0 then
+ tryGrove Willow x upperY
+ upperGrove <- upperGrove - 1
+ if upperGrove = 0 then upperCooldown <- 3 + int (nextDecor () % 3UL)
+ elif upperCooldown > 0 then
+ upperCooldown <- upperCooldown - 1
+ else
let upperRoll = nextDecor ()
- let kind = kindOf upperRoll
- let anchorY = if kind = Willow then river.CenterY - 2 else river.CenterY - 1
- tryPlace kind x anchorY
+ if upperRoll % 32UL = 0UL then
+ upperGrove <- 1 + int (nextDecor () % 2UL)
+ tryGrove Willow x upperY
+ elif upperRoll % 4UL = 0UL then
+ tryPlace (scatterKindOf upperRoll) x (river.CenterY - 1)
// 6) 30 个 spawn:核心区内的规则网格。
let sc = max 2 p.SpawnColumns
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 9a56de4..3e52546 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -444,10 +444,25 @@ module VillageArt =
if half > 0 then
yield (centerX - half, centerY + dy, half * 2 + 1) ]
- /// Soft radial lantern glow drawn as concentric filled circles. Every ring carries a
- /// low, premultiplied warm alpha so the rings sum to `peakAlpha` at the core and thin
- /// out toward the rim; because each ring's rgb is premultiplied, the core can never
- /// saturate to an opaque white block the way straight rgba layers did.
+ /// P29 夜景灯光径向衰减:半径 0 处为 1(最亮),距离达到 radius 处为 0;中间按二次
+ /// `1 - (d/r)^2` 平滑递减,严格单调、无平台(既没有硬边方斑,也不会出现等亮台阶)。
+ /// 纯函数、无时钟/随机源,供灯笼/窗光光晕与单测共用。
+ let lanternFalloff (distance: float32) (radius: float32) : float32 =
+ if radius <= 0.0f then
+ 0.0f
+ elif distance <= 0.0f then
+ 1.0f
+ elif distance >= radius then
+ 0.0f
+ else
+ let t = distance / radius
+ 1.0f - t * t
+
+ /// Soft radial lantern glow drawn as concentric filled circles. Each ring's alpha is the
+ /// incremental step of `lanternFalloff` at that ring radius, so the accumulated warm alpha
+ /// follows the smooth quadratic falloff from the core out to `radius` instead of stepping or
+ /// flattening. Because every ring's rgb is premultiplied by its own low alpha, the core can
+ /// never saturate to an opaque white block the way straight rgba layers did.
let lanternGlowLayers
(centerX: int)
(centerY: int)
@@ -460,12 +475,20 @@ module VillageArt =
if radius <= 0 || rings <= 0 || peakAlpha <= 0 then
[]
else
- let perRing = max 1 (peakAlpha / rings)
- let glowColor = premultiply red green blue perRing
+ let radiusF = float32 radius
+ let targetAlpha (ringRadius: int) =
+ let falloff = lanternFalloff (float32 ringRadius) radiusF
+ int (Math.Round(float peakAlpha * float falloff, MidpointRounding.AwayFromZero))
+ let mutable previousTarget = 0
[ for ring in 0 .. rings - 1 do
let ringRadius = max 1 (radius * (rings - ring) / rings)
- for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do
- yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), glowColor) ]
+ let target = targetAlpha ringRadius
+ let increment = max 0 (target - previousTarget)
+ previousTarget <- max previousTarget target
+ if increment > 0 then
+ let glowColor = premultiply red green blue increment
+ for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do
+ yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), glowColor) ]
/// Characters spawn at tile centres; push the sprite down half a tile so its feet rest
/// on the tile's ground line instead of floating at the centre.
@@ -685,6 +708,41 @@ module VillageArt =
[ (x, List.min ys); (x, List.max ys) ])
|> List.distinct
+ /// P29 夜景灯光源瓦片:桥头灯笼 + 民居门窗暖光(确定性纯函数,便于单测)。
+ /// 每户至少门格发光;变体 A 再取第一扇横向窗,变体 B(整面格栅窗)与门同格故不另加。
+ let nightLightTiles (map: MapGen.Result) : (int * int) list =
+ let lanterns = bridgeLanternTiles map
+ let houseLights =
+ map.Buildings
+ |> List.collect (fun building ->
+ let windowTiles =
+ if houseFacadeVariant building = 0 then
+ [ for x in building.Left .. building.Left + building.Width - 1 do
+ if x <> building.DoorX && (x - building.Left) % 2 = 0 then yield (x, building.DoorY) ]
+ else
+ [ (building.DoorX, building.DoorY) ]
+ (building.DoorX, building.DoorY) :: windowTiles)
+ lanterns @ houseLights |> List.distinct
+
+ /// P29 夜景光晕叠加层:对每个灯光源画一圈柔和径向衰减(半径 3-6 tile),
+ /// 由 `lanternGlowLayers` 保证无硬方斑、无等亮台阶。纯绘制,不改瓦片/可达性。
+ let drawNightGlows
+ (spriteBatch: SpriteBatch)
+ (pixel: Texture2D)
+ (map: MapGen.Result)
+ (radiusPixels: int)
+ (peakAlpha: int)
+ (tileX: int -> int)
+ (tileY: int -> int)
+ (cell: int) : unit =
+ if peakAlpha > 0 && radiusPixels > 0 then
+ let half = cell / 2
+ for (lightX, lightY) in nightLightTiles map do
+ let centerX = tileX lightX + half
+ let centerY = tileY lightY + half
+ for (rect, color) in lanternGlowLayers centerX centerY radiusPixels 8 255 214 150 peakAlpha do
+ spriteBatch.Draw(pixel, rect, color)
+
let drawWorld
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
@@ -835,7 +893,8 @@ module VillageArt =
(viewportWidth: int)
(viewportHeight: int)
(map: MapGen.Result)
- (tint: XnaColor) : int =
+ (tint: XnaColor)
+ (glowScale: float32) : int =
let scale =
min (float32 viewportWidth / float32 (max 1 (map.Width * Sim.tilePixels)))
(float32 viewportHeight / float32 (max 1 (map.Height * Sim.tilePixels)))
@@ -854,6 +913,9 @@ module VillageArt =
let code = map.Tiles.[y * map.Width + x]
spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle (spriteOf code), tint)
drawMapStructures spriteBatch textures pixel tileX tileY cell map
+ if glowScale > 0.0f then
+ // 半径 4 tile 的柔和径向衰减;随 cell 缩放,整图/放大帧观感一致。
+ drawNightGlows spriteBatch pixel map (4 * cell) (int (150.0f * glowScale)) tileX tileY cell
map.Width * map.Height
/// P20 证据用:按相机做视口裁剪,只绘制可见瓦片(1:1 像素),返回实际绘制数。
@@ -866,7 +928,8 @@ module VillageArt =
(viewportHeight: int)
(map: MapGen.Result)
(tick: int64)
- (tint: XnaColor) : int =
+ (tint: XnaColor)
+ (glowScale: float32) : int =
let x0, y0, x1, y1 =
MapGen.visibleTileRange map.Width map.Height (int camera.X) (int camera.Y) viewportWidth viewportHeight
let tileX x = x * Sim.tilePixels - int camera.X
@@ -887,6 +950,8 @@ module VillageArt =
spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, Sim.tilePixels, Sim.tilePixels), tileSourceRectangle (spriteOf x y code), tint)
drawn <- drawn + 1
drawMapStructures spriteBatch textures pixel tileX tileY Sim.tilePixels map
+ if glowScale > 0.0f then
+ drawNightGlows spriteBatch pixel map (4 * Sim.tilePixels) (int (150.0f * glowScale)) tileX tileY Sim.tilePixels
drawn
let drawCharacter