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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
|
namespace LivingVillage.Desktop
open Microsoft.Xna.Framework
open LivingVillage.Kernel
open LivingVillage.Kernel.Sim
/// 顶部窄信息条 HUD 的纯布局/配色模型:只做确定性计算,实际绘制在 Game.fs。
/// 常驻状态(时段/时间/倍速/体力/钱币)压进一条窄带,绝不用整屏纯色或大黑块。
module HudLayout =
let barHeight = 36
let accentHeight = 3
let paddingX = 16
let segmentGap = 20
let textScale = 2
/// 常驻段的像素图标(7x7 点阵,按 iconScale 放大),全部用 pixel 贴图绘制、无外部素材。
type HudIcon =
| PeriodIcon
| ClockIcon
| SpeedIcon
| EnergyIcon
| MoneyIcon
type Segment =
{ Label: string
Value: string
Icon: HudIcon }
let segmentText (segment: Segment) : string = sprintf "%s %s" segment.Label segment.Value
/// 常驻状态片段,数值全部取自世界真实状态,绝不编造。
let segments (world: World) (control: SimulationControl) : Segment list =
let needs = world.Avatar.Mind.Needs
[ { Label = "时段"; Value = M6Presentation.timePeriodLabel world.Tick; Icon = PeriodIcon }
{ Label = "时间"; Value = M6Presentation.clockTimeLabel world.Tick; Icon = ClockIcon }
{ Label = "倍速"; Value = M6Presentation.speedLabel control; Icon = SpeedIcon }
{ Label = "体力"; Value = sprintf "%.0f" needs.Energy; Icon = EnergyIcon }
{ Label = "钱币"; Value = sprintf "%.0f" needs.Money; Icon = MoneyIcon } ]
// ---- 图标几何与点阵(纯数据/纯函数,Game 与回归测试共用)----
let iconGrid = 7
let iconScale = textScale
let iconWidth = iconGrid * iconScale
let iconHeight = iconGrid * iconScale
let iconGap = 6
/// 7x7 点阵,'1' 为实心。全部原创点阵,仅用现有像素贴图绘制。
let iconRows (icon: HudIcon) : string list =
match icon with
| PeriodIcon ->
[ "0001000"
"1010101"
"0011100"
"0111110"
"0011100"
"1010101"
"0001000" ]
| ClockIcon ->
[ "0011100"
"0100010"
"1001001"
"1001000"
"1001000"
"0100010"
"0011100" ]
| SpeedIcon ->
[ "0100010"
"0110011"
"0011011"
"0001110"
"0011011"
"0110011"
"0100010" ]
| EnergyIcon ->
[ "0000000"
"0101010"
"1111111"
"1111111"
"0111110"
"0011100"
"0001000" ]
| MoneyIcon ->
[ "0011100"
"0111110"
"1110111"
"1100011"
"1110111"
"0111110"
"0011100" ]
/// 文本起始 x(图标占 iconWidth + iconGap)。
let textOrigin (segmentX: int) : int = segmentX + iconWidth + iconGap
/// 段文本(含标签)的像素宽度。
let segmentWidth (segment: Segment) : int =
FloatingPrompt.measureWidth (segmentText segment) textScale
/// 段总宽(图标 + 间隙 + 文本)。
let segmentSpan (segment: Segment) : int =
iconWidth + iconGap + segmentWidth segment
/// 左对齐排布,返回 (起点 x, 总宽, 分段);段内 x 起为图标,textOrigin 起为文本。
/// 纯函数,便于布局回归断言。
let layout (segments: Segment list) : (int * int * Segment) list =
let _, placed =
segments
|> List.fold
(fun (x, acc) segment ->
let width = segmentSpan segment
x + width + segmentGap, (x, width, segment) :: acc)
(paddingX, [])
List.rev placed
/// P26:常驻片段文本的 1px 描边偏移(上/下/左/右),保证白天/夜景下都可读。
let textOutlineOffsets : (int * int) list = [ (-1, 0); (1, 0); (0, -1); (0, 1) ]
/// P26:常驻片段的半透明底色药丸,贴合文字高度并在左右各留 6px;纯函数便于回归。
let segmentBackdrop (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle =
let height = 8 * textScale + 10
let y = (barHeight - height) / 2
Microsoft.Xna.Framework.Rectangle(x - 6, y, width + 12, height)
type Palette =
{ BarTop: Color
BarBottom: Color
Accent: Color
Label: Color
Value: Color
SegmentBackdrop: Color
Outline: Color }
/// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。
/// 每个常驻片段再叠一层半透明底色 + 1px 深色描边,双时段都能读清。
let palette (mode: M6DayNight) : Palette =
match mode with
| Day ->
{ BarTop = Color(40, 56, 62, 200)
BarBottom = Color(22, 34, 40, 200)
Accent = Color(212, 168, 96)
Label = Color(206, 220, 208)
Value = Color(242, 248, 234)
SegmentBackdrop = Color(8, 14, 20, 150)
Outline = Color(6, 10, 14, 255) }
| Night ->
{ BarTop = Color(22, 30, 48, 205)
BarBottom = Color(13, 19, 33, 205)
Accent = Color(120, 168, 196)
Label = Color(176, 196, 214)
Value = Color(228, 238, 246)
SegmentBackdrop = Color(4, 8, 16, 160)
Outline = Color(3, 6, 12, 255) }
// ---- P43:右上日历钟卡(纯模型;季节/天数/时段/时钟全部由真实 tick 导出)----
type CalendarReading =
{ Season: string
DayNumber: int64
Period: string
Clock: string }
/// 一季 28 模拟日,四季循环。纯演示层日历,只读 Tick,不写任何模拟状态。
let daysPerSeason = 28L
let seasonNames = [| "春"; "夏"; "秋"; "冬" |]
let dayIndexOf (tick: int64) : int64 = tick / Sim.ticksPerDay
let dayNumberOf (tick: int64) : int64 = dayIndexOf tick + 1L
let seasonOf (tick: int64) : string =
let index = ((dayIndexOf tick / daysPerSeason) % 4L + 4L) % 4L
seasonNames.[int index]
let calendarReading (tick: int64) : CalendarReading =
{ Season = seasonOf tick
DayNumber = dayNumberOf tick
Period = M6Presentation.timePeriodLabel tick
Clock = M6Presentation.clockTimeLabel tick }
let calendarLine1 (reading: CalendarReading) : string =
sprintf "%s · 第%d天" reading.Season reading.DayNumber
let calendarLine2 (reading: CalendarReading) : string =
sprintf "%s %s" reading.Period reading.Clock
let calendarMarginRight = 12
let calendarMarginTop = 8
let calendarPadX = 10
let calendarPadY = 8
let calendarLineGap = 4
let calendarTextHeight = 8 * textScale
let calendarCardHeight = 2 * calendarTextHeight + calendarLineGap + 2 * calendarPadY
let calendarIcon = ClockIcon
/// 右上角卡片矩形:宽度按两行文字实测宽度 + 图标 + 内边距,绝不越界、绝不压住顶部窄条。
let calendarCardRect (viewportWidth: int) (reading: CalendarReading) : Rectangle =
let textWidth =
max
(FloatingPrompt.measureWidth (calendarLine1 reading) textScale)
(FloatingPrompt.measureWidth (calendarLine2 reading) textScale)
let width = iconWidth + iconGap + textWidth + 2 * calendarPadX
Rectangle(
viewportWidth - width - calendarMarginRight,
barHeight + accentHeight + calendarMarginTop,
width,
calendarCardHeight)
// ---- P43:体力图形化条形(填充比例取真实 Needs.Energy,0..100)----
let energyBarHeight = 10
let energyBarFill (energy: float32) : float32 =
if energy < 0.0f then 0.0f
elif energy > 100.0f then 1.0f
else energy / 100.0f
let energyBarTrack (x: int) (y: int) (width: int) : Rectangle =
Rectangle(x, y, width, energyBarHeight)
let energyBarFillRect (x: int) (y: int) (width: int) (energy: float32) : Rectangle =
Rectangle(x, y, int (float32 width * energyBarFill energy), energyBarHeight)
// ---- P43:底部 10 格 hotbar(原创点阵图标 + 键 1-0 选中 + 选中高亮)----
let hotbarSlotCount = 10
let hotbarSlotSize = 40
let hotbarSlotGap = 6
let hotbarIconScale = 3
let hotbarPad = 8
let hotbarBottomMargin = 12
let hotbarWidth = hotbarSlotCount * hotbarSlotSize + (hotbarSlotCount - 1) * hotbarSlotGap
let hotbarBarRect (viewportWidth: int) (viewportHeight: int) : Rectangle =
Rectangle(
(viewportWidth - hotbarWidth) / 2 - hotbarPad,
viewportHeight - hotbarSlotSize - hotbarBottomMargin - hotbarPad,
hotbarWidth + 2 * hotbarPad,
hotbarSlotSize + 2 * hotbarPad)
let hotbarSlotRect (viewportWidth: int) (viewportHeight: int) (index: int) : Rectangle =
let bar = hotbarBarRect viewportWidth viewportHeight
Rectangle(
bar.X + hotbarPad + index * (hotbarSlotSize + hotbarSlotGap),
bar.Y + hotbarPad,
hotbarSlotSize,
hotbarSlotSize)
let hotbarIconRect (slot: Rectangle) : Rectangle =
let size = iconGrid * hotbarIconScale
Rectangle(slot.X + (slot.Width - size) / 2, slot.Y + (slot.Height - size) / 2, size, size)
/// 10 个原创 7x7 点阵占位图标(0..9),仅用现有像素贴图绘制,无外部素材。
let hotbarIconPatterns : string list list =
[ [ "0011100"; "0100010"; "1000001"; "1000001"; "1000001"; "0100010"; "0011100" ]
[ "0001000"; "0011000"; "0001000"; "0001000"; "0001000"; "0001000"; "0011100" ]
[ "0000000"; "0100010"; "1110111"; "1111111"; "0111110"; "0011100"; "0001000" ]
[ "0001000"; "0001000"; "0011100"; "0111110"; "1111111"; "1111111"; "0111110" ]
[ "0000000"; "0001000"; "0011100"; "1111110"; "0011100"; "0001000"; "0000000" ]
[ "0010100"; "0010100"; "0111110"; "0011100"; "0011100"; "0101010"; "0100010" ]
[ "0011100"; "0111110"; "1101011"; "1101011"; "1101011"; "0111110"; "0011100" ]
[ "0010001"; "0010010"; "0010100"; "0011000"; "0110000"; "0100000"; "1000000" ]
[ "0000100"; "0001100"; "0011110"; "0111100"; "0111000"; "0110000"; "0100000" ]
[ "0001000"; "0011100"; "0111110"; "1111111"; "0011100"; "0100010"; "1111111" ] ]
let hotbarIconRows (index: int) : string list =
hotbarIconPatterns.[((index % hotbarSlotCount) + hotbarSlotCount) % hotbarSlotCount]
/// 键位 1..9 -> 槽 0..8,键 0 -> 槽 9;其余键不选格。
let selectSlotForDigit (digit: int) : int option =
if digit >= 1 && digit <= 9 then Some (digit - 1)
elif digit = 0 then Some 9
else None
/// 在 0..9 内循环移动选中格(供方向导航/测试),越界自动回绕。
let cycleHotbar (current: int) (delta: int) : int =
let n = hotbarSlotCount
((current + delta) % n + n) % n
/// hotbar 输入仅在「游戏内(非菜单)」且世界输入允许(无面板/对话)时生效。
let hotbarInputAllowed (menuPlaying: bool) (worldInputAllowed: bool) : bool =
menuPlaying && worldInputAllowed
|