summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p46-analysis.txt27
-rw-r--r--docs/evidence/p46-cloud-a.pngbin0 -> 55176 bytes
-rw-r--r--docs/evidence/p46-cloud-b.pngbin0 -> 55224 bytes
-rw-r--r--docs/evidence/p46-menu-lantern-a.pngbin0 -> 56599 bytes
-rw-r--r--docs/evidence/p46-menu-lantern-b.pngbin0 -> 56606 bytes
-rw-r--r--docs/evidence/p46-splash-ripple.pngbin0 -> 53954 bytes
-rw-r--r--scripts/analyze-p46.py158
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P46MenuAmbienceTests.fs176
-rw-r--r--src/LivingVillage.Desktop/Game.fs89
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/MenuAmbience.fs117
12 files changed, 561 insertions, 8 deletions
diff --git a/docs/evidence/p46-analysis.txt b/docs/evidence/p46-analysis.txt
new file mode 100644
index 0000000..3abe03c
--- /dev/null
+++ b/docs/evidence/p46-analysis.txt
@@ -0,0 +1,27 @@
+P46 menu/splash environment FX analysis (LV_P46_SHOT=1)
+
+1) 水面逐格涟漪 — per-column brightness std over the 2x32px water band
+ splash ripple frame: col_std=9.620 range=[88.7, 113.6] (required >= 3.0)
+ menu lantern-a : col_std=10.014 range=[60.1, 84.5]
+ menu lantern-b : col_std=10.264 range=[57.9, 82.5]
+ menu water row frame diff (a vs b) mean |d| = 31.958
+
+2) 灯笼呼吸光晕 — mean luma in a 88x88 box around bank lantern #1
+ lantern box = x[212,300) y[536,624)
+ phase a (peak) luma = 59.807
+ phase b (trough) luma = 56.025
+ luma delta = 3.781 (required >= 1.5)
+
+3) 屋脊上方视差云影 — horizontal shift of the sky band (0..43px)
+ measured shift = 39px
+ expected per layer (speeds (0.5, 1.0), frames 100->140) = [20, 40]px
+ dominant layer = 1 (alpha 30) -> expected 40px
+
+4) 无大黑块 / 不透明校验 (every evidence frame)
+ p46-splash-ripple: alpha_min=255 black_px(rgb<6)=0
+ p46-cloud-a: alpha_min=255 black_px(rgb<6)=0
+ p46-cloud-b: alpha_min=255 black_px(rgb<6)=0
+ p46-menu-lantern-a: alpha_min=255 black_px(rgb<6)=0
+ p46-menu-lantern-b: alpha_min=255 black_px(rgb<6)=0
+
+verdict = PASS
diff --git a/docs/evidence/p46-cloud-a.png b/docs/evidence/p46-cloud-a.png
new file mode 100644
index 0000000..b8cc539
--- /dev/null
+++ b/docs/evidence/p46-cloud-a.png
Binary files differ
diff --git a/docs/evidence/p46-cloud-b.png b/docs/evidence/p46-cloud-b.png
new file mode 100644
index 0000000..18bfc38
--- /dev/null
+++ b/docs/evidence/p46-cloud-b.png
Binary files differ
diff --git a/docs/evidence/p46-menu-lantern-a.png b/docs/evidence/p46-menu-lantern-a.png
new file mode 100644
index 0000000..42721e6
--- /dev/null
+++ b/docs/evidence/p46-menu-lantern-a.png
Binary files differ
diff --git a/docs/evidence/p46-menu-lantern-b.png b/docs/evidence/p46-menu-lantern-b.png
new file mode 100644
index 0000000..e712de1
--- /dev/null
+++ b/docs/evidence/p46-menu-lantern-b.png
Binary files differ
diff --git a/docs/evidence/p46-splash-ripple.png b/docs/evidence/p46-splash-ripple.png
new file mode 100644
index 0000000..0ffd637
--- /dev/null
+++ b/docs/evidence/p46-splash-ripple.png
Binary files differ
diff --git a/scripts/analyze-p46.py b/scripts/analyze-p46.py
new file mode 100644
index 0000000..a5828d5
--- /dev/null
+++ b/scripts/analyze-p46.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""P46 menu/splash environment-FX analysis.
+
+Reads the `LV_P46_SHOT=1` frames and writes docs/evidence/p46-analysis.txt with the
+three acceptance numbers, each measured from real screenshots:
+
+ 1. water band per-column std (逐格涟漪: splash ripple frame + menu night frames);
+ 2. lantern luma delta (灯笼呼吸两相位: menu peak vs trough);
+ 3. cloud shadow shift px (屋脊上方视差云影: cross-correlation of the sky band),
+ plus a no-black-block / opaque check on every frame.
+
+Usage:
+ python3 scripts/analyze-p46.py <record-dir> <out-txt>
+
+Exit 0 when all thresholds pass, 1 when they fail, 2 on usage/read error.
+"""
+
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+TILE = 32
+WATER_ROW_OFFSETS = (96, 64) # TitleScreen.layout waterRows = height-96, height-64
+SKY_BAND = 44 # clouds live above TitleScreen.layout RidgeY = 44
+
+WATER_COL_STD_MIN = 3.0
+LANTERN_LUMA_DELTA_MIN = 1.5
+CLOUD_SHIFT_TOL = 3
+
+# Mirrors MenuAmbience.cloud*: two layers, speeds 0.5 / 1.0 px per frame, alpha 12 / 30.
+CLOUD_SPEEDS = (0.5, 1.0)
+CLOUD_ALPHAS = (12, 30)
+CLOUD_EARLY_FRAME = 100
+CLOUD_LATE_FRAME = 140
+
+
+def luma(path):
+ a = np.asarray(Image.open(path).convert("RGB"), dtype=np.float64)
+ return 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2]
+
+
+def rgba(path):
+ return np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
+
+
+def water_col_std(image, height, width):
+ band = np.concatenate(
+ [image[height - off:height - off + TILE, :] for off in WATER_ROW_OFFSETS], axis=0
+ )
+ columns = np.array([band[:, c * TILE:(c + 1) * TILE].mean() for c in range(width // TILE)])
+ return float(columns.std()), float(columns.min()), float(columns.max())
+
+
+def expected_cloud_offsets(width):
+ span = width + 200
+ offsets = []
+ for speed in CLOUD_SPEEDS:
+ early = int(round(speed * CLOUD_EARLY_FRAME))
+ late = int(round(speed * CLOUD_LATE_FRAME))
+ offsets.append(((late - early) % span + span) % span)
+ return offsets
+
+
+def main(argv):
+ if len(argv) != 3:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ out_txt = Path(argv[2])
+
+ splash = luma(record_dir / "p46-splash-ripple.png")
+ menu_a = luma(record_dir / "p46-menu-lantern-a.png")
+ menu_b = luma(record_dir / "p46-menu-lantern-b.png")
+ cloud_a = luma(record_dir / "p46-cloud-a.png")
+ cloud_b = luma(record_dir / "p46-cloud-b.png")
+ height, width = splash.shape
+
+ failures = []
+ lines = ["P46 menu/splash environment FX analysis (LV_P46_SHOT=1)", ""]
+
+ # 1) water band per-column std
+ s_std, s_min, s_max = water_col_std(splash, height, width)
+ a_std, a_min, a_max = water_col_std(menu_a, height, width)
+ b_std, b_min, b_max = water_col_std(menu_b, height, width)
+ lines.append("1) 水面逐格涟漪 — per-column brightness std over the 2x32px water band")
+ lines.append(f" splash ripple frame: col_std={s_std:.3f} range=[{s_min:.1f}, {s_max:.1f}] (required >= {WATER_COL_STD_MIN})")
+ lines.append(f" menu lantern-a : col_std={a_std:.3f} range=[{a_min:.1f}, {a_max:.1f}]")
+ lines.append(f" menu lantern-b : col_std={b_std:.3f} range=[{b_min:.1f}, {b_max:.1f}]")
+ menu_water_diff = float(np.abs(menu_a[height - 96:height - 64, :] - menu_b[height - 96:height - 64, :]).mean())
+ lines.append(f" menu water row frame diff (a vs b) mean |d| = {menu_water_diff:.3f}")
+ if s_std < WATER_COL_STD_MIN:
+ failures.append(f"splash water col_std {s_std:.3f} < {WATER_COL_STD_MIN}")
+ if a_std < WATER_COL_STD_MIN:
+ failures.append(f"menu water col_std {a_std:.3f} < {WATER_COL_STD_MIN}")
+ lines.append("")
+
+ # 2) lantern luma delta. Bank lantern index 1 -> x = width/5, y = (height-96)-44.
+ lx, ly = width // 5, (height - 96) - 44
+ box = (slice(ly - 44, ly + 44), slice(lx - 44, lx + 44))
+ luma_a = float(menu_a[box].mean())
+ luma_b = float(menu_b[box].mean())
+ delta = abs(luma_a - luma_b)
+ lines.append("2) 灯笼呼吸光晕 — mean luma in a 88x88 box around bank lantern #1")
+ lines.append(f" lantern box = x[{lx-44},{lx+44}) y[{ly-44},{ly+44})")
+ lines.append(f" phase a (peak) luma = {luma_a:.3f}")
+ lines.append(f" phase b (trough) luma = {luma_b:.3f}")
+ lines.append(f" luma delta = {delta:.3f} (required >= {LANTERN_LUMA_DELTA_MIN})")
+ if delta < LANTERN_LUMA_DELTA_MIN:
+ failures.append(f"lantern luma delta {delta:.3f} < {LANTERN_LUMA_DELTA_MIN}")
+ lines.append("")
+
+ # 3) cloud shadow shift px, cross-correlating the sky band (clouds only live up there).
+ pa = cloud_a[:SKY_BAND, :].mean(axis=0)
+ pb = cloud_b[:SKY_BAND, :].mean(axis=0)
+ pa = pa - pa.mean()
+ pb = pb - pb.mean()
+ search = width // 8
+ sad = [float(np.mean((pb[s:] - pa[:width - s]) ** 2)) for s in range(search)]
+ measured = int(np.argmin(sad))
+ expected = expected_cloud_offsets(width)
+ dominant = int(np.argmax(CLOUD_ALPHAS))
+ lines.append("3) 屋脊上方视差云影 — horizontal shift of the sky band (0..43px)")
+ lines.append(f" measured shift = {measured}px")
+ lines.append(f" expected per layer (speeds {CLOUD_SPEEDS}, frames {CLOUD_EARLY_FRAME}->{CLOUD_LATE_FRAME}) = {expected}px")
+ lines.append(f" dominant layer = {dominant} (alpha {CLOUD_ALPHAS[dominant]}) -> expected {expected[dominant]}px")
+ if abs(measured - expected[dominant]) > CLOUD_SHIFT_TOL:
+ failures.append(f"cloud shift {measured} != dominant layer {expected[dominant]} (tol {CLOUD_SHIFT_TOL})")
+ if expected[0] == expected[1]:
+ failures.append("parallax layers must have different speeds")
+ lines.append("")
+
+ # no black block / opaque guard on every evidence frame.
+ names = ["p46-splash-ripple", "p46-cloud-a", "p46-cloud-b", "p46-menu-lantern-a", "p46-menu-lantern-b"]
+ lines.append("4) 无大黑块 / 不透明校验 (every evidence frame)")
+ for name in names:
+ a = rgba(record_dir / f"{name}.png")
+ alpha_min = int(a[:, :, 3].min())
+ black_px = int((a[:, :, :3].max(axis=2) < 6).sum())
+ lines.append(f" {name}: alpha_min={alpha_min} black_px(rgb<6)={black_px}")
+ if alpha_min < 250:
+ failures.append(f"{name} has transparent pixels (alpha_min {alpha_min})")
+ if black_px > 0:
+ failures.append(f"{name} has black pixels ({black_px})")
+ lines.append("")
+
+ lines.append("verdict = " + ("PASS" if not failures else "FAIL"))
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ sys.stdout.write("\n".join(lines) + "\n")
+ for f in failures:
+ sys.stderr.write(f"FAIL {f}\n")
+ return 0 if not failures else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv))
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 @@
<Compile Include="P43HudTests.fs" />
<Compile Include="P44DialogTests.fs" />
<Compile Include="P45CharacterAnimTests.fs" />
+ <Compile Include="P46MenuAmbienceTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
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)
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 @@
<Compile Include="M6Presentation.fs" />
<Compile Include="HudLayout.fs" />
<Compile Include="DialogOverlay.fs" />
+ <Compile Include="MenuAmbience.fs" />
<Compile Include="PerformanceSummary.fs" />
<Compile Include="TitleScreen.fs" />
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