summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop
diff options
context:
space:
mode:
Diffstat (limited to 'src/LivingVillage.Desktop')
-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
4 files changed, 152 insertions, 34 deletions
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