diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 10:12:20 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 10:12:20 +0800 |
| commit | 9df2da41289f4d0852f86e86eae81ca7f7e7b598 (patch) | |
| tree | bca1a071e3369a9e2ebbf5e424da3dd45547f2be /src/LivingVillage.Desktop | |
| parent | c932969539b00a42937f0aa5ed1e10b4e94932a3 (diff) | |
| download | living-village-9df2da41289f4d0852f86e86eae81ca7f7e7b598.tar.gz | |
fix(desktop): 修复 HUD 缺字并统一轻量面板与情境提示
字符表扩充至 197 字(补 间/门/谈/体/屋/返/×/,/:等全部 UI 用字),用 Noto Sans CJK 重新生成图集并逐字校验;新增字符表覆盖全部 UI 文案、瞬态状态 2.5 秒过期、对话完成后回到轻量面板等回归测试;户外与室内统一左下角轻量 HUD,互动日志改为短暂反馈;样例脚本保留 E 交谈 提示与对话菜单停留帧。
Diffstat (limited to 'src/LivingVillage.Desktop')
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png | bin | 50697 -> 52827 bytes | |||
| -rw-r--r-- | src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 9 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/ChineseText.fs | 68 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Game.fs | 49 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Interaction.fs | 65 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/SampleScript.fs | 14 |
7 files changed, 179 insertions, 28 deletions
diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png Binary files differindex acea5a9..9678aeb 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png +++ b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png 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 -> |
