diff options
Diffstat (limited to 'src')
| -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 |
6 files changed, 203 insertions, 17 deletions
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 |
