summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 18:48:05 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 18:48:05 +0800
commitd3a2604e30b1deb91afebf550a79cde71bbca6d6 (patch)
tree7cf10505e76f5a6749faadb96ff4d678a3c8fb26 /src
parent323552b7e49550d378d3871fadcb711c16c71fed (diff)
downloadliving-village-d3a2604e30b1deb91afebf550a79cde71bbca6d6.tar.gz
p42: 启动/菜单水面涟漪整改 — 振幅0.32+波峰列, 帧差14.3, 防降振回归 (leader fix: 提交+推送)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P42LaunchFxTests.fs79
-rw-r--r--src/LivingVillage.Desktop/Game.fs87
-rw-r--r--src/LivingVillage.Desktop/LaunchScreen.fs12
-rw-r--r--src/LivingVillage.Desktop/TitleScreen.fs32
5 files changed, 200 insertions, 11 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 46bede8..03d63bd 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -21,6 +21,7 @@
<Compile Include="P38HudIconTests.fs" />
<Compile Include="P39ArtTests.fs" />
<Compile Include="P40ArtTests.fs" />
+ <Compile Include="P42LaunchFxTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P42LaunchFxTests.fs b/src/LivingVillage.Desktop.Tests/P42LaunchFxTests.fs
new file mode 100644
index 0000000..9c1fe6d
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P42LaunchFxTests.fs
@@ -0,0 +1,79 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+
+/// P42 launch/menu environment FX:
+/// * water ripples whose brightness phase is a pure function of (frame, column, row);
+/// * eave/bank lantern breathing glow through the shared P14 premultiplied-alpha path;
+/// * a deterministic shot table for the LV_P42_SHOT evidence hook.
+/// Every function is frame-driven, so re-rendering the same frame is byte-identical.
+[<TestClass>]
+type P42LaunchFxTests () =
+
+ [<TestMethod>]
+ member _.RippleAndBreathAreDeterministicForTheSameFrame () =
+ for frame in [ 0L; 1L; 17L; 30L; 150L; 4097L ] do
+ for column in [ 0; 1; 7; 39 ] do
+ for rowIndex in [ 0; 1 ] do
+ Assert.AreEqual<float32>(
+ TitleScreen.waterRippleBrightness frame column rowIndex,
+ TitleScreen.waterRippleBrightness frame column rowIndex)
+ Assert.AreEqual<int>(
+ TitleScreen.waterRippleLift frame column rowIndex,
+ TitleScreen.waterRippleLift frame column rowIndex)
+ for index in [ 0; 1; 3 ] do
+ Assert.AreEqual<int>(
+ TitleScreen.lanternBreathAlpha frame index,
+ TitleScreen.lanternBreathAlpha frame index)
+
+ [<TestMethod>]
+ member _.RipplePhaseMovesWithTheFrame () =
+ // The same column changes brightness as the frame advances (the ripple travels),
+ // and adjacent columns differ at a fixed frame (the ripple is per-tile).
+ let values = [ 0L .. 24L ] |> List.map (fun f -> TitleScreen.waterRippleBrightness f 5 0)
+ Assert.IsTrue(values |> List.distinct |> List.length > 1, "ripple must move with the frame")
+ let atFrame = [ 0 .. 12 ] |> List.map (fun c -> TitleScreen.waterRippleBrightness 40L c 0)
+ Assert.IsTrue(atFrame |> List.distinct |> List.length > 1, "ripple must vary across columns")
+ // Over a full period it swings both above and below the base brightness.
+ let overPeriod = [ for f in 0L .. 80L -> TitleScreen.waterRippleBrightness f 5 0 ]
+ Assert.IsTrue(overPeriod |> List.exists (fun v -> v > 1.0f) && overPeriod |> List.exists (fun v -> v < 1.0f),
+ "ripple must swing above and below the base brightness")
+
+ [<TestMethod>]
+ member _.BreathAmplitudeIsBoundedAndVaries () =
+ let samples =
+ [ for frame in 0L .. 120L do
+ for index in 0 .. 5 do
+ yield TitleScreen.lanternBreathAlpha frame index ]
+ Assert.IsTrue(samples |> List.forall (fun a -> a >= 40 && a <= 96),
+ "breath alpha must stay in [40, 96] so it never goes dark or saturates")
+ Assert.IsTrue(samples |> List.distinct |> List.length > 1, "breath must vary with frame/index")
+ // Out-of-step lanterns: two adjacent lights differ at the same frame.
+ Assert.AreNotEqual<int>(TitleScreen.lanternBreathAlpha 50L 0, TitleScreen.lanternBreathAlpha 50L 1)
+
+ [<TestMethod>]
+ member _.P42ShotTableIsOrderedAndNamesAreDistinct () =
+ Assert.IsTrue(P42ShotScript.splashEarlyFrame < P42ShotScript.splashLateFrame)
+ Assert.IsTrue(LaunchScreen.revealProgress P42ShotScript.splashEarlyFrame > 0.0f)
+ Assert.IsTrue(LaunchScreen.revealProgress P42ShotScript.splashLateFrame < 1.0f,
+ "both splash FX frames must still show the water band")
+ Assert.IsTrue(P42ShotScript.menuFrame >= 120L, "menu shot must wait for the entrance to settle")
+ let names = [ P42ShotScript.splashEarlyName; P42ShotScript.splashLateName; P42ShotScript.menuName ]
+ Assert.AreEqual<int>(names.Length, names |> List.distinct |> List.length)
+
+ [<TestMethod>]
+ member _.RippleAmplitudeStaysAboveVisibilityBudget () =
+ // Guards against a regression that quietly lowers the ripple back below visibility.
+ Assert.IsTrue(TitleScreen.waterRippleAmplitude >= 0.25f,
+ "ripple amplitude must stay >= 0.25 to read at 1280x720 (1x)")
+ let samples =
+ [ for frame in 0L .. 40L do
+ for column in 0 .. 39 do
+ yield TitleScreen.waterRippleBrightness frame column 0 ]
+ let peakToPeak = (List.max samples) - (List.min samples)
+ Assert.IsTrue(peakToPeak >= 0.5f, sprintf "ripple peak-to-peak %.3f must be >= 0.50" peakToPeak)
+ // Adjacent 28px tile columns must differ enough to be visible at 1x.
+ let brightness = TitleScreen.waterRippleBrightness 150L
+ let adjacent = [ for column in 0 .. 38 -> abs (brightness column 0 - brightness (column + 1) 0) ]
+ Assert.IsTrue(List.max adjacent >= 0.2f, sprintf "max adjacent-column ripple step %.3f must be >= 0.20" (List.max adjacent))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 94cc451..121a090 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -301,6 +301,11 @@ type LivingVillageGame() as this =
let mutable p40Step = 0
let mutable p40PendingName = ""
let mutable p40Pending = false
+ // P42 证据钩子:LV_P42_SHOT=1 时拍启动环境动效两帧 + 菜单动效一帧后退出;
+ // 全程走正常启动/菜单流程(不改 duration/结构/输入),仅按帧表落盘。
+ let p42ShotMode = Environment.GetEnvironmentVariable("LV_P42_SHOT") = "1"
+ let mutable p42SplashEarlyDone = false
+ let mutable p42SplashLateDone = false
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -1203,6 +1208,24 @@ type LivingVillageGame() as this =
printfn "menu-shot=done frames=%d" menuShotFrame
this.Exit()
+ /// P42 证据:启动环境动效两帧 + 菜单动效一帧,拍完即退出。
+ member private this.CaptureP42Shot () =
+ let save (name: string) =
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name)
+ printfn "p42-shot=%s frame=%d" name (if splashActive then splashFrame else menuEntranceFrame)
+ if splashActive then
+ if not p42SplashEarlyDone && splashFrame >= P42ShotScript.splashEarlyFrame then
+ p42SplashEarlyDone <- true
+ save P42ShotScript.splashEarlyName
+ if not p42SplashLateDone && splashFrame >= P42ShotScript.splashLateFrame then
+ p42SplashLateDone <- true
+ save P42ShotScript.splashLateName
+ elif menu.Page <> Playing && menuEntranceFrame >= P42ShotScript.menuFrame then
+ save P42ShotScript.menuName
+ printfn "p42-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 =
@@ -1445,6 +1468,8 @@ type LivingVillageGame() as this =
this.SaveBackBuffer(sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex)
if menuShotMode then
this.CaptureMenuShot()
+ if p42ShotMode then
+ this.CaptureP42Shot()
if storyShotMode && storyShotPrepared then
storyShotFrame <- storyShotFrame + 1L
if not storyShotSaved && storyShotFrame = 20L then
@@ -1489,11 +1514,35 @@ type LivingVillageGame() as this =
spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.RidgeY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (fst TitleScreen.roofSlots), Color.White)
spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White)
- // 水面色带反射天色,随显现度提高而淡出。
- let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - reveal)))
- for row in layout.WaterRows do
+ // 水面色带反射天色,随显现度提高而淡出;P42 叠加逐格涟漪(相位随帧/列/行)。
+ let waterAlpha = int (255.0f * (1.0f - reveal))
+ let waterColor = Color(150, 170, 178, waterAlpha)
+ for (rowIndex, row) in List.indexed layout.WaterRows do
for x in 0 .. Sim.tilePixels .. width - 1 do
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 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
+ spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor)
+ if TitleScreen.waterRippleCrest 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
+ 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))
+ 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))
// 标题:淡入 + 上浮,先画深色投影再画亮面。
let titleRise = int (24.0f * (1.0f - reveal))
@@ -1575,16 +1624,32 @@ type LivingVillageGame() as this =
spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + skylineDrop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White))
layout.RoofTileSlots
- // Water band drawn from the atlas water tile, reflecting the skyline above.
- let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - entrance)))
- for row in layout.WaterRows do
+ // Water band drawn from the atlas water tile; P42 adds per-tile ripple.
+ let waterAlpha = int (255.0f * (1.0f - entrance))
+ let waterColor = Color(150, 170, 178, waterAlpha)
+ for (rowIndex, row) in List.indexed layout.WaterRows do
for x in 0 .. Sim.tilePixels .. width - 1 do
spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor)
-
- // Lantern glows along the water bank.
- let lanternColor, lanternHalo = TitleScreen.lanternColors
- for (lx, ly) in layout.Lanterns do
- spriteBatch.Draw(pixel, Rectangle(lx - 8, ly - 8, 24, 24), Color(lanternHalo.R, lanternHalo.G, lanternHalo.B, 70))
+ for x in 0 .. Sim.tilePixels .. width - 1 do
+ let column = x / Sim.tilePixels
+ let brightness = TitleScreen.waterRippleBrightness 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
+ spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor)
+ if TitleScreen.waterRippleCrest 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
+ 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))
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))
diff --git a/src/LivingVillage.Desktop/LaunchScreen.fs b/src/LivingVillage.Desktop/LaunchScreen.fs
index 0fecc6d..7fbee8e 100644
--- a/src/LivingVillage.Desktop/LaunchScreen.fs
+++ b/src/LivingVillage.Desktop/LaunchScreen.fs
@@ -45,3 +45,15 @@ module MenuShotScript =
let downFrame = 140L
let loadFrame = 150L
let exitFrame = 170L
+
+/// P42 取证:环境动效证据帧表(60Hz 帧号纯数据,Game 与回归测试共用)。
+/// splash 两帧都落在水面尚可见的阶段(reveal<1),菜单帧取入场完成后的动画相位。
+module P42ShotScript =
+
+ let splashEarlyName = "p42-splash-1"
+ let splashLateName = "p42-splash-2"
+ let menuName = "p42-menu-anim"
+
+ let splashEarlyFrame = 10L
+ let splashLateFrame = 28L
+ let menuFrame = 150L
diff --git a/src/LivingVillage.Desktop/TitleScreen.fs b/src/LivingVillage.Desktop/TitleScreen.fs
index 0dcd693..2e760ef 100644
--- a/src/LivingVillage.Desktop/TitleScreen.fs
+++ b/src/LivingVillage.Desktop/TitleScreen.fs
@@ -88,6 +88,38 @@ module TitleScreen =
if frameCount >= 120L then 0.0f
else 1.0f - float32 frameCount / 120.0f
+ // ---- P42 environment FX: all pure functions of the frame counter, so re-rendering
+ // the same frame is byte-identical. Shared by the splash and the start menu. ----
+
+ /// Sine ripple amplitude of the water band. Kept as a named constant so a test can
+ /// assert it never regresses below the full-frame visibility budget (>= 0.25).
+ let waterRippleAmplitude = 0.32f
+
+ /// Water ripple phase: moves with the frame and is dense across tile columns
+ /// (~5 tiles per wave), so adjacent 28px columns differ enough to read at 1x.
+ let waterRipplePhase (frame: int64) (column: int) (rowIndex: int) : float32 =
+ float32 (frame % 4096L) / 5.0f + float32 column * 1.25f + float32 rowIndex * 0.6f
+
+ /// Water ripple brightness per tile column: base 1.0 +/- waterRippleAmplitude sine.
+ let waterRippleBrightness (frame: int64) (column: int) (rowIndex: int) : float32 =
+ 1.0f + waterRippleAmplitude * sin (waterRipplePhase frame column rowIndex)
+
+ /// Water crest lift in whole pixels (-2..2) for a tile column; drives the moving
+ /// ripple highlight without ever leaving a hole in the water band.
+ let waterRippleLift (frame: int64) (column: int) (rowIndex: int) : int =
+ int (System.Math.Round(float (sin (waterRipplePhase frame column rowIndex) * 2.0f), System.MidpointRounding.AwayFromZero))
+
+ /// True on columns sitting on a wave crest; the renderer caps these with a bright
+ /// highlight and the frame analysis counts them.
+ let waterRippleCrest (frame: int64) (column: int) (rowIndex: int) : bool =
+ sin (waterRipplePhase frame column rowIndex) > 0.55f
+
+ /// Eave-lantern breathing glow alpha, bounded to [40, 96]; phase moves with the
+ /// frame and the lantern index so the bank lanterns breathe out of step.
+ let lanternBreathAlpha (frame: int64) (index: int) : int =
+ let phase = float32 (frame % 4096L) / 22.0f + float32 index * 1.3f
+ 68 + int (System.Math.Round(float (sin phase * 28.0f), System.MidpointRounding.AwayFromZero))
+
let layout (viewportWidth: int) (viewportHeight: int) : TitleLayout =
let width = max 320 viewportWidth
let height = max 240 viewportHeight