diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-20 14:20:54 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-20 14:20:54 +0800 |
| commit | 7e197babfbb68d7f7d1866d6a0ca47cf17d3af23 (patch) | |
| tree | abf951825edce69139f099a73dfb6f2575ff0259 /src/LivingVillage.Desktop | |
| parent | 4f8aca922e6cf47dce95aab79541065d487d5272 (diff) | |
| download | living-village-7e197babfbb68d7f7d1866d6a0ca47cf17d3af23.tar.gz | |
feat(m5): 增加可交互对话、需求观察与编年史
[变更性质]
- 本提交新增 M5 用户能力,不是缺陷修复。
[新增功能]
- 增加玩家六意图对话、需求面板、可见 NPC 观察、编年史与因果谣言冒烟验收。
[实现方案]
- 在 Kernel 中扩展 Avatar Mind、对话结果、观察数据、Annals 和确定性谣言链。
- 在 Desktop 中加入键盘交互、像素文本面板和运行时状态展示,并补充 Headless 与测试覆盖。
[影响范围]
- 覆盖 Kernel、Headless、Desktop 及测试工程;保留既有 M3/M4 确定性与交易、关系、谣言行为。
Diffstat (limited to 'src/LivingVillage.Desktop')
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 133 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 211 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 |
3 files changed, 338 insertions, 7 deletions
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 62fccb9..09d3364 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -71,7 +71,67 @@ module NpcView = | Wander -> "wander" | Work -> "work" | Chat -> "chat" - | Work -> "work" + +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" |] + '?', [| "01110"; "10001"; "00001"; "00010"; "00100"; "00000"; "00100" |] ] + |> Map.ofList + + let draw (spriteBatch: SpriteBatch) (pixel: Texture2D) (x: int) (y: int) (scale: int) (color: Color) (text: string) = + let fallback = glyphs.['?'] + let mutable cursor = x + for character in text.ToUpperInvariant() do + if character = ' ' then + cursor <- cursor + (4 * scale) + else + let rows = glyphs |> Map.tryFind character |> Option.defaultValue fallback + 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) type LivingVillageGame() as this = inherit Game() @@ -98,6 +158,7 @@ type LivingVillageGame() as this = let mutable prevKb = Unchecked.defaultof<KeyboardState> let mutable relationView = false let mutable relationSnapshot: float32[,] option = None + let mutable m5View = M5Interaction.initial do graphics.PreferredBackBufferWidth <- 1280 @@ -105,8 +166,9 @@ 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 M1 [AUTOPLAY]" else "Living Village M1" + this.Window.Title <- if autoplay then "Living Village M5 [AUTOPLAY]" else "Living Village M5" printfn $"mode={modeName} seed=42" + printfn "controls=WASD move E interact 1-6 choose Q observe Tab needs C chronicle L relations Esc close/exit" member private this.CenterCamera() = let viewport = this.GraphicsDevice.Viewport let vw = float32 viewport.Width @@ -130,15 +192,44 @@ type LivingVillageGame() as this = override this.Update(gameTime: GameTime) = let kb = Keyboard.GetState() - if kb.IsKeyDown(Keys.Escape) then this.Exit() - let lPressed = kb.IsKeyDown(Keys.L) && not (prevKb.IsKeyDown(Keys.L)) - prevKb <- kb + let pressed (key: Keys) = kb.IsKeyDown(key) && not (prevKb.IsKeyDown(key)) + let pressedAny (keys: Keys list) = keys |> List.exists pressed + let lPressed = pressed Keys.L + let escapePressed = pressed Keys.Escape + if 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}" + 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}" - if not relationView then + 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 @@ -168,8 +259,10 @@ type LivingVillageGame() as this = NpcView.actionName world.Npcs.[0].Mind.Action else "none" let viewTag = if relationView then " | [RELATIONS]" else "" - this.Window.Title <- $"Living Village M3b{titleSuffix} | fps {fps:F1} | tick {world.Tick} | npc {npcAction}{viewTag}" + this.Window.Title <- + $"Living Village M5{titleSuffix} | fps {fps:F1} | tick {world.Tick} | 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 member private this.DrawLine (a: Vector2) (b: Vector2) (thickness: float32) (color: Color) = let d = b - a @@ -212,6 +305,7 @@ type LivingVillageGame() as this = override this.Draw(gameTime: GameTime) = if relationView then this.DrawRelationView() + this.DrawM5Overlay() else this.DrawWorldView() @@ -242,3 +336,28 @@ type LivingVillageGame() as this = let (NpcId idx) = npc.Id spriteBatch.Draw(pixel, npcDst, npcShades.[idx % npcShades.Length]) spriteBatch.End() + this.DrawM5Overlay() + + member private this.DrawM5Overlay() = + let viewport = this.GraphicsDevice.Viewport + let panelWidth = min 460 (max 1 (viewport.Width - 32)) + let panelHeight = min 520 (max 1 (viewport.Height - 32)) + let panelX = max 16 (viewport.Width - panelWidth - 16) + let panelY = 16 + let panel = Rectangle(panelX, panelY, panelWidth, panelHeight) + let innerX = panelX + 16 + let innerWidth = panelWidth - 32 + let textScale = 2 + let lineHeight = 18 + let maxChars = max 1 (innerWidth / (6 * textScale)) + let maxLines = max 1 ((panelHeight - 32) / lineHeight) + let lines = M5Interaction.panelLines world m5View |> List.truncate maxLines + spriteBatch.Begin() + spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 220)) + spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 4), Color(92, 180, 190)) + lines + |> 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(220, 235, 220) else Color(190, 205, 210) + PixelText.draw spriteBatch pixel innerX (panelY + 12 + index * lineHeight) textScale color visible) + spriteBatch.End() diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs new file mode 100644 index 0000000..6f175d6 --- /dev/null +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -0,0 +1,211 @@ +namespace LivingVillage.Desktop + +open System +open LivingVillage.Kernel +open LivingVillage.Kernel.Sim + +type M5Panel = + | WorldPanel + | NeedsPanel + | ObservationPanel + | DialoguePanel + | ChroniclePanel + +type M5Command = + | Interact + | Intent1 + | Intent2 + | Intent3 + | Intent4 + | Intent5 + | Intent6 + | Observe + | ToggleNeeds + | ShowChronicle + | ClosePanel + +type M5View = + { Panel: M5Panel + Menu: DialogueMenu option + Needs: NeedsPanel option + Observations: Observation list + Chronicle: string + Status: string } + +module M5Interaction = + + let initial : M5View = + { Panel = WorldPanel + Menu = None + Needs = None + Observations = [] + Chronicle = "" + Status = "ready" } + + let private observationBounds (world: World) : VisibleBounds = + let radius = Sim.chatRangePx + { Min = { X = world.Avatar.Pos.X - radius; Y = world.Avatar.Pos.Y - radius } + Max = { X = world.Avatar.Pos.X + radius; Y = world.Avatar.Pos.Y + radius } } + + let private menuStatus (menu: DialogueMenu) : string = + sprintf "dialogue target=%A options=%d; press 1-6" menu.Target menu.Options.Length + + let private intentName (intent: DialogueIntent) : string = + match intent with + | SmallTalk -> "SMALLTALK" + | AskHelp -> "ASK HELP" + | OfferTrade -> "OFFER TRADE" + | Joke -> "JOKE" + | Apologize -> "APOLOGIZE" + | Provoke -> "PROVOKE" + + let private needName (kind: NeedKind) : string = + match kind with + | HungerNeed -> "HUNGER" + | EnergyNeed -> "ENERGY" + | SocialNeed -> "SOCIAL" + | MoneyNeed -> "MONEY" + + let private npcNumber (NpcId id) = id + + let private chooseIntent (index: int) (world: World) (view: M5View) : World * M5View = + match view.Menu with + | None -> world, { view with Status = "no dialogue menu; press E near an NPC" } + | Some menu -> + match List.tryItem index menu.Options with + | None -> world, { view with Status = "invalid dialogue option" } + | Some intent -> + match Sim.chooseDialogue menu.Target intent world with + | DialogueSucceeded(outcome, next) -> + next, + { view with + Panel = DialoguePanel + Menu = None + Chronicle = Sim.annalText next + Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response } + | DialogueRejected(failure, next) -> + next, { view with Status = sprintf "dialogue rejected: %A" failure } + + let apply (command: M5Command) (world: World) (view: M5View) : World * M5View = + match command with + | Interact -> + match Sim.openDialogueMenu world with + | Some menu -> + world, + { view with + Panel = DialoguePanel + Menu = Some menu + Status = menuStatus menu } + | None -> world, { view with Panel = WorldPanel; Menu = None; Status = "no nearby NPC available" } + | Intent1 -> chooseIntent 0 world view + | Intent2 -> chooseIntent 1 world view + | Intent3 -> chooseIntent 2 world view + | Intent4 -> chooseIntent 3 world view + | Intent5 -> chooseIntent 4 world view + | Intent6 -> chooseIntent 5 world view + | Observe -> + let observations = Sim.observeVisible (observationBounds world) world + world, + { view with + Panel = ObservationPanel + Observations = observations + Status = sprintf "observation visible_npcs=%d" observations.Length } + | ToggleNeeds -> + if view.Panel = NeedsPanel then + world, { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" } + else + world, + { view with + Panel = NeedsPanel + Needs = Some(Sim.needsPanel world) + Status = "needs panel open" } + | ShowChronicle -> + world, + { view with + Panel = ChroniclePanel + Chronicle = Sim.annalText world + Status = sprintf "chronicle entries=%d" world.Annals.Length } + | ClosePanel -> world, { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } + + let panelName (view: M5View) : string = + match view.Panel with + | WorldPanel -> "world" + | NeedsPanel -> "needs" + | ObservationPanel -> "observation" + | DialoguePanel -> "dialogue" + | ChroniclePanel -> "chronicle" + + let titleText (view: M5View) : string = + match view.Panel with + | WorldPanel -> "E INTERACT | Q OBSERVE | TAB NEEDS | C CHRONICLE | L RELATIONS" + | NeedsPanel -> "NEEDS | TAB CLOSE | Q OBSERVE | C CHRONICLE" + | ObservationPanel -> "OBSERVATION | TAB NEEDS | C CHRONICLE | ESC CLOSE" + | DialoguePanel -> + match view.Menu with + | Some menu -> + let options = + menu.Options + |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent)) + |> String.concat " | " + sprintf "DIALOGUE TARGET=%A | %s | ESC CLOSE" menu.Target options + | None -> "DIALOGUE RECORDED | E INTERACT | ESC CLOSE" + | ChroniclePanel -> "CHRONICLE | TAB NEEDS | Q OBSERVE | ESC CLOSE" + + let private chronicleLines (text: string) : string list = + text.Split([| '\n' |], System.StringSplitOptions.RemoveEmptyEntries) |> Array.toList + + let panelLines (world: World) (view: M5View) : string list = + let status = if String.IsNullOrWhiteSpace view.Status then "READY" else view.Status.ToUpperInvariant() + match view.Panel with + | WorldPanel -> + [ "M5 VILLAGE" + "E INTERACT" + "Q OBSERVE" + "TAB NEEDS" + "C CHRONICLE" + "L RELATIONS" + status ] + | NeedsPanel -> + match view.Needs with + | None -> [ "NEEDS"; "NO DATA"; status ] + | Some panel -> + let topNeed = + panel.Ranked + |> List.tryHead + |> Option.map (fun score -> sprintf "TOP %s" (needName score.Kind)) + |> Option.defaultValue "TOP NONE" + [ "NEEDS" + sprintf "HUNGER %.0f" panel.Needs.Hunger + sprintf "ENERGY %.0f" panel.Needs.Energy + sprintf "SOCIAL %.0f" panel.Needs.Social + sprintf "MONEY %.0f" panel.Needs.Money + topNeed + status ] + | ObservationPanel -> + let rows = + view.Observations + |> List.truncate 8 + |> List.collect (fun observation -> + let urgency = + observation.Ranked + |> List.tryHead + |> Option.map (fun score -> score.Urgency) + |> Option.defaultValue 0.0f + [ sprintf "NPC %d U %.0f" (npcNumber observation.Id) urgency + sprintf "H %.0f E %.0f S %.0f M %.0f" + observation.Needs.Hunger + observation.Needs.Energy + observation.Needs.Social + observation.Needs.Money ]) + "OBSERVATION" :: (if rows.IsEmpty then [ "NO NPC IN RANGE" ] else rows) @ [ status ] + | DialoguePanel -> + match view.Menu with + | None -> [ "DIALOGUE RECORDED"; status ] + | Some menu -> + let options = + menu.Options + |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent)) + [ sprintf "DIALOGUE NPC %d" (npcNumber menu.Target) ] @ options @ [ "ESC CLOSE"; status ] + | ChroniclePanel -> + let entries = chronicleLines view.Chronicle |> List.truncate 10 + "CHRONICLE" :: (if entries.IsEmpty then [ "NO ENTRIES" ] else entries) @ [ status ] diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj index 9af59ac..cd8ccab 100644 --- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj +++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj @@ -6,6 +6,7 @@ </PropertyGroup> <ItemGroup> + <Compile Include="Interaction.fs" /> <Compile Include="Game.fs" /> <Compile Include="Program.fs" /> </ItemGroup> |
