summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 02:43:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 02:43:39 +0800
commitd3c0ef17cb1c4d30c1d586e2a5b616f58bd3d875 (patch)
tree19d93967c15548f80a83d7bf6617b826fb5cb5e7
parentef78374c316587fb585b72e6d427cd6e61203655 (diff)
downloadliving-village-d3c0ef17cb1c4d30c1d586e2a5b616f58bd3d875.tar.gz
feat(mapgen): 河道连续性不变量 + 桥/石板路网/沿河民居(P20 第二步)p20-map-gen-foundation
- 河道改为贯穿全图连续水带(每列一段、宽度 >= RiverWidth),置于核心区上下 两侧且不相交;新增不变量测试:逐列水段数恒定、每段宽度下限、任意水格 >=2 个水邻居(禁止断裂缺口与孤立水点)。 - Result 新增 Rivers/Bridges/Paths/Buildings/BridgeCrossingsOk;每河 2 座桥、 横贯主路 + 垂直连接路 + 远岸路 + 白墙黑瓦民居(门朝路)。 - 公开 isWalkable/floodFill;ReachabilityOk 现覆盖 30 spawn + 所有门 + 所有桥面, BridgeCrossingsOk 保证每桥两岸同连通域。 - 回归:Desktop 115->119、Kernel 90 不变;final_digest 与基线一致。
-rw-r--r--docs/维护说明.md27
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs76
-rw-r--r--src/LivingVillage.Desktop/Game.fs50
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs186
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs40
5 files changed, 312 insertions, 67 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 2c8f406..0123fa3 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -269,6 +269,27 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
3 次一致、`performance_determinism=PASS`。
+## P20 第二步:河道连续性不变量 + 桥 / 石板路网 / 沿河民居
+
+- 河道重做:由「`not (inCore)` 逐格随机游走」改为**贯穿全图的东西向连续水带**(`Rivers : River list`,
+ 每条每列恰好一段、宽度恒 `>= max 3 RiverWidth`),置于核心区上下两侧且与核心不相交。
+ 新增不变量测试 `RiversAreContinuousWithoutGapsOrIsolatedTiles`:逐列水段数 == `Rivers.Length` 且每段
+ `>= RiverWidth`、任意水格 4 邻接水邻居 `>= 2`(禁止断裂缺口与孤立水点)。
+- 交通结构(`Result` 新增 `Bridges`/`Paths`/`Buildings`/`BridgeCrossingsOk`):
+ 每条河 2 座桥(2 格宽,桥面仍记水但 `isWalkable` 放行);主石板路沿核心中心行横贯全图,
+ 每座桥有垂直连接路贯通两岸与远岸路;远岸路两侧布置民居矩形(`Building`:白墙黑瓦两行色块,门朝路)。
+ `MapGen.isWalkable`/`MapGen.floodFill` 公开供测试:`ReachabilityOk` 现在同时要求 30 spawn、所有门、
+ 所有桥面可达;`BridgeCrossingsOk` 要求每座桥两岸均被同一连通域覆盖。
+- 回归新增 4 条:河道连续性、桥/路/民居存在且沿河、桥两岸可达(spawn/门/桥全连通)、结构逐字节确定性。
+ Desktop **115 -> 119**,Kernel **90** 不变。
+- 渲染证据(`drawMapFitted` 每瓦片 6px 放大 + `drawMapViewport` 1:1 + 桥中心 detail 帧);
+ `p20b-map-*`(整图,含桥/路/白墙黑瓦民居)、`p20b-detail-*`(1:1 桥/路/民居近景)、
+ `p20b-culled-*`。白块回归 0 命中;整图 1536x1152。
+- 实测(Xvfb,本机):256x192 生成 ≈ **63 ms**、64x48 ≈ **2.0 ms**;`reachable_ok=true`、
+ `reachable_tiles=47652`(= 49152 - 两河水面 1524 + 桥面 24,三 seed 恒定,印证连续性不变量);
+ 裁剪帧 1050/49152 = 2.1%,1:1 单帧 ≈ 0.7 ms(implied ≈1300-1600 fps)。
+- 门槛同上:clean rebuild 0/0;`final_digest` 3 次一致、`performance_determinism=PASS`。
+
## 已验证命令
```bash
@@ -359,8 +380,10 @@ python3 scripts/check-no-white-blocks.py evidence/night-run
- `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、
`smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、
`night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`)
-- `/tmp/opencode/lv-p20-art/`(P20:`mapgen-256x192-seed{4242,7,99}.png` 整图、
- `mapgen-culled-256x192-seed{4242,7,99}.png` 裁剪帧)
+- `/tmp/opencode/lv-p20-art/`(P20:第一步 `mapgen-256x192-seed{4242,7,99}.png` 整图、
+ `mapgen-culled-256x192-seed{4242,7,99}.png` 裁剪帧;第二步 `p20b-map-256x192-seed{4242,7,99}.png`
+ 整图(含桥/路/沿河民居)、`p20b-detail-256x192-seed{4242,7,99}.png` 桥/路/民居 1:1 近景、
+ `p20b-culled-256x192-seed{...}.png` 裁剪帧)
- `/tmp/opencode/lv-p19-art/`(P19:`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、
`p19-night-00h30-01.png`、`p19-before-noon-yellow.png`/`p19-after-noon-neutral.png`;同一份拷入 `evidence/`)
- `/tmp/opencode/lv-p18-art/`(P18:`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
index 4454b33..b0b58e7 100644
--- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -18,6 +18,24 @@ type MapGenTests () =
let checksum (map: MapGen.Result) : int64 =
map.Tiles |> Array.mapi (fun i v -> int64 i * int64 (v + 1)) |> Array.sum
+ let waterRunsInColumn (map: MapGen.Result) (x: int) : int list =
+ let runs = ResizeArray<int>()
+ let mutable run = 0
+ for y in 0 .. map.Height - 1 do
+ if map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water then
+ run <- run + 1
+ elif run > 0 then
+ runs.Add run
+ run <- 0
+ if run > 0 then runs.Add run
+ List.ofSeq runs
+
+ let waterNeighbourCount (map: MapGen.Result) (x: int) (y: int) : int =
+ [ (1, 0); (-1, 0); (0, 1); (0, -1) ]
+ |> List.sumBy (fun (dx, dy) ->
+ 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)
+
[<TestMethod>]
member _.SameSeedGeneratesByteIdenticalTerrain () =
let a = MapGen.generateWithSize 64 48 (uint64 4242)
@@ -71,6 +89,64 @@ type MapGenTests () =
Assert.AreEqual<int>(1280 / Sim.tilePixels + 1, ox1)
Assert.AreEqual<int>(720 / Sim.tilePixels + 1, oy1)
+ // ---- P20 第二步:河道连续性 + 桥/路/建筑 + 交通可达 ----
+
+ [<TestMethod>]
+ member _.RiversAreContinuousWithoutGapsOrIsolatedTiles () =
+ let map = MapGen.generateWithSize 256 192 (uint64 4242)
+ Assert.IsTrue(map.Rivers.Length >= 1, "at least one river band")
+ let minWidth = map.Rivers |> List.map (fun river -> river.Width) |> List.min
+ Assert.IsTrue(minWidth >= 3, "river width lower bound")
+ for x in 1 .. map.Width - 2 do
+ let runs = waterRunsInColumn map x
+ if runs.Length <> map.Rivers.Length then
+ Assert.Fail(sprintf "column %d must hold exactly %d contiguous runs, saw %d" x map.Rivers.Length runs.Length)
+ for run in runs do
+ Assert.IsTrue(run >= minWidth, sprintf "column %d has a run of %d < min width %d" x run minWidth)
+ for y in 0 .. map.Height - 1 do
+ for x in 0 .. map.Width - 1 do
+ if map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water then
+ Assert.IsTrue(waterNeighbourCount map x y >= 2, sprintf "water tile (%d,%d) is isolated" x y)
+
+ [<TestMethod>]
+ member _.StructuresContainBridgesRoadsAndBuildingsAlongRivers () =
+ let map = MapGen.generateWithSize 256 192 (uint64 4242)
+ Assert.IsTrue(map.Bridges.Length >= map.Rivers.Length, "at least one bridge per river")
+ Assert.IsTrue(map.Paths.Length > 0, "stone road network exists")
+ Assert.IsTrue(map.Buildings.Length >= 4, "at least four riverside building blocks")
+ for building in map.Buildings do
+ Assert.IsTrue(MapGen.isWalkable map building.DoorX building.DoorY, sprintf "door (%d,%d) must be walkable" building.DoorX building.DoorY)
+ let nearWater =
+ [ for y in max 0 (building.Top - 12) .. min (map.Height - 1) (building.Top + building.Height + 12) do
+ for x in max 0 (building.Left - 12) .. min (map.Width - 1) (building.Left + building.Width + 12) ->
+ map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water ]
+ |> List.exists id
+ Assert.IsTrue(nearWater, "building block must sit along a river")
+
+ [<TestMethod>]
+ member _.BridgesKeepBothRiverBanksReachableFromSpawns () =
+ let map = MapGen.generateWithSize 256 192 (uint64 4242)
+ let startX, startY = map.Spawns.Head
+ let visited = MapGen.floodFill map startX startY
+ for (x, y) in map.Spawns do
+ Assert.IsTrue(visited.[y * map.Width + x], sprintf "spawn (%d,%d) unreachable" x y)
+ for building in map.Buildings do
+ Assert.IsTrue(visited.[building.DoorY * map.Width + building.DoorX], "building door unreachable via bridge/road")
+ for (x, y) in map.Bridges do
+ Assert.IsTrue(visited.[y * map.Width + x], sprintf "bridge tile (%d,%d) unreachable" x y)
+ Assert.IsTrue(map.BridgeCrossingsOk, "each bridge must connect both river banks")
+
+ [<TestMethod>]
+ member _.StructuresAreDeterministicAcrossRuns () =
+ let a = MapGen.generateWithSize 256 192 (uint64 4242)
+ let b = MapGen.generateWithSize 256 192 (uint64 4242)
+ Assert.IsTrue(a.Rivers = b.Rivers)
+ Assert.IsTrue(a.Bridges = b.Bridges)
+ Assert.IsTrue(a.Paths = b.Paths)
+ Assert.IsTrue(a.Buildings = b.Buildings)
+ let c = MapGen.generateWithSize 256 192 (uint64 7)
+ Assert.IsTrue(a.Buildings <> c.Buildings || a.Bridges <> c.Bridges, "different seeds must relocate structures")
+
[<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 64dc3e2..5d2ba54 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -690,48 +690,58 @@ type LivingVillageGame() as this =
printfn "menu-shot=done frames=%d" menuShotFrame
this.Exit()
- /// P20 证据:把一次 MapGen 绘制渲染到离屏 target 并落盘,返回绘制瓦片数。
- member private this.RenderMapGen (drawFitted: bool) : int * Texture2D =
- let viewport = this.GraphicsDevice.Viewport
- use target = new RenderTarget2D(this.GraphicsDevice, viewport.Width, viewport.Height)
+ /// P20 证据:把一次 MapGen 绘制渲染到指定尺寸离屏 target,返回绘制瓦片数与纹理。
+ member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) : int * Texture2D =
+ use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight)
this.GraphicsDevice.SetRenderTarget target
this.GraphicsDevice.Clear(Color(18, 26, 20))
- let camX = max 0 ((mapGenResult.Width * Sim.tilePixels - viewport.Width) / 2)
- let camY = max 0 ((mapGenResult.Height * Sim.tilePixels - viewport.Height) / 2)
spriteBatch.Begin()
let drawn =
if drawFitted then
- VillageArt.drawMapFitted spriteBatch artTextures viewport.Width viewport.Height mapGenResult Color.White
+ VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult Color.White
else
- VillageArt.drawMapViewport spriteBatch artTextures (Vector2(float32 camX, float32 camY)) viewport.Width viewport.Height mapGenResult 0L Color.White
+ VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L Color.White
spriteBatch.End()
this.GraphicsDevice.SetRenderTarget null
- let data = Array.zeroCreate<Color> (viewport.Width * viewport.Height)
+ let data = Array.zeroCreate<Color> (targetWidth * targetHeight)
target.GetData<Color>(data)
- let texture = new Texture2D(this.GraphicsDevice, viewport.Width, viewport.Height)
+ let texture = new Texture2D(this.GraphicsDevice, targetWidth, targetHeight)
texture.SetData(data)
(drawn, texture)
- /// P20 证据:整图等比渲染 + 1:1 视口裁剪帧各存一张,打印绘制计数后退出。
+ /// P20 证据:整图放大渲染(每瓦片 6px)+ 1:1 视口裁剪帧各存一张,打印计数后退出。
member private this.DrawMapGenShot () =
System.IO.Directory.CreateDirectory recordDirectory |> ignore
let viewport = this.GraphicsDevice.Viewport
- let fittedDrawn, fittedTexture = this.RenderMapGen true
+ let mapCell = 6
+ let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true
use _fitted = fittedTexture
- use stream = System.IO.File.Create(sprintf "%s/mapgen-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed))
- fittedTexture.SaveAsPng(stream, viewport.Width, viewport.Height)
- printfn "mapgen-fitted drawn=%d viewport=%dx%d" fittedDrawn viewport.Width viewport.Height
+ use stream = System.IO.File.Create(sprintf "%s/p20b-map-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed))
+ fittedTexture.SaveAsPng(stream, fittedTexture.Width, fittedTexture.Height)
+ printfn "mapgen-fitted drawn=%d png=%dx%d" fittedDrawn fittedTexture.Width fittedTexture.Height
- let camX = max 0 ((mapGenResult.Width * Sim.tilePixels - viewport.Width) / 2)
- let camY = max 0 ((mapGenResult.Height * Sim.tilePixels - viewport.Height) / 2)
- let culledDrawn, culledTexture = this.RenderMapGen false
+ let clampRange maximum value = max 0 (min (max 0 maximum) value)
+ let camX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) ((mapGenResult.Width * Sim.tilePixels - viewport.Width) / 2)
+ let camY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) ((mapGenResult.Height * Sim.tilePixels - viewport.Height) / 2)
+ let culledDrawn, culledTexture = this.RenderMapGen viewport.Width viewport.Height camX camY false
use _culled = culledTexture
- use culledStream = System.IO.File.Create(sprintf "%s/mapgen-culled-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed))
+ use culledStream = System.IO.File.Create(sprintf "%s/p20b-culled-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed))
culledTexture.SaveAsPng(culledStream, viewport.Width, viewport.Height)
let total = mapGenResult.Width * mapGenResult.Height
let visible = MapGen.visibleTileCount mapGenResult.Width mapGenResult.Height camX camY viewport.Width viewport.Height
printfn "mapgen-cull drawn=%d visible=%d total=%d culled=%.1f%%" culledDrawn visible total (100.0 * (1.0 - float culledDrawn / float total))
+ match mapGenResult.Bridges with
+ | (bx, by) :: _ ->
+ let detailCamX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) (bx * Sim.tilePixels - viewport.Width / 2)
+ let detailCamY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) (by * Sim.tilePixels - viewport.Height / 2)
+ let detailDrawn, detailTexture = this.RenderMapGen viewport.Width viewport.Height detailCamX detailCamY false
+ use _detail = detailTexture
+ use detailStream = System.IO.File.Create(sprintf "%s/p20b-detail-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed))
+ detailTexture.SaveAsPng(detailStream, detailTexture.Width, detailTexture.Height)
+ printfn "mapgen-detail drawn=%d bridge=(%d,%d) camera=(%d,%d)" detailDrawn bx by detailCamX detailCamY
+ | [] -> ()
+
let smallWatch = System.Diagnostics.Stopwatch.StartNew()
let smallMap = MapGen.generateWithSize 64 48 (uint64 4242)
smallWatch.Stop()
@@ -742,7 +752,7 @@ type LivingVillageGame() as this =
this.GraphicsDevice.SetRenderTarget frameTarget
for _ in 1 .. 30 do
spriteBatch.Begin()
- VillageArt.drawMapViewport spriteBatch artTextures (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 |> ignore
spriteBatch.End()
this.GraphicsDevice.SetRenderTarget null
frameWatch.Stop()
diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
index 4f59f84..c59e6bd 100644
--- a/src/LivingVillage.Desktop/MapGen.fs
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -32,6 +32,20 @@ module MapGen =
SpawnColumns: int
SpawnRows: int }
+ /// P20 第二步:一条贯穿全图的东西向河道(每列恰好一段,宽度下限 RiverWidth)。
+ type River =
+ { CenterY: int
+ Width: int }
+
+ /// 沿河民居色块:白墙黑瓦矩形屋顶,门开向石板路。
+ type Building =
+ { Left: int
+ Top: int
+ Width: int
+ Height: int
+ DoorX: int
+ DoorY: int }
+
type Result =
{ Width: int
Height: int
@@ -39,8 +53,13 @@ module MapGen =
Tiles: int array
Core: Bounds
Spawns: (int * int) list
+ Rivers: River list
+ Bridges: (int * int) list
+ Paths: (int * int) list
+ Buildings: Building list
ReachableTiles: int
- ReachabilityOk: bool }
+ ReachabilityOk: bool
+ BridgeCrossingsOk: bool }
/// 默认仍是现状游戏尺寸 64x48,核心 14 格、5x6=30 个 spawn。
let defaultParams (seed: uint64) : Params =
@@ -125,21 +144,22 @@ module MapGen =
if multiOctave widthScale x y p.Seed < 0.18f && not (inCore x y) then
tiles.[idx x y] <- int GroundTile.Peat
- // 2) 河道:RiverCount 条自西向东随机游走,宽度 RiverWidth。
- let mutable rng = p.Seed ^^^ 0xBEEFUL
- for _ in 1 .. max 0 p.RiverCount do
- let nextState, rawStartY = splitmix rng
- rng <- nextState
- let mutable y = 2 + int (rawStartY % uint64 (max 1 (h - 4)))
- for x in 2 .. w - 3 do
- let nextStep, rawStep = splitmix rng
- rng <- nextStep
- let drift = int (rawStep % 3UL) - 1
- y <- max 2 (min (h - 3) (y + drift))
- for dy in 0 .. p.RiverWidth - 1 do
- let yy = y + dy
- if yy <= h - 2 && not (inCore x yy) then
- tiles.[idx x yy] <- int GroundTile.Water
+ // 2) 河道:连续贯穿全图(每列恰好一段),置于核心区上下两侧,宽度恒 >= RiverWidth。
+ let riverWidth = max 3 p.RiverWidth
+ let riverBands =
+ [ for i in 0 .. max 0 p.RiverCount - 1 do
+ if i % 2 = 0 then
+ let centerY = max 1 (min (h - 1 - riverWidth) (min (core.MinY - riverWidth - 2) (h / 4)))
+ if centerY + riverWidth - 1 < core.MinY then
+ yield { CenterY = centerY; Width = riverWidth }
+ else
+ let centerY = max 1 (min (h - 1 - riverWidth) (max (core.MaxY + 2) (3 * h / 4)))
+ if centerY > core.MaxY then
+ yield { CenterY = centerY; Width = riverWidth } ]
+ for river in riverBands do
+ for x in 1 .. w - 2 do
+ for dy in 0 .. river.Width - 1 do
+ tiles.[idx x (river.CenterY + dy)] <- int GroundTile.Water
// 3) 核心活动区整平为草地(生成器保证可通行)。
for y in core.MinY .. core.MaxY do
@@ -151,7 +171,7 @@ module MapGen =
let minSq = minDist * minDist
let attempts = max 100 ((w * h) / 16)
let mutable placed: (int * int) list = []
- let mutable scatterRng = rng
+ let mutable scatterRng = p.Seed ^^^ 0xC0FFEEUL
for _ in 1 .. attempts do
let nextState, raw = splitmix scatterRng
scatterRng <- nextState
@@ -167,7 +187,70 @@ module MapGen =
tiles.[idx x y] <- int GroundTile.Stone
placed <- (x, y) :: placed
- // 5) 30 个 spawn:核心区内的规则网格。
+ // 5) 交通:桥 + 石板路网 + 沿河民居(白墙黑瓦矩形色块,门朝远岸路)。
+ let mutable structureRng = p.Seed ^^^ 0x51AB1EUL
+ let inMap x y = x >= 0 && x < w && y >= 0 && y < h
+ let mutable bridges: (int * int) list = []
+ let mutable paths: (int * int) list = []
+ let mutable buildings: Building list = []
+ let pathSet = System.Collections.Generic.HashSet<int>()
+ let addPath x y =
+ if inMap x y && tiles.[idx x y] <> int GroundTile.Water then
+ tiles.[idx x y] <- int GroundTile.Stone
+ if pathSet.Add(idx x y) then paths <- (x, y) :: paths
+ let nextBetween lo hi =
+ let nextState, raw = splitmix structureRng
+ structureRng <- nextState
+ lo + int (raw % uint64 (max 1 (hi - lo + 1)))
+ let roadY = (core.MinY + core.MaxY) / 2
+ for x in 1 .. w - 2 do
+ addPath x roadY
+ let bridgeColumns =
+ [ for river in riverBands do
+ let mutable picked: int list = []
+ for _ in 1 .. 2 do
+ let mutable bx = nextBetween 4 (max 4 (w - 5))
+ let mutable guard = 0
+ while List.contains bx picked && guard < 8 do
+ bx <- nextBetween 4 (max 4 (w - 5))
+ guard <- guard + 1
+ picked <- bx :: picked
+ yield (river, bx) ]
+ for river, bx in bridgeColumns do
+ for dx in 0 .. 1 do
+ if bx + dx <= w - 2 then
+ for dy in 0 .. river.Width - 1 do
+ 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
+ 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
+ for x in max 1 (bx - 7) .. min (w - 2) (bx + 7) do
+ addPath x farY
+ let buildingWidth = 3 + nextBetween 0 1
+ let doorX = min (w - 3) (max 2 (bx + 3))
+ let building =
+ if river.CenterY < core.MinY then
+ { Left = max 1 (doorX - buildingWidth / 2)
+ Top = max 1 (farY - 2)
+ Width = buildingWidth
+ Height = 2
+ DoorX = doorX
+ DoorY = max 1 (farY - 1) }
+ else
+ { Left = max 1 (doorX - buildingWidth / 2)
+ Top = farY + 1
+ Width = buildingWidth
+ Height = 2
+ DoorX = doorX
+ DoorY = farY + 1 }
+ buildings <- building :: buildings
+ addPath building.DoorX building.DoorY
+
+ // 6) 30 个 spawn:核心区内的规则网格。
let sc = max 2 p.SpawnColumns
let sr = max 2 p.SpawnRows
let spanX = max 1 (core.MaxX - core.MinX - 2)
@@ -179,8 +262,9 @@ module MapGen =
(core.MinX + 1 + (c * spanX) / (sc - 1),
core.MinY + 1 + (r * spanY) / (sr - 1)) ]
- // 6) BFS(4 邻接、非水可走):从核心中心洪泛,校验全部 spawn 可达。
- let walkable x y = tiles.[idx x y] <> int GroundTile.Water
+ // 7) BFS(4 邻接、桥面可走):校验 spawn / 门 / 桥与两岸全部连通。
+ let bridgeSet = System.Collections.Generic.HashSet<int>(bridges |> List.map (fun (x, y) -> idx x y))
+ let walkable x y = tiles.[idx x y] <> int GroundTile.Water || bridgeSet.Contains(idx x y)
let flood startX startY =
let visited = Array.create (w * h) false
let queue = System.Collections.Generic.Queue<int>()
@@ -202,25 +286,14 @@ module MapGen =
queue.Enqueue ni
visited
- let carve (sx: int) (sy: int) =
- let mutable x = sx
- while x <> cx do
- x <- x + sign (cx - x)
- if tiles.[idx x sy] = int GroundTile.Water then tiles.[idx x sy] <- int GroundTile.Grass
- let mutable y = sy
- while y <> cy do
- y <- y + sign (cy - y)
- if tiles.[idx x y] = int GroundTile.Water then tiles.[idx x y] <- int GroundTile.Grass
-
- let mutable visited = flood cx cy
- let unreachable = spawns |> List.filter (fun (x, y) -> not visited.[idx x y])
- for spawn in unreachable do
- carve (fst spawn) (snd spawn)
- if not unreachable.IsEmpty then
- visited <- flood cx cy
-
+ 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 bridgesOk = bridges |> List.forall (fun (x, y) -> visited.[idx x y])
+ let crossingsOk =
+ bridgeColumns
+ |> List.forall (fun (river, bx) -> visited.[idx bx (river.CenterY - 1)] && visited.[idx bx (river.CenterY + river.Width)])
let reachableTiles = visited |> Array.filter id |> Array.length
- let reachabilityOk = spawns |> List.forall (fun (x, y) -> visited.[idx x y])
{ Width = w
Height = h
@@ -228,12 +301,47 @@ module MapGen =
Tiles = tiles
Core = core
Spawns = spawns
+ Rivers = riverBands
+ Bridges = bridges
+ Paths = paths
+ Buildings = buildings
ReachableTiles = reachableTiles
- ReachabilityOk = reachabilityOk }
+ ReachabilityOk = spawnsOk && doorsOk && bridgesOk
+ BridgeCrossingsOk = crossingsOk }
let generateWithSize (width: int) (height: int) (seed: uint64) : Result =
generate { defaultParams seed with Width = width; Height = height }
+ /// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。
+ let isWalkable (map: Result) (x: int) (y: int) : bool =
+ if x < 0 || x >= map.Width || y < 0 || y >= map.Height then
+ false
+ else
+ map.Tiles.[y * map.Width + x] <> int GroundTile.Water
+ || (map.Bridges |> List.exists (fun (bx, by) -> bx = x && by = y))
+
+ /// 从给定起点做 4 邻接洪泛(承认桥面),返回逐瓦片可达标记。
+ let floodFill (map: Result) (startX: int) (startY: int) : bool array =
+ let visited = Array.create (map.Width * map.Height) false
+ let queue = System.Collections.Generic.Queue<int>()
+ if isWalkable map startX startY then
+ let start = startY * map.Width + startX
+ visited.[start] <- true
+ queue.Enqueue start
+ while queue.Count > 0 do
+ let cur = queue.Dequeue()
+ let x = cur % map.Width
+ let y = cur / map.Width
+ for dx, dy in [ (1, 0); (-1, 0); (0, 1); (0, -1) ] do
+ let nx = x + dx
+ let ny = y + dy
+ if isWalkable map nx ny then
+ let ni = ny * map.Width + nx
+ if not visited.[ni] then
+ visited.[ni] <- true
+ queue.Enqueue ni
+ visited
+
// ---- 视口裁剪(纯函数):只绘制可见瓦片,与 drawWorld 现有窗口一致 ----
/// 返回含端点的可见瓦片范围 (x0, y0, x1, y1);空视口返回 (0,0,-1,-1)。
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 51e17e0..19c9aa6 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -724,9 +724,34 @@ module VillageArt =
/// P20 证据用:把整张 MapGen 地图等比缩放到视口内绘制,返回绘制的瓦片数。
/// 仅用于 `LV_MAPGEN_SHOT` 整图取证,不参与正常游戏循环。
+ 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)
+
+ /// 整图证据里叠加桥面与沿河民居(白墙黑瓦色块 + 门)。
+ let private drawMapStructures
+ (spriteBatch: SpriteBatch)
+ (textures: ArtTextures)
+ (pixel: Texture2D)
+ (tileX: int -> int)
+ (tileY: int -> int)
+ (cell: int)
+ (map: MapGen.Result) =
+ let bridgeSource = tileSourceRectangle BridgeSprite
+ 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
+ for row in building.Top .. building.Top + building.Height - 1 do
+ // 白墙朝向道路一行(门所在行),其余为黑瓦屋顶,两侧河岸朝向一致。
+ let color =
+ if row = building.DoorY then XnaColor(236, 232, 220)
+ else XnaColor(48, 52, 60)
+ fillRect spriteBatch pixel color (tileX building.Left) (tileY row) (building.Width * cell) (cell)
+ fillRect spriteBatch pixel (XnaColor(96, 60, 42)) (tileX building.DoorX) (tileY building.DoorY) cell cell
+
let drawMapFitted
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
+ (pixel: Texture2D)
(viewportWidth: int)
(viewportHeight: int)
(map: MapGen.Result)
@@ -737,6 +762,8 @@ module VillageArt =
let cell = max 1 (int (float32 Sim.tilePixels * scale))
let offsetX = (viewportWidth - map.Width * cell) / 2
let offsetY = (viewportHeight - map.Height * cell) / 2
+ let tileX x = offsetX + x * cell
+ let tileY y = offsetY + y * cell
let spriteOf code =
match code with
| 1 -> WaterSprite
@@ -746,13 +773,15 @@ module VillageArt =
for y in 0 .. map.Height - 1 do
for x in 0 .. map.Width - 1 do
let code = map.Tiles.[y * map.Width + x]
- spriteBatch.Draw(textures.TileAtlas, XnaRectangle(offsetX + x * cell, offsetY + y * cell, cell, cell), tileSourceRectangle (spriteOf code), tint)
+ spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle (spriteOf code), tint)
+ drawMapStructures spriteBatch textures pixel tileX tileY cell map
map.Width * map.Height
/// P20 证据用:按相机做视口裁剪,只绘制可见瓦片(1:1 像素),返回实际绘制数。
let drawMapViewport
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
+ (pixel: Texture2D)
(camera: XnaVector2)
(viewportWidth: int)
(viewportHeight: int)
@@ -761,6 +790,8 @@ module VillageArt =
(tint: XnaColor) : 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
+ let tileY y = y * Sim.tilePixels - int camera.Y
let spriteOf (x: int) (y: int) code =
match code with
| 1 ->
@@ -775,12 +806,9 @@ module VillageArt =
for y in y0 .. y1 do
for x in x0 .. x1 do
let code = map.Tiles.[y * map.Width + x]
- spriteBatch.Draw(
- textures.TileAtlas,
- XnaRectangle(x * Sim.tilePixels - int camera.X, y * Sim.tilePixels - int camera.Y, Sim.tilePixels, Sim.tilePixels),
- tileSourceRectangle (spriteOf x y code),
- tint)
+ 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
drawn
let drawCharacter