namespace LivingVillage.Desktop open System open LivingVillage.Kernel open LivingVillage.Kernel.Sim open LivingVillage.Desktop.VillagePresentation type InteractionAction = | TalkTo of NpcId | EnterHome of HomeId | ExitHome of HomeId type InteractionTarget = { Action: InteractionAction Position: Vec2 Text: string DistanceSquared: float32 } 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 StatusTick: int64 option HomeMode: HomeMode Prompt: string option PromptTargetPos: Vec2 option } module InteractionResolver = let private tileCenter (tile: TilePosition) : Vec2 = { X = float32 (tile.X * Sim.tilePixels + Sim.tilePixels / 2) Y = float32 (tile.Y * Sim.tilePixels + Sim.tilePixels / 2) } let private homeDoorPosition (home: Home) : Vec2 = home.Door |> worldTileForSceneTile |> tileCenter let private blockingTiles : Set = scene.Elements |> List.choose (fun element -> match element.Prop with | WhiteWallDarkTileHome -> let tile = worldTileForSceneTile element.Position Some(tile.X, tile.Y) | _ -> None) |> Set.ofList let private tileAt (position: Vec2) : int * int = int (Math.Floor(float position.X / float Sim.tilePixels)), int (Math.Floor(float position.Y / float Sim.tilePixels)) let hasClearPath (fromPosition: Vec2) (targetPosition: Vec2) : bool = let dx = targetPosition.X - fromPosition.X let dy = targetPosition.Y - fromPosition.Y let distance = sqrt (dx * dx + dy * dy) let steps = max 1 (int (Math.Ceiling(float (distance / float32 Sim.tilePixels * 4.0f)))) [ 1 .. steps - 1 ] |> List.forall (fun step -> let fraction = float32 step / float32 steps let sample : Vec2 = { X = fromPosition.X + dx * fraction Y = fromPosition.Y + dy * fraction } not (Set.contains (tileAt sample) blockingTiles)) let private distanceSquared (first: Vec2) (second: Vec2) : float32 = let dx = second.X - first.X let dy = second.Y - first.Y dx * dx + dy * dy let private dialogueAvailable (npc: Npc) : bool = npc.Mind.Action <> Sleep && npc.Mind.Action <> Chat let private npcNumber (NpcId id) = id let private target action position text avatarPosition : InteractionTarget = { Action = action Position = position Text = text DistanceSquared = distanceSquared avatarPosition position } let private targetIsReachable (avatarPosition: Vec2) (position: Vec2) : bool = distanceSquared avatarPosition position <= Sim.chatRangeSq && hasClearPath avatarPosition position let resolve (world: World) (homeMode: HomeMode) : InteractionTarget option = let avatarPosition = world.Avatar.Pos let candidates = match homeMode with | Inside homeId -> scene.Homes |> List.choose (fun home -> if home.Id = homeId then let position = homeDoorPosition home let avatarTile = sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then Some(target (ExitHome home.Id) position "E 出屋" avatarPosition) else None else None) | Outside -> let homes = scene.Homes |> List.choose (fun home -> let position = homeDoorPosition home let avatarTile = sceneTileFromPixel { X = avatarPosition.X; Y = avatarPosition.Y } if isNearHomeDoor avatarTile home && targetIsReachable avatarPosition position then Some(target (EnterHome home.Id) position "E 进屋" avatarPosition) else None) let npcs = world.Npcs |> Array.choose (fun npc -> if dialogueAvailable npc && targetIsReachable avatarPosition npc.Pos then Some(target (TalkTo npc.Id) npc.Pos "E 交谈" avatarPosition) else None) |> Array.toList homes @ npcs candidates |> List.sortBy (fun candidate -> let stableId = match candidate.Action with | TalkTo id -> npcNumber id | EnterHome(HomeId id) | ExitHome(HomeId id) -> id candidate.DistanceSquared, stableId) |> List.tryHead let resolveAction = resolve module FloatingPrompt = type PromptRect = { X: int Y: int Width: int Height: int } // HUD exclusion geometry shared with the Game.fs renderer: bottom-left lightweight // panel band and top-right corner status strip. let hudBottomBandHeight = 160 let hudTopRightWidth = 248 let hudTopRightHeight = 72 /// Width of text as PixelText.draw advances it (scale applied, CJK 8px, latin 6px, space 4px). let measureWidth (text: string) (scale: int) : int = text.ToUpperInvariant() |> Seq.fold (fun cursor character -> if character = ' ' then cursor + 4 * scale elif CjkGlyphAtlas.sourceRectangle character |> Option.isSome then cursor + 8 * scale else cursor + 6 * scale) 0 /// Anchor the one short prompt above the unique target: horizontally centered on the /// entity, clamped inside the viewport, and never covering either HUD band. let layout (viewportWidth: int) (viewportHeight: int) (anchorWorldX: float32) (anchorWorldY: float32) (cameraX: float32) (cameraY: float32) (scale: int) (text: string) : PromptRect = let width = measureWidth text scale let height = 8 * scale + 10 let margin = 8 let screenX = int anchorWorldX - int cameraX - width / 2 let screenY = int anchorWorldY - int cameraY - 56 let x = if width + margin * 2 > viewportWidth then margin else max margin (min (viewportWidth - margin - width) screenX) let topSafe = if x + width >= viewportWidth - hudTopRightWidth then hudTopRightHeight + margin else margin let bottomSafe = viewportHeight - hudBottomBandHeight - height - margin let y = max topSafe (min bottomSafe screenY) { X = x; Y = y; Width = width; Height = height } module M5Interaction = let initial : M5View = { Panel = WorldPanel Menu = None Needs = None Observations = [] Chronicle = "" Status = "ready" StatusTick = None HomeMode = Outside Prompt = None PromptTargetPos = None } let worldInputAllowed (view: M5View) : bool = view.Panel = WorldPanel let refreshPrompt (world: World) (view: M5View) : M5View = if worldInputAllowed view then let resolved = InteractionResolver.resolve world view.HomeMode { view with Prompt = resolved |> Option.map (fun target -> target.Text) PromptTargetPos = resolved |> Option.map (fun target -> target.Position) } else { view with Prompt = None; PromptTargetPos = None } let resolveAction (world: World) (homeMode: HomeMode) : InteractionTarget option = InteractionResolver.resolveAction world homeMode 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 menuNpcNumber (menu: DialogueMenu) : int = npcNumber menu.Target let intentLine (intent: DialogueIntent) : string = intentName intent 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 = WorldPanel Menu = None Chronicle = Sim.annalText next Prompt = None PromptTargetPos = None Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response StatusTick = Some next.Tick } | DialogueRejected(failure, next) -> next, { view with Panel = WorldPanel Menu = None Prompt = None PromptTargetPos = None Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) StatusTick = Some next.Tick } let private commandAllowed (command: M5Command) (view: M5View) : bool = match command with | ClosePanel -> true | Interact -> view.Panel = WorldPanel | Intent1 | Intent2 | Intent3 | Intent4 | Intent5 | Intent6 -> view.Panel = DialoguePanel | Observe | ToggleNeeds | ShowChronicle -> view.Panel <> DialoguePanel let private applyAllowed (command: M5Command) (world: World) (view: M5View) : World * M5View = match command with | Interact -> match InteractionResolver.resolve world view.HomeMode with | Some { Action = TalkTo target } -> let menu = { Target = target; Options = Sim.dialogueOptions } world, { view with Panel = DialoguePanel Menu = Some menu Prompt = None PromptTargetPos = None Status = menuStatus menu } | Some { Action = EnterHome _ } -> let pixel = { PixelPosition.X = world.Avatar.Pos.X; Y = world.Avatar.Pos.Y } let scenePosition = sceneTileFromPixel pixel match enterHome scenePosition view.HomeMode with | Ok nextMode -> world, { view with Panel = WorldPanel Menu = None HomeMode = nextMode Prompt = None PromptTargetPos = None Status = "home entered" } | Error failure -> world, { view with Prompt = None; Status = sprintf "home entry rejected: %A" failure } | Some { Action = ExitHome _ } -> match exitHome view.HomeMode with | Ok nextMode -> world, { view with Panel = WorldPanel Menu = None HomeMode = nextMode Prompt = None PromptTargetPos = None Status = "home exited" } | Error failure -> world, { view with Prompt = None; Status = sprintf "home exit rejected: %A" failure } | None -> world, { view with Panel = WorldPanel; Menu = None; Prompt = None; Status = "no nearby interaction" } | 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 Prompt = None PromptTargetPos = None Status = sprintf "observation visible_npcs=%d" observations.Length } | ToggleNeeds -> if view.Panel = NeedsPanel then world, refreshPrompt world { view with Panel = WorldPanel; Needs = None; Status = "needs panel closed" } else world, { view with Panel = NeedsPanel Needs = Some(Sim.needsPanel world) Prompt = None PromptTargetPos = None Status = "needs panel open" } | ShowChronicle -> world, { view with Panel = ChroniclePanel Chronicle = Sim.annalText world Prompt = None PromptTargetPos = None Status = sprintf "chronicle entries=%d" world.Annals.Length } | ClosePanel -> world, refreshPrompt world { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } let apply (command: M5Command) (world: World) (view: M5View) : World * M5View = if commandAllowed command view then let nextWorld, nextView = applyAllowed command world view let stamped = if nextView.Status <> view.Status && Option.isNone nextView.StatusTick then { nextView with StatusTick = Some nextWorld.Tick } else nextView nextWorld, stamped else world, view let statusDisplayDurationTicks = 150L 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 -> "江南水乡" | 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" -> "面板已关闭" | "home entered" -> "已进入民居" | "home exited" -> "已离开民居" | "save succeeded" -> "保存成功" | "save failed" -> "保存失败" | 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 statusText (view: M5View) : string = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status let transientStatusLine (nowTick: int64) (view: M5View) : string option = match view.StatusTick with | Some tick when view.Panel = WorldPanel && nowTick - tick < statusDisplayDurationTicks -> Some(statusText view) | _ -> None /// Minimal corner status for the persistent HUD: day counter plus money/energy taken /// directly from the avatar's real needs (no invented values). let cornerStatusLines (world: World) : string list = let day = world.Tick / Sim.ticksPerDay + 1L let needs = world.Avatar.Mind.Needs [ sprintf "天数 %d" day sprintf "金钱 %.0f / 精力 %.0f" needs.Money needs.Energy ] 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 = statusText view match view.Panel with | WorldPanel -> (view.Prompt |> Option.toList) @ (transientStatusLine world.Tick view |> Option.toList) | 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 ]