diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 127 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/jiangnan-world.png | bin | 3394 -> 3847 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 27 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/M6Presentation.fs | 80 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/VillageArt.fs | 79 |
5 files changed, 257 insertions, 56 deletions
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 88f6016..b250824 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) @@ -473,8 +473,9 @@ type PrototypeTests () = Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 2 } : TilePosition), sprintf "missing ridge tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y - 1 } : TilePosition), sprintf "missing eave tile dx=%d" dx) Assert.IsTrue(footprint.Contains({ X = door.X + dx; Y = door.Y } : TilePosition), sprintf "missing wall tile dx=%d" dx) - // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds + 1 bench + 2 lanterns. - Assert.AreEqual<int>(33, footprint.Count) + // 12 house + 3 bridge + 3 bamboo + 2 garden + 2x2 willow + 2 shrub + 2 flowers + 2 reeds + // + 1 bench + 2 lanterns + 2 stone lanterns + 1 water vat + 1 market stall (P17). + Assert.AreEqual<int>(37, footprint.Count) [<TestMethod>] member _.VegetationAndRiversidePropsDecorateTheScene () = @@ -489,13 +490,20 @@ type PrototypeTests () = Assert.AreEqual<int>(2, count VillageArt.ReedCluster) Assert.AreEqual<int>(1, count VillageArt.StoneBench) Assert.AreEqual<int>(2, count VillageArt.RiverLantern) + Assert.AreEqual<int>(2, count VillageArt.StoneLantern) + Assert.AreEqual<int>(1, count VillageArt.WaterVat) + Assert.AreEqual<int>(1, count VillageArt.MarketStall) - // Reeds must root in water; lanterns stand on the bank path. + // Reeds must root in water; lanterns and P17 street details stand on the paved bank path. for prop in plan.Props do let tile = VillageArt.worldTile plan prop.Position match prop.Kind with | VillageArt.ReedCluster -> Assert.IsTrue(grounds.Contains(prop.Position), "reeds must sit on water tiles") - | VillageArt.RiverLantern -> Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "lanterns must stand on the bank path") + | VillageArt.RiverLantern + | VillageArt.StoneLantern + | VillageArt.WaterVat + | VillageArt.MarketStall -> + Assert.IsTrue(plan.Grounds |> List.contains (prop.Position, VillageArt.StonePath), "street details must stand on the bank path") | _ -> () [<TestMethod>] @@ -526,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) @@ -650,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) @@ -822,21 +858,35 @@ type PrototypeTests () = Assert.AreEqual<float32>(1.0f, (atHour 0).Blend) Assert.AreEqual<float32>(1.0f, (atHour 2).Blend) - // Dawn ramp: 4:00 -> 6:00 goes night -> day, monotonic. - let dawnStart = (atHour 4).Blend - let dawnMid = (M6Presentation.lightingAtTick (int64 5 * ticksPerHour)).Blend - let dawnEnd = (atHour 6).Blend - Assert.AreEqual<float32>(1.0f, dawnStart) - Assert.AreEqual<float32>(0.5f, dawnMid) - Assert.AreEqual<float32>(0.0f, dawnEnd) - - // Dusk ramp 20:00 -> 22:00 goes day -> night monotonically. - let duskStart = (atHour 20).Blend - let duskMid = (M6Presentation.lightingAtTick (int64 21 * ticksPerHour)).Blend - let duskEnd = (atHour 22).Blend - Assert.AreEqual<float32>(0.0f, duskStart) - Assert.AreEqual<float32>(0.5f, duskMid) - Assert.AreEqual<float32>(1.0f, duskEnd) + // P17 window: dawn 4:00 -> 6:30 goes night -> day, monotonic (fractional hours). + let dawnMidTick = int64 5 * ticksPerHour + ticksPerHour / 4L // 5:15 + let dawnEndTick = int64 6 * ticksPerHour + ticksPerHour / 2L // 6:30 + Assert.AreEqual<float32>(1.0f, (atHour 4).Blend) + Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick dawnMidTick).Blend) + Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick dawnEndTick).Blend) + + // Dusk ramp 19:30 -> 22:00 goes day -> night monotonically. + let duskStartTick = int64 19 * ticksPerHour + ticksPerHour / 2L // 19:30 + let duskMidTick = int64 20 * ticksPerHour + ticksPerHour * 3L / 4L // 20:45 + Assert.AreEqual<float32>(0.0f, (M6Presentation.lightingAtTick duskStartTick).Blend) + Assert.AreEqual<float32>(0.5f, (M6Presentation.lightingAtTick duskMidTick).Blend) + Assert.AreEqual<float32>(1.0f, (atHour 22).Blend) + + // Blue hour: the transition lingers past 6:00 and starts before 20:00. + Assert.IsTrue((atHour 6).Blend > 0.0f && (atHour 6).Blend < 0.5f) + Assert.IsTrue((atHour 20).Blend > 0.0f && (atHour 20).Blend < 0.5f) + + // Golden-hour warmth peaks mid-dawn/mid-dusk and vanishes at noon/midnight. + Assert.AreEqual<float32>(0.0f, (atHour 12).SkyWarmth) + Assert.AreEqual<float32>(0.0f, (atHour 0).SkyWarmth) + Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick dawnMidTick).SkyWarmth) + Assert.AreEqual<float32>(1.0f, (M6Presentation.lightingAtTick duskMidTick).SkyWarmth) + Assert.IsTrue((atHour 5).SkyWarmth > 0.5f) + Assert.IsTrue((atHour 21).SkyWarmth > 0.5f) + // The warm shift pushes the sky redder and less blue than the plain blend. + let warmDawn = atHour 5 + Assert.IsTrue(warmDawn.Background.R > (M6Presentation.backgroundAtBlend warmDawn.Blend).R) + Assert.IsTrue(warmDawn.Background.B < (M6Presentation.backgroundAtBlend warmDawn.Blend).B) // Determinism without randomness. let once = atHour 13 @@ -847,6 +897,24 @@ type PrototypeTests () = Assert.AreEqual<float32>(0.0f, (atHour 13).LanternGlow) Assert.AreEqual<float32>(1.0f, (atHour 0).LanternGlow) + // Evidence hook: fractional start hours map exactly onto sim ticks. + Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75) + Assert.AreEqual<int64>(6L * ticksPerHour, M6Presentation.startTickForHour 6.0) + + [<TestMethod>] + member _.StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight () = + let ticksPerHour = Sim.ticksPerDay / 24L + + // P17-FIX: the evidence hour must win on the sample/autoplay path too, otherwise + // StartNewGame re-created the world at 00:00 and dusk/day shots were actually night. + Assert.AreEqual<int64>(M6Presentation.startTickForHour 20.75, M6Presentation.resolveStartTick (Some 20.75) false) + Assert.AreEqual<int64>(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.resolveStartTick (Some 20.75) false) + // Explicit hour overrides the daylight bootstrap. + Assert.AreEqual<int64>(12L * ticksPerHour, M6Presentation.resolveStartTick (Some 12.0) true) + // No override: daylight -> 6:00, otherwise midnight. + Assert.AreEqual<int64>(6L * ticksPerHour, M6Presentation.resolveStartTick None true) + Assert.AreEqual<int64>(0L, M6Presentation.resolveStartTick None false) + [<TestMethod>] member _.TitleEntranceProgressIsDeterministicAndCompleteAfterTwoSeconds () = // 120 frames at the fixed 60Hz step = 2s entrance, pure function of frame count. @@ -1019,6 +1087,21 @@ type PrototypeTests () = Assert.AreEqual<string list>([], missing, sprintf "Missing Jiangnan PNG assets: %s" (String.concat ", " missing)) + // The baked world atlas must match the 28-slot layout the renderer indexes into + // (P17 added 石灯笼/水缸/摊贩摊位 as slots 25..27). + Assert.AreEqual<int>(28, VillageArt.worldTileCount) + let worldPath = Path.Combine(AppContext.BaseDirectory, "Assets/jiangnan-world.png") + let header = File.ReadAllBytes worldPath + let be32 offset = + (int header.[offset] <<< 24) + ||| (int header.[offset + 1] <<< 16) + ||| (int header.[offset + 2] <<< 8) + ||| int header.[offset + 3] + let atlasWidth : int = be32 16 + let atlasHeight : int = be32 20 + Assert.IsTrue((atlasWidth = VillageArt.worldAtlasWidth), "world atlas width must match 28 x 32px slots") + Assert.IsTrue((atlasHeight = VillageArt.worldAtlasHeight), "world atlas height must stay 32px") + [<TestMethod>] member _.CjkGlyphManifestIsUniqueAndMatchesAtlasGrid () = // 防复发:图集用字增长时,清单必须非空、每行恰 1 个字符、字符唯一, diff --git a/src/LivingVillage.Desktop/Assets/jiangnan-world.png b/src/LivingVillage.Desktop/Assets/jiangnan-world.png Binary files differindex 9138560..6e97728 100644 --- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png +++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 3f3a1d8..765cea8 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -126,8 +126,24 @@ type LivingVillageGame() as this = let mutable cjkAtlas = Unchecked.defaultof<Texture2D> let mutable pixel = Unchecked.defaultof<Texture2D> let renderPlan = VillageArt.sampleRenderPlan () + // Evidence hook: pin the starting wall-clock hour (e.g. 20.75 for 黄昏), desktop-only. + let startHourOverride = + match Environment.GetEnvironmentVariable("LV_AUTOPLAY_START_HOUR") with + | null | "" -> None + | value -> + match Double.TryParse(value, Globalization.NumberStyles.Float, Globalization.CultureInfo.InvariantCulture) with + | true, hour -> Some hour + | _ -> None + // Single source of truth for the starting tick so the sample/autoplay path (which + // re-creates the world via StartNewGame) honours the evidence hour too. + let evidenceStartTick () = + M6Presentation.resolveStartTick startHourOverride daylightAutoplay + let withStartTick (world: World) : World = + let tick = evidenceStartTick () + { world with Tick = tick; Time = float tick * Sim.dtSeconds } let createInitialWorld () = WorldBootstrap.initialWorldForDaylight daylightAutoplay 42UL Sim.npcCount + |> withStartTick let mutable world = createInitialWorld () let mutable camera = Vector2.Zero let mutable avatarFacing = VillageArt.SouthFacing @@ -210,17 +226,18 @@ type LivingVillageGame() as this = this.CenterCamera() member private this.StartNewGame (occupation: Occupation.Kind option) = + let startTick = evidenceStartTick () let state = occupation - |> Option.map (fun kind -> - let startTick = if daylightAutoplay then 6L * Sim.ticksPerDay / 24L else 0L - WorldBootstrap.occupationStateFor 42UL kind startTick) - world <- WorldBootstrap.initialWorldWithOccupation daylightAutoplay 42UL Sim.npcCount state + |> Option.map (fun kind -> WorldBootstrap.occupationStateFor 42UL kind startTick) + world <- + WorldBootstrap.initialWorldWithOccupation daylightAutoplay 42UL Sim.npcCount state + |> withStartTick this.ResetWorldView() m5View <- { m5View with Task = state } menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame let occupationName = occupation |> Option.map Occupation.nameOf |> Option.defaultValue "none" - printfn $"new-game=ok seed=42 npcs={Sim.npcCount} occupation={occupationName}" + printfn $"new-game=ok seed=42 npcs={Sim.npcCount} occupation={occupationName} tick={world.Tick}" member private this.SaveWorld (prefix: string) : bool = try diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 65300f0..752034a 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -13,7 +13,8 @@ type M6RenderProfile = Background: Color WorldTint: Color Blend: float32 - LanternGlow: float32 } + LanternGlow: float32 + SkyWarmth: float32 } module M6Presentation = @@ -22,36 +23,68 @@ module M6Presentation = | Day -> "白天" | Night -> "夜晚" - /// Continuous light blend: 0 = full day, 1 = full night. Ramps are deterministic - /// pure functions of the simulation tick (dawn 4:00-6:00, dusk 20:00-22:00), so the - /// boundaries agree with Sim.isNightTick (night >= 22:00 or < 6:00). + // Transition windows in float hours. Dawn lingers past 6:00 and dusk begins before + // 20:00 so the blue hour is longer and the sky warms before it darkens; the anchors + // still agree with Sim.isNightTick (night >= 22:00 or < 6:00) once fully dark. + let dawnStartHour = 4.0f + let dawnEndHour = 6.5f + let duskStartHour = 19.5f + let duskEndHour = 22.0f + + /// Continuous light blend: 0 = full day, 1 = full night. Deterministic pure function + /// of the simulation tick, parameterised by the dawn/dusk windows above. let lightingBlend (tick: int64) : float32 = let hourTicks = Sim.ticksPerDay / 24L - let hour = float32 (tick / hourTicks) + let hour = float32 tick / float32 hourTicks let clamp01 v = if v < 0.0f then 0.0f elif v > 1.0f then 1.0f else v - if hour >= 6.0f && hour < 20.0f then 0.0f - elif hour >= 22.0f then 1.0f - elif hour < 4.0f then 1.0f - elif hour < 6.0f then clamp01 (1.0f - (hour - 4.0f) / 2.0f) - else clamp01 ((hour - 20.0f) / 2.0f) + if hour >= dawnEndHour && hour < duskStartHour then 0.0f + elif hour >= duskEndHour || hour < dawnStartHour then 1.0f + elif hour < dawnEndHour then clamp01 (1.0f - (hour - dawnStartHour) / (dawnEndHour - dawnStartHour)) + else clamp01 ((hour - duskStartHour) / (duskEndHour - duskStartHour)) + + /// Golden-hour warmth (0..1): a triangular bump peaking mid-dawn and mid-dusk, zero at + /// noon and midnight. Drives the warm sky/world tint so P17 transition frames read golden. + let transitionWarmth (tick: int64) : float32 = + let hour = float32 tick / float32 (Sim.ticksPerDay / 24L) + let bump (startHour: float32) (endHour: float32) : float32 = + if hour <= startHour || hour >= endHour then + 0.0f + else + let mid = (startHour + endHour) * 0.5f + let half = (endHour - startHour) * 0.5f + 1.0f - abs (hour - mid) / half + max (bump dawnStartHour dawnEndHour) (bump duskStartHour duskEndHour) let private lerp (a: int) (b: int) (blend: float32) : int = a + int (float32 (b - a) * blend) - let backgroundAtBlend (blend: float32) : Color = - Color(lerp 100 18 blend, lerp 160 25 blend, lerp 210 55 blend) + /// Blended sky/world colour plus the golden-hour warmth shift (warm channels up, blue down). + let backgroundAtWarmth (blend: float32) (warmth: float32) : Color = + Color( + lerp 100 18 blend + int (52.0f * warmth), + lerp 160 25 blend + int (14.0f * warmth), + lerp 210 55 blend - int (34.0f * warmth)) + + let worldTintAtWarmth (blend: float32) (warmth: float32) : Color = + Color( + lerp 255 118 blend + int (10.0f * warmth), + lerp 248 136 blend - int (6.0f * warmth), + lerp 224 184 blend - int (26.0f * warmth)) - let worldTintAtBlend (blend: float32) : Color = - Color(lerp 255 118 blend, lerp 248 136 blend, lerp 224 184 blend) + /// Warmth-free base colours (kept for callers that only have a blend value). + let backgroundAtBlend (blend: float32) : Color = backgroundAtWarmth blend 0.0f + let worldTintAtBlend (blend: float32) : Color = worldTintAtWarmth blend 0.0f - /// Pure day-phase lighting: deterministic tint/background plus lantern warm-glow boost. + /// Pure day-phase lighting: deterministic tint/background/warmth plus lantern glow. let lightingAtTick (tick: int64) : M6RenderProfile = let blend = lightingBlend tick + let warmth = transitionWarmth tick { Mode = if blend < 0.5f then Day else Night - Background = backgroundAtBlend blend + Background = backgroundAtWarmth blend warmth Blend = blend - WorldTint = worldTintAtBlend blend - LanternGlow = blend } + WorldTint = worldTintAtWarmth blend warmth + LanternGlow = blend + SkyWarmth = warmth } let profileAtTick (tick: int64) : M6RenderProfile = lightingAtTick tick @@ -67,6 +100,17 @@ module M6Presentation = /// 倍速标签(语义别名,HUD 用)。 let speedLabel (control: SimulationControl) : string = clockLabel control + /// Deterministic start tick for a wall-clock hour (evidence harness): 20.75 -> 20:45. + let startTickForHour (hour: float) : int64 = + int64 (hour * float (Sim.ticksPerDay / 24L)) + + /// Start-tick decision shared by bootstrap and new-game: an explicit evidence hour wins, + /// else daylight starts at 6:00, else midnight. Pure function, no side effects. + let resolveStartTick (startHour: float option) (startAtDaylight: bool) : int64 = + match startHour with + | Some hour -> startTickForHour hour + | None -> if startAtDaylight then 6L * Sim.ticksPerDay / 24L else 0L + let private hourOf (tick: int64) : int = int ((tick / (Sim.ticksPerDay / 24L)) % 24L) 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 |
