summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 10:58:30 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 10:58:30 +0800
commite91a4a7ed0a55e9406623d1649cc432369bc0834 (patch)
tree40de4d948158146cd1bd4c14a683829055ecd989
parent9df2da41289f4d0852f86e86eae81ca7f7e7b598 (diff)
downloadliving-village-e91a4a7ed0a55e9406623d1649cc432369bc0834.tar.gz
feat(desktop): 民居正面完整化与图集足迹
新增屋脊/檐口/木构粉墙/格窗/木门五个地块图块,可进屋民居以门为锚点绘制四宽三高完整立面(黛瓦正脊、檐口层次、木构白墙、格窗与木门);新增 planFootprint 供生成阻挡判定,村民不会刷在建筑图块上。
-rw-r--r--scripts/make-jiangnan-art.py73
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs2
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs14
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin1098 -> 1483 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs47
5 files changed, 124 insertions, 12 deletions
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index 478e732..98e357c 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 256x32 (8 ground/prop tiles, 32px each)
+ jiangnan-world.png 416x32 (13 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(256, 32)
+ c = Canvas(416, 32)
tile = 32
def slot(index):
@@ -227,6 +227,73 @@ def build_world_atlas():
c.set(x0 + sx + 1, row_y - 3, sprout_light)
c.outline(x0, 0, 32, 32)
+ x0 = slot(8)
+ ridge_bar = (38, 42, 50, 255)
+ for y in range(14, 28):
+ c.rect(x0 + 3, y, 26, 1, ridge_bar)
+ c.rect(x0 + 3, 13, 26, 1, roof_light)
+ c.rect(x0 + 3, 27, 26, 1, roof_dark)
+ for end_x, end_w in ((0, 6), (26, 6)):
+ for step in range(5):
+ c.rect(x0 + end_x + (step if end_x == 0 else 0), 14 - step * 2, end_w, 2, ridge_bar)
+ c.rect(x0 + end_x, 6 if end_x == 0 else 8, end_w, 1, roof_light)
+ c.rect(x0 + 2, 27, 28, 1, roof_dark)
+ for x in range(7, 26, 6):
+ c.rect(x0 + x, 14, 1, 13, roof_dark)
+
+ x0 = slot(9)
+ c.rect(x0, 0, 32, 14, roof)
+ for x in range(0, 32, 8):
+ c.rect(x0 + x, 0, 1, 14, roof_dark)
+ c.rect(x0, 3, 32, 1, roof_light)
+ c.rect(x0, 14, 32, 2, roof_dark)
+ c.rect(x0, 16, 32, 2, roof_light)
+ c.rect(x0, 18, 32, 3, (23, 31, 34, 70))
+ for tip_x in (2, 29):
+ c.rect(x0 + tip_x, 12, 1, 4, roof_light)
+
+ x0 = slot(10)
+ c.rect(x0, 0, 32, 32, wall)
+ c.rect(x0, 0, 32, 3, wood)
+ c.rect(x0, 0, 32, 1, wood_light)
+ c.rect(x0, 3, 32, 1, wood_dark)
+ for post_x in (0, 29):
+ c.rect(x0 + post_x, 0, 3, 32, wood)
+ c.rect(x0 + post_x, 0, 1, 32, wood_light)
+ c.rect(x0 + post_x + 2, 0, 1, 32, wood_dark)
+ c.rect(x0 + 3, 9, 26, 1, wall_seam)
+ c.rect(x0 + 3, 19, 26, 1, wall_seam)
+ c.rect(x0 + 3, 28, 26, 4, (128, 128, 126, 255))
+ c.rect(x0 + 3, 28, 26, 1, (150, 150, 146, 255))
+
+ x0 = slot(11)
+ c.rect(x0, 0, 32, 32, wall)
+ c.rect(x0, 0, 32, 3, wood)
+ c.rect(x0, 0, 32, 1, wood_light)
+ c.rect(x0, 3, 32, 1, wood_dark)
+ c.rect(x0 + 7, 8, 18, 16, (94, 66, 40, 255))
+ c.rect(x0 + 9, 10, 14, 12, (61, 45, 49, 255))
+ for lx in (12, 16, 20):
+ c.rect(x0 + lx, 10, 1, 12, (196, 180, 152, 255))
+ for ly in (14, 18):
+ c.rect(x0 + 9, ly, 14, 1, (196, 180, 152, 255))
+ c.rect(x0 + 7, 8, 18, 1, (164, 111, 61, 255))
+ c.rect(x0 + 3, 28, 26, 4, (128, 128, 126, 255))
+ c.rect(x0 + 3, 28, 26, 1, (150, 150, 146, 255))
+
+ x0 = slot(12)
+ c.rect(x0, 0, 32, 32, wall)
+ c.rect(x0, 0, 32, 3, wood)
+ c.rect(x0, 0, 32, 1, wood_light)
+ c.rect(x0, 3, 32, 1, wood_dark)
+ c.rect(x0 + 6, 4, 20, 28, (94, 66, 40, 255))
+ c.rect(x0 + 8, 6, 16, 26, wood)
+ c.rect(x0 + 8, 6, 16, 1, wood_light)
+ c.rect(x0 + 15, 6, 2, 26, wood_dark)
+ c.rect(x0 + 10, 12, 4, 1, wood_dark)
+ c.rect(x0 + 19, 18, 3, 3, (196, 158, 74, 255))
+ c.rect(x0 + 6, 29, 20, 3, (83, 52, 39, 255))
+
return c
@@ -446,7 +513,7 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 256, 32),
+ ("jiangnan-world.png", build_world_atlas(), 416, 32),
("jiangnan-characters.png", build_character_atlas(), 1024, 48),
("jiangnan-interior.png", build_interior_atlas(), 392, 96),
]
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
index 526d1af..e9713e5 100644
--- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs
+++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
@@ -102,7 +102,7 @@ type DesktopTests () =
let renderPlan = VillageArt.sampleRenderPlan ()
let blockedTiles =
(renderPlan.Grounds |> List.map (fun (tile, _) -> VillageArt.worldTile renderPlan tile))
- @ (renderPlan.Props |> List.map (fun prop -> VillageArt.worldTile renderPlan prop.Position))
+ @ VillageArt.planFootprint renderPlan
|> List.map (fun tile -> tile.X, tile.Y)
|> Set.ofList
let sceneTileOf (position: Vec2) : TilePosition =
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index a403223..06b3250 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -291,6 +291,20 @@ type PrototypeTests () =
Assert.AreEqual<int>(4, first.Interior.Elements.Length)
[<TestMethod>]
+ member _.HouseFacadeCoversRoofEaveAndWalledDoorRow () =
+ let plan = VillageArt.sampleRenderPlan ()
+ let footprint = VillageArt.planFootprint plan |> Set.ofList
+ let door = scene.Homes |> List.head |> fun home -> VillageArt.worldTile plan home.Door
+
+ // The enterable home must present a full facade: ridge, eave, and a walled row with the door.
+ for dx in -2 .. 1 do
+ 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 + 2 bridge + 3 bamboo + 2 garden.
+ Assert.AreEqual<int>(19, footprint.Count)
+
+ [<TestMethod>]
member _.FacingUsesTheActualMovementOrTargetVector () =
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, VillageArt.directionFromVector { X = 1.0f; Y = 0.1f })
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, VillageArt.directionFromVector { X = -1.0f; Y = 0.1f })
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index 75dc906..766405f 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 03cd22e..5883a97 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -97,6 +97,11 @@ module VillageArt =
| BridgeSprite
| BambooSprite
| GardenSprite
+ | RoofRidgeSprite
+ | RoofEaveSprite
+ | WallWoodSprite
+ | WallWindowSprite
+ | DoorSprite
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -158,7 +163,7 @@ module VillageArt =
ground StonePath 6 6
ground StonePath 6 7 ]
Props =
- [ renderProp House 6 4
+ [ renderProp House 6 5
renderProp Bridge 10 5
renderProp BambooGrove 3 2
renderProp VegetablePatch 8 7 ]
@@ -173,6 +178,23 @@ module VillageArt =
{ X = plan.Origin.X + localPosition.X
Y = plan.Origin.Y + localPosition.Y }
+ /// Every tile a prop paints over, in world coordinates. Used by draw ordering and by
+ /// spawn-overlap assertions so villagers never start inside a painted prop.
+ let planFootprint (plan: JiangnanRenderPlan) : TilePosition list =
+ let many positions = positions |> List.map (worldTile plan)
+ plan.Props
+ |> List.collect (fun prop ->
+ let x, y = prop.Position.X, prop.Position.Y
+ match prop.Kind with
+ | House ->
+ [ for dx in -2 .. 1 do
+ for dy in -2 .. 0 do
+ yield position (x + dx) (y + dy) ]
+ | Bridge -> [ position x y; position x (y + 1) ]
+ | BambooGrove -> [ position x y; position x (y + 1); position (x + 1) y ]
+ | VegetablePatch -> [ position x y; position (x + 1) y ])
+ |> many
+
let private positiveModulo divisor value =
let remainder = value % divisor
if remainder < 0 then remainder + divisor else remainder
@@ -249,7 +271,8 @@ module VillageArt =
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldAtlasWidth = Sim.tilePixels * 8
+ let worldTileCount = 13
+ let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
let characterAtlasWidth = 32 * 4 * 4 * 2
let characterAtlasHeight = 48
@@ -285,6 +308,11 @@ module VillageArt =
| BridgeSprite -> 5
| BambooSprite -> 6
| GardenSprite -> 7
+ | RoofRidgeSprite -> 8
+ | RoofEaveSprite -> 9
+ | WallWoodSprite -> 10
+ | WallWindowSprite -> 11
+ | DoorSprite -> 12
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -342,12 +370,15 @@ module VillageArt =
| StonePath -> StonePathSprite
draw position sprite
- let drawHouse localPosition =
- let position = worldTile plan localPosition
- for dx in -1 .. 1 do
- draw (offsetTile position dx 0) RoofSprite
- draw (offsetTile position dx 1) WallSprite
- draw (offsetTile position 0 1) BridgeSprite
+ let drawHouse doorTile =
+ let position = worldTile plan doorTile
+ for dx in -2 .. 1 do
+ draw (offsetTile position dx -2) RoofRidgeSprite
+ draw (offsetTile position dx -1) RoofEaveSprite
+ draw (offsetTile position -2 0) WallWoodSprite
+ draw (offsetTile position -1 0) WallWindowSprite
+ draw (offsetTile position 0 0) DoorSprite
+ draw (offsetTile position 1 0) WallWoodSprite
let drawBridge localPosition =
let position = worldTile plan localPosition