Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions docs/story-data-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<open font> (<game font asset>)` (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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
59 changes: 40 additions & 19 deletions src/story/ui-fonts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<open font> (<game font asset>)", 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]]);
}
}

Expand All @@ -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),
Expand Down
35 changes: 31 additions & 4 deletions tests/unit/story-ui-fonts.test.mjs
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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
Expand All @@ -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));
});
Loading