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 /src | |
| parent | 5c67e85688ff1e6150b03501adae1a5d5e67bbdc (diff) | |
| download | living-village-588560deda6b701a62653dff19079dc0a21fa944.tar.gz | |
p43: HUD 星露谷化切片 — 右上日历钟卡/体力条形图/底部10格hotbar(1-0选中+高亮+输入隔离)
Diffstat (limited to 'src')
| -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 |
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 |
