From da11e3507f63b9126d534399e9b29fc2615ed481 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 11:17:39 +0800 Subject: feat(desktop): P28 民居第二变体强化配色与轮廓(青灰挑檐色阶 + 竖长格栅窗 + 绿院门) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make-jiangnan-art.py slot 28/29/30:B 变体改用亮青灰瓦色阶、竖长木格栅窗、绿双扇院门 - VillageArt 变体 B 渲染:挑檐多叠一层、向两侧各出一格(水面之上不叠),墙身统一格栅窗 - 仅绘制分支,瓦片/可达性零改动 --- .../Assets/jiangnan-world.png | Bin 4468 -> 4492 bytes src/LivingVillage.Desktop/VillageArt.fs | 23 ++++++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png index 11145a8..b67dd01 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-world.png and b/src/LivingVillage.Desktop/Assets/jiangnan-world.png differ diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index d2fcc1a..9a56de4 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -612,7 +612,8 @@ module VillageArt = spriteBatch.Draw(pixel, XnaRectangle(x, y, max 1 w, max 1 h), color) /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。 - /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 挑檐挑出 + 木格栅窗 + 院门。 + /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 亮青灰挑檐(多叠一层、向两侧各出一格)+ + /// 竖长木格栅窗 + 绿院门(P28 强化配色与轮廓,保证 512 整图 ~2px/tile 下两变体可分)。 /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。 let houseFacadeVariant (building: MapGen.Building) : int = let remainder = (building.Left + building.Top) % 2 @@ -649,14 +650,22 @@ module VillageArt = else WallWoodSprite drawCell wallSprite x building.DoorY | _ -> - // 变体 B(P26):挑檐向两侧各出一格 + 木格栅窗 + 院门。 - for x in (left - 1) .. (right + 1) do - drawCell RoofEaveOverhangSprite x building.Top + // 变体 B(P26/P28):亮青灰挑檐向两侧各出一格 + 竖长木格栅窗 + 绿院门。 + // P28 再向上叠一层挑檐,使 B 的轮廓比 A 高一格;水面之上不叠,避免屋顶压水。 + let isWater x y = + x >= 0 && x < map.Width && y >= 0 && y < map.Height + && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water + for row in building.Top .. building.DoorY - 1 do + for x in (left - 1) .. (right + 1) do + drawCell RoofEaveOverhangSprite x row + let upper = building.Top - 1 + if upper >= 0 then + for x in (left - 1) .. (right + 1) do + if not (isWater x upper) then + drawCell RoofEaveOverhangSprite x upper for x in left .. right do let wallSprite = - if x = building.DoorX then CourtyardDoorSprite - elif (x - left) % 2 = 0 then WallLatticeSprite - else WallWoodSprite + if x = building.DoorX then CourtyardDoorSprite else WallLatticeSprite drawCell wallSprite x building.DoorY // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。 for decoration in map.Decorations do -- cgit v1.2.3 From 26afb04feb9a7bec3162eccf64f1df04bfda4def Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 11:17:43 +0800 Subject: feat(mapgen): P28 512 沿河民居加密至 16 并宽度 2/3/4 分层 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExtraRiversideHouses 10 -> 16(沿河 +6 座),宽度加权 2/3/4 形成聚落层次 - 仅 large 分支执行、默认图不消耗 RNG:64x48=6042052、256x192=1284656962 锁定不变 - 新增 LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps 回归 --- src/LivingVillage.Desktop.Tests/MapGenTests.fs | 15 +++++++++++++++ src/LivingVillage.Desktop/MapGen.fs | 23 ++++++++++++++--------- 2 files changed, 29 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index 6486dd7..b737707 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -256,6 +256,21 @@ type MapGenTests () = Assert.IsTrue(a.Buildings = b.Buildings) Assert.AreEqual(MapGen.serialize a, MapGen.serialize b) + /// P28:512 沿河民居由 10 -> 16 增密(+6),并出现 2/3/4 三档宽度(附属小筑/民居/大宅), + /// 形成聚落层次;默认图仍为 0,不消耗 RNG,故 64/256 checksum 锁定不变。 + [] + member _.LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps () = + Assert.AreEqual(16, (MapGen.paramsForSize 512 384 (uint64 1)).ExtraRiversideHouses) + Assert.AreEqual(0, (MapGen.paramsForSize 64 48 (uint64 1)).ExtraRiversideHouses) + Assert.AreEqual(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) + 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(24, map.Buildings.Length) + [] member _.DefaultSizeHouseCountUnchanged () = Assert.AreEqual(4, (MapGen.generateWithSize 64 48 (uint64 4242)).Buildings.Length) diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs index ce9fb3a..3705635 100644 --- a/src/LivingVillage.Desktop/MapGen.fs +++ b/src/LivingVillage.Desktop/MapGen.fs @@ -97,6 +97,7 @@ module MapGen = /// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。 /// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。 /// P24:仅对大于 256x192 的图追加沿河民居(泊松散布),保证既有 checksum 不变。 + /// P28:大图沿河民居由 10 增至 16(+6 座,宽度 2/3/4 变化),默认图仍为 0、不消耗 RNG。 let paramsForSize (width: int) (height: int) (seed: uint64) : Params = let base_ = defaultParams seed let large = width > 256 || height > 192 @@ -104,7 +105,7 @@ module MapGen = Width = width Height = height RiverCount = max base_.RiverCount (max 1 (height / 96)) - ExtraRiversideHouses = (if large then 10 else 0) + ExtraRiversideHouses = (if large then 16 else 0) DecorativeStones = not large RiverDecorations = large } @@ -307,18 +308,20 @@ module MapGen = // 5b) P24:大图沿河民居加密。从既有路网瓦片出发,向四邻定向落位 3x2 白墙黛瓦民居, // 门开向相邻路格并接入路网(BFS 可达);仅在 ExtraRiversideHouses>0 时执行, // 且只在核心区外的草地落位,故 64x48/256x192 输出逐字节不变。 + // P28:宽度按 2/3/4 加权变化(2=附属小筑、4=大宅),形成聚落层次而非等距重复; + // 数量 10 -> 16(沿河再增 6 座)。仍只在大图分支执行,默认图 RNG 不消耗,checksum 不变。 if p.ExtraRiversideHouses > 0 then - let footprintFree (left: int) (top: int) = - left >= 1 && left + 2 <= w - 2 && top >= 1 && top + 1 <= h - 2 - && [ for dx in 0 .. 2 do + let footprintFree (left: int) (top: int) (width: int) = + left >= 1 && left + width - 1 <= w - 2 && top >= 1 && top + 1 <= h - 2 + && [ for dx in 0 .. width - 1 do for dy in 0 .. 1 -> (left + dx, top + dy) ] |> List.forall (fun (x, y) -> tiles.[idx x y] = int GroundTile.Grass && not (inCore x y)) - let overlaps (left: int) (top: int) = + let overlaps (left: int) (top: int) (width: int) = buildings |> List.exists (fun b -> - left < b.Left + b.Width && b.Left < left + 3 + left < b.Left + b.Width && b.Left < left + width && top < b.Top + b.Height && b.Top < top + 2) let directions = [ (0, -1); (0, 1); (-1, 0); (1, 0) ] let mutable extraPlaced = 0 @@ -331,15 +334,17 @@ module MapGen = if paths.Length > 0 then let pathX, pathY = List.item (int (raw % uint64 paths.Length)) paths let dirX, dirY = directions.[int ((raw >>> 32) % 4UL)] + let widthRoll = int ((raw >>> 40) % 10UL) + let buildingWidth = if widthRoll < 5 then 3 elif widthRoll < 8 then 2 else 4 let doorX = pathX + dirX let doorY = pathY + dirY - let left = doorX - 1 + let left = doorX - buildingWidth / 2 let top = doorY - 1 - if footprintFree left top && not (overlaps left top) then + if footprintFree left top buildingWidth && not (overlaps left top buildingWidth) then let building = { Left = left Top = top - Width = 3 + Width = buildingWidth Height = 2 DoorX = doorX DoorY = doorY } -- cgit v1.2.3 From 1b4c4e0708692d424a575f0d50c349367ffde09d Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 11:17:48 +0800 Subject: feat(desktop): P28 整图 2px/tile 真实帧取证钩子 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LV_MAPGEN_FIT_WIDTH/HEIGHT 额外渲染整图到指定像素目标(1024x768 -> 恰 2px/tile) - 仅取证模式触发,不进入正常游戏循环 --- src/LivingVillage.Desktop/Game.fs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) (limited to 'src') diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 591d1b9..824c1c1 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -296,6 +296,15 @@ type LivingVillageGame() as this = match Environment.GetEnvironmentVariable("LV_MAPGEN_SEED") with | null | "" -> 4242UL | v -> match UInt64.TryParse v with (true, value) -> value | _ -> 4242UL + // P28 证据钩子:额外把整图按指定像素目标渲染一次(用于 1 tile≈2px 的缩放可读性取证)。 + let mapGenFitWidth = + match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_WIDTH") with + | null | "" -> 0 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 + let mapGenFitHeight = + match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_HEIGHT") with + | null | "" -> 0 + | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 let mutable mapGenResult = Unchecked.defaultof let mutable mapGenGenMs = 0.0 @@ -891,6 +900,15 @@ type LivingVillageGame() as this = fittedTexture.SaveAsPng(stream, fittedTexture.Width, fittedTexture.Height) printfn "mapgen-fitted drawn=%d png=%dx%d" fittedDrawn fittedTexture.Width fittedTexture.Height + // P28:整图按指定像素目标再渲染一次(真实帧)。1024x768 -> 512x384 图恰为 2px/tile, + // 用于证明两民居变体在「整图 1 tile≈2px」缩放下肉眼可分。 + if mapGenFitWidth > 0 && mapGenFitHeight > 0 then + let fitDrawn, fitTexture = this.RenderMapGen mapGenFitWidth mapGenFitHeight 0 0 true + use _fit = fitTexture + use fitStream = System.IO.File.Create(sprintf "%s/p28-fit-%dx%d-seed%d.png" recordDirectory mapGenFitWidth mapGenFitHeight (int mapGenResult.Seed)) + fitTexture.SaveAsPng(fitStream, mapGenFitWidth, mapGenFitHeight) + printfn "mapgen-fit drawn=%d png=%dx%d" fitDrawn mapGenFitWidth mapGenFitHeight + 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) -- cgit v1.2.3 From 533b39f6ccc6a2608c43659430e79d996fe049e2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 11:31:13 +0800 Subject: feat(desktop): P28 整图夜景取证(真实 tint + 月光 veil) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LV_MAPGEN_NIGHT=1 时整图按 23:30 的 WorldTint 与 golden/moonlight veil 渲染 - RenderMapGen 参数化 tint/veil tick,与 DrawWorldView 共用同一套纯函数 --- src/LivingVillage.Desktop/Game.fs | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 824c1c1..38d9e94 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -305,6 +305,9 @@ type LivingVillageGame() as this = match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_HEIGHT") with | null | "" -> 0 | 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" + let mapGenNightTick = if mapGenNight then M6Presentation.resolveStartTick (Some 23.5) false else 0L let mutable mapGenResult = Unchecked.defaultof let mutable mapGenGenMs = 0.0 @@ -871,16 +874,28 @@ type LivingVillageGame() as this = this.Exit() /// P20 证据:把一次 MapGen 绘制渲染到指定尺寸离屏 target,返回绘制瓦片数与纹理。 - member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) : int * Texture2D = + /// P28:可传入世界 tint 与可选 veil tick(夜景取证用)。 + member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) (tint: Color) (veilTick: int64 option) : int * Texture2D = use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight) this.GraphicsDevice.SetRenderTarget target this.GraphicsDevice.Clear(Color(18, 26, 20)) spriteBatch.Begin() let drawn = if drawFitted then - VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult Color.White + VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult tint else - VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L Color.White + VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L tint + // 与 DrawWorldView 同一套纯函数 veil,保证取证夜景=真实渲染夜景。 + 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 (targetWidth * targetHeight) @@ -894,25 +909,27 @@ type LivingVillageGame() as this = System.IO.Directory.CreateDirectory recordDirectory |> ignore let viewport = this.GraphicsDevice.Viewport let mapCell = 6 - let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true + let mapTint = if mapGenNight then (M6Presentation.profileAtTick mapGenNightTick).WorldTint else Color.White + let mapVeil = if mapGenNight then Some mapGenNightTick else None + let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true mapTint mapVeil use _fitted = fittedTexture 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 + printfn "mapgen-fitted drawn=%d png=%dx%d night=%b" fittedDrawn fittedTexture.Width fittedTexture.Height mapGenNight // P28:整图按指定像素目标再渲染一次(真实帧)。1024x768 -> 512x384 图恰为 2px/tile, // 用于证明两民居变体在「整图 1 tile≈2px」缩放下肉眼可分。 if mapGenFitWidth > 0 && mapGenFitHeight > 0 then - let fitDrawn, fitTexture = this.RenderMapGen mapGenFitWidth mapGenFitHeight 0 0 true + let fitDrawn, fitTexture = this.RenderMapGen mapGenFitWidth mapGenFitHeight 0 0 true mapTint mapVeil use _fit = fitTexture use fitStream = System.IO.File.Create(sprintf "%s/p28-fit-%dx%d-seed%d.png" recordDirectory mapGenFitWidth mapGenFitHeight (int mapGenResult.Seed)) fitTexture.SaveAsPng(fitStream, mapGenFitWidth, mapGenFitHeight) - printfn "mapgen-fit drawn=%d png=%dx%d" fitDrawn mapGenFitWidth mapGenFitHeight + printfn "mapgen-fit drawn=%d png=%dx%d night=%b" fitDrawn mapGenFitWidth mapGenFitHeight mapGenNight 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 + let culledDrawn, culledTexture = this.RenderMapGen viewport.Width viewport.Height camX camY false Color.White None use _culled = culledTexture 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) @@ -924,7 +941,7 @@ type LivingVillageGame() as this = | (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 + let detailDrawn, detailTexture = this.RenderMapGen viewport.Width viewport.Height detailCamX detailCamY false Color.White None 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) -- cgit v1.2.3 From 7cb699bb7ac6cddb4cb33ede7c6f8962a3b60565 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 11:31:13 +0800 Subject: test(desktop): P28 断言变体 B 挑檐亮度显著高于变体 A 屋脊 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 直接解码 world 图集 slot 8/28 平均亮度,防配色差异被回退抹平 --- src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index bc3425a..7f67280 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1171,6 +1171,59 @@ type PrototypeTests () = Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 32 x 32px slots") Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px") + /// P28:防复发。第二变体(P26)在 512 整图 ~2px/tile 下不可辨,根因是挑檐与第一变体同为 + /// 近黑黛瓦。此回归直接解码 world 图集,断言变体 B 的挑檐(slot 28)平均亮度显著高于 + /// 变体 A 的屋脊(slot 8),保证「配色差异」不会在后续编辑中被悄悄抹平。 + [] + member _.VariantBRoofPaletteIsClearlyLighterThanVariantA () = + let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") + let bytes = File.ReadAllBytes worldPath + let be32 offset = + (int bytes.[offset] <<< 24) + ||| (int bytes.[offset + 1] <<< 16) + ||| (int bytes.[offset + 2] <<< 8) + ||| int bytes.[offset + 3] + Assert.AreEqual(0x89504E47, be32 0) + let width = be32 16 + let height = be32 20 + Assert.AreEqual(VillageArt.worldAtlasWidth, width) + Assert.AreEqual(VillageArt.worldAtlasHeight, height) + // Collect IDAT and inflate; the baked atlas is 8-bit RGBA, non-interlaced, filter 0. + let mutable offset = 8 + use idat = new MemoryStream() + while offset < bytes.Length do + let length = be32 offset + let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4) + if tag = "IDAT" then idat.Write(bytes, offset + 8, length) + offset <- offset + 12 + length + idat.Position <- 0L + use inflater = new System.IO.Compression.ZLibStream(idat, System.IO.Compression.CompressionMode.Decompress) + use raw = new MemoryStream() + inflater.CopyTo raw + let data = raw.ToArray() + let stride = width * 4 + 1 + let pixel (x: int) (y: int) = + let rowStart = y * stride + Assert.AreEqual(0uy, data.[rowStart]) + let p = rowStart + 1 + x * 4 + (int data.[p], int data.[p + 1], int data.[p + 2], int data.[p + 3]) + let slotMeanBrightness (slot: int) = + let mutable sum = 0.0 + let mutable count = 0 + for y in 0 .. height - 1 do + for x in slot * 32 .. slot * 32 + 31 do + let (r, g, b, a) = pixel x y + if a > 0 then + sum <- sum + float (r + g + b) / 3.0 + count <- count + 1 + Assert.IsTrue(count > 0, sprintf "slot %d must have opaque pixels" slot) + sum / float count + let roofA = slotMeanBrightness 8 // RoofRidgeSprite: variant A near-black 黛瓦 + let roofB = slotMeanBrightness 28 // RoofEaveOverhangSprite: variant B light blue-grey 瓦 + Assert.IsTrue( + roofB > roofA + 15.0, + sprintf "variant B roof must be clearly lighter than variant A (A=%.1f B=%.1f)" roofA roofB) + [] member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () = // 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一, -- cgit v1.2.3