summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 12:43:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 12:43:20 +0800
commit12616f1d67d936a542e1e22926fb0ebcd4108906 (patch)
tree64e99e4eac72815d8aab15c599abb60d02d31c82
parenteaedd532c7d93dec9cca5d361685ed32c85aeebb (diff)
downloadliving-village-12616f1d67d936a542e1e22926fb0ebcd4108906.tar.gz
feat(desktop): 目标上方浮动情境提示
新增 FloatingPrompt 纯布局模块(含 measureWidth 与 PixelText 等宽规则):短提示锚定在唯一目标头顶上方,水平居中并钳制在视口内,避开左下 HUD 带(height-160)与右上状态条带(宽度 248);沿用现有 CJK 图集字体路径,不新造字形;对话/需求面板与菜单打开时不显示(复用 worldInputAllowed 判定与渲染门)。回归测试覆盖 1280x720/1920x1080 五个极限锚点、宽度测量、面板开启时提示为空且禁用世界输入。
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs40
-rw-r--r--src/LivingVillage.Desktop/Game.fs13
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs67
3 files changed, 116 insertions, 4 deletions
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index af34802..7e4ce5f 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -630,6 +630,46 @@ type PrototypeTests () =
CollectionAssert.AreEqual(snapshot, tieWorld.Npcs)
[<TestMethod>]
+ member _.FloatingPromptStaysInViewportAndClearOfHudBands () =
+ for (width, height) in [ (1280, 720); (1920, 1080) ] do
+ let anchors : (float32 * float32) list =
+ [ (100.0f, 100.0f) // near top-left
+ (float32 width / 2.0f, float32 height / 2.0f)
+ (float32 width - 60.0f, 120.0f) // near top-right strip
+ (200.0f, float32 height - 200.0f) // above bottom-left HUD band
+ (float32 width - 100.0f, float32 height - 120.0f) ]
+ for (anchorX, anchorY) in anchors do
+ for text in [ "E 交谈"; "E 进屋"; "E 出屋" ] do
+ let rect = FloatingPrompt.layout width height anchorX anchorY 0.0f 0.0f 2 text
+ let textWidth = FloatingPrompt.measureWidth text 2
+ Assert.AreEqual<int>(textWidth, rect.Width)
+ Assert.IsTrue(rect.Width > 0, "prompt must not be truncated to zero width")
+ Assert.IsTrue(rect.X >= 0 && rect.X + rect.Width <= width, sprintf "x overflow %A %A" (anchorX, anchorY) rect)
+ Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= height, sprintf "y overflow %A %A" (anchorX, anchorY) rect)
+ // Never cover the bottom-left HUD band (camera 0: band starts at height - 160).
+ Assert.IsTrue(rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8, sprintf "bottom HUD overlap %A %A" (anchorX, anchorY) rect)
+ // Never cover the top-right status strip.
+ if rect.X + rect.Width >= width - FloatingPrompt.hudTopRightWidth then
+ Assert.IsTrue(rect.Y >= FloatingPrompt.hudTopRightHeight, sprintf "top-right HUD overlap %A %A" (anchorX, anchorY) rect)
+
+ [<TestMethod>]
+ member _.FloatingPromptMeasureMatchesPixelTextAdvances () =
+ // E(12) space(8) 交(16) 谈(16) at scale 2.
+ Assert.AreEqual<int>(52, FloatingPrompt.measureWidth "E 交谈" 2)
+ Assert.AreEqual<int>(20, FloatingPrompt.measureWidth "E " 2)
+
+ [<TestMethod>]
+ member _.FloatingPromptHiddenWhilePanelsOrMenusAreOpen () =
+ let world = Sim.initialWorldN 42UL 2
+ let dialogueView = { M5Interaction.initial with Panel = DialoguePanel }
+ let cleared = M5Interaction.refreshPrompt world dialogueView
+ Assert.AreEqual<string option>(None, cleared.Prompt)
+ Assert.IsFalse(M5Interaction.worldInputAllowed dialogueView)
+
+ let needsView = { M5Interaction.initial with Panel = NeedsPanel }
+ Assert.AreEqual<string option>(None, M5Interaction.refreshPrompt world needsView |> fun value -> value.Prompt)
+
+ [<TestMethod>]
member _.WorldPanelDoesNotRenderA永久KeyList () =
let world = Sim.initialWorldN 42UL 0
let lines = M5Interaction.panelLines world M5Interaction.initial
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 4720008..1db5c3d 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -666,6 +666,19 @@ type LivingVillageGame() as this =
let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick
VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint
spriteBatch.End()
+
+ // Contextual floating prompt over the unique target; same CJK atlas font path as the HUD.
+ if m5View.Panel = M5Panel.WorldPanel && menu.Page = Playing then
+ 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
+ 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
+ spriteBatch.End()
+ | _ -> ()
this.DrawM5Overlay()
member private this.DrawM5Overlay() =
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 916544c..3c011cb 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -45,7 +45,8 @@ type M5View =
Status: string
StatusTick: int64 option
HomeMode: HomeMode
- Prompt: string option }
+ Prompt: string option
+ PromptTargetPos: Vec2 option }
module InteractionResolver =
@@ -149,6 +150,52 @@ module InteractionResolver =
let resolveAction = resolve
+module FloatingPrompt =
+
+ type PromptRect =
+ { X: int
+ Y: int
+ Width: int
+ Height: int }
+
+ // HUD exclusion geometry shared with the Game.fs renderer: bottom-left lightweight
+ // panel band and top-right corner status strip.
+ let hudBottomBandHeight = 160
+ let hudTopRightWidth = 248
+ let hudTopRightHeight = 72
+
+ /// Width of text as PixelText.draw advances it (scale applied, CJK 8px, latin 6px, space 4px).
+ let measureWidth (text: string) (scale: int) : int =
+ text.ToUpperInvariant()
+ |> Seq.fold (fun cursor character ->
+ if character = ' ' then cursor + 4 * scale
+ elif CjkGlyphAtlas.sourceRectangle character |> Option.isSome then cursor + 8 * scale
+ else cursor + 6 * scale) 0
+
+ /// Anchor the one short prompt above the unique target: horizontally centered on the
+ /// entity, clamped inside the viewport, and never covering either HUD band.
+ let layout
+ (viewportWidth: int)
+ (viewportHeight: int)
+ (anchorWorldX: float32)
+ (anchorWorldY: float32)
+ (cameraX: float32)
+ (cameraY: float32)
+ (scale: int)
+ (text: string) : PromptRect =
+ let width = measureWidth text scale
+ let height = 8 * scale + 10
+ let margin = 8
+ let screenX = int anchorWorldX - int cameraX - width / 2
+ let screenY = int anchorWorldY - int cameraY - 56
+ let x =
+ if width + margin * 2 > viewportWidth then margin
+ else max margin (min (viewportWidth - margin - width) screenX)
+ let topSafe = if x + width >= viewportWidth - hudTopRightWidth then hudTopRightHeight + margin else margin
+ let bottomSafe = viewportHeight - hudBottomBandHeight - height - margin
+ let y = max topSafe (min bottomSafe screenY)
+ { X = x; Y = y; Width = width; Height = height }
+
module M5Interaction =
let initial : M5View =
@@ -160,16 +207,20 @@ module M5Interaction =
Status = "ready"
StatusTick = None
HomeMode = Outside
- Prompt = None }
+ Prompt = None
+ PromptTargetPos = None }
let worldInputAllowed (view: M5View) : bool =
view.Panel = WorldPanel
let refreshPrompt (world: World) (view: M5View) : M5View =
if worldInputAllowed view then
- { view with Prompt = InteractionResolver.resolve world view.HomeMode |> Option.map (fun target -> target.Text) }
+ let resolved = InteractionResolver.resolve world view.HomeMode
+ { view with
+ Prompt = resolved |> Option.map (fun target -> target.Text)
+ PromptTargetPos = resolved |> Option.map (fun target -> target.Position) }
else
- { view with Prompt = None }
+ { view with Prompt = None; PromptTargetPos = None }
let resolveAction (world: World) (homeMode: HomeMode) : InteractionTarget option =
InteractionResolver.resolveAction world homeMode
@@ -250,6 +301,7 @@ module M5Interaction =
Menu = None
Chronicle = Sim.annalText next
Prompt = None
+ PromptTargetPos = None
Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
StatusTick = Some next.Tick }
| DialogueRejected(failure, next) ->
@@ -258,6 +310,7 @@ module M5Interaction =
Panel = WorldPanel
Menu = None
Prompt = None
+ PromptTargetPos = None
Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure)
StatusTick = Some next.Tick }
@@ -286,6 +339,7 @@ module M5Interaction =
Panel = DialoguePanel
Menu = Some menu
Prompt = None
+ PromptTargetPos = None
Status = menuStatus menu }
| Some { Action = EnterHome _ } ->
let pixel = { PixelPosition.X = world.Avatar.Pos.X; Y = world.Avatar.Pos.Y }
@@ -298,6 +352,7 @@ module M5Interaction =
Menu = None
HomeMode = nextMode
Prompt = None
+ PromptTargetPos = None
Status = "home entered" }
| Error failure -> world, { view with Prompt = None; Status = sprintf "home entry rejected: %A" failure }
| Some { Action = ExitHome _ } ->
@@ -309,6 +364,7 @@ module M5Interaction =
Menu = None
HomeMode = nextMode
Prompt = None
+ PromptTargetPos = None
Status = "home exited" }
| Error failure -> world, { view with Prompt = None; Status = sprintf "home exit rejected: %A" failure }
| None ->
@@ -327,6 +383,7 @@ module M5Interaction =
Panel = ObservationPanel
Observations = observations
Prompt = None
+ PromptTargetPos = None
Status = sprintf "observation visible_npcs=%d" observations.Length }
| ToggleNeeds ->
if view.Panel = NeedsPanel then
@@ -337,6 +394,7 @@ module M5Interaction =
Panel = NeedsPanel
Needs = Some(Sim.needsPanel world)
Prompt = None
+ PromptTargetPos = None
Status = "needs panel open" }
| ShowChronicle ->
world,
@@ -344,6 +402,7 @@ module M5Interaction =
Panel = ChroniclePanel
Chronicle = Sim.annalText world
Prompt = None
+ PromptTargetPos = None
Status = sprintf "chronicle entries=%d" world.Annals.Length }
| ClosePanel -> world, refreshPrompt world { view with Panel = WorldPanel; Menu = None; Status = "panel closed" }