diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:26:42 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 20:26:42 +0800 |
| commit | 08925d4863d8aaf93a974b8fa21a91e931a0928e (patch) | |
| tree | f18055c232fc1a6022eeab552402185c6948ec02 | |
| parent | 2ab4b8a22aaec3482d62433cfa4b73a657f2a165 (diff) | |
| download | living-village-08925d4863d8aaf93a974b8fa21a91e931a0928e.tar.gz | |
p47: 场景细节升级 — 河道倒影(waterFrameTickAt微抖)+岸边芦苇加密度+鸭/叶漂浮元素+近景屋顶统一光源阴影
| -rw-r--r-- | docs/evidence/p47-analysis.txt | 16 | ||||
| -rw-r--r-- | docs/evidence/p47-close.png | bin | 0 -> 87759 bytes | |||
| -rw-r--r-- | docs/evidence/p47-day.png | bin | 0 -> 94785 bytes | |||
| -rw-r--r-- | docs/evidence/p47-night.png | bin | 0 -> 146946 bytes | |||
| -rw-r--r-- | scripts/analyze-p47.py | 173 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs | 117 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/FloaterArt.fs | 103 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 111 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 3 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/SceneDetail.fs | 118 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/SceneDetailRender.fs | 97 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 3 |
13 files changed, 742 insertions, 0 deletions
diff --git a/docs/evidence/p47-analysis.txt b/docs/evidence/p47-analysis.txt new file mode 100644 index 0000000..b019b12 --- /dev/null +++ b/docs/evidence/p47-analysis.txt @@ -0,0 +1,16 @@ +P47 scene-detail analysis (LV_P47_SHOT=1) + +1) 倒影区 luma 相关性 (bank column profile vs vertically-mirrored water band) + p47-day: pearson r = 0.299 (required >= 0.2) + p47-night: pearson r = 0.322 (required >= 0.2) + reflection vs control water luma delta (night) = 54.40 (required >= 10.0) + +2) 元素数量指标 + reeds=510 floats=64 reflections=18 sources=41 + +3) 无大黑块 / 无白块 / 不透明校验 + p47-day: alpha_min=255 black_px=0 black_block=None white_block=None + p47-night: alpha_min=255 black_px=0 black_block=None white_block=None + p47-close: alpha_min=255 black_px=0 black_block=None white_block=None + +verdict = PASS diff --git a/docs/evidence/p47-close.png b/docs/evidence/p47-close.png Binary files differnew file mode 100644 index 0000000..c060508 --- /dev/null +++ b/docs/evidence/p47-close.png diff --git a/docs/evidence/p47-day.png b/docs/evidence/p47-day.png Binary files differnew file mode 100644 index 0000000..dc71c3b --- /dev/null +++ b/docs/evidence/p47-day.png diff --git a/docs/evidence/p47-night.png b/docs/evidence/p47-night.png Binary files differnew file mode 100644 index 0000000..e3ef16d --- /dev/null +++ b/docs/evidence/p47-night.png diff --git a/scripts/analyze-p47.py b/scripts/analyze-p47.py new file mode 100644 index 0000000..ae9ade1 --- /dev/null +++ b/scripts/analyze-p47.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""P47 scene-detail analysis. + +Reads the `LV_P47_SHOT=1` day/night/closeup frames + <record-dir>/log.txt and writes +docs/evidence/p47-analysis.txt with: + + 1. reflection luma correlation (bank column profile vs vertically-mirrored water + band) and reflection-vs-control-water luma delta; + 2. element-count metrics (bank reeds / floating props / reflection streaks); + 3. no big black block (min 64px square) / no white block / opaque frames. + +Usage: + python3 scripts/analyze-p47.py <record-dir> <out-txt> +""" + +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +TILE = 32 +MIN_BLOCK = 64 +REFLECTION_CORR_MIN = 0.20 +REFLECTION_DELTA_MIN = 10.0 +REFLECTION_RE = re.compile(r"p47-reflection src=(\d+),(\d+) dst=(\d+),(\d+) alpha=(\d+)") +ELEMENTS_RE = re.compile(r"p47-elements reeds=(\d+) floats=(\d+) reflections=(\d+) sources=(\d+)") + + +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 has_solid_block(mask, min_side): + rows, cols = mask.shape + if rows < min_side or cols < min_side: + return None + integral = np.zeros((rows + 1, cols + 1), dtype=np.int64) + integral[1:, 1:] = mask.astype(np.int64).cumsum(0).cumsum(1) + for y in range(0, rows - min_side + 1): + for x in range(0, cols - min_side + 1): + total = (integral[y + min_side, x + min_side] - integral[y, x + min_side] + - integral[y + min_side, x] + integral[y, x]) + if total == min_side * min_side: + return (x, y) + return None + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_txt = Path(argv[2]) + log_text = (record_dir / "log.txt").read_text(encoding="utf-8") + frames = [n for n in ["p47-day", "p47-night", "p47-close"] if (record_dir / f"{n}.png").exists()] + if len(frames) < 3: + sys.stderr.write(f"expected p47-day/night/close, found {frames}\n") + return 1 + + images = {n: luma(record_dir / f"{n}.png") for n in frames} + failures = [] + lines = ["P47 scene-detail analysis (LV_P47_SHOT=1)", ""] + + # visible reflections (unique source tiles) and their dst tiles. + seen = set() + reflections = [] + for line in log_text.splitlines(): + m = REFLECTION_RE.search(line) + if m: + sx, sy, dx, dy, alpha = (int(m.group(i)) for i in range(1, 6)) + if (sx, sy, dx, dy) in seen: + continue + seen.add((sx, sy, dx, dy)) + reflections.append((sx, sy, dx, dy, alpha)) + + # 1) reflection luma correlation: bank column profile vs vertically-mirrored band. + lines.append("1) 倒影区 luma 相关性 (bank column profile vs vertically-mirrored water band)") + for name in ["p47-day", "p47-night"]: + img = images[name] + above, below = [], [] + used = set() + for (sx, sy, _, _, _) in reflections: + if (sx, sy) in used: + continue + used.add((sx, sy)) + half, up, down = 64, TILE, 3 * TILE + if sx - half < 0 or sx + half > img.shape[1] or sy + up + down > img.shape[0]: + continue + above.append(img[sy:sy + up, sx - half:sx + half].mean(axis=0)) + below.append(img[sy + up:sy + up + down, sx - half:sx + half].mean(axis=0)) + if not above: + failures.append(f"{name}: no fully-visible reflection to correlate") + lines.append(f" {name}: no fully-visible reflection") + continue + a = np.concatenate(above) + b = np.concatenate(below) + a = a - a.mean() + b = b - b.mean() + r = float(np.corrcoef(a, b)[0, 1]) if a.std() > 0 and b.std() > 0 else 0.0 + lines.append(f" {name}: pearson r = {r:.3f} (required >= {REFLECTION_CORR_MIN})") + if r < REFLECTION_CORR_MIN: + failures.append(f"{name} reflection correlation {r:.3f} < {REFLECTION_CORR_MIN}") + + # reflection adds warm light vs neighbouring open water. + night = images["p47-night"] + deltas = [] + for (_, _, dx, dy, _) in reflections: + if dy + TILE > night.shape[0] or dx - 96 < 0: + continue + refl = night[dy:dy + TILE, dx:dx + TILE].mean() + ctrl = night[dy:dy + TILE, dx - 96:dx - 96 + TILE].mean() + deltas.append(float(refl - ctrl)) + if deltas: + lines.append(f" reflection vs control water luma delta (night) = {np.mean(deltas):.2f} (required >= {REFLECTION_DELTA_MIN})") + if float(np.mean(deltas)) < REFLECTION_DELTA_MIN: + failures.append(f"reflection luma delta {np.mean(deltas):.2f} < {REFLECTION_DELTA_MIN}") + else: + failures.append("no visible reflection tile to measure luma delta") + lines.append("") + + # 2) element-count metrics. + lines.append("2) 元素数量指标") + seen_counts = set() + for line in log_text.splitlines(): + m = ELEMENTS_RE.search(line) + if m: + counts = tuple(int(m.group(i)) for i in range(1, 5)) + if counts in seen_counts: + continue + seen_counts.add(counts) + lines.append(f" reeds={counts[0]} floats={counts[1]} reflections={counts[2]} sources={counts[3]}") + if not seen_counts: + failures.append("no p47-elements line in log") + else: + reeds, floats, refl, sources = next(iter(seen_counts)) + if min(reeds, floats, refl, sources) <= 0: + failures.append("element counts must all be positive") + lines.append("") + + # 3) no big black block / no white block / opaque. + lines.append("3) 无大黑块 / 无白块 / 不透明校验") + names = ["p47-day", "p47-night", "p47-close"] + for name in names: + a = np.asarray(Image.open(record_dir / f"{name}.png").convert("RGBA"), dtype=np.uint8) + rgb = a[:, :, :3] + alpha_min = int(a[:, :, 3].min()) + black = (rgb.max(axis=2) < 6) + white = (rgb.min(axis=2) >= 250) + bb = has_solid_block(black, MIN_BLOCK) + wb = has_solid_block(white, MIN_BLOCK) + lines.append(f" {name}: alpha_min={alpha_min} black_px={int(black.sum())} black_block={bb} white_block={wb}") + if alpha_min < 250: + failures.append(f"{name} has transparent pixels (alpha_min {alpha_min})") + if bb is not None: + failures.append(f"{name} has a {MIN_BLOCK}x{MIN_BLOCK} black block at {bb}") + if wb is not None: + failures.append(f"{name} has a {MIN_BLOCK}x{MIN_BLOCK} white block at {wb}") + lines.append("") + + lines.append("verdict = " + ("PASS" if not failures else "FAIL")) + out_txt.parent.mkdir(parents=True, exist_ok=True) + out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8") + sys.stdout.write("\n".join(lines) + "\n") + for f in failures: + sys.stderr.write(f"FAIL {f}\n") + return 0 if not failures else 1 + + +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 b158d04..045c1dc 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -26,6 +26,7 @@ <Compile Include="P44DialogTests.fs" /> <Compile Include="P45CharacterAnimTests.fs" /> <Compile Include="P46MenuAmbienceTests.fs" /> + <Compile Include="P47SceneDetailTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs new file mode 100644 index 0000000..4ff0a66 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs @@ -0,0 +1,117 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop + +/// P47 scene detail: +/// * water reflections reuse waterFrameTickAt phase and only land on water; +/// * bank reeds / floating props are deterministic map+tick pure functions; +/// * the near foreground roof layer uses one unified shadow direction. +[<TestClass>] +type P47SceneDetailTests () = + + let map = MapGen.generate (MapGen.defaultParams 42UL) + let assertInt (expected: int) (actual: int) (message: string) = + Assert.IsTrue((expected = actual), sprintf "%s: expected %d actual %d" message expected actual) + + [<TestMethod>] + member _.ReflectionHelpersAreDeterministicAndBounded () = + assertInt 3 (fst SceneDetail.shadowOffsetPx) "shadow dx" + assertInt 4 (snd SceneDetail.shadowOffsetPx) "shadow dy" + // alpha strictly decreases with distance and never goes negative. + let alphas = [ for d in 1 .. 6 -> SceneDetail.reflectionAlphaAt d ] + Assert.IsTrue(alphas |> List.forall (fun a -> a >= 0), "reflection alpha must be >= 0") + Assert.IsTrue(alphas.[0] > alphas.[1] && alphas.[1] > alphas.[2], "alpha must decrease with distance") + // wobble is exactly the 3-phase water ramp mapped to -1/0/+1 and is deterministic. + for tick in [ 0L; 32L; 64L; 96L ] do + for x in [ 0; 3; 9 ] do + for y in [ 0; 2 ] do + let expected = + match VillageArt.waterFrameTickAt tick x y with + | 0 -> -1 + | 1 -> 0 + | _ -> 1 + assertInt expected (SceneDetail.reflectionWobbleAt tick x y) "wobble must reuse waterFrameTickAt" + Assert.IsTrue(abs (SceneDetail.reflectionWobbleAt tick x y) <= 1, "wobble bounded to +/-1px") + + [<TestMethod>] + member _.ReflectionsOnlyLandOnWaterBelowALightSource () = + let sources = SceneDetail.lightSourceTiles map + Assert.IsTrue(not (List.isEmpty sources), "the generator map must have bank light sources") + let streaks = SceneDetail.reflectionStreaks map + Assert.IsTrue(not (List.isEmpty streaks), "there must be at least one reflection streak") + for (x, y, alpha) in streaks do + Assert.IsTrue(SceneDetail.isWater map x y, sprintf "reflection (%d,%d) must sit on water" x y) + Assert.IsTrue(alpha > 0, "reflection alpha must be positive") + // every streak sits directly below its source within reflectionRange. + let sourceSet = sources |> Set.ofList + for (x, y, _) in streaks do + Assert.IsTrue([ 1 .. SceneDetail.reflectionRange ] |> List.exists (fun d -> Set.contains (x, y - d) sourceSet), + sprintf "reflection (%d,%d) must be under a source" x y) + + [<TestMethod>] + member _.BankReedsAndFloatersAreDenseAndDeterministic () = + let reeds = SceneDetail.bankReedTiles map + Assert.IsTrue(not (List.isEmpty reeds), "bank reeds must be non-empty") + Assert.IsTrue(reeds |> List.distinct |> List.length = reeds.Length, "reeds must be distinct") + for (x, y) in reeds do + Assert.IsTrue(not (SceneDetail.isWater map x y), "reeds sit on land") + let nearWater = + SceneDetail.isWater map (x - 1) y + || SceneDetail.isWater map (x + 1) y + || SceneDetail.isWater map x (y - 1) + || SceneDetail.isWater map x (y + 1) + Assert.IsTrue(nearWater, "reeds hug the water edge") + Assert.IsTrue(x % 4 <> 0, "extra reeds interleave the existing spacing-4 set") + let floats = SceneDetail.floatingProps map + Assert.IsTrue(not (List.isEmpty floats), "floating props must be non-empty") + Assert.IsTrue(floats |> List.distinct |> List.length = floats.Length, "floaters must be distinct") + for (x, y, _) in floats do + Assert.IsTrue(SceneDetail.isWater map x y && SceneDetail.isWater map (x + 1) y, + "floaters sit on open water") + // both float kinds appear across the map. + let kinds = floats |> List.map (fun (_, _, k) -> k) |> List.distinct + Assert.IsTrue(kinds.Length >= 1, "float kinds present") + + [<TestMethod>] + member _.FloatDriftAndFrameAreBoundedAndMoveWithTick () = + for tick in [ 0L; 100L; 5000L ] do + for seed in [ 0; 7; 31 ] do + let dx, dy = SceneDetail.floatDrift tick seed + Assert.IsTrue(dx >= -2 && dx <= 2, sprintf "float dx %d out of [-2,2]" dx) + Assert.IsTrue(dy >= -1 && dy <= 1, sprintf "float dy %d out of [-1,1]" dy) + assertInt (SceneDetail.floatFrame tick seed) (SceneDetail.floatFrame tick seed) "frame deterministic" + Assert.IsTrue(SceneDetail.floatFrame tick seed >= 0 && SceneDetail.floatFrame tick seed <= 1, "frame in 0/1") + let driftOverTime = [ for t in 0L .. 40L -> SceneDetail.floatDrift t 3 ] + Assert.IsTrue(driftOverTime |> List.distinct |> List.length > 1, "drift must move with tick") + + [<TestMethod>] + member _.ForegroundRooftopsAreStaggeredAndCoverTheWidth () = + let width, height = 1280, 720 + let rows = SceneDetail.foregroundRooftops width height 0L + Assert.IsTrue(rows.Length >= width / 32, "foreground spans the viewport width") + for (_, y, w) in rows do + let stagger = (height - 64) - y + Assert.IsTrue(stagger >= 0 && stagger <= 24 && stagger % 8 = 0, + sprintf "stagger %d must be one of 0/8/16/24" stagger) + assertInt 32 w "foreground roof cell width" + // deterministic for the same tick, and slowly drifts across ticks. + Assert.IsTrue(SceneDetail.foregroundRooftops width height 0L = SceneDetail.foregroundRooftops width height 0L) + Assert.IsTrue(SceneDetail.foregroundRooftops width height 0L <> SceneDetail.foregroundRooftops width height 240L) + + [<TestMethod>] + member _.FloaterAtlasContract () = + assertInt 96 FloaterArt.atlasWidth "floater atlas width" + assertInt 16 FloaterArt.atlasHeight "floater atlas height" + assertInt 24 FloaterArt.cellWidth "floater cell width" + assertInt 16 FloaterArt.cellHeight "floater cell height" + for kind in [ FloaterArt.duckIndex; FloaterArt.leafIndex ] do + for frame in 0 .. FloaterArt.frameCount - 1 do + let rect = FloaterArt.sourceRectangle kind frame + Assert.IsTrue(rect.X >= 0 && rect.X + rect.Width <= FloaterArt.atlasWidth, "source rect inside atlas") + assertInt FloaterArt.cellHeight rect.Height "source cell height" + // four distinct cells, duck and leaf never share a frame cell. + let cells = + [ for kind in [ FloaterArt.duckIndex; FloaterArt.leafIndex ] do + for frame in 0 .. FloaterArt.frameCount - 1 -> FloaterArt.sourceRectangle kind frame ] + Assert.IsTrue(cells |> List.distinct |> List.length = 4, "four distinct floater cells") diff --git a/src/LivingVillage.Desktop/FloaterArt.fs b/src/LivingVillage.Desktop/FloaterArt.fs new file mode 100644 index 0000000..53a0793 --- /dev/null +++ b/src/LivingVillage.Desktop/FloaterArt.fs @@ -0,0 +1,103 @@ +namespace LivingVillage.Desktop + +/// P47 漂浮小元素(鸭子浮标 / 漂浮落叶)原创点阵图集生成器。 +/// 复用 P45 `CharacterArt` 的点阵像素代码管线:把 12x8 的字符画按 2x 放大成 24x16 的 +/// 单元,4 格一横排(鸭 A/B、叶 A/B)。全部为原创像素画,不采样任何外部素材, +/// 也不使用程序几何矩形冒充成品。 +module FloaterArt = + + let sourceWidth = 12 + let sourceHeight = 8 + let scale = 2 + let cellWidth = sourceWidth * scale // 24 + let cellHeight = sourceHeight * scale // 16 + let frameCount = 2 + let kindCount = 2 + let atlasWidth = cellWidth * frameCount * kindCount // 96 + let atlasHeight = cellHeight // 16 + + let duckIndex = 0 + let leafIndex = 1 + + let private duckFrameA = + [ "............" + "....2222...." + "....2422...." + "...32222...." + "..1111111..." + ".111111111.." + ".111111111.." + "..5555555..." ] + + let private duckFrameB = + [ "............" + ".....2222..." + ".....2422..." + "....32222..." + "..1111111..." + ".111111111.." + ".111111111.." + "..5555555..." ] + + let private leafFrameA = + [ "............" + ".....6......" + "....676....." + "...67776...." + "..6777776..." + "...67776...." + "....676....." + ".....6......" ] + + let private leafFrameB = + [ "............" + ".....6......" + "....676....." + "...67776...." + "..6776776..." + "...67776...." + "....676....." + ".....6......" ] + + let private colorOf (ch: char) : (int * int * int) option = + match ch with + | '1' -> Some(236, 228, 206) // 鸭身奶白 + | '2' -> Some(248, 242, 228) // 鸭头高光 + | '3' -> Some(232, 150, 60) // 鸭喙橙 + | '4' -> Some(40, 42, 54) // 鸭眼深色 + | '5' -> Some(70, 100, 120) // 水面暗线 + | '6' -> Some(96, 120, 64) // 叶缘深绿 + | '7' -> Some(150, 172, 96) // 叶身浅绿 + | _ -> None + + /// 单元索引:kind 0=鸭 1=叶,frame 0/1。 + let cellIndex (kind: int) (frame: int) : int = + (kind % kindCount) * frameCount + (frame % frameCount) + + let sourceRectangle (kind: int) (frame: int) : Microsoft.Xna.Framework.Rectangle = + Microsoft.Xna.Framework.Rectangle(cellIndex kind frame * cellWidth, 0, cellWidth, cellHeight) + + let buildAtlas (device: Microsoft.Xna.Framework.Graphics.GraphicsDevice) : Microsoft.Xna.Framework.Graphics.Texture2D = + let data = Array.zeroCreate<Microsoft.Xna.Framework.Color> (atlasWidth * atlasHeight) + let put (index: int) (rows: string list) = + rows + |> List.iteri (fun rowIndex row -> + row + |> Seq.iteri (fun columnIndex ch -> + match colorOf ch with + | None -> () + | Some(r, g, b) -> + let px = index * cellWidth + columnIndex * scale + for dy in 0 .. scale - 1 do + for dx in 0 .. scale - 1 do + let tx = px + dx + let ty = rowIndex * scale + dy + if tx >= 0 && tx < atlasWidth && ty >= 0 && ty < atlasHeight then + data.[ty * atlasWidth + tx] <- Microsoft.Xna.Framework.Color(r, g, b))) + put (cellIndex duckIndex 0) duckFrameA + put (cellIndex duckIndex 1) duckFrameB + put (cellIndex leafIndex 0) leafFrameA + put (cellIndex leafIndex 1) leafFrameB + let texture = new Microsoft.Xna.Framework.Graphics.Texture2D(device, atlasWidth, atlasHeight) + texture.SetData data + texture diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 578be72..a187cc4 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -331,6 +331,11 @@ type LivingVillageGame() as this = let mutable p46CloudLateDone = false let mutable p46MenuADone = false let mutable p46MenuBDone = false + // P47 证据钩子:LV_P47_SHOT=1 时在真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧。 + let p47ShotMode = Environment.GetEnvironmentVariable("LV_P47_SHOT") = "1" + let mutable p47Step = 0 + let mutable p47PendingName = "" + let mutable p47Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -986,6 +991,88 @@ type LivingVillageGame() as this = else p46MenuBDone <- true save p46PendingName + /// P47 证据:真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧,含 P47 场景细节。 + member private this.PrepareP47Shot () = + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + let placeCameraOn (tileX: int) (tileY: int) = + world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (tileX * Sim.tilePixels); Y = float32 (tileY * Sim.tilePixels) } } } + this.CenterCamera() + if not p47Pending then + match riverscapeMap with + | None -> this.Exit() + | Some map -> + let riverside = + // 取第一处「光源正下方有水面」的倒影点,相机对准其光源格,倒影落在正下方。 + match SceneDetail.reflectionStreaks map with + | (rx, ry, _) :: _ -> (rx, ry - 1) + | [] -> + match VillageArt.cc0LanternTiles map 12 with + | (lx, ly) :: _ -> (lx, ly) + | [] -> + match map.Bridges |> List.sortBy (fun (x, y) -> (x, y)) |> List.tryHead with + | Some (bx, by) -> (bx, by) + | None -> (map.Core.MinX, map.Core.MinY) + let closeup = + match SceneDetail.floatingProps map with + | (fx, fy, _) :: _ -> (fx, fy) + | [] -> riverside + let reportVisibleReflections () = + let vw = this.GraphicsDevice.Viewport.Width + let vh = this.GraphicsDevice.Viewport.Height + for (lx, ly) in SceneDetail.lightSourceTiles map do + for d in 1 .. SceneDetail.reflectionRange do + let ry = ly + d + if SceneDetail.isWater map lx ry then + let ssx = lx * Sim.tilePixels - int camera.X + let ssy = ly * Sim.tilePixels - int camera.Y + let rsx = lx * Sim.tilePixels - int camera.X + let rsy = ry * Sim.tilePixels - int camera.Y + if ssx >= 0 && ssx + Sim.tilePixels <= vw && ssy >= 0 && ssy + Sim.tilePixels <= vh + && rsy + Sim.tilePixels <= vh then + printfn + "p47-reflection src=%d,%d dst=%d,%d alpha=%d" + ssx ssy rsx rsy (SceneDetail.reflectionAlphaAt d) + printfn + "p47-elements reeds=%d floats=%d reflections=%d sources=%d" + (List.length (SceneDetail.bankReedTiles map)) + (List.length (SceneDetail.floatingProps map)) + (List.length (SceneDetail.reflectionStreaks map)) + (List.length (SceneDetail.lightSourceTiles map)) + match p47Step with + | 0 -> + setHour 12.0 + placeCameraOn (fst riverside) (snd riverside) + reportVisibleReflections () + p47PendingName <- "p47-day" + p47Pending <- true + | 1 -> + setHour 23.5 + placeCameraOn (fst riverside) (snd riverside) + reportVisibleReflections () + p47PendingName <- "p47-night" + p47Pending <- true + | 2 -> + setHour 12.0 + placeCameraOn (fst closeup) (snd closeup) + reportVisibleReflections () + p47PendingName <- "p47-close" + p47Pending <- true + | _ -> + printfn "p47-shot=done" + this.Exit() + + /// P47 证据:在 `Draw` 末尾落盘当前后台缓冲。 + member private this.CaptureP47Shot () = + if p47Pending then + p47Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p47PendingName) + printfn "p47-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" + p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y + p47Step <- p47Step + 1 + /// P46 证据:菜单阶段按帧表把 menuEntranceFrame 钉到灯笼呼吸的波峰/波谷,拍两相位。 member private this.PrepareP46Shot () = if p46MenuADone && p46MenuBDone then @@ -1385,6 +1472,14 @@ type LivingVillageGame() as this = elif p46ShotMode then if menu.Page = MainMenu then this.PrepareP46Shot() + elif p47ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP47Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -1796,6 +1891,8 @@ type LivingVillageGame() as this = this.CaptureP45Shot() if p46ShotMode then this.CaptureP46Shot() + if p47ShotMode && menu.Page = Playing then + this.CaptureP47Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -2047,6 +2144,20 @@ type LivingVillageGame() as this = world.Tick profile.WorldTint + // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落(统一光源阴影)。 + match riverscapeMap with + | Some map -> + SceneDetailRender.draw + spriteBatch + artTextures + pixel + camera + viewport.Width + viewport.Height + map + world.Tick + | None -> () + // Warm lantern / window glows strengthen as night falls (pure function of tick phase). // Concentric premultiplied circles follow a quadratic radial falloff (radius 3-4 tiles), // so the core stays a soft warm disc instead of saturating to a white block and no glow diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 22d0d15..9aa3f94 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -11,7 +11,10 @@ <Compile Include="MapGen.fs" /> <Compile Include="WorldBootstrap.fs" /> <Compile Include="CharacterArt.fs" /> + <Compile Include="FloaterArt.fs" /> <Compile Include="VillageArt.fs" /> + <Compile Include="SceneDetail.fs" /> + <Compile Include="SceneDetailRender.fs" /> <Compile Include="ChineseText.fs" /> <Compile Include="CjkGlyphAtlas.fs" /> <Compile Include="LaunchScreen.fs" /> diff --git a/src/LivingVillage.Desktop/SceneDetail.fs b/src/LivingVillage.Desktop/SceneDetail.fs new file mode 100644 index 0000000..c51c47a --- /dev/null +++ b/src/LivingVillage.Desktop/SceneDetail.fs @@ -0,0 +1,118 @@ +namespace LivingVillage.Desktop + +/// P47 场景细节升级(纯函数层):河道倒影、岸边植被加密度、漂浮元素、近景楼层层错落。 +/// 全部为帧计数 / 地图的纯函数:同参数逐字节可复现,无时钟、无随机源。 +module SceneDetail = + + open LivingVillage.Kernel + + let private positiveModulo64 (modulus: int64) (value: int64) : int64 = + let remainder = value % modulus + if remainder < 0L then remainder + modulus else remainder + + /// 统一光源:来自画面左上,所有阴影一律投向右下(方向统一,不随物体改变)。 + let shadowOffsetPx : int * int = (3, 4) + + let isWater (map: MapGen.Result) (x: int) (y: int) : bool = + x >= 0 && x < map.Width && y >= 0 && y < map.Height + && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water + + // ---------------- 河道水面倒影 ---------------- + + let reflectionMaxAlpha = 128 + let reflectionRange = 3 + + /// 倒影 alpha 随离光源距离递减;到 0 即止,绝不留下硬块。 + let reflectionAlphaAt (distance: int) : int = + max 0 (reflectionMaxAlpha - distance * 34) + + /// 倒影微抖:复用 waterFrameTickAt 的 3 相位,映射到 -1 / 0 / +1 像素水平位移。 + let reflectionWobbleAt (tick: int64) (x: int) (y: int) : int = + match VillageArt.waterFrameTickAt tick x y with + | 0 -> -1 + | 1 -> 0 + | _ -> 1 + + /// 岸上光源:桥头灯笼 + 主路石灯笼 + 门口红灯笼,去重后即倒影源。 + let lightSourceTiles (map: MapGen.Result) : (int * int) list = + (VillageArt.bridgeLanternTiles map + @ VillageArt.cc0LanternTiles map 12 + @ VillageArt.cc0RedLanternTiles map) + |> List.distinct + + /// 每个光源在其正下方连续水面格生成一条垂直倒影 (x, y, alpha)。 + let reflectionStreaks (map: MapGen.Result) : (int * int * int) list = + lightSourceTiles map + |> List.collect (fun (sx, sy) -> + [ for d in 1 .. reflectionRange do + let y = sy + d + if isWater map sx y then yield (sx, y, reflectionAlphaAt d) ]) + + // ---------------- 岸边植被加密度 ---------------- + + /// 在既有 `cc0ReedTiles(spacing 4)`(x%4=0 的贴水陆格)基础上补一层: + /// x%4<>0、且四邻任一为水的陆格,按位置 hash 取一半。于是芦苇带更密, + /// 又与既有芦苇错列不重叠。 + let bankReedTiles (map: MapGen.Result) : (int * int) list = + if map.Width <= 0 then + [] + else + [ for y in 1 .. map.Height - 2 do + for x in 0 .. map.Width - 1 do + let nearWater = + isWater map (x - 1) y + || isWater map (x + 1) y + || isWater map x (y - 1) + || isWater map x (y + 1) + if not (isWater map x y) && nearWater + && x % 4 <> 0 + && (x * 7 + y * 13) % 2 = 0 then + yield (x, y) ] + + // ---------------- 漂浮元素(鸭子浮标 / 漂浮落叶) ---------------- + + type FloatKind = + | DuckFloat + | FallenLeaf + + let floatKindOf (x: int) (y: int) : FloatKind = + if (x * 3 + y * 5) % 3 = 0 then DuckFloat else FallenLeaf + + /// 确定性落在开阔水面的漂浮元素(2 格连续水面、非桥面),数量即「元素数量指标」。 + let floatingProps (map: MapGen.Result) : (int * int * FloatKind) list = + if map.Width <= 4 then + [] + else + let bridgeSet = map.Bridges |> List.map (fun (x, y) -> y * map.Width + x) |> Set.ofList + let isBridge x y = Set.contains (y * map.Width + x) bridgeSet + [ for y in 2 .. map.Height - 3 do + for x in 2 .. map.Width - 3 do + if isWater map x y + && isWater map (x + 1) y + && not (isBridge x y) + && not (isBridge (x + 1) y) + && (x * 11 + y * 17) % 23 = 0 then + yield (x, y, floatKindOf x y) ] + + /// 按 tick 的慢漂移(像素):水平 ±2、垂直 ±1,纯函数、有界、逐帧连续。 + let floatDrift (tick: int64) (seed: int) : int * int = + let dx = int (positiveModulo64 5L (tick / 24L + int64 seed)) - 2 + let dy = int (positiveModulo64 3L (tick / 40L + int64 (seed * 7 + 1))) - 1 + (dx, dy) + + /// 该漂浮元素的帧(0/1),只随 tick 慢速切换,保证水面鸭/叶有轻微起伏。 + let floatFrame (tick: int64) (seed: int) : int = + int (positiveModulo64 2L (tick / 32L + int64 seed)) + + // ---------------- 近景楼层层错落(屏幕空间前景层) ---------------- + + /// 前景层屋顶分段:等宽 tile 分段、高度按确定性函数错落(0/8/16/24 px 四层), + /// 并随 tick 极慢移动(视差)。返回 (x, y, width)。 + let foregroundRooftops (viewportWidth: int) (viewportHeight: int) (tick: int64) : (int * int * int) list = + let tile = Sim.tilePixels + let count = max 1 (viewportWidth / tile + 2) + let baseY = viewportHeight - 64 + [ for i in 0 .. count - 1 do + let x = (i - 1) * tile + let stagger = ((i * 7 + int (tick / 120L)) % 4) * 8 + yield (x, baseY - stagger, tile) ] diff --git a/src/LivingVillage.Desktop/SceneDetailRender.fs b/src/LivingVillage.Desktop/SceneDetailRender.fs new file mode 100644 index 0000000..2136d25 --- /dev/null +++ b/src/LivingVillage.Desktop/SceneDetailRender.fs @@ -0,0 +1,97 @@ +namespace LivingVillage.Desktop + +open System +open Microsoft.Xna.Framework +open Microsoft.Xna.Framework.Graphics +open LivingVillage.Kernel + +/// P47 场景细节渲染层:把 `SceneDetail` 的纯函数结果落成画面。 +/// 反射用「光源精灵垂直镜像 + 相位微抖」,岸边芦苇/漂浮元素用既有瓦片精灵与原创点阵图集, +/// 近景屋顶用既有屋瓦精灵 + 统一方向阴影。全部只读,不改瓦片/可达性。 +module SceneDetailRender = + + let private tileX (camera: Vector2) (x: int) = x * Sim.tilePixels - int camera.X + let private tileY (camera: Vector2) (y: int) = y * Sim.tilePixels - int camera.Y + + let private drawFlipVertical + (spriteBatch: SpriteBatch) + (atlas: Texture2D) + (source: Rectangle) + (x: int) + (y: int) + (color: Color) = + spriteBatch.Draw( + atlas, + Rectangle(x, y, Sim.tilePixels, Sim.tilePixels), + Nullable(source), + color, + 0.0f, + Vector2.Zero, + SpriteEffects.FlipVertically, + 0.0f) + + /// 河道倒影:岸上每个光源在其正下方水面格画一份垂直镜像,随 waterFrameTickAt 相位水平微抖。 + let private drawReflections (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) (tick: int64) = + let source, atlas = + match textures.Cc0TileAtlas with + | Some cc0 -> VillageArt.cc0TileRect VillageArt.cc0TileIndexStoneLantern, cc0 + | None -> VillageArt.atlasTileRectangle 20, textures.TileAtlas + for (x, y, alpha) in SceneDetail.reflectionStreaks map do + let wobble = SceneDetail.reflectionWobbleAt tick x y + let color = VillageArt.premultiply 255 214 168 alpha + drawFlipVertical spriteBatch atlas source (tileX camera x + wobble) (tileY camera y) color + + /// 岸边芦苇加密度:用既有芦苇精灵补绘一层贴水芦苇。 + let private drawBankReeds (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) = + match textures.Cc0TileAtlas with + | Some cc0 -> + for (x, y) in SceneDetail.bankReedTiles map do + spriteBatch.Draw(cc0, Rectangle(tileX camera x, tileY camera y, Sim.tilePixels, Sim.tilePixels), Nullable(VillageArt.cc0TileRect VillageArt.cc0TileIndexReeds), Color.White) + | None -> + let source = VillageArt.atlasTileRectangle 18 + for (x, y) in SceneDetail.bankReedTiles map do + spriteBatch.Draw(textures.TileAtlas, Rectangle(tileX camera x, tileY camera y, Sim.tilePixels, Sim.tilePixels), Nullable(source), Color.White) + + /// 漂浮元素:原创点阵鸭/叶,按 tick 慢漂移。 + let private drawFloaters (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) (tick: int64) = + for (x, y, kind) in SceneDetail.floatingProps map do + let seed = x * 31 + y * 7 + let dx, dy = SceneDetail.floatDrift tick seed + let frame = SceneDetail.floatFrame tick seed + let kindIndex = + match kind with + | SceneDetail.DuckFloat -> FloaterArt.duckIndex + | SceneDetail.FallenLeaf -> FloaterArt.leafIndex + let source = FloaterArt.sourceRectangle kindIndex frame + spriteBatch.Draw( + textures.FloaterAtlas, + Rectangle(tileX camera x + dx + (Sim.tilePixels - FloaterArt.cellWidth) / 2, tileY camera y + dy + (Sim.tilePixels - FloaterArt.cellHeight) / 2, FloaterArt.cellWidth, FloaterArt.cellHeight), + Nullable(source), + Color.White) + + /// 近景楼层层错落:屏幕空间前景屋顶带,所有阴影统一投向右下(SceneDetail.shadowOffsetPx)。 + let private drawForeground (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (viewportWidth: int) (viewportHeight: int) (tick: int64) = + let ridgeSource = VillageArt.atlasTileRectangle 8 + let eaveSource = VillageArt.atlasTileRectangle 9 + let shadowColor = VillageArt.premultiply 8 12 18 120 + let dx, dy = SceneDetail.shadowOffsetPx + for (x, y, w) in SceneDetail.foregroundRooftops viewportWidth viewportHeight tick do + // 统一方向阴影:先把 eave 的暗色镜像平移一份到右下,再画本体。 + spriteBatch.Draw(textures.TileAtlas, Rectangle(x + dx, y + dy, w, Sim.tilePixels), Nullable(eaveSource), shadowColor) + spriteBatch.Draw(textures.TileAtlas, Rectangle(x, y - Sim.tilePixels, w, Sim.tilePixels), Nullable(ridgeSource), Color.White) + spriteBatch.Draw(textures.TileAtlas, Rectangle(x, y, w, Sim.tilePixels), Nullable(eaveSource), Color.White) + + /// 一次性绘制全部 P47 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层)。 + let draw + (spriteBatch: SpriteBatch) + (textures: VillageArt.ArtTextures) + (pixel: Texture2D) + (camera: Vector2) + (viewportWidth: int) + (viewportHeight: int) + (map: MapGen.Result) + (tick: int64) = + drawReflections spriteBatch textures camera map tick + drawBankReeds spriteBatch textures camera map + drawFloaters spriteBatch textures camera map tick + drawForeground spriteBatch textures viewportWidth viewportHeight tick diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 98e4cdf..3dbb8d4 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -123,6 +123,7 @@ module VillageArt = { TileAtlas: Texture2D CharacterAtlas: Texture2D InteriorAtlas: Texture2D + FloaterAtlas: Texture2D Cc0TileAtlas: Texture2D option } type private TileSprite = @@ -768,6 +769,8 @@ module VillageArt = // 不再采样任何外部/占位角色素材;尺寸与既有 characterSourceRectangle 完全一致。 CharacterAtlas = CharacterArt.buildAtlas device InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight + // P47:漂浮小元素(鸭/叶)改用原创点阵像素代码内嵌生成的图集(2 kind x 2 frame)。 + FloaterAtlas = FloaterArt.buildAtlas device Cc0TileAtlas = cc0 } let atlasTileRectangle (index: int) : Microsoft.Xna.Framework.Rectangle = |
