summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p47-analysis.txt21
-rw-r--r--docs/evidence/p47-close.pngbin87759 -> 99421 bytes
-rw-r--r--docs/evidence/p47-day.pngbin94785 -> 98590 bytes
-rw-r--r--docs/evidence/p47-night.pngbin146946 -> 177298 bytes
-rw-r--r--scripts/analyze-p47.py226
-rw-r--r--src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs28
-rw-r--r--src/LivingVillage.Desktop/Game.fs127
-rw-r--r--src/LivingVillage.Desktop/SceneDetail.fs74
-rw-r--r--src/LivingVillage.Desktop/SceneDetailRender.fs62
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
--- a/docs/evidence/p47-close.png
+++ b/docs/evidence/p47-close.png
Binary files differ
diff --git a/docs/evidence/p47-day.png b/docs/evidence/p47-day.png
index dc71c3b..9495757 100644
--- a/docs/evidence/p47-day.png
+++ b/docs/evidence/p47-day.png
Binary files differ
diff --git a/docs/evidence/p47-night.png b/docs/evidence/p47-night.png
index e3ef16d..dee3238 100644
--- a/docs/evidence/p47-night.png
+++ b/docs/evidence/p47-night.png
Binary files 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 + <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.
+ 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 <record-dir> <out-txt>
@@ -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)
[<TestMethod>]
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