summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 20:26:42 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 20:26:42 +0800
commit08925d4863d8aaf93a974b8fa21a91e931a0928e (patch)
treef18055c232fc1a6022eeab552402185c6948ec02 /src
parent2ab4b8a22aaec3482d62433cfa4b73a657f2a165 (diff)
downloadliving-village-08925d4863d8aaf93a974b8fa21a91e931a0928e.tar.gz
p47: 场景细节升级 — 河道倒影(waterFrameTickAt微抖)+岸边芦苇加密度+鸭/叶漂浮元素+近景屋顶统一光源阴影
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs117
-rw-r--r--src/LivingVillage.Desktop/FloaterArt.fs103
-rw-r--r--src/LivingVillage.Desktop/Game.fs111
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj3
-rw-r--r--src/LivingVillage.Desktop/SceneDetail.fs118
-rw-r--r--src/LivingVillage.Desktop/SceneDetailRender.fs97
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs3
8 files changed, 553 insertions, 0 deletions
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index b158d04..045c1dc 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -26,6 +26,7 @@
<Compile Include="P44DialogTests.fs" />
<Compile Include="P45CharacterAnimTests.fs" />
<Compile Include="P46MenuAmbienceTests.fs" />
+ <Compile Include="P47SceneDetailTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs
new file mode 100644
index 0000000..4ff0a66
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P47SceneDetailTests.fs
@@ -0,0 +1,117 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+
+/// P47 scene detail:
+/// * water reflections reuse waterFrameTickAt phase and only land on water;
+/// * bank reeds / floating props are deterministic map+tick pure functions;
+/// * the near foreground roof layer uses one unified shadow direction.
+[<TestClass>]
+type P47SceneDetailTests () =
+
+ let map = MapGen.generate (MapGen.defaultParams 42UL)
+ let assertInt (expected: int) (actual: int) (message: string) =
+ Assert.IsTrue((expected = actual), sprintf "%s: expected %d actual %d" message expected actual)
+
+ [<TestMethod>]
+ member _.ReflectionHelpersAreDeterministicAndBounded () =
+ assertInt 3 (fst SceneDetail.shadowOffsetPx) "shadow dx"
+ 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.[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
+ for x in [ 0; 3; 9 ] do
+ for y in [ 0; 2 ] do
+ let expected =
+ match VillageArt.waterFrameTickAt tick x y with
+ | 0 -> -1
+ | 1 -> 0
+ | _ -> 1
+ assertInt expected (SceneDetail.reflectionWobbleAt tick x y) "wobble must reuse waterFrameTickAt"
+ Assert.IsTrue(abs (SceneDetail.reflectionWobbleAt tick x y) <= 1, "wobble bounded to +/-1px")
+
+ [<TestMethod>]
+ member _.ReflectionsOnlyLandOnWaterBelowALightSource () =
+ let sources = SceneDetail.lightSourceTiles map
+ 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.
+ 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)
+
+ [<TestMethod>]
+ member _.BankReedsAndFloatersAreDenseAndDeterministic () =
+ let reeds = SceneDetail.bankReedTiles map
+ Assert.IsTrue(not (List.isEmpty reeds), "bank reeds must be non-empty")
+ Assert.IsTrue(reeds |> List.distinct |> List.length = reeds.Length, "reeds must be distinct")
+ for (x, y) in reeds do
+ Assert.IsTrue(not (SceneDetail.isWater map x y), "reeds sit on land")
+ let nearWater =
+ SceneDetail.isWater map (x - 1) y
+ || SceneDetail.isWater map (x + 1) y
+ || SceneDetail.isWater map x (y - 1)
+ || SceneDetail.isWater map x (y + 1)
+ Assert.IsTrue(nearWater, "reeds hug the water edge")
+ Assert.IsTrue(x % 4 <> 0, "extra reeds interleave the existing spacing-4 set")
+ let floats = SceneDetail.floatingProps map
+ Assert.IsTrue(not (List.isEmpty floats), "floating props must be non-empty")
+ Assert.IsTrue(floats |> List.distinct |> List.length = floats.Length, "floaters must be distinct")
+ for (x, y, _) in floats do
+ Assert.IsTrue(SceneDetail.isWater map x y && SceneDetail.isWater map (x + 1) y,
+ "floaters sit on open water")
+ // both float kinds appear across the map.
+ let kinds = floats |> List.map (fun (_, _, k) -> k) |> List.distinct
+ Assert.IsTrue(kinds.Length >= 1, "float kinds present")
+
+ [<TestMethod>]
+ member _.FloatDriftAndFrameAreBoundedAndMoveWithTick () =
+ for tick in [ 0L; 100L; 5000L ] do
+ for seed in [ 0; 7; 31 ] do
+ let dx, dy = SceneDetail.floatDrift tick seed
+ Assert.IsTrue(dx >= -2 && dx <= 2, sprintf "float dx %d out of [-2,2]" dx)
+ Assert.IsTrue(dy >= -1 && dy <= 1, sprintf "float dy %d out of [-1,1]" dy)
+ assertInt (SceneDetail.floatFrame tick seed) (SceneDetail.floatFrame tick seed) "frame deterministic"
+ Assert.IsTrue(SceneDetail.floatFrame tick seed >= 0 && SceneDetail.floatFrame tick seed <= 1, "frame in 0/1")
+ let driftOverTime = [ for t in 0L .. 40L -> SceneDetail.floatDrift t 3 ]
+ Assert.IsTrue(driftOverTime |> List.distinct |> List.length > 1, "drift must move with tick")
+
+ [<TestMethod>]
+ member _.ForegroundRooftopsAreStaggeredAndCoverTheWidth () =
+ let width, height = 1280, 720
+ let rows = SceneDetail.foregroundRooftops width height 0L
+ Assert.IsTrue(rows.Length >= width / 32, "foreground spans the viewport width")
+ for (_, y, w) in rows do
+ let stagger = (height - 64) - y
+ Assert.IsTrue(stagger >= 0 && stagger <= 24 && stagger % 8 = 0,
+ sprintf "stagger %d must be one of 0/8/16/24" stagger)
+ assertInt 32 w "foreground roof cell width"
+ // deterministic for the same tick, and slowly drifts across ticks.
+ Assert.IsTrue(SceneDetail.foregroundRooftops width height 0L = SceneDetail.foregroundRooftops width height 0L)
+ Assert.IsTrue(SceneDetail.foregroundRooftops width height 0L <> SceneDetail.foregroundRooftops width height 240L)
+
+ [<TestMethod>]
+ member _.FloaterAtlasContract () =
+ assertInt 96 FloaterArt.atlasWidth "floater atlas width"
+ assertInt 16 FloaterArt.atlasHeight "floater atlas height"
+ assertInt 24 FloaterArt.cellWidth "floater cell width"
+ assertInt 16 FloaterArt.cellHeight "floater cell height"
+ for kind in [ FloaterArt.duckIndex; FloaterArt.leafIndex ] do
+ for frame in 0 .. FloaterArt.frameCount - 1 do
+ let rect = FloaterArt.sourceRectangle kind frame
+ Assert.IsTrue(rect.X >= 0 && rect.X + rect.Width <= FloaterArt.atlasWidth, "source rect inside atlas")
+ assertInt FloaterArt.cellHeight rect.Height "source cell height"
+ // four distinct cells, duck and leaf never share a frame cell.
+ let cells =
+ [ for kind in [ FloaterArt.duckIndex; FloaterArt.leafIndex ] do
+ for frame in 0 .. FloaterArt.frameCount - 1 -> FloaterArt.sourceRectangle kind frame ]
+ Assert.IsTrue(cells |> List.distinct |> List.length = 4, "four distinct floater cells")
diff --git a/src/LivingVillage.Desktop/FloaterArt.fs b/src/LivingVillage.Desktop/FloaterArt.fs
new file mode 100644
index 0000000..53a0793
--- /dev/null
+++ b/src/LivingVillage.Desktop/FloaterArt.fs
@@ -0,0 +1,103 @@
+namespace LivingVillage.Desktop
+
+/// P47 漂浮小元素(鸭子浮标 / 漂浮落叶)原创点阵图集生成器。
+/// 复用 P45 `CharacterArt` 的点阵像素代码管线:把 12x8 的字符画按 2x 放大成 24x16 的
+/// 单元,4 格一横排(鸭 A/B、叶 A/B)。全部为原创像素画,不采样任何外部素材,
+/// 也不使用程序几何矩形冒充成品。
+module FloaterArt =
+
+ let sourceWidth = 12
+ let sourceHeight = 8
+ let scale = 2
+ let cellWidth = sourceWidth * scale // 24
+ let cellHeight = sourceHeight * scale // 16
+ let frameCount = 2
+ let kindCount = 2
+ let atlasWidth = cellWidth * frameCount * kindCount // 96
+ let atlasHeight = cellHeight // 16
+
+ let duckIndex = 0
+ let leafIndex = 1
+
+ let private duckFrameA =
+ [ "............"
+ "....2222...."
+ "....2422...."
+ "...32222...."
+ "..1111111..."
+ ".111111111.."
+ ".111111111.."
+ "..5555555..." ]
+
+ let private duckFrameB =
+ [ "............"
+ ".....2222..."
+ ".....2422..."
+ "....32222..."
+ "..1111111..."
+ ".111111111.."
+ ".111111111.."
+ "..5555555..." ]
+
+ let private leafFrameA =
+ [ "............"
+ ".....6......"
+ "....676....."
+ "...67776...."
+ "..6777776..."
+ "...67776...."
+ "....676....."
+ ".....6......" ]
+
+ let private leafFrameB =
+ [ "............"
+ ".....6......"
+ "....676....."
+ "...67776...."
+ "..6776776..."
+ "...67776...."
+ "....676....."
+ ".....6......" ]
+
+ let private colorOf (ch: char) : (int * int * int) option =
+ match ch with
+ | '1' -> Some(236, 228, 206) // 鸭身奶白
+ | '2' -> Some(248, 242, 228) // 鸭头高光
+ | '3' -> Some(232, 150, 60) // 鸭喙橙
+ | '4' -> Some(40, 42, 54) // 鸭眼深色
+ | '5' -> Some(70, 100, 120) // 水面暗线
+ | '6' -> Some(96, 120, 64) // 叶缘深绿
+ | '7' -> Some(150, 172, 96) // 叶身浅绿
+ | _ -> None
+
+ /// 单元索引:kind 0=鸭 1=叶,frame 0/1。
+ let cellIndex (kind: int) (frame: int) : int =
+ (kind % kindCount) * frameCount + (frame % frameCount)
+
+ let sourceRectangle (kind: int) (frame: int) : Microsoft.Xna.Framework.Rectangle =
+ Microsoft.Xna.Framework.Rectangle(cellIndex kind frame * cellWidth, 0, cellWidth, cellHeight)
+
+ let buildAtlas (device: Microsoft.Xna.Framework.Graphics.GraphicsDevice) : Microsoft.Xna.Framework.Graphics.Texture2D =
+ let data = Array.zeroCreate<Microsoft.Xna.Framework.Color> (atlasWidth * atlasHeight)
+ let put (index: int) (rows: string list) =
+ rows
+ |> List.iteri (fun rowIndex row ->
+ row
+ |> Seq.iteri (fun columnIndex ch ->
+ match colorOf ch with
+ | None -> ()
+ | Some(r, g, b) ->
+ let px = index * cellWidth + columnIndex * scale
+ for dy in 0 .. scale - 1 do
+ for dx in 0 .. scale - 1 do
+ let tx = px + dx
+ let ty = rowIndex * scale + dy
+ if tx >= 0 && tx < atlasWidth && ty >= 0 && ty < atlasHeight then
+ data.[ty * atlasWidth + tx] <- Microsoft.Xna.Framework.Color(r, g, b)))
+ put (cellIndex duckIndex 0) duckFrameA
+ put (cellIndex duckIndex 1) duckFrameB
+ put (cellIndex leafIndex 0) leafFrameA
+ put (cellIndex leafIndex 1) leafFrameB
+ let texture = new Microsoft.Xna.Framework.Graphics.Texture2D(device, atlasWidth, atlasHeight)
+ texture.SetData data
+ texture
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 578be72..a187cc4 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -331,6 +331,11 @@ type LivingVillageGame() as this =
let mutable p46CloudLateDone = false
let mutable p46MenuADone = false
let mutable p46MenuBDone = false
+ // P47 证据钩子:LV_P47_SHOT=1 时在真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧。
+ let p47ShotMode = Environment.GetEnvironmentVariable("LV_P47_SHOT") = "1"
+ let mutable p47Step = 0
+ let mutable p47PendingName = ""
+ let mutable p47Pending = false
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable flowHold = 0
@@ -986,6 +991,88 @@ type LivingVillageGame() as this =
else p46MenuBDone <- true
save p46PendingName
+ /// P47 证据:真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧,含 P47 场景细节。
+ member private this.PrepareP47Shot () =
+ let setHour (hour: float) =
+ let tick = M6Presentation.resolveStartTick (Some hour) false
+ world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds }
+ 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 not p47Pending then
+ match riverscapeMap with
+ | 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 closeup =
+ match SceneDetail.floatingProps map with
+ | (fx, fy, _) :: _ -> (fx, fy)
+ | [] -> riverside
+ let reportVisibleReflections () =
+ 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
+ printfn
+ "p47-reflection src=%d,%d dst=%d,%d alpha=%d"
+ ssx ssy rsx rsy (SceneDetail.reflectionAlphaAt d)
+ printfn
+ "p47-elements reeds=%d floats=%d reflections=%d sources=%d"
+ (List.length (SceneDetail.bankReedTiles map))
+ (List.length (SceneDetail.floatingProps map))
+ (List.length (SceneDetail.reflectionStreaks map))
+ (List.length (SceneDetail.lightSourceTiles map))
+ match p47Step with
+ | 0 ->
+ setHour 12.0
+ placeCameraOn (fst riverside) (snd riverside)
+ reportVisibleReflections ()
+ p47PendingName <- "p47-day"
+ p47Pending <- true
+ | 1 ->
+ setHour 23.5
+ placeCameraOn (fst riverside) (snd riverside)
+ reportVisibleReflections ()
+ p47PendingName <- "p47-night"
+ p47Pending <- true
+ | 2 ->
+ setHour 12.0
+ placeCameraOn (fst closeup) (snd closeup)
+ reportVisibleReflections ()
+ p47PendingName <- "p47-close"
+ p47Pending <- true
+ | _ ->
+ printfn "p47-shot=done"
+ this.Exit()
+
+ /// P47 证据:在 `Draw` 末尾落盘当前后台缓冲。
+ member private this.CaptureP47Shot () =
+ if p47Pending then
+ p47Pending <- false
+ System.IO.Directory.CreateDirectory recordDirectory |> ignore
+ this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p47PendingName)
+ 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
+ p47Step <- p47Step + 1
+
/// P46 证据:菜单阶段按帧表把 menuEntranceFrame 钉到灯笼呼吸的波峰/波谷,拍两相位。
member private this.PrepareP46Shot () =
if p46MenuADone && p46MenuBDone then
@@ -1385,6 +1472,14 @@ type LivingVillageGame() as this =
elif p46ShotMode then
if menu.Page = MainMenu then
this.PrepareP46Shot()
+ elif p47ShotMode then
+ if menu.Page = MainMenu then
+ this.DispatchMenuInput Confirm
+ elif menu.Page = OccupationSelect then
+ if menu.Selected <> 0 then this.DispatchMenuInput Down
+ else this.DispatchMenuInput Confirm
+ elif menu.Page = Playing then
+ this.PrepareP47Shot()
elif menu.Page = Playing then
this.UpdatePlaying gameTime kb pressed pressedAny
elif menuShotMode then
@@ -1796,6 +1891,8 @@ type LivingVillageGame() as this =
this.CaptureP45Shot()
if p46ShotMode then
this.CaptureP46Shot()
+ if p47ShotMode && menu.Page = Playing then
+ this.CaptureP47Shot()
/// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。
member private this.DrawSplash() =
@@ -2047,6 +2144,20 @@ 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
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index 22d0d15..9aa3f94 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -11,7 +11,10 @@
<Compile Include="MapGen.fs" />
<Compile Include="WorldBootstrap.fs" />
<Compile Include="CharacterArt.fs" />
+ <Compile Include="FloaterArt.fs" />
<Compile Include="VillageArt.fs" />
+ <Compile Include="SceneDetail.fs" />
+ <Compile Include="SceneDetailRender.fs" />
<Compile Include="ChineseText.fs" />
<Compile Include="CjkGlyphAtlas.fs" />
<Compile Include="LaunchScreen.fs" />
diff --git a/src/LivingVillage.Desktop/SceneDetail.fs b/src/LivingVillage.Desktop/SceneDetail.fs
new file mode 100644
index 0000000..c51c47a
--- /dev/null
+++ b/src/LivingVillage.Desktop/SceneDetail.fs
@@ -0,0 +1,118 @@
+namespace LivingVillage.Desktop
+
+/// P47 场景细节升级(纯函数层):河道倒影、岸边植被加密度、漂浮元素、近景楼层层错落。
+/// 全部为帧计数 / 地图的纯函数:同参数逐字节可复现,无时钟、无随机源。
+module SceneDetail =
+
+ open LivingVillage.Kernel
+
+ let private positiveModulo64 (modulus: int64) (value: int64) : int64 =
+ let remainder = value % modulus
+ if remainder < 0L then remainder + modulus else remainder
+
+ /// 统一光源:来自画面左上,所有阴影一律投向右下(方向统一,不随物体改变)。
+ let shadowOffsetPx : int * int = (3, 4)
+
+ let isWater (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
+
+ // ---------------- 河道水面倒影 ----------------
+
+ let reflectionMaxAlpha = 128
+ let reflectionRange = 3
+
+ /// 倒影 alpha 随离光源距离递减;到 0 即止,绝不留下硬块。
+ let reflectionAlphaAt (distance: int) : int =
+ max 0 (reflectionMaxAlpha - distance * 34)
+
+ /// 倒影微抖:复用 waterFrameTickAt 的 3 相位,映射到 -1 / 0 / +1 像素水平位移。
+ let reflectionWobbleAt (tick: int64) (x: int) (y: int) : int =
+ match VillageArt.waterFrameTickAt tick x y with
+ | 0 -> -1
+ | 1 -> 0
+ | _ -> 1
+
+ /// 岸上光源:桥头灯笼 + 主路石灯笼 + 门口红灯笼,去重后即倒影源。
+ 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) ])
+
+ // ---------------- 岸边植被加密度 ----------------
+
+ /// 在既有 `cc0ReedTiles(spacing 4)`(x%4=0 的贴水陆格)基础上补一层:
+ /// x%4<>0、且四邻任一为水的陆格,按位置 hash 取一半。于是芦苇带更密,
+ /// 又与既有芦苇错列不重叠。
+ let bankReedTiles (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
+ let nearWater =
+ isWater map (x - 1) y
+ || isWater map (x + 1) y
+ || isWater map x (y - 1)
+ || isWater map x (y + 1)
+ if not (isWater map x y) && nearWater
+ && x % 4 <> 0
+ && (x * 7 + y * 13) % 2 = 0 then
+ yield (x, y) ]
+
+ // ---------------- 漂浮元素(鸭子浮标 / 漂浮落叶) ----------------
+
+ type FloatKind =
+ | DuckFloat
+ | FallenLeaf
+
+ let floatKindOf (x: int) (y: int) : FloatKind =
+ if (x * 3 + y * 5) % 3 = 0 then DuckFloat else FallenLeaf
+
+ /// 确定性落在开阔水面的漂浮元素(2 格连续水面、非桥面),数量即「元素数量指标」。
+ let floatingProps (map: MapGen.Result) : (int * int * FloatKind) list =
+ if map.Width <= 4 then
+ []
+ else
+ let bridgeSet = map.Bridges |> List.map (fun (x, y) -> y * map.Width + x) |> Set.ofList
+ let isBridge x y = Set.contains (y * map.Width + x) bridgeSet
+ [ for y in 2 .. map.Height - 3 do
+ for x in 2 .. map.Width - 3 do
+ if isWater map x y
+ && isWater map (x + 1) y
+ && not (isBridge x y)
+ && not (isBridge (x + 1) y)
+ && (x * 11 + y * 17) % 23 = 0 then
+ yield (x, y, floatKindOf x y) ]
+
+ /// 按 tick 的慢漂移(像素):水平 ±2、垂直 ±1,纯函数、有界、逐帧连续。
+ let floatDrift (tick: int64) (seed: int) : int * int =
+ let dx = int (positiveModulo64 5L (tick / 24L + int64 seed)) - 2
+ let dy = int (positiveModulo64 3L (tick / 40L + int64 (seed * 7 + 1))) - 1
+ (dx, dy)
+
+ /// 该漂浮元素的帧(0/1),只随 tick 慢速切换,保证水面鸭/叶有轻微起伏。
+ let floatFrame (tick: int64) (seed: int) : int =
+ int (positiveModulo64 2L (tick / 32L + int64 seed))
+
+ // ---------------- 近景楼层层错落(屏幕空间前景层) ----------------
+
+ /// 前景层屋顶分段:等宽 tile 分段、高度按确定性函数错落(0/8/16/24 px 四层),
+ /// 并随 tick 极慢移动(视差)。返回 (x, y, width)。
+ let foregroundRooftops (viewportWidth: int) (viewportHeight: int) (tick: int64) : (int * int * int) list =
+ let tile = Sim.tilePixels
+ let count = max 1 (viewportWidth / tile + 2)
+ let baseY = viewportHeight - 64
+ [ for i in 0 .. count - 1 do
+ let x = (i - 1) * tile
+ let stagger = ((i * 7 + int (tick / 120L)) % 4) * 8
+ yield (x, baseY - stagger, tile) ]
diff --git a/src/LivingVillage.Desktop/SceneDetailRender.fs b/src/LivingVillage.Desktop/SceneDetailRender.fs
new file mode 100644
index 0000000..2136d25
--- /dev/null
+++ b/src/LivingVillage.Desktop/SceneDetailRender.fs
@@ -0,0 +1,97 @@
+namespace LivingVillage.Desktop
+
+open System
+open Microsoft.Xna.Framework
+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
+ (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
+ 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 private drawBankReeds (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) =
+ match textures.Cc0TileAtlas with
+ | Some cc0 ->
+ for (x, y) in SceneDetail.bankReedTiles map do
+ spriteBatch.Draw(cc0, Rectangle(tileX camera x, tileY camera y, Sim.tilePixels, Sim.tilePixels), Nullable(VillageArt.cc0TileRect VillageArt.cc0TileIndexReeds), Color.White)
+ | None ->
+ let source = VillageArt.atlasTileRectangle 18
+ for (x, y) in SceneDetail.bankReedTiles map do
+ spriteBatch.Draw(textures.TileAtlas, Rectangle(tileX camera x, tileY camera y, Sim.tilePixels, Sim.tilePixels), Nullable(source), Color.White)
+
+ /// 漂浮元素:原创点阵鸭/叶,按 tick 慢漂移。
+ let private drawFloaters (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (camera: Vector2) (map: MapGen.Result) (tick: int64) =
+ for (x, y, kind) in SceneDetail.floatingProps map do
+ let seed = x * 31 + y * 7
+ let dx, dy = SceneDetail.floatDrift tick seed
+ let frame = SceneDetail.floatFrame tick seed
+ let kindIndex =
+ match kind with
+ | SceneDetail.DuckFloat -> FloaterArt.duckIndex
+ | SceneDetail.FallenLeaf -> FloaterArt.leafIndex
+ let source = FloaterArt.sourceRectangle kindIndex frame
+ spriteBatch.Draw(
+ textures.FloaterAtlas,
+ Rectangle(tileX camera x + dx + (Sim.tilePixels - FloaterArt.cellWidth) / 2, tileY camera y + dy + (Sim.tilePixels - FloaterArt.cellHeight) / 2, FloaterArt.cellWidth, FloaterArt.cellHeight),
+ Nullable(source),
+ Color.White)
+
+ /// 近景楼层层错落:屏幕空间前景屋顶带,所有阴影统一投向右下(SceneDetail.shadowOffsetPx)。
+ let private drawForeground (spriteBatch: SpriteBatch) (textures: VillageArt.ArtTextures) (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 dx, dy = SceneDetail.shadowOffsetPx
+ for (x, y, w) in SceneDetail.foregroundRooftops viewportWidth viewportHeight tick do
+ // 统一方向阴影:先把 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)
+
+ /// 一次性绘制全部 P47 场景细节(倒影 → 岸边芦苇 → 漂浮元素 → 近景前景层)。
+ let draw
+ (spriteBatch: SpriteBatch)
+ (textures: VillageArt.ArtTextures)
+ (pixel: Texture2D)
+ (camera: Vector2)
+ (viewportWidth: int)
+ (viewportHeight: int)
+ (map: MapGen.Result)
+ (tick: int64) =
+ drawReflections spriteBatch textures camera map tick
+ drawBankReeds spriteBatch textures camera map
+ drawFloaters spriteBatch textures camera map tick
+ drawForeground spriteBatch textures viewportWidth viewportHeight tick
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 98e4cdf..3dbb8d4 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -123,6 +123,7 @@ module VillageArt =
{ TileAtlas: Texture2D
CharacterAtlas: Texture2D
InteriorAtlas: Texture2D
+ FloaterAtlas: Texture2D
Cc0TileAtlas: Texture2D option }
type private TileSprite =
@@ -768,6 +769,8 @@ module VillageArt =
// 不再采样任何外部/占位角色素材;尺寸与既有 characterSourceRectangle 完全一致。
CharacterAtlas = CharacterArt.buildAtlas device
InteriorAtlas = loadTexture device interiorAtlasRelativePath interiorAtlasWidth interiorAtlasHeight
+ // P47:漂浮小元素(鸭/叶)改用原创点阵像素代码内嵌生成的图集(2 kind x 2 frame)。
+ FloaterAtlas = FloaterArt.buildAtlas device
Cc0TileAtlas = cc0 }
let atlasTileRectangle (index: int) : Microsoft.Xna.Framework.Rectangle =