summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 12:41:06 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 12:41:06 +0800
commit92bcc9b9fc4eeea4f11913a0de2a34811b262dfd (patch)
tree7fa25b2a3fc2ebeea7b2fe2cbc6285848d7c0a26
parent4034f55ee0b9cbd8c9db5f850e10e21e15fb7274 (diff)
downloadliving-village-92bcc9b9fc4eeea4f11913a0de2a34811b262dfd.tar.gz
feat(desktop): P30 512 民居增密 + 乡村空区填充 + 主路柔化
- MapGen(仅大图尺寸自适应分支,默认 64x48/256x192 不进入、不消费额外 RNG): ① ExtraRiversideHouses 16 -> 30,组团 2-5 座;含基础岸屋的路段优先布组并为每座孤立 基础民居贴格配对,placeGroup 试排后按「与既有/同组民居足迹间隙 <=2」过滤,孤立单屋不落地。 512 seed4242:组团民居 24 -> 45,规模 [5;4x6;3x2;2x3],0 组 <2 座。 ② 新增 GroundTile.PaddyField=4/VegetablePlot=5(成熟 tile;非水可通行)+ Result.Groves (纯视觉)+ Result.Farmhouses(门贴院落路、计入 BFS);18x14 确定性网格铺田块/树丛/农舍, 离水 >=3 格、避开路网/核心/民居。草地瓦片 96.3% -> 83.9%,水田 11595 + 菜畦 12877 格。 ③ Result.MainRoadCenter 逐列中心线,value noise(scale 130、幅度 ±3)确定性缓弯, 相邻列步长 <=1、遇弯补格保证 4 连通;默认图主街仍笔直。 - VillageArt:水田/菜畦/树丛/农舍渲染(复用成熟 sprite 与同一立面纯函数)、农舍门窗计入夜光、 新增 drawMapRegion 局部真实离屏渲染。 - Game:P30 证据钩子 LV_MAPGEN_P30_SHOT=1(整图 3072x2304 day/night + 田块/主路 512x256@4px)。 - 回归 +5:组团规模/空区填充/田块合规/农舍可达确定性/主路确定性弯曲。 Kernel 92 / Desktop 156 全绿;Release 0 警告 0 错误; final_digest=953775FAEB2F… 3 次一致、performance_determinism=PASS; 64x48=6042052、256x192=1284656962 锁定不变。
-rw-r--r--docs/维护说明.md44
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs112
-rw-r--r--src/LivingVillage.Desktop/Game.fs127
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs346
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs59
5 files changed, 632 insertions, 56 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 2997f62..e234283 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -574,6 +574,35 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- **遗留**:民居总数与 P28 相同(24 座),本轮只做「组团化」未继续提高总密度;河岸垂柳成丛后
仍为纯视觉层,不参与通行;默认 64x48/256x192 观感不变。
+## P30:512 民居增密 + 乡村空区填充 + 主路柔化(P29 观感遗留)
+
+- **① 民居增密与组团(仅大图 mapgen 分支)**:`ExtraRiversideHouses` 16 -> 30,组团规模由
+ 2-4 改为 2-5 座;含基础岸屋的横向路段优先布组,并为每座仍孤立的基础民居在同行贴 1 格补
+ 一座。`placeGroup` 先局部试排,再用 `connected`(与既有民居或同组民居足迹间隙 <=2)过滤后
+ 才提交,孤立单屋根本不落地。512x384 seed4242:沿河/沿路组团民居 24 -> **45 座**,组团规模
+ `[5;4x6;3x2;2x3]`,**0 组 <2 座**;宽度仍按 2/3/4 加权(附属小筑/民居/大宅)。
+- **② 乡村空区填充(仅大图)**:新增 `GroundTile.PaddyField=4 / VegetablePlot=5`(成熟 tile
+ 素材:水田走静态水面 sprite、菜畦走菜地 sprite;均为非水瓦片故仍可通行)、`Result.Groves`
+ (竹丛/灌木丛,纯视觉层,不入瓦片、不影响通行)、`Result.Farmhouses`(散落农舍,门贴院落路,
+ 计入 BFS 可达性)。按 18x14 确定性网格铺田块/树丛/农舍,田块离水面 >=3 格并避开路网/核心/
+ 民居;上部与下部空带均有田块。512x384 seed4242:水田 11595 + 菜畦 12877 格、树丛 852 格、
+ 散落农舍 21 座;草地瓦片占比 **96.3% -> 83.9%**(整图真实渲染中草地像素约 80.8%)。
+- **③ 主路柔化(仅大图)**:新增 `Result.MainRoadCenter` 逐列中心线,用确定性 value noise
+ (scale 130、幅度 ±3 格)微扰,相邻列步长恒 `<=1`,遇弯补一格保证 4 连通;桥的竖向引路按
+ `roadYAt bx` 对齐。默认图主街仍笔直。同 seed 两次逐字节一致(`serialize` 含中心线)。
+- **回归 +5**:`LargeMapGroupsAreTwoToFiveWithFewIsolated`、
+ `LargeMapCountrysideFillsEmptyBandsWithMatureTiles`、`FieldsStayOnLandAwayFromWaterPathsAndCore`、
+ `LargeMapFarmsteadDoorsAreReachableAndDeterministic`、`LargeMapMainRoadIsDeterministicSerpentine`。
+ Kernel **92** / Desktop **156** 全绿;Release build 0 警告 0 错误;
+ `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、
+ `performance_determinism=PASS`;**64x48=6042052、256x192=1284656962 锁定不变**
+ (`DefaultSizeChecksumsArePinned`)。
+- **取证** `/tmp/opencode/lv-p30/art/`(真实离屏渲染,非构造):`p30-map-512x384-seed4242-{day,night}.png`
+ (3072x2304 整图)、`p30-field-cluster-4x-{day,night}.png`(512x256,4px/tile,田块+农舍+树丛)、
+ `p30-roadserp-4x-day.png`(512x256,4px/tile,主路逐点弯曲)。`check-no-white-blocks` 5 帧 0 命中。
+- **遗留**:散落农舍与乡村树丛为纯布局/美术层,不与 NPC 数值、任务系统耦合;默认
+ 64x48/256x192 判定不进入新分支、不消费额外 RNG,观感不变。
+
## 已验证命令
```bash
@@ -750,6 +779,14 @@ run_p29 4096 3072 1 /tmp/opencode/lv-p29/art/p29-night8
# 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
+# P30 512 证据出图(真实离屏渲染:整图 3072x2304 + 田块/主路局部 512x256@4px per tile)
+mkdir -p /tmp/opencode/lv-p30/art
+timeout 300 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 LV_MAPGEN_P30_SHOT=1 \
+ LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \
+ LV_RECORD_DIR=/tmp/opencode/lv-p30/art \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p30/art/*.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 \
@@ -841,6 +878,9 @@ python3 scripts/check-no-white-blocks.py evidence/night-run
- `/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)
+ `p29-variant-pair-ab-4x-{day,night}.png` 512x256 A/B 对照;源帧 `p29-day/`、`p29-night/`、
+ `p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git)
+- `/tmp/opencode/lv-p30/art/`(P30:`p30-map-512x384-seed4242-{day,night}.png` 3072x2304 整图、
+ `p30-field-cluster-4x-{day,night}.png` 512x256 田块+农舍+树丛 4px/tile、
+ `p30-roadserp-4x-day.png` 512x256 主路逐点弯曲 4px/tile;工作区 `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 d83a144..3ff2904 100644
--- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -36,6 +36,25 @@ type MapGenTests () =
let nx, ny = x + dx, y + dy
if nx >= 0 && nx < map.Width && ny >= 0 && ny < map.Height && map.Tiles.[ny * map.Width + nx] = int MapGen.GroundTile.Water then 1 else 0)
+ /// P30:把民居按「足迹邻近(两轴间隙 <=2 格)」聚成组团,返回各组规模。
+ let houseGroups (houses: MapGen.Building list) : int list =
+ let arr = houses |> List.sortBy (fun b -> b.Top, b.Left) |> Array.ofList
+ let n = arr.Length
+ let parent = Array.init n id
+ let rec find i = if parent.[i] = i then i else (parent.[i] <- find parent.[i]; parent.[i])
+ let union a b =
+ let ra = find a
+ let rb = find b
+ if ra <> rb then parent.[ra] <- rb
+ let near (a: MapGen.Building) (b: MapGen.Building) =
+ let xGap = max (a.Left - (b.Left + b.Width - 1)) (b.Left - (a.Left + a.Width - 1))
+ let yGap = max (a.Top - (b.Top + b.Height - 1)) (b.Top - (a.Top + a.Height - 1))
+ xGap <= 2 && yGap <= 2
+ for i in 0 .. n - 1 do
+ for j in i + 1 .. n - 1 do
+ if near arr.[i] arr.[j] then union i j
+ [ for i in 0 .. n - 1 -> find i ] |> List.groupBy id |> List.map (fun (_, group) -> group.Length)
+
[<TestMethod>]
member _.SameSeedGeneratesByteIdenticalTerrain () =
let a = MapGen.generateWithSize 64 48 (uint64 4242)
@@ -256,20 +275,20 @@ 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 锁定不变。
+ /// P28/P30:512 沿河民居增密并出现 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>(30, (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)
+ Assert.IsTrue(map.Buildings.Length >= 28, sprintf "P30 512 must place >=28 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)
+ Assert.IsTrue(map.Buildings.Length + map.Farmhouses.Length >= 32, "total houses (clusters + farmsteads) must be >=32")
[<TestMethod>]
member _.DefaultSizeHouseCountUnchanged () =
@@ -439,6 +458,89 @@ type MapGenTests () =
let c = MapGen.generateWithSize 512 384 (uint64 7)
Assert.AreNotEqual<string>(MapGen.serialize a, MapGen.serialize c)
+ // ---- P30:512 民居增密 + 组团规模 + 乡村空区填充 + 主路柔化 ----
+
+ [<TestMethod>]
+ member _.LargeMapGroupsAreTwoToFiveWithFewIsolated () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let groups = houseGroups map.Buildings
+ Assert.IsTrue(groups.Length > 0, "512 must form house groups")
+ let singletons = groups |> List.filter (fun n -> n < 2) |> List.length
+ Assert.IsTrue(singletons <= 2, sprintf "isolated single-house groups must be <=2, saw %d" singletons)
+ let inRange = groups |> List.filter (fun n -> n >= 2 && n <= 5) |> List.length
+ Assert.IsTrue(inRange * 2 >= groups.Length, "most groups must hold 2-5 houses")
+ // 另一 seed 同样不产孤立组,验证不是对 4242 的过拟合。
+ let other = houseGroups (MapGen.generateWithSize 512 384 (uint64 7)).Buildings
+ Assert.IsTrue((other |> List.filter (fun n -> n < 2) |> List.length) <= 2)
+
+ [<TestMethod>]
+ member _.LargeMapCountrysideFillsEmptyBandsWithMatureTiles () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot
+ let paddy = map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.PaddyField) |> Array.length
+ let veg = map.Tiles |> Array.filter (fun c -> c = int MapGen.GroundTile.VegetablePlot) |> Array.length
+ Assert.IsTrue(paddy > 0, "paddy fields must appear on 512")
+ Assert.IsTrue(veg > 0, "vegetable plots must appear on 512")
+ Assert.IsTrue(paddy + veg > 4000, sprintf "fields must cover a meaningful share, saw %d" (paddy + veg))
+ Assert.IsTrue(map.Groves.Length > 0, "tree groves must appear")
+ Assert.IsTrue(map.Farmhouses.Length > 0, "scattered farmsteads must appear")
+ // 上部/下部空带都要有田块,避免大面积纯草。
+ let topBand = [ for y in 0 .. 90 do for x in 0 .. map.Width - 1 -> map.Tiles.[y * map.Width + x] ] |> List.filter isField |> List.length
+ let bottomBand = [ for y in 296 .. map.Height - 1 do for x in 0 .. map.Width - 1 -> map.Tiles.[y * map.Width + x] ] |> List.filter isField |> List.length
+ Assert.IsTrue(topBand > 100, sprintf "top band must contain fields, saw %d" topBand)
+ Assert.IsTrue(bottomBand > 100, sprintf "bottom band must contain fields, saw %d" bottomBand)
+ // 默认图完全不进入乡村分支。
+ let small = MapGen.generateWithSize 256 192 (uint64 4242)
+ Assert.AreEqual<int>(0, small.Groves.Length)
+ Assert.AreEqual<int>(0, small.Farmhouses.Length)
+ Assert.AreEqual<int>(0, small.Tiles |> Array.filter isField |> Array.length)
+
+ [<TestMethod>]
+ member _.FieldsStayOnLandAwayFromWaterPathsAndCore () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let pathSet = System.Collections.Generic.HashSet<int>(map.Paths |> List.map (fun (x, y) -> y * map.Width + x))
+ let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot
+ for y in 0 .. map.Height - 1 do
+ for x in 0 .. map.Width - 1 do
+ let i = y * map.Width + x
+ if isField map.Tiles.[i] then
+ Assert.AreNotEqual<int>(int MapGen.GroundTile.Water, map.Tiles.[i])
+ Assert.IsFalse(pathSet.Contains i, sprintf "field (%d,%d) must not sit on a path" x y)
+ let inCore = x >= map.Core.MinX && x <= map.Core.MaxX && y >= map.Core.MinY && y <= map.Core.MaxY
+ Assert.IsFalse(inCore, sprintf "field (%d,%d) must stay outside the core" x y)
+
+ [<TestMethod>]
+ member _.LargeMapFarmsteadDoorsAreReachableAndDeterministic () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(map.ReachabilityOk, "512 reachability must hold with farmsteads")
+ let visited = MapGen.floodFill map (fst map.Spawns.Head) (snd map.Spawns.Head)
+ for farmhouse in map.Farmhouses do
+ Assert.IsTrue(MapGen.isWalkable map farmhouse.DoorX farmhouse.DoorY, sprintf "farmstead door (%d,%d) must be walkable" farmhouse.DoorX farmhouse.DoorY)
+ Assert.IsTrue(visited.[farmhouse.DoorY * map.Width + farmhouse.DoorX], sprintf "farmstead door (%d,%d) unreachable" farmhouse.DoorX farmhouse.DoorY)
+ let other = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(map.Farmhouses = other.Farmhouses)
+ Assert.IsTrue(map.Groves = other.Groves)
+ Assert.IsTrue(map.Tiles = other.Tiles)
+ Assert.AreEqual<string>(MapGen.serialize map, MapGen.serialize other)
+
+ [<TestMethod>]
+ member _.LargeMapMainRoadIsDeterministicSerpentine () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let other = MapGen.generateWithSize 512 384 (uint64 4242)
+ // 同 seed 两次逐字节一致(含主路中心线)。
+ Assert.IsTrue(map.MainRoadCenter = other.MainRoadCenter, "road centerline must be byte-identical across runs")
+ Assert.AreEqual<string>(MapGen.serialize map, MapGen.serialize other)
+ // 逐点微扰:相邻列步长 <=1,且相对中段基准确有 >=2 格弯曲。
+ map.MainRoadCenter
+ |> Array.pairwise
+ |> Array.iter (fun (a, b) -> Assert.IsTrue(abs (b - a) <= 1, sprintf "road step must be <=1, saw %d -> %d" a b))
+ let baseY = map.MainRoadCenter.[map.Width / 2]
+ Assert.IsTrue(map.MainRoadCenter |> Array.exists (fun y -> abs (y - baseY) >= 2), "main road must bend by >=2 tiles")
+ // 默认图主街仍笔直(不进入新分支)。
+ let small = MapGen.generateWithSize 256 192 (uint64 4242)
+ let smallBase = small.MainRoadCenter.[small.Width / 2]
+ Assert.IsTrue(small.MainRoadCenter |> Array.forall (fun y -> y = smallBase), "default map road must stay straight")
+
[<TestMethod>]
member _.VisibleRangeClampsAtLegacyMapEdges () =
let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 5f6e99e..3a9a004 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -307,6 +307,8 @@ type LivingVillageGame() as this =
| 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"
+ // P30 证据钩子:LV_MAPGEN_P30_SHOT=1 时按 P30 规格出图(整图 day/night + 田块局部 + 路网弯曲局部)。
+ let mapGenP30Shot = Environment.GetEnvironmentVariable("LV_MAPGEN_P30_SHOT") = "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
@@ -972,9 +974,132 @@ type LivingVillageGame() as this =
printfn "mapgen-shot=done"
this.Exit()
+ /// P30 证据:把地图任意 tile 矩形以 cell px/tile 真实离屏渲染,含 tint 与可选 veil/夜光。
+ member private this.RenderMapRegion (tileX0: int) (tileY0: int) (tilesW: int) (tilesH: int) (cell: int) (tint: Color) (veilTick: int64 option) : int * Texture2D =
+ let targetWidth = max 1 (tilesW * cell)
+ let targetHeight = max 1 (tilesH * cell)
+ use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight)
+ this.GraphicsDevice.SetRenderTarget target
+ this.GraphicsDevice.Clear(Color(18, 26, 20))
+ spriteBatch.Begin()
+ let glowScale =
+ match veilTick with
+ | Some tick -> (M6Presentation.profileAtTick tick).LanternGlow
+ | None -> 0.0f
+ let drawn =
+ VillageArt.drawMapRegion spriteBatch artTextures pixel tileX0 tileY0 tilesW tilesH cell mapGenResult tint glowScale
+ 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)
+ target.GetData<Color>(data)
+ let texture = new Texture2D(this.GraphicsDevice, targetWidth, targetHeight)
+ texture.SetData(data)
+ (drawn, texture)
+
+ /// P30 田块局部取景:以「窗口内田块最密」的散落农舍为中心(含田块/树丛/院落),
+ /// 优先完整落入画面的农舍;否则退化为首个田块。确定性纯函数。
+ member private this.P30FieldRegion () : int * int =
+ let w = mapGenResult.Width
+ let h = mapGenResult.Height
+ let regionW = 128
+ let regionH = 64
+ let clampX x = max 0 (min (max 0 (w - regionW)) x)
+ let clampY y = max 0 (min (max 0 (h - regionH)) y)
+ let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot
+ let countFields (cx: int) (cy: int) =
+ let x0 = max 0 (cx - regionW / 2)
+ let x1 = min (w - 1) (cx + regionW / 2)
+ let y0 = max 0 (cy - regionH / 2)
+ let y1 = min (h - 1) (cy + regionH / 2)
+ let mutable n = 0
+ for y in y0 .. y1 do
+ for x in x0 .. x1 do
+ if isField mapGenResult.Tiles.[y * w + x] then n <- n + 1
+ n
+ let interior =
+ mapGenResult.Farmhouses
+ |> List.filter (fun b -> b.Top >= 16 && b.Top <= h - 80 && b.Left >= regionW / 2 && b.Left <= w - regionW / 2)
+ let candidates = if interior.IsEmpty then mapGenResult.Farmhouses else interior
+ match candidates |> List.sortBy (fun b -> (-(countFields (b.Left + b.Width / 2) (b.Top + 1)), b.Top, b.Left)) |> List.tryHead with
+ | Some b -> (clampX (b.Left + b.Width / 2 - regionW / 2), clampY (b.Top - regionH / 2))
+ | None ->
+ let mutable found = None
+ let mutable y = 0
+ while found.IsNone && y < h do
+ let mutable x = 0
+ while found.IsNone && x < w do
+ let code = mapGenResult.Tiles.[y * w + x]
+ if isField code then found <- Some(x, y)
+ x <- x + 1
+ y <- y + 1
+ match found with
+ | Some (x, y) -> (clampX (x - regionW / 2), clampY (y - regionH / 2))
+ | None -> (clampX (w / 2 - regionW / 2), clampY (h / 2 - regionH / 2))
+
+ /// P30 路网弯曲局部取景:以主路中段某个弯折列为横向中心,纵向对准主路中心线。
+ member private this.P30RoadRegion () : int * int =
+ let w = mapGenResult.Width
+ let h = mapGenResult.Height
+ let regionW = 128
+ let regionH = 64
+ let clampX x = max 0 (min (max 0 (w - regionW)) x)
+ let clampY y = max 0 (min (max 0 (h - regionH)) y)
+ let center = mapGenResult.MainRoadCenter
+ let roadY = if w > 0 then center.[w / 2] else h / 2
+ let bendX =
+ [ for x in 1 .. w - 2 do
+ if abs (center.[x] - center.[x - 1]) > 0 then yield x ]
+ |> List.tryFind (fun x -> x > w / 4 && x < (3 * w) / 4)
+ |> Option.defaultValue (w / 2)
+ (clampX (bendX - regionW / 2), clampY (roadY - regionH / 2))
+
+ /// P30 证据出图:整图 3072x2304(6px/tile)day/night + 田块局部 512x256(4px/tile)
+ /// + 路网弯曲局部 512x256(4px/tile)。全部真实离屏渲染,拍完即退出。
+ member private this.DrawP30Shot () =
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ let save (name: string) (texture: Texture2D) =
+ use stream = System.IO.File.Create(sprintf "%s/%s.png" recordDirectory name)
+ texture.SaveAsPng(stream, texture.Width, texture.Height)
+ let nightTick = M6Presentation.resolveStartTick (Some 23.5) false
+ let nightTint = (M6Presentation.profileAtTick nightTick).WorldTint
+ let fullW = mapGenResult.Width * 6
+ let fullH = mapGenResult.Height * 6
+ let _, dayTexture = this.RenderMapGen fullW fullH 0 0 true Color.White None
+ use _day = dayTexture
+ save (sprintf "p30-map-%dx%d-seed%d-day" mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) dayTexture
+ let _, nightTexture = this.RenderMapGen fullW fullH 0 0 true nightTint (Some nightTick)
+ use _night = nightTexture
+ save (sprintf "p30-map-%dx%d-seed%d-night" mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) nightTexture
+ let fx, fy = this.P30FieldRegion ()
+ let _, fieldDay = this.RenderMapRegion fx fy 128 64 4 Color.White None
+ use _fieldDay = fieldDay
+ save "p30-field-cluster-4x-day" fieldDay
+ let _, fieldNight = this.RenderMapRegion fx fy 128 64 4 nightTint (Some nightTick)
+ use _fieldNight = fieldNight
+ save "p30-field-cluster-4x-night" fieldNight
+ let rx, ry = this.P30RoadRegion ()
+ let _, roadDay = this.RenderMapRegion rx ry 128 64 4 Color.White None
+ use _roadDay = roadDay
+ save "p30-roadserp-4x-day" roadDay
+ printfn "p30-shot=done map=%dx%d field_region=(%d,%d) road_region=(%d,%d) night=%b" mapGenResult.Width mapGenResult.Height fx fy rx ry mapGenNight
+ this.Exit()
+
override this.Draw(gameTime: GameTime) =
if mapGenShotMode then
- this.DrawMapGenShot()
+ if mapGenP30Shot then
+ this.DrawP30Shot()
+ else
+ this.DrawMapGenShot()
else
if splashActive then
this.DrawSplash()
diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
index a9d6c9f..abd4a7e 100644
--- a/src/LivingVillage.Desktop/MapGen.fs
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -10,11 +10,14 @@ open LivingVillage.Kernel
module MapGen =
/// 地面码,与既有 ProceduralMap / 渲染映射保持一致。
+ /// P30:大图乡村空区新增 4=水田 / 5=菜畦(成熟 tile 素材;非水故仍可通行)。
type GroundTile =
| Grass = 0
| Water = 1
| Stone = 2
| Peat = 3
+ | PaddyField = 4
+ | VegetablePlot = 5
type Bounds =
{ MinX: int
@@ -67,6 +70,17 @@ module MapGen =
X: int
Y: int }
+ /// P30 树丛种类:竹丛 / 灌木丛;纯视觉层(不写瓦片、不影响通行),仅大图乡村空区生成。
+ type GroveKind =
+ | BambooClump
+ | ShrubClump
+
+ /// P30 树丛:单格树/灌木,成丛由若干相邻格组成;渲染走既有 Bamboo/Shrub 成熟素材。
+ type Grove =
+ { Kind: GroveKind
+ X: int
+ Y: int }
+
type Result =
{ Width: int
Height: int
@@ -79,6 +93,12 @@ module MapGen =
Paths: (int * int) list
Buildings: Building list
Decorations: RiverDecoration list
+ /// P30 散落农舍:与沿河组团民居分列,避免污染组团规模统计;门贴草地/院落路可达。
+ Farmhouses: Building list
+ /// P30 乡村树丛:纯视觉,不入瓦片、不影响通行与可达性。
+ Groves: Grove list
+ /// P30 主路中心线(逐列 y):默认图为笔直 roadY;大图为确定性缓弯(相邻列步长 <=1)。
+ MainRoadCenter: int array
ReachableTiles: int
ReachabilityOk: bool
BridgeCrossingsOk: bool }
@@ -103,6 +123,8 @@ module MapGen =
/// P24:仅对大于 256x192 的图追加沿河民居(泊松散布),保证既有 checksum 不变。
/// P28:大图沿河民居由 10 增至 16(+6 座,宽度 2/3/4 变化),默认图仍为 0、不消耗 RNG。
/// P29:大图额外启用聚落组团发牌种子(独立 clusterRng),默认图保持 0、不进入组团分支。
+ /// P30:大图沿河/沿路民居目标由 16 增至 30(组团 2-5 座为主),并新增乡村空区填充与主路弯曲,
+ /// 全部只在 large 分支执行;64x48/256x192 判定不进入、不消费任何额外 RNG。
let paramsForSize (width: int) (height: int) (seed: uint64) : Params =
let base_ = defaultParams seed
let large = width > 256 || height > 192
@@ -110,7 +132,7 @@ module MapGen =
Width = width
Height = height
RiverCount = max base_.RiverCount (max 1 (height / 96))
- ExtraRiversideHouses = (if large then 16 else 0)
+ ExtraRiversideHouses = (if large then 30 else 0)
DecorativeStones = not large
RiverDecorations = large
ClusterSeed = (if large then seed ^^^ 0xC1A57E2UL else 0UL) }
@@ -254,6 +276,8 @@ module MapGen =
let mutable bridges: (int * int) list = []
let mutable paths: (int * int) list = []
let mutable buildings: Building list = []
+ let mutable farmhouses: Building list = []
+ let mutable groves: Grove list = []
let pathSet = System.Collections.Generic.HashSet<int>()
let addPath x y =
if inMap x y && tiles.[idx x y] <> int GroundTile.Water then
@@ -264,8 +288,29 @@ module MapGen =
structureRng <- nextState
lo + int (raw % uint64 (max 1 (hi - lo + 1)))
let roadY = (core.MinY + core.MaxY) / 2
+ // P30 主路柔化:仅大图把主街按逐点确定性微扰成缓弯(曲率 ±3 格、相邻列步长 <=1,
+ // 遇弯补一格保证 4 连通);默认图仍为笔直主街,故 64x48/256x192 输出逐字节不变。
+ let largeMap = p.ExtraRiversideHouses > 0
+ let roadCenter =
+ let arr = Array.create w roadY
+ if largeMap then
+ let roadSeed = p.Seed ^^^ 0x5E9B0AUL
+ let mutable prev = roadY
+ for x in 1 .. w - 2 do
+ let noise = valueNoise (float32 x) 0.0f roadSeed 130.0f
+ let target = roadY + int (System.Math.Round(3.0 * float (noise - 0.5f) * 2.0))
+ let y = max (prev - 1) (min (prev + 1) target)
+ arr.[x] <- y
+ prev <- y
+ // 两边界补与首/末列相接,避免边缘断头。
+ arr.[0] <- arr.[1]
+ arr.[w - 1] <- arr.[w - 2]
+ arr
+ let roadYAt (x: int) = roadCenter.[max 0 (min (w - 1) x)]
for x in 1 .. w - 2 do
- addPath x roadY
+ if x > 1 && roadYAt x <> roadYAt (x - 1) then
+ addPath x (roadYAt (x - 1))
+ addPath x (roadYAt x)
let bridgeColumns =
[ for river in riverBands do
let mutable picked: int list = []
@@ -284,8 +329,9 @@ module MapGen =
let yy = river.CenterY + dy
if inMap (bx + dx) yy then
bridges <- (bx + dx, yy) :: bridges
- let yLo = min (river.CenterY - 1) roadY
- let yHi = max (river.CenterY + river.Width) roadY
+ let roadYb = roadYAt bx
+ let yLo = min (river.CenterY - 1) roadYb
+ let yHi = max (river.CenterY + river.Width) roadYb
for y in yLo .. yHi do
addPath bx y
let farY = if river.CenterY < core.MinY then river.CenterY - 2 else river.CenterY + river.Width + 1
@@ -311,12 +357,12 @@ module MapGen =
buildings <- building :: buildings
addPath building.DoorX building.DoorY
- // 5b) P24/P28/P29:大图沿河聚落组团。仅在 ExtraRiversideHouses>0 时执行,只落在核心区外
+ // 5b) P24/P28/P29/P30:大图沿河聚落组团。仅在 ExtraRiversideHouses>0 时执行,只落在核心区外
// 草地并避免与既有民居重叠,故 64x48/256x192 输出逐字节不变。
- // P29:不改「沿路随机散点」,而是沿横向石板路(主街 + 河岸路)成排成组落位:
- // 每组 2-4 座、组内 1 格间隙、组间留 4-6 格空档,形成「沿河成排 / 桥头小广场」的
- // 聚落肌理;组发牌使用独立 clusterRng(group seeding),不触碰既有 structureRng,
- // 桥/路/灯笼等基础结构坐标不受影响。宽度仍按 2/3/4 加权(附属小筑/民居/大宅)。
+ // P29:沿横向石板路(主街 + 河岸路)成排成组落位,组发牌用独立 clusterRng(group seeding),
+ // 不触碰既有 structureRng,桥/路/灯笼等基础结构坐标不受影响。宽度按 2/3/4 加权。
+ // P30:目标密度由 16 增至 30,组团规模改为 2-5 座;含基础岸屋的路段优先布组,
+ // 使每座基础民居并入 >=2 座的组团,消除大面积孤立单屋。
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
@@ -331,6 +377,13 @@ module MapGen =
left < b.Left + b.Width && b.Left < left + width
&& top < b.Top + b.Height && b.Top < top + 2)
// 横向路段的连续格(主街/河岸路),先按 (y,x) 排序再按 group seed 打散成确定性布线顺序。
+ // P30:含既有岸屋的路段优先处理,保证每座基础民居都能被并进 2-5 座的组团。
+ let runHasBase (runY: int) (x0: int) (x1: int) =
+ buildings
+ |> List.exists (fun b ->
+ (b.Top = runY - 2 || b.Top = runY + 1)
+ && b.Left <= x1
+ && b.Left + b.Width - 1 >= x0)
let horizontalRuns =
[ for y in 1 .. h - 2 do
let mutable x = 1
@@ -342,51 +395,230 @@ module MapGen =
if x - x0 >= 4 then yield (y, x0, x - 1)
else
x <- x + 1 ]
- |> List.sortBy (fun (y, x0, _) -> (hash2 y x0 p.ClusterSeed))
+ |> List.sortBy (fun (y, x0, x1) -> ((if runHasBase y x0 x1 then 0 else 1), hash2 y x0 p.ClusterSeed))
let mutable clusterRng = p.ClusterSeed
let nextCluster () =
let nextState, raw = splitmix clusterRng
clusterRng <- nextState
raw
let mutable extraPlaced = 0
- 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)
- // 默认屋在路北(门贴路南缘);北侧容不下时改南侧(沿用既有岸屋「门在顶排」口径)。
+ // 在一条横向路段的 [lo,hi] 区间内铺一个 2-5 座组团:门贴路缘、组内 1 格间隙、
+ // 宽度按 2/3/4 加权。先做局部试排,只有落满 >=2 座、或能与既有民居并组时才提交,
+ // 从源头消除「孤立单屋」。返回实际落位数。
+ let nearFootprint (a: Building) (b: Building) =
+ let xGap = max (a.Left - (b.Left + b.Width - 1)) (b.Left - (a.Left + a.Width - 1))
+ let yGap = max (a.Top - (b.Top + b.Height - 1)) (b.Top - (a.Top + a.Height - 1))
+ xGap <= 2 && yGap <= 2
+ let placeGroup (runY: int) (lo: int) (hi: int) (desired: int) : int =
+ if hi - lo + 1 < 4 then
+ 0
+ else
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)
+ [ lo .. min hi (lo + 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)
+ let mutable x = max lo (min (hi - 1) (lo + int (nextCluster () % 3UL)))
+ let tentative = ResizeArray<Building>()
+ let mutable guard = 0
+ while tentative.Count < desired && x <= hi - 1 && guard < 48 do
+ 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
+ let clashes =
+ tentative
+ |> Seq.exists (fun b ->
+ left < b.Left + b.Width && b.Left < left + buildingWidth
+ && top < b.Top + b.Height && b.Top < top + 2)
+ if doorX <= hi && footprintFree left top buildingWidth && not (overlaps left top buildingWidth) && not clashes then
+ tentative.Add
+ { Left = left
+ Top = top
+ Width = buildingWidth
+ Height = 2
+ DoorX = doorX
+ DoorY = doorY }
+ x <- x + buildingWidth + 1
+ else
+ x <- x + 1
+ guard <- guard + 1
+ let connected (t: Building) =
+ (buildings |> List.exists (fun b -> nearFootprint b t))
+ || (tentative |> Seq.exists (fun o -> not (obj.ReferenceEquals(o, t)) && nearFootprint o t))
+ let keep = tentative |> Seq.filter connected |> Seq.toList
+ for building in keep do
+ buildings <- building :: buildings
+ addPath building.DoorX building.DoorY
+ keep.Length
+ // P30 基础岸屋配对:为每座尚孤立的基础民居在同行贴 1 格补一座,保证并入 >=2 座组团。
+ let baseBuildings = buildings
+ let sameBuilding (a: Building) (c: Building) =
+ a.Left = c.Left && a.Top = c.Top && a.Width = c.Width && a.DoorX = c.DoorX && a.DoorY = c.DoorY
+ for b in baseBuildings do
+ let isolated =
+ buildings
+ |> List.forall (fun o -> sameBuilding o b || not (nearFootprint o b))
+ if isolated then
+ let widthRoll = int (nextCluster () % 10UL)
+ let buildingWidth = if widthRoll < 5 then 3 elif widthRoll < 8 then 2 else 4
+ let candidates = [ b.Left + b.Width + 1; b.Left - 1 - buildingWidth ]
+ let mutable done_ = false
+ for left in candidates do
+ if not done_
+ && left >= 1
+ && left + buildingWidth - 1 <= w - 2
+ && footprintFree left b.Top buildingWidth
+ && not (overlaps left b.Top buildingWidth) then
+ let doorX = left + buildingWidth / 2
+ buildings <-
+ { Left = left
+ Top = b.Top
+ Width = buildingWidth
+ Height = 2
+ DoorX = doorX
+ DoorY = b.DoorY }
+ :: buildings
+ addPath doorX b.DoorY
+ done_ <- true
+ for (runY, runStart, runEnd) in horizontalRuns do
+ if extraPlaced < p.ExtraRiversideHouses then
+ let runLength = runEnd - runStart + 1
+ // 长路段容纳多个组团,短河岸路各一个。
+ let groupsForRun = max 1 (min 3 (runLength / 24))
+ for group in 0 .. groupsForRun - 1 do
+ if extraPlaced < p.ExtraRiversideHouses then
+ let lo = runStart + (group * runLength) / groupsForRun
+ let hi = runStart + ((group + 1) * runLength) / groupsForRun - 1
+ let desired = min (p.ExtraRiversideHouses - extraPlaced) (2 + int (nextCluster () % 4UL))
+ let placed = placeGroup runY lo hi desired
+ extraPlaced <- extraPlaced + placed
+
+ // 5d) P30 乡村空区填充:上部/下部空带按确定性网格铺田块(水田/菜畦走成熟 tile
+ // 素材,写入 4/5 号非水瓦片,仍可通行)、树丛(纯视觉)与少量散落农舍(门贴院落路)。
+ // 仅在 large 分支执行:默认图不进入、不消费任何额外 RNG;田块离水面 >=3 格,
+ // 避开路网/核心/民居,故不影响河道连续性与 BFS 可达性。
+ if p.ExtraRiversideHouses > 0 then
+ let mutable countrysideRng = p.ClusterSeed ^^^ 0xF1E1D5EEDUL
+ let nextCountry () =
+ let nextState, raw = splitmix countrysideRng
+ countrysideRng <- nextState
+ raw
+ // 水面垂直距离:河流整幅横贯,故只需按 y 计算;边界列留 99(不会被选中)。
+ let waterDistance = Array.create (w * h) 99
+ for river in riverBands do
+ for x in 1 .. w - 2 do
+ for y in 0 .. h - 1 do
+ let d =
+ if y < river.CenterY then river.CenterY - y
+ elif y > river.CenterY + river.Width - 1 then y - (river.CenterY + river.Width - 1)
+ else 0
+ let i = idx x y
+ if d < waterDistance.[i] then waterDistance.[i] <- d
+ let structureBlocked = Array.create (w * h) false
+ for y in core.MinY .. core.MaxY do
+ for x in core.MinX .. core.MaxX do
+ structureBlocked.[idx x y] <- true
+ for (px, py) in paths do
+ structureBlocked.[idx px py] <- true
+ let markBuilding (b: Building) =
+ for yy in b.Top .. b.Top + b.Height - 1 do
+ for xx in b.Left .. b.Left + b.Width - 1 do
+ if inMap xx yy then structureBlocked.[idx xx yy] <- true
+ for b in buildings do
+ markBuilding b
+ let freeGround (x: int) (y: int) (minWater: int) =
+ inMap x y
+ && tiles.[idx x y] = int GroundTile.Grass
+ && waterDistance.[idx x y] >= minWater
+ && not structureBlocked.[idx x y]
+ let cellW = 18
+ let cellH = 14
+ let tryField (cellX: int) (cellY: int) (roll: uint64) =
+ let fw = 6 + int ((roll >>> 8) % 9UL) // 6..14
+ let fh = 4 + int ((roll >>> 16) % 7UL) // 4..10
+ let maxOffX = max 0 (cellW - 4 - fw)
+ let maxOffY = max 0 (cellH - 4 - fh)
+ let left = cellX + 2 + int ((roll >>> 24) % uint64 (maxOffX + 1))
+ let top = cellY + 2 + int ((roll >>> 32) % uint64 (maxOffY + 1))
+ let isPaddy = (roll >>> 40) % 2UL = 0UL
+ let free =
+ left >= 1
+ && top >= 1
+ && left + fw - 1 <= w - 2
+ && top + fh - 1 <= h - 2
+ && [ for dy in 0 .. fh - 1 do
+ for dx in 0 .. fw - 1 -> (left + dx, top + dy) ]
+ |> List.forall (fun (x, y) -> freeGround x y 3)
+ if free then
+ let code = if isPaddy then int GroundTile.PaddyField else int GroundTile.VegetablePlot
+ for dy in 0 .. fh - 1 do
+ for dx in 0 .. fw - 1 do
+ tiles.[idx (left + dx) (top + dy)] <- code
+ true
+ else
+ false
+ let tryGrove (cellX: int) (cellY: int) (roll: uint64) =
+ let gw = 2 + int ((roll >>> 8) % 3UL) // 2..4
+ let gh = 1 + int ((roll >>> 12) % 2UL) // 1..2
+ let left = cellX + 2 + int ((roll >>> 16) % uint64 (max 1 (cellW - 4 - gw + 1)))
+ let top = cellY + 2 + int ((roll >>> 24) % uint64 (max 1 (cellH - 4 - gh + 1)))
+ let kind = if (roll >>> 32) % 2UL = 0UL then BambooClump else ShrubClump
+ let cells = [ for dy in 0 .. gh - 1 do for dx in 0 .. gw - 1 -> (left + dx, top + dy) ]
+ if cells |> List.forall (fun (x, y) -> freeGround x y 2) then
+ groves <- (cells |> List.map (fun (x, y) -> { Kind = kind; X = x; Y = y })) @ groves
+ true
+ else
+ false
+ let tryFarmhouse (cellX: int) (cellY: int) (roll: uint64) =
+ let fw = 2 + int ((roll >>> 8) % 2UL) // 2..3
+ let left = cellX + 2 + int ((roll >>> 12) % uint64 (max 1 (cellW - 4 - fw + 1)))
+ let top = cellY + 2 + int ((roll >>> 20) % uint64 (max 1 (cellH - 4 - 2 + 1)))
+ let doorX = left + fw / 2
+ let doorY = top + 1
+ let free =
+ left >= 1
+ && top >= 1
+ && left + fw - 1 <= w - 2
+ && top + 1 <= h - 2
+ && [ for dy in 0 .. 1 do
+ for dx in 0 .. fw - 1 -> (left + dx, top + dy) ]
+ |> List.forall (fun (x, y) -> freeGround x y 3)
+ if free then
+ farmhouses <-
+ { Left = left
+ Top = top
+ Width = fw
+ Height = 2
+ DoorX = doorX
+ DoorY = doorY }
+ :: farmhouses
+ addPath doorX doorY
+ // 屋后小菜畦 2 行(留在本网格内,避免与邻格重叠)。
+ let gardenFree =
+ top + 3 <= h - 2
+ && [ for dy in 0 .. 1 do
+ for dx in 0 .. fw - 1 -> (left + dx, top + 2 + dy) ]
+ |> List.forall (fun (x, y) -> freeGround x y 3)
+ if gardenFree then
+ for dy in 0 .. 1 do
+ for dx in 0 .. fw - 1 do
+ tiles.[idx (left + dx) (top + 2 + dy)] <- int GroundTile.VegetablePlot
+ true
+ else
+ false
+ let mutable cellY = 0
+ while cellY < h - 2 do
+ let mutable cellX = 0
+ while cellX < w - 2 do
+ let roll = nextCountry ()
+ let bucket = int (roll % 100UL)
+ if bucket < 58 then tryField cellX cellY roll |> ignore
+ elif bucket < 82 then tryGrove cellX cellY roll |> ignore
+ elif bucket < 84 then tryFarmhouse cellX cellY roll |> ignore
+ cellX <- cellX + cellW
+ cellY <- cellY + cellH
// 5c) P26:河岸装饰(护岸石垒/芦苇/垂柳),仅大图启用。纯视觉层,不写入任何瓦片,
// 故 64x48/256x192 的 tiles checksum 与默认世界完全不受影响;装饰只落在紧邻水面的
@@ -510,7 +742,7 @@ module MapGen =
let visited = flood (fst spawns.Head) (snd spawns.Head)
let spawnsOk = spawns |> List.forall (fun (x, y) -> visited.[idx x y])
- let doorsOk = buildings |> List.forall (fun b -> visited.[idx b.DoorX b.DoorY])
+ let doorsOk = (buildings @ farmhouses) |> List.forall (fun b -> visited.[idx b.DoorX b.DoorY])
let bridgesOk = bridges |> List.forall (fun (x, y) -> visited.[idx x y])
let crossingsOk =
bridgeColumns
@@ -528,6 +760,9 @@ module MapGen =
Paths = paths
Buildings = buildings
Decorations = decorations
+ Farmhouses = farmhouses
+ Groves = groves
+ MainRoadCenter = roadCenter
ReachableTiles = reachableTiles
ReachabilityOk = spawnsOk && doorsOk && bridgesOk
BridgeCrossingsOk = crossingsOk }
@@ -567,6 +802,25 @@ module MapGen =
.Append(decorCode decoration.Kind).Append(':')
.Append(decoration.X).Append(':').Append(decoration.Y).Append(';')
|> ignore
+ builder.Append('|') |> ignore
+ for building in map.Farmhouses do
+ builder
+ .Append(building.Left).Append(':').Append(building.Top).Append(':')
+ .Append(building.Width).Append(':').Append(building.Height).Append(':')
+ .Append(building.DoorX).Append(':').Append(building.DoorY).Append(';')
+ |> ignore
+ builder.Append('|') |> ignore
+ let groveCode = function
+ | BambooClump -> 0
+ | ShrubClump -> 1
+ for grove in map.Groves do
+ builder
+ .Append(groveCode grove.Kind).Append(':')
+ .Append(grove.X).Append(':').Append(grove.Y).Append(';')
+ |> ignore
+ builder.Append('|') |> ignore
+ for y in map.MainRoadCenter do
+ builder.Append(y).Append(',') |> ignore
builder.ToString()
/// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 3e52546..4faa886 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -656,7 +656,7 @@ module VillageArt =
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 drawHouse (building: MapGen.Building) =
let left = building.Left
let right = building.Left + building.Width - 1
match houseFacadeVariant building with
@@ -690,6 +690,11 @@ module VillageArt =
let wallSprite =
if x = building.DoorX then CourtyardDoorSprite else WallLatticeSprite
drawCell wallSprite x building.DoorY
+ for building in map.Buildings do
+ drawHouse building
+ // P30 散落农舍与沿河民居共用同一套立面纯函数。
+ for farmhouse in map.Farmhouses do
+ drawHouse farmhouse
// P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。
for decoration in map.Decorations do
match decoration.Kind with
@@ -698,6 +703,11 @@ module VillageArt =
| MapGen.RiverDecorationKind.Willow ->
drawCell WillowTopSprite decoration.X decoration.Y
drawCell WillowBaseSprite decoration.X (decoration.Y + 1)
+ // P30 乡村树丛:竹丛 / 灌木丛(纯视觉,不写瓦片、不影响通行)。
+ for grove in map.Groves do
+ match grove.Kind with
+ | MapGen.BambooClump -> drawCell BambooSprite grove.X grove.Y
+ | MapGen.ShrubClump -> drawCell ShrubSprite grove.X grove.Y
/// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。
let bridgeLanternTiles (map: MapGen.Result) : (int * int) list =
@@ -710,10 +720,11 @@ module VillageArt =
/// P29 夜景灯光源瓦片:桥头灯笼 + 民居门窗暖光(确定性纯函数,便于单测)。
/// 每户至少门格发光;变体 A 再取第一扇横向窗,变体 B(整面格栅窗)与门同格故不另加。
+ /// P30:散落农舍的门窗暖光一并计入。
let nightLightTiles (map: MapGen.Result) : (int * int) list =
let lanterns = bridgeLanternTiles map
let houseLights =
- map.Buildings
+ (map.Buildings @ map.Farmhouses)
|> List.collect (fun building ->
let windowTiles =
if houseFacadeVariant building = 0 then
@@ -772,6 +783,9 @@ module VillageArt =
match code with
| 1 -> draw position (waterSpriteAt position)
| 2 -> draw position StonePathSprite
+ // P30 乡村空区:水田(4)走静态水面素材、菜畦(5)走菜地素材;均为成熟 tile。
+ | 4 -> draw position WaterSprite
+ | 5 -> draw position GardenSprite
| _ -> draw position GrassSprite
let drawBaseTile (position: TilePosition) =
@@ -907,6 +921,9 @@ module VillageArt =
match code with
| 1 -> WaterSprite
| 2 -> StonePathSprite
+ // P30 乡村空区:水田走静态水面、菜畦走菜地素材。
+ | 4 -> WaterSprite
+ | 5 -> GardenSprite
| _ -> GrassSprite
for y in 0 .. map.Height - 1 do
for x in 0 .. map.Width - 1 do
@@ -942,6 +959,8 @@ module VillageArt =
| 1 -> WaterSpriteB
| _ -> WaterSpriteC
| 2 -> StonePathSprite
+ | 4 -> WaterSprite
+ | 5 -> GardenSprite
| _ -> GrassSprite
let mutable drawn = 0
for y in y0 .. y1 do
@@ -954,6 +973,42 @@ module VillageArt =
drawNightGlows spriteBatch pixel map (4 * Sim.tilePixels) (int (150.0f * glowScale)) tileX tileY Sim.tilePixels
drawn
+ /// P30 证据用:把地图的任意 tile 矩形 [tileX0,tileY0,tilesW,tilesH] 以 cell px/tile 真实
+ /// 离屏渲染到 target(含民居/田块/树丛/桥/河岸装饰与可选夜光),返回绘制瓦片数。
+ /// 与 drawMapFitted/drawMapViewport 共用 drawMapStructures / drawNightGlows 纯函数。
+ let drawMapRegion
+ (spriteBatch: SpriteBatch)
+ (textures: ArtTextures)
+ (pixel: Texture2D)
+ (tileX0: int)
+ (tileY0: int)
+ (tilesW: int)
+ (tilesH: int)
+ (cell: int)
+ (map: MapGen.Result)
+ (tint: XnaColor)
+ (glowScale: float32) : int =
+ let tileX x = (x - tileX0) * cell
+ let tileY y = (y - tileY0) * cell
+ let spriteOf code =
+ match code with
+ | 1 -> WaterSprite
+ | 2 -> StonePathSprite
+ | 4 -> WaterSprite
+ | 5 -> GardenSprite
+ | _ -> GrassSprite
+ let mutable drawn = 0
+ for y in tileY0 .. tileY0 + tilesH - 1 do
+ for x in tileX0 .. tileX0 + tilesW - 1 do
+ if x >= 0 && x < map.Width && y >= 0 && y < map.Height then
+ let code = map.Tiles.[y * map.Width + x]
+ spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle (spriteOf code), tint)
+ drawn <- drawn + 1
+ drawMapStructures spriteBatch textures pixel tileX tileY cell map
+ if glowScale > 0.0f then
+ drawNightGlows spriteBatch pixel map (4 * cell) (int (150.0f * glowScale)) tileX tileY cell
+ drawn
+
let drawCharacter
(spriteBatch: SpriteBatch)
(textures: ArtTextures)