From 837fd81a2bd857011855ca1dc32e646629f14236 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 11:08:32 +0800 Subject: p74: 启动开场过渡(水墨式渐显)+ 开始界面布局回归 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增纯函数 InkReveal.fs(inkStrength ease-out 消散 + 6 滴预乘墨滴 dabs),DrawSplash 在场景层之上、标题之下绘制,与既有 reveal/veil 淡入叠加,形成可辨识水墨开场;单滴 alpha<255 且椭圆行落成,无实心黑块。开始界面沿用既有 开始/读档/退出(有档 继续)。新增 P74LaunchTransitionTests(4 例)与 LV_P74_SHOT/-analyze-p74 证据(5 帧墨幕非增、两两 diff>0,verdict=PASS)。HUD 结构未动;Desktop 350/350、Kernel 120/120、digest 与 p70 一致。 --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P74LaunchTransitionTests.fs | 59 ++++++++++++++++++++++ src/LivingVillage.Desktop/Game.fs | 29 +++++++++++ src/LivingVillage.Desktop/InkReveal.fs | 58 +++++++++++++++++++++ .../LivingVillage.Desktop.fsproj | 1 + 5 files changed, 148 insertions(+) create mode 100644 src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs create mode 100644 src/LivingVillage.Desktop/InkReveal.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 11a1b90..fb7ecb7 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -45,6 +45,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs b/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs new file mode 100644 index 0000000..880f33b --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs @@ -0,0 +1,59 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +/// P74 回归:启动开场过渡(水墨渐显)纯函数性质 + 开始界面布局入口。 +[] +type P74LaunchTransitionTests () = + + let width = 1280 + let height = 720 + + [] + member _.InkRevealDissolvesMonotonicallyAndStaysSoft () = + // 起手满墨、96 帧后散尽,且整体强度随帧非增(确定性 ease-out)。 + Assert.AreEqual(1.0f, InkReveal.inkStrength 0L) + Assert.AreEqual(0.0f, InkReveal.inkStrength InkReveal.dissolveFrames) + Assert.AreEqual(0.0f, InkReveal.inkStrength 140L) + let strengths = [ 0L; 8L; 30L; 60L; 95L ] |> List.map InkReveal.inkStrength + Assert.AreEqual(strengths, strengths |> List.sortDescending) + // 墨滴始终存在、均在画布内、alpha < 255(柔和墨晕,非实心黑块)、半径不覆盖半屏。 + for frame in [ 0L; 8L; 30L; 60L; 95L ] do + let dabs = InkReveal.dabs frame width height + Assert.IsTrue(dabs.Length > 0, "墨滴集合不得为空") + for dab in dabs do + Assert.IsTrue(dab.Alpha >= 0 && dab.Alpha < 255, sprintf "alpha %d 越界" dab.Alpha) + Assert.IsTrue(dab.Rx > 0 && dab.Rx < width / 2, "Rx 覆盖过半屏") + Assert.IsTrue(dab.Ry > 0 && dab.Ry < height / 2, "Ry 覆盖过半屏") + + [] + member _.InkRevealDabsChangeOverTheTransition () = + // 同一帧两次调用一致(确定性);不同帧墨滴布局不同(动画在变)。 + Assert.AreEqual(InkReveal.dabs 20L width height, InkReveal.dabs 20L width height) + Assert.AreNotEqual(InkReveal.dabs 8L width height, InkReveal.dabs 60L width height) + + [] + member _.LaunchScreenTransitionIsDeterministic () = + Assert.AreEqual(0.0f, LaunchScreen.revealProgress 0L) + Assert.AreEqual(1.0f, LaunchScreen.revealProgress LaunchScreen.fadeInFrames) + Assert.AreEqual(1.0f, LaunchScreen.veilAlpha 0L) + Assert.AreEqual(0.0f, LaunchScreen.veilAlpha LaunchScreen.durationFrames) + Assert.IsTrue(LaunchScreen.isComplete LaunchScreen.durationFrames) + Assert.IsFalse(LaunchScreen.isComplete (LaunchScreen.durationFrames - 1L)) + Assert.AreEqual("江南水乡", LaunchScreen.title) + + [] + member _.StartMenuLayoutExposesStartLoadExit () = + // 开场结束后的可交互开始界面:无存档 → 开始/读档/退出。 + let fresh = MenuState.create { Paused = false; Speed = OneX } false + Assert.AreEqual(MainMenu, fresh.Page) + Assert.AreEqual([ NewGameItem; LoadItem; ExitItem ], MenuState.items fresh) + let labels = MenuState.items fresh |> List.map MenuState.itemLabel + Assert.AreEqual([ "开始"; "读档"; "退出" ], labels) + // 有进行中的世界 → 继续/读档/退出。 + let resumed = MenuState.create { Paused = false; Speed = OneX } true + Assert.AreEqual([ ContinueItem; LoadItem; ExitItem ], MenuState.items resumed) + Assert.AreEqual("继续", MenuState.itemLabel (MenuState.selectedItem resumed)) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 3d04ca7..a4f994b 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -319,6 +319,10 @@ type LivingVillageGame() as this = let p42ShotMode = Environment.GetEnvironmentVariable("LV_P42_SHOT") = "1" let mutable p42SplashEarlyDone = false let mutable p42SplashLateDone = false + // P74 证据钩子:LV_P74_SHOT=1 时在 60Hz 固定步进下按 splash 帧表拍开场过渡序列,拍完退出。 + let p74ShotMode = Environment.GetEnvironmentVariable("LV_P74_SHOT") = "1" + let p74Frames = [ 8L; 30L; 60L; 100L; 140L ] + let mutable p74Saved = Set.empty // P43 证据钩子:LV_P43_SHOT=1 时在真实世界里拍昼/夜两张 HUD 整帧(日历钟卡 + 体力条 + 10 格 hotbar)。 let p43ShotMode = Environment.GetEnvironmentVariable("LV_P43_SHOT") = "1" let mutable p43Step = 0 @@ -2617,6 +2621,20 @@ type LivingVillageGame() as this = printfn "p42-shot=done" this.Exit() + /// P74 证据:splash 帧表逐帧落盘(证明开场过渡在变),拍完即退出。 + member private this.CaptureP74Shot () = + if splashActive then + match p74Frames |> List.tryFind (fun f -> not (Set.contains f p74Saved) && splashFrame >= f) with + | Some frame -> + p74Saved <- Set.add frame p74Saved + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/p74-splash-%03d.png" recordDirectory frame) + printfn "p74-shot=p74-splash-%03d inkStrength=%.3f" frame (InkReveal.inkStrength splashFrame) + | None -> () + if p74Saved.Count >= p74Frames.Length then + printfn "p74-shot=done" + this.Exit() + /// P20 证据:把一次 MapGen 绘制渲染到指定尺寸离屏 target,返回绘制瓦片数与纹理。 /// P28:可传入世界 tint 与可选 veil tick(夜景取证用)。 member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) (tint: Color) (veilTick: int64 option) : int * Texture2D = @@ -2867,6 +2885,8 @@ type LivingVillageGame() as this = this.CaptureMenuShot() if p42ShotMode then this.CaptureP42Shot() + if p74ShotMode then + this.CaptureP74Shot() if storyShotMode && storyShotPrepared then storyShotFrame <- storyShotFrame + 1L if not storyShotSaved && storyShotFrame = 20L then @@ -2990,6 +3010,15 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, Rectangle(lx - 1, ly - 7, 4, 2), Color(90, 60, 40)) spriteBatch.Draw(pixel, Rectangle(lx - 2, ly + 5, 6, 2), Color(90, 60, 40)) + // P74 水墨式渐显:柔和墨滴从画布各处晕开、随帧消散,露出后方天色/水面/屋脊。 + // 单滴 alpha < 255 且用预乘椭圆行落成,绝无实心黑块;绘在标题之下、场景之上。 + let inkR, inkG, inkB = InkReveal.inkRgb + for dab in InkReveal.dabs splashFrame width height do + if dab.Alpha > 0 then + let inkColor = VillageArt.premultiply inkR inkG inkB dab.Alpha + for (runX, runY, runW) in VillageArt.ellipseRuns dab.X dab.Y dab.Rx dab.Ry do + spriteBatch.Draw(pixel, Rectangle(runX, runY, runW, 1), inkColor) + // 标题:淡入 + 上浮,先画深色投影再画亮面。 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 diff --git a/src/LivingVillage.Desktop/InkReveal.fs b/src/LivingVillage.Desktop/InkReveal.fs new file mode 100644 index 0000000..04c1675 --- /dev/null +++ b/src/LivingVillage.Desktop/InkReveal.fs @@ -0,0 +1,58 @@ +namespace LivingVillage.Desktop + +open System + +/// P74 启动开场过渡:纯程序实现的水墨式渐显。 +/// 启动最初数帧用若干柔和墨滴(预乘 alpha 椭圆行,绝无整屏纯色/大黑块)从中心向外 +/// 晕开,同时整体墨幕强度随帧 ease-out 消退,露出后方的天色/水面/屋脊/标题。 +/// 全部为帧计数与画布尺寸的纯函数,确定性可复现;Kernel 不参与。 +module InkReveal = + + /// 墨幕完全消散所需帧数(< LaunchScreen.durationFrames,先散墨再见标题)。 + let dissolveFrames = 96L + + /// 单滴墨的最大 alpha:始终 < 255,保证是柔光墨晕而非实心黑块。 + let maxAlpha = 200 + + /// 墨滴种子:归一化中心 + 归一化基准半径 + 相位(纯数据,确定性布局)。 + let private seeds : (float32 * float32 * float32 * float32 * int) list = + [ (0.22f, 0.30f, 0.14f, 0.10f, 0) + (0.46f, 0.22f, 0.18f, 0.12f, 1) + (0.74f, 0.34f, 0.12f, 0.10f, 2) + (0.58f, 0.62f, 0.20f, 0.13f, 3) + (0.30f, 0.70f, 0.16f, 0.11f, 4) + (0.83f, 0.67f, 0.11f, 0.08f, 5) ] + + let private clamp01 (v: float32) = if v < 0.0f then 0.0f elif v > 1.0f then 1.0f else v + + /// 整体墨幕强度:起手 1,随帧 ease-out 退到 0(dissolveFrames 后为 0)。 + let inkStrength (frame: int64) : float32 = + if frame <= 0L then 1.0f + elif frame >= dissolveFrames then 0.0f + else + let t = clamp01 (float32 frame / float32 dissolveFrames) + 1.0f - t |> fun remaining -> remaining * remaining * remaining + + /// 一滴墨:像素中心/半径/alpha。alpha 已含墨幕强度,绘制方按 alpha 预乘 rgb。 + type Dab = + { X: int + Y: int + Rx: int + Ry: int + Alpha: int } + + /// 当前帧的墨滴集合:消散过程中半径外扩、alpha 下降,读起来像墨在水中晕开。 + let dabs (frame: int64) (width: int) (height: int) : Dab list = + let strength = inkStrength frame + let expand = 1.0f + 0.9f * (1.0f - strength) + seeds + |> List.map (fun (cx, cy, brx, bry, phase) -> + let wobble = 1.0f + 0.06f * float32 (sin (float (phase) * 1.7)) + { X = int (cx * float32 width) + Y = int (cy * float32 height) + Rx = max 1 (int (brx * float32 width * expand * wobble)) + Ry = max 1 (int (bry * float32 height * expand * wobble)) + Alpha = int (float32 maxAlpha * strength) }) + + /// 墨色(深靛而非纯黑),绘制时与 alpha 预乘。 + let inkRgb : int * int * int = (26, 36, 52) diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 7243705..8abbdd6 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -21,6 +21,7 @@ + -- cgit v1.2.3