From d85cce460fd7fbb8854fb11ba547be1baf576de7 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:08:15 +0800 Subject: feat(desktop): P16 启动画面淡入(Esc/空格跳过)+ 开始界面收敛三项(开始/读档/退出)+ CJK 补字 238 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LivingVillage.Desktop.Tests.fsproj | 1 + src/LivingVillage.Desktop.Tests/M6bTests.fs | 5 +- src/LivingVillage.Desktop.Tests/P16UiTests.fs | 66 +++++++++++++ src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 9 +- .../Assets/cjk-glyph-atlas.png | Bin 60594 -> 63292 bytes src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 10 ++ src/LivingVillage.Desktop/ChineseText.fs | 4 + src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 +- src/LivingVillage.Desktop/Game.fs | 110 +++++++++++++++------ src/LivingVillage.Desktop/LaunchScreen.fs | 32 ++++++ .../LivingVillage.Desktop.fsproj | 1 + src/LivingVillage.Desktop/MenuState.fs | 12 ++- 12 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P16UiTests.fs create mode 100644 src/LivingVillage.Desktop/LaunchScreen.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 a8aac24..60d9995 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -9,6 +9,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/M6bTests.fs b/src/LivingVillage.Desktop.Tests/M6bTests.fs index 5aea9bd..1b2ef70 100644 --- a/src/LivingVillage.Desktop.Tests/M6bTests.fs +++ b/src/LivingVillage.Desktop.Tests/M6bTests.fs @@ -13,8 +13,9 @@ type M6bMenuTests () = Assert.AreEqual(MainMenu, state.Page) Assert.AreEqual( - [ NewGameItem; LoadItem; SettingsItem; ControlsItem; ExitItem ], + [ NewGameItem; LoadItem; ExitItem ], MenuState.items state) + Assert.AreEqual([ "开始"; "读档"; "退出" ], MenuState.items state |> List.map MenuState.itemLabel) Assert.AreEqual(0, state.Selected) [] @@ -32,7 +33,7 @@ type M6bMenuTests () = [] member _.SettingsCanSelectEveryExistingSimulationMode () = let initial = MenuState.create SimulationControl.initial false - let settings, _ = MenuState.update Confirm (MenuState.move Down (MenuState.move Down initial)) + let settings = MenuState.openSettings initial let oneX, oneXCommand = MenuState.update Confirm settings let twoX, twoXCommand = MenuState.update Confirm (MenuState.move Down settings) let fiveX, fiveXCommand = MenuState.update Confirm (MenuState.move Down (MenuState.move Down settings)) diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs new file mode 100644 index 0000000..0bfdb62 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs @@ -0,0 +1,66 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Desktop + +// ---- P16 启动画面 / 开始界面 ---- + +[] +type P16LaunchTests () = + + [] + member _.LaunchRevealAndVeilAreDeterministicAndComplete () = + Assert.AreEqual(0.0f, LaunchScreen.revealProgress 0L) + Assert.AreEqual(1.0f, LaunchScreen.revealProgress LaunchScreen.fadeInFrames) + Assert.AreEqual(1.0f, LaunchScreen.revealProgress 1000L) + Assert.AreEqual(1.0f, LaunchScreen.veilAlpha 0L) + Assert.AreEqual(0.0f, LaunchScreen.veilAlpha LaunchScreen.durationFrames) + Assert.IsTrue(LaunchScreen.veilAlpha 30L > LaunchScreen.veilAlpha 90L, "veil must decrease monotonically") + Assert.IsFalse(LaunchScreen.isComplete (LaunchScreen.durationFrames - 1L)) + Assert.IsTrue(LaunchScreen.isComplete LaunchScreen.durationFrames) + + [] + member _.LaunchSkipIsEscOrSpaceAndTextUsesTheAtlasPath () = + Assert.IsTrue(LaunchScreen.skipRequested true false) + Assert.IsTrue(LaunchScreen.skipRequested false true) + Assert.IsFalse(LaunchScreen.skipRequested false false) + Assert.IsTrue(PixelText.isRenderable LaunchScreen.title) + Assert.IsTrue(PixelText.isRenderable LaunchScreen.skipHint) + Assert.IsTrue(PixelText.isRenderable LaunchScreen.subtitle) + Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains LaunchScreen.skipHint) + +[] +type P16StartMenuTests () = + + [] + member _.StartMenuIsExactlyThreeItemsWithChineseLabels () = + let state = MenuState.create SimulationControl.initial false + + Assert.AreEqual(3, MenuState.items state |> List.length) + Assert.AreEqual([ "开始"; "读档"; "退出" ], MenuState.items state |> List.map MenuState.itemLabel) + + [] + member _.StartMenuNavigationSelectsEachItemAndWraps () = + let state = MenuState.create SimulationControl.initial false + let down1 = MenuState.move Down state + let down2 = MenuState.move Down down1 + let wrap = MenuState.move Down down2 + + Assert.AreEqual(NewGameItem, MenuState.selectedItem state) + Assert.AreEqual(LoadItem, MenuState.selectedItem down1) + Assert.AreEqual(ExitItem, MenuState.selectedItem down2) + Assert.AreEqual(NewGameItem, MenuState.selectedItem wrap) + + Assert.AreEqual(StartNewGame, MenuState.update Confirm state |> snd) + Assert.AreEqual(LoadGame, MenuState.update Confirm down1 |> snd) + Assert.AreEqual(ExitGame, MenuState.update Confirm down2 |> snd) + + [] + member _.ReturningToMenuOffersContinueAsTheFirstOfThreeItems () = + let playing = MenuState.create SimulationControl.initial true |> MenuState.enterGame + let main = MenuState.openMain playing + + Assert.AreEqual([ ContinueItem; LoadItem; ExitItem ], MenuState.items main) + Assert.AreEqual([ "继续"; "读档"; "退出" ], MenuState.items main |> List.map MenuState.itemLabel) + Assert.AreEqual(ContinueGame, MenuState.update Confirm main |> snd) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 658e2e1..036154e 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -119,10 +119,11 @@ type PrototypeTests () = let controlLabels = MenuState.controlRows |> List.map (fun row -> row.Action) let settingsLabels = MenuState.settings |> List.map MenuState.settingsLabel - Assert.IsTrue(itemLabels |> List.contains "开始新游戏") - Assert.IsTrue(itemLabels |> List.contains "读取存档") - Assert.IsTrue(itemLabels |> List.contains "设置") + Assert.IsTrue(itemLabels |> List.contains "开始") + Assert.IsTrue(itemLabels |> List.contains "读档") Assert.IsTrue(itemLabels |> List.contains "退出") + Assert.AreEqual("设置", MenuState.itemLabel SettingsItem) + Assert.AreEqual("操作说明", MenuState.itemLabel ControlsItem) Assert.AreEqual( [ "移动" "互动" @@ -945,7 +946,7 @@ type PrototypeTests () = [] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = - Assert.AreEqual(228, characters.Length) + Assert.AreEqual(238, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual(16, columns) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png index 598c2bc..af83cd7 100644 Binary files a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png and b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png differ diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt index cdfc550..701200b 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -72,6 +72,7 @@ 屋 工 已 +币 帮 年 庄 @@ -116,9 +117,11 @@ 日 时 明 +昏 易 是 晚 +晨 景 暂 更 @@ -128,15 +131,18 @@ 板 林 标 +格 档 桥 歉 +段 民 水 求 江 没 法 +清 渔 游 物 @@ -153,6 +159,7 @@ 社 离 移 +空 竹 笑 粮 @@ -193,7 +200,9 @@ 货 资 足 +跳 转 +过 近 返 还 @@ -223,6 +232,7 @@ 食 饥 饿 +黄 黛 , : diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs index 82418e6..0d988fe 100644 --- a/src/LivingVillage.Desktop/ChineseText.fs +++ b/src/LivingVillage.Desktop/ChineseText.fs @@ -83,6 +83,10 @@ module ChineseText = "开始新游戏" "读取存档" "退出" + "开始" + "读档" + "继续" + "ESC 或 空格 跳过" "移动" "互动" "对话" diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index 07895c8..b6e6947 100644 --- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs +++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs @@ -11,7 +11,7 @@ module CjkGlyphAtlas = // Keep this table sorted and stable so the generated PNG has a reproducible layout. let private characterTable = - "×一不业中主乐乡书买事于互交人今从价任传体作使保倍停入关内农准出到前力功务动助单卖南原友取受可同和回围圃土在墙备夜大天夫失好始存完家察对就层居屋工已帮年庄库应度开式当录待得心必态怒总恼意戏成或打找报拒择挑捕据接提播操收效敌数新方无日时明易是晚景暂更最有村板林标档桥歉民水求江没法渔游物状理瓦生用留白目石确社离移竹笑粮精系紧给绝继绪续置翻聊职能范获菜衅行观认讨议记讲论设话询说请读谅谈败货资足转近返还进迫退送选速道郎量金鉴钱门闭闲间闻附零需面项须食饥饿黛,:" + "×一不业中主乐乡书买事于互交人今从价任传体作使保倍停入关内农准出到前力功务动助单卖南原友取受可同和回围圃土在墙备夜大天夫失好始存完家察对就层居屋工已币帮年庄库应度开式当录待得心必态怒总恼意戏成或打找报拒择挑捕据接提播操收效敌数新方无日时明昏易是晚晨景暂更最有村板林标格档桥歉段民水求江没法清渔游物状理瓦生用留白目石确社离移空竹笑粮精系紧给绝继绪续置翻聊职能范获菜衅行观认讨议记讲论设话询说请读谅谈败货资足跳转过近返还进迫退送选速道郎量金鉴钱门闭闲间闻附零需面项须食饥饿黄黛,:" let characters = characterTable.ToCharArray() let rowCount = (characters.Length + columns - 1) / columns diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 1b9508b..8402a72 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -140,6 +140,9 @@ type LivingVillageGame() as this = let mutable simulationControl = startSimulationControl let mutable legacyStepsPerFrame = (if sampleMode then None else initialLegacyStepsPerFrame) let mutable menu = MenuState.create startSimulationControl false + let mutable splashActive = true + let mutable splashFrame = 0L + let mutable menuEntranceFrame = 0L let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -481,34 +484,21 @@ type LivingVillageGame() as this = printfn "flow=return-menu" flowStep <- 2 | 2 when menu.Page = MainMenu -> - moveDown 3 + moveDown 1 this.DispatchMenuInput Confirm - printfn "flow=settings=open" - this.DispatchMenuInput Down - this.DispatchMenuInput Confirm - this.DispatchMenuInput Back - printfn "flow=settings=back" + printfn "flow=load" flowStep <- 3 - | 3 when menu.Page = MainMenu -> - moveDown 4 - this.DispatchMenuInput Confirm - printfn "flow=controls=open" - flowStep <- 30 - | 30 -> - flowHold <- flowHold + 1 - if flowHold >= 60 || menu.Page <> Controls then - flowHold <- 0 - flowStep <- 31 - | 31 -> + | 3 -> this.DispatchMenuInput Back - printfn "flow=controls=back" + printfn "flow=back" flowStep <- 4 | 4 when menu.Page = MainMenu -> - this.DispatchMenuInput Confirm this.SaveWorld "flow=save" |> ignore flowStep <- 5 - | 5 when menu.Page = Playing -> - this.LoadWorld "flow=load" |> ignore + | 5 when menu.Page = MainMenu -> + moveDown 1 + this.DispatchMenuInput Confirm + printfn "flow=load-again" flowStep <- 6 | 6 -> this.OpenMainMenu() @@ -540,12 +530,22 @@ type LivingVillageGame() as this = let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) let pressedAny (keys: Keys list) = keys |> List.exists pressed - if autoplayFlow then - this.RunAutoplayFlow() - elif menu.Page = Playing then - this.UpdatePlaying gameTime kb pressed pressedAny + if splashActive then + splashFrame <- splashFrame + 1L + let skip = LaunchScreen.skipRequested (pressed Keys.Escape) (pressed Keys.Space) + if skip || LaunchScreen.isComplete splashFrame then + splashActive <- false + menuEntranceFrame <- 0L + printfn (if skip then "splash=skip frame=%d" else "splash=done frame=%d") splashFrame else - this.UpdateMenu pressed + if autoplayFlow then + this.RunAutoplayFlow() + elif menu.Page = Playing then + this.UpdatePlaying gameTime kb pressed pressedAny + else + this.UpdateMenu pressed + if menu.Page <> Playing then + menuEntranceFrame <- menuEntranceFrame + 1L match pauseHelpControl with | Some original when menu.Page = Playing -> let _, restoredControl = MenuState.exitPauseHelp original menu @@ -606,7 +606,9 @@ type LivingVillageGame() as this = spriteBatch.End() override this.Draw(gameTime: GameTime) = - if menu.Page <> Playing then + if splashActive then + this.DrawSplash() + elif menu.Page <> Playing then this.DrawMenu() elif relationView then this.DrawRelationView() @@ -629,6 +631,58 @@ type LivingVillageGame() as this = use stream = System.IO.File.Create(path) texture.SaveAsPng(stream, width, height) + /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 + member private this.DrawSplash() = + let viewport = this.GraphicsDevice.Viewport + let width = viewport.Width + let height = viewport.Height + let layout = TitleScreen.layout width height + let reveal = LaunchScreen.revealProgress splashFrame + let veil = LaunchScreen.veilAlpha splashFrame + let alpha (value: int) = int (float32 value * reveal) + spriteBatch.Begin() + + // 分层天色:沿用标题画面色带,绝不整屏纯色或大黑块。 + let bandCount = List.length layout.SkyBands + let bandHeight = height / bandCount + List.iteri (fun index (band: TitleScreen.ColorRgb) -> + let y0 = index * bandHeight + let y1 = if index = bandCount - 1 then height else y0 + bandHeight + spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands + + // 屋脊剪影随暗幕退场而上浮就位。 + let drop = int (28.0f * veil) + for x in 0 .. Sim.tilePixels .. width - 1 do + spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.RidgeY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (fst TitleScreen.roofSlots), Color.White) + spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White) + + // 水面色带反射天色,随显现度提高而淡出。 + let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - reveal))) + for row in layout.WaterRows do + for x in 0 .. Sim.tilePixels .. width - 1 do + spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor) + + // 标题:淡入 + 上浮,先画深色投影再画亮面。 + let titleRise = int (24.0f * (1.0f - reveal)) + PixelText.draw spriteBatch pixel cjkAtlas (layout.TitleX + 4) (layout.TitleY + titleRise + 4) layout.TitleScale (Color(12, 20, 28, alpha 255)) LaunchScreen.title + PixelText.draw spriteBatch pixel cjkAtlas layout.TitleX (layout.TitleY + titleRise) layout.TitleScale (Color(235, 246, 232, alpha 255)) LaunchScreen.title + + // 英文副标题保持同一 CJK/点阵字体路径。 + let subtitleScale = 2 + let subtitleWidth = FloatingPrompt.measureWidth LaunchScreen.subtitle subtitleScale + PixelText.draw spriteBatch pixel cjkAtlas ((width - subtitleWidth) / 2) (layout.TitleY + 78) subtitleScale (Color(180, 210, 214, alpha 255)) LaunchScreen.subtitle + + // 跳过提示:轻微呼吸闪烁,落在底部独立窄条上(非整屏黑块)。 + let blink = if (splashFrame / 20L) % 2L = 0L then 220 else 130 + let hintScale = 2 + let hintWidth = FloatingPrompt.measureWidth LaunchScreen.skipHint hintScale + let hintX = (width - hintWidth) / 2 + let hintY = height - 56 + spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 8 * hintScale + 16), Color(10, 16, 24, int (170.0f * (0.5f + 0.5f * reveal)))) + spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 2), Color(92, 180, 190, alpha 220)) + PixelText.draw spriteBatch pixel cjkAtlas hintX hintY hintScale (Color(220, 235, 225, alpha blink)) LaunchScreen.skipHint + spriteBatch.End() + member private this.DrawMenu() = let viewport = this.GraphicsDevice.Viewport let width = viewport.Width @@ -678,7 +732,7 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands // Deterministic 2s entrance: skyline settles from above as the progress unwinds. - let entrance = TitleScreen.entranceProgress hudClock + let entrance = TitleScreen.entranceProgress menuEntranceFrame let skylineDrop = int (36.0f * entrance) List.iteri (fun column slot -> let x = column * Sim.tilePixels diff --git a/src/LivingVillage.Desktop/LaunchScreen.fs b/src/LivingVillage.Desktop/LaunchScreen.fs new file mode 100644 index 0000000..0977777 --- /dev/null +++ b/src/LivingVillage.Desktop/LaunchScreen.fs @@ -0,0 +1,32 @@ +namespace LivingVillage.Desktop + +/// 启动画面:固定的 60Hz 步进下,全部为帧计数的纯函数,确定性可复现。 +/// 标题淡入(revealProgress)、整屏暗幕退场(veilAlpha),Esc/空格可随时跳过。 +module LaunchScreen = + + let durationFrames = 150L + let fadeInFrames = 45L + let title = "江南水乡" + let subtitle = "Living Village" + let skipHint = "ESC 或 空格 跳过" + + let private clamp01 (value: float32) : float32 = + if value < 0.0f then 0.0f + elif value > 1.0f then 1.0f + else value + + /// 0 = 完全未显示,1 = 完全显现。帧计数纯函数。 + let revealProgress (frame: int64) : float32 = + if frame <= 0L then 0.0f + else clamp01 (float32 frame / float32 fadeInFrames) + + /// 整屏暗幕:起手 1,随进程退到 0(1 -> 0)。 + let veilAlpha (frame: int64) : float32 = + if frame <= 0L then 1.0f + elif frame >= durationFrames then 0.0f + else 1.0f - clamp01 (float32 frame / float32 durationFrames) + + let isComplete (frame: int64) : bool = frame >= durationFrames + + /// Esc 或 空格 触发跳过(调用方用“按下沿”结果,不做按键状态判定)。 + let skipRequested (escapePressed: bool) (spacePressed: bool) : bool = escapePressed || spacePressed diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 093f7ae..27e5da6 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -12,6 +12,7 @@ + diff --git a/src/LivingVillage.Desktop/MenuState.fs b/src/LivingVillage.Desktop/MenuState.fs index 619ac58..0155472 100644 --- a/src/LivingVillage.Desktop/MenuState.fs +++ b/src/LivingVillage.Desktop/MenuState.fs @@ -80,11 +80,13 @@ module MenuState = Settings = simulationControl Error = None } + /// P16 开始界面收敛为三项:开始(无存档)或继续(回到进行中的世界)/ 读档 / 退出。 + /// 设置与操作说明不再占用开始界面,仍可在游戏内通过 F1/F2/F3 与 P 打开。 let items (state: MenuState) : MenuItem list = if state.HasCurrentWorld then - [ ContinueItem; NewGameItem; LoadItem; SettingsItem; ControlsItem; ExitItem ] + [ ContinueItem; LoadItem; ExitItem ] else - [ NewGameItem; LoadItem; SettingsItem; ControlsItem; ExitItem ] + [ NewGameItem; LoadItem; ExitItem ] let settingsLabel setting : string = match setting with @@ -95,9 +97,9 @@ module MenuState = let itemLabel item : string = match item with - | ContinueItem -> "继续游戏" - | NewGameItem -> "开始新游戏" - | LoadItem -> "读取存档" + | ContinueItem -> "继续" + | NewGameItem -> "开始" + | LoadItem -> "读档" | SettingsItem -> "设置" | ControlsItem -> "操作说明" | ExitItem -> "退出" -- cgit v1.2.3 From f79839a4bb57b977b7208e5f826a8b0989cbc33e Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:10:18 +0800 Subject: feat(desktop): P16 HUD 顶部窄信息条(时段/时间/倍速/体力/钱币)+ 冬夜冷色 + 面板/提示渐变重绘 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/LivingVillage.Desktop.Tests/P16UiTests.fs | 59 ++++++++++++++ src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 5 +- src/LivingVillage.Desktop/ChineseText.fs | 6 ++ src/LivingVillage.Desktop/Game.fs | 94 +++++++++++++--------- src/LivingVillage.Desktop/HudLayout.fs | 68 ++++++++++++++++ src/LivingVillage.Desktop/Interaction.fs | 9 +-- .../LivingVillage.Desktop.fsproj | 1 + src/LivingVillage.Desktop/M6Presentation.fs | 22 +++++ 8 files changed, 219 insertions(+), 45 deletions(-) create mode 100644 src/LivingVillage.Desktop/HudLayout.fs (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/P16UiTests.fs b/src/LivingVillage.Desktop.Tests/P16UiTests.fs index 0bfdb62..0db4506 100644 --- a/src/LivingVillage.Desktop.Tests/P16UiTests.fs +++ b/src/LivingVillage.Desktop.Tests/P16UiTests.fs @@ -64,3 +64,62 @@ type P16StartMenuTests () = Assert.AreEqual([ ContinueItem; LoadItem; ExitItem ], MenuState.items main) Assert.AreEqual([ "继续"; "读档"; "退出" ], MenuState.items main |> List.map MenuState.itemLabel) Assert.AreEqual(ContinueGame, MenuState.update Confirm main |> snd) + +// ---- P16 HUD 顶部窄信息条 ---- + +[] +type P16HudTests () = + + let world = Sim.initialWorldN 42UL 4 + + [] + member _.HudBarIsANarrowStripNotAFullscreenBlock () = + Assert.IsTrue(HudLayout.barHeight <= 40, "HUD bar must stay a narrow strip") + Assert.IsTrue(HudLayout.barHeight * 4 < 720, "HUD bar must not dominate the viewport") + + [] + member _.HudSegmentsAreDeterministicAndUseRealState () = + let control = SimulationControl.initial + let segments = HudLayout.segments world control + Assert.AreEqual([ "时段"; "时间"; "倍速"; "体力"; "钱币" ], segments |> List.map (fun segment -> segment.Label)) + Assert.AreEqual(segments, HudLayout.segments world control) + let energy = segments |> List.find (fun segment -> segment.Label = "体力") + Assert.AreEqual(sprintf "%.0f" world.Avatar.Mind.Needs.Energy, energy.Value) + let money = segments |> List.find (fun segment -> segment.Label = "钱币") + Assert.AreEqual(sprintf "%.0f" world.Avatar.Mind.Needs.Money, money.Value) + + [] + member _.HudLayoutPlacesSegmentsDeterministicallyWithinWidth () = + let placed = HudLayout.layout (HudLayout.segments world SimulationControl.initial) + Assert.AreEqual(5, placed.Length) + let (firstX, _, _) = List.head placed + Assert.AreEqual(HudLayout.paddingX, firstX) + placed + |> List.pairwise + |> List.iter (fun ((x1, w1, _), (x2, _, _)) -> + Assert.IsTrue(x2 >= x1 + w1, sprintf "segments overlap: %d+%d vs %d" x1 w1 x2)) + let (lastX, lastWidth, _) = List.last placed + Assert.IsTrue(lastX + lastWidth <= 1280, "last segment must fit inside a 1280 viewport") + + [] + member _.HudClockAndPeriodArePureFunctions () = + let ticksPerHour = Sim.ticksPerDay / 24L + Assert.AreEqual("12:00", M6Presentation.clockTimeLabel (12L * ticksPerHour)) + Assert.AreEqual("00:00", M6Presentation.clockTimeLabel 0L) + Assert.AreEqual("06:30", M6Presentation.clockTimeLabel (6L * ticksPerHour + ticksPerHour / 2L)) + Assert.AreEqual("白天", M6Presentation.timePeriodLabel (12L * ticksPerHour)) + Assert.AreEqual("夜晚", M6Presentation.timePeriodLabel 0L) + Assert.AreEqual("清晨", M6Presentation.timePeriodLabel (6L * ticksPerHour)) + Assert.AreEqual("黄昏", M6Presentation.timePeriodLabel (19L * ticksPerHour)) + + [] + member _.HudNightPaletteLeansCoolAndLabelsAreAtlasBacked () = + let day = HudLayout.palette Day + let night = HudLayout.palette Night + Assert.IsTrue(night.Accent.B > night.Accent.R, "night accent should lean cool/blue (winter-night tint)") + Assert.IsTrue(day.Accent.R > day.Accent.B, "day accent should lean warm") + for label in [ "时段"; "时间"; "倍速"; "体力"; "钱币" ] do + Assert.IsTrue(PixelText.isRenderable label, sprintf "HUD label %s must render" label) + Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "时段") + Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "体力") + Assert.IsTrue(ChineseText.requiredUiLabels |> List.contains "钱币") diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 036154e..88f6016 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -792,9 +792,8 @@ type PrototypeTests () = Assert.IsTrue(rect.Y >= 0 && rect.Y + rect.Height <= height, sprintf "y overflow %A %A" (anchorX, anchorY) rect) // Never cover the bottom-left HUD band (camera 0: band starts at height - 160). Assert.IsTrue(rect.Y + rect.Height <= height - FloatingPrompt.hudBottomBandHeight + 8, sprintf "bottom HUD overlap %A %A" (anchorX, anchorY) rect) - // Never cover the top-right status strip. - if rect.X + rect.Width >= width - FloatingPrompt.hudTopRightWidth then - Assert.IsTrue(rect.Y >= FloatingPrompt.hudTopRightHeight, sprintf "top-right HUD overlap %A %A" (anchorX, anchorY) rect) + // Never cover the full-width top status bar. + Assert.IsTrue(rect.Y >= FloatingPrompt.hudTopBarHeight, sprintf "top HUD overlap %A %A" (anchorX, anchorY) rect) [] member _.FloatingPromptMeasureMatchesPixelTextAdvances () = diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs index 0d988fe..52e124d 100644 --- a/src/LivingVillage.Desktop/ChineseText.fs +++ b/src/LivingVillage.Desktop/ChineseText.fs @@ -124,6 +124,12 @@ module ChineseText = "E 进屋" "E 交谈" "时间" + "时段" + "倍速" + "体力" + "钱币" + "清晨" + "黄昏" "互动已记录" "1倍速" "2倍速" diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 8402a72..3bb43f8 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -864,52 +864,72 @@ type LivingVillageGame() as this = this.DrawM5Overlay() member private this.DrawM5Overlay() = + this.DrawHudBar() + if m5View.Panel = WorldPanel then + this.DrawTransientStatus() + else + this.DrawPanel() + + /// 顶部窄信息条:时段/时间/倍速/体力/钱币,半透明渐变 + 强调线,绝非大黑块。 + member private this.DrawHudBar() = let viewport = this.GraphicsDevice.Viewport - let textScale = 2 - let lineHeight = 18 let profile = M6Presentation.profileAtTick world.Tick - let timeLine = sprintf "时间 %s %s" (M6Presentation.dayNightLabel profile.Mode) (M6Presentation.clockLabel simulationControl) - let persistentLines = - timeLine :: (m5View.Prompt |> Option.toList) - let dialogueLines = - match m5View.Panel, m5View.Menu with - | DialoguePanel, Some menu -> - menu.Options - |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (M5Interaction.intentLine intent)) - |> fun options -> [ sprintf "互动:村民 %d" (M5Interaction.menuNpcNumber menu) ] @ options - | _ -> [] + let palette = HudLayout.palette profile.Mode + let placed = HudLayout.layout (HudLayout.segments world simulationControl) + let barHeight = HudLayout.barHeight + let textY = (barHeight - 8 * HudLayout.textScale) / 2 + spriteBatch.Begin() + // 竖向渐变:上半略亮、下半压暗,避免整条纯色。 + spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop) + spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom) + spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent) + for (x, _, segment) in placed do + PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label + let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale + PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value + spriteBatch.End() + + /// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。 + member private this.DrawTransientStatus() = + let viewport = this.GraphicsDevice.Viewport let statusView = { m5View with StatusTick = Some hudStatusAt } - let statusLine = M5Interaction.transientStatusLine hudClock statusView |> Option.toList - let allLines = persistentLines @ dialogueLines @ statusLine - let cornerLines = M5Interaction.cornerStatusLines world - let panelWidth = min 360 (max 1 (viewport.Width - 32)) - let maxLines = max 1 ((viewport.Height - 48) / lineHeight) - let lines = allLines |> List.truncate maxLines - let panelHeight = lines.Length * lineHeight + 24 + match M5Interaction.transientStatusLine hudClock statusView with + | None -> () + | Some line -> + let scale = 2 + let width = FloatingPrompt.measureWidth line scale + let x = 16 + let y = HudLayout.barHeight + HudLayout.accentHeight + 8 + spriteBatch.Begin() + spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, width + 16, 8 * scale + 12), Color(16, 26, 32, 200)) + spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, 2, 8 * scale + 12), Color(92, 180, 190)) + PixelText.draw spriteBatch pixel cjkAtlas x y scale (Color(232, 242, 232)) line + spriteBatch.End() + + /// 打开的面板(对话/需求/观察/年鉴/任务):底部左侧轻量渐变板,沿用既有 panelLines。 + member private this.DrawPanel() = + let viewport = this.GraphicsDevice.Viewport + let textScale = 2 + let lineHeight = 18 + let lines = M5Interaction.panelLines world m5View + let panelWidth = min 380 (max 1 (viewport.Width - 32)) + let maxLines = max 1 ((viewport.Height - HudLayout.barHeight - 48) / lineHeight) + let visibleLines = lines |> List.truncate maxLines + let panelHeight = visibleLines.Length * lineHeight + 26 let panelX = 16 - let panelY = max 16 (viewport.Height - panelHeight - 16) - let panel = Rectangle(panelX, panelY, panelWidth, panelHeight) + let panelY = max (HudLayout.barHeight + 12) (viewport.Height - panelHeight - 16) let innerX = panelX + 12 let innerWidth = max 1 (panelWidth - 24) let maxChars = max 1 (innerWidth / (6 * textScale)) spriteBatch.Begin() - spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 160)) + // 竖向渐变板(半透明,非纯黑),顶部与左侧各一条强调线。 + spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(30, 44, 52, 190)) + spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight / 2, panelWidth, panelHeight - panelHeight / 2), Color(16, 24, 30, 190)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 3), Color(92, 180, 190)) - lines - |> List.iteri (fun index line -> - let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line - let color = if index = 0 then Color(220, 235, 220) else Color(190, 205, 210) - PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 12 + index * lineHeight) textScale color visible) - - // Minimal top-right status strip: day counter + real money/energy values. - let cornerWidth = 232 - let cornerHeight = List.length cornerLines * lineHeight + 20 - let cornerX = viewport.Width - cornerWidth - 16 - let cornerY = 16 - spriteBatch.Draw(pixel, Rectangle(cornerX, cornerY, cornerWidth, cornerHeight), Color(15, 19, 25, 150)) - spriteBatch.Draw(pixel, Rectangle(cornerX, cornerY, cornerWidth, 3), Color(92, 180, 190)) - cornerLines + spriteBatch.Draw(pixel, Rectangle(panelX, panelY, 2, panelHeight), Color(92, 180, 190, 160)) + visibleLines |> List.iteri (fun index line -> let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line - PixelText.draw spriteBatch pixel cjkAtlas (cornerX + 10) (cornerY + 10 + index * lineHeight) textScale (Color(220, 235, 220)) visible) + let color = if index = 0 then Color(226, 238, 226) else Color(192, 208, 212) + PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 13 + index * lineHeight) textScale color visible) spriteBatch.End() diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs new file mode 100644 index 0000000..d943cec --- /dev/null +++ b/src/LivingVillage.Desktop/HudLayout.fs @@ -0,0 +1,68 @@ +namespace LivingVillage.Desktop + +open Microsoft.Xna.Framework +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +/// 顶部窄信息条 HUD 的纯布局/配色模型:只做确定性计算,实际绘制在 Game.fs。 +/// 常驻状态(时段/时间/倍速/体力/钱币)压进一条窄带,绝不用整屏纯色或大黑块。 +module HudLayout = + + let barHeight = 36 + let accentHeight = 3 + let paddingX = 16 + let segmentGap = 20 + let textScale = 2 + + type Segment = + { Label: string + Value: string } + + let segmentText (segment: Segment) : string = sprintf "%s %s" segment.Label segment.Value + + /// 常驻状态片段,数值全部取自世界真实状态,绝不编造。 + let segments (world: World) (control: SimulationControl) : Segment list = + let needs = world.Avatar.Mind.Needs + [ { Label = "时段"; Value = M6Presentation.timePeriodLabel world.Tick } + { Label = "时间"; Value = M6Presentation.clockTimeLabel world.Tick } + { Label = "倍速"; Value = M6Presentation.speedLabel control } + { Label = "体力"; Value = sprintf "%.0f" needs.Energy } + { Label = "钱币"; Value = sprintf "%.0f" needs.Money } ] + + /// 段文本(含标签)的像素宽度。 + let segmentWidth (segment: Segment) : int = + FloatingPrompt.measureWidth (segmentText segment) textScale + + /// 左对齐排布,返回 (起点 x, 宽度, 分段);纯函数,便于布局回归断言。 + let layout (segments: Segment list) : (int * int * Segment) list = + let _, placed = + segments + |> List.fold + (fun (x, acc) segment -> + let width = segmentWidth segment + x + width + segmentGap, (x, width, segment) :: acc) + (paddingX, []) + List.rev placed + + type Palette = + { BarTop: Color + BarBottom: Color + Accent: Color + Label: Color + Value: Color } + + /// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。 + let palette (mode: M6DayNight) : Palette = + match mode with + | Day -> + { BarTop = Color(40, 56, 62, 200) + BarBottom = Color(22, 34, 40, 200) + Accent = Color(212, 168, 96) + Label = Color(206, 220, 208) + Value = Color(242, 248, 234) } + | Night -> + { BarTop = Color(22, 30, 48, 205) + BarBottom = Color(13, 19, 33, 205) + Accent = Color(120, 168, 196) + Label = Color(176, 196, 214) + Value = Color(228, 238, 246) } diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index b29cf6b..dd372ef 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -161,11 +161,10 @@ module FloatingPrompt = Width: int Height: int } - // HUD exclusion geometry shared with the Game.fs renderer: bottom-left lightweight - // panel band and top-right corner status strip. + // HUD exclusion geometry shared with the Game.fs renderer: full-width top status bar + // and the bottom-left lightweight panel band. + let hudTopBarHeight = 39 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 = @@ -194,7 +193,7 @@ module FloatingPrompt = 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 topSafe = hudTopBarHeight + margin let bottomSafe = viewportHeight - hudBottomBandHeight - height - margin let y = max topSafe (min bottomSafe screenY) { X = x; Y = y; Width = width; Height = height } diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 27e5da6..d57036b 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -17,6 +17,7 @@ + diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index ea8fd2f..65300f0 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -63,3 +63,25 @@ module M6Presentation = | OneX -> "1倍速" | TwoX -> "2倍速" | FiveX -> "5倍速" + + /// 倍速标签(语义别名,HUD 用)。 + let speedLabel (control: SimulationControl) : string = clockLabel control + + let private hourOf (tick: int64) : int = + int ((tick / (Sim.ticksPerDay / 24L)) % 24L) + + /// 24 小时制的模拟时钟(HH:MM),由 tick 纯函数导出。 + let clockTimeLabel (tick: int64) : string = + let hourTicks = Sim.ticksPerDay / 24L + let hour = hourOf tick + let minute = int ((tick % hourTicks) * 60L / hourTicks) + sprintf "%02d:%02d" hour minute + + /// 时段标签:清晨/白天/黄昏/夜晚,与实际 tick 一致(纯函数)。 + let timePeriodLabel (tick: int64) : string = + let hour = hourOf tick + if hour < 5 then "夜晚" + elif hour < 8 then "清晨" + elif hour < 17 then "白天" + elif hour < 20 then "黄昏" + else "夜晚" -- cgit v1.2.3 From 81a84f9f86d59387a349caa339148c7a7a6d1e72 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:26:05 +0800 Subject: feat(desktop): P16-FIX 开始界面截图模式(LV_AUTOPLAY_MENU_SHOT)+ 真实菜单证据补拍 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...273\264\346\212\244\350\257\264\346\230\216.md" | 16 ++++++- src/LivingVillage.Desktop.Tests/P16UiTests.fs | 24 ++++++++++ src/LivingVillage.Desktop/Game.fs | 53 +++++++++++++++++----- src/LivingVillage.Desktop/LaunchScreen.fs | 15 ++++++ 4 files changed, 94 insertions(+), 14 deletions(-) (limited to 'src') diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index 614de78..ad68e5b 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.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 菜单截图帧表 ---- + +[] +type P16MenuShotTests () = + + [] + 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(names.Length, names |> List.distinct |> List.length) + + [] + member _.MenuShotSelectsStartThenLoadOnFreshState () = + let state = MenuState.create SimulationControl.initial false + Assert.AreEqual(NewGameItem, MenuState.selectedItem state) + Assert.AreEqual(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 (width * height) + this.GraphicsDevice.GetBackBufferData(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 (width * height) - this.GraphicsDevice.GetBackBufferData(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 -- cgit v1.2.3