diff --git a/.gitignore b/.gitignore index 6721a54d..767b4166 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ venv/ __pycache__/ *.py[cod] +# --- content packs (docs/PACKS.md): the pack index for a static host, generated by `node tools/packs.mjs index` +# (the server answers /packs/index.json itself; tools/package.mjs writes this file into the release zips) ----------- +packs/index.json + # --- tests & logs ---------------------------------------------------------------------------------- test/e2e/out/ logs/ diff --git a/packs/README.md b/packs/README.md new file mode 100644 index 00000000..586e6112 --- /dev/null +++ b/packs/README.md @@ -0,0 +1,28 @@ +# packs/ — content packs + +Put a content pack here as its own folder: `packs//pack.json` (the manifest) next to the files it names. The +server lists every pack it finds at `/packs/index.json` and picks up a new or changed pack on the next page load — no +restart, no build step. `node tools/packs.mjs list` shows what was found, what was skipped and why. + +In this version one type loads: **`lang`**, a language for the interface (and, optionally, the game texts): + +```json +{ + "id": "ja", + "type": "lang", + "lang": "ja", + "name": "日本語", + "englishName": "Japanese", + "version": "1.0.0", + "app": ">=0.2.0", + "authors": ["your name"], + "fallback": ["en"], + "files": { "ui": "ui.json", "data": "data.json" } +} +``` + +A language can also be one file, `public/i18n/.json`, with the same fields in its `_meta` block — that is how +English ships. Types `assets` (art, audio, fonts) and `data` (data patches) are planned; this version lists such a pack +as not supported and does not load it. + +The format, the rules and how later types plug in: [docs/PACKS.md](../docs/PACKS.md). Translating: [docs/I18N.md](../docs/I18N.md). diff --git a/public/css/screens/title.css b/public/css/screens/title.css index 108f6f27..ed29a4ab 100644 --- a/public/css/screens/title.css +++ b/public/css/screens/title.css @@ -192,7 +192,7 @@ animation: rise-in 600ms 140ms var(--ease-out) backwards; } .title-cn__colon { color: #f4f6f5; } -/* English title (ui/lang.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ +/* a title in an alphabetic script (English …; screens/title.js): the display face, smaller so "Stronghold Protocol: Alliance" fits one line */ .title-cn--latin { font-family: var(--font-display); font-size: .6rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; } .title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); } .title-tag { @@ -243,10 +243,12 @@ .title-invite .icon { width: .2rem; height: .2rem; } .title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; } .title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); } -/* language switch (中文 | English) in the top-right corner */ +/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */ .title-lang { display: inline-flex; margin-bottom: .1rem; } .title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; } -html[data-lang="en"] .title-login .btn--xl { letter-spacing: .3em; } +.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; } +.title-lang.lang-select select { height: .3rem; font-size: .14rem; } +html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; } .title-conn .ping { height: .26rem; font-size: .15rem; } .title-foot { diff --git a/public/i18n/en.json b/public/i18n/en.json index 01b2db31..807a8b24 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1,4 +1,18 @@ { + "_meta": { + "type": "lang", + "lang": "en", + "name": "English", + "englishName": "English", + "version": "0.2.0", + "app": ">=0.2.0", + "authors": [ + "@YuriRestia (GitHub PR #70, the seed of these strings)", + "the Stronghold Protocol contributors" + ], + "credits": "Game texts (data/i18n/en.json): the official EN client data (© Hypergryph / Yostar) via ArknightsAssets/ArknightsGamedata.", + "complete": true + }, "_about": "English UI strings: Chinese msgid → English (shared/i18n.js t(); docs/I18N.md). Placeholders {name} and {n|one|other} as in the msgid; a 'context::msgid' key is a tc() variant. Seeded from GitHub PR #70 (branch en-translation) by @YuriRestia, reused with credit (NOTICE.md). Game texts are not here: data/i18n/en.json (tools/build-i18n.mjs).", "你已在其他同盟中,请先离开当前同盟": "You are already in another Alliance. Leave it first.", "你已不在该同盟中": "You are no longer in this Alliance", @@ -116,6 +130,7 @@ "请输入博士代号": "Please enter your Doctor callsign", "卫戍协议": "Stronghold Protocol", "盟约": "Alliance", + "卫戍协议:盟约 · STRONGHOLD PROTOCOL": "Stronghold Protocol: Alliance · Web Simulation", "调配资金与干员,与同伴协同布防,抵御多波次进攻,直至击败敌方领袖。": "Allocate Funds and Operators, coordinate defenses with your allies, hold off wave after wave, and defeat the Enemy Leader.", "收到同盟邀请": "Alliance invite received", "· 输入代号后将自动加入": "· You'll join automatically after entering your callsign", diff --git a/public/js/data.js b/public/js/data.js index 71630939..f3b725b0 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -18,15 +18,18 @@ // Synchronous getters (getChess, getBond, …) return null until the file has loaded; use // `loadData(...)` to await, or the `useData(...)` hook to re-render when files arrive. // -// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale('en')` downloads the overlay -// data/i18n/en.json once (tools/build-i18n.mjs: the official EN texts by record id and field, shared/i18nData.js) and -// from then on `get`, `lookup` and `list` — and every getter built on them — return the records with their English -// texts (a copy per file, built on first use; untranslated fields keep the Chinese text). `setLocale('zh')` goes back. -// Each switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps -// a Chinese game-data name to the current language (server messages name operators / items in Chinese). +// Language (docs/I18N.md): the files hold the official Chinese texts. `setLocale(lang, chain)` downloads (once each) the +// game-text overlays data/i18n/.json of the chain — the language's own, its base's, a fallback's (ui/lang.js +// passes the languages of the pack's chain that have one; default: the language alone) — built by tools/build-i18n.mjs +// (the official texts by record id and field, shared/i18nData.js). From then on `get`, `lookup` and `list` — and every +// getter built on them — return the records with their texts in that language (a copy per file, built on first use): +// each text from the first overlay of the chain that has it, else the Chinese one. `setLocale('zh')` goes back. Each +// switch notifies the subscribers of every loaded file, so `useData` components re-render. `localeName(zh)` maps a +// Chinese game-data name to the current language (server messages name operators / items in Chinese). import { useEffect, useReducer } from '../vendor/hooks.module.js'; import { applyFileOverlay } from '../../shared/i18nData.js'; +import { canonicalLang } from '../../shared/i18nPacks.js'; /** Known data files (name → URL basename). Unknown names are allowed too (`/data/.json`). */ export const DATA_FILES = Object.freeze({ @@ -134,6 +137,10 @@ export function createDataStore(opts = {}) { const warned = new Set(); /** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */ let locale = 'zh'; + /** The languages whose overlays apply, best first (a subset of the chain setLocale got: those that downloaded). */ + let chain = []; + /** setLocale calls so far: a slower earlier call never overrides a later one. */ + let switches = 0; /** @type {Map>} */ const overlayLoads = new Map(); /** @type {Map} */ @@ -237,16 +244,22 @@ export function createDataStore(opts = {}) { return entry.promise; } - /** The localized copy of a ready file (null in Chinese or without an overlay for the file). */ + /** + * The localized copy of a ready file (null in Chinese or without an overlay for the file): the overlays of the chain + * applied from the last to the first, each text checked against the Chinese file, so the best overlay wins per text. + */ function localizedEntry(name) { - if (locale === 'zh') return null; + if (locale === 'zh' || !chain.length) return null; const e = entries.get(name); - const fileOverlay = overlays.get(locale)?.files?.[name]; - if (!e || e.status !== 'ready' || !fileOverlay) return null; + if (!e || e.status !== 'ready') return null; + const fileOverlays = chain.map((c) => overlays.get(c)?.files?.[name]).filter(Boolean); + if (!fileOverlays.length) return null; let l = localized.get(name); if (!l || l.lang !== locale || l.base !== e.value) { let value = e.value; - try { value = applyFileOverlay(e.value, fileOverlay).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + for (const fo of fileOverlays.reverse()) { + try { value = applyFileOverlay(e.value, fo, value).value; } catch (err) { console.warn(`[data] ${name}: overlay not applied`, err); } + } l = { lang: locale, base: e.value, value, index: null }; localized.set(name, l); } @@ -265,13 +278,16 @@ export function createDataStore(opts = {}) { return e.index; } - /** Download (once per language) the overlay data/i18n/.json; resolves to it, or null when unavailable. */ - function loadOverlay(lang) { + /** + * Download (once per language) a game-text overlay — `url` (a pack folder's file, from the pack index) or the language + * folder's data/i18n/.json; resolves to it, or null when unavailable. + */ + function loadOverlay(lang, url = `${base}i18n/${lang}.json`) { if (overlayLoads.has(lang)) return overlayLoads.get(lang); const p = (async () => { for (let attempt = 0; ; attempt++) { try { - const res = await doFetch(`${base}i18n/${lang}.json`, { cache: 'no-cache' }); + const res = await doFetch(url, { cache: 'no-cache' }); if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null }); const json = await res.json(); if (!json || typeof json !== 'object' || !json.files) throw Object.assign(new Error('not an overlay'), { badJson: true }); @@ -279,7 +295,7 @@ export function createDataStore(opts = {}) { return json; } catch (err) { if (transientFailure(err) && attempt < retryDelays.length) { await wait(retryDelays[attempt]); continue; } - console.warn(`[data] ${base}i18n/${lang}.json unavailable (${err?.message || err}); game texts stay Chinese`); + console.warn(`[data] ${url} unavailable (${err?.message || err}); its game texts are not applied`); overlayLoads.delete(lang); // a later switch may try again return null; } @@ -333,33 +349,52 @@ export function createDataStore(opts = {}) { listeners.add(fn); return () => listeners.delete(fn); }, - /** Current game-text locale ('zh' or a language whose overlay is applied). */ + /** Current game-text locale ('zh' or the language of the last switch). */ locale: () => locale, + /** The languages whose overlays apply now, best first (empty in Chinese). */ + localeChain: () => [...chain], /** - * Switch the game texts' language: 'zh' at once; another language once its overlay has downloaded (it stays Chinese - * when the overlay is unavailable). Every loaded file's subscribers are notified. Resolves to the locale in effect. + * Switch the game texts' language: 'zh' at once; another language once the overlays of `chain` (default: the + * language alone) have downloaded — those that fail are left out. When every overlay of a non-empty chain fails the + * locale does not change (the texts stay as they are); an empty chain (a pack without game texts) switches with + * the Chinese texts. Every loaded file's subscribers are notified. Resolves to the locale in effect. * @param {string} lang + * @param {(string | { code: string, url?: string })[]} [chainIn] overlay languages, best first (with the URL of + * each overlay when it is not the language folder's data/i18n/.json) * @returns {Promise} */ - async setLocale(lang) { - const want = typeof lang === 'string' && /^[a-z]{2}$/.test(lang) ? lang : 'zh'; - if (want !== 'zh' && !(await loadOverlay(want))) return locale; - if (want === locale) return locale; + async setLocale(lang, chainIn) { + const code = canonicalLang(lang); + const want = code && code === lang ? code : 'zh'; + const items = (Array.isArray(chainIn) ? chainIn : [want]).map((c) => (typeof c === 'string' ? { code: c, url: undefined } : c)); + const seen = new Set(); + const asked = want === 'zh' ? [] : items.filter((c) => c && canonicalLang(c.code) === c.code && c.code !== 'zh' && !seen.has(c.code) && seen.add(c.code)); + const ask = asked.map((c) => c.code); + const seq = ++switches; + const got = await Promise.all(asked.map((c) => loadOverlay(c.code, typeof c.url === 'string' && c.url ? c.url : undefined))); + if (seq !== switches) return locale; // a later switch is under way + const next = ask.filter((_, i) => got[i]); + if (ask.length && !next.length) return locale; + if (want === locale && next.join('|') === chain.join('|')) return locale; locale = want; + chain = next; localized.clear(); for (const name of [...entries.keys()]) notify(name); return locale; }, /** - * A Chinese game-data name (operator, item, bond, token …) in the current locale (itself in Chinese or when the - * overlay does not know it). + * A Chinese game-data name (operator, item, bond, token …) in the current locale: the first overlay of the chain + * that knows it, else the name itself (also in Chinese). * @param {string} zh * @returns {string} */ localeName(zh) { if (locale === 'zh' || typeof zh !== 'string') return zh; - const names = overlays.get(locale)?.names; - return (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) || zh; + for (const c of chain) { + const names = overlays.get(c)?.names; + if (names && Object.prototype.hasOwnProperty.call(names, zh) && names[zh]) return names[zh]; + } + return zh; }, }; } diff --git a/public/js/screens/title.js b/public/js/screens/title.js index 9d3f3917..2ff7873d 100644 --- a/public/js/screens/title.js +++ b/public/js/screens/title.js @@ -1,5 +1,6 @@ -// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language switch -// (中文 | English, ui/lang.js; in English the big title is the English one and the small wordmark above it hides). +// Title screen: season-style backdrop, big title 卫戍协议:盟约, remembered nickname, 开始, the language menu +// (中文 | English | every pack in public/i18n/, ui/lang.js; a title in an alphabetic script — English — is the big one and +// the small wordmark above it hides). // // Pressing 开始 validates the nickname (1..NAME_MAX_LEN chars, no control characters), stores it, // marks this tab as "entered" (so reloads skip the title) and hands the name to net.js, which @@ -20,6 +21,7 @@ import { data, useData } from '../data.js'; import { FullscreenButton, detectFeatures } from '../ui/device.js'; import { LangToggle, useLang } from '../ui/lang.js'; import { t, N_ } from '../../../shared/i18n.js'; +import { scriptOf } from '../../../shared/i18nPacks.js'; import { GIcon } from '../ui/gameComponents.js'; import { SettingsModal } from '../ui/settings.js'; @@ -187,7 +189,7 @@ const STATUS_TEXT = { export function TitleScreen() { const conn = useStore((s) => s.connection, shallowEqual); const pendingJoin = useStore((s) => s.ui.pendingJoin); - const lang = useLang(); + useLang(); // re-render on a language switch const [name, setName] = useState(() => store.get().me.name || identity.loadName() || ''); const [settingsOpen, setSettingsOpen] = useState(false); const assetsSettled = useData('assets'); @@ -215,6 +217,9 @@ export function TitleScreen() { // touch screens: no autofocus (it would pop the on-screen keyboard over a landscape phone's whole view) const touchUi = useMemo(() => detectFeatures().coarse, []); + // a title in an alphabetic script (English, French …) is the big one in the display face and the wordmark above it + // hides; a CJK / kana / Hangul title keeps the Chinese layout (shared/i18nPacks.js scriptOf — a pack needs no flag) + const alphabetic = scriptOf(t('卫戍协议')) === 'alphabetic'; return html`