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 -> "闲聊" | AskHelp -> "求助" | OfferTrade -> "提议交易" | Joke -> "讲笑话" | Apologize -> "道歉" | Provoke -> "挑衅" let private needName (kind: NeedKind) : string = match kind with | HungerNeed -> "饥饿" | EnergyNeed -> "精力" | SocialNeed -> "社交" | MoneyNeed -> "金钱" let private itemName (item: ItemKind) : string = match item with | Food -> "食物" let private responseName (response: DialogueResponse) : string = match response with | Friendly -> "友好" | Helpful -> "乐于助人" | Bargaining -> "讨价还价" | Amused -> "开心" | Forgiving -> "原谅" | Hostile -> "敌意" | Reserved -> "保留" | Refused -> "拒绝" | Offended -> "恼怒" let private dialogueFailureName (failure: DialogueFailure) : string = match failure with | DialogueTargetNotFound -> "找不到目标" | DialogueTargetOutOfRange -> "目标不在范围内" | DialogueTargetUnavailable -> "目标暂时无法互动" let private tradeFailureName (failure: TradeFailure) : string = match failure with | BuyerNotFound -> "找不到买家" | SellerNotFound -> "找不到卖家" | SameParticipant -> "买家和卖家不能是同一人" | InvalidQuantity -> "数量必须大于零" | OutOfStock -> "库存不足" | InsufficientFunds -> "资金不足" 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: %s" (dialogueFailureName 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 tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string = match result with | TradeSucceeded _ -> match quote with | Some unitPrice -> sprintf "交易成功:%s ×%d,报价 %.2f,总价 %.2f" (itemName request.Item) request.Quantity unitPrice (unitPrice * float32 request.Quantity) | None -> sprintf "交易成功:%s ×%d" (itemName request.Item) request.Quantity | TradeRejected(failure, _) -> sprintf "交易失败:%s" (tradeFailureName failure) 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 互动 | Q 观察 | TAB 需求 | C 年鉴 | L 关系" | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴" | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭" | 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 "互动:村民 %d | %s | ESC 关闭" (npcNumber menu.Target) options | None -> "互动已记录 | E 互动 | ESC 关闭" | ChroniclePanel -> "年鉴 | TAB 需求 | Q 观察 | ESC 关闭" let private statusLabel (status: string) : string = match status with | "ready" -> "准备就绪" | "needs panel open" -> "需求面板已打开" | "needs panel closed" -> "需求面板已关闭" | "panel closed" -> "面板已关闭" | value when value.StartsWith("dialogue target=") -> "请选择对话方式" | value when value.StartsWith("dialogue intent=") -> "对话完成" | value when value.StartsWith("dialogue rejected:") -> let reason = value.Substring("dialogue rejected:".Length).Trim() if String.IsNullOrWhiteSpace reason then "对话失败" else sprintf "对话失败:%s" reason | value when value.StartsWith("observation") -> "观察完成" | value when value.StartsWith("chronicle") -> "年鉴已打开" | value when value.StartsWith("no dialogue") -> "当前没有可用对话" | value when value.StartsWith("invalid dialogue") -> "对话选项无效" | value when value.StartsWith("no nearby") -> "附近没有可互动村民" | _ -> "状态已更新" let private annalLine (entry: AnnalEntry) : string = match entry.Kind with | DialogueAnnal outcome -> sprintf "对话:村民 %d 对村民 %d 使用%s,回应%s" (npcNumber outcome.Actor) (npcNumber outcome.Target) (intentName outcome.Intent) (responseName outcome.Response) | RumorAnnal rumor -> sprintf "传闻:村民 %d 传给村民 %d,传播 %d 层" (npcNumber rumor.Narrator) (npcNumber rumor.Receiver) rumor.Depth | TradeAnnal event -> match event.Kind with | TradeEvent(buyer, seller, item, quantity, unitPrice) -> sprintf "交易:村民 %d 从村民 %d 获得%s ×%d,单价 %.2f" (npcNumber buyer) (npcNumber seller) (itemName item) quantity unitPrice | _ -> "交易记录" let private chronicleLines (world: World) : string list = world.Annals |> List.rev |> List.truncate 10 |> List.map annalLine let panelLines (world: World) (view: M5View) : string list = let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status match view.Panel with | WorldPanel -> [ "村庄" "E 互动" "Q 观察" "TAB 需求" "C 年鉴" "L 关系" status ] | NeedsPanel -> match view.Needs with | None -> [ "需求"; "暂无数据"; status ] | Some panel -> let topNeed = panel.Ranked |> List.tryHead |> Option.map (fun score -> sprintf "最紧迫:%s" (needName score.Kind)) |> Option.defaultValue "暂无紧迫需求" [ "需求" sprintf "饥饿 %.0f" panel.Needs.Hunger sprintf "精力 %.0f" panel.Needs.Energy sprintf "社交 %.0f" panel.Needs.Social sprintf "金钱 %.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 "村民 %d 紧迫度 %.0f" (npcNumber observation.Id) urgency sprintf "饥饿 %.0f 精力 %.0f 社交 %.0f 金钱 %.0f" observation.Needs.Hunger observation.Needs.Energy observation.Needs.Social observation.Needs.Money ]) "观察" :: (if rows.IsEmpty then [ "范围内暂无村民" ] else rows) @ [ status ] | DialoguePanel -> match view.Menu with | None -> [ "互动已记录"; status ] | Some menu -> let options = menu.Options |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (intentName intent)) [ sprintf "互动:村民 %d" (npcNumber menu.Target) ] @ options @ [ "ESC 关闭"; status ] | ChroniclePanel -> let entries = chronicleLines world "年鉴" :: (if entries.IsEmpty then [ "暂无记录" ] else entries) @ [ status ]