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 图集(脚本可复现,逐字节一致) --- .../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 ++++++++++++++++----- 4 files changed, 68 insertions(+), 21 deletions(-) (limited to 'src') 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