summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 16:02:35 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 16:02:35 +0800
commit975d6721973fa526014fb2401e97075ecbf51c2c (patch)
tree2099e87d01f742ce57868e20a20abb2ba2fa8e7c
parent3196b4914d037052db0cf191be0431dc4ac4d596 (diff)
downloadliving-village-975d6721973fa526014fb2401e97075ecbf51c2c.tar.gz
p78: 夜帧水面微光斑、生成器世界炊烟与桥头窄光晕
-rw-r--r--docs/art-p78-plan.md10
-rw-r--r--docs/evidence/p78-analysis.txt14
-rw-r--r--docs/evidence/p78-day-noon.pngbin0 -> 116943 bytes
-rw-r--r--docs/evidence/p78-log.txt51
-rw-r--r--docs/evidence/p78-night.pngbin0 -> 171815 bytes
-rw-r--r--docs/维护说明.md19
-rw-r--r--scripts/analyze-p78.py189
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P78NightAmbienceTests.fs62
-rw-r--r--src/LivingVillage.Desktop/Game.fs131
-rw-r--r--src/LivingVillage.Desktop/SceneDetail.fs70
-rw-r--r--src/LivingVillage.Desktop/SceneDetailRender.fs75
12 files changed, 615 insertions, 7 deletions
diff --git a/docs/art-p78-plan.md b/docs/art-p78-plan.md
new file mode 100644
index 0000000..af3be63
--- /dev/null
+++ b/docs/art-p78-plan.md
@@ -0,0 +1,10 @@
+# P78 美术整改计划(夜晚氛围与场景细节)
+
+用户拍板方向下,角色动画/昼夜四档/HUD 药丸化/水墨开场已收口,当前差距是夜帧层次与小元素存在感。
+1. 夜帧水面加逐格微光斑:位置由地图 hash 稀疏取样、相位/位移由帧号驱动,同帧 byte-identical。
+2. 炊烟补到生成器世界(此前只在 64×48 样例平面):每户屋顶脊一缕,夜帧 alpha/体量更大更醒目。
+3. 桥头灯柱夜里加一圈窄光晕(半径 1 tile),不铺满河面,与主路灯笼的 4-tile 光晕区分。
+4. 夜帧加成层画在全部 veil 之后,避免被夜色乘暗;白天 `lanternGlow=0` 整层为空,白天帧零回归。
+5. 回归:`P78NightAmbienceTests` 钉纯函数(昼夜两档、确定性、有界);`scripts/analyze-p78.py` 在
+ 真实昼/夜截图上量光斑对比、暗部层次/无铁板一块、炊烟夜亮、桥头窄光晕与白天零回归。
+6. 素材仍走既有自绘/CC0 图集,不引入程序几何冒充成品;范围只做以上三项,其余留给下一单。
diff --git a/docs/evidence/p78-analysis.txt b/docs/evidence/p78-analysis.txt
new file mode 100644
index 0000000..d8526ee
--- /dev/null
+++ b/docs/evidence/p78-analysis.txt
@@ -0,0 +1,14 @@
+P78(夜晚氛围与场景细节)证据分析 — 真实 Desktop xvfb 1280x720,同日同机位 正午 vs 22 时
+近黑阈值 24;实心块 = 24x24 全近黑。
+
+day lantern=0.00(须 0.00)
+night glint stripe=141.2 ctl_mean=77.6 ctl_std=4.4 delta=63.5
+water bbox std: day=24.0 night=33.9
+night near-black frac=0.007 max24blk=184
+smoke region mean: day=100.6 night=143.8 delta=43.2 smoke_alpha=182
+bridge ring diff: day=-5.7 night=13.5
+day vs night differing_pixels=921600
+
+verdict=PASS
+ 夜帧光斑对比/非光斑平整、水面层次不低于白天、无铁板一块;
+ 炊烟夜帧更醒目、桥头窄光晕仅夜帧出现;白天 lantern=0 且两帧有差异。
diff --git a/docs/evidence/p78-day-noon.png b/docs/evidence/p78-day-noon.png
new file mode 100644
index 0000000..f0f9126
--- /dev/null
+++ b/docs/evidence/p78-day-noon.png
Binary files differ
diff --git a/docs/evidence/p78-log.txt b/docs/evidence/p78-log.txt
new file mode 100644
index 0000000..b5ff892
--- /dev/null
+++ b/docs/evidence/p78-log.txt
@@ -0,0 +1,51 @@
+world-mode=riverscape bounds=256x192
+mode=keyboard seed=42
+controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit
+fps=61.0 tick=0 pos=(4112,3088)
+fps=60.0 tick=0 pos=(4112,3088)
+splash=done frame=150
+menu=occupation-select
+new-game=ok seed=42 npcs=30 occupation=农夫 tick=0
+p78-shot=day hour=12.0 tick=2592000 lantern=0.00 water=-16,280,1312,96 glints=252 smoke=4 bridge_halo=16
+p78-regions day glint_src=-1,-1 glint_ctl=-1,-1 bridge_src=-1,-1
+p78-shot=day hour=12.0 tick=2592000 lantern=0.00 water=-16,280,1312,96 glints=252 smoke=4 bridge_halo=16
+p78-regions day glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=day hour=12.0 tick=2592000 lantern=0.00 water=-16,280,1312,96 glints=252 smoke=4 bridge_halo=16
+p78-regions day glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=day hour=12.0 tick=2592000 lantern=0.00 water=-16,280,1312,96 glints=252 smoke=4 bridge_halo=16
+p78-regions day glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-frame=p78-day tick=2592000 camera=(6352,4328)
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-shot=night hour=22.0 tick=4752000 lantern=1.00 water=-16,280,1312,96 smoke_src=656,344 smoke_alpha=182 smoke_cell=28 glint_alpha=124
+p78-regions night glint_src=176,280 glint_ctl=208,280 bridge_src=592,280
+p78-frame=p78-night tick=4752000 camera=(6352,4328)
+p78-shot=done
diff --git a/docs/evidence/p78-night.png b/docs/evidence/p78-night.png
new file mode 100644
index 0000000..dd165c7
--- /dev/null
+++ b/docs/evidence/p78-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index a77ba90..aa544af 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -276,6 +276,25 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
24×24 近黑块,亮字充足;几何上 panel/dialog 底边 < hotbar 顶;昼/夜、对话两态帧 pairwise diff>0,
落 `docs/evidence/p77-*`。
+## P78 夜晚氛围与场景细节(水面微光斑 / 炊烟 / 桥头窄光晕)
+
+- 夜帧水面逐格微光斑(`SceneDetail.waterGlintTiles/waterGlintPhase/waterGlintAlpha/waterGlintOffset`
+ + `SceneDetailRender.drawNightWater`):水面/水田按 `(x*5+y*3)%6` 稀疏取样,相位/±1px 位移由帧号
+ 驱动,同帧逐字节可复现;夜帧两档呼吸(满亮/六成),白天恒 0。
+- 炊烟补到生成器世界(此前仅 64×48 样例平面):`SceneDetail.smokeSourceTiles`(每户民居/农舍屋顶脊
+ 上一格)+ `smokeDrift/smokeAlphaAt/smokeCellSize`,`SceneDetailRender.drawSmoke` 用江南图集 22-24
+ 三相位烟精灵绘制;夜帧 alpha(182 vs 78)与体量(28 vs 20)更大更醒目,白天仍可辨。
+- 桥头灯柱窄光晕:`SceneDetailRender.drawBridgeHalos` 复用 `VillageArt.lanternGlowLayers`,半径恒
+ `bridgeHaloRadiusTiles=1`(窄),只围灯柱一圈,与主路灯笼 4-tile 光晕区分。
+- 绘制顺序:夜帧加成层(光斑 + 桥头光晕)画在全部 veil 之后(`Game.DrawWorldView`),故不被夜色乘暗;
+ 白天 `lanternGlow=0` 整层为空、白天帧零回归。炊烟随主细节层绘制(昼/夜都在)。
+- **回归**:`P78NightAmbienceTests`(光斑只落水面、白天恒 0、夜帧两档且帧号确定性、位移有界;炊烟
+ 夜帧 alpha/体量更大、升腾有界确定性;桥头光晕半径恒 1 且灯柱瓦片同源)。
+- **证据**:`LV_P78_SHOT=1` 同机位正午/22 时两帧 → `scripts/analyze-p78.py`:夜帧光斑条带对比
+ +63.5、非光斑水面 std 4.4、水面 bbox std 夜 33.9 ≥ 昼 24.0、近黑占比 0.007 无实心 24×24 近黑块;
+ 炊烟区夜-昼 +43.2(alpha 182);桥头环带差 夜 +13.5 / 昼 −5.7;白天 lantern=0.00,
+ 落 `docs/evidence/p78-*`。
+
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
diff --git a/scripts/analyze-p78.py b/scripts/analyze-p78.py
new file mode 100644
index 0000000..f97edd5
--- /dev/null
+++ b/scripts/analyze-p78.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""P78(夜晚氛围与场景细节)基准截图抽帧分析。
+
+读 `LV_P78_SHOT=1` 的真实 xvfb 1280x720 帧(同日同时刻机位先拍正午、再拍 22 时)与日志,
+在像素层给出可测断言,避免只靠 vision 手判:
+
+ 1. 夜帧水面逐格微光斑:光斑格条带亮度 - 相邻非光斑水格亮度 >= 10;非光斑水格 std <= 12
+ (暗部平整、光斑不是噪声)。
+ 2. 暗部层次:夜帧水面 bbox std >= 白天水面 bbox std,且夜帧无实心 24x24 近黑块、
+ 近黑占比 <= 0.02(不是「铁板一块」)。
+ 3. 炊烟:夜帧炊烟区亮度 - 白天炊烟区亮度 >= 20(夜帧更醒目);日志 smoke_alpha(夜) > 80。
+ 4. 桥头灯柱窄光晕:灯柱环带亮度 - 外圈亮度 夜帧 >= 8,白天 < 8(夜帧才加、窄)。
+ 5. 白天零回归:日志 lantern=0.00;白天/夜帧 pairwise diff > 0。
+
+Usage: python3 scripts/analyze-p78.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+from numpy.lib.stride_tricks import sliding_window_view
+
+REGIONS_RE = re.compile(
+ r"^p78-regions (day|night) glint_src=(-?\d+),(-?\d+) glint_ctl=(-?\d+),(-?\d+) bridge_src=(-?\d+),(-?\d+)",
+ re.MULTILINE,
+)
+NIGHT_RE = re.compile(
+ r"^p78-shot=night .*smoke_src=(-?\d+),(-?\d+) smoke_alpha=(\d+)",
+ re.MULTILINE,
+)
+DAY_RE = re.compile(r"^p78-shot=day .*lantern=([\d.]+)", re.MULTILINE)
+
+TILE = 32
+BLOCK = 24
+NEAR_BLACK = 24
+WATER_BBOX_Y = (280, 376)
+
+
+def luma(a):
+ return 0.299 * a[:, :, 0] + 0.587 * a[:, :, 1] + 0.114 * a[:, :, 2]
+
+
+def last_regions(text, tag):
+ hits = [m for m in REGIONS_RE.finditer(text) if m.group(1) == tag]
+ if not hits:
+ return None
+ m = hits[-1]
+ return {
+ "glint": (int(m.group(2)), int(m.group(3))),
+ "ctl": (int(m.group(4)), int(m.group(5))),
+ "bridge": (int(m.group(6)), int(m.group(7))),
+ }
+
+
+def ring_mean(a, cx, cy, r0, r1):
+ y, x = np.ogrid[: a.shape[0], : a.shape[1]]
+ d = np.sqrt((x - cx) ** 2 + (y - cy) ** 2)
+ m = (d >= r0) & (d < r1)
+ return float(luma(a)[m].mean())
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
+ failures = []
+ lines = [
+ "P78(夜晚氛围与场景细节)证据分析 — 真实 Desktop xvfb 1280x720,同日同机位 正午 vs 22 时",
+ "近黑阈值 24;实心块 = 24x24 全近黑。",
+ "",
+ ]
+
+ day_img = rec / "p78-day.png"
+ night_img = rec / "p78-night.png"
+ if not day_img.exists() or not night_img.exists():
+ out.write_text("missing p78-day.png / p78-night.png\nverdict=FAIL\n", encoding="utf-8")
+ print("missing frames")
+ return 1
+ day = np.asarray(Image.open(day_img).convert("RGB"), dtype=np.int16)
+ night = np.asarray(Image.open(night_img).convert("RGB"), dtype=np.int16)
+
+ day_regions = last_regions(log, "day")
+ night_regions = last_regions(log, "night")
+ if not day_regions or not night_regions:
+ failures.append("missing p78-regions day/night")
+ if "p78-shot=day" not in log or "p78-shot=night" not in log:
+ failures.append("missing p78-shot day/night lines")
+
+ day_lantern = DAY_RE.search(log)
+ if day_lantern and float(day_lantern.group(1)) != 0.0:
+ failures.append(f"白天 lantern={day_lantern.group(1)} 应为 0.00(白天零回归)")
+ lines.append(f"day lantern={day_lantern.group(1) if day_lantern else '?'}(须 0.00)")
+
+ # 1. 夜帧水面微光斑对比。
+ if night_regions:
+ gx, gy = night_regions["glint"]
+ cx, cy = night_regions["ctl"]
+ if gx < 0 or cx < 0:
+ failures.append("夜帧未定位到可见光斑/对照水格")
+ else:
+ g_tile = luma(night)[gy : gy + TILE, gx : gx + TILE]
+ c_tile = luma(night)[cy : cy + TILE, cx : cx + TILE]
+ stripe = g_tile[12:16, 6:26].mean()
+ ctl_mean = c_tile.mean()
+ ctl_std = c_tile.std()
+ delta = stripe - ctl_mean
+ lines.append(
+ f"night glint stripe={stripe:.1f} ctl_mean={ctl_mean:.1f} ctl_std={ctl_std:.1f} delta={delta:.1f}"
+ )
+ if delta < 10:
+ failures.append(f"夜帧光斑对比不足 delta={delta:.1f} < 10")
+ if ctl_std > 12:
+ failures.append(f"夜帧非光斑水面不够平整 std={ctl_std:.1f} > 12")
+
+ # 2. 暗部层次 + 无铁板一块。
+ day_water = luma(day)[WATER_BBOX_Y[0] : WATER_BBOX_Y[1], :]
+ night_water = luma(night)[WATER_BBOX_Y[0] : WATER_BBOX_Y[1], :]
+ lines.append(
+ f"water bbox std: day={day_water.std():.1f} night={night_water.std():.1f}"
+ )
+ if night_water.std() < day_water.std():
+ failures.append("夜帧水面层次低于白天(暗部被压平)")
+ mask = (night[:, :, 0] <= NEAR_BLACK) & (night[:, :, 1] <= NEAR_BLACK) & (night[:, :, 2] <= NEAR_BLACK)
+ frac = float(mask.mean())
+ block = int(sliding_window_view(mask.astype(np.int64), (BLOCK, BLOCK)).sum(axis=(2, 3)).max())
+ lines.append(f"night near-black frac={frac:.3f} max24blk={block}")
+ if frac > 0.02:
+ failures.append(f"夜帧近黑占比 {frac:.3f} > 0.02(铁板一块)")
+ if block >= BLOCK * BLOCK:
+ failures.append("夜帧存在实心 24x24 近黑块")
+
+ # 3. 炊烟夜帧更醒目。
+ night_meta = NIGHT_RE.search(log)
+ if not night_meta:
+ failures.append("missing p78-shot=night smoke metadata")
+ else:
+ sx, sy, smoke_alpha = int(night_meta.group(1)), int(night_meta.group(2)), int(night_meta.group(3))
+ day_smoke = luma(day)[320:400, 620:720].mean()
+ night_smoke = luma(night)[320:400, 620:720].mean()
+ lines.append(
+ f"smoke region mean: day={day_smoke:.1f} night={night_smoke:.1f} delta={night_smoke - day_smoke:.1f} smoke_alpha={smoke_alpha}"
+ )
+ if night_smoke - day_smoke < 20:
+ failures.append(f"夜帧炊烟不够醒目 delta={night_smoke - day_smoke:.1f} < 20")
+ if smoke_alpha <= 80:
+ failures.append(f"夜帧炊烟 alpha {smoke_alpha} 应 > 80")
+
+ # 4. 桥头灯柱窄光晕。
+ if day_regions and night_regions and night_regions["bridge"][0] >= 0:
+ bx, by = night_regions["bridge"]
+ ccx, ccy = bx + TILE // 2, by + TILE // 2
+ day_ring = ring_mean(day, ccx, ccy, 8, 30) - ring_mean(day, ccx, ccy, 40, 64)
+ night_ring = ring_mean(night, ccx, ccy, 8, 30) - ring_mean(night, ccx, ccy, 40, 64)
+ lines.append(f"bridge ring diff: day={day_ring:.1f} night={night_ring:.1f}")
+ if night_ring < 8:
+ failures.append(f"夜帧桥头窄光晕不足 diff={night_ring:.1f} < 8")
+ if day_ring >= 8:
+ failures.append(f"白天桥头不应有夜光晕 diff={day_ring:.1f}")
+
+ # 5. 白天/夜帧差异。
+ diff = int((np.abs(day.astype(np.int64) - night.astype(np.int64)).sum(axis=2) > 0).sum())
+ lines.append(f"day vs night differing_pixels={diff}")
+ if diff == 0:
+ failures.append("白天与夜帧完全相同")
+
+ lines.append("")
+ if failures:
+ lines.append("verdict=FAIL")
+ for f in failures:
+ lines.append(" - " + f)
+ else:
+ lines.append("verdict=PASS")
+ lines.append(" 夜帧光斑对比/非光斑平整、水面层次不低于白天、无铁板一块;")
+ lines.append(" 炊烟夜帧更醒目、桥头窄光晕仅夜帧出现;白天 lantern=0 且两帧有差异。")
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 1ea6349..5f2ac14 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -49,6 +49,7 @@
<Compile Include="P75HudStripTests.fs" />
<Compile Include="P76HotbarPanelTests.fs" />
<Compile Include="P77LayoutTests.fs" />
+ <Compile Include="P78NightAmbienceTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P78NightAmbienceTests.fs b/src/LivingVillage.Desktop.Tests/P78NightAmbienceTests.fs
new file mode 100644
index 0000000..2a06798
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P78NightAmbienceTests.fs
@@ -0,0 +1,62 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+
+/// P78 夜晚氛围与场景细节回归(纯函数层):
+/// * 夜帧水面逐格微光斑:只落在水面/水田、帧号驱动且确定性、昼夜两档(白天恒 0);
+/// * 炊烟:生成器世界每户一个烟源、夜帧 alpha/尺寸更大、升腾摇晃有界且确定性;
+/// * 桥头灯柱近圈光晕:半径恒为 1 tile(窄光晕),复用既有灯柱瓦片。
+[<TestClass>]
+type P78NightAmbienceTests () =
+
+ let map = MapGen.generate (MapGen.defaultParams 42UL)
+
+ [<TestMethod>]
+ member _.WaterGlintsAreNightOnlyDeterministicAndTwoTier () =
+ let glints = SceneDetail.waterGlintTiles map
+ Assert.IsTrue(not (List.isEmpty glints), "生成器世界须有水面光斑候选格")
+ for (x, y) in glints do
+ Assert.IsTrue(SceneDetail.isWaterSurface map x y, sprintf "光斑 (%d,%d) 必须落在水面/水田" x y)
+ // 白天恒定关闭(零回归),夜帧两个亮度档。
+ for phase in 0 .. SceneDetail.waterGlintPhaseCount - 1 do
+ Assert.IsTrue((SceneDetail.waterGlintAlpha 0.0f phase) = 0, "白天光斑必须为 0(零回归)")
+ let nightBright = SceneDetail.waterGlintAlpha 1.0f 0
+ let nightDim = SceneDetail.waterGlintAlpha 1.0f 1
+ Assert.IsTrue(nightBright > 0, "夜帧光斑须可见")
+ Assert.IsTrue(nightBright > nightDim && nightDim > 0, "光斑须有两档呼吸层次")
+ // 帧号驱动且确定性。
+ for tick in [ 0L; 8L; 16L; 32L ] do
+ let phase = SceneDetail.waterGlintPhase tick 3 5
+ Assert.IsTrue(phase >= 0 && phase < SceneDetail.waterGlintPhaseCount, "相位越界")
+ Assert.IsTrue((SceneDetail.waterGlintPhase tick 3 5) = phase, "相位须确定性")
+ Assert.IsTrue(abs (SceneDetail.waterGlintOffset tick 3 5) <= 1, "光斑位移须有界 ±1px")
+ Assert.IsTrue(
+ (SceneDetail.waterGlintPhase 0L 3 5) <> (SceneDetail.waterGlintPhase 8L 3 5)
+ || (SceneDetail.waterGlintPhase 8L 3 5) <> (SceneDetail.waterGlintPhase 16L 3 5)
+ || (SceneDetail.waterGlintPhase 16L 3 5) <> (SceneDetail.waterGlintPhase 24L 3 5),
+ "光斑相位须随帧号变化")
+
+ [<TestMethod>]
+ member _.SmokeIsProminentAtNightAndDeterministic () =
+ let sources = SceneDetail.smokeSourceTiles map
+ let expected = (map.Buildings @ map.Farmhouses) |> List.length
+ Assert.IsTrue(not (List.isEmpty sources), "生成器世界须有炊烟源(民居/农舍)")
+ Assert.IsTrue(List.length sources <= expected, "去重后炊烟源数不得超过户数")
+ let dayAlpha = SceneDetail.smokeAlphaAt 0.0f
+ let nightAlpha = SceneDetail.smokeAlphaAt 1.0f
+ Assert.IsTrue(dayAlpha > 0, "白天炊烟仍须可辨")
+ Assert.IsTrue(nightAlpha > dayAlpha, "夜帧炊烟须更醒目")
+ Assert.IsTrue((SceneDetail.smokeCellSize 1.0f) > (SceneDetail.smokeCellSize 0.0f), "夜帧炊烟体量须更大")
+ for tick in [ 0L; 24L; 48L ] do
+ let frame = SceneDetail.smokeFrame tick
+ Assert.IsTrue(frame >= 0 && frame <= 2, "炊烟需三相位")
+ let sway, rise = SceneDetail.smokeDrift tick 7
+ Assert.IsTrue(abs sway <= 1, "炊烟摇曳须有界 ±1px")
+ Assert.IsTrue(rise <= 0 && rise >= -4, "炊烟只向上且位移有界")
+ Assert.IsTrue((SceneDetail.smokeDrift tick 7) = (sway, rise), "炊烟位移须确定性")
+
+ [<TestMethod>]
+ member _.BridgeHaloIsNarrowAndMatchesLanternTiles () =
+ Assert.IsTrue((SceneDetail.bridgeHaloRadiusTiles = 1), "桥头光晕须为窄光晕(1 tile)")
+ Assert.IsTrue((SceneDetail.bridgeLanternTiles map) = (VillageArt.bridgeLanternTiles map), "桥头灯柱瓦片须同源")
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 8c1ea21..a83f04c 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -396,6 +396,10 @@ type LivingVillageGame() as this =
// 6/12/18/22 时各拍一帧(同机位),四张真实帧用于四时段光照档差异核验。
let p73ShotMode = Environment.GetEnvironmentVariable("LV_P73_SHOT") = "1"
let mutable p73Step = 0
+ // P78 证据钩子:LV_P78_SHOT=1 时在同一机位拍「白天 / 夜晚」两帧,机位同时纳入桥头灯柱、
+ // 河面与一户带炊烟的民居,用于核验夜帧水面光斑/桥头窄光晕/炊烟与白天零回归。
+ let p78ShotMode = Environment.GetEnvironmentVariable("LV_P78_SHOT") = "1"
+ let mutable p78Step = 0
// P52 证据钩子:LV_P52_SHOT=1 时选职业开局 → 走到 NPC 旁用真实 Interact 开对话 →
// 真实 BuyFromTarget / SellToTarget 各一次,拍「对话入口 / 买入后 / 卖出后」三帧;
// 每笔交易打印钱与背包数量前后值,供 analyze 脚本算 delta。
@@ -1482,7 +1486,114 @@ type LivingVillageGame() as this =
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 p73ShotMode then
+ if p78ShotMode then
+ // 机位:倒影最长的桥头灯柱 → 其最近一户民居的中点;同机位先拍正午(白天对照),
+ // 再拍 22 时(夜帧),两帧差异只来自光照与夜帧加成层。
+ match riverscapeMap with
+ | Some map ->
+ let bridgePick =
+ SceneDetail.bridgeLanternTiles map
+ |> List.sortByDescending (fun (x, y) -> SceneDetail.reflectionLength map x y)
+ |> List.tryHead
+ match bridgePick with
+ | None ->
+ printfn "p78-shot=error no-bridge-lantern"
+ this.Exit()
+ | Some (bx, by) ->
+ let nearestHouse =
+ (map.Buildings @ map.Farmhouses)
+ |> List.sortBy (fun b -> abs (b.DoorX - bx) + abs (b.DoorY - by))
+ |> List.tryHead
+ let camTileX, camTileY =
+ match nearestHouse with
+ | Some b -> (bx + b.DoorX) / 2, (by + b.DoorY) / 2
+ | None -> bx, by
+ let waterBounds () =
+ let vw = this.GraphicsDevice.Viewport.Width
+ let vh = this.GraphicsDevice.Viewport.Height
+ let visible =
+ [ for y in 0 .. map.Height - 1 do
+ for x in 0 .. map.Width - 1 do
+ if SceneDetail.isWaterSurface map x y then
+ let sx = x * Sim.tilePixels - int camera.X
+ let sy = y * Sim.tilePixels - int camera.Y
+ if sx + Sim.tilePixels > 0 && sx < vw && sy + Sim.tilePixels > 0 && sy < vh then
+ yield (sx, sy) ]
+ match visible with
+ | [] -> (0, 0, 0, 0)
+ | _ ->
+ let xs = visible |> List.map fst
+ let ys = visible |> List.map snd
+ let x0, y0 = List.min xs, List.min ys
+ let x1 = List.max xs + Sim.tilePixels
+ let y1 = List.max ys + Sim.tilePixels
+ (x0, y0, x1 - x0, y1 - y0)
+ let smokeScreen =
+ match nearestHouse with
+ | Some b -> (b.DoorX * Sim.tilePixels - int camera.X, (b.DoorY - 3) * Sim.tilePixels - int camera.Y)
+ | None -> (-1, -1)
+ let vw = this.GraphicsDevice.Viewport.Width
+ let vh = this.GraphicsDevice.Viewport.Height
+ let screenX (x: int) = x * Sim.tilePixels - int camera.X
+ let screenY (y: int) = y * Sim.tilePixels - int camera.Y
+ let visibleTile (x: int) (y: int) =
+ screenX x >= 0 && screenX x + Sim.tilePixels <= vw && screenY y >= 0 && screenY y + Sim.tilePixels <= vh
+ let glintTiles = SceneDetail.waterGlintTiles map
+ let glintPick = glintTiles |> List.tryPick (fun (x, y) -> if visibleTile x y then Some(x, y) else None)
+ let glintCtl =
+ glintPick
+ |> Option.bind (fun (gx, gy) ->
+ [ (gx + 1, gy); (gx + 2, gy); (gx - 1, gy) ]
+ |> List.tryFind (fun (cx, cy) -> SceneDetail.isWaterSurface map cx cy && not (List.contains (cx, cy) glintTiles)))
+ let bridgeScreen = SceneDetail.bridgeLanternTiles map |> List.tryFind (fun (x, y) -> visibleTile x y)
+ let regionFmt (x, y) = sprintf "%d,%d" (screenX x) (screenY y)
+ let regionLine (tag: string) =
+ printfn
+ "p78-regions %s glint_src=%s glint_ctl=%s bridge_src=%s"
+ tag
+ (match glintPick with Some p -> regionFmt p | None -> "-1,-1")
+ (match glintCtl with Some p -> regionFmt p | None -> "-1,-1")
+ (match bridgeScreen with Some p -> regionFmt p | None -> "-1,-1")
+ if p78Step = 0 then
+ setHour 12.0
+ placeCameraOn camTileX camTileY
+ p47PendingName <- "p78-day"
+ p47Pending <- true
+ let wx, wy, ww, wh = waterBounds ()
+ printfn
+ "p78-shot=day hour=12.0 tick=%d lantern=%.2f water=%d,%d,%d,%d glints=%d smoke=%d bridge_halo=%d"
+ world.Tick
+ (M6Presentation.effectiveLanternGlow world.Tick)
+ wx wy ww wh
+ (List.length (SceneDetail.waterGlintTiles map))
+ (List.length (SceneDetail.smokeSourceTiles map))
+ (List.length (SceneDetail.bridgeLanternTiles map))
+ regionLine "day"
+ elif p78Step = 1 then
+ // 保持同一机位,只把时刻改到夜晚。
+ setHour 22.0
+ p47PendingName <- "p78-night"
+ p47Pending <- true
+ let wx, wy, ww, wh = waterBounds ()
+ printfn
+ "p78-shot=night hour=22.0 tick=%d lantern=%.2f water=%d,%d,%d,%d smoke_src=%d,%d smoke_alpha=%d smoke_cell=%d glint_alpha=%d"
+ world.Tick
+ (M6Presentation.effectiveLanternGlow world.Tick)
+ wx wy ww wh
+ (fst smokeScreen) (snd smokeScreen)
+ (SceneDetail.smokeAlphaAt (M6Presentation.effectiveLanternGlow world.Tick))
+ (SceneDetail.smokeCellSize (M6Presentation.effectiveLanternGlow world.Tick))
+ (SceneDetail.waterGlintAlpha
+ (M6Presentation.effectiveLanternGlow world.Tick)
+ (SceneDetail.waterGlintPhase world.Tick camTileX camTileY))
+ regionLine "night"
+ else
+ printfn "p78-shot=done"
+ this.Exit()
+ | None ->
+ printfn "p78-shot=error no-riverscape-map"
+ this.Exit()
+ elif p73ShotMode then
// 同一桥头光源机位的四时段巡拍;tick 只经 setHour 改变,保证画面差异只来自光照档。
let hours = [ ("p73-morning", 6.0); ("p73-noon", 12.0); ("p73-dusk", 18.0); ("p73-night", 22.0) ]
if p73Step < hours.Length then
@@ -1626,6 +1737,10 @@ type LivingVillageGame() as this =
printfn "p73-frame=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)"
p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y
p73Step <- p73Step + 1
+ elif p78ShotMode then
+ printfn "p78-frame=%s tick=%d camera=(%.0f,%.0f)"
+ p47PendingName world.Tick camera.X camera.Y
+ p78Step <- p78Step + 1
else
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
@@ -2431,7 +2546,7 @@ type LivingVillageGame() as this =
elif p57ShotMode then
if menu.Page = MainMenu then
this.PrepareP57Shot()
- elif p47ShotMode || p73ShotMode then
+ elif p47ShotMode || p73ShotMode || p78ShotMode then
if menu.Page = MainMenu then
this.DispatchMenuInput Confirm
elif menu.Page = OccupationSelect then
@@ -2925,7 +3040,7 @@ type LivingVillageGame() as this =
this.CaptureP46Shot()
if p57ShotMode then
this.CaptureP57Shot()
- if (p47ShotMode || p73ShotMode) && menu.Page = Playing then
+ if (p47ShotMode || p73ShotMode || p78ShotMode) && menu.Page = Playing then
this.CaptureP47Shot()
if p52ShotMode && menu.Page = Playing then
this.CaptureP52Shot()
@@ -3259,7 +3374,7 @@ type LivingVillageGame() as this =
VillageArt.drawCc0BoatOverlay spriteBatch artTextures camera map worldTint
| _ -> ()
- // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落。
+ // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落 + P78 炊烟。
// 画在夜光/船之后,保证倒影不被灯笼呼吸光晕盖掉、仍可辨认。
match riverscapeMap with
| Some map ->
@@ -3272,6 +3387,7 @@ type LivingVillageGame() as this =
viewport.Height
map
world.Tick
+ lanternGlow
| None -> ()
VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos
@@ -3304,6 +3420,13 @@ type LivingVillageGame() as this =
if period.VeilAlpha > 0 then
let v = period.Veil
spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply (int v.R) (int v.G) (int v.B) period.VeilAlpha)
+
+ // P78:夜帧加成层(水面逐格微光斑 + 桥头灯柱窄光晕)。画在全部 veil 之后,
+ // 故不被夜色乘暗、暗部层次仍可读;白天 lanternGlow=0,本层为空、白天帧零回归。
+ match riverscapeMap with
+ | Some map when lanternGlow > 0.0f ->
+ SceneDetailRender.drawNight spriteBatch pixel camera map world.Tick lanternGlow
+ | _ -> ()
spriteBatch.End()
// Contextual floating prompt over the unique target; same CJK atlas font path as the HUD.
diff --git a/src/LivingVillage.Desktop/SceneDetail.fs b/src/LivingVillage.Desktop/SceneDetail.fs
index 50313ac..54665ac 100644
--- a/src/LivingVillage.Desktop/SceneDetail.fs
+++ b/src/LivingVillage.Desktop/SceneDetail.fs
@@ -144,7 +144,75 @@ module SceneDetail =
let floatFrame (tick: int64) (seed: int) : int =
int (positiveModulo64 2L (tick / 32L + int64 seed))
- // ---------------- 近景楼层层错落(屏幕空间前景层) ----------------
+ // ---------------- P78 夜帧水面逐格微光斑 ----------------
+
+ /// 光斑候选水面格:水面/水田按位置 hash 稀疏取样(约 1/6 水面格),位置确定性。
+ /// 帧号只驱动相位/位移,同帧同 tick 逐字节可复现。
+ let waterGlintTiles (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
+ if isWaterSurface map x y && (x * 5 + y * 3) % 6 = 0 then
+ yield (x, y) ]
+
+ let waterGlintPhaseCount = 4
+
+ /// 逐格相位(0..3),只随 tick 慢速切换,让相邻光斑不同步闪烁。
+ let waterGlintPhase (tick: int64) (x: int) (y: int) : int =
+ int (positiveModulo64 4L (tick / 8L + int64 (x * 3 + y * 5)))
+
+ /// 光斑 alpha:夜越深越亮;相位在满亮/六成亮间呼吸,保证暗部有两档以上层次。
+ let waterGlintAlpha (nightStrength: float32) (phase: int) : int =
+ if nightStrength <= 0.0f then
+ 0
+ else
+ let baseAlpha = 46.0f + 78.0f * max 0.0f (min 1.0f nightStrength)
+ int (baseAlpha * (if phase % 2 = 0 then 1.0f else 0.6f))
+
+ /// 帧号驱动的光斑水平位移(±1px),镜像水面波纹,同帧确定性。
+ let waterGlintOffset (tick: int64) (x: int) (y: int) : int =
+ int (positiveModulo64 3L (tick / 16L + int64 (x + y))) - 1
+
+ // ---------------- P78 炊烟(生成器世界) ----------------
+
+ /// 生成器世界的炊烟源:每户民居与散落农舍屋顶脊上一格(沿用样例世界 0,-3 偏移)。
+ let smokeSourceTiles (map: MapGen.Result) : (int * int) list =
+ (map.Buildings @ map.Farmhouses)
+ |> List.map (fun building -> building.DoorX, building.DoorY - 3)
+ |> List.distinct
+
+ /// 炊烟三相位(复用 VillageArt 的确定性帧),供绘制取精灵。
+ let smokeFrame (tick: int64) : int =
+ VillageArt.smokeFrameTick tick
+
+ /// 炊烟上升/摇曳位移:随 tick 缓慢升高并左右摆动,有界、逐帧连续。
+ let smokeDrift (tick: int64) (seed: int) : int * int =
+ let rise = int (positiveModulo64 5L (tick / 24L + int64 seed))
+ let sway = int (positiveModulo64 3L (tick / 32L + int64 (seed * 3 + 1))) - 1
+ (sway, -rise)
+
+ /// 炊烟 alpha:夜帧更醒目(用户要求夜间存在感更强),白天仍可辨。
+ let smokeAlphaAt (nightStrength: float32) : int =
+ let night = max 0.0f (min 1.0f nightStrength)
+ int (78.0f + 104.0f * night)
+
+ /// 炊烟绘制边长:夜帧略放大,增强存在感。
+ let smokeCellSize (nightStrength: float32) : int =
+ let night = max 0.0f (min 1.0f nightStrength)
+ int (20.0f + 8.0f * night)
+
+ // ---------------- P78 桥头灯柱近圈光晕 ----------------
+
+ /// 桥头灯柱瓦片(与 VillageArt.bridgeLanternTiles 同源,供窄光晕绘制与取证计数)。
+ let bridgeLanternTiles (map: MapGen.Result) : (int * int) list =
+ VillageArt.bridgeLanternTiles map
+
+ /// 窄光晕半径(tile):固定 1,只围灯柱一圈,不铺满整条河。
+ let bridgeHaloRadiusTiles = 1
+
+ /// ---------------- 近景楼层层错落(屏幕空间前景层) ----------------
/// 前景层屋顶分段:等宽 tile 分段、高度按确定性函数错落(0/8/16/24 px 四层),
/// 并随 tick 极慢移动(视差)。返回 (x, y, width)。
diff --git a/src/LivingVillage.Desktop/SceneDetailRender.fs b/src/LivingVillage.Desktop/SceneDetailRender.fs
index abe0b89..df2fa10 100644
--- a/src/LivingVillage.Desktop/SceneDetailRender.fs
+++ b/src/LivingVillage.Desktop/SceneDetailRender.fs
@@ -79,7 +79,76 @@ module SceneDetailRender =
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 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层)。
+ /// P78 炊烟:生成器世界每户屋顶脊上方一缕,帧号驱动升腾/摇曳,夜帧更醒目。
+ /// 复用江南图集的 22-24 三相位烟精灵,只读、不改瓦片。
+ let private drawSmoke
+ (spriteBatch: SpriteBatch)
+ (textures: VillageArt.ArtTextures)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (tick: int64)
+ (nightStrength: float32) =
+ let alpha = SceneDetail.smokeAlphaAt nightStrength
+ let size = SceneDetail.smokeCellSize nightStrength
+ let source = VillageArt.atlasTileRectangle (22 + SceneDetail.smokeFrame tick)
+ let color =
+ if nightStrength > 0.5f then VillageArt.premultiply 238 230 214 alpha
+ else VillageArt.premultiply 222 226 228 alpha
+ for (x, y) in SceneDetail.smokeSourceTiles map do
+ let seed = x * 29 + y * 11
+ let dx, dy = SceneDetail.smokeDrift tick seed
+ let cx = tileX camera x + (Sim.tilePixels - size) / 2 + dx
+ let cy = tileY camera y + (Sim.tilePixels - size) / 2 + dy
+ spriteBatch.Draw(textures.TileAtlas, Rectangle(cx, cy, size, size), source, color)
+
+ /// P78 夜帧水面逐格微光斑:帧号驱动、位置确定性,暗部因此有两档以上层次而非整片死黑。
+ let private drawNightWater
+ (spriteBatch: SpriteBatch)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (tick: int64)
+ (nightStrength: float32) =
+ for (x, y) in SceneDetail.waterGlintTiles map do
+ let alpha = SceneDetail.waterGlintAlpha nightStrength (SceneDetail.waterGlintPhase tick x y)
+ if alpha > 0 then
+ let wobble = SceneDetail.waterGlintOffset tick x y
+ let cx = tileX camera x
+ let cy = tileY camera y
+ let soft = VillageArt.premultiply 150 190 232 (alpha / 3)
+ let cool = VillageArt.premultiply 198 224 255 alpha
+ spriteBatch.Draw(pixel, Rectangle(cx + 6 + wobble, cy + 12, 20, 4), soft)
+ spriteBatch.Draw(pixel, Rectangle(cx + 10 + wobble, cy + 13, 11, 2), cool)
+
+ /// P78 桥头灯柱窄光晕:只围灯柱一圈(半径 1 tile),夜里给灯柱一圈暖光而不铺满河面。
+ let private drawBridgeHalos
+ (spriteBatch: SpriteBatch)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (nightStrength: float32) =
+ let peak = int (78.0f * max 0.0f (min 1.0f nightStrength))
+ if peak > 0 then
+ let half = Sim.tilePixels / 2
+ let radius = SceneDetail.bridgeHaloRadiusTiles * Sim.tilePixels
+ for (x, y) in SceneDetail.bridgeLanternTiles map do
+ let cx = tileX camera x + half
+ let cy = tileY camera y + half
+ for (rect, color) in VillageArt.lanternGlowLayers cx cy radius 5 255 214 150 peak do
+ spriteBatch.Draw(pixel, rect, color)
+
+ /// P78 夜帧加成层(水面光斑 + 桥头窄光晕):在 veil 之后调用,故不被夜色乘暗、仍醒目。
+ let drawNight
+ (spriteBatch: SpriteBatch)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (tick: int64)
+ (nightStrength: float32) =
+ drawNightWater spriteBatch pixel camera map tick nightStrength
+ drawBridgeHalos spriteBatch pixel camera map nightStrength
+
+ /// 一次性绘制全部 P47 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层 → P78 炊烟)。
let draw
(spriteBatch: SpriteBatch)
(textures: VillageArt.ArtTextures)
@@ -88,8 +157,10 @@ module SceneDetailRender =
(viewportWidth: int)
(viewportHeight: int)
(map: MapGen.Result)
- (tick: int64) =
+ (tick: int64)
+ (nightStrength: float32) =
drawReflections spriteBatch pixel camera map tick
drawBankReeds spriteBatch textures camera map
drawFloaters spriteBatch textures camera map tick
drawForeground spriteBatch textures pixel viewportWidth viewportHeight tick
+ drawSmoke spriteBatch textures camera map tick nightStrength