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 // P54:记录上一帧玩家所在瓦片,用于折叠「到达瓦片」环境信号(仅在跨越瓦片时触发)。 let mutable lastAvatarTile : (int * int) option = None 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 // P42 证据钩子:LV_P42_SHOT=1 时拍启动环境动效两帧 + 菜单动效一帧后退出; // 全程走正常启动/菜单流程(不改 duration/结构/输入),仅按帧表落盘。 let p42ShotMode = Environment.GetEnvironmentVariable("LV_P42_SHOT") = "1" let mutable p42SplashEarlyDone = false let mutable p42SplashLateDone = false // P43 证据钩子:LV_P43_SHOT=1 时在真实世界里拍昼/夜两张 HUD 整帧(日历钟卡 + 体力条 + 10 格 hotbar)。 let p43ShotMode = Environment.GetEnvironmentVariable("LV_P43_SHOT") = "1" let mutable p43Step = 0 let mutable p43PendingName = "" let mutable p43Pending = false // P44 证据钩子:LV_P44_SHOT=1 时在真实世界里开一场 NPC 对话弹层,拍默认页/翻页后/关闭恢复三帧。 let p44ShotMode = Environment.GetEnvironmentVariable("LV_P44_SHOT") = "1" let mutable p44Step = 0 let mutable p44PendingName = "" let mutable p44Pending = false // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。 let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1" let mutable p45Step = 0 let mutable p45PendingName = "" let mutable p45Pending = false // P46 证据钩子:LV_P46_SHOT=1 时拍 splash 涟漪中帧 + 云影两帧 + 菜单灯笼呼吸两相位, // 全程走正常启动/菜单流程,仅按帧表落盘。 let p46ShotMode = Environment.GetEnvironmentVariable("LV_P46_SHOT") = "1" let mutable p46PendingName = "" let mutable p46Pending = false let mutable p46SplashRippleDone = false let mutable p46CloudEarlyDone = false let mutable p46CloudLateDone = false let mutable p46MenuADone = false let mutable p46MenuBDone = false // P47 证据钩子:LV_P47_SHOT=1 时在真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧。 let p47ShotMode = Environment.GetEnvironmentVariable("LV_P47_SHOT") = "1" let mutable p47Step = 0 let mutable p47PendingName = "" let mutable p47Pending = false // P52 证据钩子:LV_P52_SHOT=1 时选职业开局 → 走到 NPC 旁用真实 Interact 开对话 → // 真实 BuyFromTarget / SellToTarget 各一次,拍「对话入口 / 买入后 / 卖出后」三帧; // 每笔交易打印钱与背包数量前后值,供 analyze 脚本算 delta。 // LV_P52_OCCUPATION=farmer|fisher|peddler|scholar(默认 peddler)。 let p52ShotMode = Environment.GetEnvironmentVariable("LV_P52_SHOT") = "1" let p52Occupation = match Environment.GetEnvironmentVariable("LV_P52_OCCUPATION") with | "farmer" -> Occupation.Farmer | "fisher" -> Occupation.Fisher | "scholar" -> Occupation.Scholar | _ -> Occupation.Peddler let mutable p52Step = 0 let mutable p52PendingName = "" let mutable p52Pending = false // P53 证据钩子:LV_P53_REC=1 时在真实世界里录制「远处 → 靠近提示出现 → 互动开对话 → // 关闭+离开 → 提示消失」帧序列,逐帧打印面板/提示/目标/提示矩形,供 analyze 脚本 verdict。 // 提示层为瞬时叠加,不新开面板。 let p53RecordMode = Environment.GetEnvironmentVariable("LV_P53_REC") = "1" let mutable p53Step = 0 let mutable p53PendingName = "" let mutable p53Pending = false // P54 证据钩子:LV_P54_SHOT=1 时以农夫真实开局,走「任务面板(待接受) → 接受 → // 环境到达 + 对话 + 卖出三条真实信号」全流程,拍「待接受 / 进行中 / 完成」帧; // 每步打印任务模板与状态,供 analyze 脚本核验 Offered→Active→Done 只由真实信号驱动。 // LV_P54_OCCUPATION=farmer|fisher|peddler|scholar(默认 farmer)。 let p54ShotMode = Environment.GetEnvironmentVariable("LV_P54_SHOT") = "1" let p54Occupation = match Environment.GetEnvironmentVariable("LV_P54_OCCUPATION") with | "fisher" -> Occupation.Fisher | "peddler" -> Occupation.Peddler | "scholar" -> Occupation.Scholar | _ -> Occupation.Farmer let mutable p54Step = 0 let mutable p54PendingName = "" let mutable p54Pending = false // P55 证据钩子:LV_P55_SHOT=1 时以农夫真实开局,跳到第 3 日(当日任务=送粮), // 走「面板(待接受) → 接受 → 真实卖出 Food(送粮完成,设计口径 +食物×1)→ 面板(已完成)」, // 打印 streak/背包/反馈,供 analyze 脚本核验报酬入账与连续天数。 let p55ShotMode = Environment.GetEnvironmentVariable("LV_P55_SHOT") = "1" let mutable p55Step = 0 let mutable p55PendingName = "" let mutable p55Pending = 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 // P43:hotbar 选中格(0..9),仅由游戏内 1-0 键或证据脚本写入,非伪造状态。 let mutable hotbarSelected = 0 // P44:对话/文字弹层模型与内容指纹(内容变化时重建,翻页状态保持不变)。 let mutable dialogOverlay: DialogOverlay.Model option = None let mutable dialogOverlayKey = "" 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 <- WorldBootstrap.appendIdentityAnnal occupationState 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 /// P43 证据:真实世界里拍昼 / 夜两张 HUD 整帧(右上日历钟卡 + 体力条形 + 底部 10 格 hotbar)。 member private this.PrepareP43Shot () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } if not p43Pending then match p43Step with | 0 -> setHour 12.0 hotbarSelected <- (HudLayout.selectSlotForDigit 1 |> Option.defaultValue 0) this.CenterCamera() p43PendingName <- "p43-hud-day" p43Pending <- true | 1 -> setHour 23.5 hotbarSelected <- (HudLayout.selectSlotForDigit 5 |> Option.defaultValue 0) this.CenterCamera() p43PendingName <- "p43-hud-night" p43Pending <- true | _ -> printfn "p43-shot=done" this.Exit() /// P43 证据:在 `Draw` 末尾落盘,并打印实测布局矩形与真实状态(供 p43-analysis.txt)。 member private this.CaptureP43Shot () = if p43Pending then p43Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p43PendingName) let viewport = this.GraphicsDevice.Viewport let reading = HudLayout.calendarReading world.Tick let cal = HudLayout.calendarCardRect viewport.Width reading let bar = HudLayout.hotbarBarRect viewport.Width viewport.Height let slot = HudLayout.hotbarSlotRect viewport.Width viewport.Height 0 printfn "p43-shot=%s tick=%d calendar=%d,%d,%d,%d hotbar=%d,%d,%d,%d slot0=%d,%d,%d,%d selected=%d energy=%.0f day=%d season=%s period=%s clock=%s" p43PendingName world.Tick cal.X cal.Y cal.Width cal.Height bar.X bar.Y bar.Width bar.Height slot.X slot.Y slot.Width slot.Height hotbarSelected world.Avatar.Mind.Needs.Energy reading.DayNumber reading.Season reading.Period reading.Clock p43Step <- p43Step + 1 /// P44 证据:真实世界里把角色放到 NPC 旁、用真实 Interact 命令开对话弹层, /// 依次拍「默认页 / 翻页后 / Esc 关闭后恢复」三帧。 member private this.PrepareP44Shot () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } if not p44Pending then match p44Step with | 0 -> setHour 12.0 let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View world <- nextWorld m5View <- nextView this.CenterCamera() p44PendingName <- "p44-dialog-a" p44Pending <- true | 1 -> match dialogOverlay with | Some model -> match DialogOverlay.nextPage model with | Some next -> dialogOverlay <- Some next | None -> () | None -> () p44PendingName <- "p44-dialog-b" p44Pending <- true | 2 -> let nextWorld, nextView = M5Interaction.apply M5Command.ClosePanel world m5View world <- nextWorld m5View <- nextView p44PendingName <- "p44-dialog-c" p44Pending <- true | _ -> printfn "p44-shot=done" this.Exit() /// P44 证据:在 `Draw` 末尾落盘,并打印弹层实测 rect / 行数 / 页码;关闭帧打印 overlay=gone。 member private this.CaptureP44Shot () = if p44Pending then p44Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p44PendingName) let viewport = this.GraphicsDevice.Viewport match dialogOverlay with | Some model -> let layout = DialogOverlay.rect viewport.Width viewport.Height model let current = DialogOverlay.view model printfn "p44-shot=%s panel=%s rect=%d,%d,%d,%d page=%d/%d lines=%d avatar=%b" p44PendingName (M5Interaction.panelName m5View) layout.X layout.Y layout.Width layout.Height current.PageNumber current.PageCount current.Lines.Length model.ShowAvatar | None -> printfn "p44-shot=%s panel=%s overlay=gone prompt=%s" p44PendingName (M5Interaction.panelName m5View) (m5View.Prompt |> Option.defaultValue "") p44Step <- p44Step + 1 member private this.PrepareP45Shot () = let baseTick = M6Presentation.resolveStartTick (Some 12.0) false let setTick (tick: int64) = world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } if not p45Pending then match p45Step with | 0 -> setTick baseTick world <- { world with Avatar = { world.Avatar with Pos = { X = float32 (100 * Sim.tilePixels); Y = float32 (100 * Sim.tilePixels) } } } avatarFacing <- VillageArt.SouthFacing avatarMoving <- false this.CenterCamera() p45PendingName <- "p45-char-a" p45Pending <- true | 1 -> setTick (baseTick + 4L) avatarFacing <- VillageArt.SouthFacing avatarMoving <- true this.CenterCamera() p45PendingName <- "p45-char-b" p45Pending <- true | 2 -> setTick (baseTick + 8L) avatarFacing <- VillageArt.EastFacing avatarMoving <- true this.CenterCamera() p45PendingName <- "p45-char-c" p45Pending <- true | _ -> printfn "p45-shot=done" this.Exit() /// P45 证据:在 `Draw` 末尾落盘,并打印角色目标矩形 / 朝向 / 帧,供裁剪与帧差分析。 member private this.CaptureP45Shot () = if p45Pending then p45Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p45PendingName) let dest = VillageArt.characterDestination world.Avatar.Pos camera let spec = VillageArt.avatarSpriteSpec avatarFacing avatarMoving world.Tick printfn "p45-shot=%s tick=%d facing=%s moving=%b dest=%d,%d,%d,%d frame=%s" p45PendingName world.Tick (sprintf "%A" avatarFacing) avatarMoving dest.X dest.Y dest.Width dest.Height (sprintf "%A" spec.Frame) p45Step <- p45Step + 1 /// P46 证据:Draw 末尾落盘。splash 阶段按帧阈值拍涟漪/云影,菜单阶段拍灯笼两相位。 member private this.CaptureP46Shot () = let save (name: string) = System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name) printfn "p46-shot=%s splashFrame=%d menuEntranceFrame=%d" name splashFrame menuEntranceFrame if splashActive then if not p46SplashRippleDone && splashFrame >= P46ShotScript.splashRippleFrame then p46SplashRippleDone <- true save P46ShotScript.splashRippleName elif not p46CloudEarlyDone && splashFrame >= P46ShotScript.cloudEarlyFrame then p46CloudEarlyDone <- true save P46ShotScript.cloudEarlyName elif not p46CloudLateDone && splashFrame >= P46ShotScript.cloudLateFrame then p46CloudLateDone <- true save P46ShotScript.cloudLateName elif p46Pending then p46Pending <- false if p46PendingName = P46ShotScript.menuPeakName then p46MenuADone <- true else p46MenuBDone <- true save p46PendingName /// P47 证据:真实生成器世界里拍「白天河道 / 夜晚河道 / 近景」三帧,含 P47 场景细节。 member private this.PrepareP47Shot () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } 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() if not p47Pending then match riverscapeMap with | None -> this.Exit() | Some map -> let riverside = // 取倒影长度最长、优先桥头/红灯笼的光源,相机对准其光源格,倒影落在正下方。 let ranked = SceneDetail.lightSources map |> List.map (fun (x, y, kind) -> let length = SceneDetail.reflectionLength map x y let kindRank = match kind with | SceneDetail.RedLantern -> 2 | SceneDetail.BridgeLantern -> 1 | SceneDetail.RoadLantern -> 0 x, y, kind, length, kindRank) let deep = ranked |> List.filter (fun (_, _, _, length, _) -> length >= 3) let pick = (if List.isEmpty deep then ranked else deep) |> List.sortByDescending (fun (_, _, _, length, kindRank) -> kindRank, length) |> List.tryHead match pick with | Some (x, y, _, _, _) -> (x, y) | None -> match SceneDetail.reflectionStreaks map with | (rx, ry, _, _) :: _ -> (rx, ry - 1) | [] -> (map.Core.MinX, map.Core.MinY) let closeup = // 近景:优先倒影更长、且离漂浮元素更近的光源,保证近景同时有倒影与漂浮元素。 let waterSources = SceneDetail.lightSources map |> List.filter (fun (x, y, _) -> SceneDetail.reflectionLength map x y >= 1) let floatTiles = SceneDetail.floatingProps map |> List.map (fun (x, y, _) -> x, y) let distToFloat (sx: int) (sy: int) = if List.isEmpty floatTiles then 0 else floatTiles |> List.map (fun (fx, fy) -> abs (fx - sx) + abs (fy - sy)) |> List.min match waterSources with | [] -> riverside | _ -> waterSources |> List.map (fun (x, y, _) -> x, y, SceneDetail.reflectionLength map x y, distToFloat x y) |> List.sortBy (fun (_, _, length, distance) -> -length, distance) |> List.head |> fun (x, y, _, _) -> (x, y) let reportVisibleReflections (shotName: string) = let vw = this.GraphicsDevice.Viewport.Width let vh = this.GraphicsDevice.Viewport.Height for (lx, ly, kind) in SceneDetail.lightSources map do for (rx, ry, alpha, _) in SceneDetail.reflectionStreakFor map lx ly kind do let ssx = lx * Sim.tilePixels - int camera.X let ssy = ly * Sim.tilePixels - int camera.Y let rsx = rx * Sim.tilePixels - int camera.X let rsy = ry * Sim.tilePixels - int camera.Y // 空白对照:同排最近的非倒影水面格。 let control = [ (2, 0); (-2, 0); (4, 0); (-4, 0); (0, 1); (0, -1) ] |> List.map (fun (ox, oy) -> rx + ox, ry + oy) |> List.tryFind (fun (cx, cy) -> SceneDetail.isWaterSurface map cx cy) if ssx >= 0 && ssx + Sim.tilePixels <= vw && ssy >= 0 && ssy + Sim.tilePixels <= vh && rsy + Sim.tilePixels <= vh then match control with | Some (cx, cy) -> printfn "p47-reflection %s src=%d,%d dst=%d,%d ctrl=%d,%d alpha=%d" shotName ssx ssy rsx rsy (cx * Sim.tilePixels - int camera.X) (cy * Sim.tilePixels - int camera.Y) alpha | None -> printfn "p47-reflection %s src=%d,%d dst=%d,%d ctrl=-1,-1 alpha=%d" shotName ssx ssy rsx rsy alpha for (rx, ry, rw) in SceneDetail.foregroundRooftops vw vh world.Tick do if rx + rw > 0 && rx < vw then printfn "p47-roof %s x=%d y=%d w=%d" shotName rx ry rw printfn "p47-elements reeds=%d floats=%d reflections=%d sources=%d maxstreak=%d" (List.length (SceneDetail.bankReedTiles map)) (List.length (SceneDetail.floatingProps map)) (List.length (SceneDetail.reflectionStreaks map)) (List.length (SceneDetail.lightSources map)) (SceneDetail.lightSources map |> List.map (fun (x, y, _) -> SceneDetail.reflectionLength map x y) |> List.fold max 0) match p47Step with | 0 -> setHour 12.0 placeCameraOn (fst riverside) (snd riverside) p47PendingName <- "p47-day" reportVisibleReflections "p47-day" p47Pending <- true | 1 -> setHour 23.5 placeCameraOn (fst riverside) (snd riverside) p47PendingName <- "p47-night" reportVisibleReflections "p47-night" p47Pending <- true | 2 -> setHour 12.0 placeCameraOn (fst closeup) (snd closeup) p47PendingName <- "p47-close" reportVisibleReflections "p47-close" p47Pending <- true | _ -> printfn "p47-shot=done" this.Exit() /// P47 证据:在 `Draw` 末尾落盘当前后台缓冲。 member private this.CaptureP47Shot () = if p47Pending then p47Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p47PendingName) printfn "p47-shot=%s tick=%d pos=(%.0f,%.0f) camera=(%.0f,%.0f)" p47PendingName world.Tick world.Avatar.Pos.X world.Avatar.Pos.Y camera.X camera.Y p47Step <- p47Step + 1 /// P52 证据:选职业开局 → 真实 Interact 开对话 → 真实 BuyFromTarget / SellToTarget 各一次。 member private this.PrepareP52Shot () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let bagText () = match m5View.Task with | None -> "-" | Some state -> state.Backpack |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) |> String.concat ";" if not p52Pending then match p52Step with | 0 -> setHour 12.0 let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View world <- nextWorld m5View <- nextView this.CenterCamera() p52PendingName <- "p52-dialog" p52Pending <- true | 1 -> let moneyBefore = world.Avatar.Mind.Needs.Money let bagBefore = bagText () let nextWorld, nextView = M5Interaction.apply M5Command.BuyFromTarget world m5View world <- nextWorld m5View <- nextView printfn "p52-trade buy npc=%A money_before=%.2f money_after=%.2f bag_before=[%s] bag_after=[%s] status=%s" world.Npcs.[0].Id moneyBefore world.Avatar.Mind.Needs.Money bagBefore (bagText ()) m5View.Status p52PendingName <- "p52-buy" p52Pending <- true | 2 -> let reopenedWorld, reopenedView = M5Interaction.apply M5Command.Interact world m5View world <- reopenedWorld m5View <- reopenedView let moneyBefore = world.Avatar.Mind.Needs.Money let bagBefore = bagText () let nextWorld, nextView = M5Interaction.apply M5Command.SellToTarget world m5View world <- nextWorld m5View <- nextView printfn "p52-trade sell npc=%A money_before=%.2f money_after=%.2f bag_before=[%s] bag_after=[%s] status=%s" world.Npcs.[0].Id moneyBefore world.Avatar.Mind.Needs.Money bagBefore (bagText ()) m5View.Status p52PendingName <- "p52-sell" p52Pending <- true | _ -> printfn "p52-shot=done" this.Exit() /// P52 证据:在 `Draw` 末尾落盘每帧,并打印面板/反馈/背包快照。 member private this.CaptureP52Shot () = if p52Pending then p52Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p52PendingName) let bag = match m5View.Task with | None -> "-" | Some state -> state.Backpack |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) |> String.concat ";" printfn "p52-shot=%s panel=%s status=%s bag=[%s]" p52PendingName (M5Interaction.panelName m5View) m5View.Status bag p52Step <- p52Step + 1 /// P54 证据:农夫真实开局 → 任务面板(待接受) → 接受 → 环境到达 + 对话 + 买/卖 /// 四条真实信号,验证 Offered→Active→Done 只由真实事件驱动。 member private this.PrepareP54Shot () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let ask (command: M5Command) = let nextWorld, nextView = M5Interaction.apply command world m5View world <- nextWorld m5View <- nextView if not p54Pending then match p54Step with | 0 -> setHour 12.0 let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } ask M5Command.ShowTaskPanel this.CenterCamera() p54PendingName <- "p54-task-offered" p54Pending <- true | 1 -> ask M5Command.AcceptTask p54PendingName <- "p54-task-active" p54Pending <- true | 2 -> ask M5Command.ClosePanel // 模拟跨入当前瓦片:以相邻瓦片为基线喂「到达瓦片」环境信号(真实折叠函数)。 let tile = (int (world.Avatar.Pos.X / float32 Sim.tilePixels), int (world.Avatar.Pos.Y / float32 Sim.tilePixels)) match m5View.Task with | Some state -> let previousTile = (fst tile + 1, snd tile) let signals = TaskRuntime.environmentSignals false false (Some previousTile) tile let advanced, message = TaskRuntime.foldSignals signals state m5View <- { m5View with Task = Some advanced; Status = (match message with Some m -> "task|" + m | None -> m5View.Status) } | None -> () this.CenterCamera() p54PendingName <- "p54-arrive" p54Pending <- true | 3 -> ask M5Command.Interact ask M5Command.Intent1 p54PendingName <- "p54-dialogue" p54Pending <- true | 4 -> ask M5Command.Interact ask M5Command.BuyFromTarget p54PendingName <- "p54-buy" p54Pending <- true | 5 -> ask M5Command.Interact ask M5Command.SellToTarget p54PendingName <- "p54-sell" p54Pending <- true | 6 -> ask M5Command.ShowTaskPanel p54PendingName <- "p54-task-done" p54Pending <- true | _ -> printfn "p54-shot=done" this.Exit() /// P54 证据:在 `Draw` 末尾落盘并打印任务模板/状态/反馈/背包。 member private this.CaptureP54Shot () = if p54Pending then p54Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p54PendingName) let stateName (state: Occupation.TaskState) = match state with | Occupation.Offered -> "offered" | Occupation.Active -> "active" | Occupation.Done -> "done" | Occupation.Failed -> "failed" let taskText = match m5View.Task with | None -> "occupation=none template=none state=none" | Some state -> match state.Today with | None -> "occupation=yes template=none state=none" | Some task -> sprintf "occupation=yes template=%s state=%s" (Occupation.taskNameOf task.TemplateId) (stateName task.State) let bag = match m5View.Task with | None -> "-" | Some state -> state.Backpack |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) |> String.concat ";" printfn "p54-shot=%s %s panel=%s status=%s bag=[%s]" p54PendingName taskText (M5Interaction.panelName m5View) m5View.Status bag p54Step <- p54Step + 1 /// P55 证据:跳到第 3 日(送粮)→ 面板(待接受) → 接受 → 真实卖出 Food(完成 +食物×1)。 member private this.PrepareP55Shot () = let setDay3Noon () = let tick = 3L * Sim.ticksPerDay + M6Presentation.resolveStartTick (Some 12.0) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let ask (command: M5Command) = let nextWorld, nextView = M5Interaction.apply command world m5View world <- nextWorld m5View <- nextView if not p55Pending then match p55Step with | 0 -> setDay3Noon () // 用真实跨日刷新把当日任务推进到第 3 日(seed 42/农夫 = 送粮)。 match m5View.Task with | Some state -> m5View <- { m5View with Task = Some(WorldBootstrap.refreshOccupationToday 42UL state world.Tick) } | None -> () let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } ask M5Command.ShowTaskPanel this.CenterCamera() p55PendingName <- "p55-task-offered" p55Pending <- true | 1 -> ask M5Command.AcceptTask p55PendingName <- "p55-task-active" p55Pending <- true | 2 -> ask M5Command.ClosePanel ask M5Command.Interact ask M5Command.SellToTarget p55PendingName <- "p55-reward" p55Pending <- true | 3 -> ask M5Command.ShowTaskPanel p55PendingName <- "p55-task-done" p55Pending <- true | _ -> printfn "p55-shot=done" this.Exit() /// P55 证据:落盘并打印模板/状态/streak/背包/反馈。 member private this.CaptureP55Shot () = if p55Pending then p55Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p55PendingName) let stateText (state: Occupation.TaskState) = match state with | Occupation.Offered -> "offered" | Occupation.Active -> "active" | Occupation.Done -> "done" | Occupation.Failed -> "failed" let templateText, taskState, streak = match m5View.Task with | None -> "none", "none", 0 | Some state -> (match state.Today with | Some task -> Occupation.taskNameOf task.TemplateId, stateText task.State, state.Streak | None -> "none", "none", state.Streak) let bag = match m5View.Task with | None -> "-" | Some state -> state.Backpack |> List.map (fun (item, quantity) -> sprintf "%A=%d" item quantity) |> String.concat ";" printfn "p55-shot=%s template=%s state=%s streak=%d panel=%s status=%s bag=[%s]" p55PendingName templateText taskState streak (M5Interaction.panelName m5View) m5View.Status bag p55Step <- p55Step + 1 /// P53 证据:真实录制提示出现/消失序列(远处 → 靠近 → 互动 → 关闭+离开)。 member private this.PrepareP53Recording () = let setHour (hour: float) = let tick = M6Presentation.resolveStartTick (Some hour) false world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } let placeAwayFromNpc () = let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = { X = npc.Pos.X + 240.0f; Y = npc.Pos.Y } } } if not p53Pending then match p53Step with | 0 -> setHour 12.0 // 只保留 0 号村民作候选,其余移远,隔离出唯一的提示对象(真实 resolver 不改)。 let npcs = Array.copy world.Npcs for index in 1 .. npcs.Length - 1 do npcs.[index] <- { npcs.[index] with Pos = { X = 4000.0f; Y = 4000.0f } } world <- { world with Npcs = npcs } placeAwayFromNpc () m5View <- M5Interaction.refreshPrompt world m5View this.CenterCamera() p53PendingName <- "p53-far" p53Pending <- true | 1 -> let npc = world.Npcs.[0] world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } m5View <- M5Interaction.refreshPrompt world m5View this.CenterCamera() p53PendingName <- "p53-prompt" p53Pending <- true | 2 -> let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View world <- nextWorld m5View <- nextView p53PendingName <- "p53-dialog" p53Pending <- true | 3 -> let closedWorld, closedView = M5Interaction.apply M5Command.ClosePanel world m5View world <- closedWorld m5View <- closedView placeAwayFromNpc () m5View <- M5Interaction.refreshPrompt world m5View this.CenterCamera() p53PendingName <- "p53-gone" p53Pending <- true | _ -> printfn "p53-rec=done" this.Exit() /// P53 证据:在 `Draw` 末尾落盘每帧,并打印面板/提示/目标/提示矩形。 member private this.CaptureP53Recording () = if p53Pending then p53Pending <- false System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p53PendingName) let targetText = match InteractionResolver.resolve world m5View.HomeMode with | Some target -> sprintf "%A" target.Action | None -> "none" let rectText = match m5View.Prompt, m5View.PromptTargetPos with | Some text, Some position -> let viewport = this.GraphicsDevice.Viewport let rect = FloatingPrompt.layout viewport.Width viewport.Height position.X position.Y camera.X camera.Y 2 text sprintf "%d,%d,%d,%d" rect.X rect.Y rect.Width rect.Height | _ -> "none" printfn "p53-rec=%s panel=%s prompt=%s target=%s rect=%s" p53PendingName (M5Interaction.panelName m5View) (m5View.Prompt |> Option.defaultValue "none") targetText rectText p53Step <- p53Step + 1 /// P46 证据:菜单阶段按帧表把 menuEntranceFrame 钉到灯笼呼吸的波峰/波谷,拍两相位。 member private this.PrepareP46Shot () = if p46MenuADone && p46MenuBDone then printfn "p46-shot=done" this.Exit() elif not p46MenuADone then // 每帧钉住目标帧,抵消 Update 末尾的自增,保证 Draw 看到精确相位。 menuEntranceFrame <- P46ShotScript.menuPeakFrame if not p46Pending then p46PendingName <- P46ShotScript.menuPeakName p46Pending <- true elif not p46MenuBDone then menuEntranceFrame <- P46ShotScript.menuTroughFrame if not p46Pending then p46PendingName <- P46ShotScript.menuTroughName p46Pending <- true /// 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}" // P44:弹层开启时用 空格/回车/E 翻页(下一页);最后一页再翻为 no-op,不崩。 let overlayOpen = dialogOverlay.IsSome if overlayOpen && (pressed Keys.Space || pressed Keys.Enter || pressed Keys.E) then match DialogOverlay.nextPage dialogOverlay.Value with | Some next -> dialogOverlay <- Some next | None -> () let m5Command = if overlayOpen then // P44:弹层开启只允许对话选项 1-6 + P52 买卖键;E/Q/Tab/C/T 等世界交互键全部屏蔽,不穿透。 if 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 m5View.Panel = DialoguePanel && pressed Keys.B then Some BuyFromTarget elif m5View.Panel = DialoguePanel && pressed Keys.V then Some SellToTarget else None else 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 m5View.Panel = DialoguePanel && pressed Keys.B then Some BuyFromTarget elif m5View.Panel = DialoguePanel && pressed Keys.V then Some SellToTarget 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 elif m5View.Panel = TaskPanel && pressedAny [ Keys.Enter; Keys.Space ] then Some AcceptTask 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 // P43:游戏内按 1-0 直接选中 hotbar 槽位;仅当世界输入允许(无面板/对话)时生效, // Esc 打开的菜单走 UpdateMenu 分支、根本不进这里,输入不穿透。 if HudLayout.hotbarInputAllowed (menu.Page = Playing) (M5Interaction.worldInputAllowed m5View) then let digitKeys = [ 1, Keys.D1; 2, Keys.D2; 3, Keys.D3; 4, Keys.D4; 5, Keys.D5 6, Keys.D6; 7, Keys.D7; 8, Keys.D8; 9, Keys.D9; 0, Keys.D0 ] for (digit, key) in digitKeys do if pressed key then match HudLayout.selectSlotForDigit digit with | Some slot -> hotbarSelected <- slot | None -> () if not relationView && not panelOpen then let input = if automated then 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 -> () // P54:把真实世界环境折叠成任务信号 —— 跨瓦片触发「到达瓦片」; // 夜里站在水边触发「夜在水边」。全部走 Kernel 纯函数,不按日历计数。 match m5View.Task with | Some state -> let tileX = int (world.Avatar.Pos.X / float32 Sim.tilePixels) let tileY = int (world.Avatar.Pos.Y / float32 Sim.tilePixels) let isNight = (M6Presentation.profileAtTick world.Tick).Mode = Night let nearWater = match riverscapeMap with | Some map -> [ 1, 0; -1, 0; 0, 1; 0, -1 ] |> List.exists (fun (dx, dy) -> SceneDetail.isWater map (tileX + dx) (tileY + dy)) | None -> false let signals = TaskRuntime.environmentSignals isNight nearWater lastAvatarTile (tileX, tileY) lastAvatarTile <- Some(tileX, tileY) let advanced, message = TaskRuntime.foldSignals signals state if advanced <> state then m5View <- { m5View with Task = Some advanced Status = (match message with Some text -> "task|" + text | None -> m5View.Status) StatusTick = (match message with Some _ -> Some world.Tick | None -> m5View.StatusTick) } | 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 if p46ShotMode && splashFrame >= 10L then // P46 取证:等画面绘制稳定后,把 splashFrame 钉到下一个未取证的目标帧, // 令 Draw 看到精确帧号(前若干帧 backbuffer 未铺满,故需 warm-up 门槛)。 let target = if not p46SplashRippleDone then Some P46ShotScript.splashRippleFrame elif not p46CloudEarlyDone then Some P46ShotScript.cloudEarlyFrame elif not p46CloudLateDone then Some P46ShotScript.cloudLateFrame else None match target with | Some t -> splashFrame <- t | None -> () 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 p43ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm elif menu.Page = OccupationSelect then if menu.Selected <> 0 then this.DispatchMenuInput Down else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP43Shot() elif p44ShotMode 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.PrepareP44Shot() elif p45ShotMode 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.PrepareP45Shot() elif p46ShotMode then if menu.Page = MainMenu then this.PrepareP46Shot() elif p47ShotMode 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.PrepareP47Shot() elif p52ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm elif menu.Page = OccupationSelect then let targetIndex = MenuState.occupationOptions |> List.findIndex (fun option -> option = Some p52Occupation) if menu.Selected <> targetIndex then this.DispatchMenuInput Down else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP52Shot() elif p53RecordMode 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.PrepareP53Recording() elif p54ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm elif menu.Page = OccupationSelect then let targetIndex = MenuState.occupationOptions |> List.findIndex (fun option -> option = Some p54Occupation) if menu.Selected <> targetIndex then this.DispatchMenuInput Down else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP54Shot() elif p55ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm elif menu.Page = OccupationSelect then let targetIndex = MenuState.occupationOptions |> List.findIndex (fun option -> option = Some Occupation.Farmer) if menu.Selected <> targetIndex then this.DispatchMenuInput Down else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP55Shot() 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}" | _ -> () if menu.Page = Playing then this.SyncDialogOverlay() 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() /// P42 证据:启动环境动效两帧 + 菜单动效一帧,拍完即退出。 member private this.CaptureP42Shot () = let save (name: string) = System.IO.Directory.CreateDirectory recordDirectory |> ignore this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory name) printfn "p42-shot=%s frame=%d" name (if splashActive then splashFrame else menuEntranceFrame) if splashActive then if not p42SplashEarlyDone && splashFrame >= P42ShotScript.splashEarlyFrame then p42SplashEarlyDone <- true save P42ShotScript.splashEarlyName if not p42SplashLateDone && splashFrame >= P42ShotScript.splashLateFrame then p42SplashLateDone <- true save P42ShotScript.splashLateName elif menu.Page <> Playing && menuEntranceFrame >= P42ShotScript.menuFrame then save P42ShotScript.menuName printfn "p42-shot=done" 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 p42ShotMode then this.CaptureP42Shot() 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() if p43ShotMode && menu.Page = Playing then this.CaptureP43Shot() if p44ShotMode && menu.Page = Playing then this.CaptureP44Shot() if p45ShotMode && menu.Page = Playing then this.CaptureP45Shot() if p46ShotMode then this.CaptureP46Shot() if p47ShotMode && menu.Page = Playing then this.CaptureP47Shot() if p52ShotMode && menu.Page = Playing then this.CaptureP52Shot() if p53RecordMode && menu.Page = Playing then this.CaptureP53Recording() if p54ShotMode && menu.Page = Playing then this.CaptureP54Shot() if p55ShotMode && menu.Page = Playing then this.CaptureP55Shot() /// 启动画面:分层天色 + 屋脊/水面剪影 + 标题淡入上浮 + 跳过提示,全部为帧计数纯函数。 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 // ③ 屋脊上方的双层视差云影:纯水平位移(随帧缓慢漂移),低 alpha 预乘柔光, // 只用椭圆行落成,绝不留大黑块/实心色块。 for layer in 0 .. MenuAmbience.cloudLayerCount - 1 do let cloudColor = VillageArt.premultiply 214 228 236 (MenuAmbience.cloudLayerAlpha layer) for (cx, cy, rx, ry) in MenuAmbience.cloudPuffs layer splashFrame width do for (runX, runY, runW) in VillageArt.ellipseRuns cx cy rx ry do spriteBatch.Draw(pixel, Rectangle(runX, runY, runW, 1), cloudColor) // 屋脊剪影随暗幕退场而上浮就位。 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) // 水面色带反射天色,随显现度提高而淡出;P42 叠加逐格涟漪(相位随帧/列/行)。 let waterAlpha = int (255.0f * (1.0f - reveal)) let waterColor = Color(150, 170, 178, waterAlpha) for (rowIndex, row) in List.indexed 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) for x in 0 .. Sim.tilePixels .. width - 1 do let column = x / Sim.tilePixels let brightness = MenuAmbience.waterColumnBrightness splashFrame column rowIndex let crestColor = Color( min 255 (int (150.0f * brightness)), min 255 (int (170.0f * brightness)), min 255 (int (178.0f * brightness)), waterAlpha) let lift = MenuAmbience.waterColumnLift splashFrame column rowIndex spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) if MenuAmbience.waterColumnCrest splashFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) // P42 屋檐灯笼呼吸光晕:复用 P14 预乘 alpha 径向路径,随帧与灯序号呼吸。 let lanternColor, _ = TitleScreen.lanternColors for (lanternIndex, (lx, ly)) in List.indexed layout.Lanterns do let breath = MenuAmbience.lanternBreathAlpha splashFrame lanternIndex for (rect, color) in VillageArt.lanternGlowLayers lx ly 28 6 252 216 148 breath do spriteBatch.Draw(pixel, rect, color) 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)) // 标题:淡入 + 上浮,先画深色投影再画亮面。 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; P42 adds per-tile ripple. let waterAlpha = int (255.0f * (1.0f - entrance)) let waterColor = Color(150, 170, 178, waterAlpha) for (rowIndex, row) in List.indexed 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) for x in 0 .. Sim.tilePixels .. width - 1 do let column = x / Sim.tilePixels let brightness = MenuAmbience.waterColumnBrightness menuEntranceFrame column rowIndex let crestColor = Color( min 255 (int (150.0f * brightness)), min 255 (int (170.0f * brightness)), min 255 (int (178.0f * brightness)), waterAlpha) let lift = MenuAmbience.waterColumnLift menuEntranceFrame column rowIndex spriteBatch.Draw(artTextures.TileAtlas, Rectangle(x, row + lift, Sim.tilePixels, Sim.tilePixels), VillageArt.atlasTileRectangle TitleScreen.waterSlot, crestColor) if MenuAmbience.waterColumnCrest menuEntranceFrame column rowIndex then spriteBatch.Draw(pixel, Rectangle(x, row + lift, Sim.tilePixels, 3), Color(225, 240, 245, waterAlpha)) // P42 bank lanterns breathe through the shared P14 premultiplied-alpha radial path. let lanternColor, _ = TitleScreen.lanternColors for (lanternIndex, (lx, ly)) in List.indexed layout.Lanterns do let breath = MenuAmbience.lanternBreathAlpha menuEntranceFrame lanternIndex for (rect, color) in VillageArt.lanternGlowLayers lx ly 28 6 252 216 148 breath do spriteBatch.Draw(pixel, rect, color) 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 | _ -> () // P47 场景细节:河道倒影 + 岸边芦苇加密度 + 漂浮元素 + 近景楼层层错落。 // 画在夜光/船之后,保证倒影不被灯笼呼吸光晕盖掉、仍可辨认。 match riverscapeMap with | Some map -> SceneDetailRender.draw spriteBatch artTextures pixel camera viewport.Width viewport.Height map world.Tick | None -> () 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() /// P44:按当前面板同步对话弹层模型;内容不变时保留翻页状态,离开 DialoguePanel 即关闭。 member private this.SyncDialogOverlay () = if m5View.Panel = DialoguePanel then let lines = M5Interaction.panelLines world m5View let key = String.concat "\n" lines match dialogOverlay with | Some _ when dialogOverlayKey = key -> () | _ -> dialogOverlayKey <- key let title = match lines with t :: _ -> t | [] -> "对话" let body = match lines with _ :: rest -> rest | [] -> [] dialogOverlay <- Some(DialogOverlay.create title body (this.GraphicsDevice.Viewport.Width) true) else dialogOverlay <- None dialogOverlayKey <- "" /// P44:绘制完整背景框 + 原点点阵头像 + 自动换行文字 + 分页标记。 member private this.DrawDialogOverlay (model: DialogOverlay.Model) = let viewport = this.GraphicsDevice.Viewport let profile = M6Presentation.profileAtTick world.Tick let palette = HudLayout.palette profile.Mode let layout = DialogOverlay.rect viewport.Width viewport.Height model let current = DialogOverlay.view model let lx = layout.X let ly = layout.Y spriteBatch.Begin() // 完整背景框:上下两段半透明深色渐变 + 四边细边框 + 顶部强调线。 spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, layout.Height / 2), Color(30, 44, 52, 224)) spriteBatch.Draw( pixel, Rectangle(lx, ly + layout.Height / 2, layout.Width, layout.Height - layout.Height / 2), Color(14, 22, 30, 232)) spriteBatch.Draw(pixel, Rectangle(lx, ly, layout.Width, 3), palette.Accent) spriteBatch.Draw(pixel, Rectangle(lx, ly + layout.Height - 2, layout.Width, 2), palette.Outline) spriteBatch.Draw(pixel, Rectangle(lx, ly, 2, layout.Height), palette.Outline) spriteBatch.Draw(pixel, Rectangle(lx + layout.Width - 2, ly, 2, layout.Height), palette.Outline) // 头像:原创 8x16 点阵 @ scale 4 = 32x64,加内框。 if model.ShowAvatar then let avatar = DialogOverlay.avatarRect layout spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y - 4, avatar.Width + 8, avatar.Height + 8), palette.SegmentBackdrop) spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y - 4, avatar.Width + 8, 2), palette.Accent) spriteBatch.Draw(pixel, Rectangle(avatar.X - 4, avatar.Y + avatar.Height + 2, avatar.Width + 8, 2), palette.Outline) this.DrawPattern DialogOverlay.avatarPattern avatar.X avatar.Y DialogOverlay.avatarScale palette.Value let textX = DialogOverlay.textOriginX layout model.ShowAvatar let titleY = ly + DialogOverlay.paddingY for (dx, dy) in HudLayout.textOutlineOffsets do PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (titleY + dy) DialogOverlay.textScale palette.Outline model.Title PixelText.draw spriteBatch pixel cjkAtlas textX titleY DialogOverlay.textScale palette.Accent model.Title let bodyY = DialogOverlay.textOriginY layout current.Lines |> List.iteri (fun index line -> let y = bodyY + index * DialogOverlay.lineHeight for (dx, dy) in HudLayout.textOutlineOffsets do PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (y + dy) DialogOverlay.textScale palette.Outline line PixelText.draw spriteBatch pixel cjkAtlas textX y DialogOverlay.textScale palette.Value line) // 分页标记:页码 + 翻页/关闭提示;有下一页时显示箭头。 let marker = sprintf "第 %d/%d 页 空格 翻页 Esc 关闭" current.PageNumber current.PageCount let markerWidth = FloatingPrompt.measureWidth marker DialogOverlay.textScale let markerX = lx + layout.Width - DialogOverlay.paddingX - markerWidth let markerY = ly + layout.Height - DialogOverlay.pageMarkerHeight PixelText.draw spriteBatch pixel cjkAtlas markerX markerY DialogOverlay.textScale palette.Label marker if current.HasNext then PixelText.draw spriteBatch pixel cjkAtlas (markerX - 18) markerY DialogOverlay.textScale palette.Accent ">" spriteBatch.End() member private this.DrawM5Overlay() = this.DrawHudBar() this.DrawCalendarCard() this.DrawHotbar() match dialogOverlay with | Some model -> this.DrawDialogOverlay model | None -> 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 let isEnergy = segment.Icon = HudLayout.EnergyIcon for (dx, dy) in HudLayout.textOutlineOffsets do drawIcon (x + dx) (iconY + dy) palette.Outline segment.Icon PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Label if not isEnergy then PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced + dx) (textY + dy) HudLayout.textScale palette.Outline segment.Value drawIcon x iconY palette.Value segment.Icon PixelText.draw spriteBatch pixel cjkAtlas textX textY HudLayout.textScale palette.Label segment.Label if isEnergy then // P43:体力改图形化条形,填充比例 = 真实 Needs.Energy / 100(0..100),非伪造。 let valueX = textX + labelAdvanced let valueWidth = max 12 (FloatingPrompt.measureWidth segment.Value HudLayout.textScale) let barY = (barHeight - HudLayout.energyBarHeight) / 2 let track = HudLayout.energyBarTrack valueX barY valueWidth spriteBatch.Draw(pixel, track, palette.Outline) spriteBatch.Draw( pixel, Rectangle(track.X + 1, track.Y + 1, max 1 (track.Width - 2), track.Height - 2), palette.SegmentBackdrop) let fill = HudLayout.energyBarFillRect (track.X + 1) (track.Y + 1) (max 1 (track.Width - 2)) world.Avatar.Mind.Needs.Energy if fill.Width > 0 then let barColor = match profile.Mode with | Day -> Color(212, 168, 96) | Night -> Color(120, 168, 196) spriteBatch.Draw(pixel, fill, barColor) else PixelText.draw spriteBatch pixel cjkAtlas (textX + labelAdvanced) textY HudLayout.textScale palette.Value segment.Value spriteBatch.End() /// 按 7x7 点阵行用像素贴图绘制图标(无外部素材);调用方须已 Begin。 member private this.DrawPattern (rows: string list) (iconX: int) (iconY: int) (scale: int) (color: Color) = rows |> List.iteri (fun row bits -> bits |> Seq.iteri (fun column bit -> if bit = '1' then spriteBatch.Draw(pixel, Rectangle(iconX + column * scale, iconY + row * scale, scale, scale), color))) /// P43:右上角日历钟卡(季节/第 N 天/时段/时钟),半透明深色底 + 细边框,绝不越界。 member private this.DrawCalendarCard() = let viewport = this.GraphicsDevice.Viewport let profile = M6Presentation.profileAtTick world.Tick let palette = HudLayout.palette profile.Mode let reading = HudLayout.calendarReading world.Tick let rect = HudLayout.calendarCardRect viewport.Width reading let line1 = HudLayout.calendarLine1 reading let line2 = HudLayout.calendarLine2 reading spriteBatch.Begin() spriteBatch.Draw(pixel, rect, palette.SegmentBackdrop) spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, rect.Width, 2), palette.Accent) spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Bottom - 2, rect.Width, 2), palette.Outline) spriteBatch.Draw(pixel, Rectangle(rect.X, rect.Y, 2, rect.Height), palette.Outline) spriteBatch.Draw(pixel, Rectangle(rect.Right - 2, rect.Y, 2, rect.Height), palette.Outline) let iconX = rect.X + HudLayout.calendarPadX let iconY = rect.Y + (rect.Height - HudLayout.iconHeight) / 2 this.DrawPattern (HudLayout.iconRows HudLayout.calendarIcon) iconX iconY HudLayout.iconScale palette.Value let textX = iconX + HudLayout.iconWidth + HudLayout.iconGap let line1Y = rect.Y + HudLayout.calendarPadY let line2Y = line1Y + 8 * HudLayout.textScale + HudLayout.calendarLineGap for (dx, dy) in HudLayout.textOutlineOffsets do PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (line1Y + dy) HudLayout.textScale palette.Outline line1 PixelText.draw spriteBatch pixel cjkAtlas (textX + dx) (line2Y + dy) HudLayout.textScale palette.Outline line2 PixelText.draw spriteBatch pixel cjkAtlas textX line1Y HudLayout.textScale palette.Value line1 PixelText.draw spriteBatch pixel cjkAtlas textX line2Y HudLayout.textScale palette.Accent line2 spriteBatch.End() /// P43:底部居中 10 格 hotbar,原创点阵图标 + 选中高亮 + 键位数字;半透明底 + 细边框。 member private this.DrawHotbar() = let viewport = this.GraphicsDevice.Viewport let profile = M6Presentation.profileAtTick world.Tick let palette = HudLayout.palette profile.Mode let bar = HudLayout.hotbarBarRect viewport.Width viewport.Height spriteBatch.Begin() spriteBatch.Draw(pixel, bar, palette.SegmentBackdrop) spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, bar.Width, 2), palette.Accent) spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Bottom - 2, bar.Width, 2), palette.Outline) spriteBatch.Draw(pixel, Rectangle(bar.X, bar.Y, 2, bar.Height), palette.Outline) spriteBatch.Draw(pixel, Rectangle(bar.Right - 2, bar.Y, 2, bar.Height), palette.Outline) for index in 0 .. HudLayout.hotbarSlotCount - 1 do let slot = HudLayout.hotbarSlotRect viewport.Width viewport.Height index let selected = index = hotbarSelected let slotBackdrop = if selected then Color(48, 66, 74, 210) else palette.SegmentBackdrop spriteBatch.Draw(pixel, slot, slotBackdrop) let border = if selected then palette.Accent else palette.Outline spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, slot.Width, 2), border) spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Bottom - 2, slot.Width, 2), border) spriteBatch.Draw(pixel, Rectangle(slot.X, slot.Y, 2, slot.Height), border) spriteBatch.Draw(pixel, Rectangle(slot.Right - 2, slot.Y, 2, slot.Height), border) let iconRect = HudLayout.hotbarIconRect slot let iconColor = if selected then palette.Accent else palette.Value this.DrawPattern (HudLayout.hotbarIconRows index) iconRect.X iconRect.Y HudLayout.hotbarIconScale iconColor let label = if index = 9 then "0" else string (index + 1) PixelText.draw spriteBatch pixel cjkAtlas (slot.X + 3) (slot.Y + 2) 1 (if selected then palette.Accent else palette.Label) label spriteBatch.End() /// 常驻情境状态:顶部条下方的小药丸,仅 WorldPanel 且瞬态未过期时出现。 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()