From fd40d78fd7506a95f0c5ad3295adfeb0a636e815 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 04:58:29 +0800 Subject: p61: G1 字形图集补齐 16 个 UI 用字(红→绿),中文界面完整 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 红:新增 src/LivingVillage.Desktop.Tests/P61GlyphCoverageTests.fs——扫描 Desktop 源码 字符串字面量内的全部 CJK 字,断言 100% 在 CjkGlyphAtlas 内;并逐字钉 G1 缺的 16 字(冬包卷品夏料春玩秋第背车轴页香鱼)、requiredUiLabels、以及运行期文案 (日历「春·第1天」/页脚「第 1/3 页…翻页」/「V 卖出 背包物品×1」/鱼卷轴香料玩车回)。 实现前实测 3 失败:缺字形 16。 绿:向 CjkGlyphAtlas.characterTable 补入 16 字(保持有序、去重),同步 Assets/cjk-glyphs.txt,并跑 scripts/generate-cjk-glyph-atlas.sh 用既有 Noto Sans CJK 重生成预生成 PNG:384x504(16x21 格,+16 字),16 个新字格均有真实字体墨迹 (ink_px 148-233,非程序几何伪字形)。更新 PrototypeTests 的图集几何常量 (307→323、rowCount 20→21、pixelHeight 480→504)。 证据:LV_P61_SHOT=1 拍 2 帧(p61-calendar 右上日历「春 · 第1天」;p61-dialog-footer 真实 Interact 开对话,页脚「第 1/3 页…翻页」+ 选项「V 卖出 背包物品×1」)。 analyze(p61-analysis.txt):缺字空白位=0、黑块=0、白块=0/2;全部抽检文案 missing_glyph_slots=0。钩子为 Game.fs 纯追加(53 行,0 删除),未动 HUD 结构。 回归(p61-verification.txt):Desktop 308/308(+4 P61)+ Kernel 119/119; --performance-baseline ×3 digest 953775FAEB2F… 不变,determinism PASS。 未改 Kernel/Sim,未引外部素材(沿用既有 Noto 字体来源),未 push。 --- .../LivingVillage.Desktop.Tests.fsproj | 1 + .../P61GlyphCoverageTests.fs | 97 +++++++++++++++++++++ src/LivingVillage.Desktop.Tests/PrototypeTests.fs | 6 +- .../Assets/cjk-glyph-atlas.png | Bin 80906 -> 85104 bytes src/LivingVillage.Desktop/Assets/cjk-glyphs.txt | 16 ++++ src/LivingVillage.Desktop/CjkGlyphAtlas.fs | 2 +- src/LivingVillage.Desktop/Game.fs | 53 +++++++++++ 7 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/LivingVillage.Desktop.Tests/P61GlyphCoverageTests.fs (limited to 'src') diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj index 951e59b..e87ca23 100644 --- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj +++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj @@ -34,6 +34,7 @@ + diff --git a/src/LivingVillage.Desktop.Tests/P61GlyphCoverageTests.fs b/src/LivingVillage.Desktop.Tests/P61GlyphCoverageTests.fs new file mode 100644 index 0000000..f5f1a10 --- /dev/null +++ b/src/LivingVillage.Desktop.Tests/P61GlyphCoverageTests.fs @@ -0,0 +1,97 @@ +namespace LivingVillage.Desktop.Tests + +open System.IO +open System.Text.RegularExpressions +open Microsoft.VisualStudio.TestTools.UnitTesting +open LivingVillage.Desktop + +/// P61(G1 整改):中文界面字形覆盖。 +/// 断言 Desktop 全部用户可见中文文案所需字形 100% 在 CjkGlyphAtlas 内, +/// 并对 G1 盘点缺的 16 字逐字断言。缺字会被 PixelText 置空(Game.fs:73-92), +/// 因此该测试是「中文界面完整」的红→绿门。 +[] +type P61GlyphCoverageTests () = + + let isCjk (c: char) = c >= '\u4e00' && c <= '\u9fff' + let isInAtlas (c: char) = CjkGlyphAtlas.contains c + + /// G1 盘点确认缺失、P61 修复的 16 个字。 + let g1MissingChars = "冬包卷品夏料春玩秋第背车轴页香鱼".ToCharArray() + + let repoRoot () : string = + let mutable dir = System.AppContext.BaseDirectory + let mutable found = None + while found.IsNone && not (System.String.IsNullOrEmpty dir) do + if File.Exists(Path.Combine(dir, "LivingVillage.sln")) then found <- Some dir + else + let parent = Path.GetDirectoryName dir + if parent = dir then dir <- "" else dir <- parent + match found with + | Some d -> d + | None -> failwith "找不到仓库根(LivingVillage.sln)" + + /// 扫描 Desktop 源内字符串字面量里的 CJK 字符(跳过注释行)。 + let sourceCjkChars () : Set = + let root = repoRoot () + let dir = Path.Combine(root, "src", "LivingVillage.Desktop") + let literal = Regex("\"([^\"\\\\]*(?:\\\\.[^\"\\\\]*)*)\"") + let mutable chars = Set.empty + for file in Directory.GetFiles(dir, "*.fs") do + for line in File.ReadAllLines file do + let trimmed = line.TrimStart() + if not (trimmed.StartsWith "//") then + for m in literal.Matches line do + for c in m.Groups.[1].Value do + if isCjk c then chars <- Set.add c chars + chars + + [] + member _.AllSourceChineseLiteralsHaveAtlasGlyphs () = + let chars = sourceCjkChars () + let missing = chars |> Set.filter (fun c -> not (isInAtlas c)) |> Set.toList + CollectionAssert.AreEqual( + [||], + missing |> List.map string |> List.toArray, + sprintf "Desktop 用户可见中文文案缺字形 %d 个: %s(需补入 CjkGlyphAtlas.characterTable 并重生成图集)" + missing.Length (missing |> List.map string |> String.concat "")) + + [] + member _.G1MissingCharsAreNowInAtlas () = + let missing = g1MissingChars |> Array.filter (fun c -> not (isInAtlas c)) |> Array.toList + CollectionAssert.AreEqual( + [||], + missing |> List.map string |> List.toArray, + sprintf "G1 缺字仍未入图集: %s" (missing |> List.map string |> String.concat "")) + + [] + member _.RequiredUiLabelsAreAtlasBacked () = + let missing = + ChineseText.requiredUiLabels + |> List.collect (fun s -> s.ToCharArray() |> Array.toList) + |> List.filter isCjk + |> List.filter (fun c -> not (isInAtlas c)) + |> List.distinct + CollectionAssert.AreEqual( + [||], + missing |> List.map string |> List.toArray, + sprintf "requiredUiLabels 缺字形: %s" (missing |> List.map string |> String.concat "")) + + [] + member _.CalendarAndFooterAndItemCopyAreAtlasBacked () = + // 派发词点名要可见完整的三类运行期文案,逐字钉住,防止再次缺字。 + let pinned = + [ HudLayout.seasonNames |> String.concat "" + HudLayout.calendarLine1 { Season = "春"; DayNumber = 1L; Period = "清晨"; Clock = "06:00" } + "第 1/3 页 空格 翻页 Esc 关闭" + "背包物品×1" + "鱼"; "卷轴"; "香料"; "玩家"; "回车:接受任务" ] + let missing = + pinned + |> List.collect (fun s -> s.ToCharArray() |> Array.toList) + |> List.filter isCjk + |> List.distinct + |> List.filter (fun c -> not (isInAtlas c)) + CollectionAssert.AreEqual( + [||], + missing |> List.map string |> List.toArray, + sprintf "运行期文案缺字形: %s" (missing |> List.map string |> String.concat "")) diff --git a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs index 2de0996..55de3c8 100644 --- a/src/LivingVillage.Desktop.Tests/PrototypeTests.fs +++ b/src/LivingVillage.Desktop.Tests/PrototypeTests.fs @@ -1094,14 +1094,14 @@ type PrototypeTests () = [] member _.CjkGlyphAtlasHasAStableCharacterTableAndGeometry () = - Assert.AreEqual(307, characters.Length) + Assert.AreEqual(323, characters.Length) CollectionAssert.AreEqual(characters, characters |> Array.sort) Assert.AreEqual(characters.Length, characters |> Array.distinct |> Array.length) Assert.AreEqual(16, columns) Assert.AreEqual(24, cellPixels) - Assert.AreEqual(20, rowCount) + Assert.AreEqual(21, rowCount) Assert.AreEqual(384, pixelWidth) - Assert.AreEqual(480, pixelHeight) + Assert.AreEqual(504, pixelHeight) Assert.AreEqual("Assets/cjk-glyph-atlas.png", assetRelativePath) Assert.AreEqual("Assets/cjk-glyphs.txt", manifestRelativePath) diff --git a/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png b/src/LivingVillage.Desktop/Assets/cjk-glyph-atlas.png index 25d317b..ed1bf37 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 1b9bf67..0191fc0 100644 --- a/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt +++ b/src/LivingVillage.Desktop/Assets/cjk-glyphs.txt @@ -42,6 +42,7 @@ 册 再 农 +冬 准 几 出 @@ -56,9 +57,11 @@ 动 助 匀 +包 单 卖 南 +卷 原 去 友 @@ -69,6 +72,7 @@ 各 同 和 +品 回 围 圃 @@ -77,6 +81,7 @@ 坏 墙 备 +夏 多 夜 大 @@ -150,6 +155,7 @@ 效 敌 数 +料 新 方 无 @@ -159,6 +165,7 @@ 明 昏 易 +春 是 晓 晚 @@ -194,6 +201,7 @@ 照 物 状 +玩 理 瓦 生 @@ -209,11 +217,13 @@ 确 社 离 +秋 移 稳 空 竹 笑 +第 粮 精 系 @@ -232,6 +242,7 @@ 耕 聊 职 +背 能 自 范 @@ -264,7 +275,9 @@ 足 路 跳 +车 转 +轴 过 近 返 @@ -296,11 +309,14 @@ 零 需 面 +页 项 须 食 饥 饿 +香 +鱼 黄 黛 , diff --git a/src/LivingVillage.Desktop/CjkGlyphAtlas.fs b/src/LivingVillage.Desktop/CjkGlyphAtlas.fs index 01d6a59..8b42596 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 b51c7f0..cdfc06b 100644 --- a/src/LivingVillage.Desktop/Game.fs +++ b/src/LivingVillage.Desktop/Game.fs @@ -318,6 +318,12 @@ type LivingVillageGame() as this = let mutable p44Step = 0 let mutable p44PendingName = "" let mutable p44Pending = false + // P61 证据钩子(G1 字形补齐):拍「右上日历(春夏秋冬·第N天)/ 对话页脚(第N/3页 + V 卖出 背包物品×1)」 + // 两帧,证明新补 16 字在真实游戏 UI 中完整可辨。 + let p61ShotMode = Environment.GetEnvironmentVariable("LV_P61_SHOT") = "1" + let mutable p61Step = 0 + let mutable p61PendingName = "" + let mutable p61Pending = false // P45 证据钩子:LV_P45_SHOT=1 时拍角色 idle / 行走朝下 / 行走朝右 三帧(真实游戏截图)。 let p45ShotMode = Environment.GetEnvironmentVariable("LV_P45_SHOT") = "1" let mutable p45Step = 0 @@ -961,6 +967,43 @@ type LivingVillageGame() as this = (m5View.Prompt |> Option.defaultValue "") p44Step <- p44Step + 1 + /// P61 证据(G1):真实世界里拍「右上日历」与「对话页脚/背包文案」两帧。 + member private this.PrepareP61Shot () = + let setHour (hour: float) = + let tick = M6Presentation.resolveStartTick (Some hour) false + world <- { world with Tick = tick; Time = float tick * Sim.dtSeconds } + if not p61Pending then + match p61Step with + | 0 -> + setHour 12.0 + let npc = world.Npcs.[0] + world <- { world with Avatar = { world.Avatar with Pos = npc.Pos } } + this.CenterCamera() + p61PendingName <- "p61-calendar" + p61Pending <- true + | 1 -> + let nextWorld, nextView = M5Interaction.apply M5Command.Interact world m5View + world <- nextWorld + m5View <- nextView + this.CenterCamera() + p61PendingName <- "p61-dialog-footer" + p61Pending <- true + | _ -> + printfn "p61-shot=done" + this.Exit() + + /// P61 证据:落盘并打印日历读数(春夏秋冬·第N天)与面板名,供 analyze 核验。 + member private this.CaptureP61Shot () = + if p61Pending then + p61Pending <- false + System.IO.Directory.CreateDirectory recordDirectory |> ignore + this.SaveBackBuffer(sprintf "%s/%s.png" recordDirectory p61PendingName) + let reading = HudLayout.calendarReading world.Tick + printfn + "p61-shot=%s tick=%d calendar=%s panel=%s" + p61PendingName world.Tick (HudLayout.calendarLine1 reading) (M5Interaction.panelName m5View) + p61Step <- p61Step + 1 + member private this.PrepareP45Shot () = let baseTick = M6Presentation.resolveStartTick (Some 12.0) false let setTick (tick: int64) = @@ -1942,6 +1985,14 @@ type LivingVillageGame() as this = else this.DispatchMenuInput Confirm elif menu.Page = Playing then this.PrepareP44Shot() + elif p61ShotMode then + if menu.Page = MainMenu then + this.DispatchMenuInput Confirm + elif menu.Page = OccupationSelect then + if menu.Selected <> 0 then this.DispatchMenuInput Down + else this.DispatchMenuInput Confirm + elif menu.Page = Playing then + this.PrepareP61Shot() elif p45ShotMode then if menu.Page = MainMenu then this.DispatchMenuInput Confirm @@ -2412,6 +2463,8 @@ type LivingVillageGame() as this = this.CaptureP43Shot() if p44ShotMode && menu.Page = Playing then this.CaptureP44Shot() + if p61ShotMode && menu.Page = Playing then + this.CaptureP61Shot() if p45ShotMode && menu.Page = Playing then this.CaptureP45Shot() if p46ShotMode then -- cgit v1.2.3