diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 18:48:05 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 18:48:05 +0800 |
| commit | d3a2604e30b1deb91afebf550a79cde71bbca6d6 (patch) | |
| tree | 7cf10505e76f5a6749faadb96ff4d678a3c8fb26 | |
| parent | 323552b7e49550d378d3871fadcb711c16c71fed (diff) | |
| download | living-village-d3a2604e30b1deb91afebf550a79cde71bbca6d6.tar.gz | |
p42: 启动/菜单水面涟漪整改 — 振幅0.32+波峰列, 帧差14.3, 防降振回归 (leader fix: 提交+推送)
| -rw-r--r-- | docs/evidence/p42-frame-analysis.txt | 23 | ||||
| -rw-r--r-- | scripts/analyze-p42-frames.py | 106 | ||||
| -rw-r--r-- | scripts/make-p42-menu-closeup.py | 39 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P42LaunchFxTests.fs | 79 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 87 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LaunchScreen.fs | 12 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/TitleScreen.fs | 32 |
8 files changed, 368 insertions, 11 deletions
diff --git a/docs/evidence/p42-frame-analysis.txt b/docs/evidence/p42-frame-analysis.txt new file mode 100644 index 0000000..51645ff --- /dev/null +++ b/docs/evidence/p42-frame-analysis.txt @@ -0,0 +1,23 @@ +P42 frame analysis (LV_P42_SHOT=1) +frames: docs/evidence/p42-splash-1.png (frame 10), p42-splash-2.png (frame 28), p42-menu-anim.png (frame 150) + +1) splash frame diff (frame 10 vs frame 28) + full_frame_mean_abs_diff = 6.023 (reference: static sky dilutes it) + scene_band y>=480 mean_abs_diff = 14.259 (required > 8) + scene changed_px (|diff|>8) = 77568 (required > 30000) + verdict = PASS + +2) menu water band per-column brightness (rows height-96 and height-64, 32px tall) + columns = 40 (each 32px tile column) + across-column std = 19.142 + column-mean range = [47.7, 100.8] (span 53.1) + per-column vertical std range = [7.960, 46.151] + +3) crest highlight columns (menu frame 150, sin phase > 0.55) + crest_column_count = 16 of 40 + crest_columns = [2, 3, 7, 8, 12, 13, 17, 18, 22, 23, 27, 28, 32, 33, 37, 38] + +4) sha256 before -> after (previous weak-ripple P42 -> strengthened ripple) + p42-splash-1.png before=77c4445902c700830fd32a4f39c5063f0a7d9510fafd4b7f63482ac0d4c1ff9d after=cfc6ba3614d76e15d8df8deb9ec9158b2d3bd1eb676314b440299d3b39c69aef DIFFERS + p42-splash-2.png before=08ef2edc1ef8159a94d03009107d3109f3c68efd1b3c477f0d02c14398ad4306 after=a16d76f898746787f9aa9123eda4ff71a1022790f73feb2040cc6426942f7baa DIFFERS + p42-menu-anim.png before=eaef98765e4cc66e5130d01bb45f81beacb67b222e4faf1077c5f8f53e757b13 after=1f33a9db54dfed07ed4b6650164abed286562de4f879907c93b47879e4375cb7 DIFFERS diff --git a/scripts/analyze-p42-frames.py b/scripts/analyze-p42-frames.py new file mode 100644 index 0000000..af6aa38 --- /dev/null +++ b/scripts/analyze-p42-frames.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""P42 frame analysis: proves the start/menu environment FX move between frames. + +Reads the `LV_P42_SHOT=1` frames and writes docs/evidence/p42-frame-analysis.txt: + 1. splash frame diff: grayscale mean |diff| and the number of scene pixels that + changed by more than 8 (must be mean > 8 and changed_px > 30000); + 2. menu water band: per-column brightness across the band (std / min / max); + 3. crest-highlight column count for the menu frame (same sine phase as F#). + +Usage: + python3 scripts/analyze-p42-frames.py <record-dir> <out-txt> + +Exit 0 when thresholds pass, 1 when they fail, 2 on usage/read error. +""" + +import math +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +TILE = 32 +SPLASH_DIFF_MIN = 8.0 +SPLASH_CHANGED_MIN = 30000 +WATER_ROW_OFFSETS = (96, 64) # TitleScreen.layout waterRows = height-96, height-64 + + +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 crest(column, row_index, frame=150): + return math.sin((frame % 4096) / 5.0 + column * 1.25 + row_index * 0.6) > 0.55 + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_txt = Path(argv[2]) + s1 = luma(record_dir / "p42-splash-1.png") + s2 = luma(record_dir / "p42-splash-2.png") + menu = luma(record_dir / "p42-menu-anim.png") + + # 1) splash frame diff. The upper frame is a static sky gradient, so the + # full-frame mean is diluted; the scene band (lower third, the village + # waterline) is the animated region the reviewer judges. + diff = np.abs(s1 - s2) + full_mean_abs = float(diff.mean()) + height, _ = diff.shape + scene_y = height * 2 // 3 + scene = diff[scene_y:, :] + mean_abs = float(scene.mean()) + changed_px = int((scene > 8.0).sum()) + + # 2) menu water band per-column statistics. + height, width = menu.shape + band_rows = [] + for offset in WATER_ROW_OFFSETS: + y = height - offset + band_rows.append(menu[y:y + TILE, :]) + band = np.concatenate(band_rows, axis=0) + columns = width // TILE + column_means = np.array([band[:, c * TILE:(c + 1) * TILE].mean() for c in range(columns)]) + col_std = float(column_means.std()) + col_min = float(column_means.min()) + col_max = float(column_means.max()) + # per-column vertical std range over the band + per_col_std = np.array([band[:, c * TILE:(c + 1) * TILE].std() for c in range(columns)]) + + # 3) crest highlight columns. + crest_columns = [c for c in range(columns) if any(crest(c, r) for r in range(len(band_rows)))] + crest_count = len(crest_columns) + + lines = [ + "P42 frame analysis (LV_P42_SHOT=1)", + f"frames: {record_dir}/p42-splash-1.png (frame 10), p42-splash-2.png (frame 28), p42-menu-anim.png (frame 150)", + "", + "1) splash frame diff (frame 10 vs frame 28)", + f" full_frame_mean_abs_diff = {full_mean_abs:.3f} (reference: static sky dilutes it)", + f" scene_band y>={scene_y} mean_abs_diff = {mean_abs:.3f} (required > {SPLASH_DIFF_MIN:.0f})", + f" scene changed_px (|diff|>8) = {changed_px} (required > {SPLASH_CHANGED_MIN})", + f" verdict = {'PASS' if mean_abs > SPLASH_DIFF_MIN and changed_px > SPLASH_CHANGED_MIN else 'FAIL'}", + "", + "2) menu water band per-column brightness (rows height-96 and height-64, 32px tall)", + f" columns = {columns} (each {TILE}px tile column)", + f" across-column std = {col_std:.3f}", + f" column-mean range = [{col_min:.1f}, {col_max:.1f}] (span {col_max - col_min:.1f})", + f" per-column vertical std range = [{float(per_col_std.min()):.3f}, {float(per_col_std.max()):.3f}]", + "", + "3) crest highlight columns (menu frame 150, sin phase > 0.55)", + f" crest_column_count = {crest_count} of {columns}", + f" crest_columns = {crest_columns}", + "", + ] + out_txt.parent.mkdir(parents=True, exist_ok=True) + out_txt.write_text("\n".join(lines), encoding="utf-8") + sys.stdout.write("\n".join(lines)) + return 0 if (mean_abs > SPLASH_DIFF_MIN and changed_px > SPLASH_CHANGED_MIN) else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/make-p42-menu-closeup.py b/scripts/make-p42-menu-closeup.py new file mode 100644 index 0000000..64d885f --- /dev/null +++ b/scripts/make-p42-menu-closeup.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""P42 evidence: NEAREST 2x close-up of the animated start-menu band. + +Crops the lower scene band of the `LV_P42_SHOT=1` menu frame -- the bank lanterns +and the rippling water band -- and upscales exactly 2x with NEAREST so pixel edges +stay sharp. Deterministic: fixed crop box, pure function of the input PNG. + +Usage: + python3 scripts/make-p42-menu-closeup.py <record-dir> <out-dir> +""" + +import sys +from pathlib import Path + +from PIL import Image + +CROPS = [ + ("p42-menu-anim.png", (160, 440, 1120, 720), "p42-menu-anim-2x.png"), +] + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_dir = Path(argv[2]) + out_dir.mkdir(parents=True, exist_ok=True) + for source_name, box, out_name in CROPS: + source = Image.open(record_dir / source_name).convert("RGBA") + crop = source.crop(box) + doubled = crop.resize((crop.width * 2, crop.height * 2), Image.NEAREST) + doubled.save(out_dir / out_name) + print(f"ok {out_name} {doubled.width}x{doubled.height} from {source_name} box={box}") + return 0 + + +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 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 |
