Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
183 lines
6.2 KiB
JavaScript
183 lines
6.2 KiB
JavaScript
// Rich-text description formatting (pure, no DOM / Preact — unit-tested in Node).
|
||
//
|
||
// Official descriptions (data/*.json `descRaw`, DATA.md §0) carry Arknights markup:
|
||
// <@ba.vup>+15%</> styled span (class `ba.vup`: value-up highlight)
|
||
// <$ba.stun>晕眩</> term span (a status keyword with a glossary entry)
|
||
// <@autochess.dgreen>3</> autochess-specific colours
|
||
// Tags nest. Anything else that looks like a tag — `<获得时>`, `<在场6名不同【炎】干员>` — is literal
|
||
// text and is kept verbatim (its closing `>` included). `\n` (real newline or the two-char escape)
|
||
// is a line break.
|
||
//
|
||
// API:
|
||
// parseRichText(src) → Seg[] where Seg = { text, cls: string[], term: boolean } | { br: true }
|
||
// richTextPlain(src) → string (markup stripped, newlines kept)
|
||
// rtClassName(cls) → CSS class for an official style id ('ba.vup' → 'rt-vup')
|
||
// formatBondEffect(bond, layers)→ bond `effectDescRaw` with its `{i:fmt}` placeholders resolved
|
||
// formatPlaceholder(value, fmt) → '15%' / '3' for the official number formats ('0%', '0.0%', '0', '0.0')
|
||
// fillPlaceholders(src, values) → replace `{i:fmt}` / `{i}` by values[i] (unknown indexes kept)
|
||
|
||
/** Official style id → our CSS modifier (css/screens/game-panels.css `.rt-*`). */
|
||
const STYLE_CLASS = {
|
||
'ba.vup': 'rt-vup',
|
||
'ba.vdown': 'rt-vdown',
|
||
'ba.rem': 'rt-rem',
|
||
'ba.acrem': 'rt-note',
|
||
'ba.kw': 'rt-kw',
|
||
'ba.talpu': 'rt-vup',
|
||
'autochess.gray': 'rt-note',
|
||
'autochess.dgreen': 'rt-mint',
|
||
'autochess.green': 'rt-mint',
|
||
'autochess.red': 'rt-vdown',
|
||
'autochess.yellow': 'rt-rem',
|
||
'eb.key': 'rt-kw',
|
||
'eb.danger': 'rt-vdown',
|
||
};
|
||
|
||
/**
|
||
* CSS class for an official style id. Term tags (`$…`) map to `rt-term`, unknown styles to `rt-hl`.
|
||
* @param {string} cls style id without the sigil, e.g. 'ba.vup'
|
||
* @param {boolean} [term]
|
||
* @returns {string}
|
||
*/
|
||
export function rtClassName(cls, term = false) {
|
||
if (term) return 'rt-term';
|
||
return STYLE_CLASS[cls] || 'rt-hl';
|
||
}
|
||
|
||
const TAG_OPEN = /^<([@$])([A-Za-z0-9_.\-]{1,48})>/;
|
||
const TAG_CLOSE = '</>';
|
||
|
||
/**
|
||
* Parse official rich text into flat styled segments.
|
||
* @param {any} src
|
||
* @returns {Array<{ text: string, cls: string[], term: boolean } | { br: true }>}
|
||
*/
|
||
export function parseRichText(src) {
|
||
if (src == null) return [];
|
||
const s = String(src).replace(/\\n/g, '\n').replace(/\r\n?/g, '\n');
|
||
/** @type {Array<{ cls: string, term: boolean }>} */
|
||
const stack = [];
|
||
const out = [];
|
||
let buf = '';
|
||
const flush = () => {
|
||
if (!buf) return;
|
||
const cls = stack.map((t) => t.cls);
|
||
const term = stack.some((t) => t.term);
|
||
const prev = out[out.length - 1];
|
||
// merge with the previous segment when styles are identical
|
||
if (prev && !prev.br && prev.term === term && prev.cls.length === cls.length && prev.cls.every((c, i) => c === cls[i])) {
|
||
prev.text += buf;
|
||
} else {
|
||
out.push({ text: buf, cls, term });
|
||
}
|
||
buf = '';
|
||
};
|
||
let i = 0;
|
||
while (i < s.length) {
|
||
const ch = s[i];
|
||
if (ch === '\n') {
|
||
flush();
|
||
out.push({ br: true });
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if (ch === '<') {
|
||
if (s.startsWith(TAG_CLOSE, i)) {
|
||
if (stack.length) {
|
||
flush();
|
||
stack.pop();
|
||
} else {
|
||
buf += TAG_CLOSE; // stray closer: keep literally
|
||
}
|
||
i += TAG_CLOSE.length;
|
||
continue;
|
||
}
|
||
const m = TAG_OPEN.exec(s.slice(i, i + 52));
|
||
if (m) {
|
||
flush();
|
||
stack.push({ cls: m[2], term: m[1] === '$' });
|
||
i += m[0].length;
|
||
continue;
|
||
}
|
||
}
|
||
buf += ch;
|
||
i += 1;
|
||
}
|
||
flush();
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Markup-free text (newlines kept).
|
||
* @param {any} src
|
||
* @returns {string}
|
||
*/
|
||
export function richTextPlain(src) {
|
||
return parseRichText(src).map((seg) => (seg.br ? '\n' : seg.text)).join('');
|
||
}
|
||
|
||
/**
|
||
* Format a number with an official format string.
|
||
* '0%' → rounded percent, '0.0%' → 1 decimal percent, '0' → integer, '0.0' → 1 decimal. Unknown → trimmed number.
|
||
* @param {number} value
|
||
* @param {string} [fmt]
|
||
* @returns {string}
|
||
*/
|
||
export function formatPlaceholder(value, fmt = '0') {
|
||
const v = Number(value);
|
||
if (!Number.isFinite(v)) return '?';
|
||
const f = typeof fmt === 'string' ? fmt : '0';
|
||
const pct = f.endsWith('%');
|
||
const core = pct ? f.slice(0, -1) : f;
|
||
const dot = core.indexOf('.');
|
||
const decimals = dot >= 0 ? Math.min(4, core.length - dot - 1) : 0;
|
||
const n = pct ? v * 100 : v;
|
||
const fixed = n.toFixed(decimals);
|
||
// avoid "-0"
|
||
const clean = Number(fixed) === 0 ? (0).toFixed(decimals) : fixed;
|
||
return pct ? `${clean}%` : clean;
|
||
}
|
||
|
||
/**
|
||
* Replace `{i:fmt}` / `{i}` placeholders with values (unknown indexes are kept as-is).
|
||
* @param {string} src
|
||
* @param {Array<number|string|null|undefined>} values
|
||
* @param {string[]} [formats] optional per-index format override
|
||
* @returns {string}
|
||
*/
|
||
export function fillPlaceholders(src, values, formats = []) {
|
||
if (src == null) return '';
|
||
return String(src).replace(/\{(\d{1,2})(?::([^{}]{1,12}))?\}/g, (whole, idx, fmt) => {
|
||
const i = Number(idx);
|
||
const v = Array.isArray(values) ? values[i] : undefined;
|
||
if (v == null) return whole;
|
||
if (typeof v === 'string') return v;
|
||
return formatPlaceholder(v, formats[i] || fmt || '0');
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The bond's in-battle effect text with its layer-dependent numbers resolved (DATA.md §3:
|
||
* `{i:fmt}` = `bb[base] + bb[perStack] × layers`).
|
||
* @param {any} bond bonds.json record
|
||
* @param {number} [layers]
|
||
* @returns {string} rich text (markup kept)
|
||
*/
|
||
export function formatBondEffect(bond, layers = 0) {
|
||
if (!bond || typeof bond !== 'object') return '';
|
||
const src = bond.effectDescRaw || bond.effectDesc || '';
|
||
const params = Array.isArray(bond.effectDescParams) ? bond.effectDescParams : [];
|
||
const bb = bond.bb && typeof bond.bb === 'object' ? bond.bb : {};
|
||
const L = Number.isFinite(Number(layers)) ? Math.max(0, Number(layers)) : 0;
|
||
const values = [];
|
||
const formats = [];
|
||
for (const p of params) {
|
||
if (!p || !Number.isInteger(p.index)) continue;
|
||
const base = Number(bb[p.base]) || 0;
|
||
const per = Number(bb[p.perStack]) || 0;
|
||
values[p.index] = base + per * L;
|
||
formats[p.index] = p.format || '0';
|
||
}
|
||
return fillPlaceholders(src, values, formats);
|
||
}
|