summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 23:24:24 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 23:24:24 +0800
commit7673d4af229735c9bdec5b167f7b29985b5bc858 (patch)
tree6942a14c5c3d404dccc97be25a7c3e4ce7c0ea41
parent5f477f21507cb2c381e9a024f1146faf9472ff3d (diff)
downloadliving-village-7673d4af229735c9bdec5b167f7b29985b5bc858.tar.gz
p53: 情境提示与输入隔离回归——提示文案=实际作用对象(唯一映射)+隔墙/边界/切换/离开/隔离断言 + LV_P53_REC 录制证据
-rw-r--r--docs/evidence/p53-dialog.pngbin0 -> 109538 bytes
-rw-r--r--docs/evidence/p53-gone.pngbin0 -> 93815 bytes
-rw-r--r--docs/evidence/p53-prompt.pngbin0 -> 92198 bytes
-rw-r--r--docs/evidence/p53-record-log.txt14
-rw-r--r--docs/evidence/p53-recording.txt17
-rw-r--r--scripts/analyze-p53.py142
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P53InteractionPromptTests.fs203
-rw-r--r--src/LivingVillage.Desktop/Game.fs91
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs14
10 files changed, 479 insertions, 3 deletions
diff --git a/docs/evidence/p53-dialog.png b/docs/evidence/p53-dialog.png
new file mode 100644
index 0000000..1d0b95c
--- /dev/null
+++ b/docs/evidence/p53-dialog.png
Binary files differ
diff --git a/docs/evidence/p53-gone.png b/docs/evidence/p53-gone.png
new file mode 100644
index 0000000..ccf26e6
--- /dev/null
+++ b/docs/evidence/p53-gone.png
Binary files differ
diff --git a/docs/evidence/p53-prompt.png b/docs/evidence/p53-prompt.png
new file mode 100644
index 0000000..b7290e7
--- /dev/null
+++ b/docs/evidence/p53-prompt.png
Binary files differ
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