summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
6 files changed, 157 insertions, 17 deletions
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 =