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/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", 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)); +});