diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P57MenuHighlightTests.fs | 105 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 97 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MenuAmbience.fs | 57 |
4 files changed, 260 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index a3c1e45..951e59b 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -33,6 +33,7 @@ <Compile Include="P53InteractionPromptTests.fs" /> <Compile Include="P54ProfessionDailyTests.fs" /> <Compile Include="P55ProfessionRewardTests.fs" /> + <Compile Include="P57MenuHighlightTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P57MenuHighlightTests.fs b/src/LivingVillage.Desktop.Tests/P57MenuHighlightTests.fs new file mode 100644 index 0000000..74f8eba --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P57MenuHighlightTests.fs @@ -0,0 +1,105 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop + +/// P57 美术 A3:菜单/启动水面第二层高光波光(P46 遗留短板)。 +/// +/// 高光层必须与主水带**可分辨**:更亮更窄、列相位错开、推进速度略不同; +/// 全为帧计数纯函数(无随机),且**不得改变**既有 waterPhase / lantern / 云影输出。 +[<TestClass>] +type P57MenuHighlightTests () = + + let twoPi = 2.0f * float32 System.Math.PI + let assertClose (expected: float32) (actual: float32) (message: string) = + Assert.IsTrue((abs (expected - actual) < 1e-4f), sprintf "%s: expected %f actual %f" message expected actual) + let assertInt (expected: int) (actual: int) (message: string) = + Assert.IsTrue((expected = actual), sprintf "%s: expected %d actual %d" message expected actual) + + let visibleColumn (frame: int64) (rowIndex: int) : int = + [ 0 .. 63 ] + |> List.tryFind (fun column -> MenuAmbience.highlightVisible frame column rowIndex) + |> Option.defaultValue -1 + + [<TestMethod>] + member _.HighlightPhaseOffsetsAdjacentColumnsByANonZeroStep () = + for frame in [ 0L; 16L; 150L; 4097L ] do + for rowIndex in [ 0; 1 ] do + for column in [ 0; 5; 21; 38 ] do + let step = + MenuAmbience.highlightPhase frame (column + 1) rowIndex + - MenuAmbience.highlightPhase frame column rowIndex + assertClose MenuAmbience.highlightColumnStep step (sprintf "frame %d col %d row %d" frame column rowIndex) + Assert.IsTrue(abs step > 1e-3f, "adjacent highlight columns must be phase-shifted") + + [<TestMethod>] + member _.HighlightMovesAtADifferentSpeedThanTheMainWaterBand () = + // 主水带连续相位速率 = (2π/3)/32;高光层 = 2π/period,二者比值应略偏离 1(本实现 1.2)。 + let mainDelta = + MenuAmbience.waterPhase 151L 5 0 - MenuAmbience.waterPhase 150L 5 0 + let highlightDelta = + MenuAmbience.highlightPhase 151L 5 0 - MenuAmbience.highlightPhase 150L 5 0 + Assert.IsTrue(highlightDelta > 0.0f && mainDelta > 0.0f, "both layers must advance with the frame") + let ratio = highlightDelta / mainDelta + assertClose 1.2f ratio "highlight speed must be slightly different (1.2x)" + Assert.IsTrue(abs (highlightDelta - mainDelta) > 1e-3f, "the two layers must not move at the same speed") + + [<TestMethod>] + member _.HighlightLayerIsBrighterAndNarrowerThanTheMainBand () = + // 更窄:条更薄、更短。 + Assert.IsTrue(MenuAmbience.highlightThickness < 3, "highlight bar must be thinner than the 3px main crest") + Assert.IsTrue(MenuAmbience.highlightBarWidth < 32, "highlight bar must be narrower than a tile") + // 更亮:纯白高光 > 主水带波峰色 (225,240,245) 的 luma。 + let r, g, b = MenuAmbience.highlightRgb + let highlightLuma = 0.299f * float32 r + 0.587f * float32 g + 0.114f * float32 b + let crestLuma = 0.299f * 225.0f + 0.587f * 240.0f + 0.114f * 245.0f + Assert.IsTrue(highlightLuma > crestLuma, sprintf "highlight luma %.1f must exceed crest luma %.1f" highlightLuma crestLuma) + // 更窄的可见条件(阈值更高 → 亮列更稀疏),但仍有可见列。 + let frame = 150L + let highlightCount = [ 0 .. 39 ] |> List.filter (fun c -> MenuAmbience.highlightVisible frame c 0) |> List.length + let mainCount = [ 0 .. 39 ] |> List.filter (fun c -> MenuAmbience.waterColumnCrest frame c 0) |> List.length + Assert.IsTrue(highlightCount > 0, "some highlight columns must be lit") + Assert.IsTrue(highlightCount <= mainCount, sprintf "highlight %d must be no denser than main crest %d" highlightCount mainCount) + + [<TestMethod>] + member _.HighlightIsDeterministicAndBounded () = + for frame in [ 0L; 1L; 33L; 173L; 99999L; 100000L; 500000L ] do + for column in [ 0; 13; 39 ] do + for rowIndex in [ 0; 1 ] do + assertClose + (MenuAmbience.highlightPhase frame column rowIndex) + (MenuAmbience.highlightPhase frame column rowIndex) + "highlight phase must be deterministic" + let alpha = MenuAmbience.highlightAlpha MenuAmbience.highlightDayLight frame column rowIndex + Assert.IsTrue(alpha >= 0 && alpha <= 255, sprintf "alpha %d out of [0,255]" alpha) + + [<TestMethod>] + member _.NightLightDimmsTheHighlightButKeepsItReadable () = + let frame = 150L + let column = visibleColumn frame 0 + Assert.IsTrue(column >= 0, "need a lit column to compare day/night") + let day = MenuAmbience.highlightAlpha MenuAmbience.highlightDayLight frame column 0 + let night = MenuAmbience.highlightAlpha MenuAmbience.highlightNightLight frame column 0 + Assert.IsTrue(night < day, sprintf "night alpha %d must be below day %d" night day) + Assert.IsTrue(night >= int (0.35f * float32 day), sprintf "night alpha %d must stay readable (>= 35%% of day)" night) + Assert.IsTrue(night >= 20, sprintf "night alpha %d must not vanish" night) + + [<TestMethod>] + member _.ExistingWaterLanternCloudOutputsArePinned () = + // 回归:高光层不得改变既有 waterPhase / lantern / 云影输出帧。 + assertInt 0 (VillageArt.waterFrameTickAt 150L 5 0) "waterFrameTickAt pin" + assertClose 1.317237f (MenuAmbience.waterColumnBrightness 150L 5 0) "main water brightness pin" + assertInt 89 (MenuAmbience.lanternBreathAlpha 50L 0) "lantern breath pin" + assertInt 70 (MenuAmbience.cloudOffsetPx 0 140L 1280) "cloud offset pin" + Assert.AreEqual<int list>([ 624; 656 ], (TitleScreen.layout 1280 720).WaterRows) + + [<TestMethod>] + member _.P57ShotScriptWaterFramesAreOrderedAndDistinct () = + Assert.IsTrue(P57ShotScript.splashFrame > 0L && P57ShotScript.splashFrame < LaunchScreen.durationFrames) + Assert.IsTrue(P57ShotScript.menuFrame >= 120L, "menu frame must wait for the entrance to settle") + let names = + [ P57ShotScript.splashBaseName + P57ShotScript.splashHighlightName + P57ShotScript.menuBaseName + P57ShotScript.menuHighlightName ] + assertInt names.Length (names |> List.distinct |> List.length) "shot names distinct" diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 8162351..b51c7f0 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -333,6 +333,14 @@ type LivingVillageGame() as this = let mutable p46CloudLateDone = false let mutable p46MenuADone = false let mutable p46MenuBDone = false + // P57 证据钩子:LV_P57_SHOT=1 时对同一 splash/menu 帧各拍「高光层关 / 开」两帧 + // (像素差即第二层高光贡献),并打印逐列主水带亮度/高光 alpha/相位供 analyze 计算。 + // p57HighlightDisabled 仅供取证时开关高光层,缺省 false(正常渲染始终开启)。 + let p57ShotMode = Environment.GetEnvironmentVariable("LV_P57_SHOT") = "1" + let mutable p57HighlightDisabled = false + let mutable p57Step = 0 + let mutable p57PendingName = "" + let mutable p57Pending = false // P47 证据钩子:LV_P47_SHOT=1 时在真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧。 let p47ShotMode = Environment.GetEnvironmentVariable("LV_P47_SHOT") = "1" let mutable p47Step = 0 @@ -1030,6 +1038,62 @@ type LivingVillageGame() as this = else p46MenuBDone <- true save p46PendingName + /// P57 取证:把当前水面帧的逐列主水带亮度/高光 alpha/两层相位打印成逗号数组。 + member private this.LogP57Columns (name: string) = + let frame = if splashActive then splashFrame else menuEntranceFrame + let columns = [ 0 .. 39 ] + let fmt (values: float32 list) = + values |> List.map (fun v -> sprintf "%.4f" v) |> String.concat "," + let main = columns |> List.map (fun c -> MenuAmbience.waterColumnBrightness frame c 0) + let hl = columns |> List.map (fun c -> float32 (MenuAmbience.highlightAlpha MenuAmbience.highlightDayLight frame c 0)) + let mainPhase = columns |> List.map (fun c -> MenuAmbience.waterPhase frame c 0) + let hlPhase = columns |> List.map (fun c -> MenuAmbience.highlightPhase frame c 0) + printfn "p57-status=%s pendingName=%s disabled=%b frame=%d main=%s hl=%s mainphase=%s hlphase=%s" + name (if String.IsNullOrEmpty p57PendingName then "-" else p57PendingName) p57HighlightDisabled frame + (fmt main) (fmt hl) (fmt mainPhase) (fmt hlPhase) + + /// P57 证据:Splash 阶段按帧表拍「高光层关 / 开」两帧。 + member private this.PrepareP57Splash () = + if not p57Pending then + match p57Step with + | 0 -> + p57HighlightDisabled <- true + p57PendingName <- P57ShotScript.splashBaseName + p57Pending <- true + | 1 -> + p57HighlightDisabled <- false + p57PendingName <- P57ShotScript.splashHighlightName + p57Pending <- true + | _ -> () + + /// P57 证据:菜单阶段拍「高光层关 / 开」两帧后收尾。 + member private this.PrepareP57Shot () = + if p57Step >= 4 then + printfn "p57-shot=done" + this.Exit() + elif not p57Pending then + match p57Step with + | 2 -> + menuEntranceFrame <- P57ShotScript.menuFrame + p57HighlightDisabled <- true + p57PendingName <- P57ShotScript.menuBaseName + p57Pending <- true + | 3 -> + menuEntranceFrame <- P57ShotScript.menuFrame + p57HighlightDisabled <- false + p57PendingName <- P57ShotScript.menuHighlightName + p57Pending <- true + | _ -> () + + /// P57 证据:Draw 末尾落盘并打印逐列数字。 + member private this.CaptureP57Shot () = + if p57Pending then + p57Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p57PendingName) + this.LogP57Columns p57PendingName + p57Step <- p57Step + 1 + /// P47 证据:真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧,含 P47 场景细节。 member private this.PrepareP47Shot () = let setHour (hour: float) = @@ -1829,6 +1893,9 @@ type LivingVillageGame() as this = match target with | Some t -> splashFrame <- t | None -> () + if p57ShotMode && splashFrame >= 10L && p57Step < 2 then + splashFrame <- P57ShotScript.splashFrame + this.PrepareP57Splash() let skip = LaunchScreen.skipRequested (pressed Keys.Escape) (pressed Keys.Space) if skip || LaunchScreen.isComplete splashFrame then splashActive <- false @@ -1886,6 +1953,9 @@ type LivingVillageGame() as this = elif p46ShotMode then if menu.Page = MainMenu then this.PrepareP46Shot() + elif p57ShotMode then + if menu.Page = MainMenu then + this.PrepareP57Shot() elif p47ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm @@ -1940,6 +2010,9 @@ type LivingVillageGame() as this = this.UpdateMenu pressed if menu.Page <> Playing then menuEntranceFrame <- menuEntranceFrame + 1L + // P57 取证:钉住菜单水面帧,令「高光层关/开」两帧渲染同一帧号(差即高光层)。 + if p57ShotMode && menu.Page = MainMenu && p57Step <= 3 then + menuEntranceFrame <- P57ShotScript.menuFrame + 1L match pauseHelpControl with | Some original when menu.Page = Playing -> let _, restoredControl = MenuState.exitPauseHelp original menu @@ -2343,6 +2416,8 @@ type LivingVillageGame() as this = this.CaptureP45Shot() if p46ShotMode then this.CaptureP46Shot() + if p57ShotMode then + this.CaptureP57Shot() if p47ShotMode && menu.Page = Playing then this.CaptureP47Shot() if p52ShotMode && menu.Page = Playing then @@ -2406,6 +2481,17 @@ type LivingVillageGame() as this = spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) if MenuAmbience.waterColumnCrest splashFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) + // P57 第二层高光波光:同列相位错开、更亮更窄的细高光条(昼/夜由 light 衰减)。 + if not p57HighlightDisabled && MenuAmbience.highlightVisible splashFrame column rowIndex then + let hr, hg, hb = MenuAmbience.highlightRgb + let ha = + MenuAmbience.highlightAlpha MenuAmbience.highlightDayLight splashFrame column rowIndex + * waterAlpha / 255 + let barX = x + (Sim.tilePixels - MenuAmbience.highlightBarWidth) / 2 + spriteBatch.Draw( + pixel, + Rectangle(barX, row + lift + MenuAmbience.highlightRowDelta, MenuAmbience.highlightBarWidth, MenuAmbience.highlightThickness), + Color(hr, hg, hb, ha)) // P42 屋檐灯笼呼吸光晕:复用 P14 预乘 alpha 径向路径,随帧与灯序号呼吸。 let lanternColor, _ = TitleScreen.lanternColors @@ -2516,6 +2602,17 @@ type LivingVillageGame() as this = spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) if MenuAmbience.waterColumnCrest menuEntranceFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) + // P57 第二层高光波光(menu)。 + if not p57HighlightDisabled && MenuAmbience.highlightVisible menuEntranceFrame column rowIndex then + let hr, hg, hb = MenuAmbience.highlightRgb + let ha = + MenuAmbience.highlightAlpha MenuAmbience.highlightDayLight menuEntranceFrame column rowIndex + * waterAlpha / 255 + let barX = x + (Sim.tilePixels - MenuAmbience.highlightBarWidth) / 2 + spriteBatch.Draw( + pixel, + Rectangle(barX, row + lift + MenuAmbience.highlightRowDelta, MenuAmbience.highlightBarWidth, MenuAmbience.highlightThickness), + Color(hr, hg, hb, ha)) // P42 bank lanterns breathe through the shared P14 premultiplied-alpha radial path. let lanternColor, _ = TitleScreen.lanternColors diff --git a/src/LivingVillage.Desktop/MenuAmbience.fs b/src/LivingVillage.Desktop/MenuAmbience.fs index 74fefeb..6d258c5 100644 --- a/src/LivingVillage.Desktop/MenuAmbience.fs +++ b/src/LivingVillage.Desktop/MenuAmbience.fs @@ -41,6 +41,49 @@ module MenuAmbience = let waterColumnCrest (frame: int64) (column: int) (rowIndex: int) : bool = waterWave frame column rowIndex > 0.7f + // ---------------- ①.5 第二层高光波光(P57) ---------------- + // P46 验收遗留:主水带波光层次偏弱。在**主水带之上**再叠一层细高光条: + // * 更快/更细:周期 80 tick(主水带连续相位 96 tick),故推进速度略不同; + // * 逐列相位错开:相邻列固定差 highlightColumnStep(=2.4 rad,非零且异于主水带的 120°); + // * 更亮:纯白 (255,255,255),亮于主水带波峰色 (225,240,245); + // * 更窄:条高 2px(主波峰 3px)、段宽 20px(满格 32px),且亮列阈值更高(0.86 > 0.7)。 + // 全为帧计数纯函数:无随机、无时钟;`light` 参数用于昼夜 tint 衰减(日场 1.0,夜场 <1)。 + + let highlightPeriodTicks = 80.0f + let highlightColumnStep = 2.4f + let highlightRowStep = 0.9f + let highlightCrestThreshold = 0.86f + let highlightThickness = 2 + let highlightBarWidth = 20 + let highlightRowDelta = 1 + let highlightRgb : int * int * int = (255, 255, 255) + let highlightMaxAlpha = 220 + let highlightDayLight = 1.0f + let highlightNightLight = 0.42f + + /// 高光层相位(弧度)。帧推进用 highlightPeriodTicks(与主水带的 96 tick 不同), + /// 逐列差 highlightColumnStep、逐行差 highlightRowStep;帧号取模保持有界、无浮点漂移。 + let highlightPhase (frame: int64) (column: int) (rowIndex: int) : float32 = + let t = float32 (frame % 100000L) + (t / highlightPeriodTicks) * (2.0f * float32 Math.PI) + + float32 column * highlightColumnStep + + float32 rowIndex * highlightRowStep + + /// 高光层正弦波值,范围 [-1, 1]。 + let highlightWave (frame: int64) (column: int) (rowIndex: int) : float32 = + sin (highlightPhase frame column rowIndex) + + /// 该列是否出现细高光条(阈值比主水带高 → 更稀疏)。 + let highlightVisible (frame: int64) (column: int) (rowIndex: int) : bool = + highlightWave frame column rowIndex > highlightCrestThreshold + + /// 高光条 alpha:波峰处最亮,随 `light`(昼夜 tint)线性衰减,钳 [0,255]。 + let highlightAlpha (light: float32) (frame: int64) (column: int) (rowIndex: int) : int = + let wave = highlightWave frame column rowIndex + let raw = float32 highlightMaxAlpha * light * (0.6f + 0.4f * wave) + let rounded = int (Math.Round(float raw, MidpointRounding.AwayFromZero)) + max 0 (min 255 rounded) + // ---------------- ② 灯笼呼吸光晕 ---------------- /// 灯笼呼吸 alpha,界于 [40, 96];相位随帧与灯序号推进,邻灯不同步。 @@ -115,3 +158,17 @@ module P46ShotScript = // menuEntranceFrame 在 Update 末尾 +1,故设置值取目标相位帧的前一帧。 let menuPeakFrame = 172L let menuTroughFrame = 241L + +/// P57 取证:第二层高光波光证据帧表(60Hz 帧号纯数据,Game 与回归测试共用)。 +/// 对同一帧各拍「高光层关 / 开」两帧,像素差即高光层贡献;splash 与 menu 各一组。 +module P57ShotScript = + + let splashBaseName = "p57-splash-base" + let splashHighlightName = "p57-splash-highlight" + let menuBaseName = "p57-menu-base" + let menuHighlightName = "p57-menu-highlight" + + /// splash 水面仍可见(reveal<1)。 + let splashFrame = 16L + /// menu 入场完成后的稳定帧(>=120)。 + let menuFrame = 180L |
