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