diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:03:22 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-28 19:03:22 +0800 |
| commit | 588560deda6b701a62653dff19079dc0a21fa944 (patch) | |
| tree | 3d734aaf9ad546ec52a42af0a2c2635d2d26d670 | |
| parent | 5c67e85688ff1e6150b03501adae1a5d5e67bbdc (diff) | |
| download | living-village-588560deda6b701a62653dff19079dc0a21fa944.tar.gz | |
p43: HUD 星露谷化切片 — 右上日历钟卡/体力条形图/底部10格hotbar(1-0选中+高亮+输入隔离)
| -rw-r--r-- | docs/evidence/p43-analysis.txt | 17 | ||||
| -rw-r--r-- | docs/evidence/p43-hud-closeup-2x.png | bin | 0 -> 11390 bytes | |||
| -rw-r--r-- | docs/evidence/p43-hud-day.png | bin | 0 -> 98865 bytes | |||
| -rw-r--r-- | docs/evidence/p43-hud-night.png | bin | 0 -> 117035 bytes | |||
| -rw-r--r-- | scripts/analyze-p43-hud.py | 130 | ||||
| -rw-r--r-- | scripts/make-p43-hud-closeup.py | 49 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P43HudTests.fs | 129 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 168 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/HudLayout.fs | 128 |
10 files changed, 620 insertions, 2 deletions
diff --git a/docs/evidence/p43-analysis.txt b/docs/evidence/p43-analysis.txt new file mode 100644 index 0000000..0245f85 --- /dev/null +++ b/docs/evidence/p43-analysis.txt @@ -0,0 +1,17 @@ +P43 HUD analysis (LV_P43_SHOT=1) + +[p43-hud-day] tick=2592000 day=1 season=春 period=白天 clock=12:00 energy=100 selected=0 + calendar_rect = (1128,47,140x52) hotbar_rect = (405,660,470x56) slot0_rect = (413,668,40x40) + hotbar_slot_mean_luma = 96.4, 42.5, 59.0, 64.7, 47.5, 54.4, 66.7, 45.2, 50.9, 64.1 + selected_slot=0 selected_luma=96.4 others_mean=55.0 delta=+41.4 + calendar: alpha_min=255 alpha_max=255 mean_luma=58.8 opaque_white_px=0 + hotbar: alpha_min=255 alpha_max=255 mean_luma=57.1 opaque_white_px=0 + +[p43-hud-night] tick=5076000 day=1 season=春 period=夜晚 clock=23:30 energy=100 selected=4 + calendar_rect = (1128,47,140x52) hotbar_rect = (405,660,470x56) slot0_rect = (413,668,40x40) + hotbar_slot_mean_luma = 41.3, 34.5, 51.4, 57.3, 95.3, 46.7, 59.4, 37.3, 43.1, 56.6 + selected_slot=4 selected_luma=95.3 others_mean=47.5 delta=+47.8 + calendar: alpha_min=255 alpha_max=255 mean_luma=53.6 opaque_white_px=0 + hotbar: alpha_min=255 alpha_max=255 mean_luma=50.5 opaque_white_px=0 + +verdict = PASS diff --git a/docs/evidence/p43-hud-closeup-2x.png b/docs/evidence/p43-hud-closeup-2x.png Binary files differnew file mode 100644 index 0000000..8a31906 --- /dev/null +++ b/docs/evidence/p43-hud-closeup-2x.png diff --git a/docs/evidence/p43-hud-day.png b/docs/evidence/p43-hud-day.png Binary files differnew file mode 100644 index 0000000..49d393f --- /dev/null +++ b/docs/evidence/p43-hud-day.png diff --git a/docs/evidence/p43-hud-night.png b/docs/evidence/p43-hud-night.png Binary files differnew file mode 100644 index 0000000..73bfc47 --- /dev/null +++ b/docs/evidence/p43-hud-night.png diff --git a/scripts/analyze-p43-hud.py b/scripts/analyze-p43-hud.py new file mode 100644 index 0000000..ad0a859 --- /dev/null +++ b/scripts/analyze-p43-hud.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""P43 HUD analysis: prove the new calendar card + energy bar + hotbar are real. + +Reads the `LV_P43_SHOT=1` frames and the shot log (which prints the layout rects +computed by HudLayout in the running game), then writes +docs/evidence/p43-analysis.txt with the measured numbers: + + 1. calendar card / hotbar / slot0 rectangles exactly as the game laid them out, + all inside the 1280x720 viewport and non-overlapping; + 2. per-slot hotbar brightness (10 distinct cells) and the selected-slot delta; + 3. calendar-card and hotbar opacity is semi-transparent (never an opaque block). + +Usage: + python3 scripts/analyze-p43-hud.py <record-dir> <shot-log> <out-txt> + +Exit 0 when checks pass, 1 when they fail, 2 on usage/read error. +""" + +import re +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +WIDTH = 1280 +HEIGHT = 720 +SHOT_RE = re.compile( + r"p43-shot=(\S+) tick=(\d+) calendar=([\d,]+) hotbar=([\d,]+) slot0=([\d,]+) " + r"selected=(\d+) energy=([\d.]+) day=(\d+) season=(\S+) period=(\S+) clock=(\S+)" +) + + +def rect(text): + x, y, w, h = (int(v) for v in text.split(",")) + return (x, y, w, h) + + +def inside(name, r): + x, y, w, h = r + return x >= 0 and y >= 0 and x + w <= WIDTH and y + h <= HEIGHT + + +def main(argv): + if len(argv) != 4: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + log_text = Path(argv[2]).read_text(encoding="utf-8") + shots = [] + for line in log_text.splitlines(): + m = SHOT_RE.search(line) + if m: + shots.append( + { + "name": m.group(1), + "tick": int(m.group(2)), + "calendar": rect(m.group(3)), + "hotbar": rect(m.group(4)), + "slot0": rect(m.group(5)), + "selected": int(m.group(6)), + "energy": float(m.group(7)), + "day": int(m.group(8)), + "season": m.group(9), + "period": m.group(10), + "clock": m.group(11), + } + ) + if not shots: + sys.stderr.write("no p43-shot lines in log\n") + return 1 + + lines = ["P43 HUD analysis (LV_P43_SHOT=1)", ""] + failures = [] + for shot in shots: + img = np.asarray(Image.open(record_dir / f"{shot['name']}.png").convert("RGBA"), dtype=np.float64) + lines.append(f"[{shot['name']}] tick={shot['tick']} day={shot['day']} season={shot['season']} " + f"period={shot['period']} clock={shot['clock']} energy={shot['energy']:.0f} selected={shot['selected']}") + cx, cy, cw, ch = shot["calendar"] + bx, by, bw, bh = shot["hotbar"] + sx, sy, sw, sh = shot["slot0"] + lines.append(f" calendar_rect = ({cx},{cy},{cw}x{ch}) hotbar_rect = ({bx},{by},{bw}x{bh}) slot0_rect = ({sx},{sy},{sw}x{sh})") + for name in ("calendar", "hotbar", "slot0"): + if not inside(name, shot[name]): + failures.append(f"{shot['name']} {name} out of viewport {shot[name]}") + # calendar must not overlap the top strip (y >= 39) and must sit left of hotbar band. + if cy < 39: + failures.append(f"{shot['name']} calendar overlaps top bar") + + # 1) per-slot brightness across the 10 slots (distinct cells, selected delta). + slot_means = [] + for index in range(10): + x = bx + 8 + index * (sw + 6) + y = by + 8 + cell = img[y:y + sh, x:x + sw, :3].mean() + slot_means.append(cell) + sel = shot["selected"] + others = [v for i, v in enumerate(slot_means) if i != sel] + lines.append(" hotbar_slot_mean_luma = " + ", ".join(f"{v:.1f}" for v in slot_means)) + lines.append(f" selected_slot={sel} selected_luma={slot_means[sel]:.1f} others_mean={np.mean(others):.1f} " + f"delta={slot_means[sel] - np.mean(others):+.1f}") + + # 2) opacity: the calendar card and hotbar backdrops must be semi-transparent. + for name, r in (("calendar", shot["calendar"]), ("hotbar", shot["hotbar"])): + x, y, w, h = r + alpha = img[y:y + h, x:x + w, 3] + region = img[y:y + h, x:x + w, :3] + opaque_white = int(((alpha >= 250) & (region.min(axis=2) >= 250)).sum()) + lines.append(f" {name}: alpha_min={int(alpha.min())} alpha_max={int(alpha.max())} " + f"mean_luma={region.mean():.1f} opaque_white_px={opaque_white}") + if opaque_white > 0: + failures.append(f"{shot['name']} {name} contains opaque white pixels") + if region.mean() < 8.0: + failures.append(f"{shot['name']} {name} too dark (possible big black block)") + lines.append("") + + lines.append("verdict = " + ("PASS" if not failures else "FAIL")) + out_txt = Path(argv[3]) + out_txt.parent.mkdir(parents=True, exist_ok=True) + out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8") + sys.stdout.write("\n".join(lines) + "\n") + if failures: + for f in failures: + sys.stderr.write(f"FAIL {f}\n") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/make-p43-hud-closeup.py b/scripts/make-p43-hud-closeup.py new file mode 100644 index 0000000..adcabca --- /dev/null +++ b/scripts/make-p43-hud-closeup.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""P43 evidence: NEAREST 2x close-up sheet of the two new HUD cards. + +Crops the top-right calendar/clock card and the bottom 10-slot hotbar from the +`LV_P43_SHOT=1` day frame and stacks them at exactly 2x with NEAREST so pixel +edges stay sharp. The sheet is a pure function of the input PNG (fixed crop +boxes, fixed gap), no synthetic pixels. + +Usage: + python3 scripts/make-p43-hud-closeup.py <record-dir> <out-dir> +""" + +import sys +from pathlib import Path + +from PIL import Image + +SOURCE = "p43-hud-day.png" +OUT_NAME = "p43-hud-closeup-2x.png" +# (left, top, right, bottom) in the 1280x720 frame; rects from the p43-shot log. +CALENDAR_BOX = (1124, 44, 1272, 102) +HOTBAR_BOX = (398, 655, 882, 719) +GAP = 12 + + +def main(argv): + if len(argv) != 3: + sys.stderr.write(__doc__) + return 2 + record_dir = Path(argv[1]) + out_dir = Path(argv[2]) + out_dir.mkdir(parents=True, exist_ok=True) + source = Image.open(record_dir / SOURCE).convert("RGBA") + crops = [source.crop(CALENDAR_BOX), source.crop(HOTBAR_BOX)] + doubled = [c.resize((c.width * 2, c.height * 2), Image.NEAREST) for c in crops] + width = max(img.width for img in doubled) + height = sum(img.height for img in doubled) + GAP * (len(doubled) - 1) + sheet = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + y = 0 + for img in doubled: + sheet.paste(img, (0, y)) + y += img.height + GAP + sheet.save(out_dir / OUT_NAME) + print(f"ok {OUT_NAME} {sheet.width}x{sheet.height} from {SOURCE} boxes={[CALENDAR_BOX, HOTBAR_BOX]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 03d63bd..d9bbee1 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -22,6 +22,7 @@ <Compile Include="P39ArtTests.fs" /> <Compile Include="P40ArtTests.fs" /> <Compile Include="P42LaunchFxTests.fs" /> + <Compile Include="P43HudTests.fs" /> <Compile Include="SampleTests.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Desktop.Tests/P43HudTests.fs b/src/LivingVillage.Desktop.Tests/P43HudTests.fs new file mode 100644 index 0000000..18f2e78 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P43HudTests.fs @@ -0,0 +1,129 @@ +namespace LivingVillage.Desktop.Tests + +open System +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim +open LivingVillage.Desktop + +/// P43 界面切片:右上日历钟卡、体力条形图、底部 10 格 hotbar 的纯模型与输入隔离回归。 +[<TestClass>] +type P43HudTests () = + + let world = Sim.initialWorldN 42UL 4 + let tickNoon = M6Presentation.resolveStartTick (Some 12.0) false + let tickNight = M6Presentation.resolveStartTick (Some 23.5) false + + let assertEqualMsg (expected: 'a) (actual: 'a) (message: string) = + Assert.IsTrue((expected = actual), sprintf "%s (expected %A, actual %A)" message expected actual) + + let closeTo (expected: float32) (actual: float32) (message: string) = + Assert.IsTrue(abs (expected - actual) < 1e-4f, sprintf "%s (expected %f, actual %f)" message expected actual) + + // ---- 1. 日历钟卡 ---- + + [<TestMethod>] + member _.CalendarReadingIsDerivedFromRealTick () = + let reading = HudLayout.calendarReading tickNoon + assertEqualMsg (M6Presentation.timePeriodLabel tickNoon) reading.Period "时段须取自 tick 纯函数" + assertEqualMsg (M6Presentation.clockTimeLabel tickNoon) reading.Clock "时钟须取自 tick 纯函数" + assertEqualMsg (tickNoon / Sim.ticksPerDay + 1L) reading.DayNumber "第 N 天 = tick/ticksPerDay + 1" + Assert.IsTrue(HudLayout.seasonNames |> Array.contains reading.Season, "季节须为四季之一") + let seasonOfDay (day: int64) = (HudLayout.calendarReading (day * Sim.ticksPerDay)).Season + let distinct = [ 0L .. 111L ] |> List.map seasonOfDay |> List.distinct + assertEqualMsg 4 distinct.Length "四个季节都应出现" + assertEqualMsg (seasonOfDay 0L) (seasonOfDay 27L) "同一季内 28 天不换季" + Assert.IsTrue(seasonOfDay 0L <> seasonOfDay 28L, "满 28 天必须换季") + + [<TestMethod>] + member _.CalendarCardFitsTopRightWithoutOverlap () = + for tick in [ tickNoon; tickNight ] do + let reading = HudLayout.calendarReading tick + let rect = HudLayout.calendarCardRect 1280 reading + Assert.IsTrue(rect.X >= 0 && rect.Y >= 0, "卡片不得越出视口左上") + Assert.IsTrue(rect.X + rect.Width <= 1280, "卡片不得越出视口右边") + Assert.IsTrue(rect.Y >= HudLayout.barHeight + HudLayout.accentHeight, "卡片不得压住顶部窄条") + let line1 = HudLayout.calendarLine1 reading + let line2 = HudLayout.calendarLine2 reading + let textW = + max + (FloatingPrompt.measureWidth line1 HudLayout.textScale) + (FloatingPrompt.measureWidth line2 HudLayout.textScale) + Assert.IsTrue(HudLayout.iconWidth + HudLayout.iconGap + textW <= rect.Width, "卡片宽度须容下图标与两行文字") + Assert.IsTrue(2 * (8 * HudLayout.textScale) + HudLayout.calendarLineGap <= rect.Height, "卡片高度须容下两行文字") + + // ---- 2. 体力图形化条形 ---- + + [<TestMethod>] + member _.EnergyBarIsProportionalAndClampedToRealState () = + closeTo 0.0f (HudLayout.energyBarFill 0.0f) "0 体力空条" + closeTo 1.0f (HudLayout.energyBarFill 100.0f) "满体力满条" + closeTo 0.5f (HudLayout.energyBarFill 50.0f) "50 体力半条" + closeTo 0.0f (HudLayout.energyBarFill -25.0f) "下限截断" + closeTo 1.0f (HudLayout.energyBarFill 250.0f) "上限截断" + let track = HudLayout.energyBarTrack 100 10 60 + let fill = HudLayout.energyBarFillRect 100 10 60 world.Avatar.Mind.Needs.Energy + Assert.IsTrue(fill.X >= track.X, "填充不得越过轨道左沿") + Assert.IsTrue(fill.X + fill.Width <= track.X + track.Width, "填充不得越过轨道右沿") + Assert.IsTrue(fill.Height <= track.Height, "填充不得高过轨道") + assertEqualMsg 0 (HudLayout.energyBarFillRect 100 10 60 0.0f).Width "空体力宽度为 0" + assertEqualMsg track.Width (HudLayout.energyBarFillRect 100 10 60 100.0f).Width "满体力填满轨道" + + // ---- 3. hotbar 布局与选中 ---- + + [<TestMethod>] + member _.HotbarSlotsAreTenDistinctNonOverlappingAndInsideViewport () = + assertEqualMsg 10 HudLayout.hotbarSlotCount "共 10 格" + let bar = HudLayout.hotbarBarRect 1280 720 + Assert.IsTrue(bar.X >= 0 && bar.Y >= 0, "hotbar 不得越出视口") + Assert.IsTrue(bar.X + bar.Width <= 1280 && bar.Y + bar.Height <= 720, "hotbar 不得越出视口") + let slots = [ 0 .. HudLayout.hotbarSlotCount - 1 ] |> List.map (HudLayout.hotbarSlotRect 1280 720) + for slot in slots do + Assert.IsTrue(slot.X >= bar.X && slot.Y >= bar.Y, "槽位须在底栏内") + Assert.IsTrue( + slot.X + slot.Width <= bar.X + bar.Width && slot.Y + slot.Height <= bar.Y + bar.Height, + "槽位须在底栏内") + assertEqualMsg HudLayout.hotbarSlotSize slot.Width "槽宽" + assertEqualMsg HudLayout.hotbarSlotSize slot.Height "槽高" + slots + |> List.pairwise + |> List.iter (fun (a, b) -> Assert.IsTrue(b.X >= a.X + a.Width, "槽位不得重叠")) + + [<TestMethod>] + member _.HotbarIconsAreTenOriginalWellFormedAndDistinct () = + let icons = [ 0 .. HudLayout.hotbarSlotCount - 1 ] |> List.map HudLayout.hotbarIconRows + for rows in icons do + assertEqualMsg HudLayout.iconGrid rows.Length "图标行数" + for row in rows do + assertEqualMsg HudLayout.iconGrid row.Length "图标行宽" + Assert.IsTrue(rows |> List.exists (fun row -> row.Contains "1"), "图标须有实心像素") + assertEqualMsg HudLayout.hotbarSlotCount (icons |> List.distinct |> List.length) "图标互异" + Assert.IsTrue(HudLayout.iconGrid * HudLayout.hotbarIconScale <= HudLayout.hotbarSlotSize, "图标须放进槽位") + + [<TestMethod>] + member _.HotbarDigitKeysOneThroughZeroSelectSlots () : unit = + for digit in 1 .. 9 do + assertEqualMsg (Some (digit - 1)) (HudLayout.selectSlotForDigit digit) (sprintf "键 %d" digit) + assertEqualMsg (Some 9) (HudLayout.selectSlotForDigit 0) "键 0 -> 第 10 格" + assertEqualMsg None (HudLayout.selectSlotForDigit 11) "非 1-0 键不选格" + + [<TestMethod>] + member _.HotbarSelectionCyclesWithinBounds () = + assertEqualMsg 0 (HudLayout.cycleHotbar 0 0) "原地" + assertEqualMsg 9 (HudLayout.cycleHotbar 0 -1) "首格向前到末格" + assertEqualMsg 0 (HudLayout.cycleHotbar 9 1) "末格向后到首格" + assertEqualMsg 3 (HudLayout.cycleHotbar 8 5) "跨回绕" + assertEqualMsg (HudLayout.hotbarSlotCount - 1) (HudLayout.cycleHotbar 0 (HudLayout.hotbarSlotCount - 1)) "最大步" + for start in 0 .. HudLayout.hotbarSlotCount - 1 do + for delta in -25 .. 25 do + let next = HudLayout.cycleHotbar start delta + Assert.IsTrue(next >= 0 && next < HudLayout.hotbarSlotCount, sprintf "循环越界 %d+%d" start delta) + + // ---- 4. 菜单打开时 hotbar 输入隔离 ---- + + [<TestMethod>] + member _.HotbarInputIsIsolatedWhileMenuOrPanelIsOpen () = + Assert.IsTrue(HudLayout.hotbarInputAllowed true true, "游戏内世界输入可用时 hotbar 可响应") + Assert.IsFalse(HudLayout.hotbarInputAllowed false true, "Esc 菜单打开(非 Playing)时 hotbar 输入不穿透") + Assert.IsFalse(HudLayout.hotbarInputAllowed true false, "面板/对话打开时 hotbar 输入不穿透") + Assert.IsFalse(HudLayout.hotbarInputAllowed false false, "菜单+面板同时打开时更不穿透") diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 121a090..ea03437 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -306,6 +306,11 @@ type LivingVillageGame() as this = let p42ShotMode = Environment.GetEnvironmentVariable("LV_P42_SHOT") = "1" let mutable p42SplashEarlyDone = false let mutable p42SplashLateDone = false + // P43 证据钩子:LV_P43_SHOT=1 时在真实世界里拍昼/夜两张 HUD 整帧(日历钟卡 + 体力条 + 10 格 hotbar)。 + let p43ShotMode = Environment.GetEnvironmentVariable("LV_P43_SHOT") = "1" + let mutable p43Step = 0 + let mutable p43PendingName = "" + let mutable p43Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 @@ -314,6 +319,8 @@ type LivingVillageGame() as this = let mutable hudClock = 0L let mutable hudStatus = "" let mutable hudStatusAt = 0L + // P43:hotbar 选中格(0..9),仅由游戏内 1-0 键或证据脚本写入,非伪造状态。 + let mutable hotbarSelected = 0 let mutable pauseHelpControl: SimulationControl option = None let recordMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_RECORD") = "1" let recordDirectory = @@ -770,6 +777,47 @@ type LivingVillageGame() as this = p40PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y p40Step <- p40Step + 1 + /// P43 证据:真实世界里拍昼 / 夜两张 HUD 整帧(右上日历钟卡 + 体力条形 + 底部 10 格 hotbar)。 + member private this.PrepareP43Shot () = + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + if not p43Pending then + match p43Step with + | 0 -> + setHour 12.0 + hotbarSelected <- (HudLayout.selectSlotForDigit 1 |> Option.defaultValue 0) + this.CenterCamera() + p43PendingName <- "p43-hud-day" + p43Pending <- true + | 1 -> + setHour 23.5 + hotbarSelected <- (HudLayout.selectSlotForDigit 5 |> Option.defaultValue 0) + this.CenterCamera() + p43PendingName <- "p43-hud-night" + p43Pending <- true + | _ -> + printfn "p43-shot=done" + this.Exit() + + /// P43 证据:在 `Draw` 末尾落盘,并打印实测布局矩形与真实状态(供 p43-analysis.txt)。 + member private this.CaptureP43Shot () = + if p43Pending then + p43Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p43PendingName) + let viewport = this.GraphicsDevice.Viewport + let reading = HudLayout.calendarReading world.Tick + let cal = HudLayout.calendarCardRect viewport.Width reading + let bar = HudLayout.hotbarBarRect viewport.Width viewport.Height + let slot = HudLayout.hotbarSlotRect viewport.Width viewport.Height 0 + printfn + "p43-shot=%s tick=%d calendar=%d,%d,%d,%d hotbar=%d,%d,%d,%d slot0=%d,%d,%d,%d selected=%d energy=%.0f day=%d season=%s period=%s clock=%s" + p43PendingName world.Tick cal.X cal.Y cal.Width cal.Height bar.X bar.Y bar.Width bar.Height + slot.X slot.Y slot.Width slot.Height hotbarSelected world.Avatar.Mind.Needs.Energy + reading.DayNumber reading.Season reading.Period reading.Clock + p43Step <- p43Step + 1 + /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = if not riverscapeTourWatchStarted then @@ -909,6 +957,17 @@ type LivingVillageGame() as this = printfn $"m5 panel={M5Interaction.panelName m5View} status={m5View.Status} tick={world.Tick}" | None -> () let panelOpen = m5View.Panel <> WorldPanel + // P43:游戏内按 1-0 直接选中 hotbar 槽位;仅当世界输入允许(无面板/对话)时生效, + // Esc 打开的菜单走 UpdateMenu 分支、根本不进这里,输入不穿透。 + if HudLayout.hotbarInputAllowed (menu.Page = Playing) (M5Interaction.worldInputAllowed m5View) then + let digitKeys = + [ 1, Keys.D1; 2, Keys.D2; 3, Keys.D3; 4, Keys.D4; 5, Keys.D5 + 6, Keys.D6; 7, Keys.D7; 8, Keys.D8; 9, Keys.D9; 0, Keys.D0 ] + for (digit, key) in digitKeys do + if pressed key then + match HudLayout.selectSlotForDigit digit with + | Some slot -> hotbarSelected <- slot + | None -> () if not relationView && not panelOpen then let input = if automated then @@ -1087,6 +1146,14 @@ type LivingVillageGame() as this = this.PrepareP40Shot() elif p37ShotMode then this.RunP37Shot() + elif p43ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP43Shot() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then @@ -1488,6 +1555,8 @@ type LivingVillageGame() as this = this.CaptureP39Shot() if p40ShotMode && menu.Page = Playing then this.CaptureP40Shot() + if p43ShotMode && menu.Page = Playing then + this.CaptureP43Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = @@ -1811,6 +1880,8 @@ type LivingVillageGame() as this = member private this.DrawM5Overlay() = this.DrawHudBar() + this.DrawCalendarCard() + this.DrawHotbar() if m5View.Panel = WorldPanel then this.DrawTransientStatus() else @@ -1849,13 +1920,106 @@ type LivingVillageGame() as this = spriteBatch.Draw(pixel, HudLayout.segmentBackdrop x width, palette.SegmentBackdrop) let textX = HudLayout.textOrigin x let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale + let isEnergy = segment.Icon = HudLayout.EnergyIcon for (dx, dy) in HudLayout.textOutlineOffsets do drawIcon (x + dx) (iconY + dy) palette.Outline segment.Icon PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Label - PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Value + if not isEnergy then + PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Value drawIcon x iconY palette.Value segment.Icon PixelText.draw spriteBatch pixel cjkAtlas textX textY HudLayout.textScale palette.Label segment.Label - PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value + if isEnergy then + // P43:体力改图形化条形,填充比例 = 真实 Needs.Energy / 100(0..100),非伪造。 + let valueX = textX + labelAdvanced + let valueWidth = max 12 (FloatingPrompt.measureWidth segment.Value HudLayout.textScale) + let barY = (barHeight - HudLayout.energyBarHeight) / 2 + let track = HudLayout.energyBarTrack valueX barY valueWidth + spriteBatch.Draw(pixel, track, palette.Outline) + spriteBatch.Draw( + pixel, + Rectangle(track.X + 1, track.Y + 1, max 1 (track.Width - 2), track.Height - 2), + palette.SegmentBackdrop) + let fill = + HudLayout.energyBarFillRect + (track.X + 1) + (track.Y + 1) + (max 1 (track.Width - 2)) + world.Avatar.Mind.Needs.Energy + if fill.Width > 0 then + let barColor = + match profile.Mode with + | Day -> Color(212, 168, 96) + | Night -> Color(120, 168, 196) + spriteBatch.Draw(pixel, fill, barColor) + else + PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value + spriteBatch.End() + + /// 按 7x7 点阵行用像素贴图绘制图标(无外部素材);调用方须已 Begin。 + member private this.DrawPattern (rows: string list) (iconX: int) (iconY: int) (scale: int) (color: Color) = + rows + |> List.iteri (fun row bits -> + bits + |> Seq.iteri (fun column bit -> + if bit = '1' then + spriteBatch.Draw(pixel, Rectangle(iconX + column * scale, iconY + row * scale, scale, scale), color))) + + /// P43:右上角日历钟卡(季节/第 N 天/时段/时钟),半透明深色底 + 细边框,绝不越界。 + member private this.DrawCalendarCard() = + let viewport = this.GraphicsDevice.Viewport + let profile = M6Presentation.profileAtTick world.Tick + let palette = HudLayout.palette profile.Mode + let reading = HudLayout.calendarReading world.Tick + let rect = HudLayout.calendarCardRect viewport.Width reading + let line1 = HudLayout.calendarLine1 reading + let line2 = HudLayout.calendarLine2 reading + spriteBatch.Begin() + spriteBatch.Draw(pixel, rect, palette.SegmentBackdrop) + spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, rect.Width, 2), palette.Accent) + spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Bottom - 2, rect.Width, 2), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, 2, rect.Height), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(rect.Right - 2, rect.Y, 2, rect.Height), palette.Outline) + let iconX = rect.X + HudLayout.calendarPadX + let iconY = rect.Y + (rect.Height - HudLayout.iconHeight) / 2 + this.DrawPattern (HudLayout.iconRows HudLayout.calendarIcon) iconX iconY HudLayout.iconScale palette.Value + let textX = iconX + HudLayout.iconWidth + HudLayout.iconGap + let line1Y = rect.Y + HudLayout.calendarPadY + let line2Y = line1Y + 8 * HudLayout.textScale + HudLayout.calendarLineGap + for (dx, dy) in HudLayout.textOutlineOffsets do + PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (line1Y + dy) HudLayout.textScale palette.Outline line1 + PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (line2Y + dy) HudLayout.textScale palette.Outline line2 + PixelText.draw spriteBatch pixel cjkAtlas textX line1Y HudLayout.textScale palette.Value line1 + PixelText.draw spriteBatch pixel cjkAtlas textX line2Y HudLayout.textScale palette.Accent line2 + spriteBatch.End() + + /// P43:底部居中 10 格 hotbar,原创点阵图标 + 选中高亮 + 键位数字;半透明底 + 细边框。 + member private this.DrawHotbar() = + let viewport = this.GraphicsDevice.Viewport + let profile = M6Presentation.profileAtTick world.Tick + let palette = HudLayout.palette profile.Mode + let bar = HudLayout.hotbarBarRect viewport.Width viewport.Height + spriteBatch.Begin() + spriteBatch.Draw(pixel, bar, palette.SegmentBackdrop) + spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, bar.Width, 2), palette.Accent) + spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Bottom - 2, bar.Width, 2), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, 2, bar.Height), palette.Outline) + spriteBatch.Draw(pixel, Rectangle(bar.Right - 2, bar.Y, 2, bar.Height), palette.Outline) + for index in 0 .. HudLayout.hotbarSlotCount - 1 do + let slot = HudLayout.hotbarSlotRect viewport.Width viewport.Height index + let selected = index = hotbarSelected + let slotBackdrop = + if selected then Color(48, 66, 74, 210) else palette.SegmentBackdrop + spriteBatch.Draw(pixel, slot, slotBackdrop) + let border = if selected then palette.Accent else palette.Outline + spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, slot.Width, 2), border) + spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Bottom - 2, slot.Width, 2), border) + spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, 2, slot.Height), border) + spriteBatch.Draw(pixel, Rectangle(slot.Right - 2, slot.Y, 2, slot.Height), border) + let iconRect = HudLayout.hotbarIconRect slot + let iconColor = if selected then palette.Accent else palette.Value + this.DrawPattern (HudLayout.hotbarIconRows index) iconRect.X iconRect.Y HudLayout.hotbarIconScale iconColor + let label = if index = 9 then "0" else string (index + 1) + PixelText.draw spriteBatch pixel cjkAtlas (slot.X + 3) (slot.Y + 2) 1 (if selected then palette.Accent else palette.Label) label spriteBatch.End() /// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。 diff --git a/src/LivingVillage.Desktop/HudLayout.fs b/src/LivingVillage.Desktop/HudLayout.fs index 05fce33..b19df4b 100644 --- a/src/LivingVillage.Desktop/HudLayout.fs +++ b/src/LivingVillage.Desktop/HudLayout.fs @@ -151,3 +151,131 @@ module HudLayout = Value = Color(228, 238, 246) SegmentBackdrop = Color(4, 8, 16, 160) Outline = Color(3, 6, 12, 255) } + + // ---- P43:右上日历钟卡(纯模型;季节/天数/时段/时钟全部由真实 tick 导出)---- + + type CalendarReading = + { Season: string + DayNumber: int64 + Period: string + Clock: string } + + /// 一季 28 模拟日,四季循环。纯演示层日历,只读 Tick,不写任何模拟状态。 + let daysPerSeason = 28L + let seasonNames = [| "春"; "夏"; "秋"; "冬" |] + + let dayIndexOf (tick: int64) : int64 = tick / Sim.ticksPerDay + let dayNumberOf (tick: int64) : int64 = dayIndexOf tick + 1L + + let seasonOf (tick: int64) : string = + let index = ((dayIndexOf tick / daysPerSeason) % 4L + 4L) % 4L + seasonNames.[int index] + + let calendarReading (tick: int64) : CalendarReading = + { Season = seasonOf tick + DayNumber = dayNumberOf tick + Period = M6Presentation.timePeriodLabel tick + Clock = M6Presentation.clockTimeLabel tick } + + let calendarLine1 (reading: CalendarReading) : string = + sprintf "%s · 第%d天" reading.Season reading.DayNumber + + let calendarLine2 (reading: CalendarReading) : string = + sprintf "%s %s" reading.Period reading.Clock + + let calendarMarginRight = 12 + let calendarMarginTop = 8 + let calendarPadX = 10 + let calendarPadY = 8 + let calendarLineGap = 4 + let calendarTextHeight = 8 * textScale + let calendarCardHeight = 2 * calendarTextHeight + calendarLineGap + 2 * calendarPadY + let calendarIcon = ClockIcon + + /// 右上角卡片矩形:宽度按两行文字实测宽度 + 图标 + 内边距,绝不越界、绝不压住顶部窄条。 + let calendarCardRect (viewportWidth: int) (reading: CalendarReading) : Rectangle = + let textWidth = + max + (FloatingPrompt.measureWidth (calendarLine1 reading) textScale) + (FloatingPrompt.measureWidth (calendarLine2 reading) textScale) + let width = iconWidth + iconGap + textWidth + 2 * calendarPadX + Rectangle( + viewportWidth - width - calendarMarginRight, + barHeight + accentHeight + calendarMarginTop, + width, + calendarCardHeight) + + // ---- P43:体力图形化条形(填充比例取真实 Needs.Energy,0..100)---- + + let energyBarHeight = 10 + + let energyBarFill (energy: float32) : float32 = + if energy < 0.0f then 0.0f + elif energy > 100.0f then 1.0f + else energy / 100.0f + + let energyBarTrack (x: int) (y: int) (width: int) : Rectangle = + Rectangle(x, y, width, energyBarHeight) + + let energyBarFillRect (x: int) (y: int) (width: int) (energy: float32) : Rectangle = + Rectangle(x, y, int (float32 width * energyBarFill energy), energyBarHeight) + + // ---- P43:底部 10 格 hotbar(原创点阵图标 + 键 1-0 选中 + 选中高亮)---- + + let hotbarSlotCount = 10 + let hotbarSlotSize = 40 + let hotbarSlotGap = 6 + let hotbarIconScale = 3 + let hotbarPad = 8 + let hotbarBottomMargin = 12 + let hotbarWidth = hotbarSlotCount * hotbarSlotSize + (hotbarSlotCount - 1) * hotbarSlotGap + + let hotbarBarRect (viewportWidth: int) (viewportHeight: int) : Rectangle = + Rectangle( + (viewportWidth - hotbarWidth) / 2 - hotbarPad, + viewportHeight - hotbarSlotSize - hotbarBottomMargin - hotbarPad, + hotbarWidth + 2 * hotbarPad, + hotbarSlotSize + 2 * hotbarPad) + + let hotbarSlotRect (viewportWidth: int) (viewportHeight: int) (index: int) : Rectangle = + let bar = hotbarBarRect viewportWidth viewportHeight + Rectangle( + bar.X + hotbarPad + index * (hotbarSlotSize + hotbarSlotGap), + bar.Y + hotbarPad, + hotbarSlotSize, + hotbarSlotSize) + + let hotbarIconRect (slot: Rectangle) : Rectangle = + let size = iconGrid * hotbarIconScale + Rectangle(slot.X + (slot.Width - size) / 2, slot.Y + (slot.Height - size) / 2, size, size) + + /// 10 个原创 7x7 点阵占位图标(0..9),仅用现有像素贴图绘制,无外部素材。 + let hotbarIconPatterns : string list list = + [ [ "0011100"; "0100010"; "1000001"; "1000001"; "1000001"; "0100010"; "0011100" ] + [ "0001000"; "0011000"; "0001000"; "0001000"; "0001000"; "0001000"; "0011100" ] + [ "0000000"; "0100010"; "1110111"; "1111111"; "0111110"; "0011100"; "0001000" ] + [ "0001000"; "0001000"; "0011100"; "0111110"; "1111111"; "1111111"; "0111110" ] + [ "0000000"; "0001000"; "0011100"; "1111110"; "0011100"; "0001000"; "0000000" ] + [ "0010100"; "0010100"; "0111110"; "0011100"; "0011100"; "0101010"; "0100010" ] + [ "0011100"; "0111110"; "1101011"; "1101011"; "1101011"; "0111110"; "0011100" ] + [ "0010001"; "0010010"; "0010100"; "0011000"; "0110000"; "0100000"; "1000000" ] + [ "0000100"; "0001100"; "0011110"; "0111100"; "0111000"; "0110000"; "0100000" ] + [ "0001000"; "0011100"; "0111110"; "1111111"; "0011100"; "0100010"; "1111111" ] ] + + let hotbarIconRows (index: int) : string list = + hotbarIconPatterns.[((index % hotbarSlotCount) + hotbarSlotCount) % hotbarSlotCount] + + /// 键位 1..9 -> 槽 0..8,键 0 -> 槽 9;其余键不选格。 + let selectSlotForDigit (digit: int) : int option = + if digit >= 1 && digit <= 9 then Some (digit - 1) + elif digit = 0 then Some 9 + else None + + /// 在 0..9 内循环移动选中格(供方向导航/测试),越界自动回绕。 + let cycleHotbar (current: int) (delta: int) : int = + let n = hotbarSlotCount + ((current + delta) % n + n) % n + + /// hotbar 输入仅在「游戏内(非菜单)」且世界输入允许(无面板/对话)时生效。 + let hotbarInputAllowed (menuPlaying: bool) (worldInputAllowed: bool) : bool = + menuPlaying && worldInputAllowed |
