summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/DialogOverlay.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop/DialogOverlay.fs')
-rw-r--r--src/LivingVillage.Desktop/DialogOverlay.fs193
1 files changed, 193 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop/DialogOverlay.fs b/src/LivingVillage.Desktop/DialogOverlay.fs
new file mode 100644
index 0000000..58ce33e
--- /dev/null
+++ b/src/LivingVillage.Desktop/DialogOverlay.fs
@@ -0,0 +1,193 @@
+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
+ let bottomMargin = 24
+
+ 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" ]