From 63e9741816ce3717cfda4cc3354ff3a0860a3ddc Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 20:22:25 +0800 Subject: feat(art): 角色六帧动画与水面/炊烟三相循环 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 角色图集每个 (变体,方向) 由 2 帧扩到 6 帧(4 走路 + 2 待机呼吸), cellIndex=(variant*4+direction)*6+frame,图集 3840x48 - 世界图集扩到 25 格 800x32:水面第 3 相 WaterSpriteC(slot 21)、 炊烟三帧 SmokeSpriteA/B/C(slot 22-24) - 帧选择为纯函数:walk=(tick/4)%4、idle=(tick/24)%2、 water=(tick/32)%3、smoke=(tick/40)%3 - 重生成 jiangnan/cc0 图集(脚本可复现,逐字节一致) --- scripts/make-cc0-art.py | 24 ++-- scripts/make-jiangnan-art.py | 146 ++++++++++++++++++++- .../Assets/cc0-characters.png | Bin 4197 -> 5575 bytes .../Assets/jiangnan-characters.png | Bin 3047 -> 5585 bytes .../Assets/jiangnan-world.png | Bin 3045 -> 3394 bytes src/LivingVillage.Desktop/VillageArt.fs | 89 ++++++++++--- 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 Binary files a/src/LivingVillage.Desktop/Assets/cc0-characters.png and b/src/LivingVillage.Desktop/Assets/cc0-characters.png differ diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png index f6c8711..dd96e60 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png and b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png differ diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png index 23e02cd..9138560 100644 Binary files a/src/LivingVillage.Desktop/Assets/jiangnan-world.png and b/src/LivingVillage.Desktop/Assets/jiangnan-world.png 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 -- cgit v1.2.3