summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 11:08:32 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 11:08:32 +0800
commit837fd81a2bd857011855ca1dc32e646629f14236 (patch)
tree896e33d5948bb5457cf0dd17494792f9aa631cd2
parentf3ad37b1d057566a9e5e01a7ee9850eb1c4598f4 (diff)
downloadliving-village-837fd81a2bd857011855ca1dc32e646629f14236.tar.gz
p74: 启动开场过渡(水墨式渐显)+ 开始界面布局回归
新增纯函数 InkReveal.fs(inkStrength ease-out 消散 + 6 滴预乘墨滴 dabs),DrawSplash 在场景层之上、标题之下绘制,与既有 reveal/veil 淡入叠加,形成可辨识水墨开场;单滴 alpha<255 且椭圆行落成,无实心黑块。开始界面沿用既有 开始/读档/退出(有档 继续)。新增 P74LaunchTransitionTests(4 例)与 LV_P74_SHOT/-analyze-p74 证据(5 帧墨幕非增、两两 diff>0,verdict=PASS)。HUD 结构未动;Desktop 350/350、Kernel 120/120、digest 与 p70 一致。
-rw-r--r--docs/evidence/p74-analysis.txt26
-rw-r--r--docs/evidence/p74-splash-008.pngbin0 -> 58446 bytes
-rw-r--r--docs/evidence/p74-splash-030.pngbin0 -> 62277 bytes
-rw-r--r--docs/evidence/p74-splash-060.pngbin0 -> 61750 bytes
-rw-r--r--docs/evidence/p74-splash-100.pngbin0 -> 55210 bytes
-rw-r--r--docs/evidence/p74-splash-140.pngbin0 -> 55286 bytes
-rw-r--r--docs/维护说明.md12
-rw-r--r--scripts/analyze-p74.py132
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs59
-rw-r--r--src/LivingVillage.Desktop/Game.fs29
-rw-r--r--src/LivingVillage.Desktop/InkReveal.fs58
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
13 files changed, 318 insertions, 0 deletions
diff --git a/docs/evidence/p74-analysis.txt b/docs/evidence/p74-analysis.txt
new file mode 100644
index 0000000..907a063
--- /dev/null
+++ b/docs/evidence/p74-analysis.txt
@@ -0,0 +1,26 @@
+P74(启动开场过渡:水墨式渐显)证据分析 — 真实 Desktop xvfb 1280x720
+钩子:LV_P74_SHOT=1(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)
+路径:splashFrame → InkReveal.inkStrength/dabs(预乘墨滴)→ 露出天色/水面/屋脊/标题
+
+log shots: 8(ink=0.770), 30(ink=0.325), 60(ink=0.053), 100(ink=0.000), 140(ink=0.000)
+
+p74-splash-008.png: size=1280x720 brightness=46.5 std=25.6 dark_block=0 white_block=405
+p74-splash-030.png: size=1280x720 brightness=51.7 std=38.4 dark_block=0 white_block=232
+p74-splash-060.png: size=1280x720 brightness=56.0 std=48.9 dark_block=0 white_block=232
+p74-splash-100.png: size=1280x720 brightness=56.4 std=49.0 dark_block=0 white_block=232
+p74-splash-140.png: size=1280x720 brightness=56.4 std=49.1 dark_block=0 white_block=232
+
+diff frame 008 vs 030: differing_pixels=670116
+diff frame 008 vs 060: differing_pixels=686287
+diff frame 008 vs 100: differing_pixels=528158
+diff frame 008 vs 140: differing_pixels=537994
+diff frame 030 vs 060: differing_pixels=733221
+diff frame 030 vs 100: differing_pixels=692402
+diff frame 030 vs 140: differing_pixels=702640
+diff frame 060 vs 100: differing_pixels=525583
+diff frame 060 vs 140: differing_pixels=545037
+diff frame 100 vs 140: differing_pixels=120790
+
+verdict=PASS
+ ink strength decays to 0 across the captured frames; frames render 1280x720,
+ no big black/white blocks, and every pair differs (the opening transition animates).
diff --git a/docs/evidence/p74-splash-008.png b/docs/evidence/p74-splash-008.png
new file mode 100644
index 0000000..d0c1a01
--- /dev/null
+++ b/docs/evidence/p74-splash-008.png
Binary files differ
diff --git a/docs/evidence/p74-splash-030.png b/docs/evidence/p74-splash-030.png
new file mode 100644
index 0000000..e42ba63
--- /dev/null
+++ b/docs/evidence/p74-splash-030.png
Binary files differ
diff --git a/docs/evidence/p74-splash-060.png b/docs/evidence/p74-splash-060.png
new file mode 100644
index 0000000..401c0d4
--- /dev/null
+++ b/docs/evidence/p74-splash-060.png
Binary files differ
diff --git a/docs/evidence/p74-splash-100.png b/docs/evidence/p74-splash-100.png
new file mode 100644
index 0000000..bce9efa
--- /dev/null
+++ b/docs/evidence/p74-splash-100.png
Binary files differ
diff --git a/docs/evidence/p74-splash-140.png b/docs/evidence/p74-splash-140.png
new file mode 100644
index 0000000..71cef5a
--- /dev/null
+++ b/docs/evidence/p74-splash-140.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 9d27dd1..3064e1a 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -209,6 +209,18 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
+## P74 启动开场过渡(水墨式渐显)
+
+- 新增纯函数模块 `InkReveal.fs`:`inkStrength`(起手 1,`dissolveFrames=96` 内 ease-out 退到 0)、
+ `dabs frame width height`(6 滴归一化种子墨滴,随消散半径外扩、alpha 下降)、`inkRgb=(26,36,52)`。
+ 单滴 `alpha < 255` 且用 `VillageArt.ellipseRuns` + `premultiply` 落成柔光墨晕,**绝无实心黑块**。
+- 接线:`Game.DrawSplash` 在场景层(天色/云/屋脊/水面/灯笼)之上、标题之下绘制墨滴,与既有
+ `LaunchScreen.revealProgress/veilAlpha` 淡入/暗幕退场叠加,形成可辨识的水墨开场;HUD 结构未动。
+- **回归**:`P74LaunchTransitionTests`(墨幕单调消散且为 0、墨滴确定/随帧变化、alpha<255、
+ 半径 < 半屏、`LaunchScreen` 不变量、开始界面三项 开始/读档/退出 + 有档 继续/读档/退出)。
+- **证据**:`LV_P74_SHOT=1`(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)→
+ `scripts/analyze-p74.py` 断言帧号/墨幕非增/无大黑块/两两 pixel diff>0,落 `docs/evidence/p74-*`。
+
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
diff --git a/scripts/analyze-p74.py b/scripts/analyze-p74.py
new file mode 100644
index 0000000..4569791
--- /dev/null
+++ b/scripts/analyze-p74.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""P74(启动开场过渡:水墨式渐显)证据分析:真实 xvfb 录启动 0-3 秒序列抽帧。
+
+判据(读真实实跑日志 + 逐帧像素统计):
+ 1. 日志含 5 行 `p74-shot=p74-splash-<frame> inkStrength=<v>`,帧号递增 {8,30,60,100,140}。
+ 2. inkStrength 随帧非增,且在 dissolveFrames(96) 后为 0(墨幕确实在消散)。
+ 3. 抽帧 PNG:1280x720、非空白(std>=6)、无 24x24 纯黑块(标题/屋脊的纯白属预期,仅报告不判失败)。
+ 4. 动画在变:任意两帧 pixel diff > 0(且首末帧 diff 显著)。
+ 5. 画面「是否好看/水墨味」的 vision 复核由 Hermes 侧完成,非本脚本判据。
+
+Usage: python3 scripts/analyze-p74.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import itertools
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+SHOT_RE = re.compile(r"^p74-shot=p74-splash-(\d+) inkStrength=([\d.]+)$", re.MULTILINE)
+EXPECTED = [8, 30, 60, 100, 140]
+DARK_SIDE = 24
+DARK_MAX = 8
+WHITE_SIDE = 64
+WHITE_MIN = 250
+
+
+def max_block(rgba, side, predicate):
+ h, w = rgba.shape[0], rgba.shape[1]
+ if h < side or w < side:
+ return 0
+ mask = predicate(rgba).astype(np.int64)
+ integral = np.zeros((h + 1, w + 1), dtype=np.int64)
+ integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1)
+ best = 0
+ for y in range(0, h - side + 1):
+ for x in range(0, w - side + 1):
+ total = (
+ integral[y + side, x + side]
+ - integral[y, x + side]
+ - integral[y + side, x]
+ + integral[y, x]
+ )
+ best = max(best, int(total))
+ return best
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
+ failures = []
+ lines = [
+ "P74(启动开场过渡:水墨式渐显)证据分析 — 真实 Desktop xvfb 1280x720",
+ "钩子:LV_P74_SHOT=1(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)",
+ "路径:splashFrame → InkReveal.inkStrength/dabs(预乘墨滴)→ 露出天色/水面/屋脊/标题",
+ "",
+ ]
+
+ shots = [(int(m.group(1)), float(m.group(2))) for m in SHOT_RE.finditer(log)]
+ got = [f for f, _ in shots]
+ lines.append("log shots: " + ", ".join(f"{f}(ink={v:.3f})" for f, v in shots))
+ if got != EXPECTED:
+ failures.append(f"log frames {got} != expected {EXPECTED}")
+ strengths = [v for _, v in shots]
+ if strengths != sorted(strengths, reverse=True):
+ failures.append("inkStrength is not non-increasing across frames")
+ if not all(v == 0.0 for f, v in shots if f >= 96):
+ failures.append("inkStrength is not 0 after dissolveFrames (96)")
+ lines.append("")
+
+ stats = {}
+ for frame in EXPECTED:
+ path = rec / f"p74-splash-{frame:03d}.png"
+ if not path.exists():
+ failures.append(f"missing frame {path.name}")
+ continue
+ rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8)
+ rgb = rgba[:, :, :3].astype(np.float64)
+ h, w = rgb.shape[0], rgb.shape[1]
+ brightness = float(rgb.mean())
+ std = float(rgb.std())
+ dark = max_block(
+ rgba, DARK_SIDE,
+ lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250),
+ )
+ white = max_block(
+ rgba, WHITE_SIDE,
+ lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250),
+ )
+ stats[frame] = {"brightness": brightness, "std": std}
+ lines.append(f"p74-splash-{frame:03d}.png: size={w}x{h} brightness={brightness:.1f} std={std:.1f} dark_block={dark} white_block={white}")
+ if (w, h) != (1280, 720):
+ failures.append(f"{path.name} size {w}x{h} != 1280x720")
+ if std < 6.0:
+ failures.append(f"{path.name} looks blank (std={std:.1f})")
+ if dark > 0:
+ failures.append(f"{path.name} has {DARK_SIDE}x{DARK_SIDE} pure-black block")
+ lines.append("")
+
+ have = [f for f in EXPECTED if f in stats]
+ for a, b in itertools.combinations(have, 2):
+ ia = np.asarray(Image.open(rec / f"p74-splash-{a:03d}.png").convert("RGB"), dtype=np.int16)
+ ib = np.asarray(Image.open(rec / f"p74-splash-{b:03d}.png").convert("RGB"), dtype=np.int16)
+ differing = int((np.abs(ia - ib).sum(axis=2) > 0).sum())
+ lines.append(f"diff frame {a:03d} vs {b:03d}: differing_pixels={differing}")
+ if differing == 0:
+ failures.append(f"frame {a} identical to {b}")
+
+ lines.append("")
+ if failures:
+ lines.append("verdict=FAIL")
+ for f in failures:
+ lines.append(" - " + f)
+ else:
+ lines.append("verdict=PASS")
+ lines.append(" ink strength decays to 0 across the captured frames; frames render 1280x720,")
+ lines.append(" no big black blocks, and every pair differs (the opening transition animates).")
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 11a1b90..fb7ecb7 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -45,6 +45,7 @@
<Compile Include="P71ApiDocsTests.fs" />
<Compile Include="P72CharacterWalkTests.fs" />
<Compile Include="P73LightingTierTests.fs" />
+ <Compile Include="P74LaunchTransitionTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs b/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs
new file mode 100644
index 0000000..880f33b
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P74LaunchTransitionTests.fs
@@ -0,0 +1,59 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+/// P74 回归:启动开场过渡(水墨渐显)纯函数性质 + 开始界面布局入口。
+[<TestClass>]
+type P74LaunchTransitionTests () =
+
+ let width = 1280
+ let height = 720
+
+ [<TestMethod>]
+ member _.InkRevealDissolvesMonotonicallyAndStaysSoft () =
+ // 起手满墨、96 帧后散尽,且整体强度随帧非增(确定性 ease-out)。
+ Assert.AreEqual<float32>(1.0f, InkReveal.inkStrength 0L)
+ Assert.AreEqual<float32>(0.0f, InkReveal.inkStrength InkReveal.dissolveFrames)
+ Assert.AreEqual<float32>(0.0f, InkReveal.inkStrength 140L)
+ let strengths = [ 0L; 8L; 30L; 60L; 95L ] |> List.map InkReveal.inkStrength
+ Assert.AreEqual<float32 list>(strengths, strengths |> List.sortDescending)
+ // 墨滴始终存在、均在画布内、alpha < 255(柔和墨晕,非实心黑块)、半径不覆盖半屏。
+ for frame in [ 0L; 8L; 30L; 60L; 95L ] do
+ let dabs = InkReveal.dabs frame width height
+ Assert.IsTrue(dabs.Length > 0, "墨滴集合不得为空")
+ for dab in dabs do
+ Assert.IsTrue(dab.Alpha >= 0 && dab.Alpha < 255, sprintf "alpha %d 越界" dab.Alpha)
+ Assert.IsTrue(dab.Rx > 0 && dab.Rx < width / 2, "Rx 覆盖过半屏")
+ Assert.IsTrue(dab.Ry > 0 && dab.Ry < height / 2, "Ry 覆盖过半屏")
+
+ [<TestMethod>]
+ member _.InkRevealDabsChangeOverTheTransition () =
+ // 同一帧两次调用一致(确定性);不同帧墨滴布局不同(动画在变)。
+ Assert.AreEqual<InkReveal.Dab list>(InkReveal.dabs 20L width height, InkReveal.dabs 20L width height)
+ Assert.AreNotEqual<InkReveal.Dab list>(InkReveal.dabs 8L width height, InkReveal.dabs 60L width height)
+
+ [<TestMethod>]
+ member _.LaunchScreenTransitionIsDeterministic () =
+ Assert.AreEqual<float32>(0.0f, LaunchScreen.revealProgress 0L)
+ Assert.AreEqual<float32>(1.0f, LaunchScreen.revealProgress LaunchScreen.fadeInFrames)
+ Assert.AreEqual<float32>(1.0f, LaunchScreen.veilAlpha 0L)
+ Assert.AreEqual<float32>(0.0f, LaunchScreen.veilAlpha LaunchScreen.durationFrames)
+ Assert.IsTrue(LaunchScreen.isComplete LaunchScreen.durationFrames)
+ Assert.IsFalse(LaunchScreen.isComplete (LaunchScreen.durationFrames - 1L))
+ Assert.AreEqual<string>("江南水乡", LaunchScreen.title)
+
+ [<TestMethod>]
+ member _.StartMenuLayoutExposesStartLoadExit () =
+ // 开场结束后的可交互开始界面:无存档 → 开始/读档/退出。
+ let fresh = MenuState.create { Paused = false; Speed = OneX } false
+ Assert.AreEqual<MenuPage>(MainMenu, fresh.Page)
+ Assert.AreEqual<MenuItem list>([ NewGameItem; LoadItem; ExitItem ], MenuState.items fresh)
+ let labels = MenuState.items fresh |> List.map MenuState.itemLabel
+ Assert.AreEqual<string list>([ "开始"; "读档"; "退出" ], labels)
+ // 有进行中的世界 → 继续/读档/退出。
+ let resumed = MenuState.create { Paused = false; Speed = OneX } true
+ Assert.AreEqual<MenuItem list>([ ContinueItem; LoadItem; ExitItem ], MenuState.items resumed)
+ Assert.AreEqual<string>("继续", MenuState.itemLabel (MenuState.selectedItem resumed))
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 3d04ca7..a4f994b 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -319,6 +319,10 @@ type LivingVillageGame() as this =
let p42ShotMode = Environment.GetEnvironmentVariable("LV_P42_SHOT") = "1"
let mutable p42SplashEarlyDone = false
let mutable p42SplashLateDone = false
+ // P74 证据钩子:LV_P74_SHOT=1 时在 60Hz 固定步进下按 splash 帧表拍开场过渡序列,拍完退出。
+ let p74ShotMode = Environment.GetEnvironmentVariable("LV_P74_SHOT") = "1"
+ let p74Frames = [ 8L; 30L; 60L; 100L; 140L ]
+ let mutable p74Saved = Set.empty<int64>
// P43 证据钩子:LV_P43_SHOT=1 时在真实世界里拍昼/夜两张 HUD 整帧(日历钟卡 + 体力条 + 10 格 hotbar)。
let p43ShotMode = Environment.GetEnvironmentVariable("LV_P43_SHOT") = "1"
let mutable p43Step = 0
@@ -2617,6 +2621,20 @@ type LivingVillageGame() as this =
printfn "p42-shot=done"
this.Exit()
+ /// P74 证据:splash 帧表逐帧落盘(证明开场过渡在变),拍完即退出。
+ member private this.CaptureP74Shot () =
+ if splashActive then
+ match p74Frames |> List.tryFind (fun f -> not (Set.contains f p74Saved) && splashFrame >= f) with
+ | Some frame ->
+ p74Saved <- Set.add frame p74Saved
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/p74-splash-%03d.png" recordDirectory frame)
+ printfn "p74-shot=p74-splash-%03d inkStrength=%.3f" frame (InkReveal.inkStrength splashFrame)
+ | None -> ()
+ if p74Saved.Count >= p74Frames.Length then
+ printfn "p74-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 =
@@ -2867,6 +2885,8 @@ type LivingVillageGame() as this =
this.CaptureMenuShot()
if p42ShotMode then
this.CaptureP42Shot()
+ if p74ShotMode then
+ this.CaptureP74Shot()
if storyShotMode && storyShotPrepared then
storyShotFrame <- storyShotFrame + 1L
if not storyShotSaved && storyShotFrame = 20L then
@@ -2990,6 +3010,15 @@ type LivingVillageGame() as this =
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))
+ // P74 水墨式渐显:柔和墨滴从画布各处晕开、随帧消散,露出后方天色/水面/屋脊。
+ // 单滴 alpha < 255 且用预乘椭圆行落成,绝无实心黑块;绘在标题之下、场景之上。
+ let inkR, inkG, inkB = InkReveal.inkRgb
+ for dab in InkReveal.dabs splashFrame width height do
+ if dab.Alpha > 0 then
+ let inkColor = VillageArt.premultiply inkR inkG inkB dab.Alpha
+ for (runX, runY, runW) in VillageArt.ellipseRuns dab.X dab.Y dab.Rx dab.Ry do
+ spriteBatch.Draw(pixel, Rectangle(runX, runY, runW, 1), inkColor)
+
// 标题:淡入 + 上浮,先画深色投影再画亮面。
let titleRise = int (24.0f * (1.0f - reveal))
PixelText.draw spriteBatch pixel cjkAtlas (layout.TitleX + 4) (layout.TitleY + titleRise + 4) layout.TitleScale (Color(12, 20, 28, alpha 255)) LaunchScreen.title
diff --git a/src/LivingVillage.Desktop/InkReveal.fs b/src/LivingVillage.Desktop/InkReveal.fs
new file mode 100644
index 0000000..04c1675
--- /dev/null
+++ b/src/LivingVillage.Desktop/InkReveal.fs
@@ -0,0 +1,58 @@
+namespace LivingVillage.Desktop
+
+open System
+
+/// P74 启动开场过渡:纯程序实现的水墨式渐显。
+/// 启动最初数帧用若干柔和墨滴(预乘 alpha 椭圆行,绝无整屏纯色/大黑块)从中心向外
+/// 晕开,同时整体墨幕强度随帧 ease-out 消退,露出后方的天色/水面/屋脊/标题。
+/// 全部为帧计数与画布尺寸的纯函数,确定性可复现;Kernel 不参与。
+module InkReveal =
+
+ /// 墨幕完全消散所需帧数(< LaunchScreen.durationFrames,先散墨再见标题)。
+ let dissolveFrames = 96L
+
+ /// 单滴墨的最大 alpha:始终 < 255,保证是柔光墨晕而非实心黑块。
+ let maxAlpha = 200
+
+ /// 墨滴种子:归一化中心 + 归一化基准半径 + 相位(纯数据,确定性布局)。
+ let private seeds : (float32 * float32 * float32 * float32 * int) list =
+ [ (0.22f, 0.30f, 0.14f, 0.10f, 0)
+ (0.46f, 0.22f, 0.18f, 0.12f, 1)
+ (0.74f, 0.34f, 0.12f, 0.10f, 2)
+ (0.58f, 0.62f, 0.20f, 0.13f, 3)
+ (0.30f, 0.70f, 0.16f, 0.11f, 4)
+ (0.83f, 0.67f, 0.11f, 0.08f, 5) ]
+
+ let private clamp01 (v: float32) = if v < 0.0f then 0.0f elif v > 1.0f then 1.0f else v
+
+ /// 整体墨幕强度:起手 1,随帧 ease-out 退到 0(dissolveFrames 后为 0)。
+ let inkStrength (frame: int64) : float32 =
+ if frame <= 0L then 1.0f
+ elif frame >= dissolveFrames then 0.0f
+ else
+ let t = clamp01 (float32 frame / float32 dissolveFrames)
+ 1.0f - t |> fun remaining -> remaining * remaining * remaining
+
+ /// 一滴墨:像素中心/半径/alpha。alpha 已含墨幕强度,绘制方按 alpha 预乘 rgb。
+ type Dab =
+ { X: int
+ Y: int
+ Rx: int
+ Ry: int
+ Alpha: int }
+
+ /// 当前帧的墨滴集合:消散过程中半径外扩、alpha 下降,读起来像墨在水中晕开。
+ let dabs (frame: int64) (width: int) (height: int) : Dab list =
+ let strength = inkStrength frame
+ let expand = 1.0f + 0.9f * (1.0f - strength)
+ seeds
+ |> List.map (fun (cx, cy, brx, bry, phase) ->
+ let wobble = 1.0f + 0.06f * float32 (sin (float (phase) * 1.7))
+ { X = int (cx * float32 width)
+ Y = int (cy * float32 height)
+ Rx = max 1 (int (brx * float32 width * expand * wobble))
+ Ry = max 1 (int (bry * float32 height * expand * wobble))
+ Alpha = int (float32 maxAlpha * strength) })
+
+ /// 墨色(深靛而非纯黑),绘制时与 alpha 预乘。
+ let inkRgb : int * int * int = (26, 36, 52)
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index 7243705..8abbdd6 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -21,6 +21,7 @@
<Compile Include="ChineseText.fs" />
<Compile Include="CjkGlyphAtlas.fs" />
<Compile Include="LaunchScreen.fs" />
+ <Compile Include="InkReveal.fs" />
<Compile Include="MenuState.fs" />
<Compile Include="Interaction.fs" />
<Compile Include="SampleScript.fs" />