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/LivingVillage.Desktop/Interaction.fs | |
| 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/LivingVillage.Desktop/Interaction.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 67 |
1 files changed, 63 insertions, 4 deletions
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" } |
