summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:53:24 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:53:24 +0800
commit37953a4b58dfc5f3b8aedf10ad6f1474c2960767 (patch)
tree96b1d86ce3cfe33d8d9493ba7a68575f54c4f4b8 /src
parenta67e9262caca67f1078f77ed2f274a46e284c6c8 (diff)
downloadliving-village-37953a4b58dfc5f3b8aedf10ad6f1474c2960767.tar.gz
feat(desktop): P18 黄昏暖色真实渲染(加性暖色 veil + 双窗口光照 + 时段标签对齐)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/P16UiTests.fs4
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs35
-rw-r--r--src/LivingVillage.Desktop/Game.fs8
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs44
4 files changed, 68 insertions, 23 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
index b071dfa..2e474ac 100644
--- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs
+++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
@@ -111,6 +111,10 @@ type P16HudTests () =
Assert.AreEqual<string>("夜晚", M6Presentation.timePeriodLabel 0L)
Assert.AreEqual<string>("清晨", M6Presentation.timePeriodLabel (6L * ticksPerHour))
Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (19L * ticksPerHour))
+ // P18: 黄昏 covers the golden hour through 20:59; 21:00 onwards is 夜晚.
+ Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (20L * ticksPerHour))
+ Assert.AreEqual<string>("黄昏", M6Presentation.timePeriodLabel (int64 20 * ticksPerHour + ticksPerHour * 3L / 4L))
+ Assert.AreEqual<string>("夜晚", M6Presentation.timePeriodLabel (21L * ticksPerHour))
[<TestMethod>]
member _.HudNightPaletteLeansCoolAndLabelsAreAtlasBacked () =
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index b250824..895dff8 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -865,37 +865,54 @@ type PrototypeTests () =
Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick dawnMidTick).Blend)
Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick dawnEndTick).Blend)
- // Dusk ramp 19:30 -> 22:00 goes day -> night monotonically.
- let duskStartTick = int64 19 * ticksPerHour + ticksPerHour / 2L // 19:30
- let duskMidTick = int64 20 * ticksPerHour + ticksPerHour * 3L / 4L // 20:45
+ // P18: dusk darkening ramp is 20:00 -> 22:00 (day -> night, monotonic).
+ let duskStartTick = int64 20 * ticksPerHour // 20:00
+ let duskMidTick = int64 21 * ticksPerHour // 21:00
Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick duskStartTick).Blend)
Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick duskMidTick).Blend)
Assert.AreEqual<float32>(1.0f, (atHour 22).Blend)
- // Blue hour: the transition lingers past 6:00 and starts before 20:00.
+ // Blue hour: the dawn lingers past 6:00; dusk stays light while it warms.
Assert.IsTrue((atHour 6).Blend > 0.0f && (atHour 6).Blend < 0.5f)
- Assert.IsTrue((atHour 20).Blend > 0.0f && (atHour 20).Blend < 0.5f)
+ let duskGlowTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30
+ Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend > 0.0f)
+ Assert.IsTrue((M6Presentation.lightingAtTick duskGlowTick).Blend < 0.5f, "20:30 must still be Day mode for a visible golden dusk")
+ Assert.AreEqual<M6DayNight>(Day, (M6Presentation.lightingAtTick duskGlowTick).Mode)
- // Golden-hour warmth peaks mid-dawn/mid-dusk and vanishes at noon/midnight.
+ // Golden-hour warmth peaks mid-dawn (5:15) / mid-dusk (20:30) and vanishes at noon/midnight.
+ let duskWarmthMidTick = int64 20 * ticksPerHour + ticksPerHour / 2L // 20:30
Assert.AreEqual<float32>(0.0f, (atHour 12).SkyWarmth)
Assert.AreEqual<float32>(0.0f, (atHour 0).SkyWarmth)
Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick dawnMidTick).SkyWarmth)
- Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskMidTick).SkyWarmth)
+ Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskWarmthMidTick).SkyWarmth)
Assert.IsTrue((atHour 5).SkyWarmth > 0.5f)
Assert.IsTrue((atHour 21).SkyWarmth > 0.5f)
- // The warm shift pushes the sky redder and less blue than the plain blend.
+ // The warm shift pushes sky and world tint redder and less blue than the plain blend;
+ // this is what made the old dusk frame still read blue-grey.
let warmDawn = atHour 5
+ let warmDusk = M6Presentation.lightingAtTick duskGlowTick
Assert.IsTrue(warmDawn.Background.R > (M6Presentation.backgroundAtBlend warmDawn.Blend).R)
Assert.IsTrue(warmDawn.Background.B < (M6Presentation.backgroundAtBlend warmDawn.Blend).B)
+ Assert.IsTrue(warmDusk.Background.R > (M6Presentation.backgroundAtBlend warmDusk.Blend).R)
+ Assert.IsTrue(warmDusk.Background.B < (M6Presentation.backgroundAtBlend warmDusk.Blend).B)
+ Assert.IsTrue(warmDusk.WorldTint.R > (M6Presentation.worldTintAtBlend warmDusk.Blend).R)
+ Assert.IsTrue(warmDusk.WorldTint.B < (M6Presentation.worldTintAtBlend warmDusk.Blend).B)
+ // Additive veil: positive and strongest at the golden peaks, zero at noon/midnight.
+ Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha (int64 12 * ticksPerHour))
+ Assert.AreEqual<int>(0, M6Presentation.goldenVeilAlpha 0L)
+ Assert.AreEqual<int>(150, M6Presentation.goldenVeilAlpha duskWarmthMidTick)
+ Assert.IsTrue(M6Presentation.goldenVeilAlpha duskGlowTick > 100)
+ Assert.IsTrue(M6Presentation.goldenVeilAlpha (int64 19 * ticksPerHour) = 0, "veil must start after 19:15, not bleed into afternoon")
// Determinism without randomness.
let once = atHour 13
let twice = atHour 13
Assert.AreEqual<M6RenderProfile>(once, twice)
- // Lantern glow: warm halo only rises with night blend.
+ // Lantern glow now also lifts through the golden hour, not only at deep night.
Assert.AreEqual<float32>(0.0f, (atHour 13).LanternGlow)
Assert.AreEqual<float32>(1.0f, (atHour 0).LanternGlow)
+ Assert.IsTrue(warmDusk.LanternGlow > 0.5f)
// Evidence hook: fractional start hours map exactly onto sim ticks.
Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 765cea8..e48888e 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -893,6 +893,14 @@ type LivingVillageGame() as this =
VillageArt.drawGroundShadow spriteBatch pixel camera npc.Pos
let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick
VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint
+
+ // Golden-hour wash: the multiplicative WorldTint can only darken, so a warm
+ // premultiplied veil is layered over the whole scene. Because rgb is premultiplied
+ // by its own low alpha it adds warm light without ever saturating to white; alpha
+ // is a pure tick function, so noon/midnight get none and dawn/dusk peak warm.
+ 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)
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 752034a..25737d7 100644
--- a/src/LivingVillage.Desktop/M6Presentation.fs
+++ b/src/LivingVillage.Desktop/M6Presentation.fs
@@ -23,13 +23,17 @@ module M6Presentation =
| Day -> "白天"
| Night -> "夜晚"
- // Transition windows in float hours. Dawn lingers past 6:00 and dusk begins before
- // 20:00 so the blue hour is longer and the sky warms before it darkens; the anchors
- // still agree with Sim.isNightTick (night >= 22:00 or < 6:00) once fully dark.
+ // Transition windows in float hours. Dawn lingers past 6:00 so the blue hour is long.
+ // Dusk keeps two independent ramps: the darkening ramp 20:00 -> 22:00 (still agrees with
+ // Sim.isNightTick at 22:00), and a wider golden-hour warmth bump peaking at 20:30. Keeping
+ // them separate is what makes the orange dusk visible: at 20:45 the scene is still only
+ // ~0.37 dark (Day mode) while warmth is high.
let dawnStartHour = 4.0f
let dawnEndHour = 6.5f
- let duskStartHour = 19.5f
+ let duskStartHour = 20.0f
let duskEndHour = 22.0f
+ let duskWarmthStartHour = 19.25f
+ let duskWarmthEndHour = 21.75f
/// Continuous light blend: 0 = full day, 1 = full night. Deterministic pure function
/// of the simulation tick, parameterised by the dawn/dusk windows above.
@@ -53,23 +57,32 @@ module M6Presentation =
let mid = (startHour + endHour) * 0.5f
let half = (endHour - startHour) * 0.5f
1.0f - abs (hour - mid) / half
- max (bump dawnStartHour dawnEndHour) (bump duskStartHour duskEndHour)
+ max (bump dawnStartHour dawnEndHour) (bump duskWarmthStartHour duskWarmthEndHour)
+
+ /// Additive golden-hour veil alpha (0 at noon/midnight, peaks ~150 at dawn/dusk mid).
+ /// Multiplicative WorldTint alone cannot warm green foliage, so the renderer layers this
+ /// premultiplied orange wash over the finished world scene.
+ let goldenVeilAlpha (tick: int64) : int =
+ int (150.0f * transitionWarmth tick)
let private lerp (a: int) (b: int) (blend: float32) : int =
a + int (float32 (b - a) * blend)
- /// Blended sky/world colour plus the golden-hour warmth shift (warm channels up, blue down).
+ /// Blended sky colour plus a strong golden-hour shift: at dusk this reads as a warm orange
+ /// horizon (red up hard, blue down hard) rather than the old muted blue-grey twilight.
let backgroundAtWarmth (blend: float32) (warmth: float32) : Color =
Color(
- lerp 100 18 blend + int (52.0f * warmth),
- lerp 160 25 blend + int (14.0f * warmth),
- lerp 210 55 blend - int (34.0f * warmth))
+ lerp 100 18 blend + int (120.0f * warmth),
+ lerp 160 25 blend + int (20.0f * warmth),
+ lerp 210 55 blend - int (90.0f * warmth))
+ /// World tint also warms (red up, green slightly down, blue down) so tiles, props and
+ /// characters all lean golden before the additive veil is layered on top.
let worldTintAtWarmth (blend: float32) (warmth: float32) : Color =
Color(
- lerp 255 118 blend + int (10.0f * warmth),
- lerp 248 136 blend - int (6.0f * warmth),
- lerp 224 184 blend - int (26.0f * warmth))
+ lerp 255 118 blend + int (70.0f * warmth),
+ lerp 248 136 blend - int (10.0f * warmth),
+ lerp 224 184 blend - int (75.0f * warmth))
/// Warmth-free base colours (kept for callers that only have a blend value).
let backgroundAtBlend (blend: float32) : Color = backgroundAtWarmth blend 0.0f
@@ -83,7 +96,9 @@ module M6Presentation =
Background = backgroundAtWarmth blend warmth
Blend = blend
WorldTint = worldTintAtWarmth blend warmth
- LanternGlow = blend
+ // Lanterns glow through the golden hour too, so dusk reads as warm lanterns at a
+ // warm sunset rather than only a local night point-light.
+ LanternGlow = max blend (warmth * 0.8f)
SkyWarmth = warmth }
let profileAtTick (tick: int64) : M6RenderProfile = lightingAtTick tick
@@ -122,10 +137,11 @@ module M6Presentation =
sprintf "%02d:%02d" hour minute
/// 时段标签:清晨/白天/黄昏/夜晚,与实际 tick 一致(纯函数)。
+ /// 黄昏覆盖到 21:00,与 dusk 暖色窗口(峰值 20:30)对齐;22:00 起才是夜晚。
let timePeriodLabel (tick: int64) : string =
let hour = hourOf tick
if hour < 5 then "夜晚"
elif hour < 8 then "清晨"
elif hour < 17 then "白天"
- elif hour < 20 then "黄昏"
+ elif hour < 21 then "黄昏"
else "夜晚"