feedback5 WI: English game texts from the official EN client data (data/i18n/en.json) and the i18n core
shared/i18n.js: gettext-style t() / tc() with the Chinese text as msgid (untranslated -> Chinese), {name},
{0} and {n|one|other} params, lists, game-data names (dn / tName), setLang / getLang / onLangChange, and the
server message wire format (msg -> wireMessage -> translateWire). Chinese stays the default, English is a
switch (the owner's decision of 2026-10-05).
tools/build-i18n.mjs (build-data.mjs untouched) walks the zh_CN and EN tables side by side and writes
data/i18n/en.json: the official English of every data/*.json text by record id and field. Blackboard texts
are matched by template (the remake's numbers read back and filled into the English template); stage names
are composed; what the EN client lacks falls back to tools/i18n/fallback-remake.json and fallback-pr70.json
(PR #70 by @YuriRestia). Default EN source ArknightsAssets/ArknightsGamedata (has act2autochess);
Kengxxiao/ArknightsGameData_YoStar is archived (2025-11) and has no 盟约 season (--source yostar).
Coverage: 14 922 / 14 931 texts (99.9 %), 99.3 % official; only 9 CN-only 甄选 operator names stay Chinese.
shared/i18nData.js: the overlay format (index-aligned leaves + a 2-char hash of each Chinese source, so a
stale leaf is never applied). public/js/data.js: setLocale('en') downloads the overlay once; get / lookup /
list return localized copies; getRaw keeps the Chinese file; localeName maps names.
This commit is contained in:
1 parent
a98b02477d
commit
e596478bef
8 files changed
+1593
-3
No files matched your search
+95
-3
@@ -17,8 +17,16 @@
|
||||
// - or either of those wrapped as { <name>: … } / { list: … } / { data: … }.
|
||||
// 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).
|
||||
|
||||
import { useEffect, useReducer } from '../vendor/hooks.module.js';
|
||||
import { applyFileOverlay } from '../../shared/i18nData.js';
|
||||
|
||||
/** Known data files (name → URL basename). Unknown names are allowed too (`/data/<name>.json`). */
|
||||
export const DATA_FILES = Object.freeze({
|
||||
@@ -122,6 +130,14 @@ export function createDataStore(opts = {}) {
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
const warned = new Set();
|
||||
/** Current locale ('zh' = the files as they are) and the downloaded overlays by language (shared/i18nData.js). */
|
||||
let locale = 'zh';
|
||||
/** @type {Map<string, Promise<any>>} */
|
||||
const overlayLoads = new Map();
|
||||
/** @type {Map<string, any>} */
|
||||
const overlays = new Map();
|
||||
/** name → { lang, value, index } localized copy of a ready file (dropped on every switch / reload of the file) */
|
||||
const localized = new Map();
|
||||
|
||||
const notify = (name) => {
|
||||
for (const fn of [...listeners]) {
|
||||
@@ -219,20 +235,67 @@ export function createDataStore(opts = {}) {
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
/** The localized copy of a ready file (null in Chinese or without an overlay for the file). */
|
||||
function localizedEntry(name) {
|
||||
if (locale === 'zh') return null;
|
||||
const e = entries.get(name);
|
||||
const fileOverlay = overlays.get(locale)?.files?.[name];
|
||||
if (!e || e.status !== 'ready' || !fileOverlay) 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); }
|
||||
l = { lang: locale, base: e.value, value, index: null };
|
||||
localized.set(name, l);
|
||||
}
|
||||
return l;
|
||||
}
|
||||
|
||||
function index(name) {
|
||||
const e = entries.get(name);
|
||||
if (!e || e.status !== 'ready') return null;
|
||||
const l = localizedEntry(name);
|
||||
if (l) {
|
||||
if (!l.index) l.index = buildIndex(name, l.value);
|
||||
return l.index;
|
||||
}
|
||||
if (!e.index) e.index = buildIndex(name, e.value);
|
||||
return e.index;
|
||||
}
|
||||
|
||||
/** Download (once per language) the overlay data/i18n/<lang>.json; resolves to it, or null when unavailable. */
|
||||
function loadOverlay(lang) {
|
||||
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' });
|
||||
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 });
|
||||
overlays.set(lang, json);
|
||||
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`);
|
||||
overlayLoads.delete(lang); // a later switch may try again
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
overlayLoads.set(lang, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Fetch (once) and return a file's JSON, or null when missing. */
|
||||
load,
|
||||
/** Load several files; resolves when all settled. */
|
||||
loadAll: (...names) => Promise.all(names.flat().map(load)),
|
||||
/** Raw JSON of a loaded file (null when missing / not loaded yet). */
|
||||
get: (name) => entries.get(name)?.value ?? null,
|
||||
/** JSON of a loaded file in the current locale (null when missing / not loaded yet). */
|
||||
get: (name) => localizedEntry(name)?.value ?? entries.get(name)?.value ?? null,
|
||||
/** The file exactly as downloaded (Chinese texts), whatever the locale. */
|
||||
getRaw: (name) => entries.get(name)?.value ?? null,
|
||||
/** 'idle' | 'loading' | 'ready' | 'missing' */
|
||||
status: (name) => entries.get(name)?.status ?? 'idle',
|
||||
/** Record by id from a loaded file (null when unknown / not loaded). */
|
||||
@@ -246,16 +309,45 @@ export function createDataStore(opts = {}) {
|
||||
invalidate(name) {
|
||||
if (!entries.has(name)) return Promise.resolve(null);
|
||||
entries.delete(name);
|
||||
localized.delete(name);
|
||||
warned.delete(name);
|
||||
const p = load(name);
|
||||
notify(name); // status is 'loading' again, never a stuck 'idle'
|
||||
return p;
|
||||
},
|
||||
/** Called with the file name whenever a file finishes loading (or is invalidated). */
|
||||
/** Called with the file name whenever a file finishes loading (or is invalidated, or the locale changes). */
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
/** Current game-text locale ('zh' or a language whose overlay is applied). */
|
||||
locale: () => locale,
|
||||
/**
|
||||
* 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.
|
||||
* @param {string} lang
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
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;
|
||||
locale = want;
|
||||
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).
|
||||
* @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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user