diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:29:27 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:29:27 +0800 |
| commit | e28b3c102f2a0b387a4df84f053306d71e5e41ba (patch) | |
| tree | 0b3b13b10bba4283dde3aba60cc0ec42495d6b82 /src | |
| parent | d671335d687496f393dbad63950707cabbf24e5b (diff) | |
| download | living-village-e28b3c102f2a0b387a4df84f053306d71e5e41ba.tar.gz | |
p44: 对话框/文字弹层 — 完整背景框+中文断行换行+分页翻页+Esc关闭+输入隔离(原创32x64点阵头像)
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P44DialogTests.fs | 123 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/DialogOverlay.fs | 193 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 210 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 |
5 files changed, 512 insertions, 16 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index d9bbee1..68ec033 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -23,6 +23,7 @@ <Compile Include="P40ArtTests.fs" /> <Compile Include="P42LaunchFxTests.fs" /> <Compile Include="P43HudTests.fs" /> + <Compile Include="P44DialogTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P44DialogTests.fs b/src/LivingVillage.Desktop.Tests/P44DialogTests.fs new file mode 100644 index 0000000..a42be5c --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P44DialogTests.fs @@ -0,0 +1,123 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P44 对话框/文字弹层回归:中文断行、分页边界、输入隔离、Esc 关闭、提示隐藏、原创头像。 +[<TestClass>] +type P44DialogTests () = + + let world = Sim.initialWorldN 42UL 4 + + let assertEqualMsg (expected: 'a) (actual: 'a) (message: string) = + Assert.IsTrue((expected = actual), sprintf "%s (expected %A, actual %A)" message expected actual) + + // ---- 中文正确断行 ---- + + [<TestMethod>] + member _.WrapBreaksChinesePerCharacterNotPerByte () = + // 每个 CJK 字宽 = 8 * textScale = 16px;宽 64px 恰好容纳 4 字。 + assertEqualMsg [ "互动村民"; "求助" ] (DialogOverlay.wrapText 64 "互动村民求助") "按字断行" + // 单字过宽时也必须能断,不得死循环或丢字。 + assertEqualMsg [ "互"; "动"; "村" ] (DialogOverlay.wrapText 10 "互动村") "超窄宽度逐字断行" + + [<TestMethod>] + member _.WrapKeepsLatinWordsWholeAndHonoursNewlines () = + // "hello" = 5*6*2 = 60px;宽 70px 只能放一个词。 + assertEqualMsg [ "hello"; "world" ] (DialogOverlay.wrapText 70 "hello world") "拉丁词不劈开" + assertEqualMsg [ "ab"; ""; "cd" ] (DialogOverlay.wrapText 200 "ab\n\ncd") "显式换行保留空行" + assertEqualMsg [ "" ] (DialogOverlay.wrapText 200 "") "空串给一行空" + for line in DialogOverlay.wrapText 64 "互动村民闲聊求助交易" do + Assert.IsTrue( + FloatingPrompt.measureWidth line DialogOverlay.textScale <= 64, + sprintf "换行后每行须不超宽:%s" line) + + // ---- 分页 ---- + + [<TestMethod>] + member _.PagesChunkBodyByLinesPerPage () = + let texts = [ "a"; "b"; "c"; "d"; "e" ] + let model = DialogOverlay.create "标题" texts 1280 false + assertEqualMsg 2 (DialogOverlay.pageCount model) "5 行按每页 4 行切成 2 页" + let pages = model.Pages + assertEqualMsg 4 pages.[0].Length "首页 4 行" + assertEqualMsg 1 pages.[1].Length "次页 1 行" + + [<TestMethod>] + member _.NextPageStopsAtLastPageWithoutCrash () = + let model = DialogOverlay.create "标题" [ "a"; "b"; "c"; "d"; "e" ] 1280 false + // 翻到最后一页。 + let mutable current = model + let mutable guard = 0 + while (DialogOverlay.nextPage current).IsSome && guard < 100 do + current <- (DialogOverlay.nextPage current).Value + guard <- guard + 1 + let last = DialogOverlay.view current + assertEqualMsg last.PageCount last.PageNumber "停在最后一页" + Assert.IsFalse(last.HasNext, "最后一页没有下一页") + // 再翻不崩、保持最后一页。 + assertEqualMsg None (DialogOverlay.nextPage current) "最后一页再翻返回 None" + assertEqualMsg current { current with Page = current.Page } "最后一页再翻不改状态" + assertEqualMsg None (DialogOverlay.prevPage { model with Page = 0 }) "首页再往回返回 None" + + // ---- 输入隔离 / 关闭 / 提示隐藏 ---- + + [<TestMethod>] + member _.OverlayBlocksWorldInteractionAndHidesPrompt () = + Assert.IsTrue(DialogOverlay.blocksWorldInteraction true, "弹层开启须禁止世界交互穿透") + Assert.IsFalse(DialogOverlay.blocksWorldInteraction false, "弹层关闭不阻断") + Assert.IsFalse(DialogOverlay.promptVisible true, "弹层开启时世界交互提示须隐藏") + Assert.IsTrue(DialogOverlay.promptVisible false, "弹层关闭后提示恢复") + // 对照 P43 hotbar 的输入隔离:菜单/面板打开时世界输入不允许。 + Assert.IsFalse(M5Interaction.worldInputAllowed { M5Interaction.initial with Panel = DialoguePanel }) + + [<TestMethod>] + member _.EscCloseDropsOverlayAndRestoresWorldPrompt () = + let model = DialogOverlay.create "标题" [ "a" ] 1280 true + let opened = Some model + Assert.IsTrue(DialogOverlay.blocksWorldInteraction opened.IsSome, "开启时阻断") + let closed = DialogOverlay.close opened + Assert.IsTrue(closed.IsNone, "关闭后弹层消失") + Assert.IsFalse(DialogOverlay.blocksWorldInteraction closed.IsSome, "关闭后不阻断") + Assert.IsTrue(DialogOverlay.promptVisible closed.IsSome, "关闭后世界提示恢复") + // Esc 关弹层 = 真实 ClosePanel:DialoguePanel 回到 WorldPanel,提示随后可刷新出来。 + let view = { M5Interaction.initial with Panel = DialoguePanel } + let _, closedView = M5Interaction.apply M5Command.ClosePanel world view + assertEqualMsg M5Panel.WorldPanel closedView.Panel "Esc/ClosePanel 回到世界面板" + let refreshed = M5Interaction.refreshPrompt world closedView + Assert.IsTrue(M5Interaction.worldInputAllowed refreshed, "关闭后世界输入恢复") + + [<TestMethod>] + member _.WorldPromptIsHiddenWhileDialogPanelOpen () = + let view = { M5Interaction.initial with Panel = DialoguePanel; Prompt = Some "E 交谈" } + let refreshed = M5Interaction.refreshPrompt world view + Assert.IsTrue(refreshed.Prompt.IsNone, "对话弹层开启时 worldview 提示须为 None") + Assert.IsTrue(refreshed.PromptTargetPos.IsNone, "对话弹层开启时提示锚点也清空") + + // ---- 完整背景框与原创头像 ---- + + [<TestMethod>] + member _.DialogFrameFitsViewportAndHoldsAvatar () = + let model = DialogOverlay.create "互动:村民 1" [ "1 闲聊"; "2 求助"; "3 交易"; "4 开玩笑"; "ESC 关闭" ] 1280 true + let layout = DialogOverlay.rect 1280 720 model + Assert.IsTrue(layout.X >= 0 && layout.Y >= 0, "弹层不得越出视口") + Assert.IsTrue(layout.X + layout.Width <= 1280 && layout.Y + layout.Height <= 720, "弹层不得越出视口") + Assert.IsTrue(layout.Width >= DialogOverlay.minFrameWidth, "背景框有足够宽度") + let avatar = DialogOverlay.avatarRect layout + Assert.IsTrue(avatar.X >= layout.X && avatar.Y >= layout.Y, "头像须在框内") + Assert.IsTrue( + avatar.X + avatar.Width <= layout.X + layout.Width && avatar.Y + avatar.Height <= layout.Y + layout.Height, + "头像须在框内") + assertEqualMsg DialogOverlay.avatarWidth avatar.Width "头像宽 32" + assertEqualMsg DialogOverlay.avatarHeight avatar.Height "头像高 64" + + [<TestMethod>] + member _.OriginalAvatarPatternIsWellFormedDotMatrix () = + assertEqualMsg DialogOverlay.avatarRows DialogOverlay.avatarPattern.Length "头像行数" + for row in DialogOverlay.avatarPattern do + assertEqualMsg DialogOverlay.avatarColumns row.Length "头像行宽" + Assert.IsTrue(DialogOverlay.avatarPattern |> List.exists (fun r -> r.Contains "1"), "头像须有实心像素") + assertEqualMsg DialogOverlay.avatarWidth (DialogOverlay.avatarColumns * DialogOverlay.avatarScale) "头像宽=列×scale" + assertEqualMsg DialogOverlay.avatarHeight (DialogOverlay.avatarRows * DialogOverlay.avatarScale) "头像高=行×scale" 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" ] diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index ea03437..00ae04b 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -311,6 +311,11 @@ type LivingVillageGame() as this = let mutable p43Step = 0 let mutable p43PendingName = "" let mutable p43Pending = false + // P44 证据钩子:LV_P44_SHOT=1 时在真实世界里开一场 NPC 对话弹层,拍默认页/翻页后/关闭恢复三帧。 + let p44ShotMode = Environment.GetEnvironmentVariable("LV_P44_SHOT") = "1" + let mutable p44Step = 0 + let mutable p44PendingName = "" + let mutable p44Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -321,6 +326,9 @@ type LivingVillageGame() as this = let mutable hudStatusAt = 0L // P43:hotbar 选中格(0..9),仅由游戏内 1-0 键或证据脚本写入,非伪造状态。 let mutable hotbarSelected = 0 + // P44:对话/文字弹层模型与内容指纹(内容变化时重建,翻页状态保持不变)。 + let mutable dialogOverlay: DialogOverlay.Model option = None + let mutable dialogOverlayKey = "" let mutable pauseHelpControl: SimulationControl option = None let recordMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_RECORD") = "1" let recordDirectory = @@ -818,6 +826,74 @@ type LivingVillageGame() as this = reading.DayNumber reading.Season reading.Period reading.Clock p43Step <- p43Step + 1 + /// P44 证据:真实世界里把角色放到 NPC 旁、用真实 Interact 命令开对话弹层, + /// 依次拍「默认页 / 翻页后 / Esc 关闭后恢复」三帧。 + member private this.PrepareP44Shot () = + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + if not p44Pending then + match p44Step with + | 0 -> + setHour 12.0 + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View + world <- nextWorld + m5View <- nextView + this.CenterCamera() + p44PendingName <- "p44-dialog-a" + p44Pending <- true + | 1 -> + match dialogOverlay with + | Some model -> + match DialogOverlay.nextPage model with + | Some next -> dialogOverlay <- Some next + | None -> () + | None -> () + p44PendingName <- "p44-dialog-b" + p44Pending <- true + | 2 -> + let nextWorld, nextView = M5Interaction.apply M5Command.ClosePanel world m5View + world <- nextWorld + m5View <- nextView + p44PendingName <- "p44-dialog-c" + p44Pending <- true + | _ -> + printfn "p44-shot=done" + this.Exit() + + /// P44 证据:在 `Draw` 末尾落盘,并打印弹层实测 rect / 行数 / 页码;关闭帧打印 overlay=gone。 + member private this.CaptureP44Shot () = + if p44Pending then + p44Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p44PendingName) + let viewport = this.GraphicsDevice.Viewport + match dialogOverlay with + | Some model -> + let layout = DialogOverlay.rect viewport.Width viewport.Height model + let current = DialogOverlay.view model + printfn + "p44-shot=%s panel=%s rect=%d,%d,%d,%d page=%d/%d lines=%d avatar=%b" + p44PendingName + (M5Interaction.panelName m5View) + layout.X + layout.Y + layout.Width + layout.Height + current.PageNumber + current.PageCount + current.Lines.Length + model.ShowAvatar + | None -> + printfn + "p44-shot=%s panel=%s overlay=gone prompt=%s" + p44PendingName + (M5Interaction.panelName m5View) + (m5View.Prompt |> Option.defaultValue "<none>") + p44Step <- p44Step + 1 + /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = if not riverscapeTourWatchStarted then @@ -936,19 +1012,35 @@ type LivingVillageGame() as this = if relationView then relationSnapshot <- Some(Sim.relationMatrix world) let state = if relationView then "on" else "off" printfn $"relation view={state} tick={world.Tick}" + // P44:弹层开启时用 空格/回车/E 翻页(下一页);最后一页再翻为 no-op,不崩。 + let overlayOpen = dialogOverlay.IsSome + if overlayOpen && (pressed Keys.Space || pressed Keys.Enter || pressed Keys.E) then + match DialogOverlay.nextPage dialogOverlay.Value with + | Some next -> dialogOverlay <- Some next + | None -> () let m5Command = - if M5Interaction.worldInputAllowed m5View && pressed Keys.E then Some Interact - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 - elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 - elif pressed Keys.Q then Some Observe - elif pressed Keys.Tab then Some ToggleNeeds - elif pressed Keys.C then Some ShowChronicle - elif pressed Keys.T then Some ShowTaskPanel - else None + if overlayOpen then + // P44:弹层开启只允许对话选项 1-6;E/Q/Tab/C/T 等世界交互键全部屏蔽,不穿透。 + if m5View.Panel = DialoguePanel && pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 + else None + else + if M5Interaction.worldInputAllowed m5View && pressed Keys.E then Some Interact + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 + elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 + elif pressed Keys.Q then Some Observe + elif pressed Keys.Tab then Some ToggleNeeds + elif pressed Keys.C then Some ShowChronicle + elif pressed Keys.T then Some ShowTaskPanel + else None match m5Command with | Some command -> let nextWorld, nextView = M5Interaction.apply command world m5View @@ -1154,6 +1246,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP43Shot() + elif p44ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP44Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -1169,6 +1269,8 @@ type LivingVillageGame() as this = pauseHelpControl <- None printfn $"help=pause-close simulation={M6Presentation.clockLabel simulationControl}" | _ -> () + if menu.Page = Playing then + this.SyncDialogOverlay() hudClock <- hudClock + 1L if m5View.Status <> hudStatus then hudStatus <- m5View.Status @@ -1557,6 +1659,8 @@ type LivingVillageGame() as this = this.CaptureP40Shot() if p43ShotMode && menu.Page = Playing then this.CaptureP43Shot() + if p44ShotMode && menu.Page = Playing then + this.CaptureP44Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -1878,14 +1982,88 @@ type LivingVillageGame() as this = | _ -> () this.DrawM5Overlay() + /// P44:按当前面板同步对话弹层模型;内容不变时保留翻页状态,离开 DialoguePanel 即关闭。 + member private this.SyncDialogOverlay () = + if m5View.Panel = DialoguePanel then + let lines = M5Interaction.panelLines world m5View + let key = String.concat "\n" lines + match dialogOverlay with + | Some _ when dialogOverlayKey = key -> () + | _ -> + dialogOverlayKey <- key + let title = match lines with t :: _ -> t | [] -> "对话" + let body = match lines with _ :: rest -> rest | [] -> [] + dialogOverlay <- Some(DialogOverlay.create title body (this.GraphicsDevice.Viewport.Width) true) + else + dialogOverlay <- None + dialogOverlayKey <- "" + + /// P44:绘制完整背景框 + 原点点阵头像 + 自动换行文字 + 分页标记。 + member private this.DrawDialogOverlay (model: DialogOverlay.Model) = + let viewport = this.GraphicsDevice.Viewport + let profile = M6Presentation.profileAtTick world.Tick + let palette = HudLayout.palette profile.Mode + let layout = DialogOverlay.rect viewport.Width viewport.Height model + let current = DialogOverlay.view model + let lx = layout.X + let ly = layout.Y + spriteBatch.Begin() + // 完整背景框:上下两段半透明深色渐变 + 四边细边框 + 顶部强调线。 + spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, layout.Height / 2), Color(30, 44, 52, 224)) + spriteBatch.Draw( + pixel, + Rectangle(lx, ly + layout.Height / 2, layout.Width, layout.Height - layout.Height / 2), + Color(14, 22, 30, 232)) + spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, 3), palette.Accent) + spriteBatch.Draw(pixel, Rectangle(lx, ly + layout.Height - 2, layout.Width, 2), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(lx, ly, 2, layout.Height), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(lx + layout.Width - 2, ly, 2, layout.Height), palette.Outline) + // 头像:原创 8x16 点阵 @ scale 4 = 32x64,加内框。 + if model.ShowAvatar then + let avatar = DialogOverlay.avatarRect layout + spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y - 4, avatar.Width + 8, avatar.Height + 8), palette.SegmentBackdrop) + spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y - 4, avatar.Width + 8, 2), palette.Accent) + spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y + avatar.Height + 2, avatar.Width + 8, 2), palette.Outline) + this.DrawPattern + DialogOverlay.avatarPattern + avatar.X + avatar.Y + DialogOverlay.avatarScale + palette.Value + let textX = DialogOverlay.textOriginX layout model.ShowAvatar + let titleY = ly + DialogOverlay.paddingY + for (dx, dy) in HudLayout.textOutlineOffsets do + PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (titleY + dy) DialogOverlay.textScale palette.Outline model.Title + PixelText.draw spriteBatch pixel cjkAtlas textX titleY DialogOverlay.textScale palette.Accent model.Title + let bodyY = DialogOverlay.textOriginY layout + current.Lines + |> List.iteri (fun index line -> + let y = bodyY + index * DialogOverlay.lineHeight + for (dx, dy) in HudLayout.textOutlineOffsets do + PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (y + dy) DialogOverlay.textScale palette.Outline line + PixelText.draw spriteBatch pixel cjkAtlas textX y DialogOverlay.textScale palette.Value line) + // 分页标记:页码 + 翻页/关闭提示;有下一页时显示箭头。 + let marker = + sprintf "第 %d/%d 页 空格 翻页 Esc 关闭" current.PageNumber current.PageCount + let markerWidth = FloatingPrompt.measureWidth marker DialogOverlay.textScale + let markerX = lx + layout.Width - DialogOverlay.paddingX - markerWidth + let markerY = ly + layout.Height - DialogOverlay.pageMarkerHeight + PixelText.draw spriteBatch pixel cjkAtlas markerX markerY DialogOverlay.textScale palette.Label marker + if current.HasNext then + PixelText.draw spriteBatch pixel cjkAtlas (markerX - 18) markerY DialogOverlay.textScale palette.Accent ">" + spriteBatch.End() + member private this.DrawM5Overlay() = this.DrawHudBar() this.DrawCalendarCard() this.DrawHotbar() - if m5View.Panel = WorldPanel then - this.DrawTransientStatus() - else - this.DrawPanel() + match dialogOverlay with + | Some model -> this.DrawDialogOverlay model + | None -> + if m5View.Panel = WorldPanel then + this.DrawTransientStatus() + else + this.DrawPanel() /// 顶部窄信息条:时段/时间/倍速/体力/钱币,半透明渐变 + 强调线,绝非大黑块。 member private this.DrawHudBar() = diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 2901dce..76e9b06 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -19,6 +19,7 @@ <Compile Include="SampleScript.fs" /> <Compile Include="M6Presentation.fs" /> <Compile Include="HudLayout.fs" /> + <Compile Include="DialogOverlay.fs" /> <Compile Include="PerformanceSummary.fs" /> <Compile Include="TitleScreen.fs" /> |
