summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 20:08:02 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 20:08:02 +0800
commite55edb080153943f396f8b324b9412c540707b24 (patch)
tree5181ccba94d773ab697539bc479cee220ea3a0fd /src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs
parent8d339b4fb8e4f5d7710b99afb1e4b54341a8a72b (diff)
downloadliving-village-e55edb080153943f396f8b324b9412c540707b24.tar.gz
p46: 菜单/启动环境动效 — 逐格涟漪(复用waterFrameTickAt)+灯笼呼吸+屋脊上双视差云影, 全纯函数可复现
Diffstat (limited to 'src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs')
-rw-r--r--src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs176
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)