summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 13:15:39 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 13:15:39 +0800
commit0b9753543bd726818c61d76384d56a30b862e1ac (patch)
tree06b21006dc540b5d9995caf394bb4c8084b9294e
parent76a6850bb88546fc21f5f78a53fbda04e77c8b52 (diff)
downloadliving-village-0b9753543bd726818c61d76384d56a30b862e1ac.tar.gz
feat(desktop): 村民与主角四帧行走动画
人物图集 5 变体 x 4 向 x 4 帧(2560x48,全部经 make-jiangnan-art.py 原创,四种腿步:站立/左跨/站立微跳/右跨镜像);animationFrame 按 tick/4 mod 4 确定性取帧(16 tick 一循环,禁实时时钟);快照式回归断言四帧循环与逐帧图集列映射,主角同规则。
-rw-r--r--scripts/make-jiangnan-art.py16
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs29
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-characters.pngbin2539 -> 3047 bytes
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs15
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)
[<TestMethod>]
- 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<AnimationFrame>(FrameOne, (VillageArt.animationFrame 0L))
Assert.AreEqual<AnimationFrame>(FrameTwo, (VillageArt.animationFrame 4L))
- Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 8L))
+ Assert.AreEqual<AnimationFrame>(FrameThree, (VillageArt.animationFrame 8L))
+ Assert.AreEqual<AnimationFrame>(FrameFour, (VillageArt.animationFrame 12L))
+ Assert.AreEqual<AnimationFrame>(FrameOne, (VillageArt.animationFrame 16L))
[<TestMethod>]
member _.VillagersUseFourDistinctVariantsDistinctFromTheAvatar () =
@@ -359,7 +361,28 @@ type PrototypeTests () =
rect.X)
|> Set.ofList
Assert.AreEqual<int>(4, variantXes.Count)
- Assert.AreEqual<int>(1280, VillageArt.characterAtlasWidth)
+ Assert.AreEqual<int>(2560, VillageArt.characterAtlasWidth)
+
+ [<TestMethod>]
+ 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<AnimationFrame list>([ FrameOne; FrameTwo; FrameThree; FrameFour; FrameOne; FrameTwo ], frames)
+
+ let cycleFrames =
+ [ 0L; 4L; 8L; 12L ]
+ |> List.map VillageArt.animationFrame
+ |> List.distinct
+ Assert.AreEqual<AnimationFrame list>([ 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<int list>([ 0; 1; 2; 3 ], perFrameX)
[<TestMethod>]
member _.ChineseCopyProvidesFormalSceneTimeMenuAndStatusLabels () =
diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-characters.png b/src/LivingVillage.Desktop/Assets/jiangnan-characters.png
index cbbe570..f6c8711 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/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