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" // P36 证据钩子:复用 sample 脚本的走位/互动序列,但按状态抓 4 帧关键帧 // (靠近→提示出现→对话层打开且世界交互被隔离→关闭离开提示消失)后退出。 let p36ShotMode = Environment.GetEnvironmentVariable("LV_P36_SHOT") = "1" let sampleMode = Environment.GetEnvironmentVariable("LV_AUTOPLAY_SAMPLE") = "1" || p36ShotMode 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) // P23 取证钩子:可把生成器世界扩到 512x384(默认仍是 256x192,不改默认值)。 let riverscapeWidth = match Environment.GetEnvironmentVariable("LV_RIVERSCAPE_WIDTH") with | null | "" -> 256 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 256 let riverscapeHeight = match Environment.GetEnvironmentVariable("LV_RIVERSCAPE_HEIGHT") with | null | "" -> 192 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 192 let riverscapeMap : MapGen.Result option = if riverscapeActive then Sim.configureBounds riverscapeWidth riverscapeHeight Some (MapGen.generateWithSize riverscapeWidth riverscapeHeight 42UL) else None let riverscapeTourMode = riverscapeTourRequested && riverscapeActive // P21 性能核对:>0 时巡游循环到该秒数再退出(默认 0 = 跑完一遍路点)。 let riverscapeTourSeconds = match Environment.GetEnvironmentVariable("LV_RIVERSCAPE_SECONDS") with | null | "" -> 0.0 | v -> match Double.TryParse(v, Globalization.NumberStyles.Float, Globalization.CultureInfo.InvariantCulture) with | true, seconds when seconds > 0.0 -> seconds | _ -> 0.0 let riverscapeTourWatch = System.Diagnostics.Stopwatch() let mutable riverscapeTourWatchStarted = false // P25 证据钩子:村民巡游。NPC 行动目标固定在 Sim 的四个活动点(厨房/家/集市/工位), // 让 avatar 在这些点之间环游,保证记录帧多数时刻屏内有 >=8 名村民。 let riverscapeVillageTour = Environment.GetEnvironmentVariable("LV_RIVERSCAPE_VILLAGE_TOUR") = "1" let villageTourWaypoints () : (int * int) list = let tileOf (p: Vec2) = (int p.X / Sim.tilePixels, int p.Y / Sim.tilePixels) let plaza = tileOf Sim.plazaPoint let kitchen = tileOf Sim.kitchenPoint let home = tileOf Sim.homePoint let worksite = tileOf Sim.worksitePoint [ plaza; home; worksite; kitchen; plaza ] /// 巡游路点:第一座桥的整列(含两端外沿)+ 民居门 + 核心中心,用于桥面行走取证。 /// LV_RIVERSCAPE_VILLAGE_TOUR=1 时改为村民活动点环游(P25 村民可见性取证)。 let riverscapeTourWaypoints () : (int * int) list = match riverscapeMap with | None -> [] | Some map when riverscapeVillageTour -> villageTourWaypoints () |> List.filter (fun (x, y) -> x >= 0 && x < map.Width && y >= 0 && y < map.Height) |> List.distinct | 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 unlockFps = Environment.GetEnvironmentVariable("LV_UNLOCK_FPS") = "1" let perfSummaryMode = Environment.GetEnvironmentVariable("LV_PERF_SUMMARY") = "1" // P24 证据钩子:每秒打印屏幕内可见 NPC 数,证明大世界村民确实被渲染。 let npcVisibleLog = Environment.GetEnvironmentVariable("LV_NPC_VISIBLE_LOG") = "1" let mutable fpsSamples: float list = [] let mutable fpsFrames = 0 let mutable fpsSeconds = 0.0 // P22:让 sample/legacy 世界也能长时间采样帧率(>0 时跑完一轮脚本后回到开头继续)。 let perfSoakSeconds = match Environment.GetEnvironmentVariable("LV_PERF_SOAK_SECONDS") with | null | "" -> 0.0 | v -> match Double.TryParse(v, Globalization.NumberStyles.Float, Globalization.CultureInfo.InvariantCulture) with | true, seconds when seconds > 0.0 -> seconds | _ -> 0.0 let perfSoakWatch = System.Diagnostics.Stopwatch() let mutable perfSoakStarted = false 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 // P31 证据钩子:LV_STORY_SHOT=1 时以指定职业开局、经真实对话管线推进剧情线后 // 打开任务面板截图,拍完即退出。LV_STORY_OCCUPATION=farmer|fisher|peddler|scholar, // LV_STORY_STAGE=0..3(默认 2),输出到 LV_RECORD_DIR/p31-story-<职业>-stage.png。 let storyShotMode = Environment.GetEnvironmentVariable("LV_STORY_SHOT") = "1" let storyShotKind = match Environment.GetEnvironmentVariable("LV_STORY_OCCUPATION") with | "fisher" -> Occupation.Fisher | "peddler" -> Occupation.Peddler | "scholar" -> Occupation.Scholar | _ -> Occupation.Farmer let storyShotStage = match Environment.GetEnvironmentVariable("LV_STORY_STAGE") with | null | "" -> 2 | v -> match Int32.TryParse v with (true, value) -> max 0 (min 3 value) | _ -> 2 let mutable storyShotPrepared = false let mutable storyShotFrame = 0L let mutable storyShotSaved = false // P37 证据钩子:LV_P37_SHOT=1 时从主菜单进入职业选择页截图,选定职业开局后打开任务面板 // 截 HUD/任务列表,然后退出。LV_P37_OCCUPATION=farmer|fisher|peddler|scholar(默认渔夫)。 let p37ShotMode = Environment.GetEnvironmentVariable("LV_P37_SHOT") = "1" let p37Occupation = match Environment.GetEnvironmentVariable("LV_P37_OCCUPATION") with | "farmer" -> Occupation.Farmer | "peddler" -> Occupation.Peddler | "scholar" -> Occupation.Scholar | _ -> Occupation.Fisher let mutable p37Step = 0 let mutable p37Hold = 0 let mutable p37Captured = false // P39 证据钩子:LV_P39_SHOT=1 时在真实 256x192 生成器世界里,把相机分别对准 // 「桥+乌篷船+芦苇」与「主路石灯笼+菜摊」,拍昼/夜整帧后退出。位置来自 // VillageArt 的确定性摆放纯函数,含真实 tint/光晕,非 mockup。 let p39ShotMode = Environment.GetEnvironmentVariable("LV_P39_SHOT") = "1" let mutable p39Step = 0 let mutable p39PendingName = "" let mutable p39Pending = false // P40 证据钩子:LV_P40_SHOT=1 时在真实 256x192 生成器世界里拍 // p40-day / p40-night / p40-boat-closeup(相机贴近乌篷船,便于 2x 特写)。 let p40ShotMode = Environment.GetEnvironmentVariable("LV_P40_SHOT") = "1" let mutable p40Step = 0 let mutable p40PendingName = "" let mutable p40Pending = false let mutable autoplayFrames = 0 let mutable flowStep = 0 let mutable flowHold = 0 let mutable sampleState = SampleScript.initial let mutable p36Captured = Array.zeroCreate 4 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 // P28 证据钩子:额外把整图按指定像素目标渲染一次(用于 1 tile≈2px 的缩放可读性取证)。 let mapGenFitWidth = match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_WIDTH") with | null | "" -> 0 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 let mapGenFitHeight = match Environment.GetEnvironmentVariable("LV_MAPGEN_FIT_HEIGHT") with | null | "" -> 0 | v -> match Int32.TryParse v with (true, value) when value > 0 -> value | _ -> 0 // P28 夜景取证:LV_MAPGEN_NIGHT=1 时整图按 23:30 的真实 tint + 月光 veil 渲染。 let mapGenNight = Environment.GetEnvironmentVariable("LV_MAPGEN_NIGHT") = "1" // P30 证据钩子:LV_MAPGEN_P30_SHOT=1 时按 P30 规格出图(整图 day/night + 田块局部 + 路网弯曲局部)。 let mapGenP30Shot = Environment.GetEnvironmentVariable("LV_MAPGEN_P30_SHOT") = "1" let mapGenNightTick = if mapGenNight then M6Presentation.resolveStartTick (Some 23.5) false else 0L 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) if unlockFps then // P21 性能核对:放开 vsync/固定步长,测真实的渲染帧率上限。 this.IsFixedTimeStep <- false graphics.SynchronizeWithVerticalRetrace <- false 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.CenterCameraOnTile (tileX: int) (tileY: int) = 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(float32 (tileX * Sim.tilePixels) + half - vw / 2.0f, 0.0f, max 0.0f (worldW - vw)) let y = MathHelper.Clamp(float32 (tileY * Sim.tilePixels) + 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 // §3 身份条目:带职业开局即写入年鉴,形成个人传记线 match state with | Some occupationState -> world <- Sim.appendAnnal { Tick = world.Tick Kind = StoryAnnal Summary = Occupation.Story.identitySummaryOf occupationState.Profile.Kind } world | None -> () 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 () = if perfSoakSeconds > 0.0 && not perfSoakStarted then perfSoakStarted <- true perfSoakWatch.Start() 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 if perfSoakSeconds > 0.0 && perfSoakWatch.Elapsed.TotalSeconds < perfSoakSeconds then sampleState <- SampleScript.initial else printfn (if nextState.TimedOut then "sample result=timeout" else "sample result=ok") this.Exit() /// P36 证据:在 sample 序列的自然状态下抓 4 帧,证明「情境提示」契约: /// 1 靠近目标(进入 ApproachNpc)2 提示出现 3 对话层打开(浮动提示消失=世界交互被隔离) /// 4 关闭并走远后提示消失。每帧打印 panel/prompt/home 对照点,第 4 帧后退出。 member private this.CaptureP36PromptShot () = if p36ShotMode then let save (index: int) (label: string) = System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/p36-prompt-approach-%d.png" recordDirectory index) printfn "p36-shot=%s index=%d tick=%d pos=(%.0f,%.0f) panel=%s prompt=%s home=%A" label index world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y (M5Interaction.panelName m5View) (m5View.Prompt |> Option.defaultValue "") m5View.HomeMode let stage = sampleState.Stage // 第 4 帧必须已真正走远(超出所有村民的交谈范围)且提示消失,才能证明「离开即消失」。 let leaveMargin = Sim.chatRangePx + 24.0f let outsideEveryRange = world.Npcs |> Array.forall (fun npc -> let dx = npc.Pos.X - world.Avatar.Pos.X let dy = npc.Pos.Y - world.Avatar.Pos.Y dx * dx + dy * dy > leaveMargin * leaveMargin) if not p36Captured.[0] && stage = SampleScript.ApproachNpc then p36Captured.[0] <- true save 1 "approach" elif not p36Captured.[1] && stage = SampleScript.ApproachNpc && m5View.Prompt.IsSome then p36Captured.[1] <- true save 2 "prompt" elif not p36Captured.[2] && m5View.Panel = DialoguePanel && m5View.Menu.IsSome then p36Captured.[2] <- true save 3 "dialogue-isolated" elif not p36Captured.[3] && stage = SampleScript.WalkAway && m5View.Prompt.IsNone && outsideEveryRange then p36Captured.[3] <- true save 4 "left" printfn "p36-shot=done tick=%d" world.Tick this.Exit() /// P16-FIX:菜单截图模式。停在开始界面,按确定性帧表向下切到「读档」,帧表见 MenuShotScript。 member private this.RunMenuShot () = menuShotFrame <- menuShotFrame + 1L if menuShotFrame = MenuShotScript.downFrame then this.DispatchMenuInput Down /// P37 证据:主菜单 →「选择职业」页截图 → 选定职业开局 → 打开任务面板截 HUD/任务列表 → 退出。 member private this.RunP37Shot () = let save (name: string) = System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name) printfn "p37-shot=%s tick=%d page=%A selected=%d task=%s" name world.Tick menu.Page menu.Selected (M5Interaction.panelLines world m5View |> List.tryHead |> Option.defaultValue "") let targetIndex = MenuState.occupationOptions |> List.findIndex (fun option -> option = Some p37Occupation) match p37Step with | 0 when menu.Page = MainMenu -> this.DispatchMenuInput Confirm p37Step <- 1 | 1 when menu.Page = OccupationSelect -> if menu.Selected <> targetIndex then this.DispatchMenuInput Down else save "p37-occupation-select" this.DispatchMenuInput Confirm p37Step <- 2 | 2 when menu.Page = Playing -> p37Hold <- p37Hold + 1 if p37Hold = 1 then let _, taskView = M5Interaction.apply ShowTaskPanel world m5View m5View <- taskView elif p37Hold >= 90 then if not p37Captured then p37Captured <- true save (sprintf "p37-%s-start" (Occupation.saveToken p37Occupation)) this.Exit() | _ -> () /// P39 证据:真实生成器世界里对准 P39 中式元素拍昼/夜整帧。 /// 相机对准「桥+船+苇」与「主路石灯笼+菜摊」两处,输出 1280x720 真实帧 /// (含 M6Presentation 的昼/夜 tint、golden/moonlight veil 与 lantern glow)。 /// 由于软件渲染下 Update 可能一帧内跑多次,截图统一在 `Draw` 末尾落盘(见 /// `CaptureP39Shot`),确保落盘的是「新状态已经画进后台缓冲」的那一帧。 member private this.PrepareP39Shot () = let placeCameraOn (tileX: int) (tileY: int) = world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (tileX * Sim.tilePixels); Y = float32 (tileY * Sim.tilePixels) } } } this.CenterCamera() let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let dayHour = 12.0 let nightHour = 23.5 if not p39Pending then match riverscapeMap with | None -> this.Exit() | Some map -> let boat = VillageArt.cc0BoatTile map // 取「离船最近的桥」的中点取景,保证桥/船/苇落在同一帧同一河段。 let bridgeTarget = match boat with | Some (boatX, boatY) -> let nearest = map.Bridges |> List.minBy (fun (bx, by) -> abs (bx - boatX) + abs (by - boatY)) ((fst nearest + boatX) / 2, (snd nearest + boatY) / 2) | None -> match map.Bridges |> List.sortBy (fun (x, y) -> (x, y)) |> List.tryHead with | Some (bx, by) -> (bx, by) | None -> (map.Core.MinX, map.Core.MinY) let lanternTarget = match VillageArt.cc0LanternTiles map 12 with | (lx, ly) :: _ -> (lx, ly) | [] -> bridgeTarget match p39Step with | 0 -> setHour dayHour placeCameraOn (fst bridgeTarget) (snd bridgeTarget) p39PendingName <- "p39-day" p39Pending <- true | 1 -> setHour nightHour placeCameraOn (fst bridgeTarget) (snd bridgeTarget) p39PendingName <- "p39-night" p39Pending <- true | 2 -> setHour nightHour placeCameraOn (fst lanternTarget) (snd lanternTarget) p39PendingName <- "p39-lantern-night" p39Pending <- true | _ -> printfn "p39-shot=done" this.Exit() /// P39 证据:在 `Draw` 末尾把刚画好的后台缓冲落盘(软件渲染下 Update 可能一帧多跑, /// 只有 Draw 之后的缓冲区才是当前状态的真实帧)。 member private this.CaptureP39Shot () = if p39Pending then p39Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p39PendingName) printfn "p39-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" p39PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y p39Step <- p39Step + 1 /// P40 证据:真实生成器世界里拍昼 / 夜整帧与乌篷船近景。位置取自确定性摆放纯函数, /// 含真实 tint 与 P14 光晕;相机贴近船体便于 2x NEAREST 特写。 member private this.PrepareP40Shot () = let placeAvatarOnLand (tileX: int) (tileY: int) = world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (tileX * Sim.tilePixels); Y = float32 (tileY * Sim.tilePixels) } } } let placeCameraOn (tileX: int) (tileY: int) = placeAvatarOnLand tileX tileY this.CenterCamera() let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let dayHour = 12.0 let nightHour = 23.5 if not p40Pending then match riverscapeMap with | None -> this.Exit() | Some map -> let boat = match VillageArt.cc0BoatTile map with | Some (bx, by) -> (bx, by) | None -> match map.Bridges |> List.sortBy (fun (x, y) -> (x, y)) |> List.tryHead with | Some (bx, by) -> (bx, by) | None -> (map.Core.MinX, map.Core.MinY) match p40Step with | 0 -> setHour dayHour placeAvatarOnLand 232 51 this.CenterCameraOnTile (fst boat) (snd boat) p40PendingName <- "p40-day" p40Pending <- true | 1 -> setHour nightHour placeAvatarOnLand 232 51 this.CenterCameraOnTile (fst boat) (snd boat) p40PendingName <- "p40-night" p40Pending <- true | 2 -> setHour dayHour placeAvatarOnLand 240 51 this.CenterCameraOnTile (fst boat + 1) (snd boat) p40PendingName <- "p40-boat" p40Pending <- true | _ -> printfn "p40-shot=done" this.Exit() /// P40 证据:与 P39 相同,在 `Draw` 末尾落盘当前后台缓冲。 member private this.CaptureP40Shot () = if p40Pending then p40Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p40PendingName) printfn "p40-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" p40PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y p40Step <- p40Step + 1 /// P20 第三步取证:真走位巡游(Sim.step 驱动)沿桥/路/民居门移动,用于桥面行走帧与昼夜录制。 member private this.RunRiverscapeTour () = if not riverscapeTourWatchStarted then riverscapeTourWatchStarted <- true riverscapeTourWatch.Start() 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 if riverscapeTourSeconds > 0.0 && riverscapeTourWatch.Elapsed.TotalSeconds < riverscapeTourSeconds then tourIndex <- 0 else printfn "riverscape tour=complete frames=%d elapsed_s=%.1f" recordIndex riverscapeTourWatch.Elapsed.TotalSeconds 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() /// P31 证据:以真实开局函数建世界,用真实对话管线(bias + onDialogue)推进剧情线, /// 再打开任务面板——与玩家操作走同一套 Kernel 纯函数,仅脚本化输入。 member private this.RunStoryShotSetup () = let seed = 42UL let kind = storyShotKind let stage = storyShotStage let lineStartDay = Occupation.Story.startDayOf seed kind + int64 (max 0 (stage - 1)) let startTick = lineStartDay * Sim.ticksPerDay + 12L * Sim.ticksPerDay / 24L let state = WorldBootstrap.occupationStateFor seed kind startTick let world0 = WorldBootstrap.initialWorldWithOccupation false seed Sim.npcCount (Some state) let world1 = { world0 with Tick = startTick; Time = float startTick * Sim.dtSeconds } let world2 = Sim.appendAnnal { Tick = startTick; Kind = StoryAnnal; Summary = Occupation.Story.identitySummaryOf kind } world1 let mutable w = world2 let mutable task = state for i in 0 .. stage - 1 do let npc = w.Npcs.[i % w.Npcs.Length] let wNear = { w with Avatar = { w.Avatar with Pos = npc.Pos } } let respond = Occupation.biasResponse (Some kind) match Sim.chooseDialogueWith respond npc.Id SmallTalk wNear with | DialogueSucceeded (_, next) -> let nextState, summary = Occupation.Story.onDialogue seed task next w <- match summary with | Some text -> Sim.appendAnnal { Tick = next.Tick; Kind = StoryAnnal; Summary = text } next | None -> next task <- nextState | DialogueRejected (failure, _) -> printfn "story-shot dialogue rejected: %A" failure world <- w m5View <- { M5Interaction.initial with Task = Some task; Seed = seed } let _, view = M5Interaction.apply ShowTaskPanel world m5View m5View <- view menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame printfn "story-shot=prepared occupation=%s stage=%d tick=%d annals=%d" (Occupation.saveToken kind) stage world.Tick world.Annals.Length 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 storyShotMode && not storyShotPrepared then storyShotPrepared <- true this.RunStoryShotSetup() elif autoplayFlow then this.RunAutoplayFlow() elif p39ShotMode 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.PrepareP39Shot() elif p40ShotMode 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.PrepareP40Shot() elif p37ShotMode then this.RunP37Shot() 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 if perfSummaryMode then fpsSamples <- fps :: fpsSamples printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" if npcVisibleLog then let viewport = this.GraphicsDevice.Viewport let margin = float32 Sim.tilePixels let visible = world.Npcs |> Array.filter (fun npc -> let sx = npc.Pos.X - camera.X let sy = npc.Pos.Y - camera.Y sx >= -margin && sx <= float32 viewport.Width + margin && sy >= -margin && sy <= float32 viewport.Height + margin) |> Array.length printfn $"visible-npcs={visible} of {world.Npcs.Length}" prevKb <- kb override this.EndRun() = if perfSummaryMode then match PerformanceSummary.summarize 5 (List.rev fpsSamples) with | Some summary -> printfn "perf-summary samples=%d warmup_dropped=5 mean_fps=%.2f min_fps=%.2f max_fps=%.2f" summary.Count summary.Mean summary.Min summary.Max | None -> printfn "perf-summary samples=%d (insufficient, all warmup)" fpsSamples.Length base.EndRun() 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,返回绘制瓦片数与纹理。 /// P28:可传入世界 tint 与可选 veil tick(夜景取证用)。 member private this.RenderMapGen (targetWidth: int) (targetHeight: int) (camX: int) (camY: int) (drawFitted: bool) (tint: Color) (veilTick: int64 option) : int * Texture2D = use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight) this.GraphicsDevice.SetRenderTarget target this.GraphicsDevice.Clear(Color(18, 26, 20)) spriteBatch.Begin() // P29:夜景取证帧与真实渲染同一套灯光衰减——按 veil tick 的 LanternGlow 叠加柔和径向光晕。 let glowScale = match veilTick with | Some tick -> (M6Presentation.profileAtTick tick).LanternGlow | None -> 0.0f let drawn = if drawFitted then VillageArt.drawMapFitted spriteBatch artTextures pixel targetWidth targetHeight mapGenResult tint glowScale else VillageArt.drawMapViewport spriteBatch artTextures pixel (Vector2(float32 camX, float32 camY)) targetWidth targetHeight mapGenResult 0L tint glowScale // 与 DrawWorldView 同一套纯函数 veil,保证取证夜景=真实渲染夜景。 match veilTick with | Some tick -> let golden = M6Presentation.goldenVeilAlpha tick if golden > 0 then spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply 255 150 70 golden) let moon = M6Presentation.moonlightVeilAlpha tick if moon > 0 then let mr, mg, mb = M6Presentation.moonlightRgb spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply mr mg mb moon) | None -> () 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 mapTint = if mapGenNight then (M6Presentation.profileAtTick mapGenNightTick).WorldTint else Color.White let mapVeil = if mapGenNight then Some mapGenNightTick else None let fittedDrawn, fittedTexture = this.RenderMapGen (mapGenResult.Width * mapCell) (mapGenResult.Height * mapCell) 0 0 true mapTint mapVeil 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 night=%b" fittedDrawn fittedTexture.Width fittedTexture.Height mapGenNight // P28:整图按指定像素目标再渲染一次(真实帧)。1024x768 -> 512x384 图恰为 2px/tile, // 用于证明两民居变体在「整图 1 tile≈2px」缩放下肉眼可分。 if mapGenFitWidth > 0 && mapGenFitHeight > 0 then let fitDrawn, fitTexture = this.RenderMapGen mapGenFitWidth mapGenFitHeight 0 0 true mapTint mapVeil use _fit = fitTexture use fitStream = System.IO.File.Create(sprintf "%s/p28-fit-%dx%d-seed%d.png" recordDirectory mapGenFitWidth mapGenFitHeight (int mapGenResult.Seed)) fitTexture.SaveAsPng(fitStream, mapGenFitWidth, mapGenFitHeight) printfn "mapgen-fit drawn=%d png=%dx%d night=%b" fitDrawn mapGenFitWidth mapGenFitHeight mapGenNight 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 Color.White None 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 Color.White None 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 0.0f |> 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() /// P30 证据:把地图任意 tile 矩形以 cell px/tile 真实离屏渲染,含 tint 与可选 veil/夜光。 member private this.RenderMapRegion (tileX0: int) (tileY0: int) (tilesW: int) (tilesH: int) (cell: int) (tint: Color) (veilTick: int64 option) : int * Texture2D = let targetWidth = max 1 (tilesW * cell) let targetHeight = max 1 (tilesH * cell) use target = new RenderTarget2D(this.GraphicsDevice, targetWidth, targetHeight) this.GraphicsDevice.SetRenderTarget target this.GraphicsDevice.Clear(Color(18, 26, 20)) spriteBatch.Begin() let glowScale = match veilTick with | Some tick -> (M6Presentation.profileAtTick tick).LanternGlow | None -> 0.0f let drawn = VillageArt.drawMapRegion spriteBatch artTextures pixel tileX0 tileY0 tilesW tilesH cell mapGenResult tint glowScale match veilTick with | Some tick -> let golden = M6Presentation.goldenVeilAlpha tick if golden > 0 then spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply 255 150 70 golden) let moon = M6Presentation.moonlightVeilAlpha tick if moon > 0 then let mr, mg, mb = M6Presentation.moonlightRgb spriteBatch.Draw(pixel, Rectangle(0, 0, targetWidth, targetHeight), VillageArt.premultiply mr mg mb moon) | None -> () 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) /// P30 田块局部取景:以「窗口内田块最密」的散落农舍为中心(含田块/树丛/院落), /// 优先完整落入画面的农舍;否则退化为首个田块。确定性纯函数。 member private this.P30FieldRegion () : int * int = let w = mapGenResult.Width let h = mapGenResult.Height let regionW = 128 let regionH = 64 let clampX x = max 0 (min (max 0 (w - regionW)) x) let clampY y = max 0 (min (max 0 (h - regionH)) y) let isField code = code = int MapGen.GroundTile.PaddyField || code = int MapGen.GroundTile.VegetablePlot let countFields (cx: int) (cy: int) = let x0 = max 0 (cx - regionW / 2) let x1 = min (w - 1) (cx + regionW / 2) let y0 = max 0 (cy - regionH / 2) let y1 = min (h - 1) (cy + regionH / 2) let mutable n = 0 for y in y0 .. y1 do for x in x0 .. x1 do if isField mapGenResult.Tiles.[y * w + x] then n <- n + 1 n let interior = mapGenResult.Farmhouses |> List.filter (fun b -> b.Top >= 16 && b.Top <= h - 80 && b.Left >= regionW / 2 && b.Left <= w - regionW / 2) let candidates = if interior.IsEmpty then mapGenResult.Farmhouses else interior match candidates |> List.sortBy (fun b -> (-(countFields (b.Left + b.Width / 2) (b.Top + 1)), b.Top, b.Left)) |> List.tryHead with | Some b -> (clampX (b.Left + b.Width / 2 - regionW / 2), clampY (b.Top - regionH / 2)) | None -> let mutable found = None let mutable y = 0 while found.IsNone && y < h do let mutable x = 0 while found.IsNone && x < w do let code = mapGenResult.Tiles.[y * w + x] if isField code then found <- Some(x, y) x <- x + 1 y <- y + 1 match found with | Some (x, y) -> (clampX (x - regionW / 2), clampY (y - regionH / 2)) | None -> (clampX (w / 2 - regionW / 2), clampY (h / 2 - regionH / 2)) /// P30 路网弯曲局部取景:以主路中段某个弯折列为横向中心,纵向对准主路中心线。 member private this.P30RoadRegion () : int * int = let w = mapGenResult.Width let h = mapGenResult.Height let regionW = 128 let regionH = 64 let clampX x = max 0 (min (max 0 (w - regionW)) x) let clampY y = max 0 (min (max 0 (h - regionH)) y) let center = mapGenResult.MainRoadCenter let roadY = if w > 0 then center.[w / 2] else h / 2 let bendX = [ for x in 1 .. w - 2 do if abs (center.[x] - center.[x - 1]) > 0 then yield x ] |> List.tryFind (fun x -> x > w / 4 && x < (3 * w) / 4) |> Option.defaultValue (w / 2) (clampX (bendX - regionW / 2), clampY (roadY - regionH / 2)) /// P30 证据出图:整图 3072x2304(6px/tile)day/night + 田块局部 512x256(4px/tile) /// + 路网弯曲局部 512x256(4px/tile)。全部真实离屏渲染,拍完即退出。 member private this.DrawP30Shot () = System.IO.Directory.CreateDirectory recordDirectory |> ignore let save (name: string) (texture: Texture2D) = use stream = System.IO.File.Create(sprintf "%s/%s.png" recordDirectory name) texture.SaveAsPng(stream, texture.Width, texture.Height) let nightTick = M6Presentation.resolveStartTick (Some 23.5) false let nightTint = (M6Presentation.profileAtTick nightTick).WorldTint let fullW = mapGenResult.Width * 6 let fullH = mapGenResult.Height * 6 let _, dayTexture = this.RenderMapGen fullW fullH 0 0 true Color.White None use _day = dayTexture save (sprintf "p30-map-%dx%d-seed%d-day" mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) dayTexture let _, nightTexture = this.RenderMapGen fullW fullH 0 0 true nightTint (Some nightTick) use _night = nightTexture save (sprintf "p30-map-%dx%d-seed%d-night" mapGenResult.Width mapGenResult.Height (int mapGenResult.Seed)) nightTexture let fx, fy = this.P30FieldRegion () let _, fieldDay = this.RenderMapRegion fx fy 128 64 4 Color.White None use _fieldDay = fieldDay save "p30-field-cluster-4x-day" fieldDay let _, fieldNight = this.RenderMapRegion fx fy 128 64 4 nightTint (Some nightTick) use _fieldNight = fieldNight save "p30-field-cluster-4x-night" fieldNight let rx, ry = this.P30RoadRegion () let _, roadDay = this.RenderMapRegion rx ry 128 64 4 Color.White None use _roadDay = roadDay save "p30-roadserp-4x-day" roadDay printfn "p30-shot=done map=%dx%d field_region=(%d,%d) road_region=(%d,%d) night=%b" mapGenResult.Width mapGenResult.Height fx fy rx ry mapGenNight this.Exit() override this.Draw(gameTime: GameTime) = if mapGenShotMode then if mapGenP30Shot then this.DrawP30Shot() else 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() if storyShotMode && storyShotPrepared then storyShotFrame <- storyShotFrame + 1L if not storyShotSaved && storyShotFrame = 20L then storyShotSaved <- true System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer( sprintf "%s/p31-story-%s-stage%d.png" recordDirectory (Occupation.saveToken storyShotKind) storyShotStage) printfn "story-shot=saved occupation=%s stage=%d" (Occupation.saveToken storyShotKind) storyShotStage this.Exit() if p36ShotMode && menu.Page = Playing && not relationView then this.CaptureP36PromptShot() if p39ShotMode && menu.Page = Playing then this.CaptureP39Shot() if p40ShotMode && menu.Page = Playing then this.CaptureP40Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 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)) // P-d:选中职业时提示其轻剧情线(仅最小展示,复用既有选择页机制)。 (match MenuState.selectedOccupation menu with | Some kind -> drawText innerX (contentY + MenuState.occupationOptions.Length * lineHeight) mutedColor (sprintf "剧情线:%s" (Occupation.Story.lineNameOf kind)) | None -> ()) 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 / window glows strengthen as night falls (pure function of tick phase). // Concentric premultiplied circles follow a quadratic radial falloff (radius 3-4 tiles), // so the core stays a soft warm disc instead of saturating to a white block and no glow // is painted where there is no light entity. 生成器世界用桥头灯笼 + 民居门窗。 if profile.LanternGlow > 0.3f then let glow = profile.LanternGlow let peakAlpha = int (140.0f * glow) let tileX x = x * Sim.tilePixels - int camera.X let tileY y = y * Sim.tilePixels - int camera.Y match riverscapeMap with | Some map -> // P39:cc0 模式下把主路石灯笼并入夜光;fallback 保持原灯光源。 VillageArt.drawNightGlows spriteBatch pixel map (4 * Sim.tilePixels) (int (150.0f * glow)) tileX tileY Sim.tilePixels artTextures.Cc0TileAtlas.IsSome | None -> for prop in renderPlan.Props do match prop.Kind with | VillageArt.RenderPropKind.RiverLantern -> let position = VillageArt.worldTile renderPlan prop.Position let centerX = tileX position.X + Sim.tilePixels / 2 let centerY = tileY position.Y + Sim.tilePixels / 2 for (rect, color) in VillageArt.lanternGlowLayers centerX centerY (3 * Sim.tilePixels) 8 255 214 150 peakAlpha do spriteBatch.Draw(pixel, rect, color) | _ -> () match riverscapeMap, profile.LanternGlow > 0.3f with | Some map, true -> VillageArt.drawCc0BoatOverlay spriteBatch artTextures camera map profile.WorldTint | _ -> () 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) // Cool moonlight wash on top of the night scene: a premultiplied blue veil whose // alpha is a pure tick function (zero by day, suppressed through dusk/dawn warmth). // Like the golden veil it adds light and can never saturate to white. let moonAlpha = M6Presentation.moonlightVeilAlpha world.Tick if moonAlpha > 0 then let mr, mg, mb = M6Presentation.moonlightRgb spriteBatch.Draw(pixel, Rectangle(0, 0, viewport.Width, viewport.Height), VillageArt.premultiply mr mg mb moonAlpha) 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) // 图标用 pixel 贴图按 7x7 点阵绘制(无外部素材),先描边后填充,保证昼夜可读。 let drawIcon (iconX: int) (iconY: int) (color: Color) (icon: HudLayout.HudIcon) = HudLayout.iconRows icon |> List.iteri (fun row bits -> bits |> Seq.iteri (fun column bit -> if bit = '1' then spriteBatch.Draw( pixel, Rectangle( iconX + column * HudLayout.iconScale, iconY + row * HudLayout.iconScale, HudLayout.iconScale, HudLayout.iconScale), color))) let iconY = (barHeight - HudLayout.iconHeight) / 2 for (x, width, segment) in placed do spriteBatch.Draw(pixel, HudLayout.segmentBackdrop x width, palette.SegmentBackdrop) let textX = HudLayout.textOrigin x let labelAdvanced = FloatingPrompt.measureWidth (segment.Label + " ") HudLayout.textScale 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 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 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()