1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
|
namespace LivingVillage.Desktop
open Microsoft.Xna.Framework
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
type M6DayNight =
| Day
| Night
type M6RenderProfile =
{ Mode: M6DayNight
Background: Color
WorldTint: Color
Blend: float32
LanternGlow: float32
SkyWarmth: float32 }
module M6Presentation =
let dayNightLabel mode : string =
match mode with
| Day -> "白天"
| Night -> "夜晚"
// 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 = 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.
let lightingBlend (tick: int64) : float32 =
let hourTicks = Sim.ticksPerDay / 24L
let hour = float32 tick / float32 hourTicks
let clamp01 v = if v < 0.0f then 0.0f elif v > 1.0f then 1.0f else v
if hour >= dawnEndHour && hour < duskStartHour then 0.0f
elif hour >= duskEndHour || hour < dawnStartHour then 1.0f
elif hour < dawnEndHour then clamp01 (1.0f - (hour - dawnStartHour) / (dawnEndHour - dawnStartHour))
else clamp01 ((hour - duskStartHour) / (duskEndHour - duskStartHour))
/// Golden-hour warmth (0..1): a triangular bump peaking mid-dawn and mid-dusk, zero at
/// noon and midnight. Drives the warm sky/world tint so P17 transition frames read golden.
let transitionWarmth (tick: int64) : float32 =
let hour = float32 tick / float32 (Sim.ticksPerDay / 24L)
let bump (startHour: float32) (endHour: float32) : float32 =
if hour <= startHour || hour >= endHour then
0.0f
else
let mid = (startHour + endHour) * 0.5f
let half = (endHour - startHour) * 0.5f
1.0f - abs (hour - mid) / half
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)
/// 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)
/// 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 (120.0f * warmth),
lerp 160 25 blend + int (20.0f * warmth),
lerp 210 55 blend - int (90.0f * warmth))
/// World tint multiplies the whole scene. The day baseline is neutral white so noon keeps
/// a true white balance (the old day base 255/248/224 clipped blue by ~12% and made daylight
/// look yellow); night stays cool, and the golden shade only appears through `warmth`, which
/// is a pure function that is zero at noon and outside the dawn/dusk windows.
let worldTintAtWarmth (blend: float32) (warmth: float32) : Color =
Color(
lerp 255 118 blend + int (70.0f * warmth),
lerp 255 136 blend - int (10.0f * warmth),
lerp 255 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
let worldTintAtBlend (blend: float32) : Color = worldTintAtWarmth blend 0.0f
/// Pure day-phase lighting: deterministic tint/background/warmth plus lantern glow.
let lightingAtTick (tick: int64) : M6RenderProfile =
let blend = lightingBlend tick
let warmth = transitionWarmth tick
{ Mode = if blend < 0.5f then Day else Night
Background = backgroundAtWarmth blend warmth
Blend = blend
WorldTint = worldTintAtWarmth blend warmth
// 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
let clockLabel (control: SimulationControl) : string =
if control.Paused then
"暂停"
else
match control.Speed with
| OneX -> "1倍速"
| TwoX -> "2倍速"
| FiveX -> "5倍速"
/// 倍速标签(语义别名,HUD 用)。
let speedLabel (control: SimulationControl) : string = clockLabel control
/// Deterministic start tick for a wall-clock hour (evidence harness): 20.75 -> 20:45.
let startTickForHour (hour: float) : int64 =
int64 (hour * float (Sim.ticksPerDay / 24L))
/// Start-tick decision shared by bootstrap and new-game: an explicit evidence hour wins,
/// else daylight starts at 6:00, else midnight. Pure function, no side effects.
let resolveStartTick (startHour: float option) (startAtDaylight: bool) : int64 =
match startHour with
| Some hour -> startTickForHour hour
| None -> if startAtDaylight then 6L * Sim.ticksPerDay / 24L else 0L
let private hourOf (tick: int64) : int =
int ((tick / (Sim.ticksPerDay / 24L)) % 24L)
/// 24 小时制的模拟时钟(HH:MM),由 tick 纯函数导出。
let clockTimeLabel (tick: int64) : string =
let hourTicks = Sim.ticksPerDay / 24L
let hour = hourOf tick
let minute = int ((tick % hourTicks) * 60L / hourTicks)
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 < 21 then "黄昏"
else "夜晚"
|