summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p76-analysis.txt16
-rw-r--r--docs/evidence/p76-dialog-panel.pngbin0 -> 130287 bytes
-rw-r--r--docs/evidence/p76-hotbar-day.pngbin0 -> 113516 bytes
-rw-r--r--docs/evidence/p76-hotbar-night.pngbin0 -> 112502 bytes
-rw-r--r--docs/维护说明.md19
-rw-r--r--scripts/analyze-p76.py152
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P76HotbarPanelTests.fs57
-rw-r--r--src/LivingVillage.Desktop/Game.fs97
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs56
10 files changed, 365 insertions, 33 deletions
diff --git a/docs/evidence/p76-analysis.txt b/docs/evidence/p76-analysis.txt
new file mode 100644
index 0000000..97513ef
--- /dev/null
+++ b/docs/evidence/p76-analysis.txt
@@ -0,0 +1,16 @@
+P76(底部条/槽位药丸化 + 对话/交互面板半透明描边)证据分析 — 真实 Desktop xvfb 1280x720
+近黑阈值 24(旧纯黑面板底色 (14,22,30) 口径);实心块 = 24x24 全近黑。
+
+p43-hud-day: hotbar=(405, 660, 470, 56) size=(470, 56) dark_frac=0.109 maxblk=94 bright=1804 | legend dark_frac=0.116 maxblk=176 bright=1275
+p43-hud-night: hotbar=(405, 660, 470, 56) size=(470, 56) dark_frac=0.108 maxblk=94 bright=1683 | legend dark_frac=0.115 maxblk=176 bright=1196
+
+p44-dialog-a: panel=(190, 558, 900, 138) dark_frac=0.013 maxblk=101 bright=2837
+
+p67-target task-panel region=(16,470,380,220) dark_frac=0.003 maxblk=70 bright=378
+
+diff p43-hud-day vs p43-hud-night: differing_pixels=921386
+diff p44-dialog-a vs p44-dialog-b: differing_pixels=3978
+
+verdict=PASS
+ hotbar/legend/dialog/task regions have no solid near-black block;
+ key chips, icons and action-name legend remain bright; frames differ pairwise.
diff --git a/docs/evidence/p76-dialog-panel.png b/docs/evidence/p76-dialog-panel.png
new file mode 100644
index 0000000..64a1251
--- /dev/null
+++ b/docs/evidence/p76-dialog-panel.png
Binary files differ
diff --git a/docs/evidence/p76-hotbar-day.png b/docs/evidence/p76-hotbar-day.png
new file mode 100644
index 0000000..5030d62
--- /dev/null
+++ b/docs/evidence/p76-hotbar-day.png
Binary files differ
diff --git a/docs/evidence/p76-hotbar-night.png b/docs/evidence/p76-hotbar-night.png
new file mode 100644
index 0000000..5166e17
--- /dev/null
+++ b/docs/evidence/p76-hotbar-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index e1a81fa..43e07d3 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -236,6 +236,25 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
~2.6%(相对整宽深色带基线 100% 下降 >97%)、无实心 24×24 黑块、亮字像素 >1000,落
`docs/evidence/p75-*`。
+## P76 底部条 / 槽位药丸化与面板换行
+
+- 底部快捷栏(`Game.DrawHotbar`):去掉整条大色块与实心 40×40 槽位填充,改与顶部条同语言——
+ 半透明条药丸 + 1px 描边 + `hotbarBarHairline` 发丝线;每个槽位内 `hotbarKeyChipRect`(键位
+ 符号小片)+ `hotbarIconRectBesideKey`(图形)**并排**。选中格用 accent 描边 + 底部发丝线,不再
+ 大色块。`selectSlotForDigit/cycleHotbar/hotbarInputAllowed/hotbarSlotRect` 等语义与 1-0 映射**不变**。
+- 底部**图例**:`HudLayout.actionLegend` 取自真实 `MenuState.controlRows`(E/Q/TAB/C/L/F6/F7),
+ 以「键位符号 + 动作名」并排展示;**纯展示,不新增/改动任何绑定**。
+- 对话/交互面板:`Game.DrawDialogOverlay` 背景由近纯黑 (14,22,30,232) 改为半透明深青
+ (46,60,70,200)/(30,42,52,210) + 1px 描边 + accent 发丝线;`Game.DrawPanel` 同步改半透明描边语言。
+- 面板换行:新增 `HudLayout.wrapText/wrapLines`(按 `FloatingPrompt.measureWidth` 实测宽度逐字折行),
+ `DrawPanel` 弃用 `Substring` 整行截断,改折行;行长不溢出、内容不丢字;Esc 关闭行为不变。
+- 保存成功/失败短暂反馈沿用既有 `DrawTransientStatus`,未重做。
+- **回归**:`P76HotbarPanelTests`(键位小片与图形并排在槽内、图例项全部来自真实 controlRows 且键序
+ 一致、折行不超宽且不丢字、`wrapLines` 铺平无损失)。
+- **证据**:`LV_P43_SHOT=1` 昼/夜(同机位)+ `LV_P44_SHOT=1` 对话面板打开态 →
+ `scripts/analyze-p76.py`:底部条/图例/对话/任务四区近黑占比 ≤0.013–0.116、无实心 24×24 近黑块、
+ 亮像素充足,落 `docs/evidence/p76-*`。
+
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
diff --git a/scripts/analyze-p76.py b/scripts/analyze-p76.py
new file mode 100644
index 0000000..d67d4a0
--- /dev/null
+++ b/scripts/analyze-p76.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+"""P76(底部条/对话面板精修)证据分析:真实 xvfb 昼/夜 HUD + 对话面板/任务面板打开态。
+
+判据(读真实实跑日志 + 区域像素统计;近黑阈值 24 对齐旧纯黑面板底色):
+ 1. 日志含 p43-shot day/night、p44-shot=p44-dialog-a、p67-shot=p67-target。
+ 2. 底部条区域(hotbar rect)与图例带:近黑占比 < 20%、无实心 24x24 近黑块、亮像素(键位/图标/文字)> 400。
+ 3. 对话面板 / 任务面板区域:近黑占比 < 20%、无实心 24x24 近黑块、亮文字像素 > 阈值。
+ 4. 昼/夜两帧、对话第 1/2 页两帧 pairwise diff > 0。
+ 5. 观感由 Hermes vision 侧复核,非本脚本判据。
+
+Usage: python3 scripts/analyze-p76.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
+
+
+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())
+ block = 0
+ if mask.shape[0] >= BLOCK and mask.shape[1] >= BLOCK:
+ win = sliding_window_view(mask.astype(np.int64), (BLOCK, BLOCK))
+ block = int(win.sum(axis=(2, 3)).max())
+ bright = int(((r[:, :, 0] >= 200) & (r[:, :, 1] >= 200) & (r[:, :, 2] >= 200)).sum())
+ return dark_frac, block, bright, (w, h)
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ failures = []
+ lines = [
+ "P76(底部条/槽位药丸化 + 对话/交互面板半透明描边)证据分析 — 真实 Desktop xvfb 1280x720",
+ "近黑阈值 24(旧纯黑面板底色 (14,22,30) 口径);实心块 = 24x24 全近黑。",
+ "",
+ ]
+
+ # --- 底部条:昼/夜 ---
+ hud = {m.group(1): (int(m.group(7)), int(m.group(8)), int(m.group(9)), int(m.group(10))) for m in P43_RE.finditer((rec / "log-P43.txt").read_text(encoding="utf-8", errors="replace") if (rec / "log-P43.txt").exists() else "")}
+ for name in ["p43-hud-day", "p43-hud-night"]:
+ path = rec / f"{name}.png"
+ if name not in hud or not path.exists():
+ failures.append(f"missing {name}")
+ continue
+ bar = hud[name]
+ df, block, bright, size = region_stats(path, bar)
+ # 图例带:条上方 30px
+ legend = (bar[0] - 5, bar[1] - 34, bar[2] + 10, 30)
+ ldf, lblock, lbright, _ = region_stats(path, legend)
+ lines.append(
+ f"{name}: hotbar={bar} size={size} dark_frac={df:.3f} maxblk={block} bright={bright} | legend dark_frac={ldf:.3f} maxblk={lblock} bright={lbright}"
+ )
+ if size != (1280, 720) and size[0] > 0:
+ pass
+ if df >= DARK_FRAC_MAX:
+ failures.append(f"{name} hotbar dark_frac {df:.3f} >= {DARK_FRAC_MAX}")
+ if block >= BLOCK * BLOCK:
+ failures.append(f"{name} hotbar has solid {BLOCK}x{BLOCK} near-black block")
+ if bright < 400:
+ failures.append(f"{name} hotbar bright {bright} < 400 (键位/图标/文字可能丢失)")
+ if ldf >= DARK_FRAC_MAX:
+ failures.append(f"{name} legend dark_frac {ldf:.3f} >= {DARK_FRAC_MAX}")
+ if lbright < 200:
+ failures.append(f"{name} legend bright {lbright} < 200 (键位+动作名可能不可读)")
+ lines.append("")
+
+ # --- 对话面板 ---
+ p44_log = (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_log)}
+ dpath = rec / "p44-dialog-a.png"
+ if "p44-dialog-a" in dialogs and dpath.exists():
+ rect = dialogs["p44-dialog-a"]
+ df, block, bright, size = region_stats(dpath, rect)
+ lines.append(f"p44-dialog-a: panel={rect} dark_frac={df:.3f} maxblk={block} bright={bright}")
+ if df >= DARK_FRAC_MAX:
+ failures.append(f"dialog panel dark_frac {df:.3f} >= {DARK_FRAC_MAX}")
+ if block >= BLOCK * BLOCK:
+ failures.append(f"dialog panel has solid {BLOCK}x{BLOCK} near-black block")
+ if bright < 300:
+ failures.append(f"dialog panel bright {bright} < 300")
+ else:
+ failures.append("missing p44-dialog-a panel")
+ lines.append("")
+
+ # --- 任务面板(交互面板换行渲染路径)---
+ tpath = rec / "p67-target.png"
+ if tpath.exists():
+ df, block, bright, size = region_stats(tpath, (16, 470, 380, 220))
+ lines.append(f"p67-target task-panel region=(16,470,380,220) 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(f"task panel has solid {BLOCK}x{BLOCK} near-black block")
+ else:
+ failures.append("missing p67-target.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-b")]:
+ 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(" hotbar/legend/dialog/task regions have no solid near-black block;")
+ lines.append(" key chips, icons and action-name legend remain bright; 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 dc2cb23..c509a0b 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -47,6 +47,7 @@
<Compile Include="P73LightingTierTests.fs" />
<Compile Include="P74LaunchTransitionTests.fs" />
<Compile Include="P75HudStripTests.fs" />
+ <Compile Include="P76HotbarPanelTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P76HotbarPanelTests.fs b/src/LivingVillage.Desktop.Tests/P76HotbarPanelTests.fs
new file mode 100644
index 0000000..5a1dd55
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P76HotbarPanelTests.fs
@@ -0,0 +1,57 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+
+/// P76 回归:底部条/槽位药丸化(键位+图形并排)与面板文本换行不截断。
+[<TestClass>]
+type P76HotbarPanelTests () =
+
+ let slot = HudLayout.hotbarSlotRect 1280 720 0
+
+ [<TestMethod>]
+ member _.KeyChipSitsBesideIconInsideTheSlot () =
+ let chip = HudLayout.hotbarKeyChipRect slot
+ let icon = HudLayout.hotbarIconRectBesideKey slot
+ Assert.IsTrue(chip.X >= slot.X && chip.Right <= slot.Right, "键位小片须在槽内")
+ Assert.IsTrue(icon.X >= chip.Right, "图形须在键位小片右侧(并排)")
+ Assert.IsTrue(icon.Right <= slot.Right, "图形须在槽内")
+ Assert.IsTrue(chip.Y >= slot.Y && chip.Bottom <= slot.Bottom, "键位小片须垂直居中在槽内")
+ let bar = HudLayout.hotbarBarRect 1280 720
+ let hair = HudLayout.hotbarBarHairline bar
+ Assert.AreEqual<int>(1, hair.Height)
+ Assert.IsTrue(hair.X >= bar.X && hair.Right <= bar.Right, "发丝线须在条内")
+
+ [<TestMethod>]
+ member _.ActionLegendUsesOnlyRealControlRows () =
+ let legend = HudLayout.actionLegend MenuState.controlRows
+ Assert.IsTrue(legend.Length > 0, "图例不得为空")
+ for row in legend do
+ Assert.IsTrue(
+ MenuState.controlRows |> List.exists (fun r -> r.Keys = row.Keys && r.Action = row.Action),
+ sprintf "图例项 %s/%s 必须是真实 controlRows,不得杜撰" row.Keys row.Action)
+ Assert.IsFalse(System.String.IsNullOrWhiteSpace row.Keys)
+ Assert.IsFalse(System.String.IsNullOrWhiteSpace row.Action)
+ // 键序与预设一致(纯展示,不改绑定)。
+ Assert.AreEqual<string list>(HudLayout.actionLegendKeys, legend |> List.map (fun r -> r.Keys))
+
+ [<TestMethod>]
+ member _.PanelWrapNeverExceedsWidthNorDropsCharacters () =
+ let maxWidth = 240
+ let scale = 2
+ let text = "今日天气晴好适合下田耕作记得给菜畦浇水并去市集看看货郎的货品是否齐全"
+ let wrapped = HudLayout.wrapText maxWidth scale text
+ Assert.IsTrue(wrapped.Length > 1, "长行须折成多行")
+ for line in wrapped do
+ Assert.IsTrue(FloatingPrompt.measureWidth line scale <= maxWidth, sprintf "折行超宽: %s" line)
+ Assert.AreEqual<string>(text, System.String.Concat wrapped)
+ Assert.AreEqual<string list>([ "" ], HudLayout.wrapText maxWidth scale "")
+ Assert.AreEqual<string list>([ "短行" ], HudLayout.wrapText maxWidth scale "短行")
+
+ [<TestMethod>]
+ member _.WrapLinesFlattensWithoutLosingText () =
+ let source = [ "第一行"; "第二行很长很长很长很长很长很长很长很长很长很长很长很长"; "" ]
+ let wrapped = HudLayout.wrapLines 160 2 source
+ Assert.AreEqual<string>(System.String.Concat source, System.String.Concat wrapped)
+ for line in wrapped do
+ Assert.IsTrue(FloatingPrompt.measureWidth line 2 <= 160)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index c6408aa..77fa314 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -3346,16 +3346,16 @@ type LivingVillageGame() as this =
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))
+ // P76:对话面板改与顶部/底部条同语言——半透明底 + 1px 描边 + 发丝强调线,避免大黑块。
+ spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, layout.Height / 2), Color(46, 60, 70, 200))
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)
+ Color(30, 42, 52, 210))
+ spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, 1), palette.Accent)
+ spriteBatch.Draw(pixel, Rectangle(lx, ly + layout.Height - 1, layout.Width, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(lx, ly, 1, layout.Height), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(lx + layout.Width - 1, ly, 1, layout.Height), palette.Outline)
// 头像:原创 8x16 点阵 @ scale 4 = 32x64,加内框。
if model.ShowAvatar then
let avatar = DialogOverlay.avatarRect layout
@@ -3513,27 +3513,54 @@ type LivingVillageGame() as this =
let palette = HudLayout.palette profile.Mode
let bar = HudLayout.hotbarBarRect viewport.Width viewport.Height
spriteBatch.Begin()
+ // P76:底部条改为与顶部条同语言的紧凑半透明药丸——细描边 + 1px 发丝线,不再铺大色块。
spriteBatch.Draw(pixel, bar, palette.SegmentBackdrop)
- spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, bar.Width, 2), palette.Accent)
- spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Bottom - 2, bar.Width, 2), palette.Outline)
- spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, 2, bar.Height), palette.Outline)
- spriteBatch.Draw(pixel, Rectangle(bar.Right - 2, bar.Y, 2, bar.Height), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, bar.Width, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Bottom - 1, bar.Width, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, 1, bar.Height), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(bar.Right - 1, bar.Y, 1, bar.Height), palette.Outline)
+ spriteBatch.Draw(pixel, HudLayout.hotbarBarHairline bar, palette.Accent)
for index in 0 .. HudLayout.hotbarSlotCount - 1 do
let slot = HudLayout.hotbarSlotRect viewport.Width viewport.Height index
let selected = index = hotbarSelected
- let slotBackdrop =
- if selected then Color(48, 66, 74, 210) else palette.SegmentBackdrop
- spriteBatch.Draw(pixel, slot, slotBackdrop)
+ // 槽位药丸:半透明底(选中略亮),1px 描边 + 发丝线,不再是实心大色块。
+ let slotFill =
+ if selected then Color(48, 66, 74, 170) else palette.SegmentBackdrop
+ spriteBatch.Draw(pixel, slot, slotFill)
let border = if selected then palette.Accent else palette.Outline
- spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, slot.Width, 2), border)
- spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Bottom - 2, slot.Width, 2), border)
- spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, 2, slot.Height), border)
- spriteBatch.Draw(pixel, Rectangle(slot.Right - 2, slot.Y, 2, slot.Height), border)
- let iconRect = HudLayout.hotbarIconRect slot
+ spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, slot.Width, 1), border)
+ spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Bottom - 1, slot.Width, 1), border)
+ spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, 1, slot.Height), border)
+ spriteBatch.Draw(pixel, Rectangle(slot.Right - 1, slot.Y, 1, slot.Height), border)
+ if selected then
+ spriteBatch.Draw(pixel, Rectangle(slot.X + 1, slot.Y + slot.Height - 2, max 1 (slot.Width - 2), 1), palette.Accent)
+ // 键位符号小片 + 图形并排(P76 信息层次)。
+ let keyChip = HudLayout.hotbarKeyChipRect slot
+ spriteBatch.Draw(pixel, keyChip, palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(keyChip.X + 1, keyChip.Y + 1, max 1 (keyChip.Width - 2), max 1 (keyChip.Height - 2)), Color(18, 28, 34, 220))
+ let label = if index = 9 then "0" else string (index + 1)
+ PixelText.draw spriteBatch pixel cjkAtlas (keyChip.X + 3) (keyChip.Y + 2) 1 (if selected then palette.Accent else palette.Label) label
+ let iconRect = HudLayout.hotbarIconRectBesideKey slot
let iconColor = if selected then palette.Accent else palette.Value
this.DrawPattern (HudLayout.hotbarIconRows index) iconRect.X iconRect.Y HudLayout.hotbarIconScale iconColor
- let label = if index = 9 then "0" else string (index + 1)
- PixelText.draw spriteBatch pixel cjkAtlas (slot.X + 3) (slot.Y + 2) 1 (if selected then palette.Accent else palette.Label) label
+ // 图例:真实按键 + 动作名并排(取自 MenuState.controlRows,纯展示,不改任何绑定)。
+ let legend = HudLayout.actionLegend MenuState.controlRows
+ if not legend.IsEmpty then
+ let legendText = legend |> List.map (fun row -> sprintf "%s %s" row.Keys row.Action) |> String.concat " "
+ let scale = HudLayout.textScale
+ let textWidth = FloatingPrompt.measureWidth legendText scale
+ let legendHeight = 8 * scale + 12
+ let legendWidth = textWidth + 20
+ let legendX = (viewport.Width - legendWidth) / 2
+ let legendY = bar.Y - legendHeight - 6
+ spriteBatch.Draw(pixel, Rectangle(legendX, legendY, legendWidth, legendHeight), palette.SegmentBackdrop)
+ spriteBatch.Draw(pixel, Rectangle(legendX, legendY, legendWidth, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(legendX, legendY + legendHeight - 1, legendWidth, 1), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(legendX, legendY, 1, legendHeight), palette.Outline)
+ spriteBatch.Draw(pixel, Rectangle(legendX + legendWidth - 1, legendY, 1, legendHeight), palette.Outline)
+ for (dx, dy) in HudLayout.textOutlineOffsets do
+ PixelText.draw spriteBatch pixel cjkAtlas (legendX + 10 + dx) (legendY + 6 + dy) scale palette.Outline legendText
+ PixelText.draw spriteBatch pixel cjkAtlas (legendX + 10) (legendY + 6) scale palette.Value legendText
spriteBatch.End()
/// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。
@@ -3560,23 +3587,27 @@ type LivingVillageGame() as this =
let lineHeight = 18
let lines = M5Interaction.panelLines world m5View
let panelWidth = min 380 (max 1 (viewport.Width - 32))
- let maxLines = max 1 ((viewport.Height - HudLayout.barHeight - 48) / lineHeight)
- let visibleLines = lines |> List.truncate maxLines
- let panelHeight = visibleLines.Length * lineHeight + 26
let panelX = 16
- let panelY = max (HudLayout.barHeight + 12) (viewport.Height - panelHeight - 16)
let innerX = panelX + 12
let innerWidth = max 1 (panelWidth - 24)
- let maxChars = max 1 (innerWidth / (6 * textScale))
+ // P76:按实测像素宽度折行(中文逐字),换行而非整行截断;不再 Substring 截断。
+ let wrapped = HudLayout.wrapLines innerWidth 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)
spriteBatch.Begin()
- // 竖向渐变板(半透明,非纯黑),顶部与左侧各一条强调线。
- spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(30, 44, 52, 190))
- spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight / 2, panelWidth, panelHeight - panelHeight / 2), Color(16, 24, 30, 190))
- spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 3), Color(92, 180, 190))
- spriteBatch.Draw(pixel, Rectangle(panelX, panelY, 2, panelHeight), Color(92, 180, 190, 160))
+ // 半透明圆角感面板:细描边 + 1px 发丝线(与 P75/P76 同语言),非大色块/纯黑。
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(38, 52, 62, 175))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight / 2, panelWidth, panelHeight - panelHeight / 2), Color(26, 38, 48, 180))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 1), Color(92, 180, 190))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, 1, panelHeight), Color(92, 180, 190, 180))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight - 1, panelWidth, 1), Color(8, 14, 20, 200))
+ spriteBatch.Draw(pixel, Rectangle(panelX + panelWidth - 1, panelY, 1, panelHeight), Color(8, 14, 20, 200))
visibleLines
|> List.iteri (fun index line ->
- let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line
let color = if index = 0 then Color(226, 238, 226) else Color(192, 208, 212)
- PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 13 + index * lineHeight) textScale color visible)
+ for (dx, dy) in HudLayout.textOutlineOffsets do
+ PixelText.draw spriteBatch pixel cjkAtlas (innerX + dx) (panelY + 13 + index * lineHeight + dy) textScale (Color(6, 10, 14)) line
+ PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 13 + index * lineHeight) textScale color line)
spriteBatch.End()
diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs
index cd06224..4310586 100644
--- a/src/LivingVillage.Desktop/HudLayout.fs
+++ b/src/LivingVillage.Desktop/HudLayout.fs
@@ -260,6 +260,39 @@ module HudLayout =
let size = iconGrid * hotbarIconScale
Rectangle(slot.X + (slot.Width - size) / 2, slot.Y + (slot.Height - size) / 2, size, size)
+ // ---- P76:底部条/槽位药丸化(键位符号 + 图形并排,去大色块)----
+
+ /// 键位符号小片(数字 1..0)尺寸:放在槽位左侧,与图标并排。
+ let hotbarKeyChipWidth = 12
+ let hotbarKeyChipHeight = 12
+
+ /// 槽位内的键位小片矩形(左对齐、垂直居中)。
+ let hotbarKeyChipRect (slot: Rectangle) : Rectangle =
+ Rectangle(slot.X + 3, slot.Y + (slot.Height - hotbarKeyChipHeight) / 2, hotbarKeyChipWidth, hotbarKeyChipHeight)
+
+ /// 槽位内图形矩形(键位小片右侧,与图标并排)。
+ let hotbarIconRectBesideKey (slot: Rectangle) : Rectangle =
+ let size = iconGrid * hotbarIconScale
+ let x = (hotbarKeyChipRect slot).Right + 4
+ Rectangle(x, slot.Y + (slot.Height - size) / 2, size, size)
+
+ /// 底部条顶部 1px 强调发丝线(与 P75 顶部条同语言)。
+ let hotbarBarHairline (bar: Rectangle) : Rectangle = Rectangle(bar.X + 1, bar.Y + 1, max 1 (bar.Width - 2), 1)
+
+ // ---- P76:真实按键 + 动作名图例(纯展示,取自 MenuState.controlRows,不改任何绑定)----
+
+ /// 底部图例:真实存在的按键/动作对(subset of MenuState.controlRows 的顺序)。
+ /// 仅做信息层次展示,不新增/改动任何快捷键;hotbar 的 1-0 选中语义保持不变。
+ let actionLegendKeys : string list = [ "E"; "Q"; "TAB"; "C"; "L"; "F6"; "F7" ]
+
+ let actionLegend (rows: ControlRow list) : ControlRow list =
+ actionLegendKeys
+ |> List.choose (fun key -> rows |> List.tryFind (fun row -> row.Keys = key))
+
+ /// 图例单条文本宽度(键位 + 空格 + 动作名)。
+ let legendEntryWidth (row: ControlRow) : int =
+ FloatingPrompt.measureWidth (sprintf "%s %s" row.Keys row.Action) textScale
+
/// 10 个原创 7x7 点阵占位图标(0..9),仅用现有像素贴图绘制,无外部素材。
let hotbarIconPatterns : string list list =
[ [ "0011100"; "0100010"; "1000001"; "1000001"; "1000001"; "0100010"; "0011100" ]
@@ -290,3 +323,26 @@ module HudLayout =
/// hotbar 输入仅在「游戏内(非菜单)」且世界输入允许(无面板/对话)时生效。
let hotbarInputAllowed (menuPlaying: bool) (worldInputAllowed: bool) : bool =
menuPlaying && worldInputAllowed
+
+ // ---- P76:面板文本按实测像素宽度折行(换行而非整行截断,中文逐字、拉丁逐字)----
+
+ 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 && FloatingPrompt.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
+
+ /// 把多行按宽度折行后铺平(不丢字符、不截断)。
+ let wrapLines (maxWidth: int) (scale: int) (lines: string list) : string list =
+ lines |> List.collect (wrapText maxWidth scale)