summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs127
-rw-r--r--src/LivingVillage.Desktop/Assets/jiangnan-world.pngbin3394 -> 3847 bytes
-rw-r--r--src/LivingVillage.Desktop/Game.fs27
-rw-r--r--src/LivingVillage.Desktop/M6Presentation.fs80
-rw-r--r--src/LivingVillage.Desktop/VillageArt.fs79
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
index 9138560..6e97728 100644
--- a/src/LivingVillage.Desktop/Assets/jiangnan-world.png
+++ b/src/LivingVillage.Desktop/Assets/jiangnan-world.png
Binary files differ
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