summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 13:46:49 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 13:46:49 +0800
commit0439e21a0582ab892c0e4bfa936c4d62ca2bcc61 (patch)
tree42cbf64e93c84f9644b2d001b12d4288b42f6a38 /src
parente959b1df06f29fe6542cc6cbe2f9005f620d5dc9 (diff)
downloadliving-village-0439e21a0582ab892c0e4bfa936c4d62ca2bcc61.tar.gz
p76: 底部条/槽位药丸化 + 对话/交互面板半透明描边与折行
DrawHotbar 去掉整条大色块与实心槽位,改半透明条+1px 描边+发丝线,槽位内键位小片与图形并排,选中用 accent 描边;新增 HudLayout.actionLegend(取真实 controlRows 的键位+动作名图例,纯展示不改绑定)。DrawDialogOverlay/DrawPanel 背景改半透明描边语言,禁用近纯黑底。新增 HudLayout.wrapText/wrapLines 按实测宽度折行,DrawPanel 弃用 Substring 截断。新增 P76HotbarPanelTests(4 例);LV_P43_SHOT 昼/夜 + LV_P44_SHOT 对话面板证据 + analyze-p76(四区无实心近黑块、亮像素充足,verdict=PASS)。Desktop 358/358、Kernel 120/120、digest 与 p70 一致。
Diffstat (limited to 'src')
-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
4 files changed, 178 insertions, 33 deletions
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)