From e959b1df06f29fe6542cc6cbe2f9005f620d5dc9 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 11:18:01 +0800 Subject: p75: HUD 顶部条去整宽深色带(改紧凑半透明药丸) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HudLayout:BarTop/BarBottom alpha=0,新增 pillHairline 1px 发丝线与 pillHeight;DrawHudBar 不再铺整条底色与整宽 accent,改画每段半透明药丸+发丝线,图标+数值并排、右侧留白充足。布局/语义/快捷键不变,HUD 结构仅视觉层次。新增 P75HudStripTests(4 例);LV_P43_SHOT 同机位昼/夜证据 + analyze-p75(顶部条近黑 2.6% vs 整宽带 100%、无实心黑块、亮字仍在,verdict=PASS)。Desktop 354/354、Kernel 120/120、digest 与 p70 一致。 --- docs/evidence/p75-analysis.txt | 15 +++ docs/evidence/p75-hud-day.png | Bin 0 -> 100778 bytes docs/evidence/p75-hud-night.png | Bin 0 -> 100226 bytes ...273\264\346\212\244\350\257\264\346\230\216.md" | 15 +++ scripts/analyze-p75.py | 134 +++++++++++++++++++++ .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P75HudStripTests.fs | 59 +++++++++ src/LivingVillage.Desktop/Game.fs | 8 +- src/LivingVillage.Desktop/HudLayout.fs | 25 ++-- 9 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 docs/evidence/p75-analysis.txt create mode 100644 docs/evidence/p75-hud-day.png create mode 100644 docs/evidence/p75-hud-night.png create mode 100644 scripts/analyze-p75.py create mode 100644 src/LivingVillage.Desktop.Tests/P75HudStripTests.fs 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 Binary files /dev/null and b/docs/evidence/p75-hud-day.png differ diff --git a/docs/evidence/p75-hud-night.png b/docs/evidence/p75-hud-night.png new file mode 100644 index 0000000..148f23e Binary files /dev/null and b/docs/evidence/p75-hud-night.png differ diff --git "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" index 3064e1a..e1a81fa 100644 --- "a/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" +++ "b/docs/\347\273\264\346\212\244\350\257\264\346\230\216.md" @@ -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 90%。 + 3. 顶部条内无 >=24x24 近黑块。 + 4. 文字可读:顶部条内亮像素(RGB 均 >=200)数量 > 300(标签/数值/图标仍在)。 + 5. 昼/夜两帧 pixel diff > 0。 + 6. 观感是否精美由 Hermes vision 侧复核,非本脚本判据。 + +Usage: python3 scripts/analyze-p75.py +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 @@ + 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 顶部条改为紧凑半透明药丸(去整宽深色带),信息不遮挡、文字可读。 +[] +type P75HudStripTests () = + + let world = Sim.initialWorldN 42UL 4 + let control = SimulationControl.initial + let placed = HudLayout.layout (HudLayout.segments world control) + + [] + member _.TopBandIsRemovedForDayAndNight () = + for mode in [ Day; Night ] do + let palette = HudLayout.palette mode + Assert.AreEqual(0uy, palette.BarTop.A) + Assert.AreEqual(0uy, palette.BarBottom.A) + Assert.AreEqual(0uy, HudLayout.bandAlpha) + + [] + 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(max 1 (pill.Width - 2), hair.Width) + + [] + member _.StripLeavesWhitespaceAndDoesNotOverlapDateCard () = + Assert.IsTrue(placed.Length > 0) + let firstX = placed |> List.head |> fun (x, _, _) -> x + Assert.AreEqual(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, "日历卡不压顶部条") + + [] + 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(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 导出)---- -- cgit v1.2.3