summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/make-jiangnan-art.py18
-rw-r--r--src/LivingVillage.Desktop.Tests/MapGenTests.fs47
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs8
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin4147 -> 4468 bytes
-rw-r--r--src/LivingVillage.Desktop/MapGen.fs91
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs16
6 files changed, 167 insertions, 13 deletions
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index 54805ab..f537942 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 992x32 (31 ground/prop tiles, 32px each)
+ jiangnan-world.png 1024x32 (32 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(992, 32)
+ c = Canvas(1024, 32)
tile = 32
def slot(index):
@@ -606,6 +606,18 @@ def build_world_atlas():
c.rect(x0 + 17, 20, 2, 2, (196, 158, 74, 255))
c.rect(x0 + 6, 28, 20, 3, (83, 52, 39, 255))
+ # slot(31) 护岸石垒 (revetment): edge-laid cobbles shoring up the riverbank.
+ x0 = slot(31)
+ c.rect(x0 + 1, 10, 30, 20, (96, 96, 92, 255))
+ revetment = [(2, 11, 8, 5), (11, 10, 9, 6), (21, 11, 8, 5),
+ (4, 17, 9, 6), (14, 17, 9, 6), (24, 18, 7, 5),
+ (2, 24, 8, 5), (11, 24, 9, 5), (21, 24, 8, 5)]
+ for sx, sy, sw, sh in revetment:
+ c.rect(x0 + sx, sy, sw, sh, stone)
+ c.rect(x0 + sx, sy, sw, 1, stone_light)
+ c.rect(x0 + sx, sy + sh - 1, sw, 1, stone_dark)
+ c.rect(x0 + 1, 29, 30, 2, (96, 96, 92, 255))
+
return c
@@ -943,7 +955,7 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 992, 32),
+ ("jiangnan-world.png", build_world_atlas(), 1024, 32),
("jiangnan-characters.png", build_character_atlas(), 3840, 48),
("jiangnan-interior.png", build_interior_atlas(), 392, 96),
]
diff --git a/src/LivingVillage.Desktop.Tests/MapGenTests.fs b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
index f426a8a..6486dd7 100644
--- a/src/LivingVillage.Desktop.Tests/MapGenTests.fs
+++ b/src/LivingVillage.Desktop.Tests/MapGenTests.fs
@@ -298,6 +298,53 @@ type MapGenTests () =
Assert.AreEqual<int64>(6042052L, checksum (MapGen.generateWithSize 64 48 (uint64 4242)))
Assert.AreEqual<int64>(1284656962L, checksum (MapGen.generateWithSize 256 192 (uint64 4242)))
+ // ---- P26:河岸装饰(护岸石垒/芦苇/垂柳)仅大图、落在岸格且不挡通行 ----
+
+ [<TestMethod>]
+ member _.RiverDecorationsOnlyEnabledOnLargeMaps () =
+ Assert.IsFalse((MapGen.paramsForSize 64 48 (uint64 1)).RiverDecorations)
+ Assert.IsFalse((MapGen.paramsForSize 256 192 (uint64 1)).RiverDecorations)
+ Assert.IsTrue((MapGen.paramsForSize 512 384 (uint64 1)).RiverDecorations)
+ Assert.AreEqual<int>(0, (MapGen.generateWithSize 64 48 (uint64 4242)).Decorations.Length)
+ Assert.AreEqual<int>(0, (MapGen.generateWithSize 256 192 (uint64 4242)).Decorations.Length)
+
+ [<TestMethod>]
+ member _.RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges () =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(map.Decorations.Length > 0, "512x384 must place riverbank decorations")
+ let pathSet = System.Collections.Generic.HashSet<int>(map.Paths |> List.map (fun (x, y) -> y * map.Width + x))
+ let bridgeSet = System.Collections.Generic.HashSet<int>(map.Bridges |> List.map (fun (x, y) -> y * map.Width + x))
+ let water x y = map.Tiles.[y * map.Width + x] = int MapGen.GroundTile.Water
+ let inCore x y = x >= map.Core.MinX && x <= map.Core.MaxX && y >= map.Core.MinY && y <= map.Core.MaxY
+ for decoration in map.Decorations do
+ let footprint =
+ match decoration.Kind with
+ | MapGen.Willow -> [ decoration.Y; decoration.Y + 1 ]
+ | _ -> [ decoration.Y ]
+ for y in footprint do
+ let i = y * map.Width + decoration.X
+ Assert.AreNotEqual<int>(int MapGen.GroundTile.Water, map.Tiles.[i], "decoration must sit on land")
+ Assert.IsFalse(pathSet.Contains i, "decoration must not block a path tile")
+ Assert.IsFalse(bridgeSet.Contains i, "decoration must not block a bridge tile")
+ Assert.IsFalse(inCore decoration.X y, "decoration must stay outside the core")
+ // Bank adjacency: at least one footprint tile must touch the river orthogonally.
+ let touchesWater =
+ footprint
+ |> List.exists (fun y ->
+ [ (decoration.X + 1, y); (decoration.X - 1, y); (decoration.X, y + 1); (decoration.X, y - 1) ]
+ |> List.exists (fun (nx, ny) ->
+ nx >= 0 && nx < map.Width && ny >= 0 && ny < map.Height && water nx ny))
+ Assert.IsTrue(touchesWater, "decoration must line the riverbank, not float inland")
+
+ [<TestMethod>]
+ member _.LargeMapRiverDecorationsAreDeterministic () =
+ let a = MapGen.generateWithSize 512 384 (uint64 4242)
+ let b = MapGen.generateWithSize 512 384 (uint64 4242)
+ Assert.IsTrue(a.Decorations = b.Decorations)
+ Assert.AreEqual<string>(MapGen.serialize a, MapGen.serialize b)
+ let c = MapGen.generateWithSize 512 384 (uint64 7)
+ Assert.AreNotEqual<string>(MapGen.serialize a, MapGen.serialize c)
+
[<TestMethod>]
member _.VisibleRangeClampsAtLegacyMapEdges () =
let x0, y0, x1, y1 = MapGen.visibleTileRange 64 48 0 0 2048 1536
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index a38228f..dd4e7b3 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -1133,10 +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 31-slot layout the renderer indexes into
+ // The baked world atlas must match the 32-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)
+ // parts 屋檐挑出/木格栅窗/院门 as slots 28..30 and 护岸石垒 as slot 31).
+ Assert.AreEqual<int>(32, VillageArt.worldTileCount)
let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png")
let header = File.ReadAllBytes worldPath
let be32 offset =
@@ -1146,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 31 x 32px slots")
+ Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 32 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 ea2866d..11145a8 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/MapGen.fs b/src/LivingVillage.Desktop/MapGen.fs
index 63b14bc..ce9fb3a 100644
--- a/src/LivingVillage.Desktop/MapGen.fs
+++ b/src/LivingVillage.Desktop/MapGen.fs
@@ -34,7 +34,9 @@ module MapGen =
/// P24:大图额外沿河民居数量(沿现有路网泊松散布)。默认 0,64x48/256x192 不受影响。
ExtraRiversideHouses: int
/// P25:是否放置孤立装饰石块。默认 true;大图关闭,避免草地上出现游离石板 tile。
- DecorativeStones: bool }
+ DecorativeStones: bool
+ /// P26:是否沿海河岸放置河岸装饰(护岸石垒/芦苇/垂柳)。默认 false;仅大图开启。
+ RiverDecorations: bool }
/// P20 第二步:一条贯穿全图的东西向河道(每列恰好一段,宽度下限 RiverWidth)。
type River =
@@ -50,6 +52,18 @@ module MapGen =
DoorX: int
DoorY: int }
+ /// P26 河岸装饰种类:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格)。
+ type RiverDecorationKind =
+ | RevetmentStone
+ | Reeds
+ | Willow
+
+ /// P26 河岸装饰:纯视觉,不写入瓦片、不影响通行;仅大图沿海河岸生成。
+ type RiverDecoration =
+ { Kind: RiverDecorationKind
+ X: int
+ Y: int }
+
type Result =
{ Width: int
Height: int
@@ -61,6 +75,7 @@ module MapGen =
Bridges: (int * int) list
Paths: (int * int) list
Buildings: Building list
+ Decorations: RiverDecoration list
ReachableTiles: int
ReachabilityOk: bool
BridgeCrossingsOk: bool }
@@ -76,7 +91,8 @@ module MapGen =
SpawnColumns = 5
SpawnRows = 6
ExtraRiversideHouses = 0
- DecorativeStones = true }
+ DecorativeStones = true
+ RiverDecorations = false }
/// P23 尺寸自适应参数:河道数量随高度增长(约每 96 行一条),其余沿用默认。
/// 64x48 / 256x192 仍得到 2 条河道,与既有输出逐字节一致;512x384 得到 4 条。
@@ -89,7 +105,8 @@ module MapGen =
Height = height
RiverCount = max base_.RiverCount (max 1 (height / 96))
ExtraRiversideHouses = (if large then 10 else 0)
- DecorativeStones = not large }
+ DecorativeStones = not large
+ RiverDecorations = large }
// ---- splitmix64 与 value noise(无外部依赖、无时钟) ----
@@ -330,6 +347,63 @@ module MapGen =
addPath building.DoorX building.DoorY
extraPlaced <- extraPlaced + 1
+ // 5c) P26:河岸装饰(护岸石垒/芦苇/垂柳),仅大图启用。纯视觉层,不写入任何瓦片,
+ // 故 64x48/256x192 的 tiles checksum 与默认世界完全不受影响;装饰只落在紧邻水面的
+ // 岸格,且避开路网/桥面/民居/核心区,保证不遮挡通行。
+ let mutable decorations: RiverDecoration list = []
+ if p.RiverDecorations then
+ let mutable decorRng = p.Seed ^^^ 0xD3C0DEUL
+ let nextDecor () =
+ let nextState, raw = splitmix decorRng
+ decorRng <- nextState
+ raw
+ let bridgeLookup = System.Collections.Generic.HashSet<int>(bridges |> List.map (fun (x, y) -> idx x y))
+ let inBuildingFootprint x y =
+ buildings
+ |> List.exists (fun b ->
+ x >= b.Left - 1 && x <= b.Left + b.Width && y >= b.Top && y <= b.DoorY + 1)
+ let blocked x y =
+ not (inMap x y)
+ || tiles.[idx x y] = int GroundTile.Water
+ || pathSet.Contains(idx x y)
+ || bridgeLookup.Contains(idx x y)
+ || inCore x y
+ || inBuildingFootprint x y
+ let occupies (kind: RiverDecorationKind) (y: int) =
+ if kind = Willow then [ y; y + 1 ] else [ y ]
+ let tooClose (kind: RiverDecorationKind) x y =
+ decorations
+ |> List.exists (fun d ->
+ let dyList = occupies d.Kind d.Y
+ occupies kind y
+ |> List.exists (fun cy ->
+ dyList
+ |> List.exists (fun oy ->
+ let dx = d.X - x
+ let dy = oy - cy
+ dx * dx + dy * dy < 9)))
+ let tryPlace (kind: RiverDecorationKind) x y =
+ let tilesFree =
+ if kind = Willow then not (blocked x y) && not (blocked x (y + 1))
+ else not (blocked x y)
+ if tilesFree && not (tooClose kind x y) then
+ decorations <- { Kind = kind; X = x; Y = y } :: decorations
+ let kindOf (roll: uint64) =
+ match int (roll % 3UL) with
+ | 1 -> Reeds
+ | 2 -> Willow
+ | _ -> RevetmentStone
+ for river in riverBands do
+ for x in 2 .. w - 3 do
+ let lowerRoll = nextDecor ()
+ if lowerRoll % 3UL <> 0UL then
+ tryPlace (kindOf lowerRoll) x (river.CenterY + river.Width)
+ if nextDecor () % 4UL = 0UL then
+ let upperRoll = nextDecor ()
+ let kind = kindOf upperRoll
+ let anchorY = if kind = Willow then river.CenterY - 2 else river.CenterY - 1
+ tryPlace kind x anchorY
+
// 6) 30 个 spawn:核心区内的规则网格。
let sc = max 2 p.SpawnColumns
let sr = max 2 p.SpawnRows
@@ -385,6 +459,7 @@ module MapGen =
Bridges = bridges
Paths = paths
Buildings = buildings
+ Decorations = decorations
ReachableTiles = reachableTiles
ReachabilityOk = spawnsOk && doorsOk && bridgesOk
BridgeCrossingsOk = crossingsOk }
@@ -414,6 +489,16 @@ module MapGen =
.Append(building.Width).Append(':').Append(building.Height).Append(':')
.Append(building.DoorX).Append(':').Append(building.DoorY).Append(';')
|> ignore
+ builder.Append('|') |> ignore
+ let decorCode = function
+ | RevetmentStone -> 0
+ | Reeds -> 1
+ | Willow -> 2
+ for decoration in map.Decorations do
+ builder
+ .Append(decorCode decoration.Kind).Append(':')
+ .Append(decoration.X).Append(':').Append(decoration.Y).Append(';')
+ |> ignore
builder.ToString()
/// 可行走判定:非水,或位于桥面上(桥面仍标记为水但可通行)。
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index f303620..d2fcc1a 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -157,6 +157,7 @@ module VillageArt =
| RoofEaveOverhangSprite
| WallLatticeSprite
| CourtyardDoorSprite
+ | RevetmentStoneSprite
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -499,7 +500,7 @@ module VillageArt =
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldTileCount = 31
+ let worldTileCount = 32
let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
let characterAtlasWidth = 32 * 5 * 4 * 6
@@ -576,6 +577,7 @@ module VillageArt =
| RoofEaveOverhangSprite -> 28
| WallLatticeSprite -> 29
| CourtyardDoorSprite -> 30
+ | RevetmentStoneSprite -> 31
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -626,13 +628,13 @@ module VillageArt =
(cell: int)
(map: MapGen.Result) =
let bridgeSource = tileSourceRectangle BridgeSprite
+ let drawCell sprite x y =
+ spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), tileSourceRectangle sprite, XnaColor.White)
for (x, y) in map.Bridges do
spriteBatch.Draw(textures.TileAtlas, XnaRectangle(tileX x, tileY y, cell, cell), bridgeSource, XnaColor.White)
for building in map.Buildings do
let left = building.Left
let right = building.Left + building.Width - 1
- 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):黛瓦(脊/檐)+ 木墙/窗 + 门。
@@ -656,6 +658,14 @@ module VillageArt =
elif (x - left) % 2 = 0 then WallLatticeSprite
else WallWoodSprite
drawCell wallSprite x building.DoorY
+ // P26 河岸装饰:护岸石垒 / 芦苇 / 垂柳(垂柳占上下两格),只在大图生成。
+ for decoration in map.Decorations do
+ match decoration.Kind with
+ | MapGen.RiverDecorationKind.RevetmentStone -> drawCell RevetmentStoneSprite decoration.X decoration.Y
+ | MapGen.RiverDecorationKind.Reeds -> drawCell ReedSprite decoration.X decoration.Y
+ | MapGen.RiverDecorationKind.Willow ->
+ drawCell WillowTopSprite decoration.X decoration.Y
+ drawCell WillowBaseSprite decoration.X (decoration.Y + 1)
/// 桥头灯笼瓦片:每座桥每列的最上/最下一格(桥两端),用于挂灯笼与夜间暖光。
let bridgeLanternTiles (map: MapGen.Result) : (int * int) list =