namespace LivingVillage.Desktop open Microsoft.Xna.Framework open LivingVillage.Kernel open LivingVillage.Kernel.Sim /// 顶部窄信息条 HUD 的纯布局/配色模型:只做确定性计算,实际绘制在 Game.fs。 /// 常驻状态(时段/时间/倍速/体力/钱币)压进一条窄带,绝不用整屏纯色或大黑块。 module HudLayout = let barHeight = 36 let accentHeight = 3 let paddingX = 16 let segmentGap = 20 let textScale = 2 /// 常驻段的像素图标(7x7 点阵,按 iconScale 放大),全部用 pixel 贴图绘制、无外部素材。 type HudIcon = | PeriodIcon | ClockIcon | SpeedIcon | EnergyIcon | MoneyIcon type Segment = { Label: string Value: string Icon: HudIcon } let segmentText (segment: Segment) : string = sprintf "%s %s" segment.Label segment.Value /// 常驻状态片段,数值全部取自世界真实状态,绝不编造。 let segments (world: World) (control: SimulationControl) : Segment list = let needs = world.Avatar.Mind.Needs [ { Label = "时段"; Value = M6Presentation.timePeriodLabel world.Tick; Icon = PeriodIcon } { Label = "时间"; Value = M6Presentation.clockTimeLabel world.Tick; Icon = ClockIcon } { Label = "倍速"; Value = M6Presentation.speedLabel control; Icon = SpeedIcon } { Label = "体力"; Value = sprintf "%.0f" needs.Energy; Icon = EnergyIcon } { Label = "钱币"; Value = sprintf "%.0f" needs.Money; Icon = MoneyIcon } ] // ---- 图标几何与点阵(纯数据/纯函数,Game 与回归测试共用)---- let iconGrid = 7 let iconScale = textScale let iconWidth = iconGrid * iconScale let iconHeight = iconGrid * iconScale let iconGap = 6 /// 7x7 点阵,'1' 为实心。全部原创点阵,仅用现有像素贴图绘制。 let iconRows (icon: HudIcon) : string list = match icon with | PeriodIcon -> [ "0001000" "1010101" "0011100" "0111110" "0011100" "1010101" "0001000" ] | ClockIcon -> [ "0011100" "0100010" "1001001" "1001000" "1001000" "0100010" "0011100" ] | SpeedIcon -> [ "0100010" "0110011" "0011011" "0001110" "0011011" "0110011" "0100010" ] | EnergyIcon -> [ "0000000" "0101010" "1111111" "1111111" "0111110" "0011100" "0001000" ] | MoneyIcon -> [ "0011100" "0111110" "1110111" "1100011" "1110111" "0111110" "0011100" ] /// 文本起始 x(图标占 iconWidth + iconGap)。 let textOrigin (segmentX: int) : int = segmentX + iconWidth + iconGap /// 段文本(含标签)的像素宽度。 let segmentWidth (segment: Segment) : int = FloatingPrompt.measureWidth (segmentText segment) textScale /// 段总宽(图标 + 间隙 + 文本)。 let segmentSpan (segment: Segment) : int = iconWidth + iconGap + segmentWidth segment /// 左对齐排布,返回 (起点 x, 总宽, 分段);段内 x 起为图标,textOrigin 起为文本。 /// 纯函数,便于布局回归断言。 let layout (segments: Segment list) : (int * int * Segment) list = let _, placed = segments |> List.fold (fun (x, acc) segment -> let width = segmentSpan segment x + width + segmentGap, (x, width, segment) :: acc) (paddingX, []) List.rev placed /// P26:常驻片段文本的 1px 描边偏移(上/下/左/右),保证白天/夜景下都可读。 let textOutlineOffsets : (int * int) list = [ (-1, 0); (1, 0); (0, -1); (0, 1) ] /// P26:常驻片段的半透明底色药丸,贴合文字高度并在左右各留 6px;纯函数便于回归。 let segmentBackdrop (x: int) (width: int) : Microsoft.Xna.Framework.Rectangle = let height = 8 * textScale + 10 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 Accent: Color Label: Color Value: Color SegmentBackdrop: Color Outline: Color } /// 白天暖色 / 夜晚(冬夜)冷色微调;半透明药丸承载信息,无整宽深色带、无大黑块。 /// P75 起 `BarTop/BarBottom` 保留字段但 alpha=0(不再铺整条底色)。 let palette (mode: M6DayNight) : Palette = match mode with | Day -> { 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, 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, 170) 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) // ---- P76:底部条/槽位药丸化(键位符号 + 图形并排,去大色块)---- /// 键位符号小片(数字 1..0)尺寸:放在槽位左侧,与图标并排。 let hotbarKeyChipWidth = 12 let hotbarKeyChipHeight = 12 /// 槽位内的键位小片矩形(左对齐、垂直居中)。 let hotbarKeyChipRect (slot: Rectangle) : Rectangle = Rectangle(slot.X + 3, slot.Y + (slot.Height - hotbarKeyChipHeight) / 2, hotbarKeyChipWidth, hotbarKeyChipHeight) /// 槽位内图形矩形(键位小片右侧,与图标并排)。 let hotbarIconRectBesideKey (slot: Rectangle) : Rectangle = let size = iconGrid * hotbarIconScale let x = (hotbarKeyChipRect slot).Right + 4 Rectangle(x, slot.Y + (slot.Height - size) / 2, size, size) /// 底部条顶部 1px 强调发丝线(与 P75 顶部条同语言)。 let hotbarBarHairline (bar: Rectangle) : Rectangle = Rectangle(bar.X + 1, bar.Y + 1, max 1 (bar.Width - 2), 1) // ---- P76:真实按键 + 动作名图例(纯展示,取自 MenuState.controlRows,不改任何绑定)---- /// 底部图例:真实存在的按键/动作对(subset of MenuState.controlRows 的顺序)。 /// 仅做信息层次展示,不新增/改动任何快捷键;hotbar 的 1-0 选中语义保持不变。 let actionLegendKeys : string list = [ "E"; "Q"; "TAB"; "C"; "L"; "F6"; "F7" ] let actionLegend (rows: ControlRow list) : ControlRow list = actionLegendKeys |> List.choose (fun key -> rows |> List.tryFind (fun row -> row.Keys = key)) /// 图例单条文本宽度(键位 + 空格 + 动作名)。 let legendEntryWidth (row: ControlRow) : int = FloatingPrompt.measureWidth (sprintf "%s %s" row.Keys row.Action) textScale /// 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 // ---- P76:面板文本按实测像素宽度折行(换行而非整行截断,中文逐字、拉丁逐字)---- let wrapText (maxWidth: int) (scale: int) (text: string) : string list = if text = "" then [ "" ] else let builder = System.Text.StringBuilder() let mutable lines = [] for ch in text.ToCharArray() do let candidate = builder.ToString() + string ch if builder.Length > 0 && FloatingPrompt.measureWidth candidate scale > maxWidth then lines <- builder.ToString() :: lines builder.Clear() |> ignore builder.Append(ch) |> ignore else builder.Append(ch) |> ignore lines <- builder.ToString() :: lines List.rev lines /// 把多行按宽度折行后铺平(不丢字符、不截断)。 let wrapLines (maxWidth: int) (scale: int) (lines: string list) : string list = lines |> List.collect (wrapText maxWidth scale)