diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:43:20 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 12:43:20 +0800 |
| commit | 12616f1d67d936a542e1e22926fb0ebcd4108906 (patch) | |
| tree | 64e99e4eac72815d8aab15c599abb60d02d31c82 /src | |
| parent | eaedd532c7d93dec9cca5d361685ed32c85aeebb (diff) | |
| download | living-village-12616f1d67d936a542e1e22926fb0ebcd4108906.tar.gz | |
feat(desktop): 目标上方浮动情境提示
新增 FloatingPrompt 纯布局模块(含 measureWidth 与 PixelText 等宽规则):短提示锚定在唯一目标头顶上方,水平居中并钳制在视口内,避开左下 HUD 带(height-160)与右上状态条带(宽度 248);沿用现有 CJK 图集字体路径,不新造字形;对话/需求面板与菜单打开时不显示(复用 worldInputAllowed 判定与渲染门)。回归测试覆盖 1280x720/1920x1080 五个极限锚点、宽度测量、面板开启时提示为空且禁用世界输入。
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 40 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 13 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 67 |
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" } |
