summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 10:12:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 10:12:20 +0800
commit9df2da41289f4d0852f86e86eae81ca7f7e7b598 (patch)
treebca1a071e3369a9e2ebbf5e424da3dd45547f2be
parentc932969539b00a42937f0aa5ed1e10b4e94932a3 (diff)
downloadliving-village-9df2da41289f4d0852f86e86eae81ca7f7e7b598.tar.gz
fix(desktop): 修复 HUD 缺字并统一轻量面板与情境提示
字符表扩充至 197 字(补 间/门/谈/体/屋/返/×/,/:等全部 UI 用字),用 Noto Sans CJK 重新生成图集并逐字校验;新增字符表覆盖全部 UI 文案、瞬态状态 2.5 秒过期、对话完成后回到轻量面板等回归测试;户外与室内统一左下角轻量 HUD,互动日志改为短暂反馈;样例脚本保留 E 交谈 提示与对话菜单停留帧。
-rw-r--r--src/LivingVillage.Desktop.Tests/DesktopTests.fs17
-rw-r--r--src/LivingVillage.Desktop.Tests/PrototypeTests.fs89
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.pngbin50697 -> 52827 bytes
-rw-r--r--src/LivingVillage.Desktop/Assets/cjk-glyphs.txt9
-rw-r--r--src/LivingVillage.Desktop/ChineseText.fs68
-rw-r--r--src/LivingVillage.Desktop/CjkGlyphAtlas.fs2
-rw-r--r--src/LivingVillage.Desktop/Game.fs49
-rw-r--r--src/LivingVillage.Desktop/Interaction.fs65
-rw-r--r--src/LivingVillage.Desktop/SampleScript.fs14
9 files changed, 279 insertions, 34 deletions
diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
index 667ab69..526d1af 100644
--- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs
+++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs
@@ -53,6 +53,23 @@ type DesktopTests () =
Assert.IsTrue(observationView.Observations |> List.exists (fun observation -> observation.Id = NpcId 0))
[<TestMethod>]
+ member _.DialogueAndOverlayViewsRejectWorldCommandsUntilClosed () =
+ let world = Sim.initialWorldN 42UL 4 |> Harness.atNpc 0
+ let openedWorld, dialogueView = M5Interaction.apply Interact world M5Interaction.initial
+ let _, afterObserve = M5Interaction.apply Observe openedWorld dialogueView
+ let _, afterNeeds = M5Interaction.apply ToggleNeeds openedWorld dialogueView
+ let _, afterChronicle = M5Interaction.apply ShowChronicle openedWorld dialogueView
+ let _, needsView = M5Interaction.apply ToggleNeeds world M5Interaction.initial
+ let _, interactionAttempt = M5Interaction.apply Interact world needsView
+ let _, closedView = M5Interaction.apply ClosePanel world dialogueView
+
+ Assert.AreEqual<M5View>(dialogueView, afterObserve)
+ Assert.AreEqual<M5View>(dialogueView, afterNeeds)
+ Assert.AreEqual<M5View>(dialogueView, afterChronicle)
+ Assert.AreEqual<M5Panel>(NeedsPanel, interactionAttempt.Panel)
+ Assert.AreEqual<M5Panel>(WorldPanel, closedView.Panel)
+
+ [<TestMethod>]
member _.HeadlessSmokeProvesTheThreeHopCausalChain () =
let result = Program.M5Smoke.run ()
diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
index c5b127f..a403223 100644
--- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
+++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs
@@ -148,7 +148,7 @@ type PrototypeTests () =
Avatar = { world.Avatar with Pos = world.Npcs.[0].Pos } }
let openedWorld, opened = M5Interaction.apply Interact nearby M5Interaction.initial
let title = M5Interaction.titleText opened
- let needsWorld, needsView = M5Interaction.apply ToggleNeeds openedWorld opened
+ let needsWorld, needsView = M5Interaction.apply ToggleNeeds openedWorld M5Interaction.initial
let needsText = String.concat "|" (M5Interaction.panelLines needsWorld needsView)
let _, chronicleView = M5Interaction.apply ShowChronicle nearby M5Interaction.initial
let chronicleText = String.concat "|" (M5Interaction.panelLines nearby chronicleView)
@@ -387,26 +387,103 @@ type PrototypeTests () =
[<TestMethod>]
member _.WorldPanelDoesNotRenderA永久KeyList () =
- let lines = M5Interaction.panelLines (Sim.initialWorldN 42UL 0) M5Interaction.initial
+ let world = Sim.initialWorldN 42UL 0
+ let lines = M5Interaction.panelLines world M5Interaction.initial
+ Assert.AreEqual<string list>([], lines)
Assert.IsFalse(lines |> List.exists (fun line -> line = "Q 观察" || line = "TAB 需求" || line = "C 年鉴" || line = "L 关系"))
[<TestMethod>]
+ member _.WorldPanelStatusIsTransientAndExpiresAfterTwoAndAHalfSeconds () =
+ let world = Sim.initialWorldN 42UL 0
+ let _, interacted = M5Interaction.apply ClosePanel world M5Interaction.initial
+ let stamped = { interacted with StatusTick = Some world.Tick }
+ Assert.AreEqual<string list>([ "E 进屋"; "面板已关闭" ], M5Interaction.panelLines world stamped)
+
+ let expired = { stamped with StatusTick = Some (world.Tick - M5Interaction.statusDisplayDurationTicks) }
+ Assert.AreEqual<string list>([ "E 进屋" ], M5Interaction.panelLines world expired)
+
+ [<TestMethod>]
+ member _.DialogueCompletionReturnsToTheLightweightWorldPanel () =
+ let world = Sim.initialWorldN 42UL 4 |> fun value -> { value with Avatar = { value.Avatar with Pos = value.Npcs.[0].Pos } }
+ let openedWorld, opened = M5Interaction.apply Interact world M5Interaction.initial
+ Assert.AreEqual<M5Panel>(DialoguePanel, opened.Panel)
+ Assert.IsTrue(opened.Menu.IsSome)
+
+ let nextWorld, view = M5Interaction.apply Intent1 openedWorld opened
+ Assert.AreEqual<M5Panel>(WorldPanel, view.Panel)
+ Assert.AreEqual<DialogueMenu option>(None, view.Menu)
+ Assert.AreEqual<int64 option>(Some nextWorld.Tick, view.StatusTick)
+ Assert.IsTrue(M5Interaction.statusText view |> fun text -> text.Contains("对话完成"))
+ Assert.IsTrue(M5Interaction.transientStatusLine nextWorld.Tick view |> Option.map (fun line -> line.Contains("对话完成")) |> Option.defaultValue false)
+
+ [<TestMethod>]
+ member _.InteractionRangeAndTargetSelectionStayDeterministicAtTheBoundary () =
+ let avatar : Vec2 =
+ { X = float32 (32 * Sim.tilePixels + Sim.tilePixels / 2)
+ Y = float32 (24 * Sim.tilePixels + Sim.tilePixels / 2) }
+ let baseWorld = Sim.initialWorldN 42UL 4
+
+ let withNpcPositions (positions: (int * Vec2) list) =
+ let npcs = Array.copy baseWorld.Npcs
+ for index in 0 .. npcs.Length - 1 do
+ npcs.[index] <- { npcs.[index] with Pos = ({ X = 2000.0f; Y = 1400.0f } : Vec2) }
+ for index, position in positions do
+ npcs.[index] <- { npcs.[index] with Pos = position }
+ { baseWorld with
+ Avatar = { baseWorld.Avatar with Pos = avatar }
+ Npcs = npcs }
+
+ let atBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx; Y = avatar.Y } ]
+ let beyondBoundary = withNpcPositions [ 0, { X = avatar.X + Sim.chatRangePx + 0.1f; Y = avatar.Y } ]
+ let competing =
+ withNpcPositions
+ [ 0, { X = avatar.X + 72.0f; Y = avatar.Y }
+ 1, { X = avatar.X + 48.0f; Y = avatar.Y } ]
+ let switched =
+ { competing with
+ Avatar = { competing.Avatar with Pos = { X = avatar.X + 64.0f; Y = avatar.Y } } }
+
+ Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve atBoundary Outside |> Option.map (fun target -> target.Action))
+ Assert.AreEqual<InteractionAction option>(None, InteractionResolver.resolve beyondBoundary Outside |> Option.map (fun target -> target.Action))
+ Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 1)), InteractionResolver.resolve competing Outside |> Option.map (fun target -> target.Action))
+ Assert.AreEqual<InteractionAction option>(Some(TalkTo(NpcId 0)), InteractionResolver.resolve switched Outside |> Option.map (fun target -> target.Action))
+
+ [<TestMethod>]
member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () =
- Assert.AreEqual<int>(188, characters.Length)
+ Assert.AreEqual<int>(197, characters.Length)
CollectionAssert.AreEqual(characters, characters |> Array.sort)
Assert.AreEqual<int>(characters.Length, characters |> Array.distinct |> Array.length)
Assert.AreEqual<int>(16, columns)
Assert.AreEqual<int>(24, cellPixels)
- Assert.AreEqual<int>(12, rowCount)
+ Assert.AreEqual<int>(13, rowCount)
Assert.AreEqual<int>(384, pixelWidth)
- Assert.AreEqual<int>(288, pixelHeight)
+ Assert.AreEqual<int>(312, pixelHeight)
Assert.AreEqual<string>("Assets/cjk-glyph-atlas.png", assetRelativePath)
Assert.AreEqual<string>("Assets/cjk-glyphs.txt", manifestRelativePath)
[<TestMethod>]
+ member _.RequiredUiLabelsAreFullyCoveredByTheGlyphTable () =
+ let labels = ChineseText.requiredUiLabels
+
+ Assert.IsTrue(labels |> List.forall PixelText.isRenderable)
+ Assert.IsTrue(labels |> List.contains "时间")
+ Assert.IsTrue(labels |> List.contains "E 出屋")
+ Assert.IsTrue(labels |> List.contains "E 进屋")
+ Assert.IsTrue(labels |> List.contains "E 交谈")
+ Assert.IsTrue(PixelText.isRenderable "时间 白天")
+ Assert.IsTrue(PixelText.isRenderable "2倍速")
+ Assert.IsTrue(PixelText.isRenderable "E 出门")
+ Assert.IsTrue(PixelText.isRenderable "E 交谈")
+ Assert.IsFalse(PixelText.isRenderable "龘")
+
+ [<TestMethod>]
member _.CjkGlyphAtlasMatchesThePackagedManifestAndRejectsUnknownCharacters () =
- Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall (fun label -> label |> Seq.forall (fun character -> character = ' ' || not (Char.IsLetter character) || contains character)))
+ Assert.IsTrue(ChineseText.requiredUiLabels |> List.forall PixelText.isRenderable)
Assert.IsTrue(contains '江')
+ Assert.IsTrue(contains '间')
+ Assert.IsTrue(contains '门')
+ Assert.IsTrue(contains '谈')
+ Assert.IsTrue(contains '体')
Assert.IsTrue(contains '龘' |> not)
Assert.AreEqual<int>(0, sourceRectangle '龘' |> Option.toList |> List.length)
let manifestPath = runtimeManifestPath ()
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
index acea5a9..9678aeb 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png
Binary files differ
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
index 6dae702..331e2ec 100644
--- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
+++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt
@@ -1,3 +1,4 @@
+×
一
不
主
@@ -11,6 +12,7 @@
从
价
传
+体
作
使
保
@@ -56,6 +58,7 @@
就
层
居
+屋
已
年
庄
@@ -158,10 +161,12 @@
请
读
谅
+谈
败
资
足
近
+返
还
进
迫
@@ -173,8 +178,10 @@
金
鉴
钱
+门
闭
闲
+间
闻
附
零
@@ -186,3 +193,5 @@
饥
饿
黛
+,
+:
diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs
index 9990cb3..95ec180 100644
--- a/src/LivingVillage.Desktop/ChineseText.fs
+++ b/src/LivingVillage.Desktop/ChineseText.fs
@@ -115,7 +115,73 @@ module ChineseText =
"请选择对话方式"
"对话完成"
"对话失败"
- "年鉴已打开" ]
+ "年鉴已打开"
+ "E 出屋"
+ "E 进屋"
+ "E 交谈"
+ "时间"
+ "互动已记录"
+ "1倍速"
+ "2倍速"
+ "5倍速"
+ "暂无记录"
+ "暂无数据"
+ "范围内暂无村民"
+ "交易记录"
+ "状态已更新"
+ "UP DOWN 选择 ENTER 确认"
+ "ENTER 应用 ESC 返回"
+ "ENTER ESC 返回"
+ "ESC 返回"
+ "当前"
+ "读取失败"
+ "暂无存档"
+ "需求面板已打开"
+ "需求面板已关闭"
+ "面板已关闭"
+ "已进入民居"
+ "已离开民居"
+ "保存成功"
+ "保存失败"
+ "观察完成"
+ "当前没有可用对话"
+ "对话选项无效"
+ "附近没有可互动目标"
+ "闲聊"
+ "求助"
+ "提议交易"
+ "讲笑话"
+ "道歉"
+ "挑衅"
+ "友好"
+ "乐于助人"
+ "讨价还价"
+ "开心"
+ "原谅"
+ "敌意"
+ "保留"
+ "拒绝"
+ "恼怒"
+ "找不到目标"
+ "目标不在范围内"
+ "目标暂时无法互动"
+ "找不到买家"
+ "找不到卖家"
+ "买家和卖家不能是同一人"
+ "数量必须大于零"
+ "库存不足"
+ "资金不足"
+ "传闻"
+ "传播"
+ "层"
+ "村民"
+ "使用"
+ "回应"
+ "从"
+ "获得"
+ "单价"
+ "食物"
+ "关闭" ]
let layoutText (maxWidth: int) (supported: Set<char>) (text: string) : TextLayout =
let width = max 1 maxWidth
diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
index e21f025..fc19828 100644
--- a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
+++ b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs
@@ -11,7 +11,7 @@ module CjkGlyphAtlas =
// Keep this table sorted and stable so the generated PNG has a reproducible layout.
let private characterTable =
- "一不主乐乡买于互交人从价传作使保倍停入关内准出到前力功动助单卖南原友取可同和回围圃在墙备夜大天失好始存完家察对就层居已年庄库应度开式当录得心必态怒总恼意戏成或打找报拒择挑据提播操效敌数新方无时明易是晚景暂更最有村板林标档桥歉民水求江没法游物状瓦用留白目石确社离移竹笑精系紧给绝继绪续置聊能范获菜衅观认讨议记讲设话说请读谅败资足近还进迫退选速道量金鉴钱闭闲闻附零需面项须食饥饿黛"
+ "×一不主乐乡买于互交人从价传体作使保倍停入关内准出到前力功动助单卖南原友取可同和回围圃在墙备夜大天失好始存完家察对就层居屋已年庄库应度开式当录得心必态怒总恼意戏成或打找报拒择挑据提播操效敌数新方无时明易是晚景暂更最有村板林标档桥歉民水求江没法游物状瓦用留白目石确社离移竹笑精系紧给绝继绪续置聊能范获菜衅观认讨议记讲设话说请读谅谈败资足近返还进迫退选速道量金鉴钱门闭闲间闻附零需面项须食饥饿黛,:"
let characters = characterTable.ToCharArray()
let rowCount = (characters.Length + columns - 1) / columns
diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs
index 62102a7..a5049f3 100644
--- a/src/LivingVillage.Desktop/Game.fs
+++ b/src/LivingVillage.Desktop/Game.fs
@@ -142,6 +142,9 @@ type LivingVillageGame() as this =
let mutable autoplayFrames = 0
let mutable flowStep = 0
let mutable sampleState = SampleScript.initial
+ let mutable hudClock = 0L
+ let mutable hudStatus = ""
+ let mutable hudStatusAt = 0L
do
graphics.PreferredBackBufferWidth <- 1280
@@ -179,12 +182,12 @@ type LivingVillageGame() as this =
member private this.SaveWorld (prefix: string) : bool =
try
WorldSave.saveToFile savePath world
- m5View <- { m5View with Status = "save succeeded" }
+ m5View <- { m5View with Status = "save succeeded"; StatusTick = Some world.Tick }
printfn $"{prefix}=ok path={savePath} tick={world.Tick}"
true
with
| ex ->
- m5View <- { m5View with Status = "save failed" }
+ m5View <- { m5View with Status = "save failed"; StatusTick = Some world.Tick }
printfn $"{prefix}=error path={savePath} message={ex.Message}"
false
@@ -440,6 +443,10 @@ type LivingVillageGame() as this =
this.UpdatePlaying gameTime kb pressed pressedAny
else
this.UpdateMenu pressed
+ hudClock <- hudClock + 1L
+ if m5View.Status <> hudStatus then
+ hudStatus <- m5View.Status
+ hudStatusAt <- hudClock
fpsFrames <- fpsFrames + 1
fpsSeconds <- fpsSeconds + gameTime.ElapsedGameTime.TotalSeconds
if fpsSeconds >= 1.0 then
@@ -582,23 +589,35 @@ type LivingVillageGame() as this =
member private this.DrawM5Overlay() =
let viewport = this.GraphicsDevice.Viewport
- let panelWidth = min 460 (max 1 (viewport.Width - 32))
- let panelHeight = min 520 (max 1 (viewport.Height - 32))
- let panelX = max 16 (viewport.Width - panelWidth - 16)
- let panelY = 16
- let panel = Rectangle(panelX, panelY, panelWidth, panelHeight)
- let innerX = panelX + 16
- let innerWidth = panelWidth - 32
let textScale = 2
let lineHeight = 18
- let maxChars = max 1 (innerWidth / (6 * textScale))
- let maxLines = max 1 ((panelHeight - 32) / lineHeight)
let profile = M6Presentation.profileAtTick world.Tick
- let m6Lines = [ sprintf "时间 %s %s" (M6Presentation.dayNightLabel profile.Mode) (M6Presentation.clockLabel simulationControl) ]
- let lines = (m6Lines @ M5Interaction.panelLines world m5View) |> List.truncate maxLines
+ let timeLine = sprintf "时间 %s %s" (M6Presentation.dayNightLabel profile.Mode) (M6Presentation.clockLabel simulationControl)
+ let persistentLines =
+ timeLine :: (m5View.Prompt |> Option.toList)
+ let dialogueLines =
+ match m5View.Panel, m5View.Menu with
+ | DialoguePanel, Some menu ->
+ menu.Options
+ |> List.mapi (fun index intent -> sprintf "%d %s" (index + 1) (M5Interaction.intentLine intent))
+ |> fun options -> [ sprintf "互动:村民 %d" (M5Interaction.menuNpcNumber menu) ] @ options
+ | _ -> []
+ let statusView = { m5View with StatusTick = Some hudStatusAt }
+ let statusLine = M5Interaction.transientStatusLine hudClock statusView |> Option.toList
+ let allLines = persistentLines @ dialogueLines @ statusLine
+ let panelWidth = min 360 (max 1 (viewport.Width - 32))
+ let maxLines = max 1 ((viewport.Height - 48) / lineHeight)
+ let lines = allLines |> List.truncate maxLines
+ let panelHeight = lines.Length * lineHeight + 24
+ let panelX = 16
+ let panelY = max 16 (viewport.Height - panelHeight - 16)
+ let panel = Rectangle(panelX, panelY, panelWidth, panelHeight)
+ let innerX = panelX + 12
+ let innerWidth = max 1 (panelWidth - 24)
+ let maxChars = max 1 (innerWidth / (6 * textScale))
spriteBatch.Begin()
- spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 220))
- spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 4), Color(92, 180, 190))
+ spriteBatch.Draw(pixel, panel, Color(15, 19, 25, 160))
+ spriteBatch.Draw(pixel, Rectangle(panelX, panelY, panelWidth, 3), Color(92, 180, 190))
lines
|> List.iteri (fun index line ->
let visible = if line.Length > maxChars then line.Substring(0, maxChars) else line
diff --git a/src/LivingVillage.Desktop/Interaction.fs b/src/LivingVillage.Desktop/Interaction.fs
index 58ebdd4..91e09ae 100644
--- a/src/LivingVillage.Desktop/Interaction.fs
+++ b/src/LivingVillage.Desktop/Interaction.fs
@@ -43,6 +43,7 @@ type M5View =
Observations: Observation list
Chronicle: string
Status: string
+ StatusTick: int64 option
HomeMode: HomeMode
Prompt: string option }
@@ -162,6 +163,7 @@ module M5Interaction =
Observations = []
Chronicle = ""
Status = "ready"
+ StatusTick = None
HomeMode = Outside
Prompt = None }
@@ -234,6 +236,10 @@ module M5Interaction =
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" }
@@ -245,15 +251,36 @@ module M5Interaction =
| DialogueSucceeded(outcome, next) ->
next,
{ view with
- Panel = DialoguePanel
+ Panel = WorldPanel
Menu = None
Chronicle = Sim.annalText next
Prompt = None
- Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response }
+ Status = sprintf "dialogue intent=%A response=%A" outcome.Intent outcome.Response
+ StatusTick = Some next.Tick }
| DialogueRejected(failure, next) ->
- next, { view with Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure) }
+ next,
+ { view with
+ Panel = WorldPanel
+ Menu = None
+ Prompt = None
+ Status = sprintf "dialogue rejected: %s" (dialogueFailureName failure)
+ StatusTick = Some next.Tick }
- let apply (command: M5Command) (world: World) (view: M5View) : World * M5View =
+ 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
@@ -325,6 +352,20 @@ module M5Interaction =
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 _ ->
@@ -384,6 +425,15 @@ module M5Interaction =
| 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 ->
@@ -415,12 +465,11 @@ module M5Interaction =
|> List.map annalLine
let panelLines (world: World) (view: M5View) : string list =
- let status = if String.IsNullOrWhiteSpace view.Status then "准备就绪" else statusLabel view.Status
+ let status = statusText view
match view.Panel with
| WorldPanel ->
- [ "村庄" ]
- @ (view.Prompt |> Option.toList)
- @ [ status ]
+ (view.Prompt |> Option.toList)
+ @ (transientStatusLine world.Tick view |> Option.toList)
| NeedsPanel ->
match view.Needs with
| None -> [ "需求"; "暂无数据"; status ]
diff --git a/src/LivingVillage.Desktop/SampleScript.fs b/src/LivingVillage.Desktop/SampleScript.fs
index c072161..0f3519e 100644
--- a/src/LivingVillage.Desktop/SampleScript.fs
+++ b/src/LivingVillage.Desktop/SampleScript.fs
@@ -52,6 +52,8 @@ module SampleScript =
let private arriveRadius = 12.0f
let private inspectFrames = 150
+ let private promptHoldFrames = 60
+ let private menuHoldFrames = 60
let private dialogueHoldFrames = 120
let private totalFrameLimit = 20000
@@ -66,7 +68,7 @@ module SampleScript =
| InspectInterior -> 600
| LeaveDoorstep -> 1800
| ApproachNpc -> 5400
- | ReadDialogueOptions -> 120
+ | ReadDialogueOptions -> 600
| CloseDialogue -> 300
| WalkAway -> 3600
| SampleComplete -> Int32.MaxValue
@@ -120,14 +122,20 @@ module SampleScript =
| ApproachNpc ->
match M5Interaction.resolveAction world Outside with
| Some { Action = TalkTo target } ->
- { advance ReadDialogueOptions state with DialogueTarget = Some target }, PushM5 Interact
+ if state.StageFrames >= promptHoldFrames then
+ { advance ReadDialogueOptions state with DialogueTarget = Some target }, PushM5 Interact
+ else
+ state, NoAction
| _ ->
match nearestChatableNpc world with
| Some npc -> state, MoveTo npc.Pos
| None -> state, NoAction
| ReadDialogueOptions ->
if view.Panel = DialoguePanel && view.Menu.IsSome then
- state, PushM5 Intent1
+ if state.StageFrames >= menuHoldFrames then
+ state, PushM5 Intent1
+ else
+ state, NoAction
else
advance CloseDialogue state, NoAction
| CloseDialogue ->