From e55edb080153943f396f8b324b9412c540707b24 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 20:08:02 +0800 Subject: p46: 菜单/启动环境动效 — 逐格涟漪(复用waterFrameTickAt)+灯笼呼吸+屋脊上双视差云影, 全纯函数可复现 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P46MenuAmbienceTests.fs | 176 +++++++++++++++++++++ src/LivingVillage.Desktop/Game.fs | 89 ++++++++++- .../LivingVillage.Desktop.fsproj | 1 + src/LivingVillage.Desktop/MenuAmbience.fs | 117 ++++++++++++++ 5 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs create mode 100644 src/LivingVillage.Desktop/MenuAmbience.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 dd51f36..b158d04 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -25,6 +25,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs b/src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs new file mode 100644 index 0000000..d01b07f --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs @@ -0,0 +1,176 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop + +/// P46 menu/splash environment FX: +/// * per-tile water ripple whose phase reuses VillageArt.waterFrameTickAt; +/// * lantern breathing glow alpha; +/// * two-layer parallax cloud shadow above the ridge (pure horizontal drift); +/// * a deterministic shot table + layout invariants (nothing moved, nothing clipped). +/// Every function is frame-driven, so re-rendering the same frame is byte-identical. +[] +type P46MenuAmbienceTests () = + + let twoPiOverThree = 2.0f * float32 System.Math.PI / 3.0f + let assertInt (expected: int) (actual: int) (message: string) = + Assert.IsTrue((expected = actual), sprintf "%s: expected %d actual %d" message expected actual) + let assertClose (expected: float32) (actual: float32) (message: string) = + Assert.IsTrue((abs (expected - actual) < 1e-5f), sprintf "%s: expected %f actual %f" message expected actual) + + [] + member _.WaterPhaseReusesWaterFrameTickAtOnTheCycleBoundary () = + // At every 32-tick boundary the fractional part is zero, so the phase must equal + // the waterFrameTickAt index scaled to 120-degree steps: that is the reuse contract. + for frame in [ 0L; 32L; 64L; 31L * 32L ] do + for column in [ 0; 1; 7; 39 ] do + for rowIndex in [ 0; 1 ] do + let expected = + float32 (VillageArt.waterFrameTickAt frame column rowIndex) * twoPiOverThree + assertClose expected (MenuAmbience.waterPhase frame column rowIndex) + (sprintf "frame %d col %d row %d must reuse waterFrameTickAt" frame column rowIndex) + + [] + member _.AllEffectsAreDeterministicForTheSameFrame () = + for frame in [ 0L; 1L; 16L; 33L; 173L; 4097L ] do + for column in [ 0; 5; 39 ] do + for rowIndex in [ 0; 1 ] do + assertClose + (MenuAmbience.waterColumnBrightness frame column rowIndex) + (MenuAmbience.waterColumnBrightness frame column rowIndex) + "water brightness must be deterministic" + assertInt + (MenuAmbience.waterColumnLift frame column rowIndex) + (MenuAmbience.waterColumnLift frame column rowIndex) + "water lift must be deterministic" + for index in [ 0; 1; 3 ] do + assertInt + (MenuAmbience.lanternBreathAlpha frame index) + (MenuAmbience.lanternBreathAlpha frame index) + "breath must be deterministic" + for layer in [ 0; 1 ] do + assertInt + (MenuAmbience.cloudOffsetPx layer 140L 1280) + (MenuAmbience.cloudOffsetPx layer 140L 1280) + "cloud offset must be deterministic" + assertInt + (List.length (MenuAmbience.cloudPuffs layer 140L 1280)) + (List.length (MenuAmbience.cloudPuffs layer 140L 1280)) + "cloud puffs must be deterministic" + + [] + member _.WaterRippleVariesAcrossColumnsAndFrames () = + Assert.IsTrue(MenuAmbience.waterRippleAmplitude >= 0.25f, "ripple amplitude must stay >= 0.25") + // Adjacent tile columns sit 120 degrees apart, so they differ visibly. + let adjacent = + [ for column in 0 .. 38 -> + abs (MenuAmbience.waterColumnBrightness 150L column 0 + - MenuAmbience.waterColumnBrightness 150L (column + 1) 0) ] + Assert.IsTrue(List.max adjacent >= 0.2f, sprintf "adjacent column step %.3f must be >= 0.20" (List.max adjacent)) + // Across one row the column brightness has a real spread. + let columns = [ for column in 0 .. 39 -> float (MenuAmbience.waterColumnBrightness 150L column 0) ] + let mean = List.average columns + let std = sqrt (List.average [ for v in columns -> (v - mean) ** 2.0 ]) + Assert.IsTrue(std >= 0.15, sprintf "across-column std %.3f must be >= 0.15" std) + // And the same column moves as the frame advances within a 32-tick window. + let overFrames = [ for frame in 0L .. 31L -> MenuAmbience.waterColumnBrightness frame 5 0 ] + Assert.IsTrue(overFrames |> List.distinct |> List.length > 3, "ripple must move with the frame") + + [] + member _.LanternBreathIsBoundedAndSwingsTwoPhases () = + let samples = + [ for frame in 0L .. 260L do + for index in 0 .. 5 -> MenuAmbience.lanternBreathAlpha frame index ] + Assert.IsTrue(samples |> List.forall (fun a -> a >= 40 && a <= 96), + "breath alpha must stay in [40, 96]") + Assert.IsTrue(samples |> List.distinct |> List.length > 1, "breath must vary") + Assert.AreNotEqual(MenuAmbience.lanternBreathAlpha 50L 0, MenuAmbience.lanternBreathAlpha 50L 1) + // The two evidence phases must actually be a peak and a trough (readable luma delta). + let peak = MenuAmbience.lanternBreathAlpha (P46ShotScript.menuPeakFrame + 1L) 0 + let trough = MenuAmbience.lanternBreathAlpha (P46ShotScript.menuTroughFrame + 1L) 0 + Assert.IsTrue(peak - trough >= 50, sprintf "breath phases %d vs %d must differ by >= 50" peak trough) + + [] + member _.CloudParallaxIsBoundedAndLayered () = + let width = 1280 + let span = width + 200 + assertInt 2 MenuAmbience.cloudLayerCount "two parallax layers" + for layer in 0 .. MenuAmbience.cloudLayerCount - 1 do + for frame in [ 0L; 40L; 100L; 140L; 10000L ] do + let offset = MenuAmbience.cloudOffsetPx layer frame width + Assert.IsTrue(offset >= 0 && offset < span, sprintf "layer %d offset %d out of [0,%d)" layer offset span) + // Every cloud body sits entirely above the ridge line: no ground/roof overlap. + for (_, cy, _, ry) in MenuAmbience.cloudPuffs layer frame width do + Assert.IsTrue(cy - ry >= 0 && cy + ry < 44, + sprintf "layer %d cloud y %d..%d must stay above ridge 44" layer (cy - ry) (cy + ry)) + // The inner layer drifts slower than the outer one (parallax), inside one span. + assertInt (2 * MenuAmbience.cloudOffsetPx 0 100L width) (MenuAmbience.cloudOffsetPx 1 100L width) "parallax 2x" + // Drift moves with the frame. + assertInt 20 (MenuAmbience.cloudOffsetPx 0 140L width - MenuAmbience.cloudOffsetPx 0 100L width) "cloud drift px" + + [] + member _.P46ShotTableIsOrderedAndNamesAreDistinct () = + Assert.IsTrue(P46ShotScript.splashRippleFrame < P46ShotScript.cloudEarlyFrame) + Assert.IsTrue(P46ShotScript.cloudEarlyFrame < P46ShotScript.cloudLateFrame) + Assert.IsTrue(P46ShotScript.menuPeakFrame < P46ShotScript.menuTroughFrame) + Assert.IsTrue(LaunchScreen.revealProgress P46ShotScript.splashRippleFrame < 1.0f, + "the ripple frame must still show the water band") + Assert.IsTrue(P46ShotScript.cloudLateFrame <= LaunchScreen.durationFrames, + "cloud frames must fall inside the splash") + Assert.IsTrue(P46ShotScript.menuPeakFrame >= 120L && P46ShotScript.menuTroughFrame >= 120L, + "menu lantern phases must wait for the entrance to settle") + let names = + [ P46ShotScript.splashRippleName + P46ShotScript.cloudEarlyName + P46ShotScript.cloudLateName + P46ShotScript.menuPeakName + P46ShotScript.menuTroughName ] + assertInt names.Length (names |> List.distinct |> List.length) "shot names distinct" + + [] + member _.LayoutKeyFieldsAreUnchanged () = + // Pins the splash/menu layout so an environment-FX change can never silently move it. + let layout = TitleScreen.layout 1280 720 + assertInt 44 layout.RidgeY "RidgeY" + assertInt 76 layout.EaveY "EaveY" + Assert.AreEqual([ 624; 656 ], layout.WaterRows) + assertInt 512 layout.TitleX "TitleX" + assertInt 120 layout.TitleY "TitleY" + assertInt 4 layout.TitleScale "TitleScale" + assertInt 410 layout.PanelX "PanelX" + assertInt 296 layout.PanelY "PanelY" + assertInt 460 layout.PanelWidth "PanelWidth" + assertInt 320 layout.PanelHeight "PanelHeight" + assertInt 450 layout.MenuX "MenuX" + assertInt 434 layout.CursorX "CursorX" + assertInt 466 layout.TextX "TextX" + assertInt 240 layout.ControlsPanelY "ControlsPanelY" + assertInt 566 layout.ControlsActionX "ControlsActionX" + + [] + member _.LayoutStaysInsideTheViewportAcrossSizes () = + for (width, height) in [ 320, 240; 640, 480; 800, 600; 1280, 720; 1920, 1080 ] do + let layout = TitleScreen.layout width height + let titleWidth = 4 * (4 * 16) + Assert.IsTrue(layout.TitleX >= 0 && layout.TitleX + titleWidth <= width, + sprintf "%dx%d title out of bounds" width height) + Assert.IsTrue(layout.PanelX >= 0 && layout.PanelY >= 0 + && layout.PanelX + layout.PanelWidth <= width + && layout.PanelY + layout.PanelHeight <= height, + sprintf "%dx%d panel out of bounds" width height) + for rowIndex in 0 .. 4 do + let cursor, label = TitleScreen.selectionRects layout rowIndex + Assert.IsTrue(TitleScreen.rectsDisjoint cursor label, + sprintf "%dx%d row %d cursor/label overlap" width height rowIndex) + Assert.IsTrue(label.X >= 0 && label.X + label.Width <= layout.PanelX + layout.PanelWidth, + sprintf "%dx%d row %d label out of panel" width height rowIndex) + for rowIndex in 0 .. 7 do + let key, action = TitleScreen.controlRowRects layout rowIndex + Assert.IsTrue(TitleScreen.rectsDisjoint key action, + sprintf "%dx%d control row %d overlap" width height rowIndex) + if height >= 600 then + Assert.IsTrue(key.Y + key.Height <= height && action.Y + action.Height <= height, + sprintf "%dx%d control row %d below viewport" width height rowIndex) + for row in layout.WaterRows do + Assert.IsTrue(row >= 0 && row + 32 <= height, + sprintf "%dx%d water row %d out of bounds" width height row) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 3e39b09..578be72 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -321,6 +321,16 @@ type LivingVillageGame() as this = let mutable p45Step = 0 let mutable p45PendingName = "" let mutable p45Pending = false + // P46 证据钩子:LV_P46_SHOT=1 时拍 splash 涟漪中帧 + 云影两帧 + 菜单灯笼呼吸两相位, + // 全程走正常启动/菜单流程,仅按帧表落盘。 + let p46ShotMode = Environment.GetEnvironmentVariable("LV_P46_SHOT") = "1" + let mutable p46PendingName = "" + let mutable p46Pending = false + let mutable p46SplashRippleDone = false + let mutable p46CloudEarlyDone = false + let mutable p46CloudLateDone = false + let mutable p46MenuADone = false + let mutable p46MenuBDone = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -954,6 +964,45 @@ type LivingVillageGame() as this = (sprintf "%A" spec.Frame) p45Step <- p45Step + 1 + /// P46 证据:Draw 末尾落盘。splash 阶段按帧阈值拍涟漪/云影,菜单阶段拍灯笼两相位。 + member private this.CaptureP46Shot () = + let save (name: string) = + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name) + printfn "p46-shot=%s splashFrame=%d menuEntranceFrame=%d" name splashFrame menuEntranceFrame + if splashActive then + if not p46SplashRippleDone && splashFrame >= P46ShotScript.splashRippleFrame then + p46SplashRippleDone <- true + save P46ShotScript.splashRippleName + elif not p46CloudEarlyDone && splashFrame >= P46ShotScript.cloudEarlyFrame then + p46CloudEarlyDone <- true + save P46ShotScript.cloudEarlyName + elif not p46CloudLateDone && splashFrame >= P46ShotScript.cloudLateFrame then + p46CloudLateDone <- true + save P46ShotScript.cloudLateName + elif p46Pending then + p46Pending <- false + if p46PendingName = P46ShotScript.menuPeakName then p46MenuADone <- true + else p46MenuBDone <- true + save p46PendingName + + /// P46 证据:菜单阶段按帧表把 menuEntranceFrame 钉到灯笼呼吸的波峰/波谷,拍两相位。 + member private this.PrepareP46Shot () = + if p46MenuADone && p46MenuBDone then + printfn "p46-shot=done" + this.Exit() + elif not p46MenuADone then + // 每帧钉住目标帧,抵消 Update 末尾的自增,保证 Draw 看到精确相位。 + menuEntranceFrame <- P46ShotScript.menuPeakFrame + if not p46Pending then + p46PendingName <- P46ShotScript.menuPeakName + p46Pending <- true + elif not p46MenuBDone then + menuEntranceFrame <- P46ShotScript.menuTroughFrame + if not p46Pending then + p46PendingName <- P46ShotScript.menuTroughName + p46Pending <- true + /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = if not riverscapeTourWatchStarted then @@ -1268,6 +1317,17 @@ type LivingVillageGame() as this = let pressedAny (keys: Keys list) = keys |> List.exists pressed if splashActive then splashFrame <- splashFrame + 1L + if p46ShotMode && splashFrame >= 10L then + // P46 取证:等画面绘制稳定后,把 splashFrame 钉到下一个未取证的目标帧, + // 令 Draw 看到精确帧号(前若干帧 backbuffer 未铺满,故需 warm-up 门槛)。 + let target = + if not p46SplashRippleDone then Some P46ShotScript.splashRippleFrame + elif not p46CloudEarlyDone then Some P46ShotScript.cloudEarlyFrame + elif not p46CloudLateDone then Some P46ShotScript.cloudLateFrame + else None + match target with + | Some t -> splashFrame <- t + | None -> () let skip = LaunchScreen.skipRequested (pressed Keys.Escape) (pressed Keys.Space) if skip || LaunchScreen.isComplete splashFrame then splashActive <- false @@ -1322,6 +1382,9 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP45Shot() + elif p46ShotMode then + if menu.Page = MainMenu then + this.PrepareP46Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -1731,6 +1794,8 @@ type LivingVillageGame() as this = this.CaptureP44Shot() if p45ShotMode && menu.Page = Playing then this.CaptureP45Shot() + if p46ShotMode then + this.CaptureP46Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -1751,6 +1816,14 @@ type LivingVillageGame() as this = 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 + // ③ 屋脊上方的双层视差云影:纯水平位移(随帧缓慢漂移),低 alpha 预乘柔光, + // 只用椭圆行落成,绝不留大黑块/实心色块。 + for layer in 0 .. MenuAmbience.cloudLayerCount - 1 do + let cloudColor = VillageArt.premultiply 214 228 236 (MenuAmbience.cloudLayerAlpha layer) + for (cx, cy, rx, ry) in MenuAmbience.cloudPuffs layer splashFrame width do + for (runX, runY, runW) in VillageArt.ellipseRuns cx cy rx ry do + spriteBatch.Draw(pixel, Rectangle(runX, runY, runW, 1), cloudColor) + // 屋脊剪影随暗幕退场而上浮就位。 let drop = int (28.0f * veil) for x in 0 .. Sim.tilePixels .. width - 1 do @@ -1765,22 +1838,22 @@ type LivingVillageGame() as this = spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor) for x in 0 .. Sim.tilePixels .. width - 1 do let column = x / Sim.tilePixels - let brightness = TitleScreen.waterRippleBrightness splashFrame column rowIndex + let brightness = MenuAmbience.waterColumnBrightness splashFrame column rowIndex let crestColor = Color( min 255 (int (150.0f * brightness)), min 255 (int (170.0f * brightness)), min 255 (int (178.0f * brightness)), waterAlpha) - let lift = TitleScreen.waterRippleLift splashFrame column rowIndex + let lift = MenuAmbience.waterColumnLift splashFrame column rowIndex spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) - if TitleScreen.waterRippleCrest splashFrame column rowIndex then + if MenuAmbience.waterColumnCrest splashFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) // P42 屋檐灯笼呼吸光晕:复用 P14 预乘 alpha 径向路径,随帧与灯序号呼吸。 let lanternColor, _ = TitleScreen.lanternColors for (lanternIndex, (lx, ly)) in List.indexed layout.Lanterns do - let breath = TitleScreen.lanternBreathAlpha splashFrame lanternIndex + let breath = MenuAmbience.lanternBreathAlpha splashFrame lanternIndex for (rect, color) in VillageArt.lanternGlowLayers lx ly 28 6 252 216 148 breath do spriteBatch.Draw(pixel, rect, color) spriteBatch.Draw(pixel, Rectangle(lx - 3, ly - 5, 8, 10), Color(lanternColor.R, lanternColor.G, lanternColor.B)) @@ -1875,22 +1948,22 @@ type LivingVillageGame() as this = spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor) for x in 0 .. Sim.tilePixels .. width - 1 do let column = x / Sim.tilePixels - let brightness = TitleScreen.waterRippleBrightness menuEntranceFrame column rowIndex + let brightness = MenuAmbience.waterColumnBrightness menuEntranceFrame column rowIndex let crestColor = Color( min 255 (int (150.0f * brightness)), min 255 (int (170.0f * brightness)), min 255 (int (178.0f * brightness)), waterAlpha) - let lift = TitleScreen.waterRippleLift menuEntranceFrame column rowIndex + let lift = MenuAmbience.waterColumnLift menuEntranceFrame column rowIndex spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) - if TitleScreen.waterRippleCrest menuEntranceFrame column rowIndex then + if MenuAmbience.waterColumnCrest menuEntranceFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) // P42 bank lanterns breathe through the shared P14 premultiplied-alpha radial path. let lanternColor, _ = TitleScreen.lanternColors for (lanternIndex, (lx, ly)) in List.indexed layout.Lanterns do - let breath = TitleScreen.lanternBreathAlpha menuEntranceFrame lanternIndex + let breath = MenuAmbience.lanternBreathAlpha menuEntranceFrame lanternIndex for (rect, color) in VillageArt.lanternGlowLayers lx ly 28 6 252 216 148 breath do spriteBatch.Draw(pixel, rect, color) spriteBatch.Draw(pixel, Rectangle(lx - 3, ly - 5, 8, 10), Color(lanternColor.R, lanternColor.G, lanternColor.B)) diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 51fb32e..22d0d15 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -21,6 +21,7 @@ + diff --git a/src/LivingVillage.Desktop/MenuAmbience.fs b/src/LivingVillage.Desktop/MenuAmbience.fs new file mode 100644 index 0000000..74fefeb --- /dev/null +++ b/src/LivingVillage.Desktop/MenuAmbience.fs @@ -0,0 +1,117 @@ +namespace LivingVillage.Desktop + +/// P46 菜单/启动环境动效(纯函数层): +/// ① 水面逐格涟漪 —— 复用 VillageArt.waterFrameTickAt 的相位索引,再叠加 32 tick 内的 +/// 分数相位,使相邻水格相位错开、且 32 tick 边界之间也连续波动; +/// ② 灯笼呼吸光晕 —— alpha 在 [40,96] 间随帧数正弦呼吸,供 VillageArt.lanternGlowLayers 复用; +/// ③ 启动画面屋脊上方双层视差云影 —— 纯水平位移、有界、随帧缓慢漂移。 +/// 全部为帧计数纯函数:同参数逐字节可复现,无时钟/随机源。 +module MenuAmbience = + + open System + + // ---------------- ① 水面逐格涟漪 ---------------- + + /// 涟漪幅度,沿用 P42 的可见性预算(>=0.25 才能在 1280x720 下读得出来)。 + let waterRippleAmplitude = 0.32f + + let private twoPiOverThree = 2.0f * float32 Math.PI / 3.0f + + /// 逐格涟漪相位(弧度)。基相位直接调用 VillageArt.waterFrameTickAt(tick/32 + tileX + + /// 2*tileY 对 3 取模),再加 tick%32/32 的分数相位:在 32 tick 的整数边界上退化为 + /// waterFrameTickAt 的 3 帧相位,边界之间则连续推进,所以既有逐列差异又有逐帧运动。 + let waterPhase (frame: int64) (column: int) (rowIndex: int) : float32 = + let index = VillageArt.waterFrameTickAt frame column rowIndex + let fraction = float32 (frame % 32L) / 32.0f + (float32 index + fraction) * twoPiOverThree + + /// 该水格的正弦波值,范围 [-1, 1]。 + let waterWave (frame: int64) (column: int) (rowIndex: int) : float32 = + sin (waterPhase frame column rowIndex) + + /// 该水格亮度倍率:1.0 ± waterRippleAmplitude。 + let waterColumnBrightness (frame: int64) (column: int) (rowIndex: int) : float32 = + 1.0f + waterRippleAmplitude * waterWave frame column rowIndex + + /// 该水格亮面整体抬升像素(-2..2),用于高光行位移而不留空洞。 + let waterColumnLift (frame: int64) (column: int) (rowIndex: int) : int = + int (Math.Round(float (waterWave frame column rowIndex * 2.0f), MidpointRounding.AwayFromZero)) + + /// 是否处于波峰(正弦 > 0.7),渲染器会给这一列加一条高光。 + let waterColumnCrest (frame: int64) (column: int) (rowIndex: int) : bool = + waterWave frame column rowIndex > 0.7f + + // ---------------- ② 灯笼呼吸光晕 ---------------- + + /// 灯笼呼吸 alpha,界于 [40, 96];相位随帧与灯序号推进,邻灯不同步。 + let lanternBreathAlpha (frame: int64) (index: int) : int = + let phase = float32 (frame % 4096L) / 22.0f + float32 index * 1.3f + 68 + int (Math.Round(float (sin phase * 28.0f), MidpointRounding.AwayFromZero)) + + // ---------------- ③ 屋脊上方双层视差云影 ---------------- + + let cloudLayerCount = 2 + + /// 两层云影的速度(px/帧),内层更慢,形成视差。 + let cloudLayerSpeed (layer: int) : float32 = + if layer % cloudLayerCount = 0 then 0.5f else 1.0f + + /// 云影中心 y。两层都落在屋脊(TitleScreen.RidgeY = 44)上方。 + let cloudLayerY (layer: int) : int = + if layer % cloudLayerCount = 0 then 16 else 32 + + /// 云影 alpha(预乘绘制,低 alpha 柔光,绝不形成实心块)。外层更快、更实,便于量测视差。 + let cloudLayerAlpha (layer: int) : int = + if layer % cloudLayerCount = 0 then 12 else 30 + + let cloudLayerRadiusX (layer: int) : int = + if layer % cloudLayerCount = 0 then 52 else 74 + + let cloudLayerRadiusY (layer: int) : int = + if layer % cloudLayerCount = 0 then 9 else 11 + + let private cloudBases = + [| [| 150; 520; 900; 1180 |] + [| 300; 700; 1050 |] |] + + /// 云影水平位移(px),按图层速度随帧单调推进并按 (width+200) 环绕,保持有界。 + let cloudOffsetPx (layer: int) (frame: int64) (width: int) : int = + let speed = cloudLayerSpeed layer + let travelled = + int (Math.Round(float (float32 (frame % 100000L) * speed), MidpointRounding.AwayFromZero)) + let span = max 1 (width + 200) + ((travelled % span) + span) % span + + /// 该图层若干云团中心 (x, y),x 已含位移并环绕,y 恒定在屋脊上方。 + let cloudPuffCenters (layer: int) (frame: int64) (width: int) : (int * int) list = + let offset = cloudOffsetPx layer frame width + let span = max 1 (width + 200) + let y = cloudLayerY layer + cloudBases.[layer % cloudLayerCount] + |> Array.toList + |> List.map (fun baseX -> (((baseX + offset) % span) - 100), y) + + /// 该图层的云团椭圆 (cx, cy, rx, ry),供渲染层用 VillageArt.ellipseRuns 落成柔边。 + let cloudPuffs (layer: int) (frame: int64) (width: int) : (int * int * int * int) list = + let rx = cloudLayerRadiusX layer + let ry = cloudLayerRadiusY layer + cloudPuffCenters layer frame width |> List.map (fun (cx, cy) -> cx, cy, rx, ry) + +/// P46 取证:环境动效证据帧表(60Hz 帧号纯数据,Game 与回归测试共用)。 +/// * splash 涟漪中帧:水面仍可见(reveal<1); +/// * 云影两帧:都由视差位移纯函数决定,帧差即云影位移; +/// * 菜单灯笼两相位:入场完成后(>=120)的呼吸波峰/波谷,两帧 luma 差即呼吸幅度。 +module P46ShotScript = + + let splashRippleName = "p46-splash-ripple" + let cloudEarlyName = "p46-cloud-a" + let cloudLateName = "p46-cloud-b" + let menuPeakName = "p46-menu-lantern-a" + let menuTroughName = "p46-menu-lantern-b" + + let splashRippleFrame = 16L + let cloudEarlyFrame = 100L + let cloudLateFrame = 140L + // menuEntranceFrame 在 Update 末尾 +1,故设置值取目标相位帧的前一帧。 + let menuPeakFrame = 172L + let menuTroughFrame = 241L -- cgit v1.2.3