summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p57-analysis.txt21
-rw-r--r--docs/evidence/p57-menu-highlight.pngbin0 -> 56395 bytes
-rw-r--r--docs/evidence/p57-shot-log.txt11
-rw-r--r--docs/evidence/p57-splash-highlight.pngbin0 -> 54110 bytes
-rw-r--r--scripts/analyze-p57.py173
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P57MenuHighlightTests.fs105
-rw-r--r--src/LivingVillage.Desktop/Game.fs97
-rw-r--r--src/LivingVillage.Desktop/MenuAmbience.fs57
9 files changed, 465 insertions, 0 deletions
diff --git a/docs/evidence/p57-analysis.txt b/docs/evidence/p57-analysis.txt
new file mode 100644
index 0000000..e47f63c
--- /dev/null
+++ b/docs/evidence/p57-analysis.txt
@@ -0,0 +1,21 @@
+P57 second water highlight layer analysis (LV_P57_SHOT=1)
+
+[splash] frame=16 columns=40
+[splash] main column std = 0.2276, highlight column std = 62.5699
+[splash] highlight std / main std = 274.966 (>= 0.3)
+[splash] layer correlation = -0.0168 (< 0.95)
+[splash] adjacent phase step: main = 2.0944 rad, highlight = 2.4000 rad
+[splash] pixel diff: water band mean=0.675 max=181.3 pixels>10=360, top max=0.0
+[splash] base: std=30.6 pure_black=0.0000% pure_white=0.0280%
+[splash] highlight: std=30.9 pure_black=0.0000% pure_white=0.0909%
+
+[menu] frame=181 columns=40
+[menu] main column std = 0.2259, highlight column std = 63.1929
+[menu] highlight std / main std = 279.771 (>= 0.3)
+[menu] layer correlation = -0.0110 (< 0.95)
+[menu] adjacent phase step: main = -4.1888 rad, highlight = 2.4000 rad
+[menu] pixel diff: water band mean=0.910 max=209.3 pixels>10=560, top max=0.0
+[menu] base: std=25.5 pure_black=0.0000% pure_white=0.0004%
+[menu] highlight: std=25.8 pure_black=0.0000% pure_white=0.0612%
+
+verdict: PASS
diff --git a/docs/evidence/p57-menu-highlight.png b/docs/evidence/p57-menu-highlight.png
new file mode 100644
index 0000000..0a4db4b
--- /dev/null
+++ b/docs/evidence/p57-menu-highlight.png
Binary files differ
diff --git a/docs/evidence/p57-shot-log.txt b/docs/evidence/p57-shot-log.txt
new file mode 100644
index 0000000..c23bd10
--- /dev/null
+++ b/docs/evidence/p57-shot-log.txt
@@ -0,0 +1,11 @@
+world-mode=riverscape bounds=256x192
+mode=keyboard seed=42
+controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit
+p57-status=p57-splash-base pendingName=p57-splash-base disabled=true frame=16 main=1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771 hl=216.0000,89.0000,112.0000,204.0000,45.0000,188.0000,137.0000,69.0000,220.0000,65.0000,143.0000,183.0000,46.0000,208.0000,106.0000,94.0000,214.0000,49.0000,172.0000,155.0000,58.0000,219.0000,79.0000,124.0000,197.0000,44.0000,197.0000,125.0000,78.0000,219.0000,58.0000,154.0000,173.0000,49.0000,213.0000,95.0000,105.0000,208.0000,46.0000,182.0000 mainphase=1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472 hlphase=1.2566,3.6566,6.0566,8.4566,10.8566,13.2566,15.6566,18.0566,20.4566,22.8566,25.2566,27.6566,30.0566,32.4566,34.8566,37.2566,39.6566,42.0566,44.4566,46.8566,49.2566,51.6566,54.0566,56.4566,58.8566,61.2566,63.6566,66.0566,68.4566,70.8566,73.2566,75.6566,78.0566,80.4566,82.8566,85.2566,87.6566,90.0566,92.4566,94.8566
+p57-status=p57-splash-highlight pendingName=p57-splash-highlight disabled=false frame=16 main=1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771,1.0000,0.7229,1.2771 hl=216.0000,89.0000,112.0000,204.0000,45.0000,188.0000,137.0000,69.0000,220.0000,65.0000,143.0000,183.0000,46.0000,208.0000,106.0000,94.0000,214.0000,49.0000,172.0000,155.0000,58.0000,219.0000,79.0000,124.0000,197.0000,44.0000,197.0000,125.0000,78.0000,219.0000,58.0000,154.0000,173.0000,49.0000,213.0000,95.0000,105.0000,208.0000,46.0000,182.0000 mainphase=1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472,3.1416,5.2360,1.0472 hlphase=1.2566,3.6566,6.0566,8.4566,10.8566,13.2566,15.6566,18.0566,20.4566,22.8566,25.2566,27.6566,30.0566,32.4566,34.8566,37.2566,39.6566,42.0566,44.4566,46.8566,49.2566,51.6566,54.0566,56.4566,58.8566,61.2566,63.6566,66.0566,68.4566,70.8566,73.2566,75.6566,78.0566,80.4566,82.8566,85.2566,87.6566,90.0566,92.4566,94.8566
+fps=61.0 tick=0 pos=(4112,3088)
+fps=60.0 tick=0 pos=(4112,3088)
+splash=done frame=150
+p57-status=p57-menu-base pendingName=p57-menu-base disabled=true frame=181 main=0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890 hl=220.0000,63.0000,147.0000,180.0000,47.0000,210.0000,103.0000,98.0000,212.0000,48.0000,175.0000,152.0000,60.0000,219.0000,76.0000,128.0000,195.0000,44.0000,199.0000,121.0000,81.0000,218.0000,56.0000,158.0000,170.0000,51.0000,215.0000,92.0000,109.0000,206.0000,45.0000,185.0000,140.0000,67.0000,220.0000,67.0000,139.0000,186.0000,45.0000,206.0000 mainphase=5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632 hlphase=14.2157,16.6157,19.0157,21.4157,23.8157,26.2157,28.6157,31.0157,33.4157,35.8157,38.2157,40.6157,43.0157,45.4157,47.8157,50.2157,52.6157,55.0157,57.4157,59.8157,62.2157,64.6157,67.0157,69.4157,71.8157,74.2157,76.6157,79.0157,81.4157,83.8157,86.2157,88.6157,91.0157,93.4157,95.8157,98.2157,100.6157,103.0157,105.4157,107.8157
+p57-status=p57-menu-highlight pendingName=p57-menu-highlight disabled=false frame=181 main=0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890,1.3139,0.8971,0.7890 hl=220.0000,63.0000,147.0000,180.0000,47.0000,210.0000,103.0000,98.0000,212.0000,48.0000,175.0000,152.0000,60.0000,219.0000,76.0000,128.0000,195.0000,44.0000,199.0000,121.0000,81.0000,218.0000,56.0000,158.0000,170.0000,51.0000,215.0000,92.0000,109.0000,206.0000,45.0000,185.0000,140.0000,67.0000,220.0000,67.0000,139.0000,186.0000,45.0000,206.0000 mainphase=5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632,1.3744,3.4688,5.5632 hlphase=14.2157,16.6157,19.0157,21.4157,23.8157,26.2157,28.6157,31.0157,33.4157,35.8157,38.2157,40.6157,43.0157,45.4157,47.8157,50.2157,52.6157,55.0157,57.4157,59.8157,62.2157,64.6157,67.0157,69.4157,71.8157,74.2157,76.6157,79.0157,81.4157,83.8157,86.2157,88.6157,91.0157,93.4157,95.8157,98.2157,100.6157,103.0157,105.4157,107.8157
+p57-shot=done
diff --git a/docs/evidence/p57-splash-highlight.png b/docs/evidence/p57-splash-highlight.png
new file mode 100644
index 0000000..661d013
--- /dev/null
+++ b/docs/evidence/p57-splash-highlight.png
Binary files differ
diff --git a/scripts/analyze-p57.py b/scripts/analyze-p57.py
new file mode 100644
index 0000000..6cf3097
--- /dev/null
+++ b/scripts/analyze-p57.py
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+"""P57 menu/splash second water highlight layer analysis.
+
+Reads the `LV_P57_SHOT=1` frames (same pinned splash/menu frame captured once with
+the highlight layer off, once on) plus <record-dir>/log.txt, and writes
+docs/evidence/p57-analysis.txt with:
+
+ * per-layer column profile std (main water band brightness vs highlight alpha)
+ and their correlation, from the logged pure-function arrays;
+ * adjacent-column phase steps for both layers (highlight must be phase-shifted
+ and move at a slightly different rate);
+ * pixel diff of base vs highlight frames (the second layer must be visible and
+ confined to the water band), and a no-big-black/white-block sanity check.
+
+Hard verdict:
+ highlight std >= main std * 0.3, layer correlation < 0.95, phase step > 0,
+ pixel diff > threshold inside the water band, no large pure black/white blocks.
+
+Usage:
+ python3 scripts/analyze-p57.py <record-dir> <out-txt>
+Exit 0 on PASS, 1 on FAIL, 2 on usage/read error.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+STATUS_RE = re.compile(
+ r"p57-status=(\S+) pendingName=(\S+) disabled=(true|false) frame=(\d+) "
+ r"main=([\d.,\-]+) hl=([\d.,\-]+) mainphase=([\d.,\-]+) hlphase=([\d.,\-]+)"
+)
+HIGHLIGHT_STD_RATIO_MIN = 0.3
+LAYER_CORR_MAX = 0.95
+PIXEL_DIFF_MIN = 20.0
+TOP_CLEAN_DIFF_MAX = 20.0
+PURE_BLACK_MAX_FRACTION = 0.02
+PURE_WHITE_MAX_FRACTION = 0.05
+WATER_ROWS = (624, 656)
+TILE = 32
+
+
+def fail(lines, out_txt, message):
+ lines.append(f"FAIL: {message}")
+ lines.append("")
+ lines.append("verdict: FAIL")
+ text = "\n".join(lines) + "\n"
+ if out_txt is not None:
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text(text, encoding="utf-8")
+ print(text)
+ return 1
+
+
+def load(path):
+ return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64)
+
+
+def parse_floats(text):
+ return [float(v) for v in text.split(",")]
+
+
+def main(argv):
+ if len(argv) != 3:
+ sys.stderr.write(__doc__)
+ return 2
+ record_dir = Path(argv[1])
+ out_txt = Path(argv[2])
+ log_path = record_dir / "log.txt"
+ if not log_path.exists():
+ sys.stderr.write(f"missing {log_path}\n")
+ return 2
+
+ lines = ["P57 second water highlight layer analysis (LV_P57_SHOT=1)", ""]
+ statuses = {}
+ for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
+ match = STATUS_RE.search(line)
+ if match:
+ statuses[match.group(1)] = {
+ "disabled": match.group(3) == "true",
+ "frame": int(match.group(4)),
+ "main": parse_floats(match.group(5)),
+ "hl": parse_floats(match.group(6)),
+ "mainphase": parse_floats(match.group(7)),
+ "hlphase": parse_floats(match.group(8)),
+ }
+
+ wanted = [
+ ("splash", "p57-splash-base", "p57-splash-highlight"),
+ ("menu", "p57-menu-base", "p57-menu-highlight"),
+ ]
+ for label, base_name, hl_name in wanted:
+ if base_name not in statuses or hl_name not in statuses:
+ return fail(lines, out_txt, f"missing log status for {base_name}/{hl_name}")
+ base_status = statuses[base_name]
+ hl_status = statuses[hl_name]
+ if not base_status["disabled"] or hl_status["disabled"]:
+ return fail(lines, out_txt, f"{label}: base must be disabled, highlight must be enabled")
+ if base_status["frame"] != hl_status["frame"]:
+ return fail(lines, out_txt, f"{label}: both captures must pin the same frame")
+
+ main = np.array(hl_status["main"], dtype=np.float64)
+ hl = np.array(hl_status["hl"], dtype=np.float64)
+ main_phase = np.array(hl_status["mainphase"], dtype=np.float64)
+ hl_phase = np.array(hl_status["hlphase"], dtype=np.float64)
+ main_std = float(main.std())
+ hl_std = float(hl.std())
+ if hl_std <= 0.0 or main_std <= 0.0:
+ return fail(lines, out_txt, f"{label}: column profiles must vary")
+ corr = float(np.corrcoef(main, hl)[0, 1])
+ main_step = float(main_phase[1] - main_phase[0])
+ hl_step = float(hl_phase[1] - hl_phase[0])
+ lines += [
+ f"[{label}] frame={hl_status['frame']} columns={len(main)}",
+ f"[{label}] main column std = {main_std:.4f}, highlight column std = {hl_std:.4f}",
+ f"[{label}] highlight std / main std = {hl_std / main_std:.3f} (>= {HIGHLIGHT_STD_RATIO_MIN})",
+ f"[{label}] layer correlation = {corr:.4f} (< {LAYER_CORR_MAX})",
+ f"[{label}] adjacent phase step: main = {main_step:.4f} rad, highlight = {hl_step:.4f} rad",
+ ]
+ if hl_std < main_std * HIGHLIGHT_STD_RATIO_MIN:
+ return fail(lines, out_txt, f"{label}: highlight std {hl_std:.4f} < main std * {HIGHLIGHT_STD_RATIO_MIN}")
+ if not (corr < LAYER_CORR_MAX):
+ return fail(lines, out_txt, f"{label}: layer correlation {corr:.4f} is not < {LAYER_CORR_MAX}")
+ if abs(hl_step) < 1e-3:
+ return fail(lines, out_txt, f"{label}: adjacent highlight columns are not phase-shifted")
+
+ base_img = load(record_dir / f"{base_name}.png")
+ hl_img = load(record_dir / f"{hl_name}.png")
+ if base_img.shape != hl_img.shape:
+ return fail(lines, out_txt, f"{label}: frame sizes differ")
+ diff = np.abs(hl_img - base_img).mean(axis=2)
+ band = diff[WATER_ROWS[0]:WATER_ROWS[1] + TILE, :]
+ top = diff[:300, :]
+ band_mean = float(band.mean())
+ band_max = float(band.max())
+ band_pixels = int((band > 10.0).sum())
+ top_max = float(top.max())
+ lines += [
+ f"[{label}] pixel diff: water band mean={band_mean:.3f} max={band_max:.1f} "
+ f"pixels>10={band_pixels}, top max={top_max:.1f}",
+ ]
+ if band_max < 20.0 or band_pixels < 50:
+ return fail(lines, out_txt, f"{label}: the highlight layer is not visible in the water band")
+ if top_max > 20.0:
+ return fail(lines, out_txt, f"{label}: highlight leaked outside the water band")
+
+ for tag, img in (("base", base_img), ("highlight", hl_img)):
+ std = float(img.std())
+ black = float((img.max(axis=2) <= 1.0).mean())
+ white = float((img.min(axis=2) >= 254.0).mean())
+ lines.append(
+ f"[{label}] {tag}: std={std:.1f} pure_black={black:.4%} pure_white={white:.4%}"
+ )
+ if std < 5.0:
+ return fail(lines, out_txt, f"{label} {tag}: frame looks blank")
+ if black > PURE_BLACK_MAX_FRACTION:
+ return fail(lines, out_txt, f"{label} {tag}: large pure-black block ({black:.2%})")
+ if white > PURE_WHITE_MAX_FRACTION:
+ return fail(lines, out_txt, f"{label} {tag}: large pure-white block ({white:.2%})")
+ lines.append("")
+
+ lines.append("verdict: PASS")
+ text = "\n".join(lines) + "\n"
+ out_txt.parent.mkdir(parents=True, exist_ok=True)
+ out_txt.write_text(text, encoding="utf-8")
+ print(text)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv)) \ No newline at end of file
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