summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/make-cc0-art.py24
-rw-r--r--scripts/make-jiangnan-art.py146
-rw-r--r--src/LivingVillage.Desktop/Assets/cc0-characters.pngbin4197 -> 5575 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-characters.pngbin3047 -> 5585 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin3045 -> 3394 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs89
6 files changed, 223 insertions, 36 deletions
diff --git a/scripts/make-cc0-art.py b/scripts/make-cc0-art.py
index 897ec4f..f5c97ee 100644
--- a/scripts/make-cc0-art.py
+++ b/scripts/make-cc0-art.py
@@ -5,7 +5,7 @@ Reads the two provenanced CC0 packs in third_party/ (Kenney Tiny Farm tiles,
Puny Characters) and emits Deterministic CC0 Atlases for the prototype:
src/LivingVillage.Desktop/Assets/cc0-tiles.png 128x32 (4 ground tiles, 32px each)
- src/LivingVillage.Desktop/Assets/cc0-characters.png 2560x48 (5 variants x 4 directions x 4 frames, 32x48 cells)
+ src/LivingVillage.Desktop/Assets/cc0-characters.png 3840x48 (5 variants x 4 directions x 6 frames, 32x48 cells)
Mapping strategy (16 -> 32): integer 2x nearest-neighbour upscale of every source
sprite; ground tiles fill the 32x32 cell exactly; Puny sprites keep their
@@ -115,14 +115,18 @@ def build_cc0_tile_atlas():
def build_cc0_character_atlas():
- """5 variants x 4 directions x 4 frames of 32x48 cells from Puny characters.
+ """5 variants x 4 directions x 6 frames of 32x48 cells from Puny characters.
Layout: each 32x48 CELL = source 16x24 content band scaled 2x (32x48), anchored
bottom (feet on the ground line). Directions: the pack provides south-facing
art only; south/north use the same band (pack limitation recorded in the
commit), west/east are mirrored copies so walkers always read toward the player.
+
+ Frames 0-3 are the walk cycle (source poses 0-3). Frames 4-5 are the idle
+ breath cycle: the neutral pose (source pose 0) relaxed, then raised 1px for
+ the inhale. The pack has no dedicated idle art, so this reuses pose 0.
"""
- c = Canvas(2560, 48)
+ c = Canvas(3840, 48)
variant_files = [
"Soldier-Blue.png",
@@ -143,12 +147,16 @@ def build_cc0_character_atlas():
cell_w = 32
for vi, pose_cells in enumerate(variants):
for di in range(4):
- for frame in range(4):
- sprite = pose_cells[frame].resize((32, 48), Image.NEAREST)
+ # Walk frames reuse poses 0-3; idle frames 4-5 reuse the neutral pose 0,
+ # with frame 5 raised 1px so the pair reads as a breath.
+ frame_poses = [pose_cells[0], pose_cells[1], pose_cells[2], pose_cells[3], pose_cells[0], pose_cells[0]]
+ frame_offsets = [0, 0, 0, 0, 0, -1]
+ for frame in range(6):
+ sprite = frame_poses[frame].resize((32, 48), Image.NEAREST)
if di in (1, 3):
sprite = sprite.transpose(Image.FLIP_LEFT_RIGHT)
- ox = cell_w * ((vi * 4 + di) * 4 + frame)
- c.blit(sprite, ox, 0, scale=1)
+ ox = cell_w * ((vi * 4 + di) * 6 + frame)
+ c.blit(sprite, ox, frame_offsets[frame], scale=1)
return c
@@ -158,7 +166,7 @@ def main():
ASSETS.mkdir(parents=True, exist_ok=True)
targets = [
("cc0-tiles.png", build_cc0_tile_atlas(), 128, 32),
- ("cc0-characters.png", build_cc0_character_atlas(), 2560, 48),
+ ("cc0-characters.png", build_cc0_character_atlas(), 3840, 48),
]
for name, canvas, w, h in targets:
path = ASSETS / name
diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py
index c484d91..2e1dee0 100644
--- a/scripts/make-jiangnan-art.py
+++ b/scripts/make-jiangnan-art.py
@@ -2,8 +2,8 @@
"""Deterministic pixel-art atlases for LivingVillage.Desktop.
Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets:
- jiangnan-world.png 672x32 (21 ground/prop tiles, 32px each)
- jiangnan-characters.png 2560x48 (5 variants x 4 directions x 4 frames, 32x48 cells)
+ jiangnan-world.png 800x32 (25 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)
Cell geometry is mirrored in VillageArt.fs; keep both in sync.
@@ -99,7 +99,7 @@ def verify_png(path, expected_w, expected_h):
def build_world_atlas():
- c = Canvas(672, 32)
+ c = Canvas(800, 32)
tile = 32
def slot(index):
@@ -430,6 +430,50 @@ def build_world_atlas():
c.rect(x0 + 14, 25, 4, 3, lantern_rib)
c.outline(x0, 0, 32, 32)
+ # Third water ripple phase (slot 21): highlights and deeps shifted one row so the
+ # three frames read as a moving surface when cycled by waterFrameTick.
+ x0 = slot(21)
+ c.rect(x0, 0, 32, 32, water)
+ c.rect(x0 + 8, 5, 2, 24, water_deep)
+ c.rect(x0 + 23, 2, 2, 28, water_deep)
+ for y in (7, 18, 27):
+ for x in range(3, 29):
+ if (x * 3 + y) % 5 != 0:
+ c.set(x0 + x, y, water_light)
+ for y in (12, 23):
+ for x in range(5, 27):
+ if (x + y * 2) % 6 == 0:
+ c.set(x0 + x, y, water_deep)
+ for sx, sy in ((12, 4), (27, 11), (18, 22), (8, 17)):
+ c.set(x0 + sx, sy, (214, 231, 238, 255))
+ c.set(x0 + sx + 1, sy, (214, 231, 238, 120))
+
+ # Chimney smoke, three frames (slots 22-24): a puff rising and thinning above the roof.
+ smoke_puff = (214, 218, 222, 190)
+ smoke_mid = (190, 196, 202, 150)
+ smoke_faint = (170, 178, 186, 110)
+
+ def puff(cx, cy, rx, ry, color):
+ for py in range(cy - ry, cy + ry + 1):
+ for px in range(cx - rx, cx + rx + 1):
+ if ((px - cx) * (px - cx)) * 2 + ((py - cy) * (py - cy)) * 3 <= rx * ry * 2:
+ c.set(px, py, color)
+
+ x0 = slot(22)
+ puff(x0 + 15, 26, 5, 4, smoke_mid)
+ puff(x0 + 16, 21, 4, 3, smoke_puff)
+ puff(x0 + 15, 17, 2, 2, smoke_mid)
+
+ x0 = slot(23)
+ puff(x0 + 16, 20, 6, 5, smoke_mid)
+ puff(x0 + 15, 13, 5, 4, smoke_puff)
+ puff(x0 + 17, 7, 3, 2, smoke_faint)
+
+ x0 = slot(24)
+ puff(x0 + 17, 13, 6, 5, smoke_faint)
+ puff(x0 + 15, 6, 5, 4, smoke_mid)
+ puff(x0 + 18, 2, 3, 2, smoke_faint)
+
return c
@@ -536,14 +580,102 @@ def draw_character_cell(c, ox, variant, direction, frame):
c.outline(ox, 0, 32, 48)
+def draw_character_idle_cell(c, ox, variant, direction, phase):
+ # phase: 0 relaxed exhale, 1 inhale (upper body raised 1px). Feet stay planted so the
+ # two poses read as a quiet breath rather than a step.
+ robe = variant["robe"]
+ light = variant["light"]
+ dark = variant["dark"]
+ extra = variant.get("extra")
+ hat = (196, 158, 74, 255)
+ hat_light = (228, 197, 112, 255)
+ hat_dark = (168, 132, 58, 255)
+ lift = 1 if phase else 0
+
+ c.rect(ox + 8, 44, 16, 1, (23, 31, 34, 110))
+ c.rect(ox + 10, 45, 12, 1, (23, 31, 34, 90))
+
+ c.rect(ox + 10, 36, 4, 7, dark)
+ c.rect(ox + 9, 42, 6, 3, SHOE)
+ c.rect(ox + 18, 36, 4, 5, dark)
+ c.rect(ox + 18, 39, 6, 3, SHOE)
+
+ ty = 22 - lift
+ c.rect(ox + 7, ty, 18, 14, robe)
+ c.rect(ox + 23, ty, 2, 14, dark)
+ c.rect(ox + 7, ty + 9, 18, 3, light)
+ c.rect(ox + 13, ty, 6, 2, light)
+ c.rect(ox + 15, ty + 2, 2, 2, light)
+
+ c.rect(ox + 5, 24 - lift, 2, 9, robe)
+ c.rect(ox + 25, 24 - lift, 2, 9, robe)
+ if direction != "north":
+ c.rect(ox + 5, 32 - lift, 2, 2, light)
+ c.rect(ox + 25, 32 - lift, 2, 2, light)
+ c.rect(ox + 5, 34 - lift, 2, 3, SKIN)
+ c.rect(ox + 25, 34 - lift, 2, 3, SKIN)
+
+ hy = 12 - lift
+ c.rect(ox + 10, hy, 12, 10, SKIN)
+ c.rect(ox + 9, hy - 4, 14, 4, HAIR)
+ c.rect(ox + 9, hy, 2, 3, HAIR)
+ c.rect(ox + 21, hy, 2, 3, HAIR)
+
+ if direction == "north":
+ c.rect(ox + 10, hy, 12, 5, HAIR)
+ elif direction == "south":
+ c.rect(ox + 12, hy + 3, 2, 2, EYE)
+ c.rect(ox + 18, hy + 3, 2, 2, EYE)
+ c.rect(ox + 15, hy + 7, 2, 1, (196, 120, 104, 255))
+ elif direction == "west":
+ c.rect(ox + 18, hy, 4, 6, HAIR)
+ c.rect(ox + 11, hy + 3, 2, 2, EYE)
+ elif direction == "east":
+ c.rect(ox + 10, hy, 4, 6, HAIR)
+ c.rect(ox + 19, hy + 3, 2, 2, EYE)
+
+ if extra == "straw_hat":
+ c.rect(ox + 11, 3 - lift, 10, 7, hat)
+ c.rect(ox + 12, 4 - lift, 8, 1, hat_light)
+ c.rect(ox + 6, 9 - lift, 20, 2, hat)
+ c.rect(ox + 7, 11 - lift, 18, 1, hat_dark)
+ elif extra == "kerchief":
+ c.rect(ox + 8, 7 - lift, 16, 4, (40, 52, 96, 255))
+ c.rect(ox + 8, 7 - lift, 16, 1, (68, 84, 138, 255))
+ c.rect(ox + 24, 10 - lift, 3, 3, (40, 52, 96, 255))
+ c.rect(ox + 24, 12 - lift, 2, 4, (68, 84, 138, 255))
+ elif extra == "bun_vest":
+ c.rect(ox + 14, 4 - lift, 4, 4, HAIR)
+ c.rect(ox + 15, 3 - lift, 2, 1, HAIR)
+ c.rect(ox + 10, ty + 1, 12, 9, dark)
+ c.rect(ox + 10, ty + 1, 12, 1, light)
+ c.rect(ox + 15, ty + 2, 2, 8, light)
+ elif extra == "hairpin_scarf":
+ c.rect(ox + 21, 9 - lift, 5, 1, hat)
+ c.rect(ox + 24, 8 - lift, 2, 1, hat_light)
+ c.rect(ox + 9, ty - 1, 14, 2, (150, 62, 58, 255))
+ c.rect(ox + 9, ty - 1, 14, 1, (188, 92, 84, 255))
+ c.rect(ox + 22, ty, 3, 6, (150, 62, 58, 255))
+ elif extra == "round_cap":
+ c.rect(ox + 9, 5 - lift, 14, 5, (48, 52, 66, 255))
+ c.rect(ox + 10, 4 - lift, 12, 2, (48, 52, 66, 255))
+ c.rect(ox + 10, 9 - lift, 12, 1, (70, 74, 90, 255))
+ c.rect(ox + 15, 3 - lift, 2, 2, hat)
+
+ c.outline(ox, 0, 32, 48)
+
+
def build_character_atlas():
- c = Canvas(2560, 48)
+ c = Canvas(3840, 48)
cell = 0
for variant in VARIANTS:
for direction in DIRECTIONS:
- for frame in (0, 1):
+ for frame in range(4):
draw_character_cell(c, cell * 32, variant, direction, frame)
cell += 1
+ for phase in range(2):
+ draw_character_idle_cell(c, cell * 32, variant, direction, phase)
+ cell += 1
return c
@@ -679,8 +811,8 @@ def build_interior_atlas():
def main():
ASSET_DIR.mkdir(parents=True, exist_ok=True)
targets = [
- ("jiangnan-world.png", build_world_atlas(), 672, 32),
- ("jiangnan-characters.png", build_character_atlas(), 2560, 48),
+ ("jiangnan-world.png", build_world_atlas(), 800, 32),
+ ("jiangnan-characters.png", build_character_atlas(), 3840, 48),
("jiangnan-interior.png", build_interior_atlas(), 392, 96),
]
for name, canvas, w, h in targets:
diff --git a/src/LivingVillage.Desktop/Assets/cc0-characters.png b/src/LivingVillage.Desktop/Assets/cc0-characters.png
index 7e553a6..afd1ed2 100644
--- a/src/LivingVillage.Desktop/Assets/cc0-characters.png
+++ b/src/LivingVillage.Desktop/Assets/cc0-characters.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png
index f6c8711..dd96e60 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
index 23e02cd..9138560 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 f72c0f3..abda888 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -88,10 +88,20 @@ module VillageArt =
| FrameThree
| FrameFour
+ /// Two-pose standing breath cycle used whenever a character is not moving.
+ type IdleFrame =
+ | IdleOne
+ | IdleTwo
+
+ /// A character either walks (four-pose cycle) or breathes in place (two-pose cycle).
+ type CharacterFrame =
+ | WalkFrame of AnimationFrame
+ | IdleFrame of IdleFrame
+
type NpcSpriteSpec =
{ Variant: NpcVisualVariant
Direction: FacingDirection
- Frame: AnimationFrame }
+ Frame: CharacterFrame }
type ArtTextures =
{ TileAtlas: Texture2D
@@ -121,6 +131,10 @@ module VillageArt =
| ReedSprite
| BenchSprite
| LanternSprite
+ | WaterSpriteC
+ | SmokeSpriteA
+ | SmokeSpriteB
+ | SmokeSpriteC
type private XnaColor = Microsoft.Xna.Framework.Color
type private XnaRectangle = Microsoft.Xna.Framework.Rectangle
@@ -272,9 +286,23 @@ module VillageArt =
| 2L -> FrameThree
| _ -> FrameFour
- /// Water surface ripple phase: flips every 32 simulation ticks, fully deterministic.
+ /// Two-pose standing breath cycle over a slower 48-tick window, fully deterministic.
+ let idleFrame (tick: int64) : IdleFrame =
+ match positiveModulo64 2L (tick / 24L) with
+ | 0L -> IdleOne
+ | _ -> IdleTwo
+
+ /// Picks the walk cycle while moving and the breath cycle while standing still.
+ let characterFrame (isMoving: bool) (tick: int64) : CharacterFrame =
+ if isMoving then WalkFrame(animationFrame tick) else IdleFrame(idleFrame tick)
+
+ /// Water surface ripple phase: three frames, one per 32 simulation ticks, fully deterministic.
let waterFrameTick (tick: int64) : int =
- int (positiveModulo64 2L (tick / 32L))
+ int (positiveModulo64 3L (tick / 32L))
+
+ /// Chimney smoke puff phase: three frames, one per 40 simulation ticks, fully deterministic.
+ let smokeFrameTick (tick: int64) : int =
+ int (positiveModulo64 3L (tick / 40L))
let directionFromVector (vector: Vec2) : FacingDirection =
if abs vector.X < 0.001f && abs vector.Y < 0.001f then
@@ -292,17 +320,18 @@ module VillageArt =
Y = nextPosition.Y - previousPosition.Y }
if abs movement.X < 0.001f && abs movement.Y < 0.001f then current else directionFromVector movement
- let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (tick: int64) : NpcSpriteSpec =
+ let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec =
{ Variant = npcVisualVariant npcId
Direction = direction
- Frame = animationFrame tick }
+ Frame = characterFrame isMoving tick }
+ /// Villagers breathe while standing at their target and walk while approaching it.
let npcSpriteSpecAtTarget (npcId: NpcId) (position: Vec2) (target: Vec2) (tick: int64) : NpcSpriteSpec =
- let direction =
- directionFromVector
- { X = target.X - position.X
- Y = target.Y - position.Y }
- npcSpriteSpec npcId direction tick
+ let movement : Vec2 =
+ { X = target.X - position.X
+ Y = target.Y - position.Y }
+ let isMoving = abs movement.X >= 0.5f || abs movement.Y >= 0.5f
+ npcSpriteSpec npcId (directionFromVector movement) isMoving tick
let characterSourceRectangle (spec: NpcSpriteSpec) : Microsoft.Xna.Framework.Rectangle =
let variantIndex =
@@ -320,28 +349,30 @@ module VillageArt =
| EastFacing -> 3
let frameIndex =
match spec.Frame with
- | FrameOne -> 0
- | FrameTwo -> 1
- | FrameThree -> 2
- | FrameFour -> 3
- let cellIndex = (variantIndex * 4 + directionIndex) * 4 + frameIndex
+ | WalkFrame FrameOne -> 0
+ | WalkFrame FrameTwo -> 1
+ | WalkFrame FrameThree -> 2
+ | WalkFrame FrameFour -> 3
+ | IdleFrame IdleOne -> 4
+ | IdleFrame IdleTwo -> 5
+ let cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex
Microsoft.Xna.Framework.Rectangle(cellIndex * 32, 0, 32, 48)
let private rgba (r: int) (g: int) (b: int) (a: int) : XnaColor = XnaColor(r, g, b, a)
- let avatarSpriteSpec (direction: FacingDirection) (tick: int64) : NpcSpriteSpec =
+ let avatarSpriteSpec (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec =
{ Variant = StrawHat
Direction = direction
- Frame = animationFrame tick }
+ Frame = characterFrame isMoving tick }
let worldAtlasRelativePath = "Assets/jiangnan-world.png"
let characterAtlasRelativePath = "Assets/jiangnan-characters.png"
let interiorAtlasRelativePath = "Assets/jiangnan-interior.png"
- let worldTileCount = 21
+ let worldTileCount = 25
let worldAtlasWidth = Sim.tilePixels * worldTileCount
let worldAtlasHeight = Sim.tilePixels
- let characterAtlasWidth = 32 * 5 * 4 * 4
+ let characterAtlasWidth = 32 * 5 * 4 * 6
let characterAtlasHeight = 48
let interiorAtlasWidth = 392
let interiorAtlasHeight = 96
@@ -405,6 +436,10 @@ module VillageArt =
| ReedSprite -> 18
| BenchSprite -> 19
| LanternSprite -> 20
+ | WaterSpriteC -> 21
+ | SmokeSpriteA -> 22
+ | SmokeSpriteB -> 23
+ | SmokeSpriteC -> 24
XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels)
let private tilePosition x y : TilePosition =
@@ -447,12 +482,17 @@ module VillageArt =
let draw position sprite =
drawWorldTile spriteBatch textures camera viewportWidth viewportHeight position sprite tint
let rippleFrame = waterFrameTick tick
+ let waterSprite =
+ match rippleFrame with
+ | 0 -> WaterSprite
+ | 1 -> WaterSpriteB
+ | _ -> WaterSpriteC
// Procedural terrain for the 512x384 large world: water/pebble/peat codes map
// onto the jiangnan atlas sprites with deterministic ripple continuity.
let drawTerrainTile (position: TilePosition) (code: int) =
match code with
- | 1 -> draw position (if rippleFrame = 0 then WaterSprite else WaterSpriteB)
+ | 1 -> draw position waterSprite
| 2 -> draw position StonePathSprite
| 3 -> draw position StonePathSprite
| _ -> draw position GrassSprite
@@ -482,7 +522,7 @@ module VillageArt =
for localPosition, kind in plan.Grounds do
let position = worldTile plan localPosition
match kind with
- | Water -> draw position (if rippleFrame = 0 then WaterSprite else WaterSpriteB)
+ | Water -> draw position waterSprite
| StonePath ->
match textures.Cc0TileAtlas with
| Some cc0 -> drawCc0Tile spriteBatch cc0 tint cc0PathRect (position.X * Sim.tilePixels - int camera.X) (position.Y * Sim.tilePixels - int camera.Y)
@@ -497,6 +537,13 @@ module VillageArt =
draw (offsetTile position -1 0) WallWindowSprite
draw (offsetTile position 0 0) DoorSprite
draw (offsetTile position 1 0) WallWoodSprite
+ // Chimney smoke drifting up from the roof, cycling through three puffs.
+ let smokeSprite =
+ match smokeFrameTick tick with
+ | 0 -> SmokeSpriteA
+ | 1 -> SmokeSpriteB
+ | _ -> SmokeSpriteC
+ draw (offsetTile position -2 -3) smokeSprite
let drawBridge localPosition =
let position = worldTile plan localPosition