From e46f1ddda07fc9c3d2617663ed28a3d1391c198b Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 20:58:24 +0800 Subject: p47: 整改 — 倒影改暖色垂直光柱(gap-skip穿桥面落水面,最长≥3格)+近景接入倒影+近景屋顶受光/背光双档luma MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/evidence/p47-analysis.txt | 21 +- docs/evidence/p47-close.png | Bin 87759 -> 99421 bytes docs/evidence/p47-day.png | Bin 94785 -> 98590 bytes docs/evidence/p47-night.png | Bin 146946 -> 177298 bytes scripts/analyze-p47.py | 226 ++++++++++++++------- .../P47SceneDetailTests.fs | 28 ++- src/LivingVillage.Desktop/Game.fs | 127 ++++++++---- src/LivingVillage.Desktop/SceneDetail.fs | 74 +++++-- src/LivingVillage.Desktop/SceneDetailRender.fs | 62 +++--- 9 files changed, 350 insertions(+), 188 deletions(-) diff --git a/docs/evidence/p47-analysis.txt b/docs/evidence/p47-analysis.txt index b019b12..d265303 100644 --- a/docs/evidence/p47-analysis.txt +++ b/docs/evidence/p47-analysis.txt @@ -1,14 +1,19 @@ -P47 scene-detail analysis (LV_P47_SHOT=1) +P47 scene-detail analysis (LV_P47_SHOT=1, leader rework) -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) +1) 倒影:垂直条纹长度 + 相对背景水面的 luma/暖色差 + p47-day: tiles=14 max_vertical_run=3 (required >= 3) luma_delta=+39.70 (required >= 8.0) warm_rb_delta=+180.17 (required >= 20.0) + p47-night: tiles=14 max_vertical_run=3 (required >= 3) luma_delta=+90.32 (required >= 8.0) warm_rb_delta=+142.39 (required >= 20.0) + p47-close: tiles=14 max_vertical_run=3 (required >= 3) luma_delta=+39.92 (required >= 8.0) warm_rb_delta=+176.28 (required >= 20.0) -2) 元素数量指标 - reeds=510 floats=64 reflections=18 sources=41 +2) 近景屋顶受光面 / 背光面 luma 差 + p47-day: roofs=40 lit=94.83 shadow=43.41 delta=+51.42 (required >= 16.0) + p47-night: roofs=40 lit=101.48 shadow=67.47 delta=+34.01 (required >= 16.0) + p47-close: roofs=40 lit=94.97 shadow=43.40 delta=+51.58 (required >= 16.0) -3) 无大黑块 / 无白块 / 不透明校验 +3) 元素数量指标 + reeds=510 floats=64 reflections=22 sources=41 maxstreak=3 + +4) 无大黑块 / 无白块 / 不透明校验 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 diff --git a/docs/evidence/p47-close.png b/docs/evidence/p47-close.png index c060508..00e3e2e 100644 Binary files a/docs/evidence/p47-close.png and b/docs/evidence/p47-close.png differ diff --git a/docs/evidence/p47-day.png b/docs/evidence/p47-day.png index dc71c3b..9495757 100644 Binary files a/docs/evidence/p47-day.png and b/docs/evidence/p47-day.png differ diff --git a/docs/evidence/p47-night.png b/docs/evidence/p47-night.png index e3ef16d..dee3238 100644 Binary files a/docs/evidence/p47-night.png and b/docs/evidence/p47-night.png differ diff --git a/scripts/analyze-p47.py b/scripts/analyze-p47.py index ae9ade1..6937413 100644 --- a/scripts/analyze-p47.py +++ b/scripts/analyze-p47.py @@ -1,13 +1,16 @@ #!/usr/bin/env python3 -"""P47 scene-detail analysis. +"""P47 scene-detail analysis (leader rework). Reads the `LV_P47_SHOT=1` day/night/closeup frames + /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. + 1. per-frame vertical reflection streaks: max run of consecutive tiles in one + column (leader ①: length >= 3 tiles) and reflection-vs-background-water + luma + warmth deltas (leader ① / ②: warm vertical column, not blue ripple); + 2. per-frame foreground roof lit-face vs shadow-face luma delta (leader ③: + two light tiers from one unified light direction); + 3. element counts (bank reeds / floating props / reflection streaks); + 4. no big black block / no white block / opaque frames. Usage: python3 scripts/analyze-p47.py @@ -22,14 +25,21 @@ 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+)") +MIN_VERTICAL_RUN = 3 +REFLECTION_DL_MIN = 8.0 +REFLECTION_WARM_MIN = 20.0 +ROOF_DELTA_MIN = 16.0 +REFLECTION_RE = re.compile( + r"p47-reflection (\S+) src=(\d+),(\d+) dst=(\d+),(\d+) ctrl=(-?\d+),(-?\d+) alpha=(\d+)") +ROOF_RE = re.compile(r"p47-roof (\S+) x=(\d+) y=(\d+) w=(\d+)") +ELEMENTS_RE = re.compile(r"p47-elements reeds=(\d+) floats=(\d+) reflections=(\d+) sources=(\d+) maxstreak=(\d+)") -def luma(path): - a = np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) +def rgb(path): + return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64) + + +def luma(a): return 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2] @@ -48,6 +58,79 @@ def has_solid_block(mask, min_side): return None +def parse_log(log_text, frame): + reflections, roofs = [], [] + for line in log_text.splitlines(): + m = REFLECTION_RE.search(line) + if m and m.group(1) == frame: + _, sx, sy, dx, dy, cx, cy, alpha = m.groups() + reflections.append((int(sx), int(sy), int(dx), int(dy), int(cx), int(cy), int(alpha))) + continue + m = ROOF_RE.search(line) + if m and m.group(1) == frame: + _, x, y, w = m.groups() + roofs.append((int(x), int(y), int(w))) + return reflections, roofs + + +def max_vertical_run(reflections): + cols = {} + for (_, _, dx, dy, _, _, _) in reflections: + cols.setdefault(dx, set()).add(dy) + best = 0 + for ys in cols.values(): + ys = sorted(ys) + cur = 1 + for i in range(1, len(ys)): + cur = cur + 1 if ys[i] == ys[i - 1] + TILE else 1 + best = max(best, cur) + return best + + +def reflection_metrics(img, reflections): + """Reflection tiles vs a background-water baseline taken from the same rows, + masking out +/-24px around every reflection column.""" + h, w, _ = img.shape + if not reflections: + return [] + rows = sorted({dy for (_, _, _, dy, _, _, _) in reflections}) + cols = sorted({dx for (_, _, dx, _, _, _, _) in reflections}) + mask = np.zeros((h, w), dtype=bool) + for dx in cols: + mask[:, max(0, dx - 24):min(w, dx + TILE + 24)] = True + background = [] + for dy in rows: + band = img[dy:dy + TILE] + m = ~mask[dy:dy + TILE] + if m.any(): + background.append(band[m].reshape(-1, 3)) + if not background: + return [] + bg = np.concatenate(background, axis=0) + bg_luma = (0.299 * bg[:, 0] + 0.587 * bg[:, 1] + 0.114 * bg[:, 2]).mean() + bg_warm = (bg[:, 0] - bg[:, 2]).mean() + out = [] + for (_, _, dx, dy, _, _, _) in reflections: + if dx < 0 or dy < 0 or dx + TILE > w or dy + TILE > h: + continue + tile = img[dy:dy + TILE, dx:dx + TILE].reshape(-1, 3) + tile_luma = (0.299 * tile[:, 0] + 0.587 * tile[:, 1] + 0.114 * tile[:, 2]).mean() + out.append((tile_luma - bg_luma, (tile[:, 0] - tile[:, 2]).mean() - bg_warm)) + return out + + +def roof_metrics(img, roofs): + h, w, _ = img.shape + lit, shadow = [], [] + for (x, y, ww) in roofs: + if x < 0 or x + ww > w or y - TILE < 0 or y + TILE > h or ww < 2: + continue + half = ww // 2 + lit.append(luma(img[y - TILE:y + TILE, x:x + half]).mean()) + shadow.append(luma(img[y - TILE:y + TILE, x + half:x + ww]).mean()) + return lit, shadow + + def main(argv): if len(argv) != 3: sys.stderr.write(__doc__) @@ -60,95 +143,80 @@ def main(argv): 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") + lines = ["P47 scene-detail analysis (LV_P47_SHOT=1, leader rework)", ""] + + # 1) reflection streaks: vertical length + warm vertical column vs background water. + lines.append("1) 倒影:垂直条纹长度 + 相对背景水面的 luma/暖色差") + for name in frames: + img = rgb(record_dir / f"{name}.png") + reflections, _ = parse_log(log_text, name) + run = max_vertical_run(reflections) + metrics = reflection_metrics(img, reflections) + if not metrics: + failures.append(f"{name}: no visible reflection tile to measure") + lines.append(f" {name}: no visible reflection tile") 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: + dl = float(np.mean([m[0] for m in metrics])) + warm = float(np.mean([m[1] for m in metrics])) + lines.append( + f" {name}: tiles={len(reflections)} max_vertical_run={run} (required >= {MIN_VERTICAL_RUN}) " + f"luma_delta={dl:+.2f} (required >= {REFLECTION_DL_MIN}) warm_rb_delta={warm:+.2f} (required >= {REFLECTION_WARM_MIN})") + if run < MIN_VERTICAL_RUN: + failures.append(f"{name} vertical reflection run {run} < {MIN_VERTICAL_RUN}") + if dl < REFLECTION_DL_MIN: + failures.append(f"{name} reflection luma delta {dl:.2f} < {REFLECTION_DL_MIN}") + if warm < REFLECTION_WARM_MIN: + failures.append(f"{name} reflection warm delta {warm:.2f} < {REFLECTION_WARM_MIN}") + lines.append("") + + # 2) foreground roofs: one lit face + one shadow face (>= two luma tiers). + lines.append("2) 近景屋顶受光面 / 背光面 luma 差") + for name in frames: + img = rgb(record_dir / f"{name}.png") + _, roofs = parse_log(log_text, name) + lit, shadow = roof_metrics(img, roofs) + if not lit: + failures.append(f"{name}: no fully-visible foreground roof to measure") + lines.append(f" {name}: no fully-visible roof") 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") + delta = float(np.mean(lit) - np.mean(shadow)) + lines.append( + f" {name}: roofs={len(lit)} lit={np.mean(lit):.2f} shadow={np.mean(shadow):.2f} " + f"delta={delta:+.2f} (required >= {ROOF_DELTA_MIN})") + if delta < ROOF_DELTA_MIN: + failures.append(f"{name} roof lit/shadow delta {delta:.2f} < {ROOF_DELTA_MIN}") lines.append("") - # 2) element-count metrics. - lines.append("2) 元素数量指标") + # 3) element-count metrics. + lines.append("3) 元素数量指标") 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)) + counts = tuple(int(m.group(i)) for i in range(1, 6)) 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]}") + lines.append(f" reeds={counts[0]} floats={counts[1]} reflections={counts[2]} sources={counts[3]} maxstreak={counts[4]}") if not seen_counts: failures.append("no p47-elements line in log") else: - reeds, floats, refl, sources = next(iter(seen_counts)) + reeds, floats, refl, sources, maxstreak = min(seen_counts) if min(reeds, floats, refl, sources) <= 0: failures.append("element counts must all be positive") + if maxstreak < MIN_VERTICAL_RUN: + failures.append(f"logged maxstreak {maxstreak} < {MIN_VERTICAL_RUN}") 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: + # 4) no big black block / no white block / opaque. + lines.append("4) 无大黑块 / 无白块 / 不透明校验") + for name in frames: a = np.asarray(Image.open(record_dir / f"{name}.png").convert("RGBA"), dtype=np.uint8) - rgb = a[:, :, :3] + rgbv = a[:, :, :3] alpha_min = int(a[:, :, 3].min()) - black = (rgb.max(axis=2) < 6) - white = (rgb.min(axis=2) >= 250) + black = (rgbv.max(axis=2) < 6) + white = (rgbv.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}") diff --git a/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs index 4ff0a66..ac8bf80 100644 --- a/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs +++ b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs @@ -20,7 +20,7 @@ type P47SceneDetailTests () = 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 |> List.forall (fun a -> a >= 96), "reflection alpha must stay legible (>= 96)") 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 @@ -40,14 +40,26 @@ type P47SceneDetailTests () = 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. + for (x, y, alpha, _) in streaks do + Assert.IsTrue(SceneDetail.isWaterSurface map x y, sprintf "reflection (%d,%d) must sit on a reflective surface" x y) + Assert.IsTrue(alpha >= 96, "reflection alpha must stay legible (>= 96)") + // every streak sits in the same column as a source, within stream range + bridge/own-structure gap. 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) + for (x, y, _, _) in streaks do + Assert.IsTrue( + [ 1 .. SceneDetail.reflectionRange + SceneDetail.reflectionGap ] + |> List.exists (fun d -> Set.contains (x, y - d) sourceSet), + sprintf "reflection (%d,%d) must be in the column below a source" x y) + // each light source keeps its kind so the renderer can tint per kind. + for (_, _, kind) in SceneDetail.lightSources map do + Assert.IsTrue(kind = SceneDetail.BridgeLantern || kind = SceneDetail.RoadLantern || kind = SceneDetail.RedLantern, + "light source kind must be one of the three known kinds") + // at least one source must produce a full >= 3 tile vertical reflection (leader acceptance). + let longest = + SceneDetail.lightSources map + |> List.map (fun (x, y, _) -> SceneDetail.reflectionLength map x y) + |> List.fold max 0 + Assert.IsTrue(longest >= 3, sprintf "the longest reflection must span >= 3 tiles, got %d" longest) [] member _.BankReedsAndFloatersAreDenseAndDeterministic () = diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index a187cc4..05afdc2 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -1004,60 +1004,98 @@ type LivingVillageGame() as this = | 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 ranked = + SceneDetail.lightSources map + |> List.map (fun (x, y, kind) -> + let length = SceneDetail.reflectionLength map x y + let kindRank = + match kind with + | SceneDetail.RedLantern -> 2 + | SceneDetail.BridgeLantern -> 1 + | SceneDetail.RoadLantern -> 0 + x, y, kind, length, kindRank) + let deep = ranked |> List.filter (fun (_, _, _, length, _) -> length >= 3) + let pick = + (if List.isEmpty deep then ranked else deep) + |> List.sortByDescending (fun (_, _, _, length, kindRank) -> kindRank, length) + |> List.tryHead + match pick with + | Some (x, y, _, _, _) -> (x, y) + | None -> + match SceneDetail.reflectionStreaks map with + | (rx, ry, _, _) :: _ -> (rx, ry - 1) + | [] -> (map.Core.MinX, map.Core.MinY) let closeup = - match SceneDetail.floatingProps map with - | (fx, fy, _) :: _ -> (fx, fy) + // 近景:优先倒影更长、且离漂浮元素更近的光源,保证近景同时有倒影与漂浮元素。 + let waterSources = + SceneDetail.lightSources map + |> List.filter (fun (x, y, _) -> SceneDetail.reflectionLength map x y >= 1) + let floatTiles = SceneDetail.floatingProps map |> List.map (fun (x, y, _) -> x, y) + let distToFloat (sx: int) (sy: int) = + if List.isEmpty floatTiles then 0 + else floatTiles |> List.map (fun (fx, fy) -> abs (fx - sx) + abs (fy - sy)) |> List.min + match waterSources with | [] -> riverside - let reportVisibleReflections () = + | _ -> + waterSources + |> List.map (fun (x, y, _) -> x, y, SceneDetail.reflectionLength map x y, distToFloat x y) + |> List.sortBy (fun (_, _, length, distance) -> -length, distance) + |> List.head + |> fun (x, y, _, _) -> (x, y) + let reportVisibleReflections (shotName: string) = 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 + for (lx, ly, kind) in SceneDetail.lightSources map do + for (rx, ry, alpha, _) in SceneDetail.reflectionStreakFor map lx ly kind do + let ssx = lx * Sim.tilePixels - int camera.X + let ssy = ly * Sim.tilePixels - int camera.Y + let rsx = rx * Sim.tilePixels - int camera.X + let rsy = ry * Sim.tilePixels - int camera.Y + // 空白对照:同排最近的非倒影水面格。 + let control = + [ (2, 0); (-2, 0); (4, 0); (-4, 0); (0, 1); (0, -1) ] + |> List.map (fun (ox, oy) -> rx + ox, ry + oy) + |> List.tryFind (fun (cx, cy) -> SceneDetail.isWaterSurface map cx cy) + if ssx >= 0 && ssx + Sim.tilePixels <= vw && ssy >= 0 && ssy + Sim.tilePixels <= vh + && rsy + Sim.tilePixels <= vh then + match control with + | Some (cx, cy) -> printfn - "p47-reflection src=%d,%d dst=%d,%d alpha=%d" - ssx ssy rsx rsy (SceneDetail.reflectionAlphaAt d) + "p47-reflection %s src=%d,%d dst=%d,%d ctrl=%d,%d alpha=%d" + shotName ssx ssy rsx rsy (cx * Sim.tilePixels - int camera.X) (cy * Sim.tilePixels - int camera.Y) alpha + | None -> + printfn "p47-reflection %s src=%d,%d dst=%d,%d ctrl=-1,-1 alpha=%d" shotName ssx ssy rsx rsy alpha + for (rx, ry, rw) in SceneDetail.foregroundRooftops vw vh world.Tick do + if rx + rw > 0 && rx < vw then + printfn "p47-roof %s x=%d y=%d w=%d" shotName rx ry rw printfn - "p47-elements reeds=%d floats=%d reflections=%d sources=%d" + "p47-elements reeds=%d floats=%d reflections=%d sources=%d maxstreak=%d" (List.length (SceneDetail.bankReedTiles map)) (List.length (SceneDetail.floatingProps map)) (List.length (SceneDetail.reflectionStreaks map)) - (List.length (SceneDetail.lightSourceTiles map)) + (List.length (SceneDetail.lightSources map)) + (SceneDetail.lightSources map + |> List.map (fun (x, y, _) -> SceneDetail.reflectionLength map x y) + |> List.fold max 0) match p47Step with | 0 -> setHour 12.0 placeCameraOn (fst riverside) (snd riverside) - reportVisibleReflections () p47PendingName <- "p47-day" + reportVisibleReflections "p47-day" p47Pending <- true | 1 -> setHour 23.5 placeCameraOn (fst riverside) (snd riverside) - reportVisibleReflections () p47PendingName <- "p47-night" + reportVisibleReflections "p47-night" p47Pending <- true | 2 -> setHour 12.0 placeCameraOn (fst closeup) (snd closeup) - reportVisibleReflections () p47PendingName <- "p47-close" + reportVisibleReflections "p47-close" p47Pending <- true | _ -> printfn "p47-shot=done" @@ -2144,20 +2182,6 @@ 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 @@ -2196,6 +2220,21 @@ type LivingVillageGame() as this = VillageArt.drawCc0BoatOverlay spriteBatch artTextures camera map profile.WorldTint | _ -> () + // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落。 + // 画在夜光/船之后,保证倒影不被灯笼呼吸光晕盖掉、仍可辨认。 + match riverscapeMap with + | Some map -> + SceneDetailRender.draw + spriteBatch + artTextures + pixel + camera + viewport.Width + viewport.Height + map + world.Tick + | None -> () + VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint diff --git a/src/LivingVillage.Desktop/SceneDetail.fs b/src/LivingVillage.Desktop/SceneDetail.fs index c51c47a..50313ac 100644 --- a/src/LivingVillage.Desktop/SceneDetail.fs +++ b/src/LivingVillage.Desktop/SceneDetail.fs @@ -17,14 +17,24 @@ module SceneDetail = x >= 0 && x < map.Width && y >= 0 && y < map.Height && map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water + /// 可倒影水面:河面(Water)与水田(PaddyField,同样走水面动画渲染)。 + let isWaterSurface (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 + || map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.PaddyField) + // ---------------- 河道水面倒影 ---------------- - let reflectionMaxAlpha = 128 - let reflectionRange = 3 + let reflectionMaxAlpha = 200 + let reflectionRange = 4 + /// 倒影射线允许穿过的非水面格数(如灯笼所立的桥面/岸沿),使「桥下红灯笼」的倒影 + /// 仍落在其正下方的河面,而不是被桥面格截断。 + let reflectionGap = 3 - /// 倒影 alpha 随离光源距离递减;到 0 即止,绝不留下硬块。 + /// 倒影 alpha 随离光源距离递减;最低仍保持 96,保证整条暖色光柱在蓝灰水面上 + /// 始终可辨(不会淡到被水面波纹吞掉)。 let reflectionAlphaAt (distance: int) : int = - max 0 (reflectionMaxAlpha - distance * 34) + max 96 (reflectionMaxAlpha - (distance - 1) * 26) /// 倒影微抖:复用 waterFrameTickAt 的 3 相位,映射到 -1 / 0 / +1 像素水平位移。 let reflectionWobbleAt (tick: int64) (x: int) (y: int) : int = @@ -33,20 +43,50 @@ module SceneDetail = | 1 -> 0 | _ -> 1 - /// 岸上光源:桥头灯笼 + 主路石灯笼 + 门口红灯笼,去重后即倒影源。 + /// 光源类型:桥头灯笼 / 主路石灯笼 / 门口红灯笼。倒影按类型着色。 + type LightKind = + | BridgeLantern + | RoadLantern + | RedLantern + + /// 岸上光源(含类型),按位置去重。 + let lightSources (map: MapGen.Result) : (int * int * LightKind) list = + let bridge = VillageArt.bridgeLanternTiles map |> List.map (fun (x, y) -> x, y, BridgeLantern) + let road = VillageArt.cc0LanternTiles map 12 |> List.map (fun (x, y) -> x, y, RoadLantern) + let red = VillageArt.cc0RedLanternTiles map |> List.map (fun (x, y) -> x, y, RedLantern) + (bridge @ road @ red) + |> List.distinctBy (fun (x, y, _) -> x, y) + 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) ]) + lightSources map |> List.map (fun (x, y, _) -> x, y) + + /// 某个光源向下的倒影:先穿过至多 reflectionGap 格非水面(桥面/岸沿),落到正下方 + /// 水面上,再沿连续水面画最多 reflectionRange 格;返回 (x, y, alpha, kind)。 + let reflectionStreakFor (map: MapGen.Result) (sx: int) (sy: int) (kind: LightKind) : (int * int * int * LightKind) list = + let mutable start = sy + 1 + let mutable gap = 0 + while gap <= reflectionGap && start < map.Height && not (isWaterSurface map sx start) do + start <- start + 1 + gap <- gap + 1 + if start >= map.Height || gap > reflectionGap || not (isWaterSurface map sx start) then + [] + else + [ for d in 0 .. reflectionRange - 1 do + let y = start + d + if isWaterSurface map sx y then yield (sx, y, reflectionAlphaAt (d + 1), kind) ] + + /// 光源正下方倒影的格数(用于挑选倒影长度足够的取证机位)。 + let reflectionLength (map: MapGen.Result) (x: int) (sy: int) : int = + lightSources map + |> List.filter (fun (sx, syy, _) -> sx = x && syy = sy) + |> List.tryHead + |> Option.map (fun (sx, syy, kind) -> reflectionStreakFor map sx syy kind |> List.length) + |> Option.defaultValue 0 + + /// 每个光源在其正下方连续水面格生成一条垂直倒影 (x, y, alpha, kind),最长 4 格。 + let reflectionStreaks (map: MapGen.Result) : (int * int * int * LightKind) list = + lightSources map + |> List.collect (fun (sx, sy, kind) -> reflectionStreakFor map sx sy kind) // ---------------- 岸边植被加密度 ---------------- diff --git a/src/LivingVillage.Desktop/SceneDetailRender.fs b/src/LivingVillage.Desktop/SceneDetailRender.fs index 2136d25..abe0b89 100644 --- a/src/LivingVillage.Desktop/SceneDetailRender.fs +++ b/src/LivingVillage.Desktop/SceneDetailRender.fs @@ -6,40 +6,31 @@ 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 + /// 河道倒影:每个光源在正下方水面格画一段暖色垂直光柱(外晕 20px + 核心 10px), + /// alpha 随离光源距离递减、逐格 ±1px 微抖,连成一条可辨认的暖色垂直条纹 + /// (明显区别于蓝灰横向波纹)。 + let private drawReflections (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 + (pixel: Texture2D) + (camera: Vector2) + (map: MapGen.Result) + (tick: int64) = + 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 cx = tileX camera x + let cy = tileY camera y + let halo = VillageArt.premultiply 255 150 70 (alpha * 3 / 5) + let core = VillageArt.premultiply 255 208 120 alpha + spriteBatch.Draw(pixel, Rectangle(cx + 4 + wobble, cy, 24, Sim.tilePixels), halo) + spriteBatch.Draw(pixel, Rectangle(cx + 10 + wobble, cy, 12, Sim.tilePixels), core) /// 岸边芦苇加密度:用既有芦苇精灵补绘一层贴水芦苇。 let private drawBankReeds (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) = @@ -69,17 +60,24 @@ module SceneDetailRender = Nullable(source), Color.White) - /// 近景楼层层错落:屏幕空间前景屋顶带,所有阴影统一投向右下(SceneDetail.shadowOffsetPx)。 - let private drawForeground (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (viewportWidth: int) (viewportHeight: int) (tick: int64) = + /// 近景楼层层错落:屏幕空间前景屋顶带。统一光源来自左上,故每段屋顶 + /// 左半受光(暖亮)、右半背光(压暗),并整体投影到右下(shadowOffsetPx)。 + let private drawForeground (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (pixel: Texture2D) (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 litColor = VillageArt.premultiply 255 236 196 46 + let darkColor = VillageArt.premultiply 6 10 18 110 let dx, dy = SceneDetail.shadowOffsetPx for (x, y, w) in SceneDetail.foregroundRooftops viewportWidth viewportHeight tick do - // 统一方向阴影:先把 eave 的暗色镜像平移一份到右下,再画本体。 + // 统一方向的投影:先把 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) + // 受光面 / 背光面:左半加暖亮、右半压暗,形成两档以上 luma 差。 + let half = max 1 (w / 2) + spriteBatch.Draw(pixel, Rectangle(x, y - Sim.tilePixels, half, 2 * Sim.tilePixels), litColor) + spriteBatch.Draw(pixel, Rectangle(x + half, y - Sim.tilePixels, w - half, 2 * Sim.tilePixels), darkColor) /// 一次性绘制全部 P47 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层)。 let draw @@ -91,7 +89,7 @@ module SceneDetailRender = (viewportHeight: int) (map: MapGen.Result) (tick: int64) = - drawReflections spriteBatch textures camera map tick + drawReflections spriteBatch pixel camera map tick drawBankReeds spriteBatch textures camera map drawFloaters spriteBatch textures camera map tick - drawForeground spriteBatch textures viewportWidth viewportHeight tick + drawForeground spriteBatch textures pixel viewportWidth viewportHeight tick -- cgit v1.2.3