summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/make-jiangnan-art.py65
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs26
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin3847 -> 4147 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs50
4 files changed, 122 insertions, 19 deletions
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index 9abb4b8..54805ab 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 800x32 (25 ground/prop tiles, 32px each)
+ jiangnan-world.png 992x32 (31 ground/prop tiles, 32px each)
jiangnan-characters.png 3840x48 (5 variants x 4 directions x 6 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(896, 32)
+ c = Canvas(992, 32)
tile = 32
def slot(index):
@@ -547,6 +547,65 @@ def build_world_atlas():
c.rect(x0 + 3, 23, 26, 6, (83, 52, 39, 255))
c.outline(x0, 0, 32, 32)
+ # --- P26 house facade variant B: projecting eave, lattice window, courtyard gate.
+ # Same baked-atlas pipeline as every other tile; the renderer only blits these cells.
+
+ # slot(28) 屋檐挑出 (projecting eave): tiled roof band over an exposed wooden lip.
+ x0 = slot(28)
+ c.rect(x0, 0, 32, 13, roof_dark)
+ for x in range(0, 32, 8):
+ c.rect(x0 + x, 0, 1, 13, roof)
+ for x in range(4, 32, 8):
+ c.rect(x0 + x, 0, 1, 13, roof)
+ c.rect(x0, 2, 32, 1, roof_light)
+ c.rect(x0, 12, 32, 1, roof)
+ c.rect(x0, 13, 32, 3, wood)
+ c.rect(x0, 13, 32, 1, wood_light)
+ c.rect(x0, 15, 32, 1, wood_dark)
+ for rx in (3, 10, 17, 24):
+ c.rect(x0 + rx, 13, 2, 5, wood_dark)
+ c.rect(x0, 18, 32, 4, (23, 31, 34, 80))
+
+ # slot(29) 木格栅窗 (lattice window): wooden posts + fine grid muntins.
+ x0 = slot(29)
+ 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 + 6, 7, 20, 18, (94, 66, 40, 255))
+ c.rect(x0 + 8, 9, 16, 14, (61, 45, 49, 255))
+ for lx in (12, 16, 20):
+ c.rect(x0 + lx, 9, 1, 14, wall_seam)
+ for ly in (14, 18):
+ c.rect(x0 + 8, ly, 16, 1, wall_seam)
+ c.rect(x0 + 6, 7, 20, 1, wood_light)
+ c.rect(x0 + 3, 28, 26, 4, (128, 128, 126, 255))
+ c.rect(x0 + 3, 28, 26, 1, (150, 150, 146, 255))
+
+ # slot(30) 院门 (courtyard gate): tiled lintel cap, double doors, brass rings.
+ x0 = slot(30)
+ 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 + 3, 4, 26, 4, roof_dark)
+ c.rect(x0 + 3, 4, 26, 1, roof_light)
+ c.rect(x0 + 1, 8, 30, 2, roof)
+ c.rect(x0 + 1, 9, 30, 1, roof_dark)
+ c.rect(x0 + 5, 10, 22, 18, (94, 66, 40, 255))
+ c.rect(x0 + 7, 12, 18, 16, wood)
+ c.rect(x0 + 7, 12, 18, 1, wood_light)
+ c.rect(x0 + 15, 12, 2, 16, wood_dark)
+ c.rect(x0 + 9, 15, 5, 4, wood_dark)
+ c.rect(x0 + 18, 15, 5, 4, wood_dark)
+ c.rect(x0 + 13, 20, 2, 2, (196, 158, 74, 255))
+ c.rect(x0 + 17, 20, 2, 2, (196, 158, 74, 255))
+ c.rect(x0 + 6, 28, 20, 3, (83, 52, 39, 255))
+
return c
@@ -884,7 +943,7 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 896, 32),
+ ("jiangnan-world.png", build_world_atlas(), 992, 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 e58139c..a38228f 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -1104,6 +1104,23 @@ type PrototypeTests () =
// 953775FA 硬对齐由 --performance-baseline 于 finalWorld 支线证据。
[<TestMethod>]
+ member _.HouseFacadeVariantsAlternateAlongTheVillage () =
+ // P26:512 大图上两种民居立面都要出现,且按位置确定性交替(纯函数)。
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let variants = map.Buildings |> List.map VillageArt.houseFacadeVariant
+ Assert.IsTrue(Set.ofList variants |> Set.contains 0, "variant A must appear")
+ Assert.IsTrue(Set.ofList variants |> Set.contains 1, "variant B must appear")
+ let repeated = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.AreEqual<(int list)>(variants, repeated.Buildings |> List.map VillageArt.houseFacadeVariant)
+ // Neighbouring buildings along the same river bank must flip variant, i.e. not one solid run.
+ let flipped =
+ variants
+ |> List.pairwise
+ |> List.filter (fun (a, b) -> a <> b)
+ |> List.length
+ Assert.IsTrue(flipped > 0, "facades must alternate, not stay one variant")
+
+ [<TestMethod>]
member _.JiangnanArtAssetsArePackagedAsRealPngFiles () =
let requiredAssets =
[ "Assets/jiangnan-world.png"
@@ -1116,9 +1133,10 @@ type PrototypeTests () =
Assert.AreEqual<string list>([], 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<int>(28, VillageArt.worldTileCount)
+ // The baked world atlas must match the 31-slot layout the renderer indexes into
+ // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27; P26 added the variant-B house
+ // parts 屋檐挑出/木格栅窗/院门 as slots 28..30).
+ Assert.AreEqual<int>(31, VillageArt.worldTileCount)
let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png")
let header = File.ReadAllBytes worldPath
let be32 offset =
@@ -1128,7 +1146,7 @@ type PrototypeTests () =
||| 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((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 31 x 32px slots")
Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px")
[<TestMethod>]
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index 6e97728..ea2866d 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 12c17ba..f303620 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -154,6 +154,9 @@ module VillageArt =
| StoneLanternSprite
| WaterVatSprite
| StallSprite
+ | RoofEaveOverhangSprite
+ | WallLatticeSprite
+ | CourtyardDoorSprite
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -496,7 +499,7 @@ module VillageArt =
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldTileCount = 28
+ let worldTileCount = 31
let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
let characterAtlasWidth = 32 * 5 * 4 * 6
@@ -570,6 +573,9 @@ module VillageArt =
| StoneLanternSprite -> 25
| WaterVatSprite -> 26
| StallSprite -> 27
+ | RoofEaveOverhangSprite -> 28
+ | WallLatticeSprite -> 29
+ | CourtyardDoorSprite -> 30
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -603,6 +609,13 @@ module VillageArt =
let private fillRect (spriteBatch: SpriteBatch) (pixel: Texture2D) (color: XnaColor) (x: int) (y: int) (w: int) (h: int) =
spriteBatch.Draw(pixel, XnaRectangle(x, y, max 1 w, max 1 h), color)
+ /// P26 民居立面变体:按位置确定性交替两种立面(沿河沿路分布)。
+ /// 0 = 黛瓦脊 + 木墙/窗 + 门(P24 原样);1 = 挑檐挑出 + 木格栅窗 + 院门。
+ /// 纯函数、不改变任何瓦片/可达性语义;64x48 与 256x192 的 tiles checksum 不受影响。
+ let houseFacadeVariant (building: MapGen.Building) : int =
+ let remainder = (building.Left + building.Top) % 2
+ if remainder < 0 then remainder + 2 else remainder
+
/// 生成器世界里的桥面与沿河民居(白墙黑瓦色块 + 门);坐标由调用方按相机换算。
let private drawMapStructures
(spriteBatch: SpriteBatch)
@@ -618,18 +631,31 @@ module VillageArt =
for building in map.Buildings do
let left = building.Left
let right = building.Left + building.Width - 1
- // P24:改用 256x192 同款民宅部件——黛瓦(脊/檐)+ 木墙/窗 + 门。
- for row in building.Top .. building.DoorY - 1 do
- let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite
- let source = tileSourceRectangle roofSprite
+ let drawCell sprite x y =
+ spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle sprite, XnaColor.White)
+ match houseFacadeVariant building with
+ | 0 ->
+ // 变体 A(P24):黛瓦(脊/檐)+ 木墙/窗 + 门。
+ for row in building.Top .. building.DoorY - 1 do
+ let roofSprite = if row = building.Top then RoofRidgeSprite else RoofEaveSprite
+ for x in left .. right do
+ drawCell roofSprite x row
+ for x in left .. right do
+ let wallSprite =
+ if x = building.DoorX then DoorSprite
+ elif (x - left) % 2 = 0 then WallWindowSprite
+ else WallWoodSprite
+ drawCell wallSprite x building.DoorY
+ | _ ->
+ // 变体 B(P26):挑檐向两侧各出一格 + 木格栅窗 + 院门。
+ for x in (left - 1) .. (right + 1) do
+ drawCell RoofEaveOverhangSprite x building.Top
for x in left .. right do
- spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY row, cell, cell), source, XnaColor.White)
- for x in left .. right do
- let wallSprite =
- if x = building.DoorX then DoorSprite
- elif (x - left) % 2 = 0 then WallWindowSprite
- else WallWoodSprite
- spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY building.DoorY, cell, cell), tileSourceRectangle wallSprite, XnaColor.White)
+ let wallSprite =
+ if x = building.DoorX then CourtyardDoorSprite
+ elif (x - left) % 2 = 0 then WallLatticeSprite
+ else WallWoodSprite
+ drawCell wallSprite x building.DoorY
/// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。
let bridgeLanternTiles (map: MapGen.Result) : (int * int) list =