diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 16:17:02 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 16:17:02 +0800 |
| commit | 012c66f7eef55e7ef98a3c09bba419b1b17c05ca (patch) | |
| tree | eaa3a0230e848d40d66418b3cfb29e898f7784ba | |
| parent | c4917adfd5dec391196a399aa53d57c680e462f4 (diff) | |
| download | living-village-012c66f7eef55e7ef98a3c09bba419b1b17c05ca.tar.gz | |
p79: 夜帧炊烟增强与光晕去叠
- 炊烟:夜帧烟柱4段向上堆叠(整格32px, alpha 180+75)+暖色外发光(r30)+中性柔光团(r12);
烟柱强光像素(夜-昼贡献>=80) 181->1171 (x6.5),峰值112->173;白天帧逐像素不变。
- 光晕去叠:VillageArt.haloRingLayers 每光源<=2层(内芯+空心外环),芯环间确定暗隙,半径4->3 tile,
峰值150->118;光晕重叠面积(夜-昼贡献>=60)中央区 15184->9633 (-36.6%),全屏 15477->9926 (-35.9%)。
- 回归 P79NightPolishTests(3);analyze-p78.py 复跑仍 PASS;正午帧与 P78 完全一致=白天零回归。
- Desktop 367/367, Kernel 120/120, digest 953775FA...F7F6C 不变
| -rw-r--r-- | docs/evidence/p79-analysis.txt | 14 | ||||
| -rw-r--r-- | docs/evidence/p79-day-noon.png | bin | 0 -> 116943 bytes | |||
| -rw-r--r-- | docs/evidence/p79-log.txt | 51 | ||||
| -rw-r--r-- | docs/evidence/p79-metrics.txt | 21 | ||||
| -rw-r--r-- | docs/evidence/p79-night.png | bin | 0 -> 146098 bytes | |||
| -rw-r--r-- | docs/维护说明.md | 17 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P79NightPolishTests.fs | 58 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 22 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/SceneDetail.fs | 29 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/SceneDetailRender.fs | 64 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 46 |
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 Binary files differnew file mode 100644 index 0000000..f0f9126 --- /dev/null +++ b/docs/evidence/p79-day-noon.png 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 Binary files differnew file mode 100644 index 0000000..d4dac39 --- /dev/null +++ b/docs/evidence/p79-night.png 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 |
