summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p75-analysis.txt15
-rw-r--r--docs/evidence/p75-hud-day.pngbin0 -> 100778 bytes
-rw-r--r--docs/evidence/p75-hud-night.pngbin0 -> 100226 bytes
-rw-r--r--docs/维护说明.md15
-rw-r--r--scripts/analyze-p75.py134
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P75HudStripTests.fs59
-rw-r--r--src/LivingVillage.Desktop/Game.fs8
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs25
9 files changed, 245 insertions, 12 deletions
diff --git a/docs/evidence/p75-analysis.txt b/docs/evidence/p75-analysis.txt
new file mode 100644
index 0000000..ea3ab62
--- /dev/null
+++ b/docs/evidence/p75-analysis.txt
@@ -0,0 +1,15 @@
+P75(HUD 顶部条去整宽深色带 → 紧凑半透明药丸)证据分析 — 真实 Desktop xvfb 1280x720
+钩子:LV_P43_SHOT=1(同机位昼 12:00 / 夜 23:30)
+判据:顶部条 y<36 近黑占比 vs 整宽深色带基线(100%)下降 >90%,文字亮像素仍在
+
+log: p43-shot=p43-hud-day tick=2592000 calendar=1128,47,140,52
+log: p43-shot=p43-hud-night tick=5076000 calendar=1128,47,140,52
+
+p43-hud-day.png: size=1280x720 strip_dark_px=1205/46080 dark_frac=0.026 drop_vs_full_band=97.4% dark_block_24=160 bright_px=1570
+p43-hud-night.png: size=1280x720 strip_dark_px=1262/46080 dark_frac=0.027 drop_vs_full_band=97.3% dark_block_24=152 bright_px=1163
+
+diff p43-hud-day vs p43-hud-night: differing_pixels=921386
+
+verdict=PASS
+ baseline full-width dark band = 46080 px (frac 1.000).
+ both strips are mostly scene/pill, no near-black block, and text/icons remain bright.
diff --git a/docs/evidence/p75-hud-day.png b/docs/evidence/p75-hud-day.png
new file mode 100644
index 0000000..576d757
--- /dev/null
+++ b/docs/evidence/p75-hud-day.png
Binary files differ
diff --git a/docs/evidence/p75-hud-night.png b/docs/evidence/p75-hud-night.png
new file mode 100644
index 0000000..148f23e
--- /dev/null
+++ b/docs/evidence/p75-hud-night.png
Binary files differ
diff --git a/docs/维护说明.md b/docs/维护说明.md
index 3064e1a..e1a81fa 100644
--- a/docs/维护说明.md
+++ b/docs/维护说明.md
@@ -221,6 +221,21 @@ dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll
- **证据**:`LV_P74_SHOT=1`(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)→
`scripts/analyze-p74.py` 断言帧号/墨幕非增/无大黑块/两两 pixel diff>0,落 `docs/evidence/p74-*`。
+## P75 HUD 顶部条信息层次(去整宽深色带)
+
+- 现状(P16/P43):整宽竖向渐变深色带(`barHeight=36`,alpha 200/205)+ 每段药丸。P75 把整宽
+ 深色带去掉,信息只由**紧凑半透明药丸**承载:`palette.BarTop/BarBottom` alpha 置 0(字段保留兼容),
+ `DrawHudBar` 不再铺整条底色与整宽 accent 带,改画每段 `segmentBackdrop` + 新增 `pillHairline`
+ 1px 强调发丝线。小图标(7×7 点阵)+ 数值并排、左右各留 6px、右侧大面积留白。
+- 纯布局 API 与语义不变:`segments/layout/segmentBackdrop/textOrigin/iconRows`、面板状态机与快捷键
+ 全部保留;中文仍走 CJK 字形图集。日期卡(`calendarCardRect` 在条下 `+accent+8`)与浮动提示避让
+ 不受影响。
+- **回归**:`P75HudStripTests`(昼/夜整宽带 alpha=0、药丸比条矮且在条内、发丝线 1px 在药丸内、
+ HUD 只占左半留白充足、日历卡不压条、亮字 vs 深描边对比、图标+数值并排)。
+- **证据**:`LV_P43_SHOT=1`(同机位昼 12:00 / 夜 23:30)→ `scripts/analyze-p75.py`:顶部条近黑占比
+ ~2.6%(相对整宽深色带基线 100% 下降 >97%)、无实心 24×24 黑块、亮字像素 >1000,落
+ `docs/evidence/p75-*`。
+
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
diff --git a/scripts/analyze-p75.py b/scripts/analyze-p75.py
new file mode 100644
index 0000000..e8f521d
--- /dev/null
+++ b/scripts/analyze-p75.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+"""P75(HUD 顶部条:去整宽深色带,改紧凑半透明药丸)证据分析:真实昼/夜同机位帧。
+
+判据(读真实实跑日志 + 顶部条像素统计):
+ 1. 日志含 `p43-shot=p43-hud-day` 与 `p43-shot=p43-hud-night`(同机位,12:00 / 23:30)。
+ 2. 顶部条区域(y<barHeight=36 全宽)近黑像素(RGB 均 <=40)占比 < 10%,
+ 相对「整宽深色带」基线(占比 100%)下降 > 90%。
+ 3. 顶部条内无 >=24x24 近黑块。
+ 4. 文字可读:顶部条内亮像素(RGB 均 >=200)数量 > 300(标签/数值/图标仍在)。
+ 5. 昼/夜两帧 pixel diff > 0。
+ 6. 观感是否精美由 Hermes vision 侧复核,非本脚本判据。
+
+Usage: python3 scripts/analyze-p75.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"^p43-shot=(p43-hud-(?:day|night))\S* tick=(\d+) calendar=(\S+)", re.MULTILINE)
+BAR_HEIGHT = 36
+DARK_MAX = 40
+DARK_FRAC_MAX = 0.10
+BLOCK_SIDE = 24
+BRIGHT_MIN = 200
+BRIGHT_MIN_COUNT = 300
+FRAMES = ["p43-hud-day", "p43-hud-night"]
+
+
+def max_block(mask, side):
+ h, w = mask.shape
+ if h < side or w < side:
+ return 0
+ integral = np.zeros((h + 1, w + 1), dtype=np.int64)
+ integral[1:, 1:] = mask.astype(np.int64).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 = [
+ "P75(HUD 顶部条去整宽深色带 → 紧凑半透明药丸)证据分析 — 真实 Desktop xvfb 1280x720",
+ "钩子:LV_P43_SHOT=1(同机位昼 12:00 / 夜 23:30)",
+ "判据:顶部条 y<36 近黑占比 vs 整宽深色带基线(100%)下降 >90%,文字亮像素仍在",
+ "",
+ ]
+
+ shots = {m.group(1): m.group(0) for m in SHOT_RE.finditer(log)}
+ for name in FRAMES:
+ if name not in shots:
+ failures.append(f"log missing p43-shot={name}")
+ else:
+ lines.append("log: " + shots[name])
+ lines.append("")
+
+ baseline_px = 1280 * BAR_HEIGHT
+ stats = {}
+ for name in FRAMES:
+ path = rec / f"{name}.png"
+ if not path.exists():
+ failures.append(f"missing frame {path.name}")
+ continue
+ rgb = np.asarray(Image.open(path).convert("RGB"), dtype=np.int16)
+ h, w = rgb.shape[0], rgb.shape[1]
+ strip = rgb[0:BAR_HEIGHT, :, :]
+ dark_mask = (strip[:, :, 0] <= DARK_MAX) & (strip[:, :, 1] <= DARK_MAX) & (strip[:, :, 2] <= DARK_MAX)
+ dark_px = int(dark_mask.sum())
+ strip_px = strip.shape[0] * strip.shape[1]
+ dark_frac = dark_px / strip_px
+ drop = 1.0 - dark_frac
+ block = max_block(dark_mask, BLOCK_SIDE)
+ bright = int(((strip[:, :, 0] >= BRIGHT_MIN) & (strip[:, :, 1] >= BRIGHT_MIN) & (strip[:, :, 2] >= BRIGHT_MIN)).sum())
+ stats[name] = {"dark_frac": dark_frac, "bright": bright}
+ lines.append(
+ f"{name}.png: size={w}x{h} strip_dark_px={dark_px}/{strip_px} dark_frac={dark_frac:.3f} drop_vs_full_band={drop*100:.1f}% dark_block_{BLOCK_SIDE}={block} bright_px={bright}"
+ )
+ if (w, h) != (1280, 720):
+ failures.append(f"{path.name} size {w}x{h} != 1280x720")
+ if dark_frac >= DARK_FRAC_MAX:
+ failures.append(f"{name} strip dark_frac {dark_frac:.3f} >= {DARK_FRAC_MAX}")
+ if drop <= 0.90:
+ failures.append(f"{name} dark-area drop {drop*100:.1f}% <= 90%")
+ if block >= BLOCK_SIDE * BLOCK_SIDE:
+ failures.append(f"{name} has a solid {BLOCK_SIDE}x{BLOCK_SIDE} near-black block in strip")
+ if bright < BRIGHT_MIN_COUNT:
+ failures.append(f"{name} strip bright_px {bright} < {BRIGHT_MIN_COUNT} (text/icons may be unreadable)")
+ lines.append("")
+
+ have = [n for n in FRAMES if n in stats]
+ if len(have) == 2:
+ a = np.asarray(Image.open(rec / f"{have[0]}.png").convert("RGB"), dtype=np.int16)
+ b = np.asarray(Image.open(rec / f"{have[1]}.png").convert("RGB"), dtype=np.int16)
+ differing = int((np.abs(a - b).sum(axis=2) > 0).sum())
+ lines.append(f"diff {have[0]} vs {have[1]}: differing_pixels={differing}")
+ if differing == 0:
+ failures.append("day and night frames are identical")
+
+ lines.append("")
+ if failures:
+ lines.append("verdict=FAIL")
+ for f in failures:
+ lines.append(" - " + f)
+ else:
+ lines.append("verdict=PASS")
+ lines.append(f" baseline full-width dark band = {baseline_px} px (frac 1.000).")
+ lines.append(" both strips are mostly scene/pill, no near-black block, and text/icons remain bright.")
+ 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 fb7ecb7..dc2cb23 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -46,6 +46,7 @@
<Compile Include="P72CharacterWalkTests.fs" />
<Compile Include="P73LightingTierTests.fs" />
<Compile Include="P74LaunchTransitionTests.fs" />
+ <Compile Include="P75HudStripTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P75HudStripTests.fs b/src/LivingVillage.Desktop.Tests/P75HudStripTests.fs
new file mode 100644
index 0000000..e80d91f
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P75HudStripTests.fs
@@ -0,0 +1,59 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+open LivingVillage.Kernel
+open LivingVillage.Kernel.Sim
+
+/// P75 回归:HUD 顶部条改为紧凑半透明药丸(去整宽深色带),信息不遮挡、文字可读。
+[<TestClass>]
+type P75HudStripTests () =
+
+ let world = Sim.initialWorldN 42UL 4
+ let control = SimulationControl.initial
+ let placed = HudLayout.layout (HudLayout.segments world control)
+
+ [<TestMethod>]
+ member _.TopBandIsRemovedForDayAndNight () =
+ for mode in [ Day; Night ] do
+ let palette = HudLayout.palette mode
+ Assert.AreEqual<byte>(0uy, palette.BarTop.A)
+ Assert.AreEqual<byte>(0uy, palette.BarBottom.A)
+ Assert.AreEqual<byte>(0uy, HudLayout.bandAlpha)
+
+ [<TestMethod>]
+ member _.PillsAreCompactInsideTheStrip () =
+ Assert.IsTrue(HudLayout.pillHeight < HudLayout.barHeight, "药丸须比窄条矮,留出上下留白")
+ for (x, width, _) in placed do
+ let pill = HudLayout.segmentBackdrop x width
+ Assert.IsTrue(pill.Y >= 0 && pill.Y + pill.Height <= HudLayout.barHeight, "药丸须在窄条内")
+ let hair = HudLayout.pillHairline x width
+ Assert.IsTrue(hair.Height = 1, "发丝线为 1px")
+ Assert.IsTrue(hair.X >= pill.X && hair.X + hair.Width <= pill.X + pill.Width, "发丝线须在药丸内")
+ Assert.AreEqual<int>(max 1 (pill.Width - 2), hair.Width)
+
+ [<TestMethod>]
+ member _.StripLeavesWhitespaceAndDoesNotOverlapDateCard () =
+ Assert.IsTrue(placed.Length > 0)
+ let firstX = placed |> List.head |> fun (x, _, _) -> x
+ Assert.AreEqual<int>(HudLayout.paddingX, firstX)
+ let lastEnd = placed |> List.last |> fun (x, width, _) -> x + width
+ // 药丸总跨度远小于通栏宽度,右侧留白充足(不再铺满整宽)。
+ Assert.IsTrue(lastEnd < 1280 / 2, sprintf "HUD 只占左半,右侧留白;lastEnd=%d" lastEnd)
+ let reading = HudLayout.calendarReading world.Tick
+ let cal = HudLayout.calendarCardRect 1280 reading
+ Assert.IsTrue(cal.Y >= HudLayout.barHeight + HudLayout.accentHeight, "日历卡不压顶部条")
+
+ [<TestMethod>]
+ member _.TextStaysReadableOnTranslucentPills () =
+ for mode in [ Day; Night ] do
+ let palette = HudLayout.palette mode
+ Assert.IsTrue(palette.SegmentBackdrop.A > 0uy && palette.SegmentBackdrop.A < 255uy, "药丸半透明")
+ let valueLuma = int palette.Value.R + int palette.Value.G + int palette.Value.B
+ let outlineLuma = int palette.Outline.R + int palette.Outline.G + int palette.Outline.B
+ Assert.IsTrue(valueLuma > 600, "数值色须够亮")
+ Assert.IsTrue(valueLuma > outlineLuma + 400, "亮字 vs 深描边对比须足够")
+ // 图标与数值并排:文本起始在图标右侧。
+ for (x, _, segment) in placed do
+ Assert.AreEqual<int>(x + HudLayout.iconWidth + HudLayout.iconGap, HudLayout.textOrigin x)
+ Assert.IsFalse(System.String.IsNullOrEmpty segment.Value)
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index a4f994b..c6408aa 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -3405,17 +3405,14 @@ type LivingVillageGame() as this =
/// 顶部窄信息条:时段/时间/倍速/体力/钱币,半透明渐变 + 强调线,绝非大黑块。
member private this.DrawHudBar() =
- let viewport = this.GraphicsDevice.Viewport
let profile = M6Presentation.profileAtTick world.Tick
let palette = HudLayout.palette profile.Mode
let placed = HudLayout.layout (HudLayout.segments world simulationControl)
let barHeight = HudLayout.barHeight
let textY = (barHeight - 8 * HudLayout.textScale) / 2
spriteBatch.Begin()
- // 竖向渐变:上半略亮、下半压暗,避免整条纯色。
- spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop)
- spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom)
- spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent)
+ // P75:去掉整宽深色渐变带(palette 的 BarTop/BarBottom alpha=0),信息只靠各段的
+ // 半透明药丸承载——紧凑的图标+数值并排、留白充足,HUD 区域黑块面积大幅下降。
// 图标用 pixel 贴图按 7x7 点阵绘制(无外部素材),先描边后填充,保证昼夜可读。
let drawIcon (iconX: int) (iconY: int) (color: Color) (icon: HudLayout.HudIcon) =
HudLayout.iconRows icon
@@ -3434,6 +3431,7 @@ type LivingVillageGame() as this =
let iconY = (barHeight - HudLayout.iconHeight) / 2
for (x, width, segment) in placed do
spriteBatch.Draw(pixel, HudLayout.segmentBackdrop x width, palette.SegmentBackdrop)
+ spriteBatch.Draw(pixel, HudLayout.pillHairline x width, palette.Accent)
let textX = HudLayout.textOrigin x
let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale
let isEnergy = segment.Icon = HudLayout.EnergyIcon
diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs
index b19df4b..cd06224 100644
--- a/src/LivingVillage.Desktop/HudLayout.fs
+++ b/src/LivingVillage.Desktop/HudLayout.fs
@@ -122,6 +122,17 @@ module HudLayout =
let y = (barHeight - height) / 2
Microsoft.Xna.Framework.Rectangle(x - 6, y, width + 12, height)
+ /// P75:药丸底部 1px 强调发丝线(取代整条铺满的 accent 带,让留白更足)。
+ let pillHairline (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle =
+ let pill = segmentBackdrop x width
+ Microsoft.Xna.Framework.Rectangle(pill.X + 1, pill.Y + pill.Height - 2, max 1 (pill.Width - 2), 1)
+
+ /// P75:顶部不再铺整宽深色带(0 = 仅靠半透明药丸承载信息,去掉「整块深色底」)。
+ let bandAlpha : byte = 0uy
+
+ /// P75:药丸高度的紧凑上界(须 < barHeight 以留出上下留白)。
+ let pillHeight : int = 8 * textScale + 10
+
type Palette =
{ BarTop: Color
BarBottom: Color
@@ -131,25 +142,25 @@ module HudLayout =
SegmentBackdrop: Color
Outline: Color }
- /// 白天暖色 / 夜晚(冬夜)冷色微调;均为半透明渐变窄条,无大黑块。
- /// 每个常驻片段再叠一层半透明底色 + 1px 深色描边,双时段都能读清。
+ /// 白天暖色 / 夜晚(冬夜)冷色微调;半透明药丸承载信息,无整宽深色带、无大黑块。
+ /// P75 起 `BarTop/BarBottom` 保留字段但 alpha=0(不再铺整条底色)。
let palette (mode: M6DayNight) : Palette =
match mode with
| Day ->
- { BarTop = Color(40, 56, 62, 200)
- BarBottom = Color(22, 34, 40, 200)
+ { BarTop = Color(40, 56, 62, 0)
+ BarBottom = Color(22, 34, 40, 0)
Accent = Color(212, 168, 96)
Label = Color(206, 220, 208)
Value = Color(242, 248, 234)
SegmentBackdrop = Color(8, 14, 20, 150)
Outline = Color(6, 10, 14, 255) }
| Night ->
- { BarTop = Color(22, 30, 48, 205)
- BarBottom = Color(13, 19, 33, 205)
+ { BarTop = Color(22, 30, 48, 0)
+ BarBottom = Color(13, 19, 33, 0)
Accent = Color(120, 168, 196)
Label = Color(176, 196, 214)
Value = Color(228, 238, 246)
- SegmentBackdrop = Color(4, 8, 16, 160)
+ SegmentBackdrop = Color(4, 8, 16, 170)
Outline = Color(3, 6, 12, 255) }
// ---- P43:右上日历钟卡(纯模型;季节/天数/时段/时钟全部由真实 tick 导出)----