diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:08:02 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:08:02 +0800 |
| commit | e55edb080153943f396f8b324b9412c540707b24 (patch) | |
| tree | 5181ccba94d773ab697539bc479cee220ea3a0fd /src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs | |
| parent | 8d339b4fb8e4f5d7710b99afb1e4b54341a8a72b (diff) | |
| download | living-village-e55edb080153943f396f8b324b9412c540707b24.tar.gz | |
p46: 菜单/启动环境动效 — 逐格涟漪(复用waterFrameTickAt)+灯笼呼吸+屋脊上双视差云影, 全纯函数可复现
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs | 176 |
1 files changed, 176 insertions, 0 deletions
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. +[<TestClass>] +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) + + [<TestMethod>] + 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) + + [<TestMethod>] + 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" + + [<TestMethod>] + 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") + + [<TestMethod>] + 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<int>(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) + + [<TestMethod>] + 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" + + [<TestMethod>] + 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" + + [<TestMethod>] + 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<int list>([ 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" + + [<TestMethod>] + 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) |
