diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:30:56 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 01:30:56 +0800 |
| commit | 6dfc6652048810e42058662bb11febde190f8a52 (patch) | |
| tree | 14d491da96ab04573d0e31872d49048c70abe763 | |
| parent | caac9fdad0a9d25319147758add43f091d42e7c6 (diff) | |
| parent | a67e9262caca67f1078f77ed2f274a46e284c6c8 (diff) | |
| download | living-village-6dfc6652048810e42058662bb11febde190f8a52.tar.gz | |
merge: P17 起时钩子修复与黄昏精修(独立复验全绿 Desktop 108/Kernel 89)
| -rw-r--r-- | docs/维护说明.md | 43 | ||||
| -rw-r--r-- | scripts/make-jiangnan-art.py | 77 | ||||
| -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 |
7 files changed, 375 insertions, 58 deletions
diff --git a/docs/维护说明.md b/docs/维护说明.md index ad68e5b..3e055c3 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -173,6 +173,37 @@ 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-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`。 + ## 已验证命令 ```bash @@ -205,6 +236,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 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=$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 + # P13 动画证据(确定性接触表 + 真实运行帧) python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等 # 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖) @@ -235,6 +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-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/scripts/make-jiangnan-art.py b/scripts/make-jiangnan-art.py index 2e1dee0..9abb4b8 100644 --- a/scripts/make-jiangnan-art.py +++ b/scripts/make-jiangnan-art.py @@ -99,7 +99,7 @@ def verify_png(path, expected_w, expected_h): def build_world_atlas(): - c = Canvas(800, 32) + c = Canvas(896, 32) tile = 32 def slot(index): @@ -474,6 +474,79 @@ def build_world_atlas(): puff(x0 + 15, 6, 5, 4, smoke_mid) puff(x0 + 18, 2, 3, 2, smoke_faint) + # --- Street-side Chinese details (P17): stone lantern, water vat, market stall. + # Baked into the atlas exactly like every other tile; the game only blits these + # cells and never builds the shapes procedurally. + + # slot(25) 石灯笼 (stone lantern): pedestal, shaft, lit chamber, flared cap + finial. + warm_lamp = (238, 200, 128, 255) + x0 = slot(25) + c.rect(x0 + 10, 26, 12, 4, stone) + c.rect(x0 + 10, 26, 12, 1, stone_light) + c.rect(x0 + 10, 29, 12, 1, stone_dark) + c.rect(x0 + 14, 18, 4, 8, stone) + c.rect(x0 + 14, 18, 1, 8, stone_light) + c.rect(x0 + 18, 18, 1, 8, stone_dark) + c.rect(x0 + 11, 12, 10, 7, stone) + c.rect(x0 + 11, 12, 10, 1, stone_light) + c.rect(x0 + 13, 14, 6, 4, warm_lamp) + c.set(x0 + 14, 15, (252, 232, 176, 255)) + c.rect(x0 + 9, 9, 14, 3, stone) + c.rect(x0 + 9, 9, 14, 1, stone_light) + c.rect(x0 + 6, 8, 20, 1, stone_dark) + c.rect(x0 + 15, 5, 2, 3, stone) + c.set(x0 + 16, 4, stone_light) + c.outline(x0, 0, 32, 32) + + # slot(26) 水缸 (water vat): barrel body with iron hoops, stone rim, still water. + vat = (96, 112, 106, 255) + vat_light = (134, 148, 140, 255) + vat_dark = (66, 78, 74, 255) + vat_water = (74, 122, 152, 255) + vat_water_hi = (150, 192, 208, 255) + x0 = slot(26) + c.rect(x0 + 6, 14, 20, 15, vat) + c.rect(x0 + 6, 14, 20, 1, vat_light) + c.rect(x0 + 6, 28, 20, 1, vat_dark) + for hoop_y in (18, 22, 26): + c.rect(x0 + 6, hoop_y, 20, 1, vat_dark) + for side_x in (4, 27): + c.rect(x0 + side_x, 17, 2, 10, vat) + c.rect(x0 + side_x, 17, 1, 10, vat_light) + c.rect(x0 + 8, 30, 16, 1, (52, 62, 58, 255)) + c.rect(x0 + 4, 12, 24, 3, vat_light) + c.rect(x0 + 6, 14, 20, 2, vat_water) + c.rect(x0 + 9, 14, 8, 1, vat_water_hi) + c.set(x0 + 20, 14, vat_water_hi) + c.outline(x0, 0, 32, 32) + + # slot(27) 摊贩摊位 (market stall): wood frame, striped awning, counter, wares. + awning_cloth = (196, 92, 74, 255) + awning_cream = (238, 222, 196, 255) + crate = (140, 96, 52, 255) + x0 = slot(27) + c.rect(x0 + 5, 8, 2, 22, wood) + c.rect(x0 + 25, 8, 2, 22, wood) + c.rect(x0 + 5, 8, 1, 22, wood_light) + c.rect(x0 + 25, 8, 1, 22, wood_light) + for i in range(0, 32, 8): + c.rect(x0 + i, 6, 8, 6, awning_cloth if (i // 8) % 2 == 0 else awning_cream) + c.rect(x0, 6, 32, 1, (150, 66, 54, 255)) + c.rect(x0, 11, 32, 1, (120, 52, 44, 255)) + c.rect(x0 + 3, 20, 26, 3, wood) + c.rect(x0 + 3, 20, 26, 1, wood_light) + c.rect(x0 + 3, 22, 26, 1, wood_dark) + c.rect(x0 + 7, 16, 7, 4, crate) + c.rect(x0 + 7, 16, 7, 1, (176, 128, 74, 255)) + c.rect(x0 + 17, 16, 7, 4, crate) + c.rect(x0 + 17, 16, 7, 1, (176, 128, 74, 255)) + for gx in (9, 12, 19, 22): + c.set(x0 + gx, 15, (86, 139, 68, 255)) + c.set(x0 + 13, 15, (196, 92, 96, 255)) + c.set(x0 + 20, 15, (222, 188, 92, 255)) + c.rect(x0 + 3, 23, 26, 6, (83, 52, 39, 255)) + c.outline(x0, 0, 32, 32) + return c @@ -811,7 +884,7 @@ def build_interior_atlas(): def main(): ASSET_DIR.mkdir(parents=True, exist_ok=True) targets = [ - ("jiangnan-world.png", build_world_atlas(), 800, 32), + ("jiangnan-world.png", build_world_atlas(), 896, 32), ("jiangnan-characters.png", build_character_atlas(), 3840, 48), ("jiangnan-interior.png", build_interior_atlas(), 392, 96), ] 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 |
