summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p79-analysis.txt14
-rw-r--r--docs/evidence/p79-day-noon.pngbin0 -> 116943 bytes
-rw-r--r--docs/evidence/p79-log.txt51
-rw-r--r--docs/evidence/p79-metrics.txt21
-rw-r--r--docs/evidence/p79-night.pngbin0 -> 146098 bytes
-rw-r--r--docs/维护说明.md17
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P79NightPolishTests.fs58
-rw-r--r--src/LivingVillage.Desktop/Game.fs22
-rw-r--r--src/LivingVillage.Desktop/SceneDetail.fs29
-rw-r--r--src/LivingVillage.Desktop/SceneDetailRender.fs64
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs46
12 files changed, 305 insertions, 18 deletions
diff --git a/docs/evidence/p79-analysis.txt b/docs/evidence/p79-analysis.txt
new file mode 100644
index 0000000..17bd159
--- /dev/null
+++ b/docs/evidence/p79-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=32.3
+night near-black frac=0.007 max24blk=184
+smoke region mean: day=100.6 night=146.8 delta=46.2 smoke_alpha=182
+bridge ring diff: day=-5.7 night=24.5
+day vs night differing_pixels=921599
+
+verdict=PASS
+ 夜帧光斑对比/非光斑平整、水面层次不低于白天、无铁板一块;
+ 炊烟夜帧更醒目、桥头窄光晕仅夜帧出现;白天 lantern=0 且两帧有差异。
diff --git a/docs/evidence/p79-day-noon.png b/docs/evidence/p79-day-noon.png
new file mode 100644
index 0000000..f0f9126
--- /dev/null
+++ b/docs/evidence/p79-day-noon.png
Binary files differ
diff --git a/docs/evidence/p79-log.txt b/docs/evidence/p79-log.txt
new file mode 100644
index 0000000..9a91c46
--- /dev/null
+++ b/docs/evidence/p79-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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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=32 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/p79-metrics.txt b/docs/evidence/p79-metrics.txt
new file mode 100644
index 0000000..5d059ba
--- /dev/null
+++ b/docs/evidence/p79-metrics.txt
@@ -0,0 +1,21 @@
+P79 夜帧整改量化对比(真实 Desktop xvfb 1280x720,同机位正午 vs 22 时)
+
+对照基准:P78 帧(docs/evidence/p78-*.png)与 P78 分析(docs/evidence/p78-analysis.txt)。
+白天帧一致性:p79 正午帧与 p78 正午帧逐像素完全相同(day frames identical=True)→ 白天零回归。
+
+1) 夜帧炊烟可见度(烟囱烟柱区 x648..696, y300..380 的「夜-昼」亮度贡献;对照列 x848..896 不变)
+ - 强光像素数(贡献 ≥ 80):181(P78)→ 1171(P79),约 ×6.5
+ - 峰值贡献:112(P78)→ 173(P79)
+ - 烟柱区净贡献均值(plume-ctrl):76.0(P78)→ 86.5(P79)
+ - analyze-p78.py「炊烟区均值 夜-昼」:43.2(P78)→ 46.2(P79)
+ 做法:夜帧烟柱 4 段向上堆叠(每段抬升 10px、整格 32px、alpha 180+75)+ 中性柔光团外发光
+ (半径 12)+ 暖色外发光(半径 30);全部只在夜帧叠加层,白天不改。
+
+2) 光晕重叠面积(「夜-昼」亮度贡献 ≥ 60 的像素数,近似多光源叠在一起的一团面积)
+ - 全屏:15477(P78)→ 9926(P79),−35.9%
+ - 中央区(x320..960, y200..520):15184(P78)→ 9633(P79),−36.6%
+ 做法:每光源光晕改为最多 2 层视觉层(内芯 2 圈 + 空心外环),芯/环之间留一圈确定暗隙
+ (同排 dy=0 不被任何图元覆盖),半径 4→3 tile,峰值 150→118;旧的多圈填充叠一团已移除。
+
+3) analyze-p78.py 复跑:verdict=PASS(光斑对比 63.5、非光斑水面 std 4.4、水面 bbox std 夜 32.3 ≥ 昼 24.0、
+ 近黑 0.007 无实心 24×24 块、桥头环带差 夜 24.5 / 昼 -5.7、day lantern=0.00)。
diff --git a/docs/evidence/p79-night.png b/docs/evidence/p79-night.png
new file mode 100644
index 0000000..d4dac39
--- /dev/null
+++ b/docs/evidence/p79-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index aa544af..3467b7c 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -295,8 +295,23 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
炊烟区夜-昼 +43.2(alpha 182);桥头环带差 夜 +13.5 / 昼 −5.7;白天 lantern=0.00,
落 `docs/evidence/p78-*`。
-## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
+## P79 夜帧整改(炊烟可见度 + 光晕去叠)
+
+- 夜帧炊烟增强(`SceneDetail.nightSmoke*` + `SceneDetailRender.drawNightSmoke`,只在夜帧叠加层):
+ 烟柱 `nightSmokeSegments=4` 段向上堆叠(`nightSmokeSegmentLift=10`、夜帧整格 32px、
+ alpha `180+75*night`),每段叠暖色外发光(半径 30)与中性柔光团(半径 12),弥补烟精灵本身 alpha
+ 很低的问题;白天帧完全不叠加、逐像素与 P78 相同。
+- 夜帧光晕去叠(`VillageArt.haloRingLayers` + `SceneDetailRender.drawNightHalos`):每个光源最多 2 层
+ 视觉层(内芯 2 圈递减填充 + 空心外环),芯/环之间留一圈确定暗隙(同排 dy=0 不被任何图元覆盖),
+ 半径 4→3 tile(`nightHaloRadiusTiles=3`)、峰值 150→118(`nightHaloPeakAlphaAt`);生成器世界的
+ 旧多圈叠加光晕(`VillageArt.drawNightGlows`)不再在实时路径调用(该函数仍供整图取证函数使用)。
+- **回归**:`P79NightPolishTests`(光晕内芯亮/芯环暗隙/外环亮且不越半径无方角、半径收紧到 3 且白天为 0;
+ 夜帧烟柱分段/抬升/alpha 高于白天/外发光更强)。
+- **证据**:重跑 `LV_P78_SHOT=1` 同机位正午/22 时两帧 → `scripts/analyze-p78.py` 仍 verdict=PASS;
+ 量化对比见 `docs/evidence/p79-metrics.txt`:烟柱强光像素(夜-昼贡献≥80)181→1171(×6.5)、
+ 光晕重叠面积(贡献≥60)中央区 15184→9633(−36.6%);正午帧与 P78 逐像素一致(白天零回归)。
+## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
26 水缸 / 27 摊贩摊位,全部烘焙进 `Assets/jiangnan-world.png`,尺寸 800x32→896x32),游戏侧只做
`tileSourceRectangle` 贴片、无程序几何。`VillageArt` 新增 `RenderPropKind.StoneLantern/WaterVat/
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 5f2ac14..d6b3308 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -50,6 +50,7 @@
<Compile Include="P76HotbarPanelTests.fs" />
<Compile Include="P77LayoutTests.fs" />
<Compile Include="P78NightAmbienceTests.fs" />
+ <Compile Include="P79NightPolishTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P79NightPolishTests.fs b/src/LivingVillage.Desktop.Tests/P79NightPolishTests.fs
new file mode 100644
index 0000000..40a38b8
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P79NightPolishTests.fs
@@ -0,0 +1,58 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open Microsoft.Xna.Framework
+open LivingVillage.Desktop
+
+/// P79 夜帧整改回归(纯函数层):
+/// * 去叠光晕:单光源 ≤2 层视觉层(内芯 + 外环),芯/环之间确定暗隙,半径收紧到 3 tile;
+/// * 夜帧炊烟增强:烟柱分段 + 外发光,夜帧 alpha 明显高于白天,白天零回归。
+[<TestClass>]
+type P79NightPolishTests () =
+
+ let map = MapGen.generate (MapGen.defaultParams 42UL)
+
+ [<TestMethod>]
+ member _.HaloRingHasTwoVisualLayersAndADarkGap () =
+ let cx, cy, radius, peak = 100, 100, 96, 118
+ let layers = VillageArt.haloRingLayers cx cy radius peak
+ Assert.IsTrue(not (List.isEmpty layers), "光晕须发出图元")
+ Assert.IsTrue(
+ (layers = VillageArt.haloRingLayers cx cy radius peak),
+ "光晕须确定性")
+ Assert.IsTrue((SceneDetail.nightHaloVisualLayers = 2), "每光源最多 2 层视觉层")
+ let covers px py =
+ layers
+ |> List.exists (fun (rect, _) -> rect.X <= px && px < rect.X + rect.Width && rect.Y <= py && py < rect.Y + rect.Height)
+ // 内芯亮、外环亮,芯环之间留一圈暗隙(同排 dy=0)。
+ Assert.IsTrue(covers cx cy, "内芯须被点亮")
+ Assert.IsTrue(covers (cx + 10) cy, "内芯直径内须被点亮")
+ let innerRadius = radius * 2 / 5
+ let gapRadius = max (innerRadius + 2) (radius * 3 / 5)
+ let gapMid = cx + (innerRadius + gapRadius) / 2
+ Assert.IsFalse(covers gapMid cy, "芯环之间须留暗隙")
+ Assert.IsFalse(covers (cx - (gapMid - cx)) cy, "暗隙须左右对称")
+ Assert.IsTrue(covers (cx + (gapRadius + radius) / 2) cy, "外环须被点亮")
+ // 外环不得越过半径、不得出现方角。
+ for (rect, _) in layers do
+ Assert.IsTrue(abs (rect.X + rect.Width / 2 - cx) <= radius + 1, "图元须在半径内")
+ Assert.IsFalse(covers (cx + radius) (cy + radius), "四角须保持为空:圆形非方斑")
+
+ [<TestMethod>]
+ member _.NightHaloIsTighterAndCapped () =
+ Assert.IsTrue((SceneDetail.nightHaloRadiusTiles = 3), "去叠后半径须收紧到 3 tile")
+ Assert.IsTrue((SceneDetail.nightHaloRadiusTiles < 4), "须比旧半径 4 tile 更紧")
+ Assert.IsTrue((SceneDetail.nightHaloPeakAlphaAt 0.0f) = 0, "白天光晕须为 0(零回归)")
+ Assert.IsTrue((SceneDetail.nightHaloPeakAlphaAt 1.0f) > 0, "夜帧光晕须可见")
+
+ [<TestMethod>]
+ member _.NightSmokeColumnAndGlowAreStrongerThanDay () =
+ Assert.IsTrue((SceneDetail.nightSmokeSegments >= 2), "夜帧烟柱须分段堆叠")
+ Assert.IsTrue((SceneDetail.nightSmokeSegmentLift > 0), "每段须向上抬升")
+ Assert.IsTrue(
+ (SceneDetail.nightSmokeAlphaAt 1.0f) > (SceneDetail.smokeAlphaAt 1.0f),
+ "夜帧烟柱 alpha 须高于白天 underlay")
+ Assert.IsTrue((SceneDetail.nightSmokeGlowPeakAt 1.0f) > (SceneDetail.nightSmokeGlowPeakAt 0.0f), "夜帧外发光须更强")
+ Assert.IsTrue(SceneDetail.nightSmokeGlowRadius > 0, "外发光半径须为正")
+ let sources = SceneDetail.smokeSourceTiles map
+ Assert.IsTrue(not (List.isEmpty sources), "须仍能取到炊烟源")
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index a83f04c..4e2a741 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -3346,18 +3346,10 @@ type LivingVillageGame() as this =
let tileX x = x * Sim.tilePixels - int camera.X
let tileY y = y * Sim.tilePixels - int camera.Y
match riverscapeMap with
- | Some map ->
- // P39:cc0 模式下把主路石灯笼并入夜光;fallback 保持原灯光源。
- VillageArt.drawNightGlows
- spriteBatch
- pixel
- map
- (4 * Sim.tilePixels)
- (int (150.0f * glow))
- tileX
- tileY
- Sim.tilePixels
- artTextures.Cc0TileAtlas.IsSome
+ | Some _ ->
+ // P79:生成器世界的夜光改到 veil 之后的去叠层(`SceneDetailRender.drawNightHalos`),
+ // 每光源 ≤2 层、层间暗隙、半径 4→3 tile,不再在此铺一大团叠加光。
+ ()
| None ->
for prop in renderPlan.Props do
match prop.Kind with
@@ -3421,11 +3413,11 @@ type LivingVillageGame() as this =
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,本层为空、白天帧零回归。
+ // P78/P79:夜帧加成层(水面逐格微光斑 + 桥头灯柱窄光晕 + 去叠灯群光晕 + 炊烟增强)。
+ // 画在全部 veil 之后,故不被夜色乘暗、暗部层次仍可读;白天 lanternGlow=0,本层为空、白天帧零回归。
match riverscapeMap with
| Some map when lanternGlow > 0.0f ->
- SceneDetailRender.drawNight spriteBatch pixel camera map world.Tick lanternGlow
+ SceneDetailRender.drawNight spriteBatch artTextures pixel camera map world.Tick lanternGlow
| _ -> ()
spriteBatch.End()
diff --git a/src/LivingVillage.Desktop/SceneDetail.fs b/src/LivingVillage.Desktop/SceneDetail.fs
index 54665ac..0e61726 100644
--- a/src/LivingVillage.Desktop/SceneDetail.fs
+++ b/src/LivingVillage.Desktop/SceneDetail.fs
@@ -198,10 +198,10 @@ module SceneDetail =
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)
+ int (20.0f + 12.0f * night)
// ---------------- P78 桥头灯柱近圈光晕 ----------------
@@ -212,6 +212,31 @@ module SceneDetail =
/// 窄光晕半径(tile):固定 1,只围灯柱一圈,不铺满整条河。
let bridgeHaloRadiusTiles = 1
+ // ---------------- P79 夜帧炊烟增强(烟柱 + 外发光) ----------------
+
+ /// 夜帧烟柱分段数:向上堆叠成可辨烟柱。
+ let nightSmokeSegments = 4
+ /// 每段抬升像素。
+ let nightSmokeSegmentLift = 10
+ /// 夜帧烟柱 alpha:显著高于白天 underlay,裸眼可从屋顶看到每一缕。
+ let nightSmokeAlphaAt (nightStrength: float32) : int =
+ int (180.0f + 75.0f * max 0.0f (min 1.0f nightStrength))
+ /// 夜帧外发光峰值 alpha:提亮烟柱周边形成外发光。
+ let nightSmokeGlowPeakAt (nightStrength: float32) : int =
+ int (90.0f + 110.0f * max 0.0f (min 1.0f nightStrength))
+ /// 夜帧外发光半径(像素)。
+ let nightSmokeGlowRadius = 30
+
+ // ---------------- P79 夜景光晕去叠 ----------------
+
+ /// 收紧后的单个光源光晕半径(tile):4 → 3。
+ let nightHaloRadiusTiles = 3
+ /// 每个光源最多可见层数(内芯 + 外环),层间留暗隙。
+ let nightHaloVisualLayers = 2
+ /// 收紧半径后的峰值 alpha(略降,避免叠加成一整团)。
+ let nightHaloPeakAlphaAt (nightStrength: float32) : int =
+ int (118.0f * max 0.0f (min 1.0f nightStrength))
+
/// ---------------- 近景楼层层错落(屏幕空间前景层) ----------------
/// 前景层屋顶分段:等宽 tile 分段、高度按确定性函数错落(0/8/16/24 px 四层),
diff --git a/src/LivingVillage.Desktop/SceneDetailRender.fs b/src/LivingVillage.Desktop/SceneDetailRender.fs
index df2fa10..8bda8a9 100644
--- a/src/LivingVillage.Desktop/SceneDetailRender.fs
+++ b/src/LivingVillage.Desktop/SceneDetailRender.fs
@@ -137,9 +137,71 @@ module SceneDetailRender =
for (rect, color) in VillageArt.lanternGlowLayers cx cy radius 5 255 214 150 peak do
spriteBatch.Draw(pixel, rect, color)
+ /// P79 夜帧炊烟增强:烟柱(3 段向上堆叠)+ 外发光。只在夜帧叠加绘制,白天帧不受影响。
+ let private drawNightSmoke
+ (spriteBatch: SpriteBatch)
+ (textures: VillageArt.ArtTextures)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (tick: int64)
+ (nightStrength: float32) =
+ let alpha = SceneDetail.nightSmokeAlphaAt nightStrength
+ let glowPeak = SceneDetail.nightSmokeGlowPeakAt nightStrength
+ let source = VillageArt.atlasTileRectangle (22 + SceneDetail.smokeFrame tick)
+ let cell = SceneDetail.smokeCellSize nightStrength
+ for (x, y) in SceneDetail.smokeSourceTiles map do
+ let seed = x * 29 + y * 11
+ let sway, rise = SceneDetail.smokeDrift tick seed
+ let baseX = tileX camera x + (Sim.tilePixels - cell) / 2
+ let baseY = tileY camera y + (Sim.tilePixels - cell) / 2
+ // 外发光:暖色径向柔光,让烟柱在夜色里浮起。
+ for (rect, glowColor) in
+ VillageArt.lanternGlowLayers
+ (baseX + cell / 2)
+ (baseY + rise)
+ SceneDetail.nightSmokeGlowRadius
+ 4
+ 255
+ 236
+ 200
+ glowPeak do
+ spriteBatch.Draw(pixel, rect, glowColor)
+ // 烟柱:自屋顶向上堆叠,越上越淡,并在每段叠一枚中性柔光团(外发光),
+ // 弥补烟精灵本身 alpha 很低的问题,使夜色里每一缕烟柱都清晰可辨。
+ for segment in 0 .. SceneDetail.nightSmokeSegments - 1 do
+ let segmentAlpha = max 1 (alpha * (SceneDetail.nightSmokeSegments - segment) / SceneDetail.nightSmokeSegments)
+ let segmentColor = VillageArt.premultiply 246 242 232 segmentAlpha
+ let sx = baseX + sway * (segment + 1) / 2
+ let sy = baseY + rise - segment * SceneDetail.nightSmokeSegmentLift
+ spriteBatch.Draw(textures.TileAtlas, Rectangle(sx, sy, cell, cell), source, segmentColor)
+ let puffPeak = max 1 (glowPeak * (SceneDetail.nightSmokeSegments - segment) / (SceneDetail.nightSmokeSegments + 1))
+ for (rect, puffColor) in
+ VillageArt.lanternGlowLayers (sx + cell / 2) (sy + cell / 2) 12 3 238 242 248 puffPeak do
+ spriteBatch.Draw(pixel, rect, puffColor)
+
+ /// P79 夜景光晕去叠:每个光源最多 2 层(内芯 + 外环)、层间暗隙、半径收紧到 3 tile。
+ let private drawNightHalos
+ (spriteBatch: SpriteBatch)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (map: MapGen.Result)
+ (includeRoadLanterns: bool)
+ (nightStrength: float32) =
+ let peak = SceneDetail.nightHaloPeakAlphaAt nightStrength
+ if peak > 0 then
+ let half = Sim.tilePixels / 2
+ let radius = SceneDetail.nightHaloRadiusTiles * Sim.tilePixels
+ for (lx, ly) in VillageArt.nightLightTilesWith includeRoadLanterns map do
+ let cx = tileX camera lx + half
+ let cy = tileY camera ly + half
+ for (rect, color) in VillageArt.haloRingLayers cx cy radius peak do
+ spriteBatch.Draw(pixel, rect, color)
+
/// P78 夜帧加成层(水面光斑 + 桥头窄光晕):在 veil 之后调用,故不被夜色乘暗、仍醒目。
let drawNight
(spriteBatch: SpriteBatch)
+ (textures: VillageArt.ArtTextures)
(pixel: Texture2D)
(camera: Vector2)
(map: MapGen.Result)
@@ -147,6 +209,8 @@ module SceneDetailRender =
(nightStrength: float32) =
drawNightWater spriteBatch pixel camera map tick nightStrength
drawBridgeHalos spriteBatch pixel camera map nightStrength
+ drawNightHalos spriteBatch pixel camera map textures.Cc0TileAtlas.IsSome nightStrength
+ drawNightSmoke spriteBatch textures pixel camera map tick nightStrength
/// 一次性绘制全部 P47 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层 → P78 炊烟)。
let draw
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index db594d2..c797270 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -538,6 +538,52 @@ module VillageArt =
for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do
yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), glowColor) ]
+ /// P79:单光源夜景光晕(去叠版)——最多 2 层视觉层:内芯(2 圈递减填充)+ 外环(空心圆环)。
+ /// 芯与环之间留一圈确定暗隙(不被任何 rect 覆盖),于是相邻多光源不会叠成一整团;
+ /// 累计 alpha 仍以 peakAlpha 为峰,不因层数而加深。半径由调用方收紧。
+ let haloRingLayers
+ (centerX: int)
+ (centerY: int)
+ (radius: int)
+ (peakAlpha: int) : (Microsoft.Xna.Framework.Rectangle * Microsoft.Xna.Framework.Color) list =
+ if radius <= 3 || peakAlpha <= 0 then
+ []
+ else
+ let innerRadius = max 1 (radius * 2 / 5)
+ let gapRadius = max (innerRadius + 2) (radius * 3 / 5)
+ let outerRadius = radius
+ // 内芯:两圈递减填充,累计峰值 = peakAlpha,不再加深。
+ let coreTarget (ringRadius: int) =
+ int (Math.Round(float peakAlpha * float (lanternFalloff (float32 ringRadius) (float32 innerRadius)), MidpointRounding.AwayFromZero))
+ let mutable previous = 0
+ let innerLayers =
+ [ for ringRadius in [ innerRadius; max 1 (innerRadius / 2) ] do
+ let target = coreTarget ringRadius
+ let increment = max 0 (target - previous)
+ previous <- max previous target
+ if increment > 0 then
+ for (x, y, width) in ellipseRuns centerX centerY ringRadius ringRadius do
+ yield (Microsoft.Xna.Framework.Rectangle(x, y, width, 1), premultiply 255 214 150 increment) ]
+ // 外环:空心圆环(外半径 outerRadius、内孔 gapRadius),只画左右两段,与内芯之间留暗隙。
+ let ringColor = premultiply 255 214 150 (max 1 (peakAlpha / 2))
+ let ringLayers =
+ [ for y in centerY - outerRadius .. centerY + outerRadius do
+ let dy = y - centerY
+ let halfAt (r: int) =
+ let squared = r * r - dy * dy
+ if squared <= 0 then 0 else int (sqrt (float squared))
+ let outerHalf = halfAt outerRadius
+ let gapHalf = halfAt gapRadius
+ if outerHalf > 0 then
+ if gapHalf <= 0 then
+ yield (Microsoft.Xna.Framework.Rectangle(centerX - outerHalf, y, outerHalf * 2 + 1, 1), ringColor)
+ else
+ let leftWidth = outerHalf - gapHalf
+ if leftWidth > 0 then
+ yield (Microsoft.Xna.Framework.Rectangle(centerX - outerHalf, y, leftWidth, 1), ringColor)
+ yield (Microsoft.Xna.Framework.Rectangle(centerX + gapHalf + 1, y, outerHalf - gapHalf, 1), ringColor) ]
+ innerLayers @ ringLayers
+
/// Characters spawn at tile centres; push the sprite down half a tile so its feet rest
/// on the tile's ground line instead of floating at the centre.
let characterFeetOffset = Sim.tilePixels / 2