diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:26:30 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:26:30 +0800 |
| commit | b6b95988e525a68ea7426ecc9690b18a1b3bbfe8 (patch) | |
| tree | 4c19ab58a610c6d783e1dfc45585b12a6ecb2e58 /src/LivingVillage.Desktop/Interaction.fs | |
| parent | 0ea26337830ada438df144b3a51f429f00dc1967 (diff) | |
| download | living-village-b6b95988e525a68ea7426ecc9690b18a1b3bbfe8.tar.gz | |
feat(m6c): 完成江南村庄中文交付
完成江南村庄场景、角色表现和中文界面的 M6c 交付范围。
新增 CJK 字形图集、中文文案与运行时加载,补充民居情境交互和确定性村庄美术表现。
更新资源复制与 Linux/macOS 打包校验,加入 188 字符清单、资源许可说明和对应原型测试。
补充江南美术方向与 M6c 交付说明,并保持原有模拟内核和桌面构建入口不变。
Diffstat (limited to 'src/LivingVillage.Desktop/Interaction.fs')
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 209 |
1 files changed, 188 insertions, 21 deletions
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs index da78b2e..58ebdd4 100644 --- a/src/LivingVillage.Desktop/Interaction.fs +++ b/src/LivingVillage.Desktop/Interaction.fs @@ -3,6 +3,18 @@ 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 @@ -30,7 +42,116 @@ type M5View = Needs: NeedsPanel option Observations: Observation list Chronicle: string - Status: string } + Status: string + HomeMode: HomeMode + Prompt: string 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<int * int> = + 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 priority = + match candidate.Action with + | EnterHome _ + | ExitHome _ -> 0 + | TalkTo _ -> 1 + let stableId = + match candidate.Action with + | TalkTo id -> npcNumber id + | EnterHome(HomeId id) + | ExitHome(HomeId id) -> id + priority, candidate.DistanceSquared, stableId) + |> List.tryHead + + let resolveAction = resolve module M5Interaction = @@ -40,7 +161,21 @@ module M5Interaction = Needs = None Observations = [] Chronicle = "" - Status = "ready" } + Status = "ready" + HomeMode = Outside + Prompt = None } + + let worldInputAllowed (view: M5View) : bool = + view.Panel = WorldPanel + + let refreshPrompt (world: World) (view: M5View) : M5View = + if worldInputAllowed view then + { view with Prompt = InteractionResolver.resolve world view.HomeMode |> Option.map (fun target -> target.Text) } + else + { view with Prompt = None } + + let resolveAction (world: World) (homeMode: HomeMode) : InteractionTarget option = + InteractionResolver.resolveAction world homeMode let private observationBounds (world: World) : VisibleBounds = let radius = Sim.chatRangePx @@ -106,13 +241,14 @@ module M5Interaction = 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 + match Sim.chooseDialogue menu.Target intent world with | DialogueSucceeded(outcome, next) -> next, { view with Panel = DialoguePanel Menu = None Chronicle = Sim.annalText next + Prompt = None Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response } | DialogueRejected(failure, next) -> next, { view with Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) } @@ -120,14 +256,42 @@ module M5Interaction = let apply (command: M5Command) (world: World) (view: M5View) : World * M5View = match command with | Interact -> - match Sim.openDialogueMenu world with - | Some menu -> + 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 Status = menuStatus menu } - | None -> world, { view with Panel = WorldPanel; Menu = None; Status = "no nearby NPC available" } + | 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 + 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 + 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 @@ -137,26 +301,29 @@ module M5Interaction = | Observe -> let observations = Sim.observeVisible (observationBounds world) world world, - { view with - Panel = ObservationPanel - Observations = observations - Status = sprintf "observation visible_npcs=%d" observations.Length } + { view with + Panel = ObservationPanel + Observations = observations + Prompt = None + 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" } + 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 Status = "needs panel open" } | ShowChronicle -> world, { view with Panel = ChroniclePanel Chronicle = Sim.annalText world + Prompt = None Status = sprintf "chronicle entries=%d" world.Annals.Length } - | ClosePanel -> world, { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } + | ClosePanel -> world, refreshPrompt world { view with Panel = WorldPanel; Menu = None; Status = "panel closed" } let tradeResultText (request: TradeRequest) (quote: float32 option) (result: TradeResult) : string = match result with @@ -181,7 +348,7 @@ module M5Interaction = let titleText (view: M5View) : string = match view.Panel with - | WorldPanel -> "E 互动 | Q 观察 | TAB 需求 | C 年鉴 | L 关系" + | WorldPanel -> "江南水乡" | NeedsPanel -> "需求 | TAB 关闭 | Q 观察 | C 年鉴" | ObservationPanel -> "观察 | TAB 需求 | C 年鉴 | ESC 关闭" | DialoguePanel -> @@ -201,6 +368,10 @@ module M5Interaction = | "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:") -> @@ -210,7 +381,7 @@ module M5Interaction = | value when value.StartsWith("chronicle") -> "年鉴已打开" | value when value.StartsWith("no dialogue") -> "当前没有可用对话" | value when value.StartsWith("invalid dialogue") -> "对话选项无效" - | value when value.StartsWith("no nearby") -> "附近没有可互动村民" + | value when value.StartsWith("no nearby") -> "附近没有可互动目标" | _ -> "状态已更新" let private annalLine (entry: AnnalEntry) : string = @@ -247,13 +418,9 @@ module M5Interaction = let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status match view.Panel with | WorldPanel -> - [ "村庄" - "E 互动" - "Q 观察" - "TAB 需求" - "C 年鉴" - "L 关系" - status ] + [ "村庄" ] + @ (view.Prompt |> Option.toList) + @ [ status ] | NeedsPanel -> match view.Needs with | None -> [ "需求"; "暂无数据"; status ] |
