From e32e288358ffcf35d45bd1c6f1912746cdbc3d37 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 18:15:23 +0800 Subject: feat(p41): 夜帧船篷对比暗化 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/LivingVillage.Desktop.Tests/P40ArtTests.fs | 101 +++++++++++++++++++++++++ src/LivingVillage.Desktop/VillageArt.fs | 12 ++- 2 files changed, 111 insertions(+), 2 deletions(-) (limited to 'src') 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 (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 + + [] 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)") + + [] + 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 -> () -- cgit v1.2.3