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