summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 18:15:23 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 18:15:23 +0800
commite32e288358ffcf35d45bd1c6f1912746cdbc3d37 (patch)
tree58d4c33a066877b53894a3c7e03e0cb0f6540fde /src
parent72cb75848dd56e2cb766cbf1df97dc1891667e1e (diff)
downloadliving-village-e32e288358ffcf35d45bd1c6f1912746cdbc3d37.tar.gz
feat(p41): 夜帧船篷对比暗化
- VillageArt.drawCc0BoatOverlay 增加夜层专用暗化系数 0.7(仅夜光晕层生效, 白天不调用 overlay,p40-day/p40-boat 逐字节不变) - P40ArtTests 新增 NightBoatAwningIsAtLeast40GreyDarkerThanSurroundingGlow: 内嵌 PNG 解码读取夜帧,断言船 2 瓦包围盒比 24px 光晕环至少暗 40 灰阶 - 新增 scripts/check-p40-night-boat-contrast.py 独立像素守卫 - 重出 docs/evidence/p40-night.png(contrast 103.2 >= 40)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/P40ArtTests.fs101
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs12
2 files changed, 111 insertions, 2 deletions
diff --git a/src/LivingVillage.Desktop.Tests/P40ArtTests.fs b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs
index 15e6601..da7becf 100644
--- a/src/LivingVillage.Desktop.Tests/P40ArtTests.fs
+++ b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs
@@ -64,6 +64,73 @@ type P40ArtTests () =
|| (r, g, b) = (28, 38, 22)
|| (r, g, b) = (22, 30, 17))
+ // --- P41 boat night contrast: pixel regression on the recorded night frame ---
+ // Decodes the MonoGame 8-bit RGBA PNG (color type 6, may use filters 0-4) so the
+ // test can measure the rendered frame without a GPU.
+ let decodePng (path: string) : int * int * (int -> int -> (int * int * int * int)) =
+ let bytes = System.IO.File.ReadAllBytes path
+ let be32 (o: int) =
+ (int bytes.[o] <<< 24) ||| (int bytes.[o + 1] <<< 16) ||| (int bytes.[o + 2] <<< 8) ||| int bytes.[o + 3]
+ let width = be32 16
+ let height = be32 20
+ let mutable offset = 8
+ let mutable idat = new System.IO.MemoryStream()
+ while offset < bytes.Length do
+ let length = be32 offset
+ let tag = System.Text.Encoding.ASCII.GetString(bytes, offset + 4, 4)
+ if tag = "IDAT" then idat.Write(bytes, offset + 8, length)
+ offset <- offset + 12 + length
+ use z = new System.IO.Compression.ZLibStream(new System.IO.MemoryStream(idat.ToArray()), System.IO.Compression.CompressionMode.Decompress)
+ use ms = new System.IO.MemoryStream()
+ z.CopyTo ms
+ let raw = ms.ToArray()
+ let bpp = 4
+ let stride = width * bpp
+ let pixels = Array.zeroCreate<byte> (stride * height)
+ let paeth (a: int) (b: int) (c: int) =
+ let p = a + b - c
+ let pa = abs (p - a)
+ let pb = abs (p - b)
+ let pc = abs (p - c)
+ if pa <= pb && pa <= pc then a elif pb <= pc then b else c
+ for y in 0 .. height - 1 do
+ let ft = int raw.[y * (stride + 1)]
+ let rowIn = y * (stride + 1) + 1
+ let rowOut = y * stride
+ let prev = rowOut - stride
+ for x in 0 .. stride - 1 do
+ let rv = int raw.[rowIn + x]
+ let a = if x >= bpp then int pixels.[rowOut + x - bpp] else 0
+ let b = if y > 0 then int pixels.[prev + x] else 0
+ let c = if y > 0 && x >= bpp then int pixels.[prev + x - bpp] else 0
+ let v =
+ match ft with
+ | 0 -> rv
+ | 1 -> rv + a
+ | 2 -> rv + b
+ | 3 -> rv + (a + b) / 2
+ | 4 -> rv + paeth a b c
+ | _ -> failwithf "unsupported PNG filter %d" ft
+ pixels.[rowOut + x] <- byte (v &&& 0xFF)
+ let get x y =
+ let i = (y * width + x) * 4
+ (int pixels.[i], int pixels.[i + 1], int pixels.[i + 2], int pixels.[i + 3])
+ width, height, get
+
+ let evidencePath (name: string) : string option =
+ let mutable dir = Some (new System.IO.DirectoryInfo(System.AppContext.BaseDirectory))
+ let mutable found = None
+ while found.IsNone && dir.IsSome do
+ let d = dir.Value
+ let candidate = System.IO.Path.Combine(d.FullName, "docs/evidence", name)
+ if System.IO.File.Exists candidate then found <- Some candidate
+ else dir <- Option.ofObj d.Parent
+ found
+
+ let luma (r: int, g: int, b: int, _: int) =
+ 0.299 * float r + 0.587 * float g + 0.114 * float b
+
+
[<TestMethod>]
member _.PavingCoversTrafficNetworkAndDropsIsolatedScatter () =
let paving = cc0PavingTiles map
@@ -168,3 +235,37 @@ type P40ArtTests () =
topRow (cc0TileIndexBoatLeft * 32) 31,
topRow (cc0TileIndexBoatRight * 32) 0,
"left/right awning halves must meet at the same arch height (no broken arc)")
+
+ [<TestMethod>]
+ member _.NightBoatAwningIsAtLeast40GreyDarkerThanSurroundingGlow () =
+ match evidencePath "p40-night.png" with
+ | None -> Assert.Inconclusive "docs/evidence/p40-night.png has not been recorded"
+ | Some path ->
+ let _, _, pixel = decodePng path
+ // The p40-night hook centers tile (234,50) in a 1280x720 viewport; the
+ // 2-tile boat occupies tiles 234-235 at row 50 (Sim.tilePixels = 32).
+ let camX = 234 * 32 + 16 - 640
+ let camY = 50 * 32 + 16 - 360
+ let x0 = 234 * 32 - camX
+ let x1 = 235 * 32 + 32 - camX
+ let y0 = 50 * 32 - camY
+ let y1 = 51 * 32 - camY
+ let mutable boatSum = 0.0
+ let mutable boatN = 0
+ for y in y0 .. y1 - 1 do
+ for x in x0 .. x1 - 1 do
+ boatSum <- boatSum + luma (pixel x y)
+ boatN <- boatN + 1
+ let m = 24
+ let mutable ringSum = 0.0
+ let mutable ringN = 0
+ for y in y0 - m .. y1 - 1 + m do
+ for x in x0 - m .. x1 - 1 + m do
+ if not (x >= x0 && x < x1 && y >= y0 && y < y1) then
+ ringSum <- ringSum + luma (pixel x y)
+ ringN <- ringN + 1
+ let boat = boatSum / float boatN
+ let ring = ringSum / float ringN
+ Assert.IsTrue(
+ ring - boat >= 40.0,
+ sprintf "night boat awning contrast %.1f grey is below the 40 grey budget (boat %.1f ring %.1f)" (ring - boat) boat ring)
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 2b2b0e7..0f176aa 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -970,18 +970,26 @@ module VillageArt =
for (rect, color) in lanternGlowLayers centerX centerY radiusPixels 8 255 214 150 peakAlpha do
spriteBatch.Draw(pixel, rect, color)
+ let cc0BoatNightTintScale = 0.7f
+
let drawCc0BoatOverlay
(spriteBatch: SpriteBatch)
(textures: ArtTextures)
(camera: XnaVector2)
(map: MapGen.Result)
(tint: XnaColor) =
+ let night =
+ XnaColor(
+ int (float32 tint.R * cc0BoatNightTintScale),
+ int (float32 tint.G * cc0BoatNightTintScale),
+ int (float32 tint.B * cc0BoatNightTintScale),
+ int tint.A)
match textures.Cc0TileAtlas with
| Some cc0 ->
match cc0BoatTile map with
| Some (boatX, boatY) ->
- drawCc0Tile spriteBatch cc0 tint (cc0TileRect cc0TileIndexBoatLeft) (boatX * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y)
- drawCc0Tile spriteBatch cc0 tint (cc0TileRect cc0TileIndexBoatRight) ((boatX + 1) * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y)
+ drawCc0Tile spriteBatch cc0 night (cc0TileRect cc0TileIndexBoatLeft) (boatX * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y)
+ drawCc0Tile spriteBatch cc0 night (cc0TileRect cc0TileIndexBoatRight) ((boatX + 1) * Sim.tilePixels - int camera.X) (boatY * Sim.tilePixels - int camera.Y)
| None -> ()
| None -> ()