summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-22 01:30:56 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-22 01:30:56 +0800
commit6dfc6652048810e42058662bb11febde190f8a52 (patch)
tree14d491da96ab04573d0e31872d49048c70abe763
parentcaac9fdad0a9d25319147758add43f091d42e7c6 (diff)
parenta67e9262caca67f1078f77ed2f274a46e284c6c8 (diff)
downloadliving-village-6dfc6652048810e42058662bb11febde190f8a52.tar.gz
merge: P17 起时钩子修复与黄昏精修(独立复验全绿 Desktop 108/Kernel 89)
-rw-r--r--docs/维护说明.md43
-rw-r--r--scripts/make-jiangnan-art.py77
-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
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
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