summaryrefslogtreecommitdiff
path: root/src/LivingVillage.Desktop/Interaction.fs
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:26:30 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:26:30 +0800
commitb6b95988e525a68ea7426ecc9690b18a1b3bbfe8 (patch)
tree4c19ab58a610c6d783e1dfc45585b12a6ecb2e58 /src/LivingVillage.Desktop/Interaction.fs
parent0ea26337830ada438df144b3a51f429f00dc1967 (diff)
downloadliving-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.fs209
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 ]