From 3863116dc9a8f0ea3a310e447f0705e59eac94ec Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 28 Sep 2026 09:13:29 +0800 Subject: [PATCH 1/2] fix(story): lay dialog texts and font fallbacks out with the game's line metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open font assets now take the game's line metrics in two more places: - The fontRole of the dialogs' and chat windows' text nodes maps their font assets as the UI's nodes do (the dialog badges' number font). - An open asset standing in for a game font asset that no role reaches, such as the Japanese font behind the untranslated Japanese labels of the Korean chat window (既読, 事務所, 着信中..., 通知センター), takes the line metrics of that game font from GAME_FONT_LINES (the values the game's font assets have: A-OTF-ShinGoPr6N, VibeMOPro, FZLTH_GB18030L2_R, Pretendard SemiBold). Those Korean labels sat 2.7 to 3.4 canvas units off; they are now at the game's baselines. The Japanese chat and frame font (NotoSansJP) is left as it is: the Noto CJK vertical metrics (hhea 1160 / -288 / 0) equal the open stand-in's. Co-Authored-By: Claude Opus 5.5 --- docs/story-data-format.md | 11 ++++-- src/story/ui-fonts.js | 59 ++++++++++++++++++++---------- tests/unit/story-ui-fonts.test.mjs | 35 ++++++++++++++++-- 3 files changed, 78 insertions(+), 27 deletions(-) diff --git a/docs/story-data-format.md b/docs/story-data-format.md index 05b402f..3f7ca0f 100644 --- a/docs/story-data-format.md +++ b/docs/story-data-format.md @@ -448,10 +448,13 @@ materials and, per text node, the font asset and material it uses. The player la using these glyph metrics and draws it with the distance-field shader the material names (in `ui/shaders/`). The format is the same whether the glyphs were generated from a font file (`source` `open`) or taken from the game's font assets (`game`). With open fonts, a font asset that the texts of a font role use (the role's `fontAsset` in -`ui/languages.json`, or the font asset of a text node whose `textStyle.fontRole` names the role) is laid out with the -line height, ascent and descent of the game's font of that role (`ui.json` `textStyle.roles`) in place of its own face -info's. The language's line spacing is set for the game's fonts: English's -100 takes one em off the pitch of a font -whose line height is 2 em, and would put the lines of a 1.448 em face 0.448 em apart. +`ui/languages.json`, or the font asset of a text node of the UI, a dialog or a chat window whose `textStyle.fontRole` +names the role) is laid out with the line height, ascent and descent of the game's font of that role (`ui.json` +`textStyle.roles`) in place of its own face info's. Any other open asset named ` ()` (a +fallback, such as the Japanese font behind the untranslated Japanese labels of the Korean chat window) takes those of +the game font asset it stands in for, where the player knows them (A-OTF-ShinGoPr6N, VibeMOPro, FZLTH_GB18030L2_R, +Pretendard SemiBold). The language's line spacing is set for the game's fonts: English's -100 takes one em off the +pitch of a font whose line height is 2 em, and would put the lines of a 1.448 em face 0.448 em apart. ```json { "format": "ournotes.story-fonts/1", "language": "en", "source": "open", diff --git a/src/story/ui-fonts.js b/src/story/ui-fonts.js index 09d741f..283b444 100644 --- a/src/story/ui-fonts.js +++ b/src/story/ui-fonts.js @@ -7,31 +7,52 @@ import { F } from "../engine/core.js"; // assets, and the language's line spacing (LocalizeManager.ApplyLanguageLineSpacing) is set for them: English's -100 // (one em less) takes the line gap of the game's primary font (A-OTF-ShinGoPr6N: line height 2 em, ascent 0.88, // descent -0.12) out. An open face without that gap (Noto Sans CJK: 1.448 em with no gap) would put the lines 0.448 em -// apart, over each other. So an open font asset standing in for a font role of the game gets the game's line height, -// ascent and descent of that role (ui.json `textStyle.roles`, per role in em) in place of its own: the lines are as -// far apart and sit where the game has them, in every language. The glyphs keep their own metrics (the open CJK -// faces share the game's ideographic em box, 0.88 / -0.12). +// apart, over each other. So an open font asset standing in for a font of the game gets that font's line height, +// ascent and descent in place of its own: the lines are as far apart and sit where the game has them, in every +// language. The glyphs keep their own metrics (the open CJK faces share the game's ideographic em box, 0.88 / -0.12). // -// The role of a font asset: the fontRole of the text nodes whose bindings (fonts.texts[path].localized) use it, and -// the font asset the language document names per role (ui/languages.json `roles`, which also covers the texts of the -// chat window and the frames). Data from the game's fonts, or without textStyle roles, is taken as it is. +// The game font of an open font asset, in this order: +// - its font role: the fontRole of the text nodes whose bindings (localized.fontAsset) use it (the story UI's nodes, +// its dialogs' and chat windows'), or the role ui/languages.json `roles` names it for; the role's line metrics are +// ui.json `textStyle.roles` (read from the game's font assets of the language); +// - else the game font asset its name stands in for (" ()", a fallback of another asset +// such as the Japanese font behind Korean text) when GAME_FONT_LINES has it. +// Data from the game's fonts is taken as it is. + +// Line metrics in em of the game's font assets (TMP FaceInfo line height, ascent line, descent line / point size), +// as their data has them: A-OTF-ShinGoPr6N (ja / en primary, the fallback of the Korean and Chinese fonts), +// VibeMOPro (the number role), FZLTH_GB18030L2_R (zh primary), Pretendard SemiBold (ko primary). +export const GAME_FONT_LINES = Object.freeze({ + "A-OTF-ShinGoPr6N-Regular SDF": Object.freeze({ lineHeightEm: 2, ascentEm: 0.88, descentEm: -0.12 }), + "VibeMOPro-Medium SDF": Object.freeze({ lineHeightEm: 1.2, ascentEm: 0.83, descentEm: -0.17 }), + "FZLTH_GB18030L2_R SDF": Object.freeze({ lineHeightEm: 1.155, ascentEm: 0.892, descentEm: -0.263 }), + "Pretendard-SemiBold SDF": Object.freeze({ lineHeightEm: 1.192871, ascentEm: 0.95166, descentEm: -0.241211 }), +}); + export class StoryFontAssets { // fonts = ui/fonts.json (or ui/simple/fonts.json); doc = the ui.json record document laid out with them; // language = ui/languages.json (may be null) constructor(fonts, doc, language) { this.fonts = fonts; this.cache = new Map(); - this.roleOf = new Map(); // font asset name -> the game's line metrics of its role - const roles = doc && doc.textStyle && doc.textStyle.roles; - if (!fonts || fonts.source !== "open" || !roles) return; - const add = (asset, role) => { - const m = roles[role]; - if (typeof asset === "string" && m && !this.roleOf.has(asset)) this.roleOf.set(asset, m); + this.lines = new Map(); // font asset name -> the game's line metrics + if (!fonts || fonts.source !== "open") return; + const roles = (doc && doc.textStyle && doc.textStyle.roles) || {}; + const add = (asset, m) => { if (typeof asset === "string" && m && !this.lines.has(asset)) this.lines.set(asset, m); }; + for (const [role, r] of Object.entries((language && language.roles) || {})) add(r && r.fontAsset, roles[role]); + const byNodes = (nodes, bindings) => { + for (const n of nodes || []) { + const t = n.textStyle && bindings ? bindings[n.path] : null; + if (t && t.localized) add(t.localized.fontAsset, roles[n.textStyle.fontRole]); + } }; - for (const [role, r] of Object.entries((language && language.roles) || {})) add(r && r.fontAsset, role); - for (const n of doc.nodes || []) { - const t = n.textStyle && fonts.texts ? fonts.texts[n.path] : null; - if (t && t.localized) add(t.localized.fontAsset, n.textStyle.fontRole); + byNodes(doc && doc.nodes, fonts.texts); + for (const [name, d] of Object.entries((doc && doc.dialogs) || {})) byNodes(d && d.nodes, (fonts.dialogTexts || {})[name]); + for (const [name, w] of Object.entries((doc && doc.chatTexts) || {})) + byNodes(Object.entries(w || {}).map(([path, r]) => ({ path, textStyle: r && r.textStyle })), (fonts.chatTexts || {})[name]); + for (const name of Object.keys(fonts.fonts || {})) { + const m = /\(([^()]+)\)$/.exec(name); + if (m) add(name, GAME_FONT_LINES[m[1]]); } } @@ -46,9 +67,9 @@ export class StoryFontAssets { return this.cache.get(name); } - // the face info of the font asset `name`: with the line metrics of the game's font of its role, else its own + // the face info of the font asset `name`: with the line metrics of its game font, else its own faceInfo(name, fi) { - const m = this.roleOf.get(name); + const m = this.lines.get(name); if (!m || !fi) return fi; const em = F(fi.m_PointSize / (fi.m_Scale || 1)); return { ...fi, m_LineHeight: F(m.lineHeightEm * em), m_AscentLine: F(m.ascentEm * em), diff --git a/tests/unit/story-ui-fonts.test.mjs b/tests/unit/story-ui-fonts.test.mjs index a40a10d..044bc5c 100644 --- a/tests/unit/story-ui-fonts.test.mjs +++ b/tests/unit/story-ui-fonts.test.mjs @@ -1,11 +1,12 @@ // StoryFontAssets (src/story/ui-fonts.js): font data made from open fonts lays lines out with the line height, ascent // and descent of the game's font of each role (ui.json textStyle.roles), found through the language document's roles -// and the text nodes' fontRole; game fonts, data without roles and fonts of no role keep their own face info. -// Synthetic inputs only. +// and the fontRole of the text nodes (the UI's, its dialogs' and chat windows'), else of the game font asset the open +// asset's name stands in for (GAME_FONT_LINES); game fonts and fonts of neither keep their own face info. Synthetic +// inputs only. import assert from "node:assert/strict"; import { test } from "node:test"; import { F } from "../../src/engine/core.js"; -import { StoryFontAssets } from "../../src/story/ui-fonts.js"; +import { GAME_FONT_LINES, StoryFontAssets } from "../../src/story/ui-fonts.js"; // Noto Sans CJK at 40 px per em: line height 1.448 em, no line gap const face = (size = 40) => ({ m_PointSize: size, m_Scale: 1, m_LineHeight: F(1.448 * size), m_AscentLine: F(1.16 * size), @@ -27,7 +28,7 @@ test("open fonts: the game's line metrics of the role, by the language's roles a assert.equal(t.name, "Talk"); assert.equal(a.get("Talk"), t); // one record per asset const s = a.get("Simple").faceInfo; // the talk text's binding, role primary - assert.deepEqual([s.m_LineHeight, s.m_AscentLine, s.m_DescentLine], [94, F(F(0.88) * 47), F(F(-0.12) * 47)]); + assert.deepEqual([s.m_LineHeight, s.m_AscentLine, s.m_DescentLine], [94, F(0.88 * 47), F(-0.12 * 47)]); assert.deepEqual(a.get("Digits").faceInfo, face(80)); // no text of the role: its own assert.equal(a.get("Missing"), null); // English line spacing -100 at 36 px: lineHeight x scale - 36 is one em, not 0.448 em @@ -41,3 +42,29 @@ test("game fonts, and data without textStyle roles, keep the font assets' own fa assert.deepEqual(new StoryFontAssets(fontsDoc("open"), uiDoc, null).get("Talk").faceInfo, face()); // by fontRole only assert.equal(new StoryFontAssets(fontsDoc("open"), uiDoc, null).get("Simple").faceInfo.m_LineHeight, 94); }); + +test("dialog and chat text nodes give their fonts' roles; a fallback stands in for the game font its name gives", () => { + const fonts = { + source: "open", textures: {}, + fonts: { Talk: { faceInfo: face() }, "Noto Digits (VibeMOPro-Medium SDF)": { faceInfo: face(80) }, + "Noto CJK JP (A-OTF-ShinGoPr6N-Regular SDF)": { faceInfo: face(40) }, "Noto (Unknown SDF)": { faceInfo: face(40) }, + Chat: { faceInfo: face(30) } }, + texts: {}, dialogTexts: { D: { "D/Badge": { localized: { fontAsset: "Noto Digits (VibeMOPro-Medium SDF)" } } } }, + chatTexts: { W: { "W/Name": { localized: { fontAsset: "Chat" } } } }, + }; + const doc = { textStyle: { roles: { primary: { lineHeightEm: 1.19, ascentEm: 0.95, descentEm: -0.24 }, + number: { lineHeightEm: 1.25, ascentEm: 0.8, descentEm: -0.2 } } }, + nodes: [], dialogs: { D: { nodes: [{ path: "D/Badge", textStyle: { fontRole: "number" } }] } }, + chatTexts: { W: { "W/Name": { textStyle: { fontRole: "primary" } } } } }; + const a = new StoryFontAssets(fonts, doc, { roles: { primary: { fontAsset: "Talk" } } }); + const lines = (n) => { const fi = a.get(n).faceInfo; return [fi.m_LineHeight, fi.m_AscentLine, fi.m_DescentLine]; }; + // the role of the data first, over the name's game font + assert.deepEqual(lines("Noto Digits (VibeMOPro-Medium SDF)"), [100, 64, -16]); + assert.deepEqual(lines("Chat"), [F(1.19 * 30), F(0.95 * 30), F(-0.24 * 30)]); + // a fallback no text uses: the line metrics of the game font it stands in for + const g = GAME_FONT_LINES["A-OTF-ShinGoPr6N-Regular SDF"]; + assert.deepEqual(lines("Noto CJK JP (A-OTF-ShinGoPr6N-Regular SDF)"), [80, F(g.ascentEm * 40), F(g.descentEm * 40)]); + assert.deepEqual(a.get("Noto (Unknown SDF)").faceInfo, face(40)); + assert.deepEqual(new StoryFontAssets({ ...fonts, source: "game" }, doc, null).get("Noto CJK JP (A-OTF-ShinGoPr6N-Regular SDF)").faceInfo, + face(40)); +}); From c0b1774fcec39ccb98b06e141b68b99e4d056f77 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 28 Sep 2026 09:13:30 +0800 Subject: [PATCH 2/2] chore(release): 0.1.4 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 582bbff..6c5be05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ournotes-player", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ournotes-player", - "version": "0.1.3", + "version": "0.1.4", "license": "AGPL-3.0-only", "devDependencies": { "esbuild": "0.25.10", diff --git a/package.json b/package.json index 2f297d4..39daea5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ournotes-player", - "version": "0.1.3", + "version": "0.1.4", "description": "Browser player for BanG Dream! Our Notes: live charts, story episodes and Live2D models with the game's own shaders (WebGL2 + WebAudio, custom elements)", "keywords": [ "bang-dream",