diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:14:31 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 10:14:31 +0800 |
| commit | b377e750a6a3f35c5d64a29f14ac3fbd87d4a752 (patch) | |
| tree | 8baac5fca38fdcecda4a00eed7a58b1031ab9dca /src | |
| parent | d36e86ac637ccc477479f88bea511c49b0a6a67c (diff) | |
| parent | 5f12e63f2d2203267c30666f1559e06ec637b91c (diff) | |
| download | living-village-b377e750a6a3f35c5d64a29f14ac3fbd87d4a752.tar.gz | |
merge: P26 美术整改(民居第二变体/河岸装饰/夜月光/HUD 双景可读)— Hermes 复验通过
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/DesktopTests.fs | 24 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/MapGenTests.fs | 47 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P16UiTests.fs | 21 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 48 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-world.png | bin | 3847 -> 4468 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 16 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/HudLayout.fs | 22 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/M6Presentation.fs | 12 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MapGen.fs | 91 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 60 |
10 files changed, 317 insertions, 24 deletions
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs index b78f4e0..7e4a849 100644 --- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs +++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs @@ -160,6 +160,30 @@ type DesktopTests () = Assert.IsTrue(noon.Background.B > noon.Background.R) [<TestMethod>] + member _.MoonlightVeilIsCoolAndOnlyAtNight () = + let noonTick = 12L * Sim.ticksPerDay / 24L + let midnightTick = 0L + let deepNightTick = 23L * Sim.ticksPerDay / 24L + Assert.IsTrue(M6Presentation.moonlightVeilAlpha noonTick = 0, "daytime must have no moonlight") + + let moon = M6Presentation.moonlightVeilAlpha midnightTick + Assert.IsTrue(moon > 0, "deep night must get a cool moonlight wash") + Assert.AreEqual<int>(moon, M6Presentation.moonlightVeilAlpha deepNightTick) + Assert.IsTrue(moon <= 72, "moonlight veil stays a low alpha so the scene is not washed out") + + let r, g, b = M6Presentation.moonlightRgb + Assert.IsTrue(b > g && g > r, "moonlight must be cool: blue-dominant channels") + + // Dusk is owned by the warm golden veil, so moonlight is suppressed there. + let duskTick = M6Presentation.startTickForHour 20.75 + Assert.IsTrue(M6Presentation.moonlightVeilAlpha duskTick < moon, "dusk is warmed, not moonlit") + + // Premultiplied premultiply of this colour never saturates to white. + let veiled = VillageArt.premultiply r g b moon + Assert.IsTrue(int veiled.B <= int veiled.A) + Assert.IsTrue(int (max veiled.R (max veiled.G veiled.B)) < 250) + + [<TestMethod>] member _.DaylightAutoplayBootstrapStartsAtDawnAndPreservesDefaultStart () = let dawnTick = 6L * Sim.ticksPerDay / 24L let daylightWorld = WorldBootstrap.initialWorldForDaylight true 42UL Sim.npcCount diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs index f426a8a..6486dd7 100644 --- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs +++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs @@ -298,6 +298,53 @@ type MapGenTests () = Assert.AreEqual<int64>(6042052L, checksum (MapGen.generateWithSize 64 48 (uint64 4242))) Assert.AreEqual<int64>(1284656962L, checksum (MapGen.generateWithSize 256 192 (uint64 4242))) + // ---- P26:河岸装饰(护岸石垒/芦苇/垂柳)仅大图、落在岸格且不挡通行 ---- + + [<TestMethod>] + member _.RiverDecorationsOnlyEnabledOnLargeMaps () = + Assert.IsFalse((MapGen.paramsForSize 64 48 (uint64 1)).RiverDecorations) + Assert.IsFalse((MapGen.paramsForSize 256 192 (uint64 1)).RiverDecorations) + Assert.IsTrue((MapGen.paramsForSize 512 384 (uint64 1)).RiverDecorations) + Assert.AreEqual<int>(0, (MapGen.generateWithSize 64 48 (uint64 4242)).Decorations.Length) + Assert.AreEqual<int>(0, (MapGen.generateWithSize 256 192 (uint64 4242)).Decorations.Length) + + [<TestMethod>] + member _.RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges () = + let map = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.IsTrue(map.Decorations.Length > 0, "512x384 must place riverbank decorations") + let pathSet = System.Collections.Generic.HashSet<int>(map.Paths |> List.map (fun (x, y) -> y * map.Width + x)) + let bridgeSet = System.Collections.Generic.HashSet<int>(map.Bridges |> List.map (fun (x, y) -> y * map.Width + x)) + let water x y = map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water + let inCore x y = x >= map.Core.MinX && x <= map.Core.MaxX && y >= map.Core.MinY && y <= map.Core.MaxY + for decoration in map.Decorations do + let footprint = + match decoration.Kind with + | MapGen.Willow -> [ decoration.Y; decoration.Y + 1 ] + | _ -> [ decoration.Y ] + for y in footprint do + let i = y * map.Width + decoration.X + Assert.AreNotEqual<int>(int MapGen.GroundTile.Water, map.Tiles.[i], "decoration must sit on land") + Assert.IsFalse(pathSet.Contains i, "decoration must not block a path tile") + Assert.IsFalse(bridgeSet.Contains i, "decoration must not block a bridge tile") + Assert.IsFalse(inCore decoration.X y, "decoration must stay outside the core") + // Bank adjacency: at least one footprint tile must touch the river orthogonally. + let touchesWater = + footprint + |> List.exists (fun y -> + [ (decoration.X + 1, y); (decoration.X - 1, y); (decoration.X, y + 1); (decoration.X, y - 1) ] + |> List.exists (fun (nx, ny) -> + nx >= 0 && nx < map.Width && ny >= 0 && ny < map.Height && water nx ny)) + Assert.IsTrue(touchesWater, "decoration must line the riverbank, not float inland") + + [<TestMethod>] + member _.LargeMapRiverDecorationsAreDeterministic () = + let a = MapGen.generateWithSize 512 384 (uint64 4242) + let b = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.IsTrue(a.Decorations = b.Decorations) + Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b) + let c = MapGen.generateWithSize 512 384 (uint64 7) + Assert.AreNotEqual<string>(MapGen.serialize a, MapGen.serialize c) + [<TestMethod>] member _.VisibleRangeClampsAtLegacyMapEdges () = let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536 diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs index 2e474ac..b68bbd3 100644 --- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs +++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs @@ -128,6 +128,27 @@ type P16HudTests () = Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "体力") Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "钱币") + [<TestMethod>] + member _.HudPersistentSegmentsGetBackdropAndOutlineInBothPeriods () = + // P26:白天/夜景两套调色板都要有半透明底色 + 不透明深色描边,保证常驻元素可读。 + for mode in [ Day; Night ] do + let palette = HudLayout.palette mode + Assert.IsTrue(palette.SegmentBackdrop.A > 0uy, "segment backdrop must be translucent, not opaque") + Assert.IsTrue(palette.SegmentBackdrop.A < 255uy, "segment backdrop must keep the scene visible") + Assert.AreEqual<byte>(255uy, palette.Outline.A) + Assert.IsTrue(palette.Outline.R < 40uy && palette.Outline.G < 40uy && palette.Outline.B < 40uy) + let outlineSum = int palette.Outline.R + int palette.Outline.G + int palette.Outline.B + let valueSum = int palette.Value.R + int palette.Value.G + int palette.Value.B + Assert.IsTrue(outlineSum < valueSum, "outline must contrast with the bright value text") + Assert.AreEqual<int>(4, HudLayout.textOutlineOffsets.Length) + for (dx, dy) in HudLayout.textOutlineOffsets do + Assert.IsTrue(dx <> 0 || dy <> 0, "outline offset must be non-zero") + let placed = HudLayout.layout (HudLayout.segments world SimulationControl.initial) + for (x, width, _) in placed do + let rect = HudLayout.segmentBackdrop x width + Assert.IsTrue(rect.X <= x && rect.X + rect.Width >= x + width, "backdrop must cover the segment text") + Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= HudLayout.barHeight, "backdrop must stay inside the bar") + // ---- P16-FIX 菜单截图帧表 ---- [<TestClass>] diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index e58139c..bc3425a 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -641,6 +641,28 @@ type PrototypeTests () = Assert.IsFalse(covers (centerX - radius - 5) centerY) [<TestMethod>] + member _.LanternGlowIsRadialWithoutSquareCorners () = + // P26 复核:光晕为同心圆叠加,逐像素累积 alpha;边缘/四角不得出现方斑。 + let centerX, centerY, radius, rings, peakAlpha = 80, 80, 40, 6, 140 + let layers = VillageArt.lanternGlowLayers centerX centerY radius rings 255 214 150 peakAlpha + let size = radius * 2 + 3 + let origin = centerX - radius - 1 + let alpha = Array2D.zeroCreate<int> size size + for (rect, color) in layers do + for y in rect.Y .. rect.Y + rect.Height - 1 do + for x in rect.X .. rect.X + rect.Width - 1 do + alpha.[y - origin, x - origin] <- alpha.[y - origin, x - origin] + int color.A + let center = alpha.[radius + 1, radius + 1] + Assert.IsTrue(center > 0, "the core must be lit") + let corner = [ (0, 0); (size - 1, 0); (0, size - 1); (size - 1, size - 1) ] + for (cx, cy) in corner do + Assert.IsTrue(alpha.[cy, cx] = 0, "glow corner must stay empty: no square patch") + // Radial falloff along the centre row: alpha never rises as distance from the core grows. + let row = [ for dx in 0 .. radius -> alpha.[radius + 1, radius + 1 + dx] ] + Assert.IsTrue(row |> List.pairwise |> List.forall (fun (a, b) -> a >= b), "glow must fade radially, not form a flat block") + Assert.IsTrue(row.[0] = center && row.[radius] < center, "core is brightest, rim is dimmer") + + [<TestMethod>] member _.CharacterFeetSitOnTheTileGroundLine () = Assert.AreEqual<int>(16, VillageArt.characterFeetOffset) let destination = VillageArt.characterDestination { X = 100.0f; Y = 200.0f } (Vector2(0.0f, 0.0f)) @@ -1104,6 +1126,23 @@ type PrototypeTests () = // 953775FA 硬对齐由 --performance-baseline 于 finalWorld 支线证据。 [<TestMethod>] + member _.HouseFacadeVariantsAlternateAlongTheVillage () = + // P26:512 大图上两种民居立面都要出现,且按位置确定性交替(纯函数)。 + let map = MapGen.generateWithSize 512 384 (uint64 4242) + let variants = map.Buildings |> List.map VillageArt.houseFacadeVariant + Assert.IsTrue(Set.ofList variants |> Set.contains 0, "variant A must appear") + Assert.IsTrue(Set.ofList variants |> Set.contains 1, "variant B must appear") + let repeated = MapGen.generateWithSize 512 384 (uint64 4242) + Assert.AreEqual<(int list)>(variants, repeated.Buildings |> List.map VillageArt.houseFacadeVariant) + // Neighbouring buildings along the same river bank must flip variant, i.e. not one solid run. + let flipped = + variants + |> List.pairwise + |> List.filter (fun (a, b) -> a <> b) + |> List.length + Assert.IsTrue(flipped > 0, "facades must alternate, not stay one variant") + + [<TestMethod>] member _.JiangnanArtAssetsArePackagedAsRealPngFiles () = let requiredAssets = [ "Assets/jiangnan-world.png" @@ -1116,9 +1155,10 @@ type PrototypeTests () = Assert.AreEqual<string list>([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing)) - // The baked world atlas must match the 28-slot layout the renderer indexes into - // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27). - Assert.AreEqual<int>(28, VillageArt.worldTileCount) + // The baked world atlas must match the 32-slot layout the renderer indexes into + // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27; P26 added the variant-B house + // parts 屋檐挑出/木格栅窗/院门 as slots 28..30 and 护岸石垒 as slot 31). + Assert.AreEqual<int>(32, VillageArt.worldTileCount) let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") let header = File.ReadAllBytes worldPath let be32 offset = @@ -1128,7 +1168,7 @@ type PrototypeTests () = ||| int header.[offset + 3] let atlasWidth : int = be32 16 let atlasHeight : int = be32 20 - Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 28 x 32px slots") + 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") [<TestMethod>] diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png Binary files differindex 6e97728..11145a8 100644 --- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png +++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index c6da443..591d1b9 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -1186,6 +1186,14 @@ type LivingVillageGame() as this = let veilAlpha = M6Presentation.goldenVeilAlpha world.Tick if veilAlpha > 0 then spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply 255 150 70 veilAlpha) + + // Cool moonlight wash on top of the night scene: a premultiplied blue veil whose + // alpha is a pure tick function (zero by day, suppressed through dusk/dawn warmth). + // Like the golden veil it adds light and can never saturate to white. + let moonAlpha = M6Presentation.moonlightVeilAlpha world.Tick + if moonAlpha > 0 then + let mr, mg, mb = M6Presentation.moonlightRgb + spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply mr mg mb moonAlpha) spriteBatch.End() // Contextual floating prompt over the unique target; same CJK atlas font path as the HUD. @@ -1222,9 +1230,13 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop) spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom) spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent) - for (x, _, segment) in placed do - PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label + for (x, width, segment) in placed do + spriteBatch.Draw(pixel, HudLayout.segmentBackdrop x width, palette.SegmentBackdrop) let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale + for (dx, dy) in HudLayout.textOutlineOffsets do + PixelText.draw spriteBatch pixel cjkAtlas (x + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Label + PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Value + PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value spriteBatch.End() diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs index d943cec..f3e4c12 100644 --- a/src/LivingVillage.Desktop/HudLayout.fs +++ b/src/LivingVillage.Desktop/HudLayout.fs @@ -44,14 +44,26 @@ module HudLayout = (paddingX, []) List.rev placed + /// P26:常驻片段文本的 1px 描边偏移(上/下/左/右),保证白天/夜景下都可读。 + let textOutlineOffsets : (int * int) list = [ (-1, 0); (1, 0); (0, -1); (0, 1) ] + + /// P26:常驻片段的半透明底色药丸,贴合文字高度并在左右各留 6px;纯函数便于回归。 + let segmentBackdrop (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle = + let height = 8 * textScale + 10 + let y = (barHeight - height) / 2 + Microsoft.Xna.Framework.Rectangle(x - 6, y, width + 12, height) + type Palette = { BarTop: Color BarBottom: Color Accent: Color Label: Color - Value: Color } + Value: Color + SegmentBackdrop: Color + Outline: Color } /// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。 + /// 每个常驻片段再叠一层半透明底色 + 1px 深色描边,双时段都能读清。 let palette (mode: M6DayNight) : Palette = match mode with | Day -> @@ -59,10 +71,14 @@ module HudLayout = BarBottom = Color(22, 34, 40, 200) Accent = Color(212, 168, 96) Label = Color(206, 220, 208) - Value = Color(242, 248, 234) } + Value = Color(242, 248, 234) + SegmentBackdrop = Color(8, 14, 20, 150) + Outline = Color(6, 10, 14, 255) } | Night -> { BarTop = Color(22, 30, 48, 205) BarBottom = Color(13, 19, 33, 205) Accent = Color(120, 168, 196) Label = Color(176, 196, 214) - Value = Color(228, 238, 246) } + Value = Color(228, 238, 246) + SegmentBackdrop = Color(4, 8, 16, 160) + Outline = Color(3, 6, 12, 255) } diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 90d2e65..825847a 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -65,6 +65,18 @@ module M6Presentation = let goldenVeilAlpha (tick: int64) : int = int (150.0f * transitionWarmth tick) + /// Cool moonlight wash applied as a premultiplied veil over the finished night scene. + /// Multiplicative night tint can only darken, so this additive cool layer is what makes deep + /// night read moon-blue. It is zero whenever the sun lights the scene (blend 0) and is + /// suppressed through the warm dawn/dusk windows (warmth), so it never competes with the + /// golden veil. Purely desktop render state; the Kernel never sees it. + let moonlightRgb : int * int * int = (120, 170, 235) + + let moonlightVeilAlpha (tick: int64) : int = + let blend = lightingBlend tick + let warmth = transitionWarmth tick + int (72.0f * blend * (1.0f - warmth)) + let private lerp (a: int) (b: int) (blend: float32) : int = a + int (float32 (b - a) * blend) diff --git a/src/LivingVillage.Desktop/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs index 63b14bc..ce9fb3a 100644 --- a/src/LivingVillage.Desktop/MapGen.fs +++ b/src/LivingVillage.Desktop/MapGen.fs @@ -34,7 +34,9 @@ module MapGen = /// P24:大图额外沿河民居数量(沿现有路网泊松散布)。默认 0,64x48/256x192 不受影响。 ExtraRiversideHouses: int /// P25:是否放置孤立装饰石块。默认 true;大图关闭,避免草地上出现游离石板 tile。 - DecorativeStones: bool } + DecorativeStones: bool + /// P26:是否沿海河岸放置河岸装饰(护岸石垒/芦苇/垂柳)。默认 false;仅大图开启。 + RiverDecorations: bool } /// P20 第二步:一条贯穿全图的东西向河道(每列恰好一段,宽度下限 RiverWidth)。 type River = @@ -50,6 +52,18 @@ module MapGen = DoorX: int DoorY: int } + /// P26 河岸装饰种类:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格)。 + type RiverDecorationKind = + | RevetmentStone + | Reeds + | Willow + + /// P26 河岸装饰:纯视觉,不写入瓦片、不影响通行;仅大图沿海河岸生成。 + type RiverDecoration = + { Kind: RiverDecorationKind + X: int + Y: int } + type Result = { Width: int Height: int @@ -61,6 +75,7 @@ module MapGen = Bridges: (int * int) list Paths: (int * int) list Buildings: Building list + Decorations: RiverDecoration list ReachableTiles: int ReachabilityOk: bool BridgeCrossingsOk: bool } @@ -76,7 +91,8 @@ module MapGen = SpawnColumns = 5 SpawnRows = 6 ExtraRiversideHouses = 0 - DecorativeStones = true } + DecorativeStones = true + RiverDecorations = false } /// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。 /// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。 @@ -89,7 +105,8 @@ module MapGen = Height = height RiverCount = max base_.RiverCount (max 1 (height / 96)) ExtraRiversideHouses = (if large then 10 else 0) - DecorativeStones = not large } + DecorativeStones = not large + RiverDecorations = large } // ---- splitmix64 与 value noise(无外部依赖、无时钟) ---- @@ -330,6 +347,63 @@ module MapGen = addPath building.DoorX building.DoorY extraPlaced <- extraPlaced + 1 + // 5c) P26:河岸装饰(护岸石垒/芦苇/垂柳),仅大图启用。纯视觉层,不写入任何瓦片, + // 故 64x48/256x192 的 tiles checksum 与默认世界完全不受影响;装饰只落在紧邻水面的 + // 岸格,且避开路网/桥面/民居/核心区,保证不遮挡通行。 + let mutable decorations: RiverDecoration list = [] + if p.RiverDecorations then + let mutable decorRng = p.Seed ^^^ 0xD3C0DEUL + let nextDecor () = + let nextState, raw = splitmix decorRng + decorRng <- nextState + raw + let bridgeLookup = System.Collections.Generic.HashSet<int>(bridges |> List.map (fun (x, y) -> idx x y)) + let inBuildingFootprint x y = + buildings + |> List.exists (fun b -> + x >= b.Left - 1 && x <= b.Left + b.Width && y >= b.Top && y <= b.DoorY + 1) + let blocked x y = + not (inMap x y) + || tiles.[idx x y] = int GroundTile.Water + || pathSet.Contains(idx x y) + || bridgeLookup.Contains(idx x y) + || inCore x y + || inBuildingFootprint x y + let occupies (kind: RiverDecorationKind) (y: int) = + if kind = Willow then [ y; y + 1 ] else [ y ] + let tooClose (kind: RiverDecorationKind) x y = + decorations + |> List.exists (fun d -> + let dyList = occupies d.Kind d.Y + occupies kind y + |> List.exists (fun cy -> + dyList + |> List.exists (fun oy -> + let dx = d.X - x + let dy = oy - cy + dx * dx + dy * dy < 9))) + let tryPlace (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 + decorations <- { Kind = kind; X = x; Y = y } :: decorations + let kindOf (roll: uint64) = + match int (roll % 3UL) with + | 1 -> Reeds + | 2 -> Willow + | _ -> RevetmentStone + for river in riverBands do + 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 + let upperRoll = nextDecor () + let kind = kindOf upperRoll + let anchorY = if kind = Willow then river.CenterY - 2 else river.CenterY - 1 + tryPlace kind x anchorY + // 6) 30 个 spawn:核心区内的规则网格。 let sc = max 2 p.SpawnColumns let sr = max 2 p.SpawnRows @@ -385,6 +459,7 @@ module MapGen = Bridges = bridges Paths = paths Buildings = buildings + Decorations = decorations ReachableTiles = reachableTiles ReachabilityOk = spawnsOk && doorsOk && bridgesOk BridgeCrossingsOk = crossingsOk } @@ -414,6 +489,16 @@ module MapGen = .Append(building.Width).Append(':').Append(building.Height).Append(':') .Append(building.DoorX).Append(':').Append(building.DoorY).Append(';') |> ignore + builder.Append('|') |> ignore + let decorCode = function + | RevetmentStone -> 0 + | Reeds -> 1 + | Willow -> 2 + for decoration in map.Decorations do + builder + .Append(decorCode decoration.Kind).Append(':') + .Append(decoration.X).Append(':').Append(decoration.Y).Append(';') + |> ignore builder.ToString() /// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。 diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 12c17ba..d2fcc1a 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -154,6 +154,10 @@ module VillageArt = | StoneLanternSprite | WaterVatSprite | StallSprite + | RoofEaveOverhangSprite + | WallLatticeSprite + | CourtyardDoorSprite + | RevetmentStoneSprite type private XnaColor = Microsoft.Xna.Framework.Color type private XnaRectangle = Microsoft.Xna.Framework.Rectangle @@ -496,7 +500,7 @@ module VillageArt = let characterAtlasRelativePath = "Assets/jiangnan-characters.png" let interiorAtlasRelativePath = "Assets/jiangnan-interior.png" - let worldTileCount = 28 + let worldTileCount = 32 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels let characterAtlasWidth = 32 * 5 * 4 * 6 @@ -570,6 +574,10 @@ module VillageArt = | StoneLanternSprite -> 25 | WaterVatSprite -> 26 | StallSprite -> 27 + | RoofEaveOverhangSprite -> 28 + | WallLatticeSprite -> 29 + | CourtyardDoorSprite -> 30 + | RevetmentStoneSprite -> 31 XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private tilePosition x y : TilePosition = @@ -603,6 +611,13 @@ module VillageArt = 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) + /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。 + /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 挑檐挑出 + 木格栅窗 + 院门。 + /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。 + let houseFacadeVariant (building: MapGen.Building) : int = + let remainder = (building.Left + building.Top) % 2 + if remainder < 0 then remainder + 2 else remainder + /// 生成器世界里的桥面与沿河民居(白墙黑瓦色块 + 门);坐标由调用方按相机换算。 let private drawMapStructures (spriteBatch: SpriteBatch) @@ -613,23 +628,44 @@ module VillageArt = (cell: int) (map: MapGen.Result) = let bridgeSource = tileSourceRectangle BridgeSprite + let drawCell sprite x y = + spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle sprite, XnaColor.White) for (x, y) in map.Bridges do spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), bridgeSource, XnaColor.White) for building in map.Buildings do let left = building.Left let right = building.Left + building.Width - 1 - // P24:改用 256x192 同款民宅部件——黛瓦(脊/檐)+ 木墙/窗 + 门。 - for row in building.Top .. building.DoorY - 1 do - let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite - let source = tileSourceRectangle roofSprite + match houseFacadeVariant building with + | 0 -> + // 变体 A(P24):黛瓦(脊/檐)+ 木墙/窗 + 门。 + for row in building.Top .. building.DoorY - 1 do + let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite + for x in left .. right do + drawCell roofSprite x row + for x in left .. right do + let wallSprite = + if x = building.DoorX then DoorSprite + elif (x - left) % 2 = 0 then WallWindowSprite + else WallWoodSprite + drawCell wallSprite x building.DoorY + | _ -> + // 变体 B(P26):挑檐向两侧各出一格 + 木格栅窗 + 院门。 + for x in (left - 1) .. (right + 1) do + drawCell RoofEaveOverhangSprite x building.Top for x in left .. right do - spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY row, cell, cell), source, XnaColor.White) - for x in left .. right do - let wallSprite = - if x = building.DoorX then DoorSprite - elif (x - left) % 2 = 0 then WallWindowSprite - else WallWoodSprite - spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY building.DoorY, cell, cell), tileSourceRectangle wallSprite, XnaColor.White) + let wallSprite = + if x = building.DoorX then CourtyardDoorSprite + elif (x - left) % 2 = 0 then WallLatticeSprite + else WallWoodSprite + drawCell wallSprite x building.DoorY + // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。 + for decoration in map.Decorations do + match decoration.Kind with + | MapGen.RiverDecorationKind.RevetmentStone -> drawCell RevetmentStoneSprite decoration.X decoration.Y + | MapGen.RiverDecorationKind.Reeds -> drawCell ReedSprite decoration.X decoration.Y + | MapGen.RiverDecorationKind.Willow -> + drawCell WillowTopSprite decoration.X decoration.Y + drawCell WillowBaseSprite decoration.X (decoration.Y + 1) /// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。 let bridgeLanternTiles (map: MapGen.Result) : (int * int) list = |
