diff options
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 34 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 54 |
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 = |
