summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 00:42:44 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 00:42:44 +0800
commita5c3400412fbab11d4f4b83a5d0294292eee5f08 (patch)
tree425bef4a81589fb67b61a1cbd046d4512e1dba42 /src
parent55575af708bc1602f820f7f705bfc01898347082 (diff)
downloadliving-village-a5c3400412fbab11d4f4b83a5d0294292eee5f08.tar.gz
feat(desktop): P17 村民走路节奏差异化(农夫/货郎/书生 纯 tick 节奏)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs34
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs54
2 files changed, 77 insertions, 11 deletions
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index 25f4d02..b8f23a0 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -335,7 +335,7 @@ type PrototypeTests () =
SouthFacing
WestFacing
EastFacing ]
- let sprites = directions |> List.map (fun direction -> npcSpriteSpec (NpcId 7) direction true 4L)
+ let sprites = directions |> List.map (fun direction -> npcSpriteSpec (NpcId 0) direction true 4L)
Assert.AreEqual<NpcVisualVariant>(sameNpc, repeatedNpc)
Assert.AreNotEqual<NpcVisualVariant>(sameNpc, otherNpc)
@@ -534,6 +534,34 @@ type PrototypeTests () =
Assert.AreEqual<CharacterFrame>(VillageArt.characterFrame true 12L, VillageArt.characterFrame true 12L)
[<TestMethod>]
+ member _.NpcTradesAndWalkRhythmsAreDistinctAndDeterministic () =
+ // Three trades repeat by id and stay stable, with no randomness.
+ Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Farmer, VillageArt.npcTrade (NpcId 0))
+ Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Peddler, VillageArt.npcTrade (NpcId 1))
+ Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.Scholar, VillageArt.npcTrade (NpcId 2))
+ Assert.AreEqual<VillageArt.NpcTrade>(VillageArt.npcTrade (NpcId 9), VillageArt.npcTrade (NpcId 0))
+
+ // Distinct cadences: peddler shuffles fastest, scholar strolls slowest.
+ let farmer = VillageArt.rhythmFor VillageArt.Farmer
+ let peddler = VillageArt.rhythmFor VillageArt.Peddler
+ let scholar = VillageArt.rhythmFor VillageArt.Scholar
+ Assert.IsTrue(peddler.WalkWindow < farmer.WalkWindow && farmer.WalkWindow < scholar.WalkWindow)
+ Assert.IsTrue(peddler.IdleWindow < farmer.IdleWindow && farmer.IdleWindow < scholar.IdleWindow)
+
+ // Same tick, three trade-specific frames (at 12 ticks: Farmer IV, Peddler II, Scholar I).
+ let frameAt trade tick = VillageArt.animationFrameFor (VillageArt.rhythmFor trade) tick
+ let frames =
+ [ VillageArt.Farmer; VillageArt.Peddler; VillageArt.Scholar ]
+ |> List.map (fun trade -> frameAt trade 12L)
+ Assert.AreEqual<AnimationFrame list>([ FrameFour; FrameTwo; FrameOne ], frames)
+ Assert.AreEqual<VillageArt.WalkRhythm>(VillageArt.rhythmFor VillageArt.Scholar, VillageArt.rhythmFor VillageArt.Scholar)
+
+ // npcSpriteSpec now follows the NPC's own trade cadence.
+ Assert.AreEqual<CharacterFrame>(WalkFrame FrameFour, (VillageArt.npcSpriteSpec (NpcId 0) VillageArt.SouthFacing true 12L).Frame)
+ Assert.AreEqual<CharacterFrame>(WalkFrame FrameTwo, (VillageArt.npcSpriteSpec (NpcId 1) VillageArt.SouthFacing true 12L).Frame)
+ Assert.AreEqual<CharacterFrame>(WalkFrame FrameOne, (VillageArt.npcSpriteSpec (NpcId 2) VillageArt.SouthFacing true 12L).Frame)
+
+ [<TestMethod>]
member _.ChimneySmokeFramesAreDeterministicAndThreePhase () =
Assert.AreEqual<int>(0, VillageArt.smokeFrameTick 0L)
Assert.AreEqual<int>(0, VillageArt.smokeFrameTick 39L)
@@ -658,8 +686,8 @@ type PrototypeTests () =
member _.RuntimeMovementAndNpcTargetsProduceStableSpriteDirections () =
let idle = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 4.0f; Y = 4.0f } VillageArt.EastFacing
let moved = VillageArt.facingAfterMovement { X = 4.0f; Y = 4.0f } { X = 2.0f; Y = 4.0f } VillageArt.EastFacing
- let targetSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 7) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L
- let arrivedSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 7) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 10.0f } 4L
+ let targetSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 8.0f } 4L
+ let arrivedSpec = VillageArt.npcSpriteSpecAtTarget (NpcId 0) { X = 10.0f; Y = 10.0f } { X = 10.0f; Y = 10.0f } 4L
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.EastFacing, idle)
Assert.AreEqual<VillageArt.FacingDirection>(VillageArt.WestFacing, moved)
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs
index 489da23..11a6d71 100644
--- a/src/LivingVillage.Desktop/VillageArt.fs
+++ b/src/LivingVillage.Desktop/VillageArt.fs
@@ -79,6 +79,19 @@ module VillageArt =
| GreyCap
| StrawHat
+ /// Presentation-only occupational archetype. Kernel NPCs carry no occupation, so the
+ /// visible trade is a pure function of NpcId; three walking rhythms coexist on screen.
+ type NpcTrade =
+ | Farmer
+ | Peddler
+ | Scholar
+
+ /// Per-trade cadence in simulation ticks plus a phase offset, all deterministic.
+ type WalkRhythm =
+ { WalkWindow: int64
+ IdleWindow: int64
+ PhaseOffset: int64 }
+
type FacingDirection =
| NorthFacing
| SouthFacing
@@ -291,23 +304,48 @@ 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 =
- match positiveModulo64 4L (tick / 4L) with
+ /// Trade cycle: NPCs repeat Farmer -> Peddler -> Scholar by id, so all three rhythms
+ /// are present without touching kernel state.
+ let npcTrade (NpcId npcId) : NpcTrade =
+ match positiveModulo 3 npcId with
+ | 0 -> Farmer
+ | 1 -> Peddler
+ | _ -> Scholar
+
+ /// Farmer ambles at the original cadence; Peddler shuffles faster; Scholar strolls slower.
+ let rhythmFor (trade: NpcTrade) : WalkRhythm =
+ match trade with
+ | Farmer -> { WalkWindow = 4L; IdleWindow = 24L; PhaseOffset = 0L }
+ | Peddler -> { WalkWindow = 3L; IdleWindow = 20L; PhaseOffset = 1L }
+ | Scholar -> { WalkWindow = 6L; IdleWindow = 36L; PhaseOffset = 2L }
+
+ /// Four-pose walk cycle at a rhythm's window (one pose per WalkWindow sim ticks).
+ let animationFrameFor (rhythm: WalkRhythm) (tick: int64) : AnimationFrame =
+ match positiveModulo64 4L (tick / rhythm.WalkWindow + rhythm.PhaseOffset) with
| 0L -> FrameOne
| 1L -> FrameTwo
| 2L -> FrameThree
| _ -> FrameFour
- /// Two-pose standing breath cycle over a slower 48-tick window, fully deterministic.
- let idleFrame (tick: int64) : IdleFrame =
- match positiveModulo64 2L (tick / 24L) with
+ /// Two-pose standing breath cycle at a rhythm's IdleWindow, fully deterministic.
+ let idleFrameFor (rhythm: WalkRhythm) (tick: int64) : IdleFrame =
+ match positiveModulo64 2L (tick / rhythm.IdleWindow + rhythm.PhaseOffset) with
| 0L -> IdleOne
| _ -> IdleTwo
/// Picks the walk cycle while moving and the breath cycle while standing still.
+ let characterFrameFor (rhythm: WalkRhythm) (isMoving: bool) (tick: int64) : CharacterFrame =
+ if isMoving then WalkFrame(animationFrameFor rhythm tick) else IdleFrame(idleFrameFor rhythm tick)
+
+ /// Farmer cadence is the base cadence (window 4/24, phase 0), preserving older semantics.
+ let animationFrame (tick: int64) : AnimationFrame =
+ animationFrameFor (rhythmFor Farmer) tick
+
+ let idleFrame (tick: int64) : IdleFrame =
+ idleFrameFor (rhythmFor Farmer) tick
+
let characterFrame (isMoving: bool) (tick: int64) : CharacterFrame =
- if isMoving then WalkFrame(animationFrame tick) else IdleFrame(idleFrame tick)
+ characterFrameFor (rhythmFor Farmer) isMoving tick
/// Water surface ripple phase: three frames, one per 32 simulation ticks, fully deterministic.
let waterFrameTick (tick: int64) : int =
@@ -342,7 +380,7 @@ module VillageArt =
let npcSpriteSpec (npcId: NpcId) (direction: FacingDirection) (isMoving: bool) (tick: int64) : NpcSpriteSpec =
{ Variant = npcVisualVariant npcId
Direction = direction
- Frame = characterFrame isMoving tick }
+ Frame = characterFrameFor (rhythmFor (npcTrade npcId)) 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 =