namespace LivingVillage.Desktop open System open Microsoft.Xna.Framework open Microsoft.Xna.Framework.Graphics open Microsoft.Xna.Framework.Input open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop.ChineseText open LivingVillage.Desktop.VillagePresentation module PixelText = let private glyphs = [ 'A', [| "01110"; "10001"; "10001"; "11111"; "10001"; "10001"; "10001" |] 'B', [| "11110"; "10001"; "10001"; "11110"; "10001"; "10001"; "11110" |] 'C', [| "01111"; "10000"; "10000"; "10000"; "10000"; "10000"; "01111" |] 'D', [| "11110"; "10001"; "10001"; "10001"; "10001"; "10001"; "11110" |] 'E', [| "11111"; "10000"; "10000"; "11110"; "10000"; "10000"; "11111" |] 'F', [| "11111"; "10000"; "10000"; "11110"; "10000"; "10000"; "10000" |] 'G', [| "01111"; "10000"; "10000"; "10111"; "10001"; "10001"; "01111" |] 'H', [| "10001"; "10001"; "10001"; "11111"; "10001"; "10001"; "10001" |] 'I', [| "11111"; "00100"; "00100"; "00100"; "00100"; "00100"; "11111" |] 'J', [| "00111"; "00010"; "00010"; "00010"; "00010"; "10010"; "01100" |] 'K', [| "10001"; "10010"; "10100"; "11000"; "10100"; "10010"; "10001" |] 'L', [| "10000"; "10000"; "10000"; "10000"; "10000"; "10000"; "11111" |] 'M', [| "10001"; "11011"; "10101"; "10101"; "10001"; "10001"; "10001" |] 'N', [| "10001"; "11001"; "10101"; "10011"; "10001"; "10001"; "10001" |] 'O', [| "01110"; "10001"; "10001"; "10001"; "10001"; "10001"; "01110" |] 'P', [| "11110"; "10001"; "10001"; "11110"; "10000"; "10000"; "10000" |] 'Q', [| "01110"; "10001"; "10001"; "10001"; "10101"; "10010"; "01101" |] 'R', [| "11110"; "10001"; "10001"; "11110"; "10100"; "10010"; "10001" |] 'S', [| "01111"; "10000"; "10000"; "01110"; "00001"; "00001"; "11110" |] 'T', [| "11111"; "00100"; "00100"; "00100"; "00100"; "00100"; "00100" |] 'U', [| "10001"; "10001"; "10001"; "10001"; "10001"; "10001"; "01110" |] 'V', [| "10001"; "10001"; "10001"; "10001"; "10001"; "01010"; "00100" |] 'W', [| "10001"; "10001"; "10001"; "10101"; "10101"; "11011"; "10001" |] 'X', [| "10001"; "10001"; "01010"; "00100"; "01010"; "10001"; "10001" |] 'Y', [| "10001"; "10001"; "01010"; "00100"; "00100"; "00100"; "00100" |] 'Z', [| "11111"; "00001"; "00010"; "00100"; "01000"; "10000"; "11111" |] '0', [| "01110"; "10001"; "10011"; "10101"; "11001"; "10001"; "01110" |] '1', [| "00100"; "01100"; "00100"; "00100"; "00100"; "00100"; "01110" |] '2', [| "01110"; "10001"; "00001"; "00010"; "00100"; "01000"; "11111" |] '3', [| "11110"; "00001"; "00001"; "01110"; "00001"; "00001"; "11110" |] '4', [| "00010"; "00110"; "01010"; "10010"; "11111"; "00010"; "00010" |] '5', [| "11111"; "10000"; "10000"; "11110"; "00001"; "00001"; "11110" |] '6', [| "01110"; "10000"; "10000"; "11110"; "10001"; "10001"; "01110" |] '7', [| "11111"; "00001"; "00010"; "00100"; "01000"; "01000"; "01000" |] '8', [| "01110"; "10001"; "10001"; "01110"; "10001"; "10001"; "01110" |] '9', [| "01110"; "10001"; "10001"; "01111"; "00001"; "00001"; "01110" |] ('-', [| "00000"; "00000"; "00000"; "11111"; "00000"; "00000"; "00000" |]) ('.', [| "00000"; "00000"; "00000"; "00000"; "00000"; "00110"; "00110" |]) ('=', [| "00000"; "11111"; "00000"; "00000"; "11111"; "00000"; "00000" |]) (':', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00110"; "00000" |]) (';', [| "00000"; "00110"; "00110"; "00000"; "00110"; "00100"; "01000" |]) ('/', [| "00001"; "00010"; "00010"; "00100"; "01000"; "01000"; "10000" |]) ('>', [| "10000"; "01000"; "00100"; "00010"; "00100"; "01000"; "10000" |]) ('?', [| "01110"; "10001"; "00001"; "00010"; "00100"; "00000"; "00100" |]) ] |> Map.ofList let isRenderable (text: string) : bool = not (isNull text) && text |> Seq.forall (fun character -> character = ' ' || Map.containsKey (Char.ToUpperInvariant character) glyphs || CjkGlyphAtlas.contains character) let draw (spriteBatch: SpriteBatch) (pixel: Texture2D) (cjkAtlas: Texture2D) (x: int) (y: int) (scale: int) (color: Color) (text: string) = let mutable cursor = x for character in text.ToUpperInvariant() do if character = ' ' then cursor <- cursor + (4 * scale) elif CjkGlyphAtlas.sourceRectangle character |> Option.isSome then let source = CjkGlyphAtlas.sourceRectangle character |> Option.get let glyphPixels = 8 * scale spriteBatch.Draw( cjkAtlas, Rectangle(cursor, y, glyphPixels, glyphPixels), Nullable(source), color) cursor <- cursor + glyphPixels else match glyphs |> Map.tryFind character with | Some rows -> for row in 0 .. rows.Length - 1 do for column in 0 .. rows.[row].Length - 1 do if rows.[row].[column] = '1' then spriteBatch.Draw(pixel, Rectangle(cursor + column * scale, y + row * scale, scale, scale), color) cursor <- cursor + (6 * scale) | None -> // Unsupported characters are intentionally blank, never a fake glyph. cursor <- cursor + (6 * scale) type LivingVillageGame() as this = inherit Game() let autoplay = (Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1") || (Environment.GetEnvironmentVariable("LV_MAP_TOUR") = "1") let autoplayFlow = Environment.GetEnvironmentVariable("LV_AUTOPLAY_FLOW") = "1" let sampleMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_SAMPLE") = "1" let menuShotMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_MENU_SHOT") = "1" let daylightAutoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY_DAYLIGHT") = "1" let automated = autoplay || autoplayFlow let modeName = if sampleMode then "sample" elif autoplayFlow then "autoplay-flow" elif autoplay then "autoplay" else "keyboard" let configuredStepsPerFrame = match Environment.GetEnvironmentVariable("LV_STEPS_PER_FRAME") with | null | "" -> 1 | s -> match Int32.TryParse s with | true, n when n > 0 -> n | _ -> 1 let initialSimulationControl, initialLegacyStepsPerFrame = SimulationControl.initialForConfiguredSteps configuredStepsPerFrame let startSimulationControl = if sampleMode then SimulationControl.setSpeed TwoX initialSimulationControl else initialSimulationControl let savePath = match Environment.GetEnvironmentVariable("LV_SAVE_PATH") with | null | "" -> "living-village.save" | path -> path let graphics = new GraphicsDeviceManager(this) let mutable spriteBatch = Unchecked.defaultof let mutable artTextures = Unchecked.defaultof let mutable cjkAtlas = Unchecked.defaultof let mutable pixel = Unchecked.defaultof let renderPlan = VillageArt.sampleRenderPlan () let legacyMapMode = Environment.GetEnvironmentVariable("LV_LEGACY_MAP") = "1" let mapScale = match Environment.GetEnvironmentVariable("LV_MAP_SCALE") with | null | "" -> 1 | v -> match Int32.TryParse v with (true, value) -> max 1 value | _ -> 1 let mapTourMode = Environment.GetEnvironmentVariable("LV_MAP_TOUR") = "1" // P20 第三步:默认可玩世界走 256x192 生成器(核心可达区站位);LV_LEGACY_MAP=1 回退 64x48。 // 证据用 sample 流程(LV_AUTOPLAY_SAMPLE)保持旧 64x48 以确保既有录像可复现,除非显式请求巡游。 let riverscapeTourRequested = Environment.GetEnvironmentVariable("LV_RIVERSCAPE_TOUR") = "1" let riverscapeActive = not legacyMapMode && not mapTourMode && mapScale <= 1 && (not sampleMode || riverscapeTourRequested) let riverscapeMap : MapGen.Result option = if riverscapeActive then Sim.configureBounds 256 192 Some (MapGen.generateWithSize 256 192 42UL) else None let riverscapeTourMode = riverscapeTourRequested && riverscapeActive /// 巡游路点:第一座桥的整列(含两端外沿)+ 民居门 + 核心中心,用于桥面行走取证。 let riverscapeTourWaypoints () : (int * int) list = match riverscapeMap with | None -> [] | Some map -> let bridgeStops = map.Bridges |> List.groupBy fst |> List.sortBy fst |> List.truncate 1 |> List.collect (fun (x, entries) -> let ys = entries |> List.map snd |> List.sort [ (x, List.min ys - 2) ] @ [ for y in ys -> (x, y) ] @ [ (x, List.max ys + 2) ]) let doors = map.Buildings |> List.map (fun building -> (building.DoorX, building.DoorY)) let core = ((map.Core.MinX + map.Core.MaxX) / 2, (map.Core.MinY + map.Core.MaxY) / 2) (bridgeStops @ doors @ [ core ]) |> List.filter (fun (x, y) -> x >= 0 && x < map.Width && y >= 0 && y < map.Height) |> List.distinct // Evidence hook: pin the starting wall-clock hour (e.g. 20.75 for 黄昏), desktop-only. let startHourOverride = match Environment.GetEnvironmentVariable("LV_AUTOPLAY_START_HOUR") with | null | "" -> None | value -> match Double.TryParse(value, Globalization.NumberStyles.Float, Globalization.CultureInfo.InvariantCulture) with | true, hour -> Some hour | _ -> None // Single source of truth for the starting tick so the sample/autoplay path (which // re-creates the world via StartNewGame) honours the evidence hour too. let evidenceStartTick () = M6Presentation.resolveStartTick startHourOverride daylightAutoplay let withStartTick (world: World) : World = let tick = evidenceStartTick () { world with Tick = tick; Time = float tick * Sim.dtSeconds } let initialWorldForMode (occupation: Occupation.State option) : World = match riverscapeMap with | Some map -> let core = map.Core WorldBootstrap.initialWorldWithPlacement daylightAutoplay 42UL Sim.npcCount ((core.MinX + core.MaxX) / 2, (core.MinY + core.MaxY) / 2) map.Spawns occupation | None -> WorldBootstrap.initialWorldWithOccupation daylightAutoplay 42UL Sim.npcCount occupation let createInitialWorld () = initialWorldForMode None |> withStartTick let mutable world = createInitialWorld () let mutable camera = Vector2.Zero let mutable avatarFacing = VillageArt.SouthFacing let mutable avatarMoving = false let mutable fpsFrames = 0 let mutable fpsSeconds = 0.0 let mutable prevKb = Unchecked.defaultof let mutable relationView = false let mutable relationSnapshot: float32[,] option = None let mutable m5View = M5Interaction.initial let mutable simulationControl = startSimulationControl let mutable legacyStepsPerFrame = (if sampleMode then None else initialLegacyStepsPerFrame) let mutable menu = MenuState.create startSimulationControl false let mutable splashActive = true let mutable splashFrame = 0L let mutable menuEntranceFrame = 0L let mutable menuShotFrame = 0L let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 let mutable sampleState = SampleScript.initial let mutable hudClock = 0L let mutable hudStatus = "" let mutable hudStatusAt = 0L let mutable pauseHelpControl: SimulationControl option = None let recordMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_RECORD") = "1" let recordDirectory = match Environment.GetEnvironmentVariable("LV_RECORD_DIR") with | null | "" -> "/tmp/lv-p5-prompt" | v -> v let recordEvery = int ( match Environment.GetEnvironmentVariable("LV_RECORD_EVERY") with | null | "" -> 25 | v -> match Int32.TryParse v with (true, value) -> max 1 value | _ -> 25) let mutable recordIndex = 0 let recordName = match Environment.GetEnvironmentVariable("LV_RECORD_NAME") with | null | "" -> "rec-frame-" | v -> v let mutable tourWaypoints = [ (1, 1); (255, 191); (509, 381); (256, 60); (256, 300); (80, 200); (430, 300) ] let mutable tourIndex = 0 let mutable tourFrame = 0 // P20 证据钩子:LV_MAPGEN_SHOT=1 时用参数化生成器出整图 + 视口裁剪帧,拍完即退出。 let mapGenShotMode = Environment.GetEnvironmentVariable("LV_MAPGEN_SHOT") = "1" let mapGenWidth = match Environment.GetEnvironmentVariable("LV_MAPGEN_WIDTH") with | null | "" -> 256 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 256 let mapGenHeight = match Environment.GetEnvironmentVariable("LV_MAPGEN_HEIGHT") with | null | "" -> 192 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 192 let mapGenSeed = match Environment.GetEnvironmentVariable("LV_MAPGEN_SEED") with | null | "" -> 4242UL | v -> match UInt64.TryParse v with (true, value) -> value | _ -> 4242UL let mutable mapGenResult = Unchecked.defaultof let mutable mapGenGenMs = 0.0 do if mapScale > 1 then Sim.configureBounds (64 * mapScale) (48 * mapScale) ProceduralMap.activate (uint64 4242) if mapGenShotMode then let stopwatch = System.Diagnostics.Stopwatch.StartNew() mapGenResult <- MapGen.generateWithSize mapGenWidth mapGenHeight mapGenSeed stopwatch.Stop() mapGenGenMs <- stopwatch.Elapsed.TotalMilliseconds printfn "mapgen-gen size=%dx%d seed=%d gen_ms=%.2f tiles=%d reachable_ok=%b reachable_tiles=%d spawns=%d" mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed) mapGenGenMs mapGenResult.Tiles.Length mapGenResult.ReachabilityOk mapGenResult.ReachableTiles mapGenResult.Spawns.Length if mapTourMode then tourWaypoints <- [ (1, 1); (Sim.mapWidthTiles / 2, Sim.mapHeightTiles / 2); (Sim.mapWidthTiles - 3, Sim.mapHeightTiles - 3) (Sim.mapWidthTiles / 2, 60); (Sim.mapWidthTiles / 2, Sim.mapHeightTiles - 60) (80, 200); (430, 300) ] if riverscapeTourMode then tourWaypoints <- riverscapeTourWaypoints () graphics.PreferredBackBufferWidth <- 1280 graphics.PreferredBackBufferHeight <- 720 graphics.SynchronizeWithVerticalRetrace <- true this.IsFixedTimeStep <- true this.TargetElapsedTime <- TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60L) this.Window.Title <- ChineseText.copy.SceneTitle printfn "world-mode=%s bounds=%dx%d" (if riverscapeActive then "riverscape" elif mapScale > 1 then "map-scale" else "legacy-64x48") Sim.mapWidthTiles Sim.mapHeightTiles printfn $"mode={modeName} seed=42" printfn "controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations P pause F1/F2/F3 speed F6 save F7 load Esc close/exit" member private this.CenterCamera() = let viewport = this.GraphicsDevice.Viewport let vw = float32 viewport.Width let vh = float32 viewport.Height let worldW = float32 (Sim.mapWidthTiles * Sim.tilePixels) let worldH = float32 (Sim.mapHeightTiles * Sim.tilePixels) let half = float32 Sim.tilePixels / 2.0f let x = MathHelper.Clamp(world.Avatar.Pos.X + half - vw / 2.0f, 0.0f, max 0.0f (worldW - vw)) let y = MathHelper.Clamp(world.Avatar.Pos.Y + half - vh / 2.0f, 0.0f, max 0.0f (worldH - vh)) camera <- Vector2(x, y) member private this.ResetWorldView() = relationView <- false relationSnapshot <- None avatarFacing <- VillageArt.SouthFacing avatarMoving <- false m5View <- M5Interaction.refreshPrompt world M5Interaction.initial this.CenterCamera() member private this.StartNewGame (occupation: Occupation.Kind option) = let startTick = evidenceStartTick () let state = occupation |> Option.map (fun kind -> WorldBootstrap.occupationStateFor 42UL kind startTick) world <- initialWorldForMode state |> withStartTick this.ResetWorldView() m5View <- { m5View with Task = state } menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame let occupationName = occupation |> Option.map Occupation.nameOf |> Option.defaultValue "none" printfn $"new-game=ok seed=42 npcs={Sim.npcCount} occupation={occupationName} tick={world.Tick}" member private this.SaveWorld (prefix: string) : bool = try WorldSave.saveToFileWith m5View.Task savePath world m5View <- { m5View with Status = "save succeeded"; StatusTick = Some world.Tick } printfn $"{prefix}=ok path={savePath} tick={world.Tick}" true with | ex -> m5View <- { m5View with Status = "save failed"; StatusTick = Some world.Tick } printfn $"{prefix}=error path={savePath} message={ex.Message}" false member private this.LoadWorld (prefix: string) : Result = match WorldSave.loadFromFileWith savePath with | Ok (nextWorld, occupation) -> world <- nextWorld this.ResetWorldView() m5View <- { m5View with Task = occupation } printfn $"{prefix}=ok path={savePath} tick={world.Tick}" Ok world.Tick | Error failure -> printfn $"{prefix}=error path={savePath} message={failure}" Error failure member private this.OpenMainMenu() = relationView <- false relationSnapshot <- None m5View <- M5Interaction.initial menu <- menu |> MenuState.setCurrentWorld true |> MenuState.openMain printfn $"menu=open tick={world.Tick}" member private this.ApplyMenuCommand (command: MenuCommand) = match command with | NoCommand -> () | ContinueGame -> menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame printfn $"continue=ok tick={world.Tick}" | StartNewGame -> menu <- MenuState.openOccupationSelect menu printfn "menu=occupation-select" | StartNewGameWith occupation -> this.StartNewGame occupation | LoadGame -> match this.LoadWorld "load" with | Ok _ -> menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame | Error failure -> menu <- menu |> MenuState.showLoadError failure | ApplySettings nextControl -> simulationControl <- nextControl legacyStepsPerFrame <- None menu <- menu |> MenuState.setSettings nextControl printfn $"simulation={M6Presentation.clockLabel simulationControl}" | ExitGame -> printfn "menu=exit" this.Exit() member private this.DispatchMenuInput (input: MenuInput) = let nextMenu, command = MenuState.update input menu menu <- nextMenu this.ApplyMenuCommand command member private this.UpdateMenu (pressed: Keys -> bool) = let pressedAny (keys: Keys list) = keys |> List.exists pressed let input = if pressedAny [ Keys.Up; Keys.W ] then Some Up elif pressedAny [ Keys.Down; Keys.S ] then Some Down elif pressed Keys.Enter then Some Confirm elif pressed Keys.Escape then Some Back else None match input with | Some nextInput -> this.DispatchMenuInput nextInput | None when (autoplay || sampleMode) && not autoplayFlow && (menu.Page = MainMenu || menu.Page = OccupationSelect) && not menu.HasCurrentWorld -> autoplayFrames <- autoplayFrames + 1 if autoplayFrames >= 15 then this.DispatchMenuInput Confirm | None -> () member private this.RunSampleMode () = let stageBefore = sampleState.Stage let positionBefore = world.Avatar.Pos let nextWorld, nextView, nextState = SampleScript.runFrame world m5View sampleState if nextWorld.Avatar.Pos <> positionBefore then avatarFacing <- VillageArt.facingAfterMovement positionBefore nextWorld.Avatar.Pos avatarFacing avatarMoving <- nextWorld.Avatar.Pos <> positionBefore world <- nextWorld m5View <- nextView sampleState <- nextState this.CenterCamera() if nextState.Stage <> stageBefore then printfn $"sample stage={nextState.Stage} tick={nextWorld.Tick} pos=({nextWorld.Avatar.Pos.X:F0},{nextWorld.Avatar.Pos.Y:F0})" if nextState.Stage = SampleScript.SampleComplete then printfn (if nextState.TimedOut then "sample result=timeout" else "sample result=ok") this.Exit() /// P16-FIX:菜单截图模式。停在开始界面,按确定性帧表向下切到「读档」,帧表见 MenuShotScript。 member private this.RunMenuShot () = menuShotFrame <- menuShotFrame + 1L if menuShotFrame = MenuShotScript.downFrame then this.DispatchMenuInput Down /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = let target = tourWaypoints.[tourIndex % tourWaypoints.Length] let targetPos : Vec2 = { X = float32 (fst target * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (snd target * Sim.tilePixels + Sim.tilePixels / 2) } let dx = targetPos.X - world.Avatar.Pos.X let dy = targetPos.Y - world.Avatar.Pos.Y let distance = sqrt (dx * dx + dy * dy) if distance < 3.0f then tourIndex <- tourIndex + 1 if tourIndex >= tourWaypoints.Length then printfn "riverscape tour=complete frames=%d" recordIndex this.Exit() else let inv = 1.0f / distance let input = { Input = { MoveX = dx * inv; MoveY = dy * inv } } let before = world.Avatar.Pos world <- Sim.step input world avatarFacing <- VillageArt.facingAfterMovement before world.Avatar.Pos avatarFacing avatarMoving <- distance > 4.0f m5View <- M5Interaction.refreshPrompt world m5View this.CenterCamera() tourFrame <- tourFrame + 1 member private this.RunMapTour () = let (tileX, tileY) = tourWaypoints.[tourIndex % tourWaypoints.Length] let position : Vec2 = { X = float32 (tileX * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (tileY * Sim.tilePixels + Sim.tilePixels / 2) } world <- { world with Avatar = { world.Avatar with Pos = position } } // give NPCs active ticks so the town stays alive between waypoints for _ in 0 .. 7 do world <- Sim.step { Input = { MoveX = 0.0f; MoveY = 0.0f } } world avatarFacing <- VillageArt.SouthFacing avatarMoving <- false m5View <- M5Interaction.refreshPrompt world m5View this.CenterCamera() tourFrame <- tourFrame + 1 if tourFrame >= 45 then tourFrame <- 0 tourIndex <- tourIndex + 1 if tourIndex >= tourWaypoints.Length then printfn "map tour=complete frames={recordIndex}" this.Exit() else printfn $"map tour={tourIndex} at tile=({tileX},{tileY})" member private this.UpdatePlaying (gameTime: GameTime) (kb: KeyboardState) (pressed: Keys -> bool) (pressedAny: Keys list -> bool) = if riverscapeTourMode then this.RunRiverscapeTour() elif mapTourMode then this.RunMapTour() elif sampleMode then this.RunSampleMode() else this.UpdatePlayingManual gameTime kb pressed pressedAny member private this.UpdatePlayingManual (gameTime: GameTime) (kb: KeyboardState) (pressed: Keys -> bool) (pressedAny: Keys list -> bool) = let mutable loadFailed = false let mutable leftGame = false let lPressed = pressed Keys.L let escapePressed = pressed Keys.Escape if pressed Keys.P then if m5View.Panel = WorldPanel then let helpState, pausedControl = MenuState.enterPauseHelp simulationControl menu menu <- helpState pauseHelpControl <- Some simulationControl simulationControl <- pausedControl printfn $"help=pause-open simulation={M6Presentation.clockLabel simulationControl}" if pressed Keys.F1 then simulationControl <- SimulationControl.setSpeed OneX simulationControl legacyStepsPerFrame <- None menu <- MenuState.setSettings simulationControl menu printfn $"simulation={M6Presentation.clockLabel simulationControl}" if pressed Keys.F2 then simulationControl <- SimulationControl.setSpeed TwoX simulationControl legacyStepsPerFrame <- None menu <- MenuState.setSettings simulationControl menu printfn $"simulation={M6Presentation.clockLabel simulationControl}" if pressed Keys.F3 then simulationControl <- SimulationControl.setSpeed FiveX simulationControl legacyStepsPerFrame <- None menu <- MenuState.setSettings simulationControl menu printfn $"simulation={M6Presentation.clockLabel simulationControl}" if pressed Keys.F6 then this.SaveWorld "save" |> ignore if pressed Keys.F7 then match this.LoadWorld "load" with | Ok _ -> menu <- MenuState.setCurrentWorld true menu | Error failure -> menu <- menu |> MenuState.setCurrentWorld true |> MenuState.showLoadError failure loadFailed <- true if not loadFailed && escapePressed then if m5View.Panel <> WorldPanel then let nextWorld, nextView = M5Interaction.apply ClosePanel world m5View world <- nextWorld m5View <- nextView printfn $"m5 panel={M5Interaction.panelName m5View} status={m5View.Status} tick={world.Tick}" elif relationView then relationView <- false relationSnapshot <- None printfn $"relation view=off tick={world.Tick}" else this.OpenMainMenu() leftGame <- true if not loadFailed && not leftGame then if lPressed && M5Interaction.worldInputAllowed m5View then relationView <- not relationView if relationView then relationSnapshot <- Some(Sim.relationMatrix world) let state = if relationView then "on" else "off" printfn $"relation view={state} tick={world.Tick}" let m5Command = if M5Interaction.worldInputAllowed m5View && pressed Keys.E then Some Interact elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 elif m5View.Panel = DialoguePanel && pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 elif pressed Keys.Q then Some Observe elif pressed Keys.Tab then Some ToggleNeeds elif pressed Keys.C then Some ShowChronicle elif pressed Keys.T then Some ShowTaskPanel else None match m5Command with | Some command -> let nextWorld, nextView = M5Interaction.apply command world m5View world <- nextWorld m5View <- nextView printfn $"m5 panel={M5Interaction.panelName m5View} status={m5View.Status} tick={world.Tick}" | None -> () let panelOpen = m5View.Panel <> WorldPanel if not relationView && not panelOpen then let input = if automated then let t = float32 world.Tick { MoveX = MathF.Sin(t / 60.0f) * 0.8f MoveY = MathF.Cos(t / 90.0f) * 0.6f } else { MoveX = (if kb.IsKeyDown(Keys.D) then 1.0f elif kb.IsKeyDown(Keys.A) then -1.0f else 0.0f) MoveY = (if kb.IsKeyDown(Keys.S) then 1.0f elif kb.IsKeyDown(Keys.W) then -1.0f else 0.0f) } let ts = { Input = input } let stepCount = SimulationControl.stepsPerFrameWithLegacy legacyStepsPerFrame simulationControl if stepCount > 0 then let mutable moved = false for _ in 1 .. stepCount do let previousPosition = world.Avatar.Pos world <- Sim.step ts world avatarFacing <- VillageArt.facingAfterMovement previousPosition world.Avatar.Pos avatarFacing moved <- moved || world.Avatar.Pos <> previousPosition avatarMoving <- moved else avatarMoving <- false this.CenterCamera() match m5View.Task with | Some state -> let refreshed = WorldBootstrap.refreshOccupationToday 42UL state world.Tick if refreshed <> state then m5View <- { m5View with Task = Some refreshed } | None -> () m5View <- M5Interaction.refreshPrompt world m5View member private this.RunAutoplayFlow() = let moveDown count = for _ in 1 .. count do this.DispatchMenuInput Down match flowStep with | 0 -> printfn "flow=main-menu" flowStep <- 50 | 50 when menu.Page = MainMenu -> flowHold <- flowHold + 1 if flowHold >= 60 then flowHold <- 0 this.DispatchMenuInput Confirm printfn "flow=new-game" flowStep <- 1 | 50 -> printfn "flow=new-game" flowStep <- 1 | 1 when menu.Page = OccupationSelect -> this.DispatchMenuInput Confirm printfn "flow=occupation=none" | 1 when menu.Page = Playing -> this.OpenMainMenu() printfn "flow=return-menu" flowStep <- 2 | 2 when menu.Page = MainMenu -> moveDown 1 this.DispatchMenuInput Confirm printfn "flow=load" flowStep <- 3 | 3 -> this.DispatchMenuInput Back printfn "flow=back" flowStep <- 4 | 4 when menu.Page = MainMenu -> this.SaveWorld "flow=save" |> ignore flowStep <- 5 | 5 when menu.Page = MainMenu -> moveDown 1 this.DispatchMenuInput Confirm printfn "flow=load-again" flowStep <- 6 | 6 -> this.OpenMainMenu() printfn "flow=exit" this.Exit() flowStep <- 7 | _ -> () override this.Initialize() = base.Initialize() prevKb <- Keyboard.GetState() override _.LoadContent() = spriteBatch <- new SpriteBatch(this.GraphicsDevice) artTextures <- VillageArt.loadTextures this.GraphicsDevice cjkAtlas <- CjkGlyphAtlas.load this.GraphicsDevice pixel <- new Texture2D(this.GraphicsDevice, 1, 1) pixel.SetData([| Color.White |]) override _.UnloadContent() = if not (isNull artTextures.TileAtlas) then artTextures.TileAtlas.Dispose() if not (isNull artTextures.CharacterAtlas) then artTextures.CharacterAtlas.Dispose() if not (isNull artTextures.InteriorAtlas) then artTextures.InteriorAtlas.Dispose() if not (isNull cjkAtlas) then cjkAtlas.Dispose() if not (isNull pixel) then pixel.Dispose() if not (isNull spriteBatch) then spriteBatch.Dispose() override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) let pressedAny (keys: Keys list) = keys |> List.exists pressed if splashActive then splashFrame <- splashFrame + 1L let skip = LaunchScreen.skipRequested (pressed Keys.Escape) (pressed Keys.Space) if skip || LaunchScreen.isComplete splashFrame then splashActive <- false menuEntranceFrame <- 0L printfn (if skip then "splash=skip frame=%d" else "splash=done frame=%d") splashFrame else if autoplayFlow then this.RunAutoplayFlow() elif menu.Page = Playing then this.UpdatePlaying gameTime kb pressed pressedAny elif menuShotMode then this.RunMenuShot() else this.UpdateMenu pressed if menu.Page <> Playing then menuEntranceFrame <- menuEntranceFrame + 1L match pauseHelpControl with | Some original when menu.Page = Playing -> let _, restoredControl = MenuState.exitPauseHelp original menu simulationControl <- restoredControl pauseHelpControl <- None printfn $"help=pause-close simulation={M6Presentation.clockLabel simulationControl}" | _ -> () hudClock <- hudClock + 1L if m5View.Status <> hudStatus then hudStatus <- m5View.Status hudStatusAt <- hudClock fpsFrames <- fpsFrames + 1 fpsSeconds <- fpsSeconds + gameTime.ElapsedGameTime.TotalSeconds if fpsSeconds >= 1.0 then let fps = float fpsFrames / fpsSeconds fpsFrames <- 0 fpsSeconds <- 0.0 printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" prevKb <- kb member private this.DrawLine (a: Vector2) (b: Vector2) (thickness: float32) (color: Color) = let d = b - a let len = d.Length() if len > 1.0f then let angle = MathF.Atan2(d.Y, d.X) spriteBatch.Draw(pixel, a, System.Nullable(), color, angle, Vector2.Zero, Vector2(len, thickness), SpriteEffects.None, 0.0f) member private this.DrawRelationView() = let profile = M6Presentation.profileAtTick world.Tick this.GraphicsDevice.Clear(profile.Background) let viewport = this.GraphicsDevice.Viewport let vw = float32 viewport.Width let vh = float32 viewport.Height let mapW = float32 (Sim.mapWidthTiles * Sim.tilePixels) let mapH = float32 (Sim.mapHeightTiles * Sim.tilePixels) let margin = 48.0f let scale = min ((vw - 2.0f * margin) / mapW) ((vh - 2.0f * margin) / mapH) let ox = (vw - mapW * scale) / 2.0f let oy = (vh - mapH * scale) / 2.0f let toScreen (p: Vec2) : Vector2 = Vector2(ox + p.X * scale, oy + p.Y * scale) spriteBatch.Begin() match relationSnapshot with | Some m -> let n = Array2D.length1 m for i in 0 .. n - 1 do for j in i + 1 .. n - 1 do let r = m.[i, j] if abs r > Sim.relationThreshold then let a = toScreen world.Npcs.[i].Pos let b = toScreen world.Npcs.[j].Pos let color = if r > 0.0f then Color.LimeGreen else Color.IndianRed let thickness = Sim.clamp (1.5f + 3.0f * abs r) 1.5f 12.0f this.DrawLine a b thickness color for npc in world.Npcs do let p = toScreen npc.Pos spriteBatch.Draw(pixel, Rectangle(int p.X - 5, int p.Y - 5, 10, 10), Color.Cyan) | None -> () spriteBatch.End() /// 将当前后台缓冲存为 PNG(取证用;record 与菜单截图共用)。 member private this.SaveBackBuffer (path: string) = let width = this.GraphicsDevice.PresentationParameters.BackBufferWidth let height = this.GraphicsDevice.PresentationParameters.BackBufferHeight let buffer = Array.zeroCreate (width * height) this.GraphicsDevice.GetBackBufferData(buffer) use texture = new Texture2D(this.GraphicsDevice, width, height) texture.SetData(buffer) use stream = System.IO.File.Create(path) texture.SaveAsPng(stream, width, height) /// P16-FIX:按 MenuShotScript 帧表落菜单/启动画面截图,拍完即退出。 member private this.CaptureMenuShot () = let save name = System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name) if splashActive then if splashFrame = MenuShotScript.splashMidFrame then save MenuShotScript.splashMidName elif menuShotFrame = MenuShotScript.initialFrame then save MenuShotScript.initialName elif menuShotFrame = MenuShotScript.loadFrame then save MenuShotScript.loadName elif menuShotFrame = MenuShotScript.exitFrame then printfn "menu-shot=done frames=%d" menuShotFrame this.Exit() /// P20 证据:把一次 MapGen 绘制渲染到指定尺寸离屏 target,返回绘制瓦片数与纹理。 member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) : int * Texture2D = use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight) this.GraphicsDevice.SetRenderTarget target this.GraphicsDevice.Clear(Color(18, 26, 20)) spriteBatch.Begin() let drawn = if drawFitted then VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult Color.White else VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L Color.White spriteBatch.End() this.GraphicsDevice.SetRenderTarget null let data = Array.zeroCreate (targetWidth * targetHeight) target.GetData(data) let texture = new Texture2D(this.GraphicsDevice, targetWidth, targetHeight) texture.SetData(data) (drawn, texture) /// P20 证据:整图放大渲染(每瓦片 6px)+ 1:1 视口裁剪帧各存一张,打印计数后退出。 member private this.DrawMapGenShot () = System.IO.Directory.CreateDirectory recordDirectory |> ignore let viewport = this.GraphicsDevice.Viewport let mapCell = 6 let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true use _fitted = fittedTexture use stream = System.IO.File.Create(sprintf "%s/p20b-map-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) fittedTexture.SaveAsPng(stream, fittedTexture.Width, fittedTexture.Height) printfn "mapgen-fitted drawn=%d png=%dx%d" fittedDrawn fittedTexture.Width fittedTexture.Height let clampRange maximum value = max 0 (min (max 0 maximum) value) let camX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) ((mapGenResult.Width * Sim.tilePixels - viewport.Width) / 2) let camY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) ((mapGenResult.Height * Sim.tilePixels - viewport.Height) / 2) let culledDrawn, culledTexture = this.RenderMapGen viewport.Width viewport.Height camX camY false use _culled = culledTexture use culledStream = System.IO.File.Create(sprintf "%s/p20b-culled-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) culledTexture.SaveAsPng(culledStream, viewport.Width, viewport.Height) let total = mapGenResult.Width * mapGenResult.Height let visible = MapGen.visibleTileCount mapGenResult.Width mapGenResult.Height camX camY viewport.Width viewport.Height printfn "mapgen-cull drawn=%d visible=%d total=%d culled=%.1f%%" culledDrawn visible total (100.0 * (1.0 - float culledDrawn / float total)) match mapGenResult.Bridges with | (bx, by) :: _ -> let detailCamX = clampRange (mapGenResult.Width * Sim.tilePixels - viewport.Width) (bx * Sim.tilePixels - viewport.Width / 2) let detailCamY = clampRange (mapGenResult.Height * Sim.tilePixels - viewport.Height) (by * Sim.tilePixels - viewport.Height / 2) let detailDrawn, detailTexture = this.RenderMapGen viewport.Width viewport.Height detailCamX detailCamY false use _detail = detailTexture use detailStream = System.IO.File.Create(sprintf "%s/p20b-detail-%dx%d-seed%d.png" recordDirectory mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) detailTexture.SaveAsPng(detailStream, detailTexture.Width, detailTexture.Height) printfn "mapgen-detail drawn=%d bridge=(%d,%d) camera=(%d,%d)" detailDrawn bx by detailCamX detailCamY | [] -> () let smallWatch = System.Diagnostics.Stopwatch.StartNew() let smallMap = MapGen.generateWithSize 64 48 (uint64 4242) smallWatch.Stop() printfn "mapgen-gen-legacy size=%dx%d gen_ms=%.2f tiles=%d reachable_ok=%b" smallMap.Width smallMap.Height smallWatch.Elapsed.TotalMilliseconds smallMap.Tiles.Length smallMap.ReachabilityOk let frameWatch = System.Diagnostics.Stopwatch.StartNew() use frameTarget = new RenderTarget2D(this.GraphicsDevice, viewport.Width, viewport.Height) this.GraphicsDevice.SetRenderTarget frameTarget for _ in 1 .. 30 do spriteBatch.Begin() VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) viewport.Width viewport.Height mapGenResult 0L Color.White |> ignore spriteBatch.End() this.GraphicsDevice.SetRenderTarget null frameWatch.Stop() let perFrameMs = frameWatch.Elapsed.TotalMilliseconds / 30.0 printfn "mapgen-frame avg_ms=%.2f implied_fps=%.1f iterations=30" perFrameMs (1000.0 / perFrameMs) printfn "mapgen-shot=done" this.Exit() override this.Draw(gameTime: GameTime) = if mapGenShotMode then this.DrawMapGenShot() else if splashActive then this.DrawSplash() elif menu.Page <> Playing then this.DrawMenu() elif relationView then this.DrawRelationView() this.DrawM5Overlay() else this.DrawWorldView() if recordMode && sampleMode then recordIndex <- recordIndex + 1 if recordIndex % recordEvery = 0 then System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s%04d.png" recordDirectory recordName recordIndex) if menuShotMode then this.CaptureMenuShot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 member private this.DrawSplash() = let viewport = this.GraphicsDevice.Viewport let width = viewport.Width let height = viewport.Height let layout = TitleScreen.layout width height let reveal = LaunchScreen.revealProgress splashFrame let veil = LaunchScreen.veilAlpha splashFrame let alpha (value: int) = int (float32 value * reveal) spriteBatch.Begin() // 分层天色:沿用标题画面色带,绝不整屏纯色或大黑块。 let bandCount = List.length layout.SkyBands let bandHeight = height / bandCount List.iteri (fun index (band: TitleScreen.ColorRgb) -> let y0 = index * bandHeight let y1 = if index = bandCount - 1 then height else y0 + bandHeight spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands // 屋脊剪影随暗幕退场而上浮就位。 let drop = int (28.0f * veil) for x in 0 .. Sim.tilePixels .. width - 1 do spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.RidgeY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (fst TitleScreen.roofSlots), Color.White) spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + drop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White) // 水面色带反射天色,随显现度提高而淡出。 let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - reveal))) for row in layout.WaterRows do for x in 0 .. Sim.tilePixels .. width - 1 do spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor) // 标题:淡入 + 上浮,先画深色投影再画亮面。 let titleRise = int (24.0f * (1.0f - reveal)) PixelText.draw spriteBatch pixel cjkAtlas (layout.TitleX + 4) (layout.TitleY + titleRise + 4) layout.TitleScale (Color(12, 20, 28, alpha 255)) LaunchScreen.title PixelText.draw spriteBatch pixel cjkAtlas layout.TitleX (layout.TitleY + titleRise) layout.TitleScale (Color(235, 246, 232, alpha 255)) LaunchScreen.title // 英文副标题保持同一 CJK/点阵字体路径。 let subtitleScale = 2 let subtitleWidth = FloatingPrompt.measureWidth LaunchScreen.subtitle subtitleScale PixelText.draw spriteBatch pixel cjkAtlas ((width - subtitleWidth) / 2) (layout.TitleY + 78) subtitleScale (Color(180, 210, 214, alpha 255)) LaunchScreen.subtitle // 跳过提示:轻微呼吸闪烁,落在底部独立窄条上(非整屏黑块)。 let blink = if (splashFrame / 20L) % 2L = 0L then 220 else 130 let hintScale = 2 let hintWidth = FloatingPrompt.measureWidth LaunchScreen.skipHint hintScale let hintX = (width - hintWidth) / 2 let hintY = height - 56 spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 8 * hintScale + 16), Color(10, 16, 24, int (170.0f * (0.5f + 0.5f * reveal)))) spriteBatch.Draw(pixel, Rectangle(hintX - 10, hintY - 8, hintWidth + 20, 2), Color(92, 180, 190, alpha 220)) PixelText.draw spriteBatch pixel cjkAtlas hintX hintY hintScale (Color(220, 235, 225, alpha blink)) LaunchScreen.skipHint spriteBatch.End() member private this.DrawMenu() = let viewport = this.GraphicsDevice.Viewport let width = viewport.Width let height = viewport.Height let layout = TitleScreen.layout width height let panelWidth = layout.PanelWidth let panelX = layout.PanelX let panelY = layout.PanelY let contentRows = match menu.Page with | MainMenu -> MenuState.items menu |> List.length | OccupationSelect -> MenuState.occupationOptions.Length | Settings -> MenuState.settings.Length | Controls -> MenuState.controlRows.Length | LoadError -> 4 | Playing -> 0 let rowHeight = if menu.Page = Controls then 25 else layout.MenuLineHeight let panelY = if menu.Page = Controls then layout.ControlsPanelY else layout.PanelY let panelHeight = min (height - panelY - 16) (max layout.PanelHeight (120 + contentRows * rowHeight + 48)) let innerX = layout.MenuX let textScale = 2 let lineHeight = layout.MenuLineHeight let selectedColor = Color(245, 222, 120) let textColor = Color(210, 226, 220) let mutedColor = Color(140, 170, 170) let drawText (x: int) (y: int) (color: Color) (text: string) = PixelText.draw spriteBatch pixel cjkAtlas x y textScale color text let drawSelection (index: int) (label: string) (y: int) = let selected = index = menu.Selected let cursor, labelRect = TitleScreen.selectionRects layout index if selected then PixelText.draw spriteBatch pixel cjkAtlas cursor.X cursor.Y textScale selectedColor ">" else PixelText.draw spriteBatch pixel cjkAtlas cursor.X cursor.Y textScale mutedColor " " PixelText.draw spriteBatch pixel cjkAtlas labelRect.X labelRect.Y textScale (if selected then selectedColor else textColor) label spriteBatch.Begin() // Layered dusk gradient: sky bands darken toward the horizon, never a flat block. let bandCount = List.length layout.SkyBands let bandHeight = height / bandCount List.iteri (fun index (band: TitleScreen.ColorRgb) -> let y0 = index * bandHeight let y1 = if index = bandCount - 1 then height else y0 + bandHeight spriteBatch.Draw(pixel, Rectangle(0, y0, width, y1 - y0), Color(band.R, band.G, band.B))) layout.SkyBands // Deterministic 2s entrance: skyline settles from above as the progress unwinds. let entrance = TitleScreen.entranceProgress menuEntranceFrame let skylineDrop = int (36.0f * entrance) List.iteri (fun column slot -> let x = column * Sim.tilePixels let source = VillageArt.atlasTileRectangle slot if slot = fst TitleScreen.roofSlots then spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.RidgeY + skylineDrop, Sim.tilePixels, Sim.tilePixels), source, Color.White) spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, layout.EaveY + skylineDrop, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle (snd TitleScreen.roofSlots), Color.White)) layout.RoofTileSlots // Water band drawn from the atlas water tile, reflecting the skyline above. let waterColor = Color(150, 170, 178, int (255.0f * (1.0f - entrance))) for row in layout.WaterRows do for x in 0 .. Sim.tilePixels .. width - 1 do spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, waterColor) // Lantern glows along the water bank. let lanternColor, lanternHalo = TitleScreen.lanternColors for (lx, ly) in layout.Lanterns do spriteBatch.Draw(pixel, Rectangle(lx - 8, ly - 8, 24, 24), Color(lanternHalo.R, lanternHalo.G, lanternHalo.B, 70)) spriteBatch.Draw(pixel, Rectangle(lx - 3, ly - 5, 8, 10), Color(lanternColor.R, lanternColor.G, lanternColor.B)) spriteBatch.Draw(pixel, Rectangle(lx - 1, ly - 7, 4, 2), Color(90, 60, 40)) spriteBatch.Draw(pixel, Rectangle(lx - 2, ly + 5, 6, 2), Color(90, 60, 40)) // Title rises into place as the entrance progress unwinds. let titleLift = int (24.0f * entrance) PixelText.draw spriteBatch pixel cjkAtlas (layout.TitleX + 4) (layout.TitleY + titleLift + 4) layout.TitleScale (Color(12, 20, 28)) layout.TitleText PixelText.draw spriteBatch pixel cjkAtlas layout.TitleX (layout.TitleY + titleLift) layout.TitleScale (Color(235, 246, 232)) layout.TitleText // Menu panel floats over the lower half; translucent so the scene layers stay visible. spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight), Color(11, 19, 27, 200)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 4), Color(92, 180, 190)) let contentY = panelY + 60 let headingY = panelY + 20 match menu.Page with | MainMenu -> drawText innerX headingY mutedColor (ChineseText.menuHeading MainMenuHeading) MenuState.items menu |> List.iteri (fun index item -> drawSelection index (MenuState.itemLabel item) (contentY + index * lineHeight)) drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认" | OccupationSelect -> drawText innerX headingY mutedColor "选择职业" MenuState.occupationOptions |> List.iteri (fun index occupation -> drawSelection index (MenuState.occupationLabel occupation) (contentY + index * lineHeight)) drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认 ESC 返回" | Settings -> drawText innerX headingY mutedColor (ChineseText.menuHeading SettingsHeading) MenuState.settings |> List.iteri (fun index setting -> drawSelection index (MenuState.settingsLabel setting) (contentY + index * lineHeight)) drawText innerX (panelY + panelHeight - 72) mutedColor (sprintf "当前 %s" (M6Presentation.clockLabel menu.Settings)) drawText innerX (panelY + panelHeight - 40) mutedColor "ENTER 应用 ESC 返回" | Controls -> drawText innerX headingY mutedColor (ChineseText.menuHeading ControlsHeading) MenuState.controlRows |> List.iteri (fun index row -> let keyRect, actionRect = TitleScreen.controlRowRects layout index PixelText.draw spriteBatch pixel cjkAtlas keyRect.X keyRect.Y textScale textColor row.Keys PixelText.draw spriteBatch pixel cjkAtlas actionRect.X actionRect.Y textScale textColor row.Action) drawText innerX (panelY + panelHeight - 40) mutedColor "ESC 返回" | LoadError -> drawText innerX headingY (Color(240, 130, 120)) "读取失败" let message = menu.Error |> Option.defaultValue "暂无存档" let visible = if message.Length > 24 then message.Substring(0, 24) else message drawText innerX contentY textColor visible drawText innerX (contentY + lineHeight * 2) mutedColor "ENTER ESC 返回" | Playing -> () spriteBatch.End() member private this.DrawWorldView() = let profile = M6Presentation.profileAtTick world.Tick let viewport = this.GraphicsDevice.Viewport this.GraphicsDevice.Clear(profile.Background) spriteBatch.Begin() match m5View.HomeMode with | Inside _ -> VillageArt.drawInterior spriteBatch pixel artTextures viewport.Width viewport.Height renderPlan.Interior profile.WorldTint let interiorAvatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick VillageArt.drawInteriorCharacter spriteBatch artTextures viewport.Width viewport.Height world.Avatar.Pos interiorAvatarSpec profile.WorldTint | Outside -> VillageArt.drawWorld spriteBatch artTextures pixel camera viewport.Width viewport.Height renderPlan riverscapeMap world.Tick profile.WorldTint // Warm lantern glows strengthen as night falls (pure function of tick phase). // Concentric premultiplied circles fall off radially from each lantern tile, so // the core stays a soft warm disc instead of saturating into a white block, and // no glow is painted where there is no lantern entity. 生成器世界用桥头灯笼。 if profile.LanternGlow > 0.3f then let glow = profile.LanternGlow let peakAlpha = int (140.0f * glow) let lanternTiles = match riverscapeMap with | Some map -> VillageArt.bridgeLanternTiles map | None -> renderPlan.Props |> List.choose (fun prop -> match prop.Kind with | VillageArt.RenderPropKind.RiverLantern -> let position = VillageArt.worldTile renderPlan prop.Position Some(position.X, position.Y) | _ -> None) for (tileX, tileY) in lanternTiles do let centerX = tileX * Sim.tilePixels - int camera.X + Sim.tilePixels / 2 let centerY = tileY * Sim.tilePixels - int camera.Y + Sim.tilePixels / 2 for (rect, color) in VillageArt.lanternGlowLayers centerX centerY 44 6 255 214 150 peakAlpha do spriteBatch.Draw(pixel, rect, color) VillageArt.drawGroundShadow spriteBatch pixel camera world.Avatar.Pos let avatarSpec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick VillageArt.drawCharacter spriteBatch artTextures camera world.Avatar.Pos avatarSpec profile.WorldTint for npc in world.Npcs do VillageArt.drawGroundShadow spriteBatch pixel camera npc.Pos let spec = VillageArt.npcSpriteSpecAtTarget npc.Id npc.Pos npc.Mind.Target world.Tick VillageArt.drawCharacter spriteBatch artTextures camera npc.Pos spec profile.WorldTint // Golden-hour wash: the multiplicative WorldTint can only darken, so a warm // premultiplied veil is layered over the whole scene. Because rgb is premultiplied // by its own low alpha it adds warm light without ever saturating to white; alpha // is a pure tick function, so noon/midnight get none and dawn/dusk peak warm. let veilAlpha = M6Presentation.goldenVeilAlpha world.Tick if veilAlpha > 0 then spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply 255 150 70 veilAlpha) spriteBatch.End() // Contextual floating prompt over the unique target; same CJK atlas font path as the HUD. if m5View.Panel = M5Panel.WorldPanel && menu.Page = Playing then match m5View.Prompt, m5View.PromptTargetPos with | Some text, Some targetPos -> let scale = 2 let rect = FloatingPrompt.layout viewport.Width viewport.Height targetPos.X targetPos.Y camera.X camera.Y scale text spriteBatch.Begin() spriteBatch.Draw(pixel, Rectangle(rect.X - 6, rect.Y - 5, rect.Width + 12, rect.Height + 8), Color(15, 19, 25, 200)) spriteBatch.Draw(pixel, Rectangle(rect.X - 6, rect.Y - 5, rect.Width + 12, 2), Color(92, 180, 190)) PixelText.draw spriteBatch pixel cjkAtlas rect.X rect.Y scale (Color(240, 250, 240)) text spriteBatch.End() | _ -> () this.DrawM5Overlay() member private this.DrawM5Overlay() = this.DrawHudBar() if m5View.Panel = WorldPanel then this.DrawTransientStatus() else this.DrawPanel() /// 顶部窄信息条:时段/时间/倍速/体力/钱币,半透明渐变 + 强调线,绝非大黑块。 member private this.DrawHudBar() = let viewport = this.GraphicsDevice.Viewport let profile = M6Presentation.profileAtTick world.Tick let palette = HudLayout.palette profile.Mode let placed = HudLayout.layout (HudLayout.segments world simulationControl) let barHeight = HudLayout.barHeight let textY = (barHeight - 8 * HudLayout.textScale) / 2 spriteBatch.Begin() // 竖向渐变:上半略亮、下半压暗,避免整条纯色。 spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, barHeight / 2), palette.BarTop) spriteBatch.Draw(pixel, Rectangle(0, barHeight / 2, viewport.Width, barHeight - barHeight / 2), palette.BarBottom) spriteBatch.Draw(pixel, Rectangle(0, barHeight, viewport.Width, HudLayout.accentHeight), palette.Accent) for (x, _, segment) in placed do PixelText.draw spriteBatch pixel cjkAtlas x textY HudLayout.textScale palette.Label segment.Label let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale PixelText.draw spriteBatch pixel cjkAtlas (x + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value spriteBatch.End() /// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。 member private this.DrawTransientStatus() = let viewport = this.GraphicsDevice.Viewport let statusView = { m5View with StatusTick = Some hudStatusAt } match M5Interaction.transientStatusLine hudClock statusView with | None -> () | Some line -> let scale = 2 let width = FloatingPrompt.measureWidth line scale let x = 16 let y = HudLayout.barHeight + HudLayout.accentHeight + 8 spriteBatch.Begin() spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, width + 16, 8 * scale + 12), Color(16, 26, 32, 200)) spriteBatch.Draw(pixel, Rectangle(x - 8, y - 6, 2, 8 * scale + 12), Color(92, 180, 190)) PixelText.draw spriteBatch pixel cjkAtlas x y scale (Color(232, 242, 232)) line spriteBatch.End() /// 打开的面板(对话/需求/观察/年鉴/任务):底部左侧轻量渐变板,沿用既有 panelLines。 member private this.DrawPanel() = let viewport = this.GraphicsDevice.Viewport let textScale = 2 let lineHeight = 18 let lines = M5Interaction.panelLines world m5View let panelWidth = min 380 (max 1 (viewport.Width - 32)) let maxLines = max 1 ((viewport.Height - HudLayout.barHeight - 48) / lineHeight) let visibleLines = lines |> List.truncate maxLines let panelHeight = visibleLines.Length * lineHeight + 26 let panelX = 16 let panelY = max (HudLayout.barHeight + 12) (viewport.Height - panelHeight - 16) let innerX = panelX + 12 let innerWidth = max 1 (panelWidth - 24) let maxChars = max 1 (innerWidth / (6 * textScale)) spriteBatch.Begin() // 竖向渐变板(半透明,非纯黑),顶部与左侧各一条强调线。 spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight / 2), Color(30, 44, 52, 190)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY + panelHeight / 2, panelWidth, panelHeight - panelHeight / 2), Color(16, 24, 30, 190)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 3), Color(92, 180, 190)) spriteBatch.Draw(pixel, Rectangle(panelX, panelY, 2, panelHeight), Color(92, 180, 190, 160)) visibleLines |> List.iteri (fun index line -> let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line let color = if index = 0 then Color(226, 238, 226) else Color(192, 208, 212) PixelText.draw spriteBatch pixel cjkAtlas innerX (panelY + 13 + index * lineHeight) textScale color visible) spriteBatch.End()