From 7673d4af229735c9bdec5b167f7b29985b5bc858 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 23:24:24 +0800 Subject: p53: 情境提示与输入隔离回归——提示文案=实际作用对象(唯一映射)+隔墙/边界/切换/离开/隔离断言 + LV_P53_REC 录制证据 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P53InteractionPromptTests.fs | 203 +++++++++++++++++++++ src/LivingVillage.Desktop/Game.fs | 91 +++++++++ src/LivingVillage.Desktop/Interaction.fs | 14 +- 4 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 85381fc..bda9932 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -30,6 +30,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs b/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs new file mode 100644 index 0000000..c3e9f62 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs @@ -0,0 +1,203 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop +open LivingVillage.Desktop.VillagePresentation + +/// P53:情境提示与输入隔离回归(brief 第 8 条交互硬要求)。 +/// 每个用例都断言**显式目标**(NpcId / HomeId),并把「提示文案」与「实际作用对象」 +/// 收敛到同一选择规则:提示写「E 交谈」时按 E 必须作用于同一 Npc。 +/// 提示层是瞬时叠加层,绝非常驻面板。 +[] +type P53InteractionPromptTests () = + + let atPosition (x: float32) (y: float32) (world: World) : World = + { world with Avatar = { world.Avatar with Pos = ({ X = x; Y = y } : Vec2) } } + + let withNpcsAt (positions: (int * Vec2) list) (world: World) : World = + let npcs = Array.copy world.Npcs + for index in 0 .. npcs.Length - 1 do + npcs.[index] <- { npcs.[index] with Pos = ({ X = 4000.0f; Y = 4000.0f } : Vec2) } + for (index, position) in positions do + npcs.[index] <- { npcs.[index] with Pos = position } + { world with Npcs = npcs } + + let avatar : Vec2 = { X = 1600.0f; Y = 800.0f } + + let promptTarget (world: World) : InteractionAction option = + InteractionResolver.resolve world Outside |> Option.map (fun target -> target.Action) + + let promptText (world: World) : string option = + (M5Interaction.refreshPrompt world M5Interaction.initial).Prompt + + [] + member _.PromptAppearsOnlyInsideRangeWithExplicitTarget () = + let baseWorld = Sim.initialWorldN 42UL 4 |> withNpcsAt [] + let atBoundary = + baseWorld + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ] + let beyond = + baseWorld + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + Sim.chatRangePx + 0.5f; Y = avatar.Y } ] + + Assert.AreEqual(Some(TalkTo(NpcId 0)), promptTarget atBoundary) + Assert.AreEqual("E 交谈", (InteractionResolver.resolve atBoundary Outside).Value.Text) + Assert.AreEqual(Some "E 交谈", promptText atBoundary) + Assert.AreEqual(None, promptTarget beyond) + Assert.AreEqual(None, promptText beyond) + + [] + member _.NearestReachableTargetWinsDeterministicallyAndOnlyOneIsShown () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } + 1, { X = avatar.X + 48.0f; Y = avatar.Y } + 2, { X = avatar.X + 60.0f; Y = avatar.Y } ] + + for _ in 1 .. 5 do + Assert.AreEqual(Some(TalkTo(NpcId 1)), promptTarget world) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual(Some "E 交谈", view.Prompt) + // 仅一个高亮:提示目标位置就是被选中的 1 号村民,别处不出现第二个提示。 + Assert.AreEqual(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + // 走近远端村民:最近目标切换为 0 号,仍然只选一个。 + let switched = world |> atPosition (avatar.X + 84.0f) avatar.Y + Assert.AreEqual(Some(TalkTo(NpcId 0)), promptTarget switched) + + [] + member _.EqualDistanceTieBreaksByStableId () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 40.0f; Y = avatar.Y } + 1, { X = avatar.X; Y = avatar.Y + 40.0f } ] + // 两个候选与玩家的距离平方相同(1600),稳定按 NpcId 升序取 0 号。 + Assert.AreEqual(Some(TalkTo(NpcId 0)), promptTarget world) + + [] + member _.PromptDisappearsImmediatelyAfterLeavingWithExplicitNone () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let shown = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual(Some "E 交谈", shown.Prompt) + Assert.IsTrue(shown.PromptTargetPos.IsSome) + + let left = world |> atPosition 3200.0f 2400.0f + Assert.AreEqual(None, promptTarget left) + let cleared = M5Interaction.refreshPrompt left shown + Assert.AreEqual(None, cleared.Prompt) + Assert.AreEqual(None, cleared.PromptTargetPos) + + [] + member _.WallBlocksBothThePromptAndTheInteraction () = + let wallAvatar : Vec2 = + { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) } + let wallNpc : Vec2 = + { X = float32 (31 * Sim.tilePixels + Sim.tilePixels / 2) + Y = float32 (22 * Sim.tilePixels + Sim.tilePixels / 2) } + let world = + Sim.initialWorldN 42UL 1 + |> atPosition wallAvatar.X wallAvatar.Y + |> withNpcsAt [ 0, wallNpc ] + + Assert.IsFalse(InteractionResolver.hasClearPath wallAvatar wallNpc) + Assert.AreEqual(None, promptTarget world) + Assert.AreEqual(None, promptText world) + + // 隔墙时按 E 不产生世界副作用,面板停在世界层。 + let nextWorld, nextView = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual(world, nextWorld) + Assert.AreEqual(WorldPanel, nextView.Panel) + + [] + member _.OpenLayersIsolateWorldInteractionAndClearThePrompt () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let panels = [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] + for panel in panels do + let view = + { M5Interaction.initial with + Panel = panel + Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) } + Assert.IsFalse(M5Interaction.worldInputAllowed view, sprintf "%A 不得放行世界输入" panel) + let nextWorld, nextView = M5Interaction.apply Interact world view + Assert.AreEqual(world, nextWorld) + Assert.IsTrue((panel = nextView.Panel), sprintf "%A 开启时 Interact 不得改选目标" panel) + Assert.AreEqual(None, (M5Interaction.refreshPrompt world view).Prompt) + // 世界层里非对话面板命令不得误开对话;买卖键只在对话层生效。 + let worldLayer = M5Interaction.initial + Assert.IsFalse(M5Interaction.worldInputAllowed { worldLayer with Panel = TaskPanel }) + let _, bought = M5Interaction.apply BuyFromTarget world worldLayer + Assert.AreEqual(None, bought.Menu |> Option.map (fun m -> m.Target.ToString())) + + [] + member _.EscapeClosesTheCurrentLayerAndRecomputesTheWorldPrompt () = + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + for panel in [ DialoguePanel; NeedsPanel; ObservationPanel; ChroniclePanel; TaskPanel ] do + let view = + { M5Interaction.initial with + Panel = panel + Menu = (if panel = DialoguePanel then Some { Target = NpcId 0; Options = Sim.dialogueOptions } else None) } + let _, closed = M5Interaction.apply ClosePanel world view + Assert.IsTrue((closed.Panel = WorldPanel), sprintf "Esc 应关闭 %A" panel) + Assert.IsTrue(closed.Menu.IsNone, sprintf "关闭 %A 后应清空菜单" panel) + Assert.AreEqual(Some "E 交谈", closed.Prompt) + + [] + member _.PromptTextAlwaysMatchesTheActedTarget () = + // 文案映射唯一来源。 + Assert.AreEqual("E 交谈", InteractionResolver.actionText (TalkTo(NpcId 0))) + Assert.AreEqual("E 进屋", InteractionResolver.actionText (EnterHome(HomeId 1))) + Assert.AreEqual("E 出屋", InteractionResolver.actionText (ExitHome(HomeId 1))) + + // 多候选中提示 1 号村民 → 按 E 必须作用于同一 1 号村民。 + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt + [ 0, { X = avatar.X + 72.0f; Y = avatar.Y } + 1, { X = avatar.X + 40.0f; Y = avatar.Y } ] + let target = InteractionResolver.resolve world Outside + Assert.AreEqual(Some(TalkTo(NpcId 1)), target |> Option.map (fun t -> t.Action)) + Assert.AreEqual(InteractionResolver.actionText target.Value.Action, target.Value.Text) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual(Some target.Value.Text, view.Prompt) + Assert.AreEqual(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + let _, interacted = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual(DialoguePanel, interacted.Panel) + Assert.AreEqual(NpcId 1, interacted.Menu.Value.Target) + + [] + member _.PromptLayerIsTransientNotAPersistentPanel () = + // 初始世界层无提示、无面板;靠近只补一条瞬时提示,不新开面板。 + Assert.AreEqual(WorldPanel, M5Interaction.initial.Panel) + Assert.AreEqual(None, M5Interaction.initial.Prompt) + let world = + Sim.initialWorldN 42UL 4 + |> atPosition avatar.X avatar.Y + |> withNpcsAt [ 0, { X = avatar.X + 32.0f; Y = avatar.Y } ] + let shown = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual(WorldPanel, shown.Panel) + Assert.IsTrue(shown.Menu.IsNone) + // 面板一旦开启,提示立即消失,且刷新多帧都保持无提示(非常驻)。 + let opened = { shown with Panel = TaskPanel } + for _ in 1 .. 5 do + let refreshed = M5Interaction.refreshPrompt world opened + Assert.AreEqual(None, refreshed.Prompt) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 0dce3a9..0cf996c 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -350,6 +350,13 @@ type LivingVillageGame() as this = let mutable p52Step = 0 let mutable p52PendingName = "" let mutable p52Pending = false + // P53 证据钩子:LV_P53_REC=1 时在真实世界里录制「远处 → 靠近提示出现 → 互动开对话 → + // 关闭+离开 → 提示消失」帧序列,逐帧打印面板/提示/目标/提示矩形,供 analyze 脚本 verdict。 + // 提示层为瞬时叠加,不新开面板。 + let p53RecordMode = Environment.GetEnvironmentVariable("LV_P53_REC") = "1" + let mutable p53Step = 0 + let mutable p53PendingName = "" + let mutable p53Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -1191,6 +1198,80 @@ type LivingVillageGame() as this = bag p52Step <- p52Step + 1 + /// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。 + member private this.PrepareP53Recording () = + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + let placeAwayFromNpc () = + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = { X = npc.Pos.X + 240.0f; Y = npc.Pos.Y } } } + if not p53Pending then + match p53Step with + | 0 -> + setHour 12.0 + // 只保留 0 号村民作候选,其余移远,隔离出唯一的提示对象(真实 resolver 不改)。 + let npcs = Array.copy world.Npcs + for index in 1 .. npcs.Length - 1 do + npcs.[index] <- { npcs.[index] with Pos = { X = 4000.0f; Y = 4000.0f } } + world <- { world with Npcs = npcs } + placeAwayFromNpc () + m5View <- M5Interaction.refreshPrompt world m5View + this.CenterCamera() + p53PendingName <- "p53-far" + p53Pending <- true + | 1 -> + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + m5View <- M5Interaction.refreshPrompt world m5View + this.CenterCamera() + p53PendingName <- "p53-prompt" + p53Pending <- true + | 2 -> + let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View + world <- nextWorld + m5View <- nextView + p53PendingName <- "p53-dialog" + p53Pending <- true + | 3 -> + let closedWorld, closedView = M5Interaction.apply M5Command.ClosePanel world m5View + world <- closedWorld + m5View <- closedView + placeAwayFromNpc () + m5View <- M5Interaction.refreshPrompt world m5View + this.CenterCamera() + p53PendingName <- "p53-gone" + p53Pending <- true + | _ -> + printfn "p53-rec=done" + this.Exit() + + /// P53 证据:在 `Draw` 末尾落盘每帧,并打印面板/提示/目标/提示矩形。 + member private this.CaptureP53Recording () = + if p53Pending then + p53Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p53PendingName) + let targetText = + match InteractionResolver.resolve world m5View.HomeMode with + | Some target -> sprintf "%A" target.Action + | None -> "none" + let rectText = + match m5View.Prompt, m5View.PromptTargetPos with + | Some text, Some position -> + let viewport = this.GraphicsDevice.Viewport + let rect = + FloatingPrompt.layout viewport.Width viewport.Height position.X position.Y camera.X camera.Y 2 text + sprintf "%d,%d,%d,%d" rect.X rect.Y rect.Width rect.Height + | _ -> "none" + printfn "p53-rec=%s panel=%s prompt=%s target=%s rect=%s" + p53PendingName + (M5Interaction.panelName m5View) + (m5View.Prompt |> Option.defaultValue "none") + targetText + rectText + p53Step <- p53Step + 1 + /// P46 证据:菜单阶段按帧表把 menuEntranceFrame 钉到灯笼呼吸的波峰/波谷,拍两相位。 member private this.PrepareP46Shot () = if p46MenuADone && p46MenuBDone then @@ -1612,6 +1693,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP52Shot() + elif p53RecordMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP53Recording() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -2027,6 +2116,8 @@ type LivingVillageGame() as this = this.CaptureP47Shot() if p52ShotMode && menu.Page = Playing then this.CaptureP52Shot() + if p53RecordMode && menu.Page = Playing then + this.CaptureP53Recording() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index 6a34b40..48bc3e7 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -111,6 +111,14 @@ module InteractionResolver = distanceSquared avatarPosition position <= Sim.chatRangeSq && hasClearPath avatarPosition position + /// P53:交互动作 → 情境提示文案的唯一映射。提示层与按 E 的实际作用对象共用同一 + /// 选择规则,文案只由动作决定,保证「提示写 E 交谈 → 按 E 作用于同一目标」。 + let actionText (action: InteractionAction) : string = + match action with + | TalkTo _ -> "E 交谈" + | EnterHome _ -> "E 进屋" + | ExitHome _ -> "E 出屋" + let resolve (world: World) (homeMode: HomeMode) : InteractionTarget option = let avatarPosition = world.Avatar.Pos let candidates = @@ -123,7 +131,7 @@ module InteractionResolver = let avatarTile = sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then - Some(target (ExitHome home.Id) position "E 出屋" avatarPosition) + Some(target (ExitHome home.Id) position (actionText (ExitHome home.Id)) avatarPosition) else None else None) | Outside -> @@ -134,13 +142,13 @@ module InteractionResolver = let avatarTile = sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then - Some(target (EnterHome home.Id) position "E 进屋" avatarPosition) + Some(target (EnterHome home.Id) position (actionText (EnterHome home.Id)) avatarPosition) else None) let npcs = world.Npcs |> Array.choose (fun npc -> if dialogueAvailable npc && targetIsReachable avatarPosition npc.Pos then - Some(target (TalkTo npc.Id) npc.Pos "E 交谈" avatarPosition) + Some(target (TalkTo npc.Id) npc.Pos (actionText (TalkTo npc.Id)) avatarPosition) else None) |> Array.toList homes @ npcs -- cgit v1.2.3