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
162
163
164
165
166
167
168
169
|
namespace LivingVillage.Desktop
module TitleScreen =
type ColorRgb =
{ R: int
G: int
B: int }
type TitleLayout =
{ SkyBands: ColorRgb list
RoofTileSlots: int list
RidgeY: int
EaveY: int
WaterRows: int list
Lanterns: (int * int) list
TitleText: string
TitleX: int
TitleY: int
TitleScale: int
MenuX: int
MenuTopY: int
MenuLineHeight: int
PanelX: int
PanelY: int
PanelWidth: int
PanelHeight: int
HeadingY: int
FirstItemY: int
CursorX: int
CursorWidth: int
TextX: int
ControlsPanelY: int
ControlsRowHeight: int
ControlsKeyX: int
ControlsKeyWidth: int
ControlsActionX: int }
type TextRect =
{ X: int
Y: int
Width: int
Height: int }
let rectsDisjoint (a: TextRect) (b: TextRect) : bool =
a.X + a.Width <= b.X
|| b.X + b.Width <= a.X
|| a.Y + a.Height <= b.Y
|| b.Y + b.Height <= a.Y
/// Key-name and action rectangles for one controls-page row. The key column has a
/// fixed width shared by every row, so all action texts start on the same x.
let controlRowRects (layout: TitleLayout) (rowIndex: int) : TextRect * TextRect =
let y = layout.ControlsPanelY + 60 + rowIndex * layout.ControlsRowHeight
{ X = layout.ControlsKeyX; Y = y; Width = layout.ControlsKeyWidth; Height = 16 },
{ X = layout.ControlsActionX; Y = y; Width = layout.PanelX + layout.PanelWidth - layout.ControlsActionX - 24; Height = 16 }
/// Cursor and label rectangles for one menu row. The cursor is drawn in its own
/// column; the label always starts to the right of it, so they can never overprint.
let selectionRects (layout: TitleLayout) (rowIndex: int) : TextRect * TextRect =
let y = layout.FirstItemY + rowIndex * layout.MenuLineHeight
{ X = layout.CursorX; Y = y; Width = layout.CursorWidth; Height = 16 },
{ X = layout.TextX; Y = y; Width = layout.PanelX + layout.PanelWidth - layout.TextX - 24; Height = 16 }
// Palette mirrors the VillageArt atlas: roof (45,49,57)/(30,33,38), water (54,116,145),
// lantern paper (212,92,74). Purely decorative reuse of existing art colors.
let private skyBands : ColorRgb list =
[ { R = 16; G = 26; B = 42 }
{ R = 20; G = 36; B = 52 }
{ R = 24; G = 46; B = 62 }
{ R = 28; G = 56; B = 68 }
{ R = 32; G = 64; B = 72 } ]
let private roofRidgeSlot = 8
let private roofEaveSlot = 9
let private waterTileSlot = 1
let private lanternColor = { R = 212; G = 92; B = 74 }
let private lanternHalo = { R = 252; G = 216; B = 148 }
let lanternColors : ColorRgb * ColorRgb = lanternColor, lanternHalo
let skyBandCount = skyBands.Length
let waterSlot = waterTileSlot
let roofSlots = roofRidgeSlot, roofEaveSlot
/// Deterministic 2s entrance at the fixed 60Hz step: 1.0 = elements fully parked
/// off-screen at start, 0.0 = settled. Pure function of the frame counter.
let entranceProgress (frameCount: int64) : float32 =
if frameCount >= 120L then 0.0f
else 1.0f - float32 frameCount / 120.0f
// ---- P42 environment FX: all pure functions of the frame counter, so re-rendering
// the same frame is byte-identical. Shared by the splash and the start menu. ----
/// Sine ripple amplitude of the water band. Kept as a named constant so a test can
/// assert it never regresses below the full-frame visibility budget (>= 0.25).
let waterRippleAmplitude = 0.32f
/// Water ripple phase: moves with the frame and is dense across tile columns
/// (~5 tiles per wave), so adjacent 28px columns differ enough to read at 1x.
let waterRipplePhase (frame: int64) (column: int) (rowIndex: int) : float32 =
float32 (frame % 4096L) / 5.0f + float32 column * 1.25f + float32 rowIndex * 0.6f
/// Water ripple brightness per tile column: base 1.0 +/- waterRippleAmplitude sine.
let waterRippleBrightness (frame: int64) (column: int) (rowIndex: int) : float32 =
1.0f + waterRippleAmplitude * sin (waterRipplePhase frame column rowIndex)
/// Water crest lift in whole pixels (-2..2) for a tile column; drives the moving
/// ripple highlight without ever leaving a hole in the water band.
let waterRippleLift (frame: int64) (column: int) (rowIndex: int) : int =
int (System.Math.Round(float (sin (waterRipplePhase frame column rowIndex) * 2.0f), System.MidpointRounding.AwayFromZero))
/// True on columns sitting on a wave crest; the renderer caps these with a bright
/// highlight and the frame analysis counts them.
let waterRippleCrest (frame: int64) (column: int) (rowIndex: int) : bool =
sin (waterRipplePhase frame column rowIndex) > 0.55f
/// Eave-lantern breathing glow alpha, bounded to [40, 96]; phase moves with the
/// frame and the lantern index so the bank lanterns breathe out of step.
let lanternBreathAlpha (frame: int64) (index: int) : int =
let phase = float32 (frame % 4096L) / 22.0f + float32 index * 1.3f
68 + int (System.Math.Round(float (sin phase * 28.0f), System.MidpointRounding.AwayFromZero))
let layout (viewportWidth: int) (viewportHeight: int) : TitleLayout =
let width = max 320 viewportWidth
let height = max 240 viewportHeight
let tilePixels = 32
let columns = width / tilePixels
let roofTileSlots =
[ 0 .. columns - 1 ]
|> List.map (fun column ->
if column % 4 = 0 || column % 7 = 3 then roofRidgeSlot else roofEaveSlot)
let waterRows =
[ height - 96
height - 64 ]
let lanternCount = max 3 (columns / 10)
let lanterns =
[ 1 .. lanternCount ]
|> List.map (fun index ->
let x = index * width / (lanternCount + 1)
let y = (List.head waterRows) - 44 + (if index % 2 = 0 then 12 else 0)
x, y)
let panelWidth = min 460 (width - 64)
{ SkyBands = skyBands
RoofTileSlots = roofTileSlots
RidgeY = 44
EaveY = 76
WaterRows = waterRows
Lanterns = lanterns
TitleText = "江南水乡"
TitleX = (width - 4 * (4 * 16)) / 2
TitleY = 120
TitleScale = 4
MenuX = (width - panelWidth) / 2 + 40
MenuTopY = height / 2 - 40
MenuLineHeight = 30
PanelX = (width - panelWidth) / 2
PanelY = height / 2 - 64
PanelWidth = panelWidth
PanelHeight = min 360 (height - height / 2 - 40)
HeadingY = height / 2 - 64 + 20
FirstItemY = height / 2 - 64 + 60
CursorX = (width - panelWidth) / 2 + 24
CursorWidth = 20
TextX = (width - panelWidth) / 2 + 24 + 20 + 12
ControlsPanelY = max 16 (height - 480)
ControlsRowHeight = 25
ControlsKeyX = (width - panelWidth) / 2 + 24
ControlsKeyWidth = 120
ControlsActionX = (width - panelWidth) / 2 + 24 + 120 + 12 }
|