diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 18:04:48 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 18:04:48 +0800 |
| commit | 931b0acbc06b9d9c7e4e16ae8d714e14e840cae8 (patch) | |
| tree | f81228b7cb7bb44228462373ae4cb7d370572398 | |
| parent | c59da29bba304152e3826ab869a421c7c75f780d (diff) | |
| download | living-village-931b0acbc06b9d9c7e4e16ae8d714e14e840cae8.tar.gz | |
feat(p40): 乌篷船篷面实心拱形重绘与像素回归
- make-cc0-art.py 重绘 boat 篷面为实心拱形竹篷:拱线以下逐列实心填充,
连续脊线高光跨越两半在瓦片边界相接,肋骨/端盖/甲板阴影收边(原 1px 竖柱
栅栏式画法改为整片拱面)
- 重生成 Assets/cc0-tiles.png(同参数逐字节稳定)
- P40ArtTests 新增 BoatAwningIsASolidArchWithAContinuousRidge 像素级断言:
脊线无透明断列、拱下每列有实心竹色、左右两半拱顶同高
属 P40 整改的篷面实心 + 回归部分。
| -rw-r--r-- | scripts/make-cc0-art.py | 120 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P40ArtTests.fs | 66 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cc0-tiles.png | bin | 1834 -> 1739 bytes |
3 files changed, 145 insertions, 41 deletions
diff --git a/scripts/make-cc0-art.py b/scripts/make-cc0-art.py index 351c20a..3290fa0 100644 --- a/scripts/make-cc0-art.py +++ b/scripts/make-cc0-art.py @@ -235,6 +235,7 @@ PAVE_JOINT = (72, 72, 70, 255) BAMBOO = (44, 58, 34, 255) BAMBOO_HI = (86, 104, 62, 255) BAMBOO_RIB = (28, 38, 22, 255) +BAMBOO_DARK = (22, 30, 17, 255) RED_LANTERN = (208, 58, 46, 255) RED_LANTERN_HI = (238, 104, 84, 255) RED_LANTERN_DARK = (150, 34, 28, 255) @@ -284,54 +285,91 @@ def draw_bridge_end(c, ox): def draw_boat(c, ox, right_half): """Wu Peng boat half (2 tiles wide): dark hull + tall curved bamboo awning. - P40: the boat was scaled up to fill the 64x32 footprint so the wu peng (拱形 - 竹篷) reads at a glance from a bridge or bank. Each half is 32px wide; the - awning is a tall elliptical bamboo arch spanning the middle of the 2-tile - boat with visible ribs, and the hull is a solid dark band with an upturned - bow/stern tip. The gunwale sits 8px down, leaving open water ripple above. + P40 fix: the awning is a *solid* arched span, not a row of 1px posts. For + every column across the awning the tile fills a horizontal band from the + arch top y(x) down to the deck line, so the silhouette reads as one filled + 拱形竹篷. The ridge (arch top plateau) is a continuous highlight band with no + transparent break. Each half is 32px wide; the right half mirrors the left, + so the two halves join into a single continuous arch with the ridge meeting + at the tile boundary (no broken arc). + + Left half local profile y(x): + x < 1 : outside the awning (bow only) + x in [1,6) : rising arc 9,7,5,3,1 (stepped outer end) + x in [6,31]: ridge plateau y=1 + The right half mirrors it (local lx -> 31-lx). The stepped end is capped with + one BAMBOO_DARK column; the hull has plank seams (y=14/22) and a waterline. """ c.fill(ox, 0, 32, 32, CLEAR) - # --- Hull: solid dark band spanning the full cell, 8px down to 30px. --- + deck_y = 9 + + def top_at(x): + """Left-half local arch top row for column x, or None outside the awning.""" + if x < 1: + return None + if x < 6: + # Rounded rise: 9,7,5,3,1 across x=1..5 so the ridge plateau starts at x=6. + return 9 - 2 * (x - 1) + return 1 + + # --- Hull: solid dark band, 8px down to 30px, with a raised bow/stern tip. --- c.fill(ox, 8, 32, 22, BOAT_HULL) c.fill(ox, 8, 32, 2, BOAT_HULL_DARK) c.fill(ox, 28, 32, 2, BOAT_HULL_DARK) - # --- Awning (wu peng): tall bamboo arch over the middle of the 2-tile boat. --- - # Arc rises from the outer edge up to a high ridge, so both halves join into - # one continuous arch. Left half: low at x=0, ridge near the inner (right) edge. - arc = [ - (0, 12), (2, 9), (4, 6), (6, 4), (8, 3), - (10, 2), (12, 1), (14, 1), (16, 1), (18, 1), - (20, 1), (22, 1), (24, 2), (26, 3), (28, 4), - (30, 5), (32, 6), - ] + # Plank seams (y=14 / y=22) + waterline (bottom rows read as below the water). + c.hline(ox, 14, 32, BOAT_HULL_DARK) + c.hline(ox, 22, 32, BOAT_HULL_DARK) + c.hline(ox, 29, 32, BOAT_HULL_DARK) + c.hline(ox, 30, 32, BOAT_HULL_DARK) if not right_half: - # Left half: bow tip at the far left, rising; awning covers inner 20px. - for i in range(24): - half = 4 + i // 2 - c.fill(ox, 8 + i, min(32, half), 1, BOAT_HULL) - c.fill(ox, 6, 4, 3, BOAT_HULL_DARK) - c.fill(ox, 5, 2, 3, BOAT_HULL) - for (x, top) in arc: - c.fill(ox + x, top, 1, 32 - top, BAMBOO) - c.fill(ox + 12, 1, 20, 2, BAMBOO_HI) - for rib in (14, 18, 22, 26, 30): - c.fill(ox + rib, 1, 1, 30, BAMBOO_RIB) - c.fill(ox + 12, 30, 20, 2, BAMBOO_RIB) - c.fill(ox + 12, 6, 2, 24, BAMBOO_HI) + c.fill(ox, 5, 3, 3, BOAT_HULL) + c.fill(ox, 6, 3, 2, BOAT_HULL_DARK) + awning_columns = list(range(1, 32)) + rib_columns = [14, 20, 26] + cap_columns = [1] # stepped outer end of the awning + + def local_top(lx): + return top_at(lx) else: - # Right half: stern tip at the far right, mirrored continuous hull. - for i in range(24): - half = 4 + i // 2 - c.fill(ox + 32 - min(32, half), 8 + i, min(32, half), 1, BOAT_HULL) - c.fill(ox + 28, 6, 4, 3, BOAT_HULL_DARK) - c.fill(ox + 30, 5, 2, 3, BOAT_HULL) - for (x, top) in arc: - c.fill(ox + 31 - x, top, 1, 32 - top, BAMBOO) - c.fill(ox, 1, 20, 2, BAMBOO_HI) - for rib in (1, 5, 9, 13, 17): - c.fill(ox + rib, 1, 1, 30, BAMBOO_RIB) - c.fill(ox, 30, 20, 2, BAMBOO_RIB) - c.fill(ox + 18, 6, 2, 24, BAMBOO_HI) + c.fill(ox + 29, 5, 3, 3, BOAT_HULL) + c.fill(ox + 28, 6, 3, 2, BOAT_HULL_DARK) + awning_columns = list(range(0, 31)) + rib_columns = [5, 11, 17] + cap_columns = [29] # mirrored stepped outer end + + def local_top(lx): + return top_at(31 - lx) + + # --- Solid arched span: each column fills from the arch line to the deck. --- + for lx in awning_columns: + t = local_top(lx) + if t is None: + continue + c.fill(ox + lx, t, 1, deck_y - t + 1, BAMBOO) + # Continuous ridge highlight: rows t..t+1 across the whole awning (no break). + for lx in awning_columns: + t = local_top(lx) + if t is None: + continue + c.fill(ox + lx, t, 1, min(2, deck_y - t + 1), BAMBOO_HI) + # Bamboo ribs below the ridge (texture only; ridge band stays continuous). + for lx in rib_columns: + t = local_top(lx) + if t is None or t + 2 > deck_y: + continue + c.fill(ox + lx, t + 2, 1, deck_y - t - 1, BAMBOO_RIB) + # Stepped end cap: one dark column closing the outer end of the awning. + for lx in cap_columns: + t = local_top(lx) + if t is None: + continue + c.fill(ox + lx, t, 1, deck_y - t + 1, BAMBOO_DARK) + # Deck shadow where the awning meets the hull. + for lx in awning_columns: + t = local_top(lx) + if t is None: + continue + c.set(ox + lx, deck_y, BAMBOO_RIB) def draw_paving(c, ox): 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<byte> (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)) + [<TestMethod>] 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<int>(21, cc0TileCount) + + [<TestMethod>] + 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<Option<int>>( + 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 Binary files differindex d26e8b3..97a875b 100644 --- a/src/LivingVillage.Desktop/Assets/cc0-tiles.png +++ b/src/LivingVillage.Desktop/Assets/cc0-tiles.png |
