diff options
Diffstat (limited to 'src/LivingVillage.Desktop/VillageArt.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 79 |
1 files changed, 68 insertions, 11 deletions
diff --git a/src/LivingVillage.Desktop/VillageArt.fs b/src/LivingVillage.Desktop/VillageArt.fs index 4845408..11a6d71 100644 --- a/src/LivingVillage.Desktop/VillageArt.fs +++ b/src/LivingVillage.Desktop/VillageArt.fs @@ -45,6 +45,9 @@ module VillageArt = | ReedCluster | StoneBench | RiverLantern + | StoneLantern + | WaterVat + | MarketStall type RenderProp = { Kind: RenderPropKind @@ -76,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 @@ -135,6 +151,9 @@ module VillageArt = | SmokeSpriteA | SmokeSpriteB | SmokeSpriteC + | StoneLanternSprite + | WaterVatSprite + | StallSprite type private XnaColor = Microsoft.Xna.Framework.Color type private XnaRectangle = Microsoft.Xna.Framework.Rectangle @@ -228,7 +247,11 @@ module VillageArt = renderProp ReedCluster 15 13 renderProp StoneBench 9 11 renderProp RiverLantern 5 11 - renderProp RiverLantern 7 14 ] + renderProp RiverLantern 7 14 + renderProp StoneLantern 4 14 + renderProp StoneLantern 16 14 + renderProp WaterVat 12 11 + renderProp MarketStall 3 11 ] Interior = { Elements = [ interiorElement Window 2 2 @@ -260,7 +283,10 @@ module VillageArt = | FlowerBush | ReedCluster | StoneBench - | RiverLantern -> [ position x y ]) + | RiverLantern + | StoneLantern + | WaterVat + | MarketStall -> [ position x y ]) |> many let private positiveModulo divisor value = @@ -278,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 = @@ -329,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 = @@ -445,7 +496,7 @@ module VillageArt = let characterAtlasRelativePath = "Assets/jiangnan-characters.png" let interiorAtlasRelativePath = "Assets/jiangnan-interior.png" - let worldTileCount = 25 + let worldTileCount = 28 let worldAtlasWidth = Sim.tilePixels * worldTileCount let worldAtlasHeight = Sim.tilePixels let characterAtlasWidth = 32 * 5 * 4 * 6 @@ -516,6 +567,9 @@ module VillageArt = | SmokeSpriteA -> 22 | SmokeSpriteB -> 23 | SmokeSpriteC -> 24 + | StoneLanternSprite -> 25 + | WaterVatSprite -> 26 + | StallSprite -> 27 XnaRectangle(index * Sim.tilePixels, 0, Sim.tilePixels, Sim.tilePixels) let private tilePosition x y : TilePosition = @@ -653,6 +707,9 @@ module VillageArt = | ReedCluster -> drawSingleTile prop.Position ReedSprite | StoneBench -> drawSingleTile prop.Position BenchSprite | RiverLantern -> drawSingleTile prop.Position LanternSprite + | StoneLantern -> drawSingleTile prop.Position StoneLanternSprite + | WaterVat -> drawSingleTile prop.Position WaterVatSprite + | MarketStall -> drawSingleTile prop.Position StallSprite // Chimney smoke is a late pass so it always reads above the roof, the bamboo and // any other prop sharing its tile. The puff sits one row above the ridge centre |
