Files
ark-stronghold-protocol/public/js/ui/richText.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
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).
2026-09-29 16:21:31 +08:00

183 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}