From e1d8d45cf08b4f30e33299eb40e865729e7adfd2 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 22:55:53 +0800 Subject: fix(ui): P15 补齐职业/任务中文用字(CJK 图集 197->228),requiredUiLabels 锁定防复发 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CjkGlyphAtlas 字表 + Assets/cjk-glyphs.txt + cjk-glyph-atlas.png 重生成(384x360,15 行): 新增 职/业/收/货/转/事/论/理/粮/工/翻/土/捕/渔/询/送/帮/农/夫/郎/书/生/待/接/受/今/日/任/务/行/中 共 31 字,覆盖职业选择页与 T 面板全部模板/状态文案,消除方框缺字。 - ChineseText.requiredUiLabels 增职业/模板/状态标签,既有 FormalChineseUiLabelsUseTheRealGlyphAtlasBoundary 与 RequiredUiLabelsAreFullyCoveredByTheGlyphTable 继续强制全字形可渲染。 - PrototypeTests 几何常量同步 197->228、13->15 行、312->360 像素。Desktop 87 全绿。 --- .../Assets/cjk-glyph-atlas.png | Bin 52827 -> 60594 bytes src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 31 +++++++++++++++++++++ src/LivingVillage.Desktop/ChineseText.fs | 25 ++++++++++++++++- src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 +- 4 files changed, 56 insertions(+), 2 deletions(-) (limited to 'src/LivingVillage.Desktop') diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png index 9678aeb..598c2bc 100644 Binary files a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png and b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png differ diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt index 331e2ec..cdfc550 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -1,16 +1,22 @@ × 一 不 +业 +中 主 乐 乡 +书 买 +事 于 互 交 人 +今 从 价 +任 传 体 作 @@ -21,12 +27,14 @@ 入 关 内 +农 准 出 到 前 力 功 +务 动 助 单 @@ -35,18 +43,21 @@ 原 友 取 +受 可 同 和 回 围 圃 +土 在 墙 备 夜 大 天 +夫 失 好 始 @@ -59,7 +70,9 @@ 层 居 屋 +工 已 +帮 年 庄 库 @@ -69,6 +82,7 @@ 式 当 录 +待 得 心 必 @@ -86,16 +100,20 @@ 拒 择 挑 +捕 据 +接 提 播 操 +收 效 敌 数 新 方 无 +日 时 明 易 @@ -119,10 +137,13 @@ 江 没 法 +渔 游 物 状 +理 瓦 +生 用 留 白 @@ -134,6 +155,7 @@ 移 竹 笑 +粮 精 系 紧 @@ -143,37 +165,46 @@ 绪 续 置 +翻 聊 +职 能 范 获 菜 衅 +行 观 认 讨 议 记 讲 +论 设 话 +询 说 请 读 谅 谈 败 +货 资 足 +转 近 返 还 进 迫 退 +送 选 速 道 +郎 量 金 鉴 diff --git a/src/LivingVillage.Desktop/ChineseText.fs b/src/LivingVillage.Desktop/ChineseText.fs index d516432..82418e6 100644 --- a/src/LivingVillage.Desktop/ChineseText.fs +++ b/src/LivingVillage.Desktop/ChineseText.fs @@ -184,7 +184,30 @@ module ChineseText = "关闭" "天数" "金钱" - "精力" ] + "精力" + "选择职业" + "暂不选择" + "农夫" + "渔夫" + "货郎" + "书生" + "任务" + "今日暂无任务" + "无职业" + "待接受" + "进行中" + "已完成" + "已失败" + "送粮" + "帮工" + "翻土" + "夜捕" + "卖渔" + "询价" + "收货" + "转卖" + "观察记事" + "论理" ] let layoutText (maxWidth: int) (supported: Set) (text: string) : TextLayout = let width = max 1 maxWidth diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index fc19828..07895c8 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 -- cgit v1.2.3 From 60bbc83d9a1d047933e4128e7b48e1c61c925a14 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 22:57:50 +0800 Subject: feat(desktop): P15 开局职业选择页——新档四选一+暂不选择,接入 v3 存档与当日任务 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MenuState:新增 OccupationSelect 页与 StartNewGameWith 命令;occupationOptions=农夫/渔夫/货郎/书生/暂不选择, 默认高亮「暂不选择」= 不写职业的旧行为;四选一确认后发 StartNewGameWith,ESC 返回主菜单(既有 MainMenu 行为与测试不变)。 - WorldBootstrap.occupationStateFor/refreshOccupationToday:按 seed+职业种子生成/跨日刷新当日 Offered 任务。 - Game:主菜单「开始新游戏」→ 职业选择页;选定后 initialWorldWithOccupation 注入 Money/Energy 与 Today, m5View.Task 随之写入 v3 存档;游玩中按日刷新;autoplay/flow 默认走「暂不选择」保持旧演示行为;DrawMenu 渲染职业页。 - 测试 Desktop 93(新增 6):职业页选项顺序/默认 None/标签、确认命令与返回、bootstrap 四职业 Money/Energy+当日池内 Offered 任务、四职业 v3 round-trip(职业+任务逐字段)、T 面板显示所选职业的模板名与「待接受」、跨日刷新保持同日。 --- src/LivingVillage.Desktop.Tests/DesktopTests.fs | 69 +++++++++++++++++++++++++ src/LivingVillage.Desktop.Tests/M6bTests.fs | 30 +++++++++++ src/LivingVillage.Desktop/Game.fs | 38 ++++++++++++-- src/LivingVillage.Desktop/MenuState.fs | 29 +++++++++++ src/LivingVillage.Desktop/WorldBootstrap.fs | 17 ++++++ 5 files changed, 178 insertions(+), 5 deletions(-) (limited to 'src/LivingVillage.Desktop') diff --git a/src/LivingVillage.Desktop.Tests/DesktopTests.fs b/src/LivingVillage.Desktop.Tests/DesktopTests.fs index 52443e9..5f2d6df 100644 --- a/src/LivingVillage.Desktop.Tests/DesktopTests.fs +++ b/src/LivingVillage.Desktop.Tests/DesktopTests.fs @@ -208,3 +208,72 @@ type TaskPanelTests () = Assert.AreEqual(dialogueView, afterTask) let _, closed = M5Interaction.apply ClosePanel world { dialogueView with Panel = TaskPanel } Assert.AreEqual(WorldPanel, closed.Panel) + +// ---- P15 开局职业选择接入:bootstrap 职业状态 / 存档 round-trip / 面板联调 ---- + +module private OccupationFlowHarness = + + let withTempSave (action: string -> unit) = + let id = System.Guid.NewGuid().ToString("N") + let path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"living-village-p15-{id}.save") + try + action path + finally + if System.IO.File.Exists path then System.IO.File.Delete path + +[] +type OccupationFlowTests () = + + [] + member _.BootstrapStampsOccupationMoneyEnergyAndTodaysOfferedTask () = + for kind in Occupation.all do + let state = WorldBootstrap.occupationStateFor 42UL kind 0L + let task = state.Today.Value + Assert.AreEqual(Occupation.Offered, task.State) + Assert.IsTrue(Occupation.taskPoolOf kind |> List.contains task.TemplateId) + Assert.AreEqual( + (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf kind) 0L kind).Value, + task) + let world = WorldBootstrap.initialWorldWithOccupation true 42UL 4 (Some state) + Assert.AreEqual(float32 state.Profile.InitialMoney, world.Avatar.Mind.Needs.Money) + Assert.AreEqual(state.Profile.InitialEnergy, world.Avatar.Mind.Needs.Energy) + + [] + member _.ChosenOccupationAndTodaysTaskRoundTripThroughV3Save () = + for kind in Occupation.all do + let state = WorldBootstrap.occupationStateFor 42UL kind (3L * Sim.ticksPerDay + 120L) + let world = WorldBootstrap.initialWorldWithOccupation false 42UL 4 (Some state) + OccupationFlowHarness.withTempSave (fun path -> + WorldSave.saveToFileWith (Some state) path world + let text = System.IO.File.ReadAllText path + Assert.IsTrue(text.StartsWith("LV_WORLD_SAVE_V3"), "chosen occupation must save as v3") + match WorldSave.loadFromFileWith path with + | Ok (restoredWorld, Some restoredState) -> + Assert.AreEqual(kind, restoredState.Profile.Kind) + Assert.AreEqual(state, restoredState) + Assert.AreEqual(text, WorldSave.saveWith (Some restoredState) restoredWorld) + | Ok (_, None) -> Assert.Fail($"v3 occupation lost for {kind}") + | Error message -> Assert.Fail($"v3 round trip failed for {kind}: {message}")) + + [] + member _.TaskPanelShowsTheChosenOccupationsOfferedTask () = + let world = Sim.initialWorldN 42UL 4 + for kind in Occupation.all do + let state = WorldBootstrap.occupationStateFor 42UL kind 0L + let view = { M5Interaction.initial with Task = Some state } + let _, taskView = M5Interaction.apply ShowTaskPanel world view + let lines = M5Interaction.panelLines world taskView + Assert.IsTrue(lines |> List.contains (Occupation.nameOf kind), $"occupation name missing for {kind}") + Assert.IsTrue( + lines |> List.exists (fun line -> line.Contains(Occupation.taskNameOf state.Today.Value.TemplateId)), + $"task name missing for {kind}") + Assert.IsTrue(lines |> List.exists (fun line -> line.Contains("待接受")), $"offered state missing for {kind}") + + [] + member _.RefreshOccupationTodayKeepsSameDayAndRegeneratesForTheNewDay () = + let state = WorldBootstrap.occupationStateFor 42UL Occupation.Farmer 0L + Assert.AreEqual(state, WorldBootstrap.refreshOccupationToday 42UL state (Sim.ticksPerDay - 1L)) + let day1 = WorldBootstrap.refreshOccupationToday 42UL state Sim.ticksPerDay + Assert.AreEqual( + Some (Occupation.dailyTaskOf 42UL (Occupation.occupationSeedOf Occupation.Farmer) 1L Occupation.Farmer).Value, + day1.Today) diff --git a/src/LivingVillage.Desktop.Tests/M6bTests.fs b/src/LivingVillage.Desktop.Tests/M6bTests.fs index 6ae0634..5aea9bd 100644 --- a/src/LivingVillage.Desktop.Tests/M6bTests.fs +++ b/src/LivingVillage.Desktop.Tests/M6bTests.fs @@ -80,3 +80,33 @@ type M6bMenuTests () = Assert.AreEqual(Some "missing save", failed.Error) Assert.AreEqual(MainMenu, returned.Page) Assert.AreEqual(NoCommand, command) + + // ---- P15 开局职业选择页 ---- + + [] + member _.OccupationSelectOffersFourKindsThenNoneAndDefaultsToNone () = + let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false) + + Assert.AreEqual(OccupationSelect, page.Page) + Assert.AreEqual( + [ Some Occupation.Farmer; Some Occupation.Fisher; Some Occupation.Peddler; Some Occupation.Scholar; None ], + MenuState.occupationOptions) + Assert.AreEqual(None, MenuState.selectedOccupation page) + Assert.AreEqual("暂不选择", MenuState.occupationLabel None) + Assert.AreEqual("农夫", MenuState.occupationLabel (Some Occupation.Farmer)) + Assert.AreEqual("书生", MenuState.occupationLabel (Some Occupation.Scholar)) + + [] + member _.OccupationSelectConfirmEmitsChosenKindAndBackReturnsToMenu () = + let page = MenuState.openOccupationSelect (MenuState.create SimulationControl.initial false) + let _, defaultCommand = MenuState.update Confirm page + Assert.AreEqual(StartNewGameWith None, defaultCommand) + + let picked = MenuState.move Up (MenuState.move Up page) + Assert.AreEqual(Some Occupation.Peddler, MenuState.selectedOccupation picked) + let _, command = MenuState.update Confirm picked + Assert.AreEqual(StartNewGameWith (Some Occupation.Peddler), command) + + let back, backCommand = MenuState.update Back page + Assert.AreEqual(MainMenu, back.Page) + Assert.AreEqual(NoCommand, backCommand) diff --git a/src/LivingVillage.Desktop/Game.fs b/src/LivingVillage.Desktop/Game.fs index 6918e00..1b9508b 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -204,11 +204,18 @@ type LivingVillageGame() as this = m5View <- M5Interaction.refreshPrompt world M5Interaction.initial this.CenterCamera() - member private this.StartNewGame() = - world <- createInitialWorld () + member private this.StartNewGame (occupation: Occupation.Kind option) = + let state = + occupation + |> Option.map (fun kind -> + let startTick = if daylightAutoplay then 6L * Sim.ticksPerDay / 24L else 0L + WorldBootstrap.occupationStateFor 42UL kind startTick) + world <- WorldBootstrap.initialWorldWithOccupation daylightAutoplay 42UL Sim.npcCount state this.ResetWorldView() + m5View <- { m5View with Task = state } menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame - printfn $"new-game=ok seed=42 npcs={Sim.npcCount}" + let occupationName = occupation |> Option.map Occupation.nameOf |> Option.defaultValue "none" + printfn $"new-game=ok seed=42 npcs={Sim.npcCount} occupation={occupationName}" member private this.SaveWorld (prefix: string) : bool = try @@ -247,7 +254,10 @@ type LivingVillageGame() as this = | ContinueGame -> menu <- menu |> MenuState.setCurrentWorld true |> MenuState.enterGame printfn $"continue=ok tick={world.Tick}" - | StartNewGame -> this.StartNewGame() + | StartNewGame -> + menu <- MenuState.openOccupationSelect menu + printfn "menu=occupation-select" + | StartNewGameWith occupation -> this.StartNewGame occupation | LoadGame -> match this.LoadWorld "load" with | Ok _ -> @@ -278,7 +288,9 @@ type LivingVillageGame() as this = else None match input with | Some nextInput -> this.DispatchMenuInput nextInput - | None when (autoplay || sampleMode) && not autoplayFlow && menu.Page = MainMenu && not menu.HasCurrentWorld -> + | None when (autoplay || sampleMode) && not autoplayFlow + && (menu.Page = MainMenu || menu.Page = OccupationSelect) + && not menu.HasCurrentWorld -> autoplayFrames <- autoplayFrames + 1 if autoplayFrames >= 15 then this.DispatchMenuInput Confirm @@ -435,6 +447,12 @@ type LivingVillageGame() as this = else avatarMoving <- false this.CenterCamera() + match m5View.Task with + | Some state -> + let refreshed = WorldBootstrap.refreshOccupationToday 42UL state world.Tick + if refreshed <> state then + m5View <- { m5View with Task = Some refreshed } + | None -> () m5View <- M5Interaction.refreshPrompt world m5View member private this.RunAutoplayFlow() = @@ -455,6 +473,9 @@ type LivingVillageGame() as this = | 50 -> printfn "flow=new-game" flowStep <- 1 + | 1 when menu.Page = OccupationSelect -> + this.DispatchMenuInput Confirm + printfn "flow=occupation=none" | 1 when menu.Page = Playing -> this.OpenMainMenu() printfn "flow=return-menu" @@ -619,6 +640,7 @@ type LivingVillageGame() as this = let contentRows = match menu.Page with | MainMenu -> MenuState.items menu |> List.length + | OccupationSelect -> MenuState.occupationOptions.Length | Settings -> MenuState.settings.Length | Controls -> MenuState.controlRows.Length | LoadError -> 4 @@ -696,6 +718,12 @@ type LivingVillageGame() as this = MenuState.items menu |> List.iteri (fun index item -> drawSelection index (MenuState.itemLabel item) (contentY + index * lineHeight)) drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认" + | OccupationSelect -> + drawText innerX headingY mutedColor "选择职业" + MenuState.occupationOptions + |> List.iteri (fun index occupation -> + drawSelection index (MenuState.occupationLabel occupation) (contentY + index * lineHeight)) + drawText innerX (panelY + panelHeight - 40) mutedColor "UP DOWN 选择 ENTER 确认 ESC 返回" | Settings -> drawText innerX headingY mutedColor (ChineseText.menuHeading SettingsHeading) MenuState.settings diff --git a/src/LivingVillage.Desktop/MenuState.fs b/src/LivingVillage.Desktop/MenuState.fs index 2848e22..619ac58 100644 --- a/src/LivingVillage.Desktop/MenuState.fs +++ b/src/LivingVillage.Desktop/MenuState.fs @@ -4,6 +4,7 @@ open LivingVillage.Kernel type MenuPage = | MainMenu + | OccupationSelect | Settings | Controls | Playing @@ -33,6 +34,7 @@ type MenuCommand = | NoCommand | ContinueGame | StartNewGame + | StartNewGameWith of Occupation.Kind option | LoadGame | ApplySettings of SimulationControl | ExitGame @@ -100,9 +102,26 @@ module MenuState = | ControlsItem -> "操作说明" | ExitItem -> "退出" + /// 开局四选一 + 「暂不选择」(None = 旧行为,无职业)。 + let occupationOptions : Occupation.Kind option list = + [ Some Occupation.Farmer + Some Occupation.Fisher + Some Occupation.Peddler + Some Occupation.Scholar + None ] + + let occupationLabel (occupation: Occupation.Kind option) : string = + match occupation with + | Some kind -> Occupation.nameOf kind + | None -> "暂不选择" + + let selectedOccupation (state: MenuState) : Occupation.Kind option = + occupationOptions |> List.item state.Selected + let private optionsFor (state: MenuState) : int = match state.Page with | MainMenu -> items state |> List.length + | OccupationSelect -> occupationOptions.Length | Settings -> settings.Length | _ -> 0 @@ -150,6 +169,14 @@ module MenuState = let openSettings (state: MenuState) : MenuState = { state with Page = Settings; Selected = 0; ReturnPage = state.Page; Error = None } + /// 新档确认后进入职业选择页;默认选中「暂不选择」= 不写职业的旧行为。 + let openOccupationSelect (state: MenuState) : MenuState = + { state with + Page = OccupationSelect + Selected = occupationOptions.Length - 1 + ReturnPage = state.Page + Error = None } + let openControls (state: MenuState) : MenuState = { state with Page = Controls; Selected = 0; ReturnPage = state.Page; Error = None } @@ -187,6 +214,7 @@ module MenuState = | Back -> match state.Page with | MainMenu -> state, ExitGame + | OccupationSelect -> openMain state, NoCommand | Settings | Controls -> returnFromSubpage state, NoCommand | LoadError -> openMain state, NoCommand @@ -194,6 +222,7 @@ module MenuState = | Confirm -> match state.Page with | MainMenu -> confirmMain state + | OccupationSelect -> state, StartNewGameWith (selectedOccupation state) | Settings -> let nextSettings = selectedControl state { state with Settings = nextSettings }, ApplySettings nextSettings diff --git a/src/LivingVillage.Desktop/WorldBootstrap.fs b/src/LivingVillage.Desktop/WorldBootstrap.fs index 0e8ea30..f8ba775 100644 --- a/src/LivingVillage.Desktop/WorldBootstrap.fs +++ b/src/LivingVillage.Desktop/WorldBootstrap.fs @@ -18,6 +18,23 @@ module WorldBootstrap = let private daylightStartTick = 6L * Sim.ticksPerDay / 24L + /// 新档职业状态:按世界 seed 与职业种子生成 nowTick 所属日的 Offered 任务(design §3)。 + let occupationStateFor (seed: uint64) (kind: Occupation.Kind) (nowTick: int64) : Occupation.State = + let baseState = Occupation.stateOf kind + { baseState with + Today = Occupation.dailyTaskOf seed (Occupation.occupationSeedOf kind) (Occupation.dayIndexOf nowTick) kind } + + /// 跨日刷新当日任务;同日(含 Done/Failed 终态)原样保留。 + let refreshOccupationToday (seed: uint64) (state: Occupation.State) (nowTick: int64) : Occupation.State = + { state with + Today = + Occupation.refreshToday + seed + (Occupation.occupationSeedOf state.Profile.Kind) + state.Profile.Kind + nowTick + state.Today } + let initialWorldWithOccupation (startAtDaylight: bool) (seed: uint64) (count: int) (occupation: Occupation.State option) : World = if count < 0 || count > npcSpawnTiles.Length then invalidArg (nameof count) "Desktop bootstrap supports at most 30 NPCs." -- cgit v1.2.3