summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:26:05 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:26:05 +0800
commit81a84f9f86d59387a349caa339148c7a7a6d1e72 (patch)
treecaa7f9209f5337f23e5961920e3b06a69368930a
parentf9e77256f9b8d3bd35a0cbe732af49d283930a5d (diff)
downloadliving-village-81a84f9f86d59387a349caa339148c7a7a6d1e72.tar.gz
feat(desktop): P16-FIX 开始界面截图模式(LV_AUTOPLAY_MENU_SHOT)+ 真实菜单证据补拍
-rw-r--r--docs/维护说明.md16
-rw-r--r--src/LivingVillage.Desktop.Tests/P16UiTests.fs24
-rw-r--r--src/LivingVillage.Desktop/Game.fs53
-rw-r--r--src/LivingVillage.Desktop/LaunchScreen.fs15
4 files changed, 94 insertions, 14 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 614de78..ad68e5b 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -161,8 +161,15 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- ⑤回归:新增 `P16UiTests.fs`(启动跳过/菜单导航/HUD 布局确定性);Desktop 94→104,Kernel 89 不变
(未触碰 Kernel/Sim.fs 数值路径)。
- ⑥证据:`/tmp/opencode/lv-p16-art/`(`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、
- `frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、`sample.mp4`
- 13.1 s、`final_verification.txt`);`check-no-white-blocks.py` 对 92 帧 0 命中。
+ `frame-splash-01.png`、`hud-bar-day.png`、`hud-bar-night.png`、`sample.mp4` 13.1 s、
+ `final_verification.txt`);`check-no-white-blocks.py` 对 92 帧 0 命中。
+- ⑦(P16-FIX 证据补拍)`frame-menu-0{1,2}.png` 实为游戏内帧,不是开始界面;新增确定性截图模式
+ `LV_AUTOPLAY_MENU_SHOT=1`:跳过 hero 自动开始,停在开始界面按固定帧表取样。帧表抽为纯数据
+ `MenuShotScript`(splash 淡入中 30 / 初始高亮 130 / 向下 140 / 高亮读档 150 / 退出 170),附 1 例
+ 回归;`Game.CaptureMenuShot` 复用 `SaveBackBuffer` 落图。产物:`menu-real-initial.png`(初始高亮
+ 「开始」)、`menu-real-load.png`(按向下后高亮「读档」)、`frame-splash-mid-01.png`(淡入中帧)。
+ 像素复验:初始帧仅第 1 行含高亮色 `(245,222,120)`(115 px),load 帧仅第 2 行含(111 px),
+ 两帧差异 575 px,确证选择项迁移。
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
@@ -193,6 +200,11 @@ ffmpeg -y -f x11grab -video_size 1280x720 -framerate 10 -i :99 -pix_fmt yuv420p
LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+# P16-FIX 开始界面截图取证(停在主菜单,拍初始/读档高亮与 splash 淡入中帧)
+timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \
+ LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p16-art \
+ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
+
# P13 动画证据(确定性接触表 + 真实运行帧)
python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等
# 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖)
diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
index 0db4506..b071dfa 100644
--- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs
+++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs
@@ -123,3 +123,27 @@ type P16HudTests () =
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "时段")
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "体力")
Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "钱币")
+
+// ---- P16-FIX 菜单截图帧表 ----
+
+[<TestClass>]
+type P16MenuShotTests () =
+
+ [<TestMethod>]
+ member _.MenuShotFrameTableIsOrderedAndNamesAreDistinct () =
+ Assert.IsTrue(MenuShotScript.splashMidFrame > 0L)
+ Assert.IsTrue(LaunchScreen.revealProgress MenuShotScript.splashMidFrame > 0.0f)
+ Assert.IsTrue(LaunchScreen.revealProgress MenuShotScript.splashMidFrame < 1.0f)
+ Assert.IsTrue(MenuShotScript.splashMidFrame < MenuShotScript.initialFrame)
+ Assert.IsTrue(MenuShotScript.initialFrame < MenuShotScript.downFrame)
+ Assert.IsTrue(MenuShotScript.downFrame < MenuShotScript.loadFrame)
+ Assert.IsTrue(MenuShotScript.loadFrame < MenuShotScript.exitFrame)
+ Assert.IsTrue(MenuShotScript.initialFrame >= 120L, "initial shot must wait for the 2s entrance to settle")
+ let names = [ MenuShotScript.splashMidName; MenuShotScript.initialName; MenuShotScript.loadName ]
+ Assert.AreEqual<int>(names.Length, names |> List.distinct |> List.length)
+
+ [<TestMethod>]
+ member _.MenuShotSelectsStartThenLoadOnFreshState () =
+ let state = MenuState.create SimulationControl.initial false
+ Assert.AreEqual<MenuItem>(NewGameItem, MenuState.selectedItem state)
+ Assert.AreEqual<MenuItem>(LoadItem, MenuState.selectedItem (MenuState.move Down state))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 3bb43f8..3f3a1d8 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -97,6 +97,7 @@ type LivingVillageGame() as this =
let autoplay = (Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1") || (Environment.GetEnvironmentVariable("LV_MAP_TOUR") = "1")
let autoplayFlow = Environment.GetEnvironmentVariable("LV_AUTOPLAY_FLOW") = "1"
let sampleMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_SAMPLE") = "1"
+ let menuShotMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_MENU_SHOT") = "1"
let daylightAutoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY_DAYLIGHT") = "1"
let automated = autoplay || autoplayFlow
let modeName =
@@ -143,6 +144,7 @@ type LivingVillageGame() as this =
let mutable splashActive = true
let mutable splashFrame = 0L
let mutable menuEntranceFrame = 0L
+ let mutable menuShotFrame = 0L
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -316,6 +318,12 @@ type LivingVillageGame() as this =
printfn (if nextState.TimedOut then "sample result=timeout" else "sample result=ok")
this.Exit()
+ /// P16-FIX:菜单截图模式。停在开始界面,按确定性帧表向下切到「读档」,帧表见 MenuShotScript。
+ member private this.RunMenuShot () =
+ menuShotFrame <- menuShotFrame + 1L
+ if menuShotFrame = MenuShotScript.downFrame then
+ this.DispatchMenuInput Down
+
member private this.RunMapTour () =
let (tileX, tileY) = tourWaypoints.[tourIndex % tourWaypoints.Length]
let position : Vec2 =
@@ -542,6 +550,8 @@ type LivingVillageGame() as this =
this.RunAutoplayFlow()
elif menu.Page = Playing then
this.UpdatePlaying gameTime kb pressed pressedAny
+ elif menuShotMode then
+ this.RunMenuShot()
else
this.UpdateMenu pressed
if menu.Page <> Playing then
@@ -605,6 +615,33 @@ type LivingVillageGame() as this =
| None -> ()
spriteBatch.End()
+ /// 将当前后台缓冲存为 PNG(取证用;record 与菜单截图共用)。
+ member private this.SaveBackBuffer (path: string) =
+ let width = this.GraphicsDevice.PresentationParameters.BackBufferWidth
+ let height = this.GraphicsDevice.PresentationParameters.BackBufferHeight
+ let buffer = Array.zeroCreate<Color> (width * height)
+ this.GraphicsDevice.GetBackBufferData<Color>(buffer)
+ use texture = new Texture2D(this.GraphicsDevice, width, height)
+ texture.SetData(buffer)
+ use stream = System.IO.File.Create(path)
+ texture.SaveAsPng(stream, width, height)
+
+ /// P16-FIX:按 MenuShotScript 帧表落菜单/启动画面截图,拍完即退出。
+ member private this.CaptureMenuShot () =
+ let save name =
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name)
+ if splashActive then
+ if splashFrame = MenuShotScript.splashMidFrame then
+ save MenuShotScript.splashMidName
+ elif menuShotFrame = MenuShotScript.initialFrame then
+ save MenuShotScript.initialName
+ elif menuShotFrame = MenuShotScript.loadFrame then
+ save MenuShotScript.loadName
+ elif menuShotFrame = MenuShotScript.exitFrame then
+ printfn "menu-shot=done frames=%d" menuShotFrame
+ this.Exit()
+
override this.Draw(gameTime: GameTime) =
if splashActive then
this.DrawSplash()
@@ -618,18 +655,10 @@ type LivingVillageGame() as this =
if recordMode && sampleMode then
recordIndex <- recordIndex + 1
if recordIndex % recordEvery = 0 then
- let directory = recordDirectory
- System.IO.Directory.CreateDirectory directory |> ignore
- let width, height =
- this.GraphicsDevice.PresentationParameters.BackBufferWidth,
- this.GraphicsDevice.PresentationParameters.BackBufferHeight
- let buffer = Array.zeroCreate<Color> (width * height)
- this.GraphicsDevice.GetBackBufferData<Color>(buffer)
- use texture = new Texture2D(this.GraphicsDevice, width, height)
- texture.SetData(buffer)
- let path = sprintf "%s/rec-frame-%04d.png" directory recordIndex
- use stream = System.IO.File.Create(path)
- texture.SaveAsPng(stream, width, height)
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/rec-frame-%04d.png" recordDirectory recordIndex)
+ if menuShotMode then
+ this.CaptureMenuShot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
diff --git a/src/LivingVillage.Desktop/LaunchScreen.fs b/src/LivingVillage.Desktop/LaunchScreen.fs
index 0977777..0fecc6d 100644
--- a/src/LivingVillage.Desktop/LaunchScreen.fs
+++ b/src/LivingVillage.Desktop/LaunchScreen.fs
@@ -30,3 +30,18 @@ module LaunchScreen =
/// Esc 或 空格 触发跳过(调用方用“按下沿”结果,不做按键状态判定)。
let skipRequested (escapePressed: bool) (spacePressed: bool) : bool = escapePressed || spacePressed
+
+/// P16-FIX 证据补拍:开始界面菜单截图的确定性帧表(纯数据,Game 与回归测试共用)。
+/// 帧号均为 60Hz Update 计数:splash 淡入中 -> 菜单入场完成(初始高亮「开始」)-> 向下
+/// -> 高亮「读档」-> 退出。输出文件名不含目录与扩展名。
+module MenuShotScript =
+
+ let splashMidName = "frame-splash-mid-01"
+ let initialName = "menu-real-initial"
+ let loadName = "menu-real-load"
+
+ let splashMidFrame = 30L
+ let initialFrame = 130L
+ let downFrame = 140L
+ let loadFrame = 150L
+ let exitFrame = 170L