From 55575af708bc1602f820f7f705bfc01898347082 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:41:41 +0800 Subject: feat(desktop): P17 街边中式细节物(石灯笼/水缸/摊贩摊位)图集扩展 28 格 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/make-jiangnan-art.py | 77 ++++++++++++++++++++- src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 31 +++++++-- .../Assets/jiangnan-world.png | Bin 3394 -> 3847 bytes src/LivingVillage.Desktop/VillageArt.fs | 25 ++++++- 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py index 2e1dee0..9abb4b8 100644 --- a/scripts/make-jiangnan-art.py +++ b/scripts/make-jiangnan-art.py @@ -99,7 +99,7 @@ def verify_png(path, expected_w, expected_h): def build_world_atlas(): - c = Canvas(800, 32) + c = Canvas(896, 32) tile = 32 def slot(index): @@ -474,6 +474,79 @@ def build_world_atlas(): puff(x0 + 15, 6, 5, 4, smoke_mid) puff(x0 + 18, 2, 3, 2, smoke_faint) + # --- Street-side Chinese details (P17): stone lantern, water vat, market stall. + # Baked into the atlas exactly like every other tile; the game only blits these + # cells and never builds the shapes procedurally. + + # slot(25) 石灯笼 (stone lantern): pedestal, shaft, lit chamber, flared cap + finial. + warm_lamp = (238, 200, 128, 255) + x0 = slot(25) + c.rect(x0 + 10, 26, 12, 4, stone) + c.rect(x0 + 10, 26, 12, 1, stone_light) + c.rect(x0 + 10, 29, 12, 1, stone_dark) + c.rect(x0 + 14, 18, 4, 8, stone) + c.rect(x0 + 14, 18, 1, 8, stone_light) + c.rect(x0 + 18, 18, 1, 8, stone_dark) + c.rect(x0 + 11, 12, 10, 7, stone) + c.rect(x0 + 11, 12, 10, 1, stone_light) + c.rect(x0 + 13, 14, 6, 4, warm_lamp) + c.set(x0 + 14, 15, (252, 232, 176, 255)) + c.rect(x0 + 9, 9, 14, 3, stone) + c.rect(x0 + 9, 9, 14, 1, stone_light) + c.rect(x0 + 6, 8, 20, 1, stone_dark) + c.rect(x0 + 15, 5, 2, 3, stone) + c.set(x0 + 16, 4, stone_light) + c.outline(x0, 0, 32, 32) + + # slot(26) 水缸 (water vat): barrel body with iron hoops, stone rim, still water. + vat = (96, 112, 106, 255) + vat_light = (134, 148, 140, 255) + vat_dark = (66, 78, 74, 255) + vat_water = (74, 122, 152, 255) + vat_water_hi = (150, 192, 208, 255) + x0 = slot(26) + c.rect(x0 + 6, 14, 20, 15, vat) + c.rect(x0 + 6, 14, 20, 1, vat_light) + c.rect(x0 + 6, 28, 20, 1, vat_dark) + for hoop_y in (18, 22, 26): + c.rect(x0 + 6, hoop_y, 20, 1, vat_dark) + for side_x in (4, 27): + c.rect(x0 + side_x, 17, 2, 10, vat) + c.rect(x0 + side_x, 17, 1, 10, vat_light) + c.rect(x0 + 8, 30, 16, 1, (52, 62, 58, 255)) + c.rect(x0 + 4, 12, 24, 3, vat_light) + c.rect(x0 + 6, 14, 20, 2, vat_water) + c.rect(x0 + 9, 14, 8, 1, vat_water_hi) + c.set(x0 + 20, 14, vat_water_hi) + c.outline(x0, 0, 32, 32) + + # slot(27) 摊贩摊位 (market stall): wood frame, striped awning, counter, wares. + awning_cloth = (196, 92, 74, 255) + awning_cream = (238, 222, 196, 255) + crate = (140, 96, 52, 255) + x0 = slot(27) + c.rect(x0 + 5, 8, 2, 22, wood) + c.rect(x0 + 25, 8, 2, 22, wood) + c.rect(x0 + 5, 8, 1, 22, wood_light) + c.rect(x0 + 25, 8, 1, 22, wood_light) + for i in range(0, 32, 8): + c.rect(x0 + i, 6, 8, 6, awning_cloth if (i // 8) % 2 == 0 else awning_cream) + c.rect(x0, 6, 32, 1, (150, 66, 54, 255)) + c.rect(x0, 11, 32, 1, (120, 52, 44, 255)) + c.rect(x0 + 3, 20, 26, 3, wood) + c.rect(x0 + 3, 20, 26, 1, wood_light) + c.rect(x0 + 3, 22, 26, 1, wood_dark) + c.rect(x0 + 7, 16, 7, 4, crate) + c.rect(x0 + 7, 16, 7, 1, (176, 128, 74, 255)) + c.rect(x0 + 17, 16, 7, 4, crate) + c.rect(x0 + 17, 16, 7, 1, (176, 128, 74, 255)) + for gx in (9, 12, 19, 22): + c.set(x0 + gx, 15, (86, 139, 68, 255)) + c.set(x0 + 13, 15, (196, 92, 96, 255)) + c.set(x0 + 20, 15, (222, 188, 92, 255)) + c.rect(x0 + 3, 23, 26, 6, (83, 52, 39, 255)) + c.outline(x0, 0, 32, 32) + return c @@ -811,7 +884,7 @@ def build_interior_atlas(): def main(): ASSET_DIR.mkdir(parents=True, exist_ok=True) targets = [ - ("jiangnan-world.png", build_world_atlas(), 800, 32), + ("jiangnan-world.png", build_world_atlas(), 896, 32), ("jiangnan-characters.png", build_character_atlas(), 3840, 48), ("jiangnan-interior.png", build_interior_atlas(), 392, 96), ] diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 88f6016..25f4d02 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -473,8 +473,9 @@ type PrototypeTests () = Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 2 } : TilePosition), sprintf "missing ridge tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx) - // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds + 1 bench + 2 lanterns. - Assert.AreEqual(33, footprint.Count) + // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds + // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17). + Assert.AreEqual(37, footprint.Count) [] member _.VegetationAndRiversidePropsDecorateTheScene () = @@ -489,13 +490,20 @@ type PrototypeTests () = Assert.AreEqual(2, count VillageArt.ReedCluster) Assert.AreEqual(1, count VillageArt.StoneBench) Assert.AreEqual(2, count VillageArt.RiverLantern) + Assert.AreEqual(2, count VillageArt.StoneLantern) + Assert.AreEqual(1, count VillageArt.WaterVat) + Assert.AreEqual(1, count VillageArt.MarketStall) - // Reeds must root in water; lanterns stand on the bank path. + // Reeds must root in water; lanterns and P17 street details stand on the paved bank path. for prop in plan.Props do let tile = VillageArt.worldTile plan prop.Position match prop.Kind with | VillageArt.ReedCluster -> Assert.IsTrue(grounds.Contains(prop.Position), "reeds must sit on water tiles") - | VillageArt.RiverLantern -> Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "lanterns must stand on the bank path") + | VillageArt.RiverLantern + | VillageArt.StoneLantern + | VillageArt.WaterVat + | VillageArt.MarketStall -> + Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "street details must stand on the bank path") | _ -> () [] @@ -1019,6 +1027,21 @@ type PrototypeTests () = Assert.AreEqual([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing)) + // The baked world atlas must match the 28-slot layout the renderer indexes into + // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27). + Assert.AreEqual(28, VillageArt.worldTileCount) + let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") + let header = File.ReadAllBytes worldPath + let be32 offset = + (int header.[offset] <<< 24) + ||| (int header.[offset + 1] <<< 16) + ||| (int header.[offset + 2] <<< 8) + ||| int header.[offset + 3] + let atlasWidth : int = be32 16 + let atlasHeight : int = be32 20 + Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 28 x 32px slots") + Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px") + [] member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () = // 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一, diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png index 9138560..6e97728 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-world.png and b/src/LivingVillage.Desktop/Assets/jiangnan-world.png differ diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 4845408..489da23 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -45,6 +45,9 @@ module VillageArt = | ReedCluster | StoneBench | RiverLantern + | StoneLantern + | WaterVat + | MarketStall type RenderProp = { Kind: RenderPropKind @@ -135,6 +138,9 @@ module VillageArt = | SmokeSpriteA | SmokeSpriteB | SmokeSpriteC + | StoneLanternSprite + | WaterVatSprite + | StallSprite type private XnaColor = Microsoft.Xna.Framework.Color type private XnaRectangle = Microsoft.Xna.Framework.Rectangle @@ -228,7 +234,11 @@ module VillageArt = renderProp ReedCluster 15 13 renderProp StoneBench 9 11 renderProp RiverLantern 5 11 - renderProp RiverLantern 7 14 ] + renderProp RiverLantern 7 14 + renderProp StoneLantern 4 14 + renderProp StoneLantern 16 14 + renderProp WaterVat 12 11 + renderProp MarketStall 3 11 ] Interior = { Elements = [ interiorElement Window 2 2 @@ -260,7 +270,10 @@ module VillageArt = | FlowerBush | ReedCluster | StoneBench - | RiverLantern -> [ position x y ]) + | RiverLantern + | StoneLantern + | WaterVat + | MarketStall -> [ position x y ]) |> many let private positiveModulo divisor value = @@ -445,7 +458,7 @@ module VillageArt = let characterAtlasRelativePath = "Assets/jiangnan-characters.png" let interiorAtlasRelativePath = "Assets/jiangnan-interior.png" - let worldTileCount = 25 + let worldTileCount = 28 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels let characterAtlasWidth = 32 * 5 * 4 * 6 @@ -516,6 +529,9 @@ module VillageArt = | SmokeSpriteA -> 22 | SmokeSpriteB -> 23 | SmokeSpriteC -> 24 + | StoneLanternSprite -> 25 + | WaterVatSprite -> 26 + | StallSprite -> 27 XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private tilePosition x y : TilePosition = @@ -653,6 +669,9 @@ module VillageArt = | ReedCluster -> drawSingleTile prop.Position ReedSprite | StoneBench -> drawSingleTile prop.Position BenchSprite | RiverLantern -> drawSingleTile prop.Position LanternSprite + | StoneLantern -> drawSingleTile prop.Position StoneLanternSprite + | WaterVat -> drawSingleTile prop.Position WaterVatSprite + | MarketStall -> drawSingleTile prop.Position StallSprite // Chimney smoke is a late pass so it always reads above the roof, the bamboo and // any other prop sharing its tile. The puff sits one row above the ridge centre -- cgit v1.2.3