summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/M6Presentation.fs
blob: 25737d70d647ed3de59c83abb62d59216d0e5c05 (plain)
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
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)

    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 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 (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
    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 "夜晚"