From 0b9753543bd726818c61d76384d56a30b862e1ac Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 13:15:39 +0800 Subject: feat(desktop): 村民与主角四帧行走动画 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 人物图集 5 变体 x 4 向 x 4 帧(2560x48,全部经 make-jiangnan-art.py 原创,四种腿步:站立/左跨/站立微跳/右跨镜像);animationFrame 按 tick/4 mod 4 确定性取帧(16 tick 一循环,禁实时时钟);快照式回归断言四帧循环与逐帧图集列映射,主角同规则。 --- scripts/make-jiangnan-art.py | 16 ++++++++---- src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 29 ++++++++++++++++++--- .../Assets/jiangnan-characters.png | Bin 2539 -> 3047 bytes src/LivingVillage.Desktop/VillageArt.fs | 15 ++++++++--- 4 files changed, 49 insertions(+), 11 deletions(-) diff --git a/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py index cdae4e9..c484d91 100644 --- a/scripts/make-jiangnan-art.py +++ b/scripts/make-jiangnan-art.py @@ -3,7 +3,7 @@ Generates three RGBA PNGs into src/LivingVillage.Desktop/Assets: jiangnan-world.png 672x32 (21 ground/prop tiles, 32px each) - jiangnan-characters.png 1280x48 (5 variants x 4 directions x 2 frames, 32x48 cells) + jiangnan-characters.png 2560x48 (5 variants x 4 directions x 4 frames, 32x48 cells) jiangnan-interior.png 392x96 (variable-size bottom-aligned interior cells) Cell geometry is mirrored in VillageArt.fs; keep both in sync. @@ -445,6 +445,7 @@ DIRECTIONS = ["north", "south", "west", "east"] def draw_character_cell(c, ox, variant, direction, frame): + # frame: 0 neutral stand, 1 left stride, 2 neutral stand (higher torso bob), 3 right stride robe = variant["robe"] light = variant["light"] dark = variant["dark"] @@ -456,15 +457,20 @@ def draw_character_cell(c, ox, variant, direction, frame): c.rect(ox + 8, 44, 16, 1, (23, 31, 34, 110)) c.rect(ox + 10, 45, 12, 1, (23, 31, 34, 90)) - if frame == 0: + if frame in (0, 2): 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) + elif frame == 1: + c.rect(ox + 9, 36, 5, 7, dark) + c.rect(ox + 9, 42, 6, 3, SHOE) + c.rect(ox + 18, 36, 3, 5, dark) + c.rect(ox + 18, 39, 6, 3, SHOE) else: c.rect(ox + 18, 36, 4, 7, dark) c.rect(ox + 18, 42, 6, 3, SHOE) - c.rect(ox + 10, 36, 4, 5, dark) + c.rect(ox + 12, 36, 3, 5, dark) c.rect(ox + 10, 39, 6, 3, SHOE) c.rect(ox + 7, 22, 18, 14, robe) @@ -531,7 +537,7 @@ def draw_character_cell(c, ox, variant, direction, frame): def build_character_atlas(): - c = Canvas(1280, 48) + c = Canvas(2560, 48) cell = 0 for variant in VARIANTS: for direction in DIRECTIONS: @@ -674,7 +680,7 @@ 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(), 1280, 48), + ("jiangnan-characters.png", build_character_atlas(), 2560, 48), ("jiangnan-interior.png", build_interior_atlas(), 392, 96), ] for name, canvas, w, h in targets: diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index d77abd6..450e94b 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -324,7 +324,7 @@ type PrototypeTests () = Assert.IsTrue(first.PanelY + first.PanelHeight <= 720) [] - member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndTwoFrames () = + member _.NpcVariantsAreStableAndAnimationCoversFourDirectionsAndFourFrames () = let sameNpc = npcVisualVariant (NpcId 7) let repeatedNpc = npcVisualVariant (NpcId 7) let otherNpc = npcVisualVariant (NpcId 8) @@ -341,7 +341,9 @@ type PrototypeTests () = Assert.IsTrue(sprites |> List.forall (fun sprite -> sprite.Frame = FrameTwo)) Assert.AreEqual(FrameOne, (VillageArt.animationFrame 0L)) Assert.AreEqual(FrameTwo, (VillageArt.animationFrame 4L)) - Assert.AreEqual(FrameOne, (VillageArt.animationFrame 8L)) + Assert.AreEqual(FrameThree, (VillageArt.animationFrame 8L)) + Assert.AreEqual(FrameFour, (VillageArt.animationFrame 12L)) + Assert.AreEqual(FrameOne, (VillageArt.animationFrame 16L)) [] member _.VillagersUseFourDistinctVariantsDistinctFromTheAvatar () = @@ -359,7 +361,28 @@ type PrototypeTests () = rect.X) |> Set.ofList Assert.AreEqual(4, variantXes.Count) - Assert.AreEqual(1280, VillageArt.characterAtlasWidth) + Assert.AreEqual(2560, VillageArt.characterAtlasWidth) + + [] + member _.WalkAnimationCoversFourFramesDeterministically () = + // 16-tick cycle, new frame every 4 sim ticks, no wall-clock dependency. + let frames = [ 0L; 4L; 8L; 12L; 16L; 20L ] |> List.map VillageArt.animationFrame + Assert.AreEqual([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameOne; FrameTwo ], frames) + + let cycleFrames = + [ 0L; 4L; 8L; 12L ] + |> List.map VillageArt.animationFrame + |> List.distinct + Assert.AreEqual([ FrameOne; FrameTwo; FrameThree; FrameFour ], cycleFrames) + + // Every frame maps to a distinct atlas cell column across the five variants. + // Every frame maps to a distinct atlas cell column across the five variants. + let perFrameX = + [ FrameOne; FrameTwo; FrameThree; FrameFour ] + |> List.map (fun frame -> + let rect = characterSourceRectangle { Variant = IndigoRobe; Direction = NorthFacing; Frame = frame } + rect.X / 32) + Assert.AreEqual([ 0; 1; 2; 3 ], perFrameX) [] member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () = diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png index cbbe570..f6c8711 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/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index db25757..6f5af07 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -84,6 +84,8 @@ module VillageArt = type AnimationFrame = | FrameOne | FrameTwo + | FrameThree + | FrameFour type NpcSpriteSpec = { Variant: NpcVisualVariant @@ -250,8 +252,13 @@ module VillageArt = let remainder = value % divisor if remainder < 0L then remainder + divisor else remainder + /// Four-pose walk cycle over a 16-tick window (one pose per 4 sim ticks). let animationFrame (tick: int64) : AnimationFrame = - if positiveModulo64 8L tick < 4L then FrameOne else FrameTwo + match positiveModulo64 4L (tick / 4L) with + | 0L -> FrameOne + | 1L -> FrameTwo + | 2L -> FrameThree + | _ -> FrameFour /// Water surface ripple phase: flips every 32 simulation ticks, fully deterministic. let waterFrameTick (tick: int64) : int = @@ -303,7 +310,9 @@ module VillageArt = match spec.Frame with | FrameOne -> 0 | FrameTwo -> 1 - let cellIndex = (variantIndex * 4 + directionIndex) * 2 + frameIndex + | FrameThree -> 2 + | FrameFour -> 3 + let cellIndex = (variantIndex * 4 + directionIndex) * 4 + 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) @@ -320,7 +329,7 @@ module VillageArt = let worldTileCount = 21 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels - let characterAtlasWidth = 32 * 5 * 4 * 2 + let characterAtlasWidth = 32 * 5 * 4 * 4 let characterAtlasHeight = 48 let interiorAtlasWidth = 392 let interiorAtlasHeight = 96 -- cgit v1.2.3