summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 19:03:22 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 19:03:22 +0800
commit588560deda6b701a62653dff19079dc0a21fa944 (patch)
tree3d734aaf9ad546ec52a42af0a2c2635d2d26d670 /src
parent5c67e85688ff1e6150b03501adae1a5d5e67bbdc (diff)
downloadliving-village-588560deda6b701a62653dff19079dc0a21fa944.tar.gz
p43: HUD 星露谷化切片 — 右上日历钟卡/体力条形图/底部10格hotbar(1-0选中+高亮+输入隔离)
Diffstat (limited to 'src')
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P43HudTests.fs129
-rw-r--r--src/LivingVillage.Desktop/Game.fs168
-rw-r--r--src/LivingVillage.Desktop/HudLayout.fs128
4 files changed, 424 insertions, 2 deletions
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