summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 19:29:27 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 19:29:27 +0800
commite28b3c102f2a0b387a4df84f053306d71e5e41ba (patch)
tree0b3b13b10bba4283dde3aba60cc0ec42495d6b82
parentd671335d687496f393dbad63950707cabbf24e5b (diff)
downloadliving-village-e28b3c102f2a0b387a4df84f053306d71e5e41ba.tar.gz
p44: 对话框/文字弹层 — 完整背景框+中文断行换行+分页翻页+Esc关闭+输入隔离(原创32x64点阵头像)
-rw-r--r--docs/evidence/p44-analysis.txt10
-rw-r--r--docs/evidence/p44-dialog-a.pngbin0 -> 113824 bytes
-rw-r--r--docs/evidence/p44-dialog-b.pngbin0 -> 114960 bytes
-rw-r--r--docs/evidence/p44-dialog-c.pngbin0 -> 100240 bytes
-rw-r--r--scripts/analyze-p44-dialog.py137
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P44DialogTests.fs123
-rw-r--r--src/LivingVillage.Desktop/DialogOverlay.fs193
-rw-r--r--src/LivingVillage.Desktop/Game.fs210
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
10 files changed, 659 insertions, 16 deletions
diff --git a/docs/evidence/p44-analysis.txt b/docs/evidence/p44-analysis.txt
new file mode 100644
index 0000000..8721ff8
--- /dev/null
+++ b/docs/evidence/p44-analysis.txt
@@ -0,0 +1,10 @@
+P44 dialog-overlay analysis (LV_P44_SHOT=1)
+
+[p44-dialog-a] panel=dialogue rect=(190,558,900x138) page=1/2 log_lines=4 avatar=True
+ rendered_text_bands=4 accent_border_px=2688
+[p44-dialog-b] panel=dialogue rect=(190,558,900x138) page=2/2 log_lines=4 avatar=True
+ rendered_text_bands=4 accent_border_px=2688
+[p44-dialog-c] panel=world prompt='E 交谈' (rect from p44-dialog-a)
+ accent_border_px=0 region_mean_abs_diff_vs_open=45.1
+
+verdict = PASS
diff --git a/docs/evidence/p44-dialog-a.png b/docs/evidence/p44-dialog-a.png
new file mode 100644
index 0000000..ad6b450
--- /dev/null
+++ b/docs/evidence/p44-dialog-a.png
Binary files differ
diff --git a/docs/evidence/p44-dialog-b.png b/docs/evidence/p44-dialog-b.png
new file mode 100644
index 0000000..c039f07
--- /dev/null
+++ b/docs/evidence/p44-dialog-b.png
Binary files differ
diff --git a/docs/evidence/p44-dialog-c.png b/docs/evidence/p44-dialog-c.png
new file mode 100644
index 0000000..99c68d6
--- /dev/null
+++ b/docs/evidence/p44-dialog-c.png
Binary files differ
diff --git a/scripts/analyze-p44-dialog.py b/scripts/analyze-p44-dialog.py
new file mode 100644
index 0000000..c9405e0
--- /dev/null
+++ b/scripts/analyze-p44-dialog.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""P44 dialog-overlay analysis.
+
+Reads the `LV_P44_SHOT=1` frames + shot log and writes
+docs/evidence/p44-analysis.txt with measured numbers:
+
+ 1. overlay rect / page / wrapped line count / avatar flag for the open frames;
+ 2. the accent top border really exists inside the overlay rect (frame a/b);
+ 3. the overlay rect is gone after Esc (frame c): accent border pixels ~0 and the
+ region differs from frame a, and the world prompt is restored.
+
+Usage:
+ python3 scripts/analyze-p44-dialog.py <record-dir> <shot-log> <out-txt>
+
+Exit 0 when checks pass, 1 when they fail, 2 on usage/read error.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+WIDTH = 1280
+HEIGHT = 720
+ACCENT_DAY = np.array([212, 168, 96])
+OPEN_RE = re.compile(r"p44-shot=(\S+) panel=(\S+) rect=(\d+),(\d+),(\d+),(\d+) page=(\d+)/(\d+) lines=(\d+) avatar=(\w+)")
+GONE_RE = re.compile(r"p44-shot=(\S+) panel=(\S+) overlay=gone prompt=(.+)")
+
+
+def load(record_dir, name):
+ return np.asarray(Image.open(record_dir / f"{name}.png").convert("RGB"), dtype=int)
+
+
+def accent_border_px(img, rect):
+ x, y, w, _ = rect
+ strip = img[y:y + 3, x:x + w]
+ return int((np.abs(strip - ACCENT_DAY).max(axis=2) <= 16).sum())
+
+
+def text_bands(img, rect, show_avatar):
+ x, y, w, h = rect
+ text_x = x + 16 + (32 + 12 if show_avatar else 0)
+ # body rows only (below the header), inside the frame.
+ body = img[y + 30:y + h - 20, text_x:x + w - 16]
+ bright = (body.max(axis=2) >= 180).any(axis=1)
+ bands = 0
+ previous = False
+ for row in bright:
+ if row and not previous:
+ bands += 1
+ previous = row
+ return bands
+
+
+def main(argv):
+ if len(argv) != 4:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ log_text = Path(argv[2]).read_text(encoding="utf-8")
+ opens = []
+ gone = []
+ for line in log_text.splitlines():
+ m = OPEN_RE.search(line)
+ if m:
+ opens.append(
+ {
+ "name": m.group(1),
+ "panel": m.group(2),
+ "rect": tuple(int(m.group(i)) for i in range(3, 7)),
+ "page": f"{m.group(7)}/{m.group(8)}",
+ "lines": int(m.group(9)),
+ "avatar": m.group(10) == "true",
+ }
+ )
+ g = GONE_RE.search(line)
+ if g:
+ gone.append({"name": g.group(1), "panel": g.group(2), "prompt": g.group(3)})
+ if not opens or not gone:
+ sys.stderr.write("need open frames and one overlay=gone frame\n")
+ return 1
+
+ failures = []
+ lines = ["P44 dialog-overlay analysis (LV_P44_SHOT=1)", ""]
+ reference_rect = None
+ reference_img = None
+ for shot in opens:
+ img = load(record_dir, shot["name"])
+ reference_rect = shot["rect"] if reference_rect is None else reference_rect
+ reference_img = img if reference_img is None else reference_img
+ x, y, w, h = shot["rect"]
+ if not (x >= 0 and y >= 0 and x + w <= WIDTH and y + h <= HEIGHT):
+ failures.append(f"{shot['name']} rect out of viewport")
+ border = accent_border_px(img, shot["rect"])
+ bands = text_bands(img, shot["rect"], shot["avatar"])
+ lines.append(f"[{shot['name']}] panel={shot['panel']} rect=({x},{y},{w}x{h}) page={shot['page']} "
+ f"log_lines={shot['lines']} avatar={shot['avatar']}")
+ lines.append(f" rendered_text_bands={bands} accent_border_px={border}")
+ if border < 500:
+ failures.append(f"{shot['name']} overlay accent border missing ({border})")
+ if bands < 1:
+ failures.append(f"{shot['name']} no wrapped text rendered")
+ if shot["lines"] > 4:
+ failures.append(f"{shot['name']} page has more than linesPerPage lines")
+
+ for shot in gone:
+ img = load(record_dir, shot["name"])
+ border = accent_border_px(img, reference_rect)
+ x, y, w, h = reference_rect
+ delta = float(np.abs(img[y:y + h, x:x + w].astype(float) - reference_img[y:y + h, x:x + w].astype(float)).mean())
+ lines.append(f"[{shot['name']}] panel={shot['panel']} prompt={shot['prompt']!r} "
+ f"(rect from {opens[0]['name']})")
+ lines.append(f" accent_border_px={border} region_mean_abs_diff_vs_open={delta:.1f}")
+ if border > 50:
+ failures.append(f"{shot['name']} overlay rect still present after close (border={border})")
+ if delta < 5.0:
+ failures.append(f"{shot['name']} region unchanged vs open, overlay may persist")
+ if "E " not in shot["prompt"]:
+ failures.append(f"{shot['name']} world prompt not restored")
+
+ lines.append("")
+ lines.append("verdict = " + ("PASS" if not failures else "FAIL"))
+ out_txt = Path(argv[3])
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ sys.stdout.write("\n".join(lines) + "\n")
+ if failures:
+ for f in failures:
+ sys.stderr.write(f"FAIL {f}\n")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
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" />