summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 09:26:53 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 09:26:53 +0800
commitebe7f80bcf54189cc51a15bf17b99293b6ff57a3 (patch)
tree89fc4406662c2ff6bc39fbef8e806731709eecf2 /src
parentdbce686019230c7a1105f5cc4b7b258698201128 (diff)
downloadliving-village-ebe7f80bcf54189cc51a15bf17b99293b6ff57a3.tar.gz
feat(desktop): P26 夜晚冷月光 veil(纯渲染层)+ 灯笼光晕径向无方斑回归
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs24
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs22
-rw-r--r--src/LivingVillage.Desktop/Game.fs8
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs12
4 files changed, 66 insertions, 0 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/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index dd4e7b3..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))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index c6da443..537ae3b 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.
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)