diff options
| -rw-r--r-- | docs/evidence/p53-dialog.png | bin | 0 -> 109538 bytes | |||
| -rw-r--r-- | docs/evidence/p53-gone.png | bin | 0 -> 93815 bytes | |||
| -rw-r--r-- | docs/evidence/p53-prompt.png | bin | 0 -> 92198 bytes | |||
| -rw-r--r-- | docs/evidence/p53-record-log.txt | 14 | ||||
| -rw-r--r-- | docs/evidence/p53-recording.txt | 17 | ||||
| -rw-r--r-- | scripts/analyze-p53.py | 142 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs | 203 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 91 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 14 |
10 files changed, 479 insertions, 3 deletions
diff --git a/docs/evidence/p53-dialog.png b/docs/evidence/p53-dialog.png Binary files differnew file mode 100644 index 0000000..1d0b95c --- /dev/null +++ b/docs/evidence/p53-dialog.png diff --git a/docs/evidence/p53-gone.png b/docs/evidence/p53-gone.png Binary files differnew file mode 100644 index 0000000..ccf26e6 --- /dev/null +++ b/docs/evidence/p53-gone.png diff --git a/docs/evidence/p53-prompt.png b/docs/evidence/p53-prompt.png Binary files differnew file mode 100644 index 0000000..b7290e7 --- /dev/null +++ b/docs/evidence/p53-prompt.png diff --git a/docs/evidence/p53-record-log.txt b/docs/evidence/p53-record-log.txt new file mode 100644 index 0000000..e05e989 --- /dev/null +++ b/docs/evidence/p53-record-log.txt @@ -0,0 +1,14 @@ +world-mode=riverscape bounds=256x192 +mode=keyboard seed=42 +controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit +fps=61.0 tick=0 pos=(4112,3088) +fps=60.0 tick=0 pos=(4112,3088) +splash=done frame=150 +menu=occupation-select +new-game=ok seed=42 npcs=30 occupation=农夫 tick=0 +p53-rec=p53-far panel=world prompt=none target=none rect=none +p53-rec=p53-prompt panel=world prompt=E 交谈 target=TalkTo (NpcId 0) rect=598,288,52,26 +p53-rec=p53-dialog panel=dialogue prompt=none target=TalkTo (NpcId 0) rect=none +fps=60.0 tick=2592000 pos=(4160,2896) +p53-rec=p53-gone panel=world prompt=none target=none rect=none +p53-rec=done diff --git a/docs/evidence/p53-recording.txt b/docs/evidence/p53-recording.txt new file mode 100644 index 0000000..7b3b458 --- /dev/null +++ b/docs/evidence/p53-recording.txt @@ -0,0 +1,17 @@ +P53 proximity-prompt / input-isolation analysis (LV_P53_REC=1) + +p53-far: panel=world prompt=none target=none rect=none +p53-prompt: panel=world prompt=E 交谈 target=TalkTo (NpcId 0) rect=598,288,52,26 +p53-dialog: panel=dialogue prompt=none target=TalkTo (NpcId 0) rect=none +p53-gone: panel=world prompt=none target=none rect=none + +frame p53-far: 1280x720 std=31.4 +frame p53-prompt: 1280x720 std=31.5 +frame p53-dialog: 1280x720 std=34.0 +frame p53-gone: 1280x720 std=31.6 + +prompt rect=(598, 288, 52, 26) paint delta (prompt vs far) = 44.55 (>= 12.0) +prompt rect restore delta (gone vs far) = 0.00 (<= 6.0) +dialogue overlay delta (dialog vs prompt, full frame) = 5.54 (>= 4.0) + +verdict: PASS diff --git a/scripts/analyze-p53.py b/scripts/analyze-p53.py new file mode 100644 index 0000000..69a9a7b --- /dev/null +++ b/scripts/analyze-p53.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""P53 proximity-prompt / input-isolation analysis. + +Reads the `LV_P53_REC=1` frame sequence + <record-dir>/log.txt and writes +docs/evidence/p53-recording.txt with per-frame measured behaviour: + + 1. sequence state from the real game log: far -> prompt -> dialog -> gone, + with the explicit resolved target (prompt "E 交谈" => TalkTo (NpcId 0)); + 2. pixel proof inside the logged prompt rect: the prompt frame paints there + (vs the far frame) and the gone frame restores it; + 3. the dialogue frame is a full overlay (differs from the world frames), and + the prompt is gone while the layer is open (transient, not a panel). + +Usage: + python3 scripts/analyze-p53.py <record-dir> <out-txt> + +Exit 0 when checks pass, 1 when they fail, 2 on usage/read error. +""" + +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +REC_RE = re.compile(r"p53-rec=(\S+) panel=(\S+) prompt=(.*?) target=(.*?) rect=(\S+)") +FRAMES = ["p53-far", "p53-prompt", "p53-dialog", "p53-gone"] +PROMPT_PAINT_MIN = 12.0 +GONE_RESTORE_MAX = 6.0 +DIALOG_DIFF_MIN = 4.0 + + +def fail(lines, out_txt, message): + lines.append(f"FAIL: {message}") + lines.append("") + lines.append("verdict: FAIL") + text = "\n".join(lines) + "\n" + if out_txt is not None: + out_txt.parent.mkdir(parents=True, exist_ok=True) + out_txt.write_text(text, encoding="utf-8") + print(text) + return 1 + + +def load(path): + return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) + + +def region(img, rect, margin=10): + x, y, w, h = rect + x0 = max(0, x - margin) + y0 = max(0, y - margin) + x1 = min(img.shape[1], x + w + margin) + y1 = min(img.shape[0], y + h + margin) + return img[y0:y1, x0:x1] + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_txt = Path(argv[2]) + log_path = record_dir / "log.txt" + if not log_path.exists(): + sys.stderr.write(f"missing {log_path}\n") + return 2 + + lines = ["P53 proximity-prompt / input-isolation analysis (LV_P53_REC=1)", ""] + frames = {} + for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines(): + match = REC_RE.search(line) + if match: + frames[match.group(1)] = { + "panel": match.group(2), + "prompt": match.group(3), + "target": match.group(4), + "rect": match.group(5), + } + + for name in FRAMES: + if name not in frames: + return fail(lines, out_txt, f"missing log frame {name}") + record = frames[name] + lines.append(f"{name}: panel={record['panel']} prompt={record['prompt']} target={record['target']} rect={record['rect']}") + + lines.append("") + if frames["p53-far"]["prompt"] != "none": + return fail(lines, out_txt, "far frame must not show a prompt") + if frames["p53-prompt"]["prompt"] != "E 交谈": + return fail(lines, out_txt, "approach frame must show the E 交谈 prompt") + if "TalkTo (NpcId 0)" not in frames["p53-prompt"]["target"]: + return fail(lines, out_txt, "approach frame must target the explicit village NpcId 0") + if frames["p53-dialog"]["panel"] != "dialogue": + return fail(lines, out_txt, "interact frame must open the dialogue layer") + if frames["p53-dialog"]["prompt"] != "none": + return fail(lines, out_txt, "prompt must be gone while the dialogue layer is open") + if frames["p53-gone"]["prompt"] != "none": + return fail(lines, out_txt, "after leaving, the prompt must be gone") + if frames["p53-prompt"]["rect"] == "none": + return fail(lines, out_txt, "approach frame must report the prompt rect") + + images = {} + for name in FRAMES: + path = record_dir / f"{name}.png" + if not path.exists(): + return fail(lines, out_txt, f"missing frame {path}") + images[name] = load(path) + std = float(images[name].std()) + lines.append(f"frame {name}: {images[name].shape[1]}x{images[name].shape[0]} std={std:.1f}") + if std < 5.0: + return fail(lines, out_txt, f"frame {name} looks blank (std {std:.1f})") + + rect = tuple(int(v) for v in frames["p53-prompt"]["rect"].split(",")) + paint_delta = float(np.abs(region(images["p53-prompt"], rect) - region(images["p53-far"], rect)).mean()) + restore_delta = float(np.abs(region(images["p53-gone"], rect) - region(images["p53-far"], rect)).mean()) + dialog_delta = float(np.abs(images["p53-dialog"] - images["p53-prompt"]).mean()) + lines += [ + "", + f"prompt rect={rect} paint delta (prompt vs far) = {paint_delta:.2f} (>= {PROMPT_PAINT_MIN})", + f"prompt rect restore delta (gone vs far) = {restore_delta:.2f} (<= {GONE_RESTORE_MAX})", + f"dialogue overlay delta (dialog vs prompt, full frame) = {dialog_delta:.2f} (>= {DIALOG_DIFF_MIN})", + "", + ] + if paint_delta < PROMPT_PAINT_MIN: + return fail(lines, out_txt, "prompt rect did not change when the prompt appeared") + if restore_delta > GONE_RESTORE_MAX: + return fail(lines, out_txt, "prompt rect did not restore after leaving") + if dialog_delta < DIALOG_DIFF_MIN: + return fail(lines, out_txt, "dialogue overlay frame does not differ from the world frame") + + lines.append("verdict: PASS") + text = "\n".join(lines) + "\n" + out_txt.parent.mkdir(parents=True, exist_ok=True) + out_txt.write_text(text, encoding="utf-8") + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) 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 @@ <Compile Include="P48MapDigestTests.fs" /> <Compile Include="P51ProfessionSliceTests.fs" /> <Compile Include="P52ProfessionTradeTests.fs" /> + <Compile Include="P53InteractionPromptTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> 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。 +/// 提示层是瞬时叠加层,绝非常驻面板。 +[<TestClass>] +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 + + [<TestMethod>] + 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<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget atBoundary) + Assert.AreEqual<string>("E 交谈", (InteractionResolver.resolve atBoundary Outside).Value.Text) + Assert.AreEqual<string option>(Some "E 交谈", promptText atBoundary) + Assert.AreEqual<InteractionAction option>(None, promptTarget beyond) + Assert.AreEqual<string option>(None, promptText beyond) + + [<TestMethod>] + 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<InteractionAction option>(Some(TalkTo(NpcId 1)), promptTarget world) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<string option>(Some "E 交谈", view.Prompt) + // 仅一个高亮:提示目标位置就是被选中的 1 号村民,别处不出现第二个提示。 + Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + // 走近远端村民:最近目标切换为 0 号,仍然只选一个。 + let switched = world |> atPosition (avatar.X + 84.0f) avatar.Y + Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget switched) + + [<TestMethod>] + 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<InteractionAction option>(Some(TalkTo(NpcId 0)), promptTarget world) + + [<TestMethod>] + 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<string option>(Some "E 交谈", shown.Prompt) + Assert.IsTrue(shown.PromptTargetPos.IsSome) + + let left = world |> atPosition 3200.0f 2400.0f + Assert.AreEqual<InteractionAction option>(None, promptTarget left) + let cleared = M5Interaction.refreshPrompt left shown + Assert.AreEqual<string option>(None, cleared.Prompt) + Assert.AreEqual<Vec2 option>(None, cleared.PromptTargetPos) + + [<TestMethod>] + 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<InteractionAction option>(None, promptTarget world) + Assert.AreEqual<string option>(None, promptText world) + + // 隔墙时按 E 不产生世界副作用,面板停在世界层。 + let nextWorld, nextView = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual<World>(world, nextWorld) + Assert.AreEqual<M5Panel>(WorldPanel, nextView.Panel) + + [<TestMethod>] + 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>(world, nextWorld) + Assert.IsTrue((panel = nextView.Panel), sprintf "%A 开启时 Interact 不得改选目标" panel) + Assert.AreEqual<string option>(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<string option>(None, bought.Menu |> Option.map (fun m -> m.Target.ToString())) + + [<TestMethod>] + 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<string option>(Some "E 交谈", closed.Prompt) + + [<TestMethod>] + member _.PromptTextAlwaysMatchesTheActedTarget () = + // 文案映射唯一来源。 + Assert.AreEqual<string>("E 交谈", InteractionResolver.actionText (TalkTo(NpcId 0))) + Assert.AreEqual<string>("E 进屋", InteractionResolver.actionText (EnterHome(HomeId 1))) + Assert.AreEqual<string>("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<InteractionAction option>(Some(TalkTo(NpcId 1)), target |> Option.map (fun t -> t.Action)) + Assert.AreEqual<string>(InteractionResolver.actionText target.Value.Action, target.Value.Text) + let view = M5Interaction.refreshPrompt world M5Interaction.initial + Assert.AreEqual<string option>(Some target.Value.Text, view.Prompt) + Assert.AreEqual<Vec2 option>(Some world.Npcs.[1].Pos, view.PromptTargetPos) + + let _, interacted = M5Interaction.apply Interact world M5Interaction.initial + Assert.AreEqual<M5Panel>(DialoguePanel, interacted.Panel) + Assert.AreEqual<NpcId>(NpcId 1, interacted.Menu.Value.Target) + + [<TestMethod>] + member _.PromptLayerIsTransientNotAPersistentPanel () = + // 初始世界层无提示、无面板;靠近只补一条瞬时提示,不新开面板。 + Assert.AreEqual<M5Panel>(WorldPanel, M5Interaction.initial.Panel) + Assert.AreEqual<string option>(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<M5Panel>(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<string option>(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 |
