summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/HudLayout.fs
blob: 63d4ed5beaba9840243b0c9c2e47e127e15f4ab8 (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
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
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
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)

    /// P75:药丸底部 1px 强调发丝线(取代整条铺满的 accent 带,让留白更足)。
    let pillHairline (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle =
        let pill = segmentBackdrop x width
        Microsoft.Xna.Framework.Rectangle(pill.X + 1, pill.Y + pill.Height - 2, max 1 (pill.Width - 2), 1)

    /// P75:顶部不再铺整宽深色带(0 = 仅靠半透明药丸承载信息,去掉「整块深色底」)。
    let bandAlpha : byte = 0uy

    /// P75:药丸高度的紧凑上界(须 < barHeight 以留出上下留白)。
    let pillHeight : int = 8 * textScale + 10

    type Palette =
        { BarTop: Color
          BarBottom: Color
          Accent: Color
          Label: Color
          Value: Color
          SegmentBackdrop: Color
          Outline: Color }

    /// 白天暖色 / 夜晚(冬夜)冷色微调;半透明药丸承载信息,无整宽深色带、无大黑块。
    /// P75 起 `BarTop/BarBottom` 保留字段但 alpha=0(不再铺整条底色)。
    let palette (mode: M6DayNight) : Palette =
        match mode with
        | Day ->
            { BarTop = Color(40, 56, 62, 0)
              BarBottom = Color(22, 34, 40, 0)
              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, 0)
              BarBottom = Color(13, 19, 33, 0)
              Accent = Color(120, 168, 196)
              Label = Color(176, 196, 214)
              Value = Color(228, 238, 246)
              SegmentBackdrop = Color(4, 8, 16, 170)
              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 = 6
    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)

    // ---- P76:底部条/槽位药丸化(键位符号 + 图形并排,去大色块)----

    /// 键位符号小片(数字 1..0)尺寸:放在槽位左侧,与图标并排。
    let hotbarKeyChipWidth = 12
    let hotbarKeyChipHeight = 12

    /// 槽位内的键位小片矩形(左对齐、垂直居中)。
    let hotbarKeyChipRect (slot: Rectangle) : Rectangle =
        Rectangle(slot.X + 3, slot.Y + (slot.Height - hotbarKeyChipHeight) / 2, hotbarKeyChipWidth, hotbarKeyChipHeight)

    /// 槽位内图形矩形(键位小片右侧,与图标并排)。
    let hotbarIconRectBesideKey (slot: Rectangle) : Rectangle =
        let size = iconGrid * hotbarIconScale
        let x = (hotbarKeyChipRect slot).Right + 4
        Rectangle(x, slot.Y + (slot.Height - size) / 2, size, size)

    /// 底部条顶部 1px 强调发丝线(与 P75 顶部条同语言)。
    let hotbarBarHairline (bar: Rectangle) : Rectangle = Rectangle(bar.X + 1, bar.Y + 1, max 1 (bar.Width - 2), 1)

    // ---- P76:真实按键 + 动作名图例(纯展示,取自 MenuState.controlRows,不改任何绑定)----

    /// 底部图例:真实存在的按键/动作对(subset of MenuState.controlRows 的顺序)。
    /// 仅做信息层次展示,不新增/改动任何快捷键;hotbar 的 1-0 选中语义保持不变。
    let actionLegendKeys : string list = [ "E"; "Q"; "TAB"; "C"; "L"; "F6"; "F7" ]

    let actionLegend (rows: ControlRow list) : ControlRow list =
        actionLegendKeys
        |> List.choose (fun key -> rows |> List.tryFind (fun row -> row.Keys = key))

    /// 图例单条文本宽度(键位 + 空格 + 动作名)。
    let legendEntryWidth (row: ControlRow) : int =
        FloatingPrompt.measureWidth (sprintf "%s %s" row.Keys row.Action) textScale

    /// 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

    // ---- P77:底部左侧互动/任务面板几何(与 hotbar 错开,不遮挡)----

    let panelLineHeight = 18
    let panelMaxWidth = 380
    let panelPadX = 12
    let panelTopExtra = 26

    /// 面板几何:左下角、宽度自适应视口,且底边始终抬到 hotbar 顶上方 8px(不遮挡快捷栏)。
    let panelRect (viewportWidth: int) (viewportHeight: int) (lineCount: int) : Rectangle =
        let width = min panelMaxWidth (max 1 (viewportWidth - 32))
        let height = max 1 lineCount * panelLineHeight + panelTopExtra
        let bar = hotbarBarRect viewportWidth viewportHeight
        let maxBottom = bar.Y - 8
        let y =
            max (barHeight + 12) (min (viewportHeight - height - 16) (max 0 (maxBottom - height)))
        Rectangle(16, y, width, height)

    // ---- P76:面板文本按实测像素宽度折行(换行而非整行截断,中文逐字、拉丁逐字)----

    let wrapText (maxWidth: int) (scale: int) (text: string) : string list =
        if text = "" then
            [ "" ]
        else
            let builder = System.Text.StringBuilder()
            let mutable lines = []
            for ch in text.ToCharArray() do
                let candidate = builder.ToString() + string ch
                if builder.Length > 0 && FloatingPrompt.measureWidth candidate scale > maxWidth then
                    lines <- builder.ToString() :: lines
                    builder.Clear() |> ignore
                    builder.Append(ch) |> ignore
                else
                    builder.Append(ch) |> ignore
            lines <- builder.ToString() :: lines
            List.rev lines

    /// 把多行按宽度折行后铺平(不丢字符、不截断)。
    let wrapLines (maxWidth: int) (scale: int) (lines: string list) : string list =
        lines |> List.collect (wrapText maxWidth scale)