namespace LivingVillage.Desktop module TitleScreen = type ColorRgb = { R: int G: int B: int } type TitleLayout = { SkyBands: ColorRgb list RoofTileSlots: int list RidgeY: int EaveY: int WaterRows: int list Lanterns: (int * int) list TitleText: string TitleX: int TitleY: int TitleScale: int MenuX: int MenuTopY: int MenuLineHeight: int PanelX: int PanelY: int PanelWidth: int PanelHeight: int HeadingY: int FirstItemY: int CursorX: int CursorWidth: int TextX: int ControlsPanelY: int ControlsRowHeight: int ControlsKeyX: int ControlsKeyWidth: int ControlsActionX: int } type TextRect = { X: int Y: int Width: int Height: int } let rectsDisjoint (a: TextRect) (b: TextRect) : bool = a.X + a.Width <= b.X || b.X + b.Width <= a.X || a.Y + a.Height <= b.Y || b.Y + b.Height <= a.Y /// Key-name and action rectangles for one controls-page row. The key column has a /// fixed width shared by every row, so all action texts start on the same x. let controlRowRects (layout: TitleLayout) (rowIndex: int) : TextRect * TextRect = let y = layout.ControlsPanelY + 60 + rowIndex * layout.ControlsRowHeight { X = layout.ControlsKeyX; Y = y; Width = layout.ControlsKeyWidth; Height = 16 }, { X = layout.ControlsActionX; Y = y; Width = layout.PanelX + layout.PanelWidth - layout.ControlsActionX - 24; Height = 16 } /// Cursor and label rectangles for one menu row. The cursor is drawn in its own /// column; the label always starts to the right of it, so they can never overprint. let selectionRects (layout: TitleLayout) (rowIndex: int) : TextRect * TextRect = let y = layout.FirstItemY + rowIndex * layout.MenuLineHeight { X = layout.CursorX; Y = y; Width = layout.CursorWidth; Height = 16 }, { X = layout.TextX; Y = y; Width = layout.PanelX + layout.PanelWidth - layout.TextX - 24; Height = 16 } // Palette mirrors the VillageArt atlas: roof (45,49,57)/(30,33,38), water (54,116,145), // lantern paper (212,92,74). Purely decorative reuse of existing art colors. let private skyBands : ColorRgb list = [ { R = 16; G = 26; B = 42 } { R = 20; G = 36; B = 52 } { R = 24; G = 46; B = 62 } { R = 28; G = 56; B = 68 } { R = 32; G = 64; B = 72 } ] let private roofRidgeSlot = 8 let private roofEaveSlot = 9 let private waterTileSlot = 1 let private lanternColor = { R = 212; G = 92; B = 74 } let private lanternHalo = { R = 252; G = 216; B = 148 } let lanternColors : ColorRgb * ColorRgb = lanternColor, lanternHalo let skyBandCount = skyBands.Length let waterSlot = waterTileSlot let roofSlots = roofRidgeSlot, roofEaveSlot /// Deterministic 2s entrance at the fixed 60Hz step: 1.0 = elements fully parked /// off-screen at start, 0.0 = settled. Pure function of the frame counter. let entranceProgress (frameCount: int64) : float32 = if frameCount >= 120L then 0.0f else 1.0f - float32 frameCount / 120.0f // ---- P42 environment FX: all pure functions of the frame counter, so re-rendering // the same frame is byte-identical. Shared by the splash and the start menu. ---- /// Sine ripple amplitude of the water band. Kept as a named constant so a test can /// assert it never regresses below the full-frame visibility budget (>= 0.25). let waterRippleAmplitude = 0.32f /// Water ripple phase: moves with the frame and is dense across tile columns /// (~5 tiles per wave), so adjacent 28px columns differ enough to read at 1x. let waterRipplePhase (frame: int64) (column: int) (rowIndex: int) : float32 = float32 (frame % 4096L) / 5.0f + float32 column * 1.25f + float32 rowIndex * 0.6f /// Water ripple brightness per tile column: base 1.0 +/- waterRippleAmplitude sine. let waterRippleBrightness (frame: int64) (column: int) (rowIndex: int) : float32 = 1.0f + waterRippleAmplitude * sin (waterRipplePhase frame column rowIndex) /// Water crest lift in whole pixels (-2..2) for a tile column; drives the moving /// ripple highlight without ever leaving a hole in the water band. let waterRippleLift (frame: int64) (column: int) (rowIndex: int) : int = int (System.Math.Round(float (sin (waterRipplePhase frame column rowIndex) * 2.0f), System.MidpointRounding.AwayFromZero)) /// True on columns sitting on a wave crest; the renderer caps these with a bright /// highlight and the frame analysis counts them. let waterRippleCrest (frame: int64) (column: int) (rowIndex: int) : bool = sin (waterRipplePhase frame column rowIndex) > 0.55f /// Eave-lantern breathing glow alpha, bounded to [40, 96]; phase moves with the /// frame and the lantern index so the bank lanterns breathe out of step. let lanternBreathAlpha (frame: int64) (index: int) : int = let phase = float32 (frame % 4096L) / 22.0f + float32 index * 1.3f 68 + int (System.Math.Round(float (sin phase * 28.0f), System.MidpointRounding.AwayFromZero)) let layout (viewportWidth: int) (viewportHeight: int) : TitleLayout = let width = max 320 viewportWidth let height = max 240 viewportHeight let tilePixels = 32 let columns = width / tilePixels let roofTileSlots = [ 0 .. columns - 1 ] |> List.map (fun column -> if column % 4 = 0 || column % 7 = 3 then roofRidgeSlot else roofEaveSlot) let waterRows = [ height - 96 height - 64 ] let lanternCount = max 3 (columns / 10) let lanterns = [ 1 .. lanternCount ] |> List.map (fun index -> let x = index * width / (lanternCount + 1) let y = (List.head waterRows) - 44 + (if index % 2 = 0 then 12 else 0) x, y) let panelWidth = min 460 (width - 64) { SkyBands = skyBands RoofTileSlots = roofTileSlots RidgeY = 44 EaveY = 76 WaterRows = waterRows Lanterns = lanterns TitleText = "江南水乡" TitleX = (width - 4 * (4 * 16)) / 2 TitleY = 120 TitleScale = 4 MenuX = (width - panelWidth) / 2 + 40 MenuTopY = height / 2 - 40 MenuLineHeight = 30 PanelX = (width - panelWidth) / 2 PanelY = height / 2 - 64 PanelWidth = panelWidth PanelHeight = min 360 (height - height / 2 - 40) HeadingY = height / 2 - 64 + 20 FirstItemY = height / 2 - 64 + 60 CursorX = (width - panelWidth) / 2 + 24 CursorWidth = 20 TextX = (width - panelWidth) / 2 + 24 + 20 + 12 ControlsPanelY = max 16 (height - 480) ControlsRowHeight = 25 ControlsKeyX = (width - panelWidth) / 2 + 24 ControlsKeyWidth = 120 ControlsActionX = (width - panelWidth) / 2 + 24 + 120 + 12 }