summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 11:12:52 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 11:12:52 +0800
commit326575888b9c8edf0ded1dcf1c01f5c78d3eca84 (patch)
tree1a31ec020d3db972d2e39b9e20f2d2750249fcdc
parent623ee728e939663aa4c98a731dcd0dcfe58bdb90 (diff)
downloadliving-village-326575888b9c8edf0ded1dcf1c01f5c78d3eca84.tar.gz
feat(desktop): 河畔植被与中式道具点缀
图集新增柳树(垂枝两段)、灌木、花丛、芦苇、石凳、灯笼七类图块并经复制规则进构建;场景加入两株垂柳、两丛灌木、两处花丛、水中两簇芦苇、河畔石凳与两盏岸边灯笼,芦苇立于水面、灯笼立于岸道,全部入图集无程序几何占位。
-rw-r--r--scripts/make-jiangnan-art.py126
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs26
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin1576 -> 3045 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs57
4 files changed, 201 insertions, 8 deletions
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index 26db3fc..616928e 100644
--- a/scripts/make-jiangnan-art.py
+++ b/scripts/make-jiangnan-art.py
@@ -2,7 +2,7 @@
"""Deterministic pixel-art atlases for LivingVillage.Desktop.
Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets:
- jiangnan-world.png 448x32 (14 ground/prop tiles, 32px each)
+ jiangnan-world.png 672x32 (21 ground/prop tiles, 32px each)
jiangnan-characters.png 1024x48 (4 variants x 4 directions x 2 frames, 32x48 cells)
jiangnan-interior.png 392x96 (variable-size bottom-aligned interior cells)
@@ -99,7 +99,7 @@ def verify_png(path, expected_w, expected_h):
def build_world_atlas():
- c = Canvas(448, 32)
+ c = Canvas(672, 32)
tile = 32
def slot(index):
@@ -310,6 +310,126 @@ def build_world_atlas():
c.set(x0 + sx, sy, (214, 231, 238, 255))
c.set(x0 + sx + 1, sy, (214, 231, 238, 120))
+ willow_leaf = (86, 138, 74, 255)
+ willow_leaf_light = (128, 172, 96, 255)
+ willow_leaf_dark = (62, 104, 58, 255)
+ trunk = (98, 64, 44, 255)
+ trunk_light = (132, 92, 60, 255)
+
+ x0 = slot(14)
+ for cx, cy, rx, ry in ((16, 12, 13, 10), (8, 8, 7, 7), (24, 8, 7, 7), (16, 6, 6, 6)):
+ for py in range(cy - ry, cy + ry):
+ for px in range(cx - rx, cx + rx):
+ if ((px - cx) * (px - cx)) * 2 + ((py - cy) * (py - cy)) * 3 <= rx * ry:
+ shade = willow_leaf
+ if (px * 5 + py * 3) % 11 == 0:
+ shade = willow_leaf_light
+ elif (px * 3 + py * 7) % 13 == 0:
+ shade = willow_leaf_dark
+ c.set(x0 + px, py, shade)
+ for dx, dy in ((-9, 12), (-5, 16), (0, 19), (6, 16), (10, 12), (-2, 10), (3, 10)):
+ for drop in range(9):
+ c.set(x0 + 16 + dx, 18 + dy // 2 + drop, willow_leaf_dark if drop % 3 else willow_leaf_light)
+ c.rect(x0 + 14, 24, 4, 8, trunk)
+ c.rect(x0 + 14, 24, 1, 8, trunk_light)
+ for bx, by in ((10, 18), (20, 16)):
+ c.rect(x0 + bx, by, 3, 2, willow_leaf_dark)
+
+ x0 = slot(15)
+ c.rect(x0 + 13, 8, 6, 20, trunk)
+ c.rect(x0 + 13, 8, 2, 20, trunk_light)
+ for bx, by in ((8, 22), (21, 19), (6, 26), (24, 25)):
+ c.rect(x0 + bx, by, 4, 2, trunk)
+ for ex, ey in ((-10, 12), (-6, 8), (-2, 6), (3, 7), (8, 11), (11, 15)):
+ for drop in range(14):
+ yy = ey + drop
+ if yy < 30:
+ c.set(x0 + 16 + ex, yy, willow_leaf_dark if drop % 3 else willow_leaf)
+ if drop % 4 == 1:
+ c.set(x0 + 17 + ex, yy, willow_leaf_light)
+ c.outline(x0, 0, 32, 32)
+
+ shrub = (74, 118, 64, 255)
+ shrub_light = (108, 152, 84, 255)
+ shrub_dark = (56, 92, 54, 255)
+ x0 = slot(16)
+ for cx, cy, rx, ry in ((11, 22, 9, 8), (22, 24, 8, 7), (16, 17, 7, 6)):
+ for py in range(cy - ry, cy + ry):
+ for px in range(cx - rx, cx + rx):
+ if ((px - cx) * (px - cx)) + ((py - cy) * (py - cy)) * 2 <= rx * ry:
+ shade = shrub
+ if (px * 7 + py * 5) % 9 == 0:
+ shade = shrub_light
+ elif (px * 5 + py * 3) % 7 == 0:
+ shade = shrub_dark
+ c.set(x0 + px, py, shade)
+ for fx, fy in ((9, 17), (17, 14), (24, 21), (13, 24), (20, 27)):
+ c.set(x0 + fx, fy, sprout_light)
+ c.outline(x0, 0, 32, 32)
+
+ x0 = slot(17)
+ flower_red = (196, 92, 96, 255)
+ flower_yellow = (222, 188, 92, 255)
+ flower_white = (232, 228, 214, 255)
+ flower_stem = (74, 118, 64, 255)
+ for fx, fy, fc in ((7, 22, flower_red), (12, 18, flower_yellow), (17, 23, flower_white),
+ (22, 19, flower_red), (26, 24, flower_yellow), (10, 27, flower_white),
+ (19, 27, flower_red)):
+ c.rect(x0 + fx, fy + 2, 1, 4, flower_stem)
+ c.rect(x0 + fx - 1, fy, 3, 3, fc)
+ c.set(x0 + fx, fy + 1, (150, 62, 58, 255) if fc == flower_red else (172, 132, 52, 255))
+ for gx, gy in ((5, 25), (14, 21), (24, 26), (20, 15)):
+ c.rect(x0 + gx, gy, 2, 1, flower_stem)
+ c.outline(x0, 0, 32, 32)
+
+ reed = (104, 142, 82, 255)
+ reed_light = (146, 178, 110, 255)
+ reed_seed = (172, 140, 92, 255)
+ x0 = slot(18)
+ for rx, height in ((6, 18), (11, 24), (15, 20), (20, 26), (25, 17)):
+ for drop in range(height):
+ yy = 31 - drop
+ sway = (drop // 5) if rx % 2 else -(drop // 5)
+ c.set(x0 + rx + sway, yy, reed if drop % 3 else reed_light)
+ c.rect(x0 + rx - 1, 31 - height, 3, 3, reed_seed)
+ c.rect(x0 + rx, 31 - height - 2, 1, 2, reed_light)
+ c.outline(x0, 0, 32, 32)
+
+ stone = (150, 150, 146, 255)
+ stone_light = (178, 178, 172, 255)
+ stone_dark = (118, 118, 114, 255)
+ x0 = slot(19)
+ c.rect(x0 + 4, 18, 24, 7, stone)
+ c.rect(x0 + 4, 18, 24, 1, stone_light)
+ c.rect(x0 + 4, 24, 24, 1, stone_dark)
+ for sx in (10, 17, 24):
+ c.rect(x0 + sx, 19, 1, 5, stone_dark)
+ for lx in (7, 23):
+ c.rect(x0 + lx, 14, 3, 4, stone)
+ c.rect(x0 + lx, 14, 3, 1, stone_light)
+ c.rect(x0 + lx - 1, 17, 5, 2, stone_dark)
+ c.rect(x0 + 6, 25, 20, 4, stone_dark)
+ c.rect(x0 + 7, 29, 18, 2, (96, 96, 92, 255))
+ c.outline(x0, 0, 32, 32)
+
+ lantern_paper = (212, 92, 74, 255)
+ lantern_paper_light = (238, 140, 104, 255)
+ lantern_rib = (150, 54, 46, 255)
+ x0 = slot(20)
+ c.rect(x0 + 15, 4, 2, 22, wood_dark)
+ c.rect(x0 + 12, 2, 8, 3, wood)
+ c.rect(x0 + 12, 2, 8, 1, wood_light)
+ c.rect(x0 + 10, 8, 12, 12, lantern_paper)
+ c.rect(x0 + 10, 8, 12, 1, lantern_paper_light)
+ c.rect(x0 + 10, 19, 12, 1, lantern_rib)
+ for rx in (12, 15, 18):
+ c.rect(x0 + rx, 9, 1, 10, lantern_rib)
+ c.rect(x0 + 13, 12, 6, 4, (252, 216, 148, 255))
+ c.rect(x0 + 11, 20, 10, 2, wood)
+ c.rect(x0 + 13, 22, 6, 2, (252, 216, 148, 255))
+ c.rect(x0 + 14, 25, 4, 3, lantern_rib)
+ c.outline(x0, 0, 32, 32)
+
return c
@@ -529,7 +649,7 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 448, 32),
+ ("jiangnan-world.png", build_world_atlas(), 672, 32),
("jiangnan-characters.png", build_character_atlas(), 1024, 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 7959d27..4d698ad 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -301,8 +301,30 @@ 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 tiles + 3 bridge + 3 bamboo + 2 garden.
- Assert.AreEqual<int>(20, footprint.Count)
+ // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds + 1 bench + 2 lanterns.
+ Assert.AreEqual<int>(33, footprint.Count)
+
+ [<TestMethod>]
+ member _.VegetationAndRiversidePropsDecorateTheScene () =
+ let plan = VillageArt.sampleRenderPlan ()
+ let kinds = plan.Props |> List.map (fun prop -> prop.Kind)
+ let count kind = kinds |> List.filter (fun candidate -> candidate = kind) |> List.length
+ let grounds = plan.Grounds |> List.map fst |> Set.ofList
+
+ Assert.AreEqual<int>(2, count VillageArt.WillowTree)
+ Assert.IsTrue(count VillageArt.ShrubPatch >= 2)
+ Assert.IsTrue(count VillageArt.FlowerBush >= 2)
+ Assert.AreEqual<int>(2, count VillageArt.ReedCluster)
+ Assert.AreEqual<int>(1, count VillageArt.StoneBench)
+ Assert.AreEqual<int>(2, count VillageArt.RiverLantern)
+
+ // Reeds must root in water; lanterns stand on the 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")
+ | _ -> ()
[<TestMethod>]
member _.WaterRippleFramesAreDeterministicAndTwoPhase () =
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index ca4a521..23e02cd 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index a66474b..151c73a 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -38,6 +38,12 @@ module VillageArt =
| Bridge
| BambooGrove
| VegetablePatch
+ | WillowTree
+ | ShrubPatch
+ | FlowerBush
+ | ReedCluster
+ | StoneBench
+ | RiverLantern
type RenderProp =
{ Kind: RenderPropKind
@@ -103,6 +109,13 @@ module VillageArt =
| WallWindowSprite
| DoorSprite
| WaterSpriteB
+ | WillowTopSprite
+ | WillowBaseSprite
+ | ShrubSprite
+ | FlowerSprite
+ | ReedSprite
+ | BenchSprite
+ | LanternSprite
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -175,7 +188,18 @@ module VillageArt =
[ renderProp House 6 5
renderProp Bridge 6 11
renderProp BambooGrove 3 2
- renderProp VegetablePatch 8 7 ]
+ renderProp VegetablePatch 8 7
+ renderProp WillowTree 1 9
+ renderProp WillowTree 13 9
+ renderProp ShrubPatch 1 7
+ renderProp ShrubPatch 10 7
+ renderProp FlowerBush 3 8
+ renderProp FlowerBush 12 8
+ renderProp ReedCluster 3 12
+ renderProp ReedCluster 15 13
+ renderProp StoneBench 9 11
+ renderProp RiverLantern 5 11
+ renderProp RiverLantern 7 14 ]
Interior =
{ Elements =
[ interiorElement Window 2 2
@@ -201,7 +225,13 @@ module VillageArt =
yield position (x + dx) (y + dy) ]
| Bridge -> [ position x y; position x (y + 1); position x (y + 2) ]
| BambooGrove -> [ position x y; position x (y + 1); position (x + 1) y ]
- | VegetablePatch -> [ position x y; position (x + 1) y ])
+ | VegetablePatch -> [ position x y; position (x + 1) y ]
+ | WillowTree -> [ position x y; position x (y + 1) ]
+ | ShrubPatch
+ | FlowerBush
+ | ReedCluster
+ | StoneBench
+ | RiverLantern -> [ position x y ])
|> many
let private positiveModulo divisor value =
@@ -284,7 +314,7 @@ module VillageArt =
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldTileCount = 14
+ let worldTileCount = 21
let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
let characterAtlasWidth = 32 * 4 * 4 * 2
@@ -327,6 +357,13 @@ module VillageArt =
| WallWindowSprite -> 11
| DoorSprite -> 12
| WaterSpriteB -> 13
+ | WillowTopSprite -> 14
+ | WillowBaseSprite -> 15
+ | ShrubSprite -> 16
+ | FlowerSprite -> 17
+ | ReedSprite -> 18
+ | BenchSprite -> 19
+ | LanternSprite -> 20
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -413,12 +450,26 @@ module VillageArt =
draw position GardenSprite
draw (offsetTile position 1 0) GardenSprite
+ let drawWillow localPosition =
+ let position = worldTile plan localPosition
+ draw position WillowTopSprite
+ draw (offsetTile position 0 1) WillowBaseSprite
+
+ let drawSingleTile localPosition sprite =
+ draw (worldTile plan localPosition) sprite
+
for prop in plan.Props do
match prop.Kind with
| House -> drawHouse prop.Position
| Bridge -> drawBridge prop.Position
| BambooGrove -> drawBamboo prop.Position
| VegetablePatch -> drawGarden prop.Position
+ | WillowTree -> drawWillow prop.Position
+ | ShrubPatch -> drawSingleTile prop.Position ShrubSprite
+ | FlowerBush -> drawSingleTile prop.Position FlowerSprite
+ | ReedCluster -> drawSingleTile prop.Position ReedSprite
+ | StoneBench -> drawSingleTile prop.Position BenchSprite
+ | RiverLantern -> drawSingleTile prop.Position LanternSprite
let drawCharacter
(spriteBatch: SpriteBatch)