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" ]
|