From 931b0acbc06b9d9c7e4e16ae8d714e14e840cae8 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 18:04:48 +0800 Subject: feat(p40): 乌篷船篷面实心拱形重绘与像素回归 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - make-cc0-art.py 重绘 boat 篷面为实心拱形竹篷:拱线以下逐列实心填充, 连续脊线高光跨越两半在瓦片边界相接,肋骨/端盖/甲板阴影收边(原 1px 竖柱 栅栏式画法改为整片拱面) - 重生成 Assets/cc0-tiles.png(同参数逐字节稳定) - P40ArtTests 新增 BoatAwningIsASolidArchWithAContinuousRidge 像素级断言: 脊线无透明断列、拱下每列有实心竹色、左右两半拱顶同高 属 P40 整改的篷面实心 + 回归部分。 --- src/LivingVillage.Desktop.Tests/P40ArtTests.fs | 66 +++++++++++++++++++++++++ src/LivingVillage.Desktop/Assets/cc0-tiles.png | Bin 1834 -> 1739 bytes 2 files changed, 66 insertions(+) (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/P40ArtTests.fs b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs index 549640e..15e6601 100644 --- a/src/LivingVillage.Desktop.Tests/P40ArtTests.fs +++ b/src/LivingVillage.Desktop.Tests/P40ArtTests.fs @@ -23,6 +23,47 @@ type P40ArtTests () = x >= 0 && x < map.Width && y >= 0 && y < map.Height && map.Tiles.[indexOf x y] = int MapGen.GroundTile.Stone + // --- P40 boat awning: solid arch silhouette (atlas pixel decode) ----------- + // The atlas is written by scripts/make-cc0-art.py as non-interlaced 8-bit RGBA + // with filter byte 0 on every scanline, so a tiny decoder is enough to assert + // the awning is a filled arch with a continuous ridge (not a row of 1px posts). + let atlasRgba () : int * int * (int -> int -> (int * int * int * int)) = + let path = System.IO.Path.Combine(System.AppContext.BaseDirectory, cc0TileAtlasRelativePath) + 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 = 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 decompressed = ms.ToArray() + let stride = width * 4 + let pixels = Array.zeroCreate (width * height * 4) + for y in 0 .. height - 1 do + let rowStart = y * (stride + 1) + 1 // skip the per-row filter byte (always 0 here) + Array.blit decompressed rowStart pixels (y * stride) stride + let get (x: int) (y: int) = + if x < 0 || x >= width || y < 0 || y >= height then (0, 0, 0, 0) + else + 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 isBambooOpaque (r: int, g: int, b: int, a: int) = + a > 0 + && ((r, g, b) = (44, 58, 34) + || (r, g, b) = (86, 104, 62) + || (r, g, b) = (28, 38, 22) + || (r, g, b) = (22, 30, 17)) + [] member _.PavingCoversTrafficNetworkAndDropsIsolatedScatter () = let paving = cc0PavingTiles map @@ -102,3 +143,28 @@ type P40ArtTests () = for index in [ cc0TileIndexPaving; cc0TileIndexRedLantern ] do Assert.IsTrue(index >= 0 && index < cc0TileCount, sprintf "P40 tile index %d out of atlas" index) Assert.AreEqual(21, cc0TileCount) + + [] + member _.BoatAwningIsASolidArchWithAContinuousRidge () = + let width, _, pixel = atlasRgba () + Assert.IsTrue(width >= (cc0TileIndexBoatRight + 1) * 32, "atlas must cover the boat tiles") + for tile in [ cc0TileIndexBoatLeft; cc0TileIndexBoatRight ] do + let ox = tile * 32 + // Ridge: y in [1,2], x in [6,25] must have no transparent column break. + for x in 6 .. 25 do + for y in 1 .. 2 do + let (_, _, _, a) = pixel (ox + x) y + Assert.IsTrue(a > 0, sprintf "boat tile %d ridge pixel (%d,%d) is transparent" tile x y) + // Solid arch: below the arch line, every x in [4,27] has opaque bamboo. + for x in 4 .. 27 do + let hasBamboo = + [ 0 .. 31 ] + |> List.exists (fun y -> isBambooOpaque (pixel (ox + x) y)) + Assert.IsTrue(hasBamboo, sprintf "boat tile %d column %d has no opaque bamboo" tile x) + // The two halves join: the arch top at the tile boundary is the same row. + let topRow (ox: int) (x: int) = + [ 0 .. 31 ] |> List.tryFind (fun y -> isBambooOpaque (pixel (ox + x) y)) + Assert.AreEqual>( + topRow (cc0TileIndexBoatLeft * 32) 31, + topRow (cc0TileIndexBoatRight * 32) 0, + "left/right awning halves must meet at the same arch height (no broken arc)") diff --git a/src/LivingVillage.Desktop/Assets/cc0-tiles.png b/src/LivingVillage.Desktop/Assets/cc0-tiles.png index d26e8b3..97a875b 100644 Binary files a/src/LivingVillage.Desktop/Assets/cc0-tiles.png and b/src/LivingVillage.Desktop/Assets/cc0-tiles.png differ -- cgit v1.2.3