diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 10:58:24 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 10:58:24 +0800 |
| commit | 0b1330de9b8d78f398753d878f495f324617d203 (patch) | |
| tree | 298710d868c5a965c4dfb94fa5db89a796ca78c0 | |
| parent | d170af8485ad787cbc2a87b352d2b5cd81605b4d (diff) | |
| download | living-village-0b1330de9b8d78f398753d878f495f324617d203.tar.gz | |
p73: 昼夜光照四时段档(清晨/正午/黄昏/夜晚)
M6Presentation 新增 periodAtTick/periodLighting 参数表(AmbientTint 乘性色调 + Veil/VeilAlpha 加性覆盖 + LanternGlow),边界与 timePeriodLabel 一致;正午为恒等档不改白平衡。DrawWorldView 并入时段色调与 effectiveLanternGlow(黄昏 0.6 起点亮、夜晚 1.0),覆盖色最后叠加。新增 P73 回归;新增 LV_P73_SHOT 同机位四帧证据 + analyze-p73(verdict=PASS);字形表补 正/午(345)。Desktop 346/346、Kernel 120/120、digest 与 p70 一致。
| -rw-r--r-- | docs/evidence/p73-analysis.txt | 28 | ||||
| -rw-r--r-- | docs/evidence/p73-dusk.png | bin | 0 -> 193559 bytes | |||
| -rw-r--r-- | docs/evidence/p73-morning.png | bin | 0 -> 188561 bytes | |||
| -rw-r--r-- | docs/evidence/p73-night.png | bin | 0 -> 185380 bytes | |||
| -rw-r--r-- | docs/evidence/p73-noon.png | bin | 0 -> 106218 bytes | |||
| -rw-r--r-- | docs/维护说明.md | 17 | ||||
| -rw-r--r-- | scripts/analyze-p73.py | 181 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P73LightingTierTests.fs | 67 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png | bin | 90591 -> 90485 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 77 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/M6Presentation.fs | 70 |
15 files changed, 432 insertions, 15 deletions
diff --git a/docs/evidence/p73-analysis.txt b/docs/evidence/p73-analysis.txt new file mode 100644 index 0000000..a8a0243 --- /dev/null +++ b/docs/evidence/p73-analysis.txt @@ -0,0 +1,28 @@ +P73(昼夜光照层次:清晨/正午/黄昏/夜晚四档)证据分析 — 真实 Desktop xvfb 1280x720 +钩子:LV_P73_SHOT=1(同一桥头光源机位,6/12/18/22 各一帧,画面差异只来自光照) +路径:tick → periodAtTick → periodLighting(环境色调/覆盖色/VeilAlpha/灯光) + 连续 blend/warmth veils + +p73-morning: hour=6.0 tick=1296000 period=清晨 blend=0.2 lantern=0.32 tint=(255, 229, 212) veil=20 +p73-noon: hour=12.0 tick=2592000 period=正午 blend=0.0 lantern=0.0 tint=(255, 255, 255) veil=0 +p73-dusk: hour=18.0 tick=3888000 period=黄昏 blend=0.0 lantern=0.6 tint=(255, 255, 255) veil=52 +p73-night: hour=22.0 tick=4752000 period=夜晚 blend=1.0 lantern=1.0 tint=(118, 136, 184) veil=26 + + +p73-morning.png: size=1280x720 brightness=107.9 R=123.0 G=118.7 B=81.8 warmth(R-B)=+41.2 std=34.1 dark_block=0 +p73-noon.png: size=1280x720 brightness=86.4 R=71.0 G=107.8 B=80.4 warmth(R-B)=-9.5 std=36.5 dark_block=0 +p73-dusk.png: size=1280x720 brightness=97.8 R=113.8 G=111.5 B=68.0 warmth(R-B)=+45.8 std=39.2 dark_block=0 +p73-night.png: size=1280x720 brightness=89.8 R=65.7 G=90.1 B=113.6 warmth(R-B)=-47.9 std=40.2 dark_block=0 + +diff p73-morning vs p73-noon: differing_pixels=905809 +diff p73-morning vs p73-dusk: differing_pixels=905399 +diff p73-morning vs p73-night: differing_pixels=921390 +diff p73-noon vs p73-dusk: differing_pixels=905696 +diff p73-noon vs p73-night: differing_pixels=921368 +diff p73-dusk vs p73-night: differing_pixels=921390 + +brightness order: morning(107.9) > dusk(97.8) > night(89.8) > noon(86.4) +warmth order: dusk(+45.8) > morning(+41.2) > noon(-9.5) > night(-47.9) + +verdict=PASS + four periods have distinct parameters; noon identity, dusk lamps on, night lamps full; + real frames render 1280x720, differ pairwise, night darker / noon brighter / dusk warmer. diff --git a/docs/evidence/p73-dusk.png b/docs/evidence/p73-dusk.png Binary files differnew file mode 100644 index 0000000..1ad7fa4 --- /dev/null +++ b/docs/evidence/p73-dusk.png diff --git a/docs/evidence/p73-morning.png b/docs/evidence/p73-morning.png Binary files differnew file mode 100644 index 0000000..1126d8b --- /dev/null +++ b/docs/evidence/p73-morning.png diff --git a/docs/evidence/p73-night.png b/docs/evidence/p73-night.png Binary files differnew file mode 100644 index 0000000..d7da3d8 --- /dev/null +++ b/docs/evidence/p73-night.png diff --git a/docs/evidence/p73-noon.png b/docs/evidence/p73-noon.png Binary files differnew file mode 100644 index 0000000..2458546 --- /dev/null +++ b/docs/evidence/p73-noon.png diff --git a/docs/维护说明.md b/docs/维护说明.md index 5fa87a4..9d27dd1 100644 --- a/docs/维护说明.md +++ b/docs/维护说明.md @@ -91,6 +91,23 @@ final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F `CharacterFeetSitOnTheTileGroundLine`;图像级 `scripts/check-no-white-blocks.py` 对夜晚录帧断言不存在 ≥64×64、每通道 ≥250、alpha ≥250 的方块(P13 旧帧 42/69 命中,修复后 0 命中)。 +## 昼夜光照档(P73) + +- 既有 `lightingBlend`(0 白天→1 夜晚,dawn 4.0–6.5 / dusk 20.0–22.0)与 `transitionWarmth` + (晨曦/黄昏三角暖色峰)是**连续曲线**;P73 在其上再叠一层**四时段档**,填补 17:00–19:15 + 原本与正午无差的黄昏空档。两者都由 `world.Tick` 纯函数导出,Kernel 不参与。 +- `M6Presentation.periodAtTick`:边界与 `timePeriodLabel` 完全一致(<5 夜晚、<8 清晨、 + <17 正午、<21 黄昏、否则夜晚)→ `Morning | Noon | Dusk | Night`。 +- `M6Presentation.periodLighting`:纯数据参数表,每档给 `AmbientTint`(乘性环境色调)、 + `Veil`/`VeilAlpha`(加性覆盖色,经 `VillageArt.premultiply`)、`LanternGlow`(灯光强度)。 + **正午档为恒等**(白 tint / `VeilAlpha=0` / 灯光 0),故既有正午白平衡不变。 +- 渲染接线(`Game.fs` `DrawWorldView`):`worldTint = multiplyTint profile.WorldTint period.AmbientTint`; + `lanternGlow = effectiveLanternGlow tick`(连续曲线与时段档取大,黄昏 0.6 起灯笼点亮、夜晚 1.0); + 时段覆盖色在 golden/moon veil 之后最后叠上。HUD/室内界未改。 +- **回归**:`P73LightingTierTests`(四档两两不同、正午恒等、黄昏灯亮、夜晚灯满、时段边界与 + `timePeriodLabel` 一致)。**证据**:`scripts/analyze-p73.py` 对 `LV_P73_SHOT=1`(同一桥头机位 + 6/12/18/22 四帧)断言参数一致 + 亮度/暖度可辨 + 两两 pixel diff,落 `docs/evidence/p73-*`。 + ## 性能基线命令 ```bash diff --git a/scripts/analyze-p73.py b/scripts/analyze-p73.py new file mode 100644 index 0000000..31595bf --- /dev/null +++ b/scripts/analyze-p73.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""P73(昼夜光照层次:四时段档)证据分析:同一桥头机位 6/12/18/22 时四张真实帧。 + +判据(读真实实跑日志 + 逐帧像素统计): + 1. 日志含四行 `p73-shot=p73-<period> hour=.. period=.. blend=.. lantern=.. tint=.. veil=..`, + 时段名覆盖 清晨/正午/黄昏/夜晚。 + 2. 参数一致性:正午 veil=0 且 lantern=0;黄昏 lantern>=0.3(路灯/灯笼开始点亮); + 夜晚 lantern>=1.0;四行 veil/tint/blend 组合两两不同。 + 3. 抽帧 PNG:1280x720、非空白(std>=8)、无 24x24 纯黑块。 + 4. 可辨差异:逐帧平均亮度(night 最暗、noon 最亮)与平均暖度(黄昏 R-B 最高); + 四帧两两像素 diff > 0。 + 5. 色调是否「好看/自然」的 vision 复核由 Hermes 侧完成,非本脚本判据。 + +Usage: python3 scripts/analyze-p73.py <record-dir> <out-txt> +Exit 0 pass / 1 fail / 2 usage. +""" + +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +SHOT_RE = re.compile( + r"^p73-shot=(\S+) hour=([\d.]+) tick=(\d+) period=(\S+) blend=([\d.]+) lantern=([\d.]+) tint=(\d+),(\d+),(\d+) veil=(\d+)$", + re.MULTILINE, +) +ORDER = ["p73-morning", "p73-noon", "p73-dusk", "p73-night"] +DARK_SIDE = 24 +DARK_MAX = 8 + + +def max_block(rgba, side, predicate): + h, w = rgba.shape[0], rgba.shape[1] + if h < side or w < side: + return 0 + mask = predicate(rgba).astype(np.int64) + integral = np.zeros((h + 1, w + 1), dtype=np.int64) + integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1) + best = 0 + for y in range(0, h - side + 1): + for x in range(0, w - side + 1): + total = ( + integral[y + side, x + side] + - integral[y, x + side] + - integral[y + side, x] + + integral[y, x] + ) + best = max(best, int(total)) + return best + + +def main(): + if len(sys.argv) != 3: + print(__doc__) + return 2 + rec = Path(sys.argv[1]) + out = Path(sys.argv[2]) + log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace") + failures = [] + lines = [ + "P73(昼夜光照层次:清晨/正午/黄昏/夜晚四档)证据分析 — 真实 Desktop xvfb 1280x720", + "钩子:LV_P73_SHOT=1(同一桥头光源机位,6/12/18/22 各一帧,画面差异只来自光照)", + "路径:tick → periodAtTick → periodLighting(环境色调/覆盖色/VeilAlpha/灯光) + 连续 blend/warmth veils", + "", + ] + + shots = {} + for m in SHOT_RE.finditer(log): + name = m.group(1) + if name not in shots: + shots[name] = { + "hour": float(m.group(2)), + "tick": int(m.group(3)), + "period": m.group(4), + "blend": float(m.group(5)), + "lantern": float(m.group(6)), + "tint": (int(m.group(7)), int(m.group(8)), int(m.group(9))), + "veil": int(m.group(10)), + } + for name in ORDER: + if name not in shots: + failures.append(f"log missing p73-shot={name}") + else: + s = shots[name] + lines.append( + f"{name}: hour={s['hour']} tick={s['tick']} period={s['period']} blend={s['blend']} lantern={s['lantern']} tint={s['tint']} veil={s['veil']}" + ) + lines.append("") + + if "p73-noon" in shots: + s = shots["p73-noon"] + if s["veil"] != 0: + failures.append(f"noon veil {s['veil']} != 0 (must be identity)") + if s["lantern"] != 0.0: + failures.append(f"noon lantern {s['lantern']} != 0") + if "p73-dusk" in shots and shots["p73-dusk"]["lantern"] < 0.3: + failures.append(f"dusk lantern {shots['p73-dusk']['lantern']} < 0.3 (lamps must start at dusk)") + if "p73-night" in shots and shots["p73-night"]["lantern"] < 1.0: + failures.append(f"night lantern {shots['p73-night']['lantern']} < 1.0") + combos = {(s["blend"], s["lantern"], s["tint"], s["veil"]) for s in shots.values()} + if len(combos) < len(shots): + failures.append("two periods share identical (blend,lantern,tint,veil) parameters") + lines.append("") + + stats = {} + for name in ORDER: + path = rec / f"{name}.png" + if not path.exists(): + failures.append(f"missing frame {name}.png") + continue + rgba = np.asarray(Image.open(path).convert("RGBA"), dtype=np.uint8) + rgb = rgba[:, :, :3].astype(np.float64) + h, w = rgb.shape[0], rgb.shape[1] + mean = rgb.mean(axis=(0, 1)) + warmth = float(mean[0] - mean[2]) + brightness = float(rgb.mean()) + std = float(rgb.std()) + dark = max_block( + rgba, DARK_SIDE, + lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250), + ) + stats[name] = {"brightness": brightness, "warmth": warmth, "std": std} + lines.append( + f"{name}.png: size={w}x{h} brightness={brightness:.1f} R={mean[0]:.1f} G={mean[1]:.1f} B={mean[2]:.1f} warmth(R-B)={warmth:+.1f} std={std:.1f} dark_block={dark}" + ) + if (w, h) != (1280, 720): + failures.append(f"{name}.png size {w}x{h} != 1280x720") + if std < 8.0: + failures.append(f"{name}.png looks blank (std={std:.1f})") + if dark > 0: + failures.append(f"{name}.png has {DARK_SIDE}x{DARK_SIDE} pure-black block") + lines.append("") + + have = [n for n in ORDER if n in stats] + for i in range(len(have)): + for j in range(i + 1, len(have)): + a = np.asarray(Image.open(rec / f"{have[i]}.png").convert("RGB"), dtype=np.int16) + b = np.asarray(Image.open(rec / f"{have[j]}.png").convert("RGB"), dtype=np.int16) + differing = int((np.abs(a - b).sum(axis=2) > 0).sum()) + lines.append(f"diff {have[i]} vs {have[j]}: differing_pixels={differing}") + if differing == 0: + failures.append(f"{have[i]} identical to {have[j]}") + + if all(n in stats for n in ORDER): + if not (stats["p73-dusk"]["warmth"] > stats["p73-noon"]["warmth"]): + failures.append("dusk is not warmer than noon (R-B)") + if not (stats["p73-night"]["warmth"] < stats["p73-noon"]["warmth"]): + failures.append("night is not cooler than noon (R-B)") + if not (stats["p73-night"]["brightness"] < stats["p73-dusk"]["brightness"]): + failures.append("night is not darker than dusk") + if not (stats["p73-night"]["brightness"] < stats["p73-morning"]["brightness"]): + failures.append("night is not darker than morning") + lines.append("") + lines.append( + f"brightness order: " + + " > ".join(f"{n.split('-')[1]}({stats[n]['brightness']:.1f})" for n in sorted(ORDER, key=lambda n: -stats[n]["brightness"])) + ) + lines.append( + f"warmth order: " + + " > ".join(f"{n.split('-')[1]}({stats[n]['warmth']:+.1f})" for n in sorted(ORDER, key=lambda n: -stats[n]["warmth"])) + ) + + lines.append("") + if failures: + lines.append("verdict=FAIL") + for f in failures: + lines.append(" - " + f) + else: + lines.append("verdict=PASS") + lines.append(" four periods have distinct parameters; noon identity, dusk lamps on, night lamps full;") + lines.append(" real frames render 1280x720, differ pairwise, night darker / noon brighter / dusk warmer.") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text("\n".join(lines) + "\n", encoding="utf-8") + print(f"wrote {out} verdict={'FAIL' if failures else 'PASS'}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 432cd03..11a1b90 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -44,6 +44,7 @@ <Compile Include="P69CopyAuditTests.fs" /> <Compile Include="P71ApiDocsTests.fs" /> <Compile Include="P72CharacterWalkTests.fs" /> + <Compile Include="P73LightingTierTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P73LightingTierTests.fs b/src/LivingVillage.Desktop.Tests/P73LightingTierTests.fs new file mode 100644 index 0000000..e5baf0a --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P73LightingTierTests.fs @@ -0,0 +1,67 @@ +namespace LivingVillage.Desktop.Tests + +open Microsoft.VisualStudio.TestTools.UnitTesting +open Microsoft.Xna.Framework +open LivingVillage.Desktop +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +/// P73 回归:四时段光照档参数表完整性 + 与既有连续光照/时段标签一致。 +[<TestClass>] +type P73LightingTierTests () = + + let ticksPerHour = Sim.ticksPerDay / 24L + let atHour hour = int64 hour * ticksPerHour + + [<TestMethod>] + member _.PeriodTableCoversFourDistinctTiers () = + let periods = [ M6Presentation.Morning; M6Presentation.Noon; M6Presentation.Dusk; M6Presentation.Night ] + let stated = + periods + |> List.map (fun p -> + let l = M6Presentation.periodLighting p + (p, l.AmbientTint.R, l.AmbientTint.G, l.AmbientTint.B, l.Veil.R, l.Veil.G, l.Veil.B, l.VeilAlpha, l.LanternGlow)) + // 四档两两不同(色调/覆盖色/alpha/灯光至少一项相异)。 + Assert.AreEqual<int>(4, stated |> List.map (fun (_, r, g, b, vr, vg, vb, a, glow) -> (r, g, b, vr, vg, vb, a, glow)) |> List.distinct |> List.length) + // 名称表覆盖全四档。 + let names = periods |> List.map M6Presentation.lightingPeriodName + Assert.AreEqual<int>(4, names |> List.distinct |> List.length) + Assert.IsTrue(names |> List.forall (fun n -> n <> "")) + + [<TestMethod>] + member _.NoonTierIsIdentityAndNightLightsLanterns () = + let noon = M6Presentation.periodLighting M6Presentation.Noon + Assert.AreEqual<int>(255, int noon.AmbientTint.R) + Assert.AreEqual<int>(255, int noon.AmbientTint.G) + Assert.AreEqual<int>(255, int noon.AmbientTint.B) + Assert.AreEqual<int>(0, noon.VeilAlpha) + Assert.AreEqual<float32>(0.0f, noon.LanternGlow) + + let night = M6Presentation.periodLighting M6Presentation.Night + Assert.AreEqual<float32>(1.0f, night.LanternGlow) + let dusk = M6Presentation.periodLighting M6Presentation.Dusk + Assert.IsTrue(dusk.LanternGlow >= 0.3f, "黄昏须开始点亮路灯/灯笼") + + // 实际灯光强度:夜晚点亮,正午熄灭。 + Assert.IsTrue(M6Presentation.effectiveLanternGlow (atHour 22) >= 1.0f) + Assert.AreEqual<float32>(0.0f, M6Presentation.effectiveLanternGlow (atHour 12)) + + [<TestMethod>] + member _.PeriodAtTickMatchesRequestedHoursAndExistingLabels () = + Assert.AreEqual<M6Presentation.M6Period>(M6Presentation.Morning, M6Presentation.periodAtTick (atHour 6)) + Assert.AreEqual<M6Presentation.M6Period>(M6Presentation.Noon, M6Presentation.periodAtTick (atHour 12)) + Assert.AreEqual<M6Presentation.M6Period>(M6Presentation.Dusk, M6Presentation.periodAtTick (atHour 18)) + Assert.AreEqual<M6Presentation.M6Period>(M6Presentation.Night, M6Presentation.periodAtTick (atHour 22)) + // 边界与既有 timePeriodLabel 完全一致:清晨↔清晨、正午↔白天、黄昏↔黄昏、夜晚↔夜晚。 + let expectedLabel period = + match period with + | M6Presentation.Morning -> "清晨" + | M6Presentation.Noon -> "白天" + | M6Presentation.Dusk -> "黄昏" + | M6Presentation.Night -> "夜晚" + for hour in 0 .. 23 do + let label = M6Presentation.timePeriodLabel (atHour hour) + Assert.AreEqual<string>(expectedLabel (M6Presentation.periodAtTick (atHour hour)), label) + // 乘法色调:白色恒等。 + let day = M6Presentation.profileAtTick (atHour 12) + Assert.AreEqual<Color>(day.WorldTint, M6Presentation.multiplyTint day.WorldTint (M6Presentation.periodLighting M6Presentation.Noon).AmbientTint) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 68e5b93..5da55f1 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1100,7 +1100,7 @@ type PrototypeTests () = [<TestMethod>] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = - Assert.AreEqual<int>(343, characters.Length) + Assert.AreEqual<int>(345, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual<int>(16, columns) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png Binary files differindex 6d422e7..75c3577 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png +++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt index 7532222..c4724b7 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -63,6 +63,7 @@ 匀 包 北 +午 单 卖 南 @@ -200,6 +201,7 @@ 档 桥 歉 +正 段 民 水 diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index a295f10..c8b7986 100644 --- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs +++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs @@ -11,7 +11,7 @@ module CjkGlyphAtlas = // Keep this table sorted and stable so the generated PNG has a reproducible layout. let private characterTable = - "×→一上不与业东两中为主乐乡书买了争事于互些交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包北单卖南卷原去友取受口只可各同和品回围圃土在地坏块墙备夏多夜大天夫失头好始子存完定家察对将小尚就层居屋工差己已巴巷币布帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数文料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果柴标格档桥歉段民水求江没法涨淡清渔游热照物状玩理瓦生用田留白的盐目看真石确社离秋移稳空竹笑第米粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范茶获菜落衅行西观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:" + "×→一上不与业东两中为主乐乡书买了争事于互些交人今从以件价任众会传位体作使保信倍停先入关内册再农冬准几出分切到前剧力功务动助匀包北午单卖南卷原去友取受口只可各同和品回围圃土在地坏块墙备夏多夜大天夫失头好始子存完定家察对将小尚就层居屋工差己已巴巷币布帮常平年庄库应庙度开式当录待得心必态怒总恼情愁意戏成或户打找报拒择指挑捕据接提播操收改效敌数文料新方无日旧时明昏易春是晓晚晨景暂更最有未村束板林果柴标格档桥歉正段民水求江没法涨淡清渔游热照物状玩理瓦生用田留白的盐目看真石确社离秋移稳空竹笑第米粮精系紧纭纷线结给绝继绪续置翻耕聊职背能自范茶获菜落衅行西观认讨议记讲论设访试话询说请读谅谈败货贩资走足路跳车转轴过近返还进迫退送选速道邻郎都里量金鉴钱门闭问闯闲间闻附雨零需面页项须食饥饿香鱼黄黛,:" let characters = characterTable.ToCharArray() let rowCount = (characters.Length + columns - 1) / columns diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 6252117..3d04ca7 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -388,6 +388,10 @@ type LivingVillageGame() as this = let mutable p47Step = 0 let mutable p47PendingName = "" let mutable p47Pending = false + // P73 证据钩子:LV_P73_SHOT=1 时在同一生成器世界的桥头光源处,按 + // 6/12/18/22 时各拍一帧(同机位),四张真实帧用于四时段光照档差异核验。 + let p73ShotMode = Environment.GetEnvironmentVariable("LV_P73_SHOT") = "1" + let mutable p73Step = 0 // P52 证据钩子:LV_P52_SHOT=1 时选职业开局 → 走到 NPC 旁用真实 Interact 开对话 → // 真实 BuyFromTarget / SellToTarget 各一次,拍「对话入口 / 买入后 / 卖出后」三帧; // 每笔交易打印钱与背包数量前后值,供 analyze 脚本算 delta。 @@ -1474,7 +1478,39 @@ type LivingVillageGame() as this = let placeCameraOn (tileX: int) (tileY: int) = world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (tileX * Sim.tilePixels); Y = float32 (tileY * Sim.tilePixels) } } } this.CenterCamera() - if not p47Pending then + if p73ShotMode then + // 同一桥头光源机位的四时段巡拍;tick 只经 setHour 改变,保证画面差异只来自光照档。 + let hours = [ ("p73-morning", 6.0); ("p73-noon", 12.0); ("p73-dusk", 18.0); ("p73-night", 22.0) ] + if p73Step < hours.Length then + match riverscapeMap with + | Some map -> + let pick = + SceneDetail.lightSources map + |> List.sortByDescending (fun (x, y, _) -> SceneDetail.reflectionLength map x y) + |> List.tryHead + let spot = match pick with Some (x, y, _) -> x, y | None -> map.Core.MinX, map.Core.MinY + let name, hour = hours.[p73Step] + setHour hour + placeCameraOn (fst spot) (snd spot) + p47PendingName <- name + let tint = (M6Presentation.profileAtTick world.Tick).WorldTint + printfn + "p73-shot=%s hour=%.1f tick=%d period=%s blend=%.3f lantern=%.2f tint=%d,%d,%d veil=%d" + name hour world.Tick + (M6Presentation.lightingPeriodName (M6Presentation.periodAtTick world.Tick)) + (M6Presentation.lightingBlend world.Tick) + (M6Presentation.effectiveLanternGlow world.Tick) + (int tint.R) (int tint.G) (int tint.B) + (M6Presentation.periodLightingAtTick world.Tick).VeilAlpha + p47Pending <- true + | None -> + printfn "p73-shot=error no-riverscape-map" + this.Exit() + else + printfn "p73-shot=done" + this.Exit() + () + elif not p47Pending then match riverscapeMap with | None -> this.Exit() | Some map -> @@ -1582,9 +1618,14 @@ type LivingVillageGame() as this = p47Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p47PendingName) - printfn "p47-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" - p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y - p47Step <- p47Step + 1 + if p73ShotMode then + printfn "p73-frame=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" + p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y + p73Step <- p73Step + 1 + else + printfn "p47-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" + p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y + p47Step <- p47Step + 1 /// P52 证据:选职业开局 → 真实 Interact 开对话 → 真实 BuyFromTarget / SellToTarget 各一次。 member private this.PrepareP52Shot () = @@ -2386,7 +2427,7 @@ type LivingVillageGame() as this = elif p57ShotMode then if menu.Page = MainMenu then this.PrepareP57Shot() - elif p47ShotMode then + elif p47ShotMode || p73ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm elif menu.Page = OccupationSelect then @@ -2864,7 +2905,7 @@ type LivingVillageGame() as this = this.CaptureP46Shot() if p57ShotMode then this.CaptureP57Shot() - if p47ShotMode && menu.Page = Playing then + if (p47ShotMode || p73ShotMode) && menu.Page = Playing then this.CaptureP47Shot() if p52ShotMode && menu.Page = Playing then this.CaptureP52Shot() @@ -3126,6 +3167,10 @@ type LivingVillageGame() as this = member private this.DrawWorldView() = let profile = M6Presentation.profileAtTick world.Tick + // P73:时段档并入世界色调与灯光强度;正午档为恒等,不影响既有正午观感。 + let period = M6Presentation.periodLightingAtTick world.Tick + let worldTint = M6Presentation.multiplyTint profile.WorldTint period.AmbientTint + let lanternGlow = M6Presentation.effectiveLanternGlow world.Tick let viewport = this.GraphicsDevice.Viewport this.GraphicsDevice.Clear(profile.Background) spriteBatch.Begin() @@ -3145,14 +3190,14 @@ type LivingVillageGame() as this = renderPlan riverscapeMap world.Tick - profile.WorldTint + worldTint // Warm lantern / window glows strengthen as night falls (pure function of tick phase). // Concentric premultiplied circles follow a quadratic radial falloff (radius 3-4 tiles), // so the core stays a soft warm disc instead of saturating to a white block and no glow // is painted where there is no light entity. 生成器世界用桥头灯笼 + 民居门窗。 - if profile.LanternGlow > 0.3f then - let glow = profile.LanternGlow + if lanternGlow > 0.3f then + let glow = lanternGlow let peakAlpha = int (140.0f * glow) let tileX x = x * Sim.tilePixels - int camera.X let tileY y = y * Sim.tilePixels - int camera.Y @@ -3180,9 +3225,9 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, rect, color) | _ -> () - match riverscapeMap, profile.LanternGlow > 0.3f with + match riverscapeMap, lanternGlow > 0.3f with | Some map, true -> - VillageArt.drawCc0BoatOverlay spriteBatch artTextures camera map profile.WorldTint + VillageArt.drawCc0BoatOverlay spriteBatch artTextures camera map worldTint | _ -> () // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落。 @@ -3202,12 +3247,12 @@ type LivingVillageGame() as this = VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick - VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint + VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec worldTint for npc in world.Npcs do VillageArt.drawGroundShadow spriteBatch pixel camera npc.Pos let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick - VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint + VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec worldTint // Golden-hour wash: the multiplicative WorldTint can only darken, so a warm // premultiplied veil is layered over the whole scene. Because rgb is premultiplied @@ -3224,6 +3269,12 @@ type LivingVillageGame() as this = if moonAlpha > 0 then let mr, mg, mb = M6Presentation.moonlightRgb spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply mr mg mb moonAlpha) + + // P73:时段档加性覆盖色,最后叠上,保证 6/12/18/22 四时段色调肉眼可辨; + // 正午 alpha=0 故为恒等,清晨偏冷、黄昏暖橙、夜晚冷蓝。 + if period.VeilAlpha > 0 then + let v = period.Veil + spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply (int v.R) (int v.G) (int v.B) period.VeilAlpha) spriteBatch.End() // Contextual floating prompt over the unique target; same CJK atlas font path as the HUD. diff --git a/src/LivingVillage.Desktop/M6Presentation.fs b/src/LivingVillage.Desktop/M6Presentation.fs index 825847a..4e88bd9 100644 --- a/src/LivingVillage.Desktop/M6Presentation.fs +++ b/src/LivingVillage.Desktop/M6Presentation.fs @@ -159,3 +159,73 @@ module M6Presentation = elif hour < 17 then "白天" elif hour < 21 then "黄昏" else "夜晚" + + // ------------------------------------------------------------------ + // P73:四时段光照档(清晨/正午/黄昏/夜晚)。 + // 既有的 blend/warmth 是连续曲线;这里在其上叠加一层「时段档」—— + // 每个时段一份乘性环境色调 + 加性覆盖色 + 灯光强度,保证 6/12/18/22 时 + // 四张真实帧肉眼可辨,尤其填补 17:00–19:15 原本与正午无差的黄昏空档。 + // 正午档为恒等(白 tint / alpha 0 / 灯光 0),因此不改变既有正午观感。 + // ------------------------------------------------------------------ + + type M6Period = + | Morning + | Noon + | Dusk + | Night + + type M6PeriodLighting = + { AmbientTint: Color + Veil: Color + VeilAlpha: int + LanternGlow: float32 } + + /// 时段档参数表(纯数据)。调用方按 periodAtTick 取值。 + let periodLighting (period: M6Period) : M6PeriodLighting = + match period with + | Morning -> + { AmbientTint = Color(238, 246, 255) + Veil = Color(255, 222, 160) + VeilAlpha = 20 + LanternGlow = 0.0f } + | Noon -> + { AmbientTint = Color(255, 255, 255) + Veil = Color(255, 255, 255) + VeilAlpha = 0 + LanternGlow = 0.0f } + | Dusk -> + { AmbientTint = Color(255, 231, 200) + Veil = Color(255, 140, 58) + VeilAlpha = 52 + LanternGlow = 0.6f } + | Night -> + { AmbientTint = Color(150, 176, 216) + Veil = Color(70, 110, 190) + VeilAlpha = 26 + LanternGlow = 1.0f } + + /// 时段枚举名(证据日志用,纯函数;与 timePeriodLabel 边界一致)。 + let lightingPeriodName = function + | Morning -> "清晨" + | Noon -> "正午" + | Dusk -> "黄昏" + | Night -> "夜晚" + + /// tick → 四时段档。边界与 timePeriodLabel 完全一致:<5 夜晚、<8 清晨、<17 正午、<21 黄昏、否则夜晚。 + let periodAtTick (tick: int64) : M6Period = + let hour = hourOf tick + if hour < 5 then Night + elif hour < 8 then Morning + elif hour < 17 then Noon + elif hour < 21 then Dusk + else Night + + let periodLightingAtTick (tick: int64) : M6PeriodLighting = periodAtTick tick |> periodLighting + + /// 两乘性色调逐通道相乘(255 为恒等)。用于把时段环境色调并入世界 tint。 + let multiplyTint (a: Color) (b: Color) : Color = + Color(int a.R * int b.R / 255, int a.G * int b.G / 255, int a.B * int b.B / 255, 255) + + /// 实际灯光强度:连续曲线与时段档取较大者(黄昏档让灯笼从 18:00 起点亮)。 + let effectiveLanternGlow (tick: int64) : float32 = + max (lightingAtTick tick).LanternGlow (periodLightingAtTick tick).LanternGlow |
