1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
|
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 }
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 =
let initial : M5View =
{ Panel = WorldPanel
Menu = None
Needs = None
Observations = []
Chronicle = ""
Status = "ready"
StatusTick = None
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
{ 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
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
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
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
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
| 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
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
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, 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
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 ]
|