diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 15:54:00 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 15:54:00 +0800 |
| commit | 3aa0049bb092694b5168d99bc6ac893b1db4740a (patch) | |
| tree | b3cf6a0e21442780cd72e8f6a842a4f3db1cecca /src/LivingVillage.Desktop/Game.fs | |
| parent | 9724663602b4b1eb755bf334c475dc2c80e4b386 (diff) | |
| download | living-village-3aa0049bb092694b5168d99bc6ac893b1db4740a.tar.gz | |
feat(m6b): add playable menu and save flow
[Change Nature]
- This commit adds the M6b desktop menu and persistence integration.
[New Capability]
- Provide new game, continue, settings, controls, load error, save, load, and exit flows.
- Provide a bounded autoplay flow covering menu navigation and persistence.
[Implementation]
- Add pure menu state transitions and focused M6b coverage.
- Connect menu commands to simulation controls, WorldSave, view resets, and rendering.
[Impact]
- Desktop runtime gains the playable M6b front end without changing out-of-scope files.
- Release focused and solution test suites remain passing.
Diffstat (limited to 'src/LivingVillage.Desktop/Game.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 367 |
1 files changed, 289 insertions, 78 deletions
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 232930d..5465ba2 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -116,6 +116,7 @@ module PixelText = ':', [| "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 @@ -137,8 +138,13 @@ type LivingVillageGame() as this = inherit Game() let autoplay = Environment.GetEnvironmentVariable("LV_AUTOPLAY") = "1" - let titleSuffix = if autoplay then " [AUTOPLAY]" else "" - let modeName = if autoplay then "autoplay" else "keyboard" + let autoplayFlow = Environment.GetEnvironmentVariable("LV_AUTOPLAY_FLOW") = "1" + let automated = autoplay || autoplayFlow + let titleSuffix = if automated then " [AUTOPLAY]" else "" + let modeName = + if autoplayFlow then "autoplay-flow" + elif autoplay then "autoplay" + else "keyboard" let configuredStepsPerFrame = match Environment.GetEnvironmentVariable("LV_STEPS_PER_FRAME") with | null | "" -> 1 @@ -167,6 +173,9 @@ type LivingVillageGame() as this = let mutable m5View = M5Interaction.initial let mutable simulationControl = initialSimulationControl let mutable legacyStepsPerFrame = initialLegacyStepsPerFrame + let mutable menu = MenuState.create initialSimulationControl false + let mutable autoplayFrames = 0 + let mutable flowStep = 0 do graphics.PreferredBackBufferWidth <- 1280 @@ -174,7 +183,7 @@ type LivingVillageGame() as this = graphics.SynchronizeWithVerticalRetrace <- true this.IsFixedTimeStep <- true this.TargetElapsedTime <- TimeSpan.FromTicks(TimeSpan.TicksPerSecond / 60L) - this.Window.Title <- if autoplay then "Living Village M6 [AUTOPLAY]" else "Living Village M6" + this.Window.Title <- $"Living Village M6{titleSuffix}" 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() = @@ -188,107 +197,246 @@ type LivingVillageGame() as this = let y = MathHelper.Clamp(world.Avatar.Pos.Y + half - vh / 2.0f, 0.0f, max 0.0f (worldH - vh)) camera <- Vector2(x, y) - override this.Initialize() = - base.Initialize() - prevKb <- Keyboard.GetState() + member private this.ResetWorldView() = + relationView <- false + relationSnapshot <- None + m5View <- M5Interaction.initial + this.CenterCamera() - override _.LoadContent() = - spriteBatch <- new SpriteBatch(this.GraphicsDevice) - atlas <- Atlas.build this.GraphicsDevice - pixel <- new Texture2D(this.GraphicsDevice, 1, 1) - pixel.SetData([| Color.White |]) + member private this.StartNewGame() = + world <- Sim.initialWorldN 42UL Sim.npcCount + this.ResetWorldView() + menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame + printfn $"new-game=ok seed=42 npcs={Sim.npcCount}" - override this.Update(gameTime: GameTime) = - let kb = Keyboard.GetState() - let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) + member private this.SaveWorld (prefix: string) : bool = + try + WorldSave.saveToFile savePath world + printfn $"{prefix}=ok path={savePath} tick={world.Tick}" + true + with + | ex -> + printfn $"{prefix}=error path={savePath} message={ex.Message}" + false + + member private this.LoadWorld (prefix: string) : Result<int64, string> = + match WorldSave.loadFromFile savePath with + | Ok nextWorld -> + world <- nextWorld + this.ResetWorldView() + 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 -> this.StartNewGame() + | 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 && not autoplayFlow && menu.Page = MainMenu && not menu.HasCurrentWorld -> + autoplayFrames <- autoplayFrames + 1 + if autoplayFrames >= 15 then + this.DispatchMenuInput Confirm + | None -> () + + member private this.UpdatePlaying (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 simulationControl <- SimulationControl.togglePause simulationControl + menu <- MenuState.setSettings simulationControl menu printfn $"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 - try - WorldSave.saveToFile savePath world - printfn $"save=ok path={savePath} tick={world.Tick}" - with - | ex -> printfn $"save=error path={savePath} message={ex.Message}" + this.SaveWorld "save" |> ignore if pressed Keys.F7 then - match WorldSave.loadFromFile savePath with - | Ok nextWorld -> - world <- nextWorld - relationView <- false - relationSnapshot <- None - m5View <- M5Interaction.initial - this.CenterCamera() - printfn $"load=ok path={savePath} tick={world.Tick}" - | Error failure -> printfn $"load=error path={savePath} message={failure}" - if escapePressed 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.Exit() - if lPressed then - relationView <- not relationView - if relationView then relationSnapshot <- Some(Sim.relationMatrix world) - let state = if relationView then "on" else "off" - printfn $"relation view={state} tick={world.Tick}" - let m5Command = - if pressed Keys.E then Some Interact - elif pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 - elif pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 - elif pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 - elif pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 - elif pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 - elif pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 - elif pressed Keys.Q then Some Observe - elif pressed Keys.Tab then Some ToggleNeeds - elif pressed Keys.C then Some ShowChronicle - else None - match m5Command with - | Some command -> - let nextWorld, nextView = M5Interaction.apply command world m5View - world <- nextWorld - m5View <- nextView - printfn $"m5 panel={M5Interaction.panelName m5View} status={m5View.Status} tick={world.Tick}" - | None -> () - let panelOpen = m5View.Panel <> WorldPanel - if not relationView && not panelOpen then - let input = - if autoplay 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 - for _ in 1 .. stepCount do - world <- Sim.step ts world - this.CenterCamera() + this.OpenMainMenu() + leftGame <- true + if not loadFailed && not leftGame then + if lPressed then + relationView <- not relationView + if relationView then relationSnapshot <- Some(Sim.relationMatrix world) + let state = if relationView then "on" else "off" + printfn $"relation view={state} tick={world.Tick}" + let m5Command = + if pressed Keys.E then Some Interact + elif pressedAny [ Keys.D1; Keys.NumPad1 ] then Some Intent1 + elif pressedAny [ Keys.D2; Keys.NumPad2 ] then Some Intent2 + elif pressedAny [ Keys.D3; Keys.NumPad3 ] then Some Intent3 + elif pressedAny [ Keys.D4; Keys.NumPad4 ] then Some Intent4 + elif pressedAny [ Keys.D5; Keys.NumPad5 ] then Some Intent5 + elif pressedAny [ Keys.D6; Keys.NumPad6 ] then Some Intent6 + elif pressed Keys.Q then Some Observe + elif pressed Keys.Tab then Some ToggleNeeds + elif pressed Keys.C then Some ShowChronicle + else None + match m5Command with + | Some command -> + let nextWorld, nextView = M5Interaction.apply command world m5View + world <- nextWorld + m5View <- nextView + printfn $"m5 panel={M5Interaction.panelName m5View} status={m5View.Status} tick={world.Tick}" + | None -> () + let panelOpen = m5View.Panel <> WorldPanel + if not relationView && not panelOpen then + let input = + if automated then + let t = float32 world.Tick + { MoveX = MathF.Sin(t / 60.0f) * 0.8f + MoveY = MathF.Cos(t / 90.0f) * 0.6f } + else + { MoveX = + (if kb.IsKeyDown(Keys.D) then 1.0f + elif kb.IsKeyDown(Keys.A) then -1.0f + else 0.0f) + MoveY = + (if kb.IsKeyDown(Keys.S) then 1.0f + elif kb.IsKeyDown(Keys.W) then -1.0f + else 0.0f) } + let ts = { Input = input } + let stepCount = SimulationControl.stepsPerFrameWithLegacy legacyStepsPerFrame simulationControl + if stepCount > 0 then + for _ in 1 .. stepCount do + world <- Sim.step ts world + this.CenterCamera() + + member private this.RunAutoplayFlow() = + let moveDown count = + for _ in 1 .. count do + this.DispatchMenuInput Down + match flowStep with + | 0 -> + printfn "flow=main-menu" + this.DispatchMenuInput Confirm + printfn "flow=new-game" + flowStep <- 1 + | 1 when menu.Page = Playing -> + this.OpenMainMenu() + printfn "flow=return-menu" + flowStep <- 2 + | 2 when menu.Page = MainMenu -> + moveDown 3 + this.DispatchMenuInput Confirm + printfn "flow=settings=open" + this.DispatchMenuInput Down + this.DispatchMenuInput Confirm + this.DispatchMenuInput Back + printfn "flow=settings=back" + flowStep <- 3 + | 3 when menu.Page = MainMenu -> + moveDown 4 + this.DispatchMenuInput Confirm + printfn "flow=controls=open" + this.DispatchMenuInput Back + printfn "flow=controls=back" + flowStep <- 4 + | 4 when menu.Page = MainMenu -> + this.DispatchMenuInput Confirm + this.SaveWorld "flow=save" |> ignore + flowStep <- 5 + | 5 when menu.Page = Playing -> + this.LoadWorld "flow=load" |> ignore + 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) + atlas <- Atlas.build this.GraphicsDevice + pixel <- new Texture2D(this.GraphicsDevice, 1, 1) + pixel.SetData([| Color.White |]) + + 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 autoplayFlow then + this.RunAutoplayFlow() + elif menu.Page = Playing then + this.UpdatePlaying gameTime kb pressed pressedAny + else + this.UpdateMenu pressed fpsFrames <- fpsFrames + 1 fpsSeconds <- fpsSeconds + gameTime.ElapsedGameTime.TotalSeconds if fpsSeconds >= 1.0 then @@ -305,8 +453,15 @@ type LivingVillageGame() as this = | Day -> "DAY" | Night -> "NIGHT" let viewTag = if relationView then " | [RELATIONS]" else "" + let pageName = + match menu.Page with + | MainMenu -> "MAIN MENU" + | Settings -> "SETTINGS" + | Controls -> "CONTROLS" + | Playing -> "PLAYING" + | LoadError -> "LOAD ERROR" this.Window.Title <- - $"Living Village M6{titleSuffix} | fps {fps:F1} | tick {world.Tick} | {dayNight} | {M6Presentation.clockLabel simulationControl} | npc {npcAction}{viewTag} | panel {M5Interaction.panelName m5View} | {M5Interaction.titleText m5View}" + $"Living Village M6{titleSuffix} | {pageName} | fps {fps:F1} | tick {world.Tick} | {dayNight} | {M6Presentation.clockLabel simulationControl} | npc {npcAction}{viewTag} | panel {M5Interaction.panelName m5View} | {M5Interaction.titleText m5View}" printfn $"fps={fps:F1} tick={world.Tick} pos=({world.Avatar.Pos.X:F0},{world.Avatar.Pos.Y:F0})" prevKb <- kb @@ -350,12 +505,68 @@ type LivingVillageGame() as this = spriteBatch.End() override this.Draw(gameTime: GameTime) = - if relationView then + if menu.Page <> Playing then + this.DrawMenu() + elif relationView then this.DrawRelationView() this.DrawM5Overlay() else this.DrawWorldView() + member private this.DrawMenu() = + this.DrawWorldView() + let viewport = this.GraphicsDevice.Viewport + let width = viewport.Width + let height = viewport.Height + let panelWidth = min 720 (max 1 (width - 64)) + let panelHeight = min 640 (max 1 (height - 48)) + let panelX = (width - panelWidth) / 2 + let panelY = (height - panelHeight) / 2 + let innerX = panelX + 32 + let textScale = 3 + let lineHeight = 30 + 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 x y textScale color text + let drawSelection (index: int) (label: string) (y: int) = + let selected = index = menu.Selected + let prefix = if selected then ">" else " " + drawText innerX y (if selected then selectedColor else textColor) (sprintf "%s %s" prefix label) + spriteBatch.Begin() + spriteBatch.Draw(pixel, Rectangle(0, 0, width, height), Color(4, 9, 14, 190)) + spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, panelHeight), Color(11, 19, 27, 245)) + spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 6), Color(92, 180, 190)) + drawText innerX (panelY + 28) (Color(235, 246, 232)) "LIVING VILLAGE" + let contentY = panelY + 100 + match menu.Page with + | MainMenu -> + drawText innerX (panelY + 70) mutedColor "MAIN MENU" + MenuState.items menu + |> List.iteri (fun index item -> drawSelection index (MenuState.itemLabel item) (contentY + index * lineHeight)) + drawText innerX (panelY + panelHeight - 54) mutedColor "UP DOWN SELECT ENTER CONFIRM" + | Settings -> + drawText innerX (panelY + 70) mutedColor "SETTINGS" + MenuState.settings + |> List.iteri (fun index setting -> drawSelection index (MenuState.settingsLabel setting) (contentY + index * lineHeight)) + drawText innerX (panelY + panelHeight - 84) mutedColor (sprintf "ACTIVE %s" (M6Presentation.clockLabel menu.Settings)) + drawText innerX (panelY + panelHeight - 54) mutedColor "ENTER APPLY ESC BACK" + | Controls -> + drawText innerX (panelY + 70) mutedColor "CONTROLS" + MenuState.controlRows + |> List.iteri (fun index row -> + drawText innerX (contentY + index * 25) textColor (sprintf "%s %s" row.Keys row.Action)) + drawText innerX (panelY + panelHeight - 28) mutedColor "ESC BACK" + | LoadError -> + drawText innerX (panelY + 70) (Color(240, 130, 120)) "LOAD ERROR" + let message = menu.Error |> Option.defaultValue "NO SAVE AVAILABLE" |> fun value -> value.ToUpperInvariant() + let visible = if message.Length > 34 then message.Substring(0, 34) else message + drawText innerX contentY textColor visible + drawText innerX (contentY + lineHeight * 2) mutedColor "ENTER OR ESC BACK" + | Playing -> () + spriteBatch.End() + member private this.DrawWorldView() = let profile = M6Presentation.profileAtTick world.Tick this.GraphicsDevice.Clear(profile.Background) |
