diff options
Diffstat (limited to 'src/LivingVillage.Desktop/DialogOverlay.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/DialogOverlay.fs | 193 |
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" ] |
