summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-20 14:20:54 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-20 14:20:54 +0800
commit7e197babfbb68d7f7d1866d6a0ca47cf17d3af23 (patch)
treeabf951825edce69139f099a73dfb6f2575ff0259 /src/LivingVillage.Desktop
parent4f8aca922e6cf47dce95aab79541065d487d5272 (diff)
downloadliving-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.fs133
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs211
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
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>