summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/维护说明.md32
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs81
-rw-r--r--src/LivingVillage.Desktop/Game.fs125
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs249
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs67
-rw-r--r--src/LivingVillage.Kernel.Tests/M6aTests.fs22
8 files changed, 559 insertions, 19 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index b22c6bf..2c8f406 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -245,6 +245,30 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
+## P20 第一步:参数化确定性生成器与视口裁剪(大地图扩程前置)
+
+- 新增纯函数模块 `src/LivingVillage.Desktop/MapGen.fs`:`generate : Params -> Result`,尺寸参数化
+ (默认仍是 64x48;可用 `generateWithSize 256 192 seed`)。组成:
+ splitmix64 随机游走河道(`RiverCount`/`RiverWidth`)、2-3 octave value noise 地形(草地/泥炭)、
+ 泊松式最小间距布点(石头装饰);核心活动区先整平为草地,再由 **BFS(4 邻接、非水可走)**
+ 从核心中心洪泛校验 5x6=30 个 spawn 全部可达,不可达则沿直线确定性修整并复验,结果写入 `ReachabilityOk`。
+ 纯函数、无时钟/随机源:同 seed 两次生成逐字节一致(`Array.mapi` 校验 + checksum)。
+- 视口裁剪纯函数 `MapGen.visibleTileRange`/`visibleTileCount`(与既有 `VillageArt.drawWorld` 的
+ `tx0..tx1/ty0..ty1` 窗口一致),`drawWorld` 已改为调用它,行为不变;`drawMapFitted`/`drawMapViewport`
+ 为证据专用绘制(整图等比 + 1:1 裁剪),不进正常游戏循环。
+- 存档兼容:沿用既有 v2/v3 的 `mapWidthTiles`/`mapHeightTiles` 边界字段(读旧档仍按 64x48),
+ P20 只补参数化边界测试,未改 Sim 数值语义;`final_digest` 与基线一致。
+- 回归:`MapGenTests`(同 seed byte-identical、异 seed 不同、256x192 30 spawn 全可达、默认尺寸 64x48、
+ 裁剪范围/端点钳制)6 条;Kernel `SaveRoundTripsParameterizedMapBounds`(256x192 v2 round-trip)1 条。
+ Desktop **109 -> 115**,Kernel **89 -> 90**。
+- 实测性能(Xvfb,本机):生成 256x192 = **52 ms**、64x48 = **1.9 ms**;256x192 生成
+ `reachable_ok=true`、`reachable_tiles≈47684`;裁剪后单帧仅绘制 **1050/49152 = 2.1%**
+ (`culled=97.9%`),离屏单帧 **0.62 ms/帧**(implied ≈1610 fps,仅 harness 立即模式)。
+- 证据:`/tmp/opencode/lv-p20-art/`(`mapgen-256x192-seed{4242,7,99}.png` 整图三 seed、
+ `mapgen-culled-256x192-seed{...}.png` 裁剪帧、harness 计数日志)。白块回归 0 命中。
+- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
+ 3 次一致、`performance_determinism=PASS`。
+
## 已验证命令
```bash
@@ -290,6 +314,12 @@ for pair in noon:12.0 dusk:20.75 night:0.5; do name=${pair%%:*}; hour=${pair##*:
LV_RECORD_DIR=/tmp/opencode/lv-p19-art/$name LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
+# P20 大地图生成器取证(整图 + 裁剪帧;每 seed 一拍即退,日志打印 gen_ms/drawn/culled)
+for seed in 4242 7 99; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \
+ LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=256 LV_MAPGEN_HEIGHT=192 LV_MAPGEN_SEED=$seed \
+ LV_RECORD_DIR=/tmp/opencode/lv-p20-art \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
+
# P18 黄昏暖色取证(20:45 真实帧;深夜把 20.75 换成 0)
timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_START_HOUR=20.75 LV_AUTOPLAY_RECORD=1 \
@@ -329,6 +359,8 @@ 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-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/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 60d9995..87e9e9d 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -11,6 +11,7 @@
<Compile Include="M6bTests.fs" />
<Compile Include="P16UiTests.fs" />
<Compile Include="PrototypeTests.fs" />
+ <Compile Include="MapGenTests.fs" />
<Compile Include="M3BatchOutputTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
new file mode 100644
index 0000000..4454b33
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -0,0 +1,81 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Kernel
+open LivingVillage.Desktop
+
+/// P20 第一步回归:确定性生成器 + 尺寸参数化 + 30 spawn 核心可达 + 视口裁剪。
+[<TestClass>]
+type MapGenTests () =
+
+ let tilesEqual (a: MapGen.Result) (b: MapGen.Result) (message: string) =
+ if a.Tiles.Length <> b.Tiles.Length then
+ Assert.Fail(sprintf "%s: tile count %d <> %d" message a.Tiles.Length b.Tiles.Length)
+ for i in 0 .. a.Tiles.Length - 1 do
+ if a.Tiles.[i] <> b.Tiles.[i] then
+ Assert.Fail(sprintf "%s: tile %d = %d, expected %d" message i a.Tiles.[i] b.Tiles.[i])
+
+ let checksum (map: MapGen.Result) : int64 =
+ map.Tiles |> Array.mapi (fun i v -> int64 i * int64 (v + 1)) |> Array.sum
+
+ [<TestMethod>]
+ member _.SameSeedGeneratesByteIdenticalTerrain () =
+ let a = MapGen.generateWithSize 64 48 (uint64 4242)
+ let b = MapGen.generateWithSize 64 48 (uint64 4242)
+ tilesEqual a b "64x48 same seed"
+ Assert.AreEqual<uint64>(a.Seed, b.Seed)
+ Assert.AreEqual<int64>(checksum a, checksum b)
+
+ let large1 = MapGen.generateWithSize 256 192 (uint64 7)
+ let large2 = MapGen.generateWithSize 256 192 (uint64 7)
+ tilesEqual large1 large2 "256x192 same seed"
+ Assert.AreEqual<int64>(checksum large1, checksum large2)
+
+ [<TestMethod>]
+ member _.DifferentSeedsProduceDifferentTerrain () =
+ let a = MapGen.generateWithSize 256 192 (uint64 1)
+ let b = MapGen.generateWithSize 256 192 (uint64 2)
+ Assert.AreNotEqual<int64>(checksum a, checksum b, "different seeds must differ")
+
+ [<TestMethod>]
+ member _.LargeMapThirtySpawnsAllReachCore () =
+ let map = MapGen.generateWithSize 256 192 (uint64 4242)
+ Assert.AreEqual<int>(256, map.Width)
+ Assert.AreEqual<int>(192, map.Height)
+ Assert.AreEqual<int>(30, map.Spawns.Length)
+ Assert.IsTrue(map.ReachabilityOk, "all 30 spawns must reach the core")
+ Assert.IsTrue(map.ReachableTiles > 30, "core region must be walkable")
+ for (x, y) in map.Spawns do
+ Assert.AreNotEqual<int>(int MapGen.GroundTile.Water, map.Tiles.[y * map.Width + x], sprintf "spawn (%d,%d) must be walkable" x y)
+
+ [<TestMethod>]
+ member _.DefaultSizeMatchesLegacyBounds () =
+ let map = MapGen.generate (MapGen.defaultParams (uint64 4242))
+ Assert.AreEqual<int>(64, map.Width)
+ Assert.AreEqual<int>(48, map.Height)
+ Assert.AreEqual<int>(64 * 48, map.Tiles.Length)
+ Assert.IsTrue(map.ReachabilityOk)
+ Assert.AreEqual<int>(30, map.Spawns.Length)
+
+ [<TestMethod>]
+ member _.VisibleRangeCullsLargeMapToViewport () =
+ let total = 256 * 192
+ let x0, y0, x1, y1 = MapGen.visibleTileRange 256 192 1000 1000 1280 720
+ let count = MapGen.visibleTileCount 256 192 1000 1000 1280 720
+ Assert.AreEqual<int>((x1 - x0 + 1) * (y1 - y0 + 1), count)
+ Assert.IsTrue(count < total, "viewport culling must draw strictly fewer tiles than the whole map")
+
+ let ox0, oy0, ox1, oy1 = MapGen.visibleTileRange 256 192 0 0 1280 720
+ Assert.AreEqual<int>(0, ox0)
+ Assert.AreEqual<int>(0, oy0)
+ Assert.AreEqual<int>(1280 / Sim.tilePixels + 1, ox1)
+ Assert.AreEqual<int>(720 / Sim.tilePixels + 1, oy1)
+
+ [<TestMethod>]
+ member _.VisibleRangeClampsAtLegacyMapEdges () =
+ let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536
+ Assert.AreEqual<int>(0, x0)
+ Assert.AreEqual<int>(0, y0)
+ Assert.AreEqual<int>(63, x1)
+ Assert.AreEqual<int>(47, y1)
+ Assert.AreEqual<int>(64 * 48, MapGen.visibleTileCount 64 48 0 0 2048 1536)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index e48888e..64dc3e2 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -188,11 +188,42 @@ type LivingVillageGame() as this =
[ (1, 1); (255, 191); (509, 381); (256, 60); (256, 300); (80, 200); (430, 300) ]
let mutable tourIndex = 0
let mutable tourFrame = 0
+ // P20 证据钩子:LV_MAPGEN_SHOT=1 时用参数化生成器出整图 + 视口裁剪帧,拍完即退出。
+ let mapGenShotMode = Environment.GetEnvironmentVariable("LV_MAPGEN_SHOT") = "1"
+ let mapGenWidth =
+ match Environment.GetEnvironmentVariable("LV_MAPGEN_WIDTH") with
+ | null | "" -> 256
+ | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 256
+ let mapGenHeight =
+ match Environment.GetEnvironmentVariable("LV_MAPGEN_HEIGHT") with
+ | null | "" -> 192
+ | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 192
+ let mapGenSeed =
+ match Environment.GetEnvironmentVariable("LV_MAPGEN_SEED") with
+ | null | "" -> 4242UL
+ | v -> match UInt64.TryParse v with (true, value) -> value | _ -> 4242UL
+ let mutable mapGenResult = Unchecked.defaultof<MapGen.Result>
+ let mutable mapGenGenMs = 0.0
do
if scale > 1 then
Sim.configureBounds (64 * scale) (48 * scale)
ProceduralMap.activate (uint64 4242)
+ if mapGenShotMode then
+ let stopwatch = System.Diagnostics.Stopwatch.StartNew()
+ mapGenResult <- MapGen.generateWithSize mapGenWidth mapGenHeight mapGenSeed
+ stopwatch.Stop()
+ mapGenGenMs <- stopwatch.Elapsed.TotalMilliseconds
+ printfn
+ "mapgen-gen size=%dx%d seed=%d gen_ms=%.2f tiles=%d reachable_ok=%b reachable_tiles=%d spawns=%d"
+ mapGenResult.Width
+ mapGenResult.Height
+ (int mapGenResult.Seed)
+ mapGenGenMs
+ mapGenResult.Tiles.Length
+ mapGenResult.ReachabilityOk
+ mapGenResult.ReachableTiles
+ mapGenResult.Spawns.Length
if mapTourMode then
tourWaypoints <-
[ (1, 1); (Sim.mapWidthTiles / 2, Sim.mapHeightTiles / 2); (Sim.mapWidthTiles - 3, Sim.mapHeightTiles - 3)
@@ -659,23 +690,87 @@ 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)
+ 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
+ else
+ VillageArt.drawMapViewport spriteBatch artTextures (Vector2(float32 camX, float32 camY)) viewport.Width viewport.Height mapGenResult 0L Color.White
+ spriteBatch.End()
+ this.GraphicsDevice.SetRenderTarget null
+ let data = Array.zeroCreate<Color> (viewport.Width * viewport.Height)
+ target.GetData<Color>(data)
+ let texture = new Texture2D(this.GraphicsDevice, viewport.Width, viewport.Height)
+ texture.SetData(data)
+ (drawn, texture)
+
+ /// P20 证据:整图等比渲染 + 1:1 视口裁剪帧各存一张,打印绘制计数后退出。
+ member private this.DrawMapGenShot () =
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ let viewport = this.GraphicsDevice.Viewport
+ let fittedDrawn, fittedTexture = this.RenderMapGen 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
+
+ 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
+ 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))
+ 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))
+
+ let smallWatch = System.Diagnostics.Stopwatch.StartNew()
+ let smallMap = MapGen.generateWithSize 64 48 (uint64 4242)
+ smallWatch.Stop()
+ printfn "mapgen-gen-legacy size=%dx%d gen_ms=%.2f tiles=%d reachable_ok=%b" smallMap.Width smallMap.Height smallWatch.Elapsed.TotalMilliseconds smallMap.Tiles.Length smallMap.ReachabilityOk
+
+ let frameWatch = System.Diagnostics.Stopwatch.StartNew()
+ use frameTarget = new RenderTarget2D(this.GraphicsDevice, viewport.Width, viewport.Height)
+ 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
+ spriteBatch.End()
+ this.GraphicsDevice.SetRenderTarget null
+ frameWatch.Stop()
+ let perFrameMs = frameWatch.Elapsed.TotalMilliseconds / 30.0
+ printfn "mapgen-frame avg_ms=%.2f implied_fps=%.1f iterations=30" perFrameMs (1000.0 / perFrameMs)
+ printfn "mapgen-shot=done"
+ this.Exit()
+
override this.Draw(gameTime: GameTime) =
- if splashActive then
- this.DrawSplash()
- elif menu.Page <> Playing then
- this.DrawMenu()
- elif relationView then
- this.DrawRelationView()
- this.DrawM5Overlay()
+ if mapGenShotMode then
+ this.DrawMapGenShot()
else
- this.DrawWorldView()
- if recordMode && sampleMode then
- recordIndex <- recordIndex + 1
- if recordIndex % recordEvery = 0 then
- System.IO.Directory.CreateDirectory recordDirectory |> ignore
- this.SaveBackBuffer(sprintf "%s/rec-frame-%04d.png" recordDirectory recordIndex)
- if menuShotMode then
- this.CaptureMenuShot()
+ if splashActive then
+ this.DrawSplash()
+ elif menu.Page <> Playing then
+ this.DrawMenu()
+ elif relationView then
+ this.DrawRelationView()
+ this.DrawM5Overlay()
+ else
+ this.DrawWorldView()
+ if recordMode && sampleMode then
+ recordIndex <- recordIndex + 1
+ if recordIndex % recordEvery = 0 then
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/rec-frame-%04d.png" recordDirectory recordIndex)
+ if menuShotMode then
+ this.CaptureMenuShot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index d57036b..739b750 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -9,6 +9,7 @@
<Compile Include="VillagePresentation.fs" />
<Compile Include="WorldBootstrap.fs" />
<Compile Include="ProceduralMap.fs" />
+ <Compile Include="MapGen.fs" />
<Compile Include="VillageArt.fs" />
<Compile Include="ChineseText.fs" />
<Compile Include="CjkGlyphAtlas.fs" />
diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
new file mode 100644
index 0000000..4f59f84
--- /dev/null
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -0,0 +1,249 @@
+namespace LivingVillage.Desktop
+
+open LivingVillage.Kernel
+
+/// P20 第一步:确定性大地图生成器的参数化纯函数片。
+/// 单一入口 `generate`:seed + 尺寸 -> 瓦片网格(0 grass / 1 water / 2 stone / 3 peat)。
+/// 组成:splitmix64 随机游走河道 + 2-3 octave value noise 地形 + 泊松式布点装饰,
+/// 核心活动区先整平为草地,再用 BFS 校验 30 个 spawn 全部可达核心(不可达则确定性修整)。
+/// 全部为纯函数:同参两次生成逐字节一致,不含时钟/随机源。
+module MapGen =
+
+ /// 地面码,与既有 ProceduralMap / 渲染映射保持一致。
+ type GroundTile =
+ | Grass = 0
+ | Water = 1
+ | Stone = 2
+ | Peat = 3
+
+ type Bounds =
+ { MinX: int
+ MinY: int
+ MaxX: int
+ MaxY: int }
+
+ type Params =
+ { Width: int
+ Height: int
+ Seed: uint64
+ RiverCount: int
+ RiverWidth: int
+ CoreSide: int
+ SpawnColumns: int
+ SpawnRows: int }
+
+ type Result =
+ { Width: int
+ Height: int
+ Seed: uint64
+ Tiles: int array
+ Core: Bounds
+ Spawns: (int * int) list
+ ReachableTiles: int
+ ReachabilityOk: bool }
+
+ /// 默认仍是现状游戏尺寸 64x48,核心 14 格、5x6=30 个 spawn。
+ let defaultParams (seed: uint64) : Params =
+ { Width = 64
+ Height = 48
+ Seed = seed
+ RiverCount = 2
+ RiverWidth = 3
+ CoreSide = 14
+ SpawnColumns = 5
+ SpawnRows = 6 }
+
+ // ---- splitmix64 与 value noise(无外部依赖、无时钟) ----
+
+ let private splitmix (state: uint64) : uint64 * uint64 =
+ let nextState = state + 0x9E3779B97F4A7C15UL
+ let mutable z = nextState
+ z <- (z ^^^ (z >>> 30)) * 0xBF58476D1CE4E5B9UL
+ z <- z ^^^ (z >>> 27)
+ z <- z * 0x94D049BB133111EBUL
+ z <- z ^^^ (z >>> 31)
+ (nextState, z)
+
+ let private hash2 (x: int) (y: int) (seed: uint64) : uint64 =
+ let base_ = seed ^^^ ((uint64 x * 0x4D3B9UL) + (uint64 y * 0x1D5UL))
+ snd (splitmix base_)
+
+ let private valueNoise (fx: float32) (fy: float32) (seed: uint64) (scale: float32) : float32 =
+ let xf = fx / scale
+ let yf = fy / scale
+ let x0 = int (floor xf)
+ let y0 = int (floor yf)
+ let tx = xf - float32 x0
+ let ty = yf - float32 y0
+ let smooth (t: float32) = t * t * (3.0f - 2.0f * t)
+ let sx = smooth tx
+ let sy = smooth ty
+ let v00 = float32 (hash2 x0 y0 seed % 1000UL) / 1000.0f
+ let v10 = float32 (hash2 (x0 + 1) y0 seed % 1000UL) / 1000.0f
+ let v01 = float32 (hash2 x0 (y0 + 1) seed % 1000UL) / 1000.0f
+ let v11 = float32 (hash2 (x0 + 1) (y0 + 1) seed % 1000UL) / 1000.0f
+ (v00 * (1.0f - sx) + v10 * sx) * (1.0f - sy) + (v01 * (1.0f - sx) + v11 * sx) * sy
+
+ /// 2-3 octave 叠加;特征尺度随地图宽度等比缩放,保证小图/大图观感一致。
+ let private multiOctave (widthScale: float32) (x: int) (y: int) (seed: uint64) : float32 =
+ let xf = float32 x
+ let yf = float32 y
+ let a = valueNoise xf yf seed (96.0f * widthScale)
+ let b = valueNoise xf yf (seed + 1UL) (38.0f * widthScale)
+ let c = valueNoise xf yf (seed + 2UL) (13.0f * widthScale)
+ 0.55f * a + 0.3f * b + 0.15f * c
+
+ // ---- 生成 ----
+
+ let private index (width: int) (x: int) (y: int) : int = y * width + x
+
+ let tileAt (result: Result) (x: int) (y: int) : GroundTile =
+ enum<GroundTile> result.Tiles.[index result.Width x y]
+
+ /// 参数化生成。尺寸下限 8x8;核心区始终先整平为草地,再 BFS 校验可达性。
+ let generate (p: Params) : Result =
+ let w = max 8 p.Width
+ let h = max 8 p.Height
+ let tiles = Array.zeroCreate<int> (w * h) // 0 = Grass
+ let idx x y = index w x y
+
+ let side = max 6 (min (min w h) p.CoreSide)
+ let cx = w / 2
+ let cy = h / 2
+ let core =
+ { MinX = max 1 (cx - side / 2)
+ MinY = max 1 (cy - side / 2)
+ MaxX = min (w - 2) (cx + side / 2)
+ MaxY = min (h - 2) (cy + side / 2) }
+ let inCore x y =
+ x >= core.MinX && x <= core.MaxX && y >= core.MinY && y <= core.MaxY
+
+ // 1) 多八度噪声:低洼草地转泥炭(跳过核心区)。
+ let widthScale = float32 w / 64.0f
+ for y in 0 .. h - 1 do
+ for x in 0 .. w - 1 do
+ 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
+
+ // 3) 核心活动区整平为草地(生成器保证可通行)。
+ for y in core.MinY .. core.MaxY do
+ for x in core.MinX .. core.MaxX do
+ tiles.[idx x y] <- int GroundTile.Grass
+
+ // 4) 泊松式布点:最小间距随尺寸缩放,只在草地打石头装饰(跳过核心区)。
+ let minDist = max 3 (w / 24)
+ let minSq = minDist * minDist
+ let attempts = max 100 ((w * h) / 16)
+ let mutable placed: (int * int) list = []
+ let mutable scatterRng = rng
+ for _ in 1 .. attempts do
+ let nextState, raw = splitmix scatterRng
+ scatterRng <- nextState
+ let x = int (raw % uint64 w)
+ let y = int ((raw >>> 20) % uint64 h)
+ let far =
+ placed
+ |> List.forall (fun (px, py) ->
+ let dx = px - x
+ let dy = py - y
+ dx * dx + dy * dy > minSq)
+ if far && not (inCore x y) && tiles.[idx x y] = int GroundTile.Grass then
+ tiles.[idx x y] <- int GroundTile.Stone
+ placed <- (x, y) :: placed
+
+ // 5) 30 个 spawn:核心区内的规则网格。
+ let sc = max 2 p.SpawnColumns
+ let sr = max 2 p.SpawnRows
+ let spanX = max 1 (core.MaxX - core.MinX - 2)
+ let spanY = max 1 (core.MaxY - core.MinY - 2)
+ let spawns =
+ [ for r in 0 .. sr - 1 do
+ for c in 0 .. sc - 1 do
+ yield
+ (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
+ let flood startX startY =
+ let visited = Array.create (w * h) false
+ let queue = System.Collections.Generic.Queue<int>()
+ if walkable startX startY then
+ let start = idx startX startY
+ visited.[start] <- true
+ queue.Enqueue start
+ while queue.Count > 0 do
+ let cur = queue.Dequeue()
+ let x = cur % w
+ let y = cur / w
+ for dx, dy in [ (1, 0); (-1, 0); (0, 1); (0, -1) ] do
+ let nx = x + dx
+ let ny = y + dy
+ if nx >= 0 && nx < w && ny >= 0 && ny < h then
+ let ni = idx nx ny
+ if not visited.[ni] && walkable nx ny then
+ visited.[ni] <- true
+ 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 reachableTiles = visited |> Array.filter id |> Array.length
+ let reachabilityOk = spawns |> List.forall (fun (x, y) -> visited.[idx x y])
+
+ { Width = w
+ Height = h
+ Seed = p.Seed
+ Tiles = tiles
+ Core = core
+ Spawns = spawns
+ ReachableTiles = reachableTiles
+ ReachabilityOk = reachabilityOk }
+
+ let generateWithSize (width: int) (height: int) (seed: uint64) : Result =
+ generate { defaultParams seed with Width = width; Height = height }
+
+ // ---- 视口裁剪(纯函数):只绘制可见瓦片,与 drawWorld 现有窗口一致 ----
+
+ /// 返回含端点的可见瓦片范围 (x0, y0, x1, y1);空视口返回 (0,0,-1,-1)。
+ let visibleTileRange (mapWidth: int) (mapHeight: int) (cameraX: int) (cameraY: int) (viewportWidth: int) (viewportHeight: int) : int * int * int * int =
+ let x0 = max 0 (cameraX / Sim.tilePixels)
+ let y0 = max 0 (cameraY / Sim.tilePixels)
+ let x1 = min (mapWidth - 1) ((cameraX + viewportWidth) / Sim.tilePixels + 1)
+ let y1 = min (mapHeight - 1) ((cameraY + viewportHeight) / Sim.tilePixels + 1)
+ if mapWidth <= 0 || mapHeight <= 0 || x1 < x0 || y1 < y0 then (0, 0, -1, -1) else (x0, y0, x1, y1)
+
+ let visibleTileCount (mapWidth: int) (mapHeight: int) (cameraX: int) (cameraY: int) (viewportWidth: int) (viewportHeight: int) : int =
+ let x0, y0, x1, y1 = visibleTileRange mapWidth mapHeight cameraX cameraY viewportWidth viewportHeight
+ if x1 < x0 || y1 < y0 then 0 else (x1 - x0 + 1) * (y1 - y0 + 1)
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 11a6d71..51e17e0 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -643,10 +643,8 @@ module VillageArt =
| Some cc0 -> drawCc0Tile spriteBatch cc0 tint (cc0GroundRect position) (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y)
| None -> draw position GrassSprite
- let tx0 = max 0 (int camera.X / Sim.tilePixels)
- let ty0 = max 0 (int camera.Y / Sim.tilePixels)
- let tx1 = min (Sim.mapWidthTiles - 1) ((int camera.X + viewportWidth) / Sim.tilePixels + 1)
- let ty1 = min (Sim.mapHeightTiles - 1) ((int camera.Y + viewportHeight) / Sim.tilePixels + 1)
+ let tx0, ty0, tx1, ty1 =
+ MapGen.visibleTileRange Sim.mapWidthTiles Sim.mapHeightTiles (int camera.X) (int camera.Y) viewportWidth viewportHeight
for y in ty0 .. ty1 do
for x in tx0 .. tx1 do
drawBaseTile (tilePosition x y)
@@ -724,6 +722,67 @@ module VillageArt =
| House -> draw (offsetTile (worldTile plan prop.Position) 0 -3) smokeSprite
| _ -> ()
+ /// P20 证据用:把整张 MapGen 地图等比缩放到视口内绘制,返回绘制的瓦片数。
+ /// 仅用于 `LV_MAPGEN_SHOT` 整图取证,不参与正常游戏循环。
+ let drawMapFitted
+ (spriteBatch: SpriteBatch)
+ (textures: ArtTextures)
+ (viewportWidth: int)
+ (viewportHeight: int)
+ (map: MapGen.Result)
+ (tint: XnaColor) : int =
+ let scale =
+ min (float32 viewportWidth / float32 (max 1 (map.Width * Sim.tilePixels)))
+ (float32 viewportHeight / float32 (max 1 (map.Height * Sim.tilePixels)))
+ let cell = max 1 (int (float32 Sim.tilePixels * scale))
+ let offsetX = (viewportWidth - map.Width * cell) / 2
+ let offsetY = (viewportHeight - map.Height * cell) / 2
+ let spriteOf code =
+ match code with
+ | 1 -> WaterSprite
+ | 2
+ | 3 -> StonePathSprite
+ | _ -> GrassSprite
+ 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)
+ map.Width * map.Height
+
+ /// P20 证据用:按相机做视口裁剪,只绘制可见瓦片(1:1 像素),返回实际绘制数。
+ let drawMapViewport
+ (spriteBatch: SpriteBatch)
+ (textures: ArtTextures)
+ (camera: XnaVector2)
+ (viewportWidth: int)
+ (viewportHeight: int)
+ (map: MapGen.Result)
+ (tick: int64)
+ (tint: XnaColor) : int =
+ let x0, y0, x1, y1 =
+ MapGen.visibleTileRange map.Width map.Height (int camera.X) (int camera.Y) viewportWidth viewportHeight
+ let spriteOf (x: int) (y: int) code =
+ match code with
+ | 1 ->
+ match waterFrameTickAt tick x y with
+ | 0 -> WaterSprite
+ | 1 -> WaterSpriteB
+ | _ -> WaterSpriteC
+ | 2
+ | 3 -> StonePathSprite
+ | _ -> GrassSprite
+ let mutable drawn = 0
+ 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)
+ drawn <- drawn + 1
+ drawn
+
let drawCharacter
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
diff --git a/src/LivingVillage.Kernel.Tests/M6aTests.fs b/src/LivingVillage.Kernel.Tests/M6aTests.fs
index fc8cc3a..9348b0f 100644
--- a/src/LivingVillage.Kernel.Tests/M6aTests.fs
+++ b/src/LivingVillage.Kernel.Tests/M6aTests.fs
@@ -292,3 +292,25 @@ type M6aTests () =
Assert.AreEqual<string>("渔夫", Occupation.nameOf Occupation.Fisher)
Assert.AreEqual<string>("货郎", Occupation.nameOf Occupation.Peddler)
Assert.AreEqual<string>("书生", Occupation.nameOf Occupation.Scholar)
+
+ /// P20:存档 v2 的边界字段在非默认尺寸下必须 round-trip,读回后
+ /// mapWidthTiles/mapHeightTiles 恢复且再次保存与原文逐字节一致。
+ /// 旧 64x48 存档的 Round-trip 保持不变由既有 v1/v2 用例覆盖。
+ [<TestMethod>]
+ member _.SaveRoundTripsParameterizedMapBounds () =
+ let path = "p20-bounds-v2.save"
+ try
+ Sim.configureBounds 256 192
+ let world = Sim.initialWorld (uint64 4242)
+ WorldSave.saveToFile path world
+ let text = File.ReadAllText path
+ Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V2"), "bounds save must stay v2")
+ match WorldSave.loadFromFile path with
+ | Error failure -> Assert.Fail(sprintf "bounds save should load: %s" failure)
+ | Ok restored ->
+ Assert.AreEqual<int>(256, Sim.mapWidthTiles)
+ Assert.AreEqual<int>(192, Sim.mapHeightTiles)
+ Assert.AreEqual<string>(text, WorldSave.save restored)
+ finally
+ Sim.configureBounds 64 48
+ if File.Exists path then File.Delete path