From e9d3e224e802ec41b9f565f5e8ed7eec8470a310 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:44:00 +0800 Subject: feat(desktop): P17 清晨/黄昏光照精修(暖色天际 + 过渡窗口参数化,分数小时平滑) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/LivingVillage.Desktop/M6Presentation.fs | 69 +++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 18 deletions(-) (limited to 'src/LivingVillage.Desktop/M6Presentation.fs') diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 65300f0..11399c0 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 -- cgit v1.2.3 From 8fad58bd62155da653251ab7457cd54ebba0b279 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 00:50:30 +0800 Subject: test(desktop): P17 取证钩子(LV_AUTOPLAY_START_HOUR 纯函数起时)+ 证据与 docs 记录 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...273\264\346\212\244\350\257\264\346\230\216.md" | 33 ++++++++++++++++++++++ src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 4 +++ src/LivingVillage.Desktop/Game.fs | 15 +++++++++- src/LivingVillage.Desktop/M6Presentation.fs | 4 +++ 4 files changed, 55 insertions(+), 1 deletion(-) (limited to 'src/LivingVillage.Desktop/M6Presentation.fs') diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index ad68e5b..024248f 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" @@ -173,6 +173,28 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 +## P17 批验:场景细节 / 人物动效 / 昼夜光照精修 + +- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 / + 26 水缸 / 27 摊贩摊位,全部烘焙进 `Assets/jiangnan-world.png`,尺寸 800x32→896x32),游戏侧只做 + `tileSourceRectangle` 贴片、无程序几何。`VillageArt` 新增 `RenderPropKind.StoneLantern/WaterVat/ + MarketStall` 与 `worldTileCount=28`;`sampleRenderPlan` 沿石路摆 4 件(石灯笼 4,14 / 16,14,水缸 + 12,11,摊位 3,11)。图集脚本二次运行 byte-identical(sha256 一致)。 +- ②村民走路节奏差异化:`VillageArt` 新增 `NpcTrade`(Farmer/Peddler/Scholar,按 `NpcId mod 3` 纯函数 + 分配)与 `WalkRhythm`(走路/待机窗口 + 相位)。农夫 4/24、货郎 3/20(更快)、书生 6/36(更慢); + `npcSpriteSpec` 改走各自节奏,`animationFrame/idleFrame/characterFrame` 保持农夫节奏即旧语义。 +- ③昼夜光照精修:`M6Presentation.lightingBlend` 过渡窗口参数化(清晨 4:00→6:30、黄昏 19:30→22:00) + 并把小时改分数(原为整数小时,过渡只有 3 档),新增纯函数 `transitionWarmth`(晨昏中点火暖色峰值 + 1,正午/午夜 0)驱动 `backgroundAtWarmth/worldTintAtWarmth`,`M6RenderProfile` 增 `SkyWarmth`。 +- ④回归:更新/新增确定性测试(行走节奏、光照锚点与暖色、图集 28 格与 PNG 尺寸、证据起时纯函数); + Desktop 107 / Kernel 89 全绿(未触碰 Kernel/Sim.fs 数值路径)。 +- ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-0{1,2}.png`、`p17-dusk-0{1,2}.png`、`p17-night-0{1,2}.png`、 + `p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick,P17 前/后各 1 帧)、`sample.mp4` + 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增环境钩子 `LV_AUTOPLAY_START_HOUR` + (纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。 +- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` + 一致、`performance_determinism=PASS`。 + ## 已验证命令 ```bash @@ -205,6 +227,15 @@ timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p16-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll +# P17 取证(街边细节/人物节奏/昼夜精修;LV_AUTOPLAY_START_HOUR 定起时:昼 12 / 黄昏 20.75 / 夜 0) +for spec in "day 12.0" "dusk 20.75" "night 0.0"; do set -- $spec; \ + timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ + LV_AUTOPLAY_START_HOUR=$2 LV_AUTOPLAY_RECORD=1 \ + LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$1 LV_RECORD_EVERY=2 \ + dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done +# 街边细节图集再生成(确定性,二次运行 byte-identical) +python3 scripts/make-jiangnan-art.py + # P13 动画证据(确定性接触表 + 真实运行帧) python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等 # 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖) @@ -235,6 +266,8 @@ python3 scripts/check-no-white-blocks.py evidence/night-run - `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) +- `/tmp/opencode/lv-p17-art/`(P17:`p17-day-0{1,2}.png`、`p17-dusk-0{1,2}.png`、`p17-night-0{1,2}.png`、 + `p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p16-art/`(P16:`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、 `sample.mp4`、`final_verification.txt`;沙箱不允许 `/tmp/lv-p16-art`,故落此路径) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 5a153b1..4d2c895 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -897,6 +897,10 @@ type PrototypeTests () = Assert.AreEqual(0.0f, (atHour 13).LanternGlow) Assert.AreEqual(1.0f, (atHour 0).LanternGlow) + // Evidence hook: fractional start hours map exactly onto sim ticks. + Assert.AreEqual(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75) + Assert.AreEqual(6L * ticksPerHour, M6Presentation.startTickForHour 6.0) + [] member _.TitleEntranceProgressIsDeterministicAndCompleteAfterTwoSeconds () = // 120 frames at the fixed 60Hz step = 2s entrance, pure function of frame count. diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 3f3a1d8..4760d4e 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -126,8 +126,21 @@ type LivingVillageGame() as this = let mutable cjkAtlas = Unchecked.defaultof let mutable pixel = Unchecked.defaultof 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 let createInitialWorld () = - WorldBootstrap.initialWorldForDaylight daylightAutoplay 42UL Sim.npcCount + let baseWorld = WorldBootstrap.initialWorldForDaylight daylightAutoplay 42UL Sim.npcCount + match startHourOverride with + | Some hour -> + let tick = M6Presentation.startTickForHour hour + { baseWorld with Tick = tick; Time = float tick * Sim.dtSeconds } + | None -> baseWorld let mutable world = createInitialWorld () let mutable camera = Vector2.Zero let mutable avatarFacing = VillageArt.SouthFacing diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 11399c0..a64d494 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -100,6 +100,10 @@ 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)) + let private hourOf (tick: int64) : int = int ((tick / (Sim.ticksPerDay / 24L)) % 24L) -- cgit v1.2.3 From a67e9262caca67f1078f77ed2f274a46e284c6c8 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 01:21:46 +0800 Subject: fix(desktop): P17-FIX 起时钩子在 sample/autoplay 路径生效(resolveStartTick 纯函数 + 回归 + 重拍证据) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...273\264\346\212\244\350\257\264\346\230\216.md" | 28 +++++++++++++++------- src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 14 +++++++++++ src/LivingVillage.Desktop/Game.fs | 26 +++++++++++--------- src/LivingVillage.Desktop/M6Presentation.fs | 7 ++++++ 4 files changed, 55 insertions(+), 20 deletions(-) (limited to 'src/LivingVillage.Desktop/M6Presentation.fs') diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index 024248f..3e055c3 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" @@ -188,10 +188,19 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll 1,正午/午夜 0)驱动 `backgroundAtWarmth/worldTintAtWarmth`,`M6RenderProfile` 增 `SkyWarmth`。 - ④回归:更新/新增确定性测试(行走节奏、光照锚点与暖色、图集 28 格与 PNG 尺寸、证据起时纯函数); Desktop 107 / Kernel 89 全绿(未触碰 Kernel/Sim.fs 数值路径)。 -- ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-0{1,2}.png`、`p17-dusk-0{1,2}.png`、`p17-night-0{1,2}.png`、 - `p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick,P17 前/后各 1 帧)、`sample.mp4` - 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增环境钩子 `LV_AUTOPLAY_START_HOUR` - (纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。 +- ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 + `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick, + P17 前/后各 1 帧)、`sample.mp4` 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增 + 环境钩子 `LV_AUTOPLAY_START_HOUR`(纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。 +- ⑥(P17-FIX 起时钩子修复)旧 `p17-day-0{1,2}` / `p17-dusk-0{1,2}` 帧的钩子在 sample/autoplay 取证路径下 + 失效:`StartNewGame` 重新创建世界时按 `daylightAutoplay` 把 Tick 重设为 0/6:00,覆盖了 + `createInitialWorld` 设好的值,导致黄昏/昼取证实际全为「夜晚 00:00」,P17 光照精修未被真实帧展示。 + 修复:抽纯函数 `M6Presentation.resolveStartTick (startHour option) (startAtDaylight)`(显式小时优先, + 否则 daylight 6:00,否则 0),`createInitialWorld` 与 `StartNewGame` 统一经 `evidenceStartTick`/`withStartTick` + 取同一 startTick(占位/任务日一致),`new-game=ok` 日志追加 `tick=` 便于机器核验;非取证人工路径不受影响。 + 回归:`StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight`(Desktop 107→108), + 实测 `tick`:昼 12.0→2,592,000、黄昏 20.75→4,482,000、夜 0→0,`sample.mp4` 为黄昏 20:45。 + 以上新文件名(`p17-day-12h-*` / `p17-dusk-20h45-*` / `p17-night-00h-*`)为准,旧名作废。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 @@ -228,10 +237,10 @@ timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P17 取证(街边细节/人物节奏/昼夜精修;LV_AUTOPLAY_START_HOUR 定起时:昼 12 / 黄昏 20.75 / 夜 0) -for spec in "day 12.0" "dusk 20.75" "night 0.0"; do set -- $spec; \ +for pair in day:12.0 dusk:20.75 night:0.0; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ - LV_AUTOPLAY_START_HOUR=$2 LV_AUTOPLAY_RECORD=1 \ - LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$1 LV_RECORD_EVERY=2 \ + LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ + LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # 街边细节图集再生成(确定性,二次运行 byte-identical) python3 scripts/make-jiangnan-art.py @@ -266,8 +275,9 @@ python3 scripts/check-no-white-blocks.py evidence/night-run - `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) -- `/tmp/opencode/lv-p17-art/`(P17:`p17-day-0{1,2}.png`、`p17-dusk-0{1,2}.png`、`p17-night-0{1,2}.png`、 - `p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`;同一份拷入 `evidence/`) +- `/tmp/opencode/lv-p17-art/`(P17:`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 + `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`; + 同一份拷入 `evidence/`) - `/tmp/opencode/lv-p16-art/`(P16:`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、 `sample.mp4`、`final_verification.txt`;沙箱不允许 `/tmp/lv-p16-art`,故落此路径) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 4d2c895..b250824 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -901,6 +901,20 @@ type PrototypeTests () = Assert.AreEqual(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.startTickForHour 20.75) Assert.AreEqual(6L * ticksPerHour, M6Presentation.startTickForHour 6.0) + [] + 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(M6Presentation.startTickForHour 20.75, M6Presentation.resolveStartTick (Some 20.75) false) + Assert.AreEqual(int64 20 * ticksPerHour + ticksPerHour * 3L / 4L, M6Presentation.resolveStartTick (Some 20.75) false) + // Explicit hour overrides the daylight bootstrap. + Assert.AreEqual(12L * ticksPerHour, M6Presentation.resolveStartTick (Some 12.0) true) + // No override: daylight -> 6:00, otherwise midnight. + Assert.AreEqual(6L * ticksPerHour, M6Presentation.resolveStartTick None true) + Assert.AreEqual(0L, M6Presentation.resolveStartTick None false) + [] member _.TitleEntranceProgressIsDeterministicAndCompleteAfterTwoSeconds () = // 120 frames at the fixed 60Hz step = 2s entrance, pure function of frame count. diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 4760d4e..765cea8 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -134,13 +134,16 @@ type LivingVillageGame() as this = 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 () = - let baseWorld = WorldBootstrap.initialWorldForDaylight daylightAutoplay 42UL Sim.npcCount - match startHourOverride with - | Some hour -> - let tick = M6Presentation.startTickForHour hour - { baseWorld with Tick = tick; Time = float tick * Sim.dtSeconds } - | None -> baseWorld + WorldBootstrap.initialWorldForDaylight daylightAutoplay 42UL Sim.npcCount + |> withStartTick let mutable world = createInitialWorld () let mutable camera = Vector2.Zero let mutable avatarFacing = VillageArt.SouthFacing @@ -223,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 a64d494..752034a 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -104,6 +104,13 @@ module M6Presentation = 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) -- cgit v1.2.3