summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 15:44:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 15:44:35 +0800
commitdce50b835235a103af1d8e6ec836fe12d841f135 (patch)
tree820d2a5f8a092e66e31b5ca06527302dd81735ed
parent21b03c043466c3cbce1d71ff055c9d71631b6073 (diff)
downloadliving-village-dce50b835235a103af1d8e6ec836fe12d841f135.tar.gz
p77: 日历卡/浮动提示药丸化并修复面板遮挡快捷栏
- DrawCalendarCard 改半透明底+1px 描边+accent 发丝线,日历卡微缩(52→48) - FloatingPrompt 新增 layoutWrapped/wrapText,锚定长文本逐字折行(不丢字、居中锚点、避让 HUD 带) - 新增 HudLayout.panelRect,DrawPanel 用其几何;DialogOverlay.bottomMargin 24→72 1280x720 下对话 rect 底 648、任务面板底 652,均 < hotbar 顶 660,不再压 3-8 号槽 - 新增 P77LayoutTests(4 尺寸不破版/不遮挡 + 真实长提示折行) - 证据 LV_P43/P44/P67_SHOT + scripts/analyze-p77.py(verdict PASS) 落 docs/evidence/p77-* - Desktop 361/361, Kernel 120/120, digest 953775FA...F7F6C 不变
-rw-r--r--docs/evidence/p77-analysis.txt18
-rw-r--r--docs/evidence/p77-calendar-day.pngbin0 -> 114234 bytes
-rw-r--r--docs/evidence/p77-calendar-night.pngbin0 -> 113263 bytes
-rw-r--r--docs/evidence/p77-panel-above-hotbar.pngbin0 -> 141387 bytes
-rw-r--r--docs/evidence/p77-prompt.pngbin0 -> 116545 bytes
-rw-r--r--docs/维护说明.md21
-rw-r--r--scripts/analyze-p77.py174
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P77LayoutTests.fs54
-rw-r--r--src/LivingVillage.Desktop/DialogOverlay.fs3
-rw-r--r--src/LivingVillage.Desktop/Game.fs44
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs19
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs53
13 files changed, 370 insertions, 17 deletions
diff --git a/docs/evidence/p77-analysis.txt b/docs/evidence/p77-analysis.txt
new file mode 100644
index 0000000..06f1f5f
--- /dev/null
+++ b/docs/evidence/p77-analysis.txt
@@ -0,0 +1,18 @@
+P77(日历卡/浮动提示药丸化 + 面板与 hotbar 错开)证据分析 — 真实 Desktop xvfb 1280x720
+近黑阈值 24;实心块 = 24x24 全近黑。
+
+p43-hud-day: calendar=(1128, 47, 140, 48) dark_frac=0.094 maxblk=184 bright=256
+p43-hud-night: calendar=(1128, 47, 140, 48) dark_frac=0.097 maxblk=176 bright=248
+
+panelRect(1280,720,5)=(16, 536, 380, 116) bottom=652 vs hotbar_top=660
+p67-target panel region=(16, 536, 380, 116) dark_frac=0.005 maxblk=70 bright=387
+
+p44-dialog-a rect=(190, 510, 900, 138) bottom=648 vs hotbar_top=660
+p44-dialog-c prompt band (0,480,1280,240) dark_frac=0.016 maxblk=176 bright=3343
+
+diff p43-hud-day vs p43-hud-night: differing_pixels=921600
+diff p44-dialog-a vs p44-dialog-c: differing_pixels=129318
+
+verdict=PASS
+ calendar/panel/prompt regions have no solid near-black block;
+ panel and dialog bottoms clear the hotbar top; frames differ pairwise.
diff --git a/docs/evidence/p77-calendar-day.png b/docs/evidence/p77-calendar-day.png
new file mode 100644
index 0000000..0823c76
--- /dev/null
+++ b/docs/evidence/p77-calendar-day.png
Binary files differ
diff --git a/docs/evidence/p77-calendar-night.png b/docs/evidence/p77-calendar-night.png
new file mode 100644
index 0000000..2c03044
--- /dev/null
+++ b/docs/evidence/p77-calendar-night.png
Binary files differ
diff --git a/docs/evidence/p77-panel-above-hotbar.png b/docs/evidence/p77-panel-above-hotbar.png
new file mode 100644
index 0000000..b7fde9b
--- /dev/null
+++ b/docs/evidence/p77-panel-above-hotbar.png
Binary files differ
diff --git a/docs/evidence/p77-prompt.png b/docs/evidence/p77-prompt.png
new file mode 100644
index 0000000..f8a72fe
--- /dev/null
+++ b/docs/evidence/p77-prompt.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 43e07d3..a77ba90 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -255,6 +255,27 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
`scripts/analyze-p76.py`:底部条/图例/对话/任务四区近黑占比 ≤0.013–0.116、无实心 24×24 近黑块、
亮像素充足,落 `docs/evidence/p76-*`。
+## P77 HUD 视觉精修收尾(日历卡 / 浮动提示药丸化 + 面板与快捷栏错开)
+
+- 右上日历卡(`Game.DrawCalendarCard`):由 2px 粗描边改与顶/底条同语言的药丸——半透明
+ `SegmentBackdrop` + 顶部 1px accent + 四边 1px `Outline`;`calendarPadY` 由 8 收到 6,卡片高度
+ 52→48(微缩),仍容下两行文字(`P43HudTests` 断言不变)。
+- 浮动提示(`Interaction.FloatingPrompt`):新增 `layoutWrapped`/`wrapText`/`maxPromptWidth=360`,
+ 锚定提示支持长文本**逐字折行**(不丢字、水平居中锚点、垂直钳制在顶/底 HUD 带之间);渲染改
+ 半透明深底 + 1px 描边 + 发丝高光,多行逐行描边绘制。原单行 `layout` 语义保留(既有测试不变)。
+- **遮挡修复**:新增 `HudLayout.panelRect`(底边恒抬到 hotbar 顶上方 8px),`Game.DrawPanel` 改用它
+ 取几何;`DialogOverlay.bottomMargin` 24→72 预留底部快捷栏带。1280×720 下对话弹层 rect Y 由
+ 558→510(底 648 < hotbar 顶 660),任务面板 rect=(16,536,380,116)(底 652 < 660),不再压
+ 物品栏 3–8 号格子。
+- **回归**:`P77LayoutTests`(1280×720/1024×768/800×600/640×480 四尺寸下 hotbar/日历卡/对话弹层/
+ 任务面板均不越界;标准尺寸下面板与弹层底边均在 hotbar 顶上方;真实长提示「E 与河边的货郎交谈并
+ 查看今日货品是否齐全以及打听附近村里的传闻」折成 ≥2 行、逐行不超 `maxPromptWidth`、拼接等于原文、
+ 且在两条 HUD 带之间;短提示仍单行且宽度与单行版式一致)。
+- **证据**:`LV_P43_SHOT=1` 昼/夜(日历卡)+ `LV_P44_SHOT=1`(提示/对话)+ `LV_P67_SHOT=1`(任务面板)
+ → `scripts/analyze-p77.py`:日历卡近黑 0.094/0.097、任务面板 0.005、提示带 0.016,均无实心
+ 24×24 近黑块,亮字充足;几何上 panel/dialog 底边 < hotbar 顶;昼/夜、对话两态帧 pairwise diff>0,
+ 落 `docs/evidence/p77-*`。
+
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
diff --git a/scripts/analyze-p77.py b/scripts/analyze-p77.py
new file mode 100644
index 0000000..5778b43
--- /dev/null
+++ b/scripts/analyze-p77.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""P77(日历卡/浮动提示药丸化 + 面板与 hotbar 不遮挡)证据分析。
+
+判据(读真实实跑日志 + 区域像素统计;近黑阈值 24 对齐旧纯黑底色):
+ 1. 日志含 p43-shot day/night(日历 rect + hotbar rect)、p44-shot 对话 rect、p67 任务面板。
+ 2. 日历卡、交互面板、提示带:近黑占比 < 20%、无实心 24x24 近黑块、亮文字像素充足。
+ 3. 几何不遮挡:面板/对话弹层底边 <= hotbar 顶(p67 面板用同一 panelRect 公式复核)。
+ 4. 昼/夜、对话两态帧 pairwise diff > 0。
+ 5. 观感由 Hermes vision 侧复核,非本脚本判据。
+
+Usage: python3 scripts/analyze-p77.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+from numpy.lib.stride_tricks import sliding_window_view
+
+P43_RE = re.compile(
+ r"^p43-shot=(p43-hud-(?:day|night))\S* tick=(\d+) calendar=(\d+),(\d+),(\d+),(\d+) hotbar=(\d+),(\d+),(\d+),(\d+)",
+ re.MULTILINE,
+)
+P44_RE = re.compile(r"^p44-shot=(p44-dialog-[a-z]) panel=dialogue rect=(\d+),(\d+),(\d+),(\d+)", re.MULTILINE)
+THR = 24
+BLOCK = 24
+DARK_FRAC_MAX = 0.20
+BAR_HEIGHT = 36
+PANEL_LINE_HEIGHT = 18
+PANEL_MAX_WIDTH = 380
+PANEL_TOP_EXTRA = 26
+
+
+def region_stats(path, rect):
+ x, y, w, h = rect
+ rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16)
+ r = rgb[y : y + h, x : x + w]
+ mask = (r[:, :, 0] <= THR) & (r[:, :, 1] <= THR) & (r[:, :, 2] <= THR)
+ dark_frac = float(mask.mean()) if mask.size else 1.0
+ block = 0
+ if mask.shape[0] >= BLOCK and mask.shape[1] >= BLOCK:
+ block = int(sliding_window_view(mask.astype(np.int64), (BLOCK, BLOCK)).sum(axis=(2, 3)).max())
+ bright = int(((r[:, :, 0] >= 200) & (r[:, :, 1] >= 200) & (r[:, :, 2] >= 200)).sum())
+ return dark_frac, block, bright
+
+
+def panel_rect(viewport_w, viewport_h, line_count):
+ width = min(PANEL_MAX_WIDTH, max(1, viewport_w - 32))
+ height = max(1, line_count) * PANEL_LINE_HEIGHT + PANEL_TOP_EXTRA
+ # hotbarBarRect (mirrors HudLayout): 10*40 + 9*6 wide, bottom-anchored.
+ hotbar_w = 10 * 40 + 9 * 6
+ hotbar_x = (viewport_w - hotbar_w) // 2 - 8
+ hotbar_y = viewport_h - 40 - 12 - 8
+ max_bottom = hotbar_y - 8
+ y = max(BAR_HEIGHT + 12, min(viewport_h - height - 16, max(0, max_bottom - height)))
+ return (16, y, width, height), (hotbar_x, hotbar_y, hotbar_w, 40 + 16)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ failures = []
+ lines = [
+ "P77(日历卡/浮动提示药丸化 + 面板与 hotbar 错开)证据分析 — 真实 Desktop xvfb 1280x720",
+ "近黑阈值 24;实心块 = 24x24 全近黑。",
+ "",
+ ]
+
+ p43 = (rec / "log-P43.txt").read_text(encoding="utf-8", errors="replace") if (rec / "log-P43.txt").exists() else ""
+ frames = {
+ m.group(1): {
+ "tick": int(m.group(2)),
+ "calendar": (int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6))),
+ "hotbar": (int(m.group(7)), int(m.group(8)), int(m.group(9)), int(m.group(10))),
+ }
+ for m in P43_RE.finditer(p43)
+ }
+ for name in ["p43-hud-day", "p43-hud-night"]:
+ path = rec / f"{name}.png"
+ if name not in frames or not path.exists():
+ failures.append(f"missing {name}")
+ continue
+ cal = frames[name]["calendar"]
+ df, block, bright = region_stats(path, cal)
+ lines.append(f"{name}: calendar={cal} dark_frac={df:.3f} maxblk={block} bright={bright}")
+ if df >= DARK_FRAC_MAX:
+ failures.append(f"{name} calendar dark_frac {df:.3f} >= {DARK_FRAC_MAX}")
+ if block >= BLOCK * BLOCK:
+ failures.append(f"{name} calendar has solid near-black block")
+ if bright < 100:
+ failures.append(f"{name} calendar bright {bright} < 100 (文字不可读?)")
+ lines.append("")
+
+ hotbar = frames.get("p43-hud-day", {}).get("hotbar")
+ # 几何不遮挡:面板与对话弹层底边须 <= hotbar 顶。
+ prect, hbrect = panel_rect(1280, 720, 5)
+ panel_bottom = prect[1] + prect[3]
+ hb_top = hbrect[1]
+ lines.append(f"panelRect(1280,720,5)={prect} bottom={panel_bottom} vs hotbar_top={hb_top}")
+ if panel_bottom > hb_top:
+ failures.append(f"panel bottom {panel_bottom} overlaps hotbar top {hb_top}")
+ p67 = rec / "p67-target.png"
+ if p67.exists():
+ df, block, bright = region_stats(p67, prect)
+ lines.append(f"p67-target panel region={prect} dark_frac={df:.3f} maxblk={block} bright={bright}")
+ if df >= DARK_FRAC_MAX:
+ failures.append(f"task panel dark_frac {df:.3f} >= {DARK_FRAC_MAX}")
+ if block >= BLOCK * BLOCK:
+ failures.append("task panel has solid near-black block")
+ if bright < 100:
+ failures.append(f"task panel bright {bright} < 100")
+ else:
+ failures.append("missing p67-target.png")
+ lines.append("")
+
+ p44 = (rec / "log-P44.txt").read_text(encoding="utf-8", errors="replace") if (rec / "log-P44.txt").exists() else ""
+ dialogs = {m.group(1): (int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5))) for m in P44_RE.finditer(p44)}
+ if "p44-dialog-a" in dialogs:
+ drect = dialogs["p44-dialog-a"]
+ d_bottom = drect[1] + drect[3]
+ lines.append(f"p44-dialog-a rect={drect} bottom={d_bottom} vs hotbar_top={hb_top}")
+ if hb_top is not None and d_bottom > hb_top:
+ failures.append(f"dialog bottom {d_bottom} overlaps hotbar top {hb_top}")
+ else:
+ failures.append("missing p44-dialog-a rect")
+ prompt_path = rec / "p44-dialog-c.png"
+ if prompt_path.exists():
+ df, block, bright = region_stats(prompt_path, (0, 480, 1280, 240))
+ lines.append(f"p44-dialog-c prompt band (0,480,1280,240) dark_frac={df:.3f} maxblk={block} bright={bright}")
+ if df >= DARK_FRAC_MAX:
+ failures.append(f"prompt band dark_frac {df:.3f} >= {DARK_FRAC_MAX}")
+ if block >= BLOCK * BLOCK:
+ failures.append("prompt band has solid near-black block")
+ if bright < 500:
+ failures.append(f"prompt band bright {bright} < 500")
+ else:
+ failures.append("missing p44-dialog-c.png")
+ lines.append("")
+
+ def diff(a, b):
+ ia = np.asarray(Image.open(rec / f"{a}.png").convert("RGB"), dtype=np.int16)
+ ib = np.asarray(Image.open(rec / f"{b}.png").convert("RGB"), dtype=np.int16)
+ return int((np.abs(ia - ib).sum(axis=2) > 0).sum())
+
+ for a, b in [("p43-hud-day", "p43-hud-night"), ("p44-dialog-a", "p44-dialog-c")]:
+ if (rec / f"{a}.png").exists() and (rec / f"{b}.png").exists():
+ d = diff(a, b)
+ lines.append(f"diff {a} vs {b}: differing_pixels={d}")
+ if d == 0:
+ failures.append(f"{a} identical to {b}")
+
+ lines.append("")
+ if failures:
+ lines.append("verdict=FAIL")
+ for f in failures:
+ lines.append(" - " + f)
+ else:
+ lines.append("verdict=PASS")
+ lines.append(" calendar/panel/prompt regions have no solid near-black block;")
+ lines.append(" panel and dialog bottoms clear the hotbar top; frames differ pairwise.")
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index c509a0b..1ea6349 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -48,6 +48,7 @@
<Compile Include="P74LaunchTransitionTests.fs" />
<Compile Include="P75HudStripTests.fs" />
<Compile Include="P76HotbarPanelTests.fs" />
+ <Compile Include="P77LayoutTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P77LayoutTests.fs b/src/LivingVillage.Desktop.Tests/P77LayoutTests.fs
new file mode 100644
index 0000000..df0e94f
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P77LayoutTests.fs
@@ -0,0 +1,54 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open Microsoft.Xna.Framework
+open LivingVillage.Desktop
+
+/// P77 回归:日历卡/浮动提示药丸化后的多尺寸不破版、面板与 hotbar 不遮挡、锚定长文本折行。
+[<TestClass>]
+type P77LayoutTests () =
+
+ let sizes = [ (1280, 720); (1024, 768); (800, 600); (640, 480) ]
+
+ [<TestMethod>]
+ member _.LayoutsStayInsideEveryViewportAndClearOfHotbar () =
+ for (w, h) in sizes do
+ let bar = HudLayout.hotbarBarRect w h
+ Assert.IsTrue(bar.X >= 0 && bar.Y >= 0 && bar.X + bar.Width <= w && bar.Y + bar.Height <= h,
+ sprintf "hotbar 越界 @%dx%d" w h)
+ let cal = HudLayout.calendarCardRect w (HudLayout.calendarReading 2592000L)
+ Assert.IsTrue(cal.X >= 0 && cal.X + cal.Width <= w && cal.Y >= 0, sprintf "日历卡越界 @%dx%d" w h)
+ let model = DialogOverlay.create "互动:村民 1" [ "1 闲聊"; "2 求助"; "3 交易"; "4 开玩笑"; "ESC 关闭" ] w true
+ let dialog = DialogOverlay.rect w h model
+ Assert.IsTrue(dialog.X >= 0 && dialog.Y >= 0 && dialog.X + dialog.Width <= w && dialog.Y + dialog.Height <= h,
+ sprintf "对话弹层越界 @%dx%d" w h)
+ let panel = HudLayout.panelRect w h 5
+ Assert.IsTrue(panel.X >= 0 && panel.Y >= 0 && panel.X + panel.Width <= w && panel.Y + panel.Height <= h,
+ sprintf "交互面板越界 @%dx%d" w h)
+
+ [<TestMethod>]
+ member _.DialogAndPanelNeverCoverTheHotbar () =
+ // 上单验收问题:互动面板压住 3-8 号格子。标准与常见尺寸下面板/弹层底边须在 hotbar 顶上方。
+ for (w, h) in [ (1280, 720); (1024, 768); (800, 600) ] do
+ let bar = HudLayout.hotbarBarRect w h
+ let panel = HudLayout.panelRect w h 5
+ Assert.IsTrue(panel.Y + panel.Height <= bar.Y, sprintf "交互面板压 hotbar @%dx%d: panelBottom=%d barTop=%d" w h (panel.Y + panel.Height) bar.Y)
+ let model = DialogOverlay.create "互动:村民 1" [ "1 闲聊"; "2 求助"; "3 交易"; "4 开玩笑"; "ESC 关闭" ] w true
+ let dialog = DialogOverlay.rect w h model
+ Assert.IsTrue(dialog.Y + dialog.Height <= bar.Y, sprintf "对话弹层压 hotbar @%dx%d: dialogBottom=%d barTop=%d" w h (dialog.Y + dialog.Height) bar.Y)
+
+ [<TestMethod>]
+ member _.AnchoredLongPromptWrapsWithoutLossAndStaysInBands () =
+ let text = "E 与河边的货郎交谈并查看今日货品是否齐全以及打听附近村里的传闻"
+ let layout = FloatingPrompt.layoutWrapped 1280 720 640.0f 360.0f 0.0f 0.0f 2 text
+ Assert.IsTrue(layout.Lines.Length >= 2, "长锚定文本须折成多行")
+ for line in layout.Lines do
+ Assert.IsTrue(FloatingPrompt.measureWidth line 2 <= FloatingPrompt.maxPromptWidth, sprintf "折行超宽: %s" line)
+ Assert.AreEqual<string>(text, System.String.Concat layout.Lines)
+ Assert.IsTrue(layout.X >= 0 && layout.X + layout.Width <= 1280, "提示不得越界")
+ Assert.IsTrue(layout.Y >= FloatingPrompt.hudTopBarHeight, "提示不得压顶部条")
+ Assert.IsTrue(layout.Y + layout.Height <= 720 - FloatingPrompt.hudBottomBandHeight, "提示不得压底部带")
+ // 短提示仍是单行,且宽度与既有单行版式一致。
+ let short = FloatingPrompt.layoutWrapped 1280 720 640.0f 360.0f 0.0f 0.0f 2 "E 交谈"
+ Assert.AreEqual<int>(1, short.Lines.Length)
+ Assert.AreEqual<int>(FloatingPrompt.measureWidth "E 交谈" 2, short.Width)
diff --git a/src/LivingVillage.Desktop/DialogOverlay.fs b/src/LivingVillage.Desktop/DialogOverlay.fs
index 58ce33e..0c402ec 100644
--- a/src/LivingVillage.Desktop/DialogOverlay.fs
+++ b/src/LivingVillage.Desktop/DialogOverlay.fs
@@ -20,7 +20,8 @@ module DialogOverlay =
let pageMarkerHeight = 20
let minFrameWidth = 480
let maxFrameWidth = 900
- let bottomMargin = 24
+ // P77:底部预留 hotbar 带(约 60px)+ 间距,保证弹层不遮挡快捷栏。
+ let bottomMargin = 72
type Layout =
{ X: int
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 77fa314..8c1ea21 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -3311,11 +3311,21 @@ type LivingVillageGame() as this =
match m5View.Prompt, m5View.PromptTargetPos with
| Some text, Some targetPos ->
let scale = 2
- let rect = FloatingPrompt.layout viewport.Width viewport.Height targetPos.X targetPos.Y camera.X camera.Y scale text
+ let layout = FloatingPrompt.layoutWrapped viewport.Width viewport.Height targetPos.X targetPos.Y camera.X camera.Y scale text
+ let promptPalette = HudLayout.palette (M6Presentation.profileAtTick world.Tick).Mode
spriteBatch.Begin()
- spriteBatch.Draw(pixel, Rectangle(rect.X - 6, rect.Y - 5, rect.Width + 12, rect.Height + 8), Color(15, 19, 25, 200))
- spriteBatch.Draw(pixel, Rectangle(rect.X - 6, rect.Y - 5, rect.Width + 12, 2), Color(92, 180, 190))
- PixelText.draw spriteBatch pixel cjkAtlas rect.X rect.Y scale (Color(240, 250, 240)) text
+ // P77:与顶/底条同语言的药丸——半透明底 + 1px 描边 + 发丝高光,非大黑块。
+ spriteBatch.Draw(pixel, Rectangle(layout.X - 6, layout.Y - 5, layout.Width + 12, layout.Height + 8), promptPalette.SegmentBackdrop)
+ spriteBatch.Draw(pixel, Rectangle(layout.X - 6, layout.Y - 5, layout.Width + 12, 1), promptPalette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(layout.X - 6, layout.Y + 3, layout.Width + 12, 1), promptPalette.Accent)
+ spriteBatch.Draw(pixel, Rectangle(layout.X - 6, layout.Y - 5, 1, layout.Height + 8), promptPalette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(layout.X + layout.Width + 5, layout.Y - 5, 1, layout.Height + 8), promptPalette.Outline)
+ layout.Lines
+ |> List.iteri (fun index line ->
+ let lineY = layout.Y + index * (8 * scale + 4)
+ for (dx, dy) in HudLayout.textOutlineOffsets do
+ PixelText.draw spriteBatch pixel cjkAtlas (layout.X + dx) (lineY + dy) scale promptPalette.Outline line
+ PixelText.draw spriteBatch pixel cjkAtlas layout.X lineY scale promptPalette.Value line)
spriteBatch.End()
| _ -> ()
this.DrawM5Overlay()
@@ -3488,11 +3498,12 @@ type LivingVillageGame() as this =
let line1 = HudLayout.calendarLine1 reading
let line2 = HudLayout.calendarLine2 reading
spriteBatch.Begin()
+ // P77:与顶/底条同语言的药丸——半透明底 + 1px 描边 + 发丝高光,卡片微缩。
spriteBatch.Draw(pixel, rect, palette.SegmentBackdrop)
- spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, rect.Width, 2), palette.Accent)
- spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Bottom - 2, rect.Width, 2), palette.Outline)
- spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, 2, rect.Height), palette.Outline)
- spriteBatch.Draw(pixel, Rectangle(rect.Right - 2, rect.Y, 2, rect.Height), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, rect.Width, 1), palette.Accent)
+ spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Bottom - 1, rect.Width, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, 1, rect.Height), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(rect.Right - 1, rect.Y, 1, rect.Height), palette.Outline)
let iconX = rect.X + HudLayout.calendarPadX
let iconY = rect.Y + (rect.Height - HudLayout.iconHeight) / 2
this.DrawPattern (HudLayout.iconRows HudLayout.calendarIcon) iconX iconY HudLayout.iconScale palette.Value
@@ -3586,16 +3597,19 @@ type LivingVillageGame() as this =
let textScale = 2
let lineHeight = 18
let lines = M5Interaction.panelLines world m5View
- let panelWidth = min 380 (max 1 (viewport.Width - 32))
- let panelX = 16
- let innerX = panelX + 12
- let innerWidth = max 1 (panelWidth - 24)
+ let widthGuess = min 380 (max 1 (viewport.Width - 32))
+ let innerWidthGuess = max 1 (widthGuess - 24)
// P76:按实测像素宽度折行(中文逐字),换行而非整行截断;不再 Substring 截断。
- let wrapped = HudLayout.wrapLines innerWidth textScale lines
+ let wrapped = HudLayout.wrapLines innerWidthGuess textScale lines
let maxLines = max 1 ((viewport.Height - HudLayout.barHeight - 48) / lineHeight)
let visibleLines = wrapped |> List.truncate maxLines
- let panelHeight = visibleLines.Length * lineHeight + 26
- let panelY = max (HudLayout.barHeight + 12) (viewport.Height - panelHeight - 16)
+ // P77:面板几何由 HudLayout 统一给出,底边抬到 hotbar 顶上方,绝不遮挡快捷栏。
+ let rect = HudLayout.panelRect viewport.Width viewport.Height visibleLines.Length
+ let panelX = rect.X
+ let panelY = rect.Y
+ let panelWidth = rect.Width
+ let panelHeight = rect.Height
+ let innerX = panelX + HudLayout.panelPadX
spriteBatch.Begin()
// 半透明圆角感面板:细描边 + 1px 发丝线(与 P75/P76 同语言),非大色块/纯黑。
spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(38, 52, 62, 175))
diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs
index 4310586..63d4ed5 100644
--- a/src/LivingVillage.Desktop/HudLayout.fs
+++ b/src/LivingVillage.Desktop/HudLayout.fs
@@ -197,7 +197,7 @@ module HudLayout =
let calendarMarginRight = 12
let calendarMarginTop = 8
let calendarPadX = 10
- let calendarPadY = 8
+ let calendarPadY = 6
let calendarLineGap = 4
let calendarTextHeight = 8 * textScale
let calendarCardHeight = 2 * calendarTextHeight + calendarLineGap + 2 * calendarPadY
@@ -324,6 +324,23 @@ module HudLayout =
let hotbarInputAllowed (menuPlaying: bool) (worldInputAllowed: bool) : bool =
menuPlaying && worldInputAllowed
+ // ---- P77:底部左侧互动/任务面板几何(与 hotbar 错开,不遮挡)----
+
+ let panelLineHeight = 18
+ let panelMaxWidth = 380
+ let panelPadX = 12
+ let panelTopExtra = 26
+
+ /// 面板几何:左下角、宽度自适应视口,且底边始终抬到 hotbar 顶上方 8px(不遮挡快捷栏)。
+ let panelRect (viewportWidth: int) (viewportHeight: int) (lineCount: int) : Rectangle =
+ let width = min panelMaxWidth (max 1 (viewportWidth - 32))
+ let height = max 1 lineCount * panelLineHeight + panelTopExtra
+ let bar = hotbarBarRect viewportWidth viewportHeight
+ let maxBottom = bar.Y - 8
+ let y =
+ max (barHeight + 12) (min (viewportHeight - height - 16) (max 0 (maxBottom - height)))
+ Rectangle(16, y, width, height)
+
// ---- P76:面板文本按实测像素宽度折行(换行而非整行截断,中文逐字、拉丁逐字)----
let wrapText (maxWidth: int) (scale: int) (text: string) : string list =
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 3a65e8e..5281180 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -211,6 +211,59 @@ module FloatingPrompt =
let y = max topSafe (min bottomSafe screenY)
{ X = x; Y = y; Width = width; Height = height }
+ /// P77:锚定提示支持长文本折行(逐字、不丢字),供真实用例(如「E 与河边的货郎交谈并查看货品」)。
+ let maxPromptWidth = 360
+
+ type PromptLayout =
+ { Lines: string list
+ X: int
+ Y: int
+ Width: int
+ Height: int }
+
+ let wrapText (maxWidth: int) (scale: int) (text: string) : string list =
+ if text = "" then
+ [ "" ]
+ else
+ let builder = System.Text.StringBuilder()
+ let mutable lines = []
+ for ch in text.ToCharArray() do
+ let candidate = builder.ToString() + string ch
+ if builder.Length > 0 && measureWidth candidate scale > maxWidth then
+ lines <- builder.ToString() :: lines
+ builder.Clear() |> ignore
+ builder.Append(ch) |> ignore
+ else
+ builder.Append(ch) |> ignore
+ lines <- builder.ToString() :: lines
+ List.rev lines
+
+ /// 锚定点的多行提示版式:按需折行、水平居中于目标、垂直钳制在两条 HUD 带之间。
+ let layoutWrapped
+ (viewportWidth: int)
+ (viewportHeight: int)
+ (anchorWorldX: float32)
+ (anchorWorldY: float32)
+ (cameraX: float32)
+ (cameraY: float32)
+ (scale: int)
+ (text: string) : PromptLayout =
+ let margin = 8
+ let wrapWidth = min maxPromptWidth (max 80 (viewportWidth - 2 * margin))
+ let lines = wrapText wrapWidth scale text
+ let width = lines |> List.map (fun line -> measureWidth line scale) |> List.max
+ let lineHeight = 8 * scale + 4
+ let height = lines.Length * lineHeight + 10
+ let screenX = int anchorWorldX - int cameraX - width / 2
+ let screenY = int anchorWorldY - int cameraY - 24 - height
+ let x =
+ if width + margin * 2 > viewportWidth then margin
+ else max margin (min (viewportWidth - margin - width) screenX)
+ let topSafe = hudTopBarHeight + margin
+ let bottomSafe = max topSafe (viewportHeight - hudBottomBandHeight - height - margin)
+ let y = max topSafe (min bottomSafe screenY)
+ { Lines = lines; X = x; Y = y; Width = width; Height = height }
+
module M5Interaction =
let initial : M5View =