summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/DialogOverlay.fs
blob: 0c402ec134155c50121883a87bf27600339b5611 (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
namespace LivingVillage.Desktop

open Microsoft.Xna.Framework

/// P44 对话框/文字弹层:纯模型组件。
/// 负责完整背景框几何、中文正确断行的自动换行、分页(下一页)与关闭语义、
/// 开启时对世界交互的隔离判定,以及原创 32x64 点阵头像。
/// 只做确定性纯计算,实际绘制在 Game.fs。
module DialogOverlay =

    let textScale = 2
    let lineHeight = 18
    let linesPerPage = 4
    let paddingX = 16
    let paddingY = 12
    let titleGap = 6
    let avatarWidth = 32
    let avatarHeight = 64
    let avatarGap = 12
    let pageMarkerHeight = 20
    let minFrameWidth = 480
    let maxFrameWidth = 900
    // P77:底部预留 hotbar 带(约 60px)+ 间距,保证弹层不遮挡快捷栏。
    let bottomMargin = 72

    type Layout =
        { X: int
          Y: int
          Width: int
          Height: int }

    type Model =
        { Title: string
          Pages: string list list
          Page: int
          ShowAvatar: bool }

    type View =
        { Lines: string list
          PageNumber: int
          PageCount: int
          HasPrev: bool
          HasNext: bool }

    // ---- 中文正确断行 ----

    let private isWide (c: char) : bool = (CjkGlyphAtlas.sourceRectangle c).IsSome

    /// 分词:CJK/可见字形单字成词,空格单独成词,拉丁/数字连续成词。
    /// 这样中文可在任意字间断行,拉丁词不会被从中间劈开。
    let private tokenize (paragraph: string) : string list =
        let tokens = System.Collections.Generic.List<string>()
        let buffer = System.Text.StringBuilder()
        let flush () =
            if buffer.Length > 0 then
                tokens.Add(buffer.ToString())
                buffer.Clear() |> ignore
        for c in paragraph do
            if isWide c then
                flush ()
                tokens.Add(string c)
            elif c = ' ' then
                flush ()
                tokens.Add " "
            else
                buffer.Append(c) |> ignore
        flush ()
        List.ofSeq tokens

    /// 把一段文字按像素宽度换行;显式 \n 强制换行。返回行列表。
    let wrapText (maxWidth: int) (text: string) : string list =
        let maxWidth = max 1 maxWidth
        text.Split('\n')
        |> Array.toList
        |> List.collect (fun paragraph ->
            let lines = System.Collections.Generic.List<string>()
            let current = System.Text.StringBuilder()
            let mutable currentWidth = 0
            let flushLine () =
                lines.Add(current.ToString().TrimEnd([| ' ' |]))
                current.Clear() |> ignore
                currentWidth <- 0
            for token in tokenize paragraph do
                let tokenWidth = FloatingPrompt.measureWidth token textScale
                if current.Length > 0 && currentWidth + tokenWidth > maxWidth then
                    flushLine ()
                if not (token = " " && current.Length = 0) then
                    current.Append(token) |> ignore
                    currentWidth <- currentWidth + tokenWidth
            if current.Length > 0 then flushLine ()
            if lines.Count = 0 then [ "" ] else List.ofSeq lines)

    // ---- 分页 ----

    let frameWidth (viewportWidth: int) : int =
        max minFrameWidth (min maxFrameWidth (viewportWidth - 64))

    let textWidth (viewportWidth: int) (showAvatar: bool) : int =
        frameWidth viewportWidth - 2 * paddingX - (if showAvatar then avatarWidth + avatarGap else 0)

    let create (title: string) (texts: string list) (viewportWidth: int) (showAvatar: bool) : Model =
        let maxWidth = textWidth viewportWidth showAvatar
        let body = texts |> List.collect (wrapText maxWidth)
        let pages = if body.IsEmpty then [ [] ] else List.chunkBySize linesPerPage body
        { Title = title
          Pages = pages
          Page = 0
          ShowAvatar = showAvatar }

    let pageCount (model: Model) : int = max 1 model.Pages.Length

    let private clampPage (model: Model) : int =
        if model.Pages.IsEmpty then 0 else max 0 (min (model.Pages.Length - 1) model.Page)

    let view (model: Model) : View =
        let page = clampPage model
        let total = pageCount model
        { Lines = (if model.Pages.IsEmpty then [] else model.Pages.[page])
          PageNumber = page + 1
          PageCount = total
          HasPrev = page > 0
          HasNext = page < total - 1 }

    /// 下一页;已在最后一页时返回 None,调用方保持当前页(翻页边界不崩)。
    let nextPage (model: Model) : Model option =
        let page = clampPage model
        if page < pageCount model - 1 then Some { model with Page = page + 1 } else None

    /// 上一页;已在第一页时返回 None。
    let prevPage (model: Model) : Model option =
        let page = clampPage model
        if page > 0 then Some { model with Page = page - 1 } else None

    // ---- 关闭与隔离 ----

    /// 关闭当前弹层。
    let close (_: Model option) : Model option = None

    /// 弹层开启时禁止任何世界交互穿透。
    let blocksWorldInteraction (overlayOpen: bool) : bool = overlayOpen

    /// 世界交互提示在弹层开启时隐藏。
    let promptVisible (overlayOpen: bool) : bool = not overlayOpen

    // ---- 几何 ----

    let private headerHeight = 8 * textScale + titleGap

    let rect (viewportWidth: int) (viewportHeight: int) (model: Model) : Layout =
        let current = view model
        let width = frameWidth viewportWidth
        let bodyLines = max 1 current.Lines.Length
        let bodyHeight = bodyLines * lineHeight
        let contentHeight = headerHeight + max bodyHeight (if model.ShowAvatar then avatarHeight else 0)
        let height = contentHeight + 2 * paddingY + pageMarkerHeight
        { X = (viewportWidth - width) / 2
          Y = viewportHeight - height - bottomMargin
          Width = width
          Height = height }

    let textOriginX (layout: Layout) (showAvatar: bool) : int =
        layout.X + paddingX + (if showAvatar then avatarWidth + avatarGap else 0)

    let textOriginY (layout: Layout) : int = layout.Y + paddingY + headerHeight

    let avatarRect (layout: Layout) : Layout =
        { X = layout.X + paddingX
          Y = layout.Y + paddingY + headerHeight
          Width = avatarWidth
          Height = avatarHeight }

    // ---- 原创 32x64 点阵头像(8x16 @ scale 4)----

    let avatarColumns = 8
    let avatarRows = 16
    let avatarScale = 4

    let avatarPattern : string list =
        [ "00000000"
          "00111100"
          "01111110"
          "01111110"
          "01111110"
          "00111100"
          "00011000"
          "00011000"
          "00111100"
          "01111110"
          "11111111"
          "11111111"
          "11111111"
          "11011011"
          "10000001"
          "00000000" ]