chore: sync ammo readout with upstream dev (#552)

This commit is contained in:
IceCodeNew committed 2026-10-12 15:03:43 +00:00
commit 0295d95d08
222 files changed
+10419 -2026

No files matched your search

+9
View File
@@ -508,6 +508,15 @@
white-space: pre-line;
}
.tooltip.is-shown { opacity: 1; }
.tooltip--aside { max-width: 3.2rem; white-space: normal; }
.tooltip--compound { display: flex; gap: .16rem; max-width: min(8rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; pointer-events: auto; overscroll-behavior: contain; }
.tooltip--compound .tooltip__main { flex: 1 1 2.2rem; min-width: 0; }
.tooltip--compound .tooltip--aside { flex: 1 1 2.2rem; min-width: 0; border-left: 1px solid var(--line-2); padding-left: .12rem; }
@media (max-width: 600px) {
.tooltip--compound { flex-direction: column; }
.tooltip--compound .tooltip__main, .tooltip--compound .tooltip--aside { flex: none; max-width: 100%; width: 100%; }
.tooltip--compound .tooltip--aside { border-left: 0; border-top: 1px solid var(--line-2); padding: .1rem 0 0; }
}
.tooltip--top.is-shown { animation: tip-up var(--t-fast) var(--ease-out); }
.tooltip--bottom.is-shown { animation: tip-down var(--t-fast) var(--ease-out); }
@keyframes tip-up { from { transform: translateY(4px); } }
+9
View File
@@ -81,6 +81,15 @@
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
/* the box beside a bond's tooltip (matchInfo.js BondBannedAside): its banned members with avatar, name and bonds */
.brief-bantip { display: flex; flex-direction: column; gap: .06rem; }
.brief-bantip__h { font-size: calc(.12 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--red); }
.brief-bantip__op { display: flex; align-items: center; gap: .08rem; }
.brief-bantip__op .uthumb { flex: none; }
.brief-bantip__txt { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.brief-bantip__txt b { font-size: calc(.14 * var(--t)); color: var(--text-hi); }
.brief-bantip__txt > span { font-size: calc(.12 * var(--t)); color: var(--text-lo); }
.brief-bantip__txt .is-this { color: var(--mint-400); }
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
+2
View File
@@ -681,6 +681,8 @@
.ff-float--arts { color: #d59cff; }
.ff-float--true { color: #fff; }
.ff-float--heal { color: #6dff9c; }
.ff-float--crit { color: #ff2a2a; }
.ff-float--miss { color: #ff9000; }
@keyframes float-up { to { transform: translate(-50%, -.4rem); opacity: 0; } }
.ff-badge { position: absolute; left: 50%; bottom: .08rem; transform: translateX(-50%); font-family: var(--font-display); font-size: .1rem; letter-spacing: .3em; color: var(--text-dim); opacity: .5; pointer-events: none; }
.gm--combat .ff-badge { display: none; }
+5
View File
@@ -265,9 +265,14 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: .12rem .2rem;
font-size: calc(.13 * var(--t));
color: var(--text-dim);
}
.title-foot__meta { display: flex; align-items: flex-end; gap: .16rem; flex-wrap: wrap; justify-content: flex-end; }
.title-repo { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
.title-repo:hover, .title-repo:focus-visible { color: var(--text-hi); border-bottom-color: currentColor; }
/* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */
.title-dev {
padding: .03rem .1rem;
+1 -1
View File
@@ -95,7 +95,7 @@
/* layers */
--z-screen: 1;
--z-banner: 40;
--z-conn: 60;
--z-conn: 75;
--z-modal: 80;
--z-toast: 90;
--z-tooltip: 95;
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Sim check · 卫戍协议:盟约</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<style>
/* The check (simcheck/ui.js) in the game's tokens (css/theme.css): green-grey panels, mint, gold / red for the
differences. A long scrolling page, fit for a phone. */
html, body { margin: 0; height: auto; min-height: 100%; overflow-y: auto; background: var(--bg-0); }
select { min-width: 0; max-width: 100%; }
#app { --sc-bg: var(--bg-0); --sc-panel: var(--bg-1); --sc-fg: var(--text-hi); --sc-muted: var(--text-lo); --sc-line: var(--line);
--sc-accent: var(--mint-500); --sc-ok: var(--mint-500); --sc-warn: var(--gold-2); --sc-bad: var(--red-premium);
--sc-font: var(--font-cjk); --sc-mono: var(--font-mono); }
</style>
</head>
<body>
<main id="app"><p style="color:var(--text-lo);padding:16px">正在载入战斗模拟…</p></main>
<script type="module" src="./sim-check.js"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
// The cross-engine check page (docs/SIM.md「Cross-engine check」): this browser runs the golden battle corpus with the
// client's own sim loader (public/js/battle/runner.js loadBrowserSim) and compares every tick with the Node reference
// that `node tools/sim-check.mjs` wrote to /dev/simcheck/data/ — any device on the LAN can open it from the server.
import { mountCheck } from './simcheck/ui.js';
import { strongholdAdapter, scenariosOf } from './simcheck/stronghold.js';
import { VERSION, textFingerprint } from './simcheck/core.js';
const app = document.getElementById('app');
globalThis.__simcheck = { status: 'loading', results: [], summary: null, error: null };
const get = async (url) => {
const r = await fetch(url, { cache: 'no-cache' });
if (!r.ok) throw Object.assign(new Error(`${r.status} ${url}`), { missing: r.status === 404 });
return r.json();
};
try {
const [doc, reference, { loadBrowserSim }, { APP_VERSION }] = await Promise.all([
get('/dev/simcheck/data/specs.json'), get('/dev/simcheck/data/reference.json'), import('/js/battle/runner.js'), import('/shared/constants.js'),
]);
if (doc.simcheck !== VERSION || reference.simcheck !== VERSION || !Array.isArray(doc.files) || !doc.files.length
|| doc.identity !== reference.identity || doc.identity !== textFingerprint(JSON.stringify({ specs: doc.specs, files: doc.files }))) {
throw new Error('参考与场景文件不匹配,请重新生成参考数据。');
}
const verifiedData = new Map();
const validate = async () => {
for (let i = 0; i < doc.files.length; i += 8) await Promise.all(doc.files.slice(i, i + 8).map(async ({ url, hash }) => {
if (typeof url !== 'string' || !/^\/(?:sim\/|shared\/|data\/|data\.js$|js\/battle\/runner\.js$|dev\/simcheck\/)/.test(url) || url.includes('..')) throw new Error('invalid reference input path');
const response = await fetch(url, { cache: 'no-cache' });
if (!response.ok) throw new Error(`参考输入不可用:${url}`);
const text = await response.text();
if (textFingerprint(text) !== hash) throw new Error(`参考数据已过期:${url}。请重新生成参考并刷新页面。`);
if (url.startsWith('/data/')) verifiedData.set(url, text);
}));
};
await validate();
const { spec, ds } = await loadBrowserSim({ fetchFn: url => {
const text = verifiedData.get(url);
if (text == null) throw new Error(`unverified data input: ${url}`);
return Promise.resolve(new Response(text, { status: 200, headers: { 'Content-Type': 'application/json' } }));
} });
const scenarios = scenariosOf(doc);
const FAMILIES = [['roster', '干员'], ['bonds', '盟约'], ['fields', '首领战场与联防'], ['standins', '补位'], ['diy', '自选']];
if (reference.made?.version !== APP_VERSION) throw new Error('参考游戏版本已过期,请重新生成。');
mountCheck(app, {
project: 'Stronghold Protocol',
title: '模拟一致性检查',
subtitle: '逐帧比较已记录的单位状态与 Node 参考,并抽样比较快照和部分内部状态。输入文件按内容指纹核对;通过不代表全部画面、事件流或内部内存均已覆盖。状态下载仅定位状态向量字段。',
validate,
adapter: strongholdAdapter({ spec, ds, doc }),
scenarios,
subsets: [
{ id: 'fast', label: '快速(黄金结果的快速子集)', pick: (sc) => sc.fast },
{ id: 'all', label: '全部', pick: () => true },
...FAMILIES.map(([id, label]) => ({ id, label, pick: (sc) => sc.family === id })),
],
reference,
info: [['游戏版本', APP_VERSION]],
});
} catch (e) {
globalThis.__simcheck = { status: 'error', results: [], summary: null, error: String(e.message || e) };
app.innerHTML = '';
const p = document.createElement('p');
p.style.cssText = 'color:var(--text-md);padding:16px;max-width:760px;margin:0 auto;font:14px/1.6 var(--font-cjk)';
p.textContent = e.missing
? '还没有参考数据:请在服务器的目录里运行 node tools/sim-check.mjs(生成 public/dev/simcheck/data/),然后刷新这个页面。'
: `载入失败:${e && e.message || e}`;
app.appendChild(p);
}
+232
View File
@@ -0,0 +1,232 @@
// simcheck — whether a deterministic simulation gives the same bits in every JavaScript engine: the server's Node and
// the players' browsers (Chrome / Edge, Firefox, Safari, on desktops and phones). A run of one scenario is traced:
// state the simulation's observable numbers after every tick (the adapter's state(): positions, HP, timers …),
// hashed to their exact IEEE-754 bits; each block of `every` ticks keeps one hash of its ticks' hashes
// view what a viewer is shown (the adapter's view(): a wire snapshot), hashed at the end of every block and at the
// end of the run
// deep the hidden state (the adapter's deep(): every value reachable from the units), hashed every `deepEvery`
// ticks (a multiple of `every`; the walk is costly) and at the end of the run
// result the outcome's digest (the adapter's result())
// A run elsewhere is compared with a reference run (Node) block by block: the first block that differs, at any level,
// says when the two parted; a dump of that block's state vectors from both sides then names the first field that
// differs (diffDumps).
//
// Integer operations only (Math.imul, shifts) over the numbers' bits: the hashes are the same in every engine. NaN is
// hashed as one value (engines may keep different NaN payloads).
//
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
// the same in both. No dependencies; Node and browsers.
const DV = new DataView(new ArrayBuffer(8));
const rotl = (x, r) => (x << r) | (x >>> (32 - r));
// murmur3's block mix and finaliser
function mix(h, k) {
k = Math.imul(k, 0xcc9e2d51); k = rotl(k, 15); k = Math.imul(k, 0x1b873593);
h ^= k; h = rotl(h, 13);
return (Math.imul(h, 5) + 0xe6546b64) | 0;
}
function fmix(h) {
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
return h >>> 0;
}
// the kind of an object by its tag, not instanceof: a simulation may run in another realm (a Node VM context) than the
// hashing code, with its own Map / Set constructors
const tag = (v) => Object.prototype.toString.call(v);
const isList = (v) => Array.isArray(v) || (ArrayBuffer.isView(v) && tag(v) !== '[object DataView]');
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
/** A 32-bit hash fed with words, numbers (their bits), strings and plain values. */
class Hash {
constructor(seed = 0x9747b28c) { this.h = seed | 0; this.n = 0; }
word(w) { this.h = mix(this.h, w | 0); this.n++; return this; }
num(x) {
if (x !== x) return this.word(0x7ff80000).word(0);
DV.setFloat64(0, x, true);
return this.word(DV.getUint32(0, true)).word(DV.getUint32(4, true));
}
str(s) { this.word(0x5354 ^ s.length); for (let i = 0; i < s.length; i++) this.word(s.charCodeAt(i)); return this; }
// null / undefined / booleans / numbers / strings / arrays / plain objects (keys in their own order, which is the
// order the same code created them in, in any engine)
value(v, depth = 0) {
if (v === null || v === undefined) return this.word(v === null ? 0x6e756c6c : 0x756e6466);
switch (typeof v) {
case 'number': return this.num(v);
case 'string': return this.str(v);
case 'boolean': return this.word(v ? 0x74727565 : 0x66616c73);
case 'bigint': return this.str(v.toString());
case 'object': break;
default: return this.word(0x66756e63);
}
if (depth > 32) return this.word(0x64656570);
if (isList(v)) { this.word(0x41525259 ^ v.length); for (const x of v) this.value(x, depth + 1); return this; }
const keys = Object.keys(v);
this.word(0x4f424a54 ^ keys.length);
for (const k of keys) { this.str(k); this.value(v[k], depth + 1); }
return this;
}
done() { return fmix(this.h ^ this.n); }
}
const hex = (u) => (u >>> 0).toString(16).padStart(8, '0');
/** The hash of a list of numbers (a state vector). */
function hashNumbers(v) { const h = new Hash(); for (let i = 0; i < v.length; i++) h.num(v[i]); return h.done(); }
/** The hash of any plain value. */
function hashValue(v) { return new Hash().value(v).done(); }
/** Portable content identity for local diagnostic inputs (not a security signature). */
function textFingerprint(s) { return hex(new Hash().str(s).done()) + hex(new Hash(0x53a9d61f).str(s).done()); }
function selectScenarios(scenarios, subsets, id, only = null) {
const subset = subsets.find(s => s.id === id);
if (!subset) throw new Error(`unknown subset: ${id}`);
const names = only == null ? null : new Set(String(only).split(',').map(s => s.trim()).filter(Boolean));
if (names) for (const name of names) if (!scenarios.some(s => s.id === name)) throw new Error(`unknown scenario: ${name}`);
const list = scenarios.filter(s => subset.pick(s) && (!names || names.has(s.id)));
if (!list.length) throw new Error('no scenarios selected');
return { subset, list };
}
/**
* The hash of everything reachable from roots: own properties (non-enumerable too, data properties only), arrays,
* typed arrays, Maps, Sets; another root met on the way counts as its index, an object met twice counts once, the keys
* in `skip` are left out (references back to shared definitions, process-wide counters …).
* @param {object[]} roots @param {{ skip?: Iterable<string>, maxDepth?: number }} [o]
*/
function deepHash(roots, { skip = [], maxDepth = 8 } = {}) {
const idx = new Map(roots.map((r, i) => [r, i])), seen = new Set(), skipped = new Set(skip), h = new Hash();
const walk = (v, depth) => {
if (v === null || v === undefined) { h.word(7); return; }
const t = typeof v;
if (t === 'number') { h.num(v); return; }
if (t === 'string') { h.str(v); return; }
if (t === 'boolean') { h.word(v ? 3 : 5); return; }
if (t !== 'object' || depth > maxDepth) return;
if (depth > 0 && idx.has(v)) { h.word(0x55550000 + idx.get(v)); return; }
if (seen.has(v)) { h.word(11); return; }
seen.add(v);
if (isList(v)) { h.word(v.length); for (const x of v) walk(x, depth + 1); return; }
const kind = tag(v);
if (kind === '[object Map]') { h.word(v.size); for (const [k, x] of v) { walk(k, depth + 1); walk(x, depth + 1); } return; }
if (kind === '[object Set]') { h.word(v.size); for (const x of v) walk(x, depth + 1); return; }
for (const k of Object.getOwnPropertyNames(v)) {
if (skipped.has(k)) continue;
const d = Object.getOwnPropertyDescriptor(v, k);
if (!d || !('value' in d) || typeof d.value === 'function') continue;
h.str(k); walk(d.value, depth + 1);
}
};
for (const r of roots) walk(r, 0);
return h.done();
}
/**
* A run of one scenario, traced as it goes; advance() runs it for a time budget (a page stays responsive), finish()
* gives the trace. The adapter:
* start(scenario) → run; step(run) → true when a tick was simulated, false when the run is over;
* state(run) → number[] (after every tick); labels?(run) → string[] (the names of state's entries, for dumps);
* view?(run) → plain value; deep?(run) → uint32; result(run) → string; maxTicks? (a runaway guard)
* dump: [first, last] ticks whose state vectors (and labels) are kept.
*/
function tracer(adapter, scenario, { every = 30, deepEvery = every * 5, dump = null } = {}) {
const max = adapter.maxTicks || 1e6;
if (!Number.isInteger(every) || every < 1 || !Number.isInteger(deepEvery) || deepEvery < every || deepEvery % every) throw new Error('invalid sample interval');
if (dump && (!Array.isArray(dump) || dump.length !== 2 || !dump.every(Number.isInteger) || dump[0] < 1 || dump[1] < dump[0] || dump[1] > max)) throw new Error('invalid dump window');
const run = adapter.start(scenario);
let t = 0, block = new Hash(), over = false;
const blocks = [], views = [], deeps = [], dumps = {};
const sample = (end) => {
if (adapter.view) views.push(hashValue(adapter.view(run)));
if (adapter.deep && (end || t % deepEvery === 0)) deeps.push(adapter.deep(run) >>> 0);
};
return {
get ticks() { return t; },
get done() { return over; },
advance(budgetMs = Infinity) {
const until = budgetMs === Infinity ? Infinity : now() + budgetMs;
while (!over) {
if (!adapter.step(run)) {
over = true;
if (t === 0) sample(true);
if (t % every) { blocks.push(block.done()); sample(true); } else if (adapter.deep && t % deepEvery) deeps.push(adapter.deep(run) >>> 0);
break;
}
if (t >= max) throw new Error(`scenario ${scenario.id}: maxTicks ${max} reached before completion`);
t++;
const s = adapter.state(run);
block.word(hashNumbers(s));
if (dump && t >= dump[0] && t <= dump[1]) dumps[t] = { state: Array.from(s, encodeNumber), labels: adapter.labels ? adapter.labels(run) : null };
if (t % every === 0) { blocks.push(block.done()); block = new Hash(); sample(false); }
if ((t & 31) === 0 && now() > until) break;
}
return over;
},
finish() {
while (!over) this.advance();
return { ticks: t, blocks: blocks.map(hex).join(''), views: views.map(hex).join(''), deeps: deeps.map(hex).join(''), result: String(adapter.result(run)), ...(dump ? { dumps } : {}) };
},
};
}
/** A whole run's trace (see tracer). */
function trace(adapter, scenario, opts) { return tracer(adapter, scenario, opts).finish(); }
const firstDiff = (a = '', b = '') => {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i += 8) if (a.slice(i, i + 8) !== b.slice(i, i + 8)) return i / 8;
return -1;
};
/**
* A trace against its reference. ok, or: `at` — where they first parted (level 'state' / 'view' / 'deep', the block,
* its ticks [from, to]); `result` — the outcomes differ; `ticks` — the runs' lengths differ. The level whose difference
* shows first is the one reported — by the tick it is seen at (a block's or a sample's last tick; a deep sample covers
* deepEvery ticks), the finer level on a tie; a run whose state never differs but whose view does still counts.
*/
function compare(ref, got, every = 30, deepEvery = every * 5) {
if (!ref) return { ok: false, missing: true };
let at = null;
for (const level of ['state', 'view', 'deep']) {
const key = level === 'state' ? 'blocks' : level === 'view' ? 'views' : 'deeps', span = level === 'deep' ? deepEvery : every;
const i = firstDiff(ref[key], got[key]);
if (i < 0) continue;
const to = Math.min((i + 1) * span, Math.max(ref.ticks, got.ticks));
if (!at || to < at.to) at = { level, block: i, from: i * span + 1, to };
}
const result = ref.result !== got.result, ticks = ref.ticks !== got.ticks;
return { ok: !at && !result && !ticks, at, result, ticks: ticks ? [ref.ticks, got.ticks] : null };
}
/** How bad a comparison is, for sorting and summaries: 3 the outcome differs, 2 what viewers see, 1 only inside. */
function severity(c) {
if (c.ok) return 0;
if (c.missing || c.result || c.ticks) return 3;
return c.at && c.at.level !== 'deep' ? 2 : 1;
}
const SPECIAL_NUMBERS = new Map([['NaN', NaN], ['Infinity', Infinity], ['-Infinity', -Infinity], ['-0', -0]]);
const encodeNumber = (x) => Number.isNaN(x) ? 'NaN' : x === Infinity ? 'Infinity' : x === -Infinity ? '-Infinity' : Object.is(x, -0) ? '-0' : x;
const bitsOf = (value) => {
const x = typeof value === 'string' && SPECIAL_NUMBERS.has(value) ? SPECIAL_NUMBERS.get(value) : value;
if (typeof x !== 'number') throw new Error('invalid numeric state entry');
if (Number.isNaN(x)) return 'NaN';
DV.setFloat64(0, x, true);
return hex(DV.getUint32(4, true)) + hex(DV.getUint32(0, true));
};
/**
* Two dumps of the same ticks (tracer `dump`) compared field by field: the differences in tick order, each with the
* tick, the entry's index and label, both values and their bits. Stops after `limit`.
*/
function diffDumps(a, b, limit = 20) {
const out = [];
const ticks = [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])].map(Number).sort((x, y) => x - y);
for (const t of ticks) {
const x = a[t], y = b[t];
if (!x || !y) { out.push({ tick: t, index: -1, label: x ? 'missing on the other side' : 'missing in the reference' }); if (out.length >= limit) break; continue; }
const n = Math.max(x.state.length, y.state.length);
for (let i = 0; i < n && out.length < limit; i++) {
const p = x.state[i], q = y.state[i];
if (Object.is(p, q) || (p !== undefined && q !== undefined && bitsOf(p) === bitsOf(q))) continue;
out.push({ tick: t, index: i, label: (x.labels && x.labels[i]) || (y.labels && y.labels[i]) || `#${i}`, ref: p, got: q, refBits: p === undefined ? '' : bitsOf(p), gotBits: q === undefined ? '' : bitsOf(q) });
}
if (out.length >= limit) break;
}
return out;
}
const VERSION = 2;
export { VERSION, Hash, hex, hashNumbers, hashValue, textFingerprint, selectScenarios, deepHash, tracer, trace, compare, severity, diffDumps };
+104
View File
@@ -0,0 +1,104 @@
// simcheck's Node side: the reference (every scenario traced in Node, core.js), headless runs of the check page in
// several engines, and the field-by-field comparison of a dump a browser downloaded with Node's run of the same ticks.
// Engines: Playwright's chromium / firefox / webkit when the `playwright` package is installed (its browsers fetched
// with `npx playwright install firefox webkit`), else a local Chrome through puppeteer-core ($CHROME_PATH or the usual
// install paths).
//
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
// the same in both.
import { existsSync, readFileSync } from 'node:fs';
import { tracer, diffDumps, VERSION } from './core.js';
/**
* Trace every scenario in Node: the reference a page compares with.
* @param {object} adapter @param {{ id: string }[]} scenarios
* @param {{ every?: number, project: string, made?: object, onProgress?: (i: number, n: number, id: string) => void }} o
*/
export function buildReference(adapter, scenarios, { every = 30, deepEvery = every * 5, project, made = {}, onProgress } = {}) {
const out = {};
scenarios.forEach((sc, i) => {
if (onProgress) onProgress(i, scenarios.length, sc.id);
const { ticks, blocks, views, deeps, result } = tracer(adapter, sc, { every, deepEvery }).finish();
out[sc.id] = { ticks, blocks, views, deeps, result };
});
return { simcheck: VERSION, project, every, deepEvery, made: { engine: `Node ${process.version}`, date: new Date().toISOString().slice(0, 10), ...made }, scenarios: out };
}
const CHROMES = [process.env.CHROME_PATH, 'C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
async function tryImport(name) { try { return await import(name); } catch { return null; } }
/**
* Open the check page (url, with ?auto=1 and the subset) in each engine and wait for its results.
* @param {string} url @param {string[]} engines 'chromium' | 'firefox' | 'webkit' | 'chrome' (a local Chrome)
* @returns {Promise<Record<string, { version?: string, status: string, summary?: object, results?: object[], report?: string, error?: string }>>}
*/
export async function runEngines(url, engines, { timeoutMs = 30 * 60000, log = () => {} } = {}) {
const pw = await tryImport('playwright'), pp = pw ? null : await tryImport('puppeteer-core');
const out = {};
for (const name of engines) {
let browser = null;
try {
if (pw && pw[name === 'chrome' ? 'chromium' : name]) {
const type = pw[name === 'chrome' ? 'chromium' : name], exe = CHROMES.find((p) => p && existsSync(p));
// chromium / chrome: Playwright's own build when it is installed, else the local Chrome
try { browser = await type.launch(name === 'chrome' && exe ? { executablePath: exe } : {}); } catch (e) {
if (type !== pw.chromium || !exe) throw e;
browser = await type.launch({ executablePath: exe });
}
} else if (pp && (name === 'chrome' || name === 'chromium')) {
const exe = CHROMES.find((p) => p && existsSync(p));
if (!exe) throw new Error('no Chrome found: set CHROME_PATH');
browser = await (pp.default || pp).launch({ executablePath: exe, headless: true });
} else throw new Error(pw ? `playwright has no ${name}` : `${name} needs the playwright package (npm i -D playwright && npx playwright install ${name})`);
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e.message || e)));
await page.goto(url, { timeout: 120000 });
const t0 = Date.now();
let last = -1;
for (;;) {
const st = await page.evaluate(() => { const s = globalThis.__simcheck; return s ? { status: s.status, n: s.results.length } : null; });
if (st && ['done', 'stopped', 'error'].includes(st.status)) break;
if (errors.length) throw new Error(errors.join('; '));
if (st && st.n !== last && st.n % 25 === 0) { log(` ${name}: ${st.n}`); last = st.n; }
if (Date.now() - t0 > timeoutMs) throw new Error('timed out');
await new Promise((r) => setTimeout(r, 1000));
}
const res = await page.evaluate(() => { const s = globalThis.__simcheck; return { status: s.status, expected: s.expected, completed: s.completed, error: s.error, summary: s.summary, results: s.results, report: s.report }; });
out[name] = { version: browser.version ? await browser.version() : '', ...res, errors };
} catch (e) {
out[name] = { status: 'error', error: String(e && e.message || e) };
} finally { if (browser) await browser.close().catch(() => {}); }
}
return out;
}
/**
* A dump a browser downloaded (the page's 下载这一段的状态) against Node's run of the same ticks: the differing entries,
* first ones first, as printable lines.
* @param {string} file @param {object} adapter @param {{ id: string }[]} scenarios
*/
export function diffDumpFile(file, adapter, scenarios, { every = 30, deepEvery = every * 5, limit = 20, identity = null } = {}) {
const d = JSON.parse(readFileSync(file, 'utf8'));
if (d.simcheck !== VERSION || !d.dumps || typeof d.dumps !== 'object' || !Array.isArray(d.window)) throw new Error('invalid dump format');
if (identity && d.identity !== identity) throw new Error('dump and reference inputs differ');
const sc = scenarios.find((s) => s.id === d.id);
if (!sc) throw new Error(`no scenario ${d.id} here`);
const ref = tracer(adapter, sc, { every, deepEvery, dump: d.window }).finish();
const diffs = diffDumps(ref.dumps, d.dumps, limit);
const lines = [`${d.id}, ticks ${d.window[0]}–${d.window[1]}: Node ${process.version} vs ${d.engine}`];
if (!diffs.length) lines.push(' the same in every entry of these ticks (the difference is in what the state vector does not hold: view / deep)');
for (const x of diffs) lines.push(x.index < 0 ? ` tick ${x.tick}: ${x.label}` : ` tick ${x.tick} ${x.label}: ${x.ref} (${x.refBits}) vs ${x.got} (${x.gotBits})`);
return lines;
}
/** One line per engine for a console. */
export function formatEngines(res) {
return Object.entries(res).map(([name, r]) => {
if (r.status === 'error') return `${name}: error — ${r.error}`;
const s = r.summary || {};
const bad = (r.results || []).filter((x) => !x.c.ok).map((x) => x.id);
return `${name} ${r.version || ''}: ${s.ok}/${s.n} the same${bad.length ? ` — different: ${bad.slice(0, 12).join(', ')}${bad.length > 12 ? ' …' : ''}` : ''}${r.errors && r.errors.length ? ` (page errors: ${r.errors.length})` : ''}`;
});
}
+62
View File
@@ -0,0 +1,62 @@
// Stronghold Protocol's battles for simcheck (core.js): the golden battle corpus — tools/golden.mjs's families roster,
// bonds, fields, standins and diy (its matches are not single battles) — as BattleSpecs (written by
// tools/sim-check.mjs), each built with /sim/spec.js createBattleFromSpec as the browser runner does
// (public/js/battle/runner.js) and stepped tick by tick; a field battle stops at its golden cap like the corpus.
// state after every tick: the battle time, the RNG state and per unit FIELDS (exact bits)
// view b.snapshot(): what viewers are sent
// deep every value reachable from the units, the definitions and the process-wide sequence numbers left out
// result resultDigest(b.result()).hash: what the server's SP_VERIFY compares
// The page (sim-check.js) and the tool (tools/sim-check.mjs) both use it, with the sim and its data loaded their way.
import { deepHash } from './core.js';
/** The data files the sim reads: public/js/battle/runner.js SIM_DATA_FILES (test/sim/simcheck.test.js keeps them equal). */
export const SIM_DATA_FILES = Object.freeze(['chess', 'enemies', 'tokens', 'stages', 'waves', 'bonds', 'items', 'garrisons', 'bands', 'effects', 'backups']);
/** Per unit in the state vector. */
export const FIELDS = Object.freeze(['alive', 'removed', 'deployed', 'x', 'y', 'hp', 'atkCd', 'sp', 'burn', 'neural', 'necrosis', 'apoptosis', 'erosion',
'dmg', 'taken', 'heal', 'blocking']);
// left out of the deep state: references back to definitions and the battle, and buffs.js's process-wide `seq` (it
// depends on what ran before in the same page or process)
const DEEP_SKIP = Object.freeze(['def', 'profile', 'kit', 'battle', 'b', 'seq']);
const QUIET = Object.freeze({ error() {}, warn() {}, info() {}, debug() {} });
/** The scenario list of a specs document: id, golden family, in the fast subset. */
export const scenariosOf = (doc) => doc.specs.map((s) => ({ id: s.id, family: s.fam, fast: s.fast }));
/**
* @param {{ spec: { createBattleFromSpec: Function, resultDigest: Function }, ds: object, doc: { specs: object[] }, logger?: object }} o
* spec: the /sim/spec.js module; ds: a simdata DataSource over SIM_DATA_FILES; doc: the specs document
*/
export function strongholdAdapter({ spec, ds, doc, logger = QUIET }) {
const byId = new Map(doc.specs.map((s) => [s.id, s]));
return {
maxTicks: 200000,
start(sc) {
const s = byId.get(sc.id);
return { b: spec.createBattleFromSpec(s.spec, ds, { logger }), cap: s.cap ?? null };
},
step(r) {
const b = r.b;
if (b.finished) return false;
if (r.cap != null && b.time >= r.cap - 1e-9) { b.forceEnd('forced'); return false; }
b.step();
return true;
},
state(r) {
const b = r.b, v = [b.time, b.rng.state() >>> 0];
for (const u of b.units) {
const e = u.elem || {}, s = u.stats || {};
v.push(u.alive ? 1 : 0, u.removed ? 1 : 0, u.deployed ? 1 : 0, u.x, u.y, u.hp, u.atkCd, u.skill && u.skill.sp != null ? u.skill.sp : -1,
e.burn ?? 0, e.neural ?? 0, e.necrosis ?? 0, e.apoptosis ?? 0, e.erosion ?? 0, s.dmg ?? 0, s.taken ?? 0, s.heal ?? 0, (u.blocking || []).length);
}
return v;
},
labels(r) {
const out = ['time', 'rng'];
for (const u of r.b.units) { const name = `${u.side === 'enemy' ? 'enemy' : 'ally'} ${u.defId}#${u.id}`; for (const f of FIELDS) out.push(`${name}.${f}`); }
return out;
},
view(r) { return r.b.snapshot(); },
deep(r) { return deepHash(r.b.units, { skip: DEEP_SKIP }); },
result(r) { return spec.resultDigest(r.b.result()).hash; },
};
}
+216
View File
@@ -0,0 +1,216 @@
// simcheck's page: runs a corpus of scenarios in this browser, compares every run with the reference made in Node
// (core.js compare) and says which ones parted, when, and how badly — the outcome, what viewers see, or only the
// hidden state. For a run that parted, the state vectors of the block where it parted can be downloaded and compared
// field by field with Node's (the project's tool, `--diff`). The summary can be copied as a report.
// Plain DOM, no framework; colours from CSS variables (--sc-*), so a page can wear its project's theme. Headless
// drivers read window.__simcheck ({ status, results, summary, report }) and may pass ?subset=<id>&auto=1 (&only=a,b).
//
// Written for Stronghold Protocol (GPL-3.0-or-later) and Duel Channel (AGPL-3.0-or-later) by the same author, and kept
// the same in both.
import { tracer, compare, severity, selectScenarios, VERSION } from './core.js';
const TEXT = {
run: '开始检查', stop: '停止', subset: '范围', scenarios: (n) => `${n} 场`,
idle: '准备好了。检查只在本机进行,不会上传任何数据。',
running: (i, n, id) => `正在检查 ${i} / ${n}:${id}`, stopped: '已停止', done: (n, t) => `完成:${n} 场,用时 ${t}`,
ok: '一致', bad: '不一致', result: '结果不同', view: '画面不同', state: '状态不同', deep: '仅内部状态不同', ticks: '长度不同', missing: '参考里没有',
where: (c, hz) => `首次检测到差异的区间:${(c.at.from / hz).toFixed(1)}–${(c.at.to / hz).toFixed(1)} 秒(第 ${c.at.from}–${c.at.to} 帧),${({ state: '状态', view: '快照', deep: '内部状态' })[c.at.level]}不同`,
outcome: '结果不同', length: (a, b) => `长度 ${a} → ${b} 帧`,
dump: '下载这一段的状态', dumping: '正在记录…',
copy: '复制报告', copied: '已复制报告', copyFail: '无法写入剪贴板,请手动复制下面的内容',
allOk: '全部选中场景的已记录状态、抽样快照与结果一致。',
engine: '这个浏览器', reference: '参考', none: '没有不一致的场次。', error: '运行出错',
};
const CSS = `
.sc { --_bg: var(--sc-bg, #101413); --_panel: var(--sc-panel, #171d1b); --_fg: var(--sc-fg, #e6ece9); --_muted: var(--sc-muted, #93a19b);
--_line: var(--sc-line, #2a3532); --_accent: var(--sc-accent, #4ed8af); --_ok: var(--sc-ok, #4ed8af); --_warn: var(--sc-warn, #f2c14e);
--_bad: var(--sc-bad, #ff6b5e); --_font: var(--sc-font, system-ui, sans-serif); --_mono: var(--sc-mono, ui-monospace, monospace);
box-sizing: border-box; max-width: 760px; margin: 0 auto; padding: 16px; color: var(--_fg); font: 14px/1.55 var(--_font); }
.sc *, .sc *::before, .sc *::after { box-sizing: inherit; }
.sc h1 { margin: 0 0 4px; font-size: 18px; }
.sc-sub { margin: 0 0 12px; color: var(--_muted); }
.sc-info { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 0 0 14px; padding: 10px 12px; background: var(--_panel);
border: 1px solid var(--_line); font-size: 13px; }
.sc-info dt { color: var(--_muted); } .sc-info dd { margin: 0; overflow-wrap: anywhere; }
.sc-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 0 0 12px; }
.sc button, .sc select { font: inherit; color: var(--_fg); background: var(--_panel); border: 1px solid var(--_line); min-height: 40px; padding: 6px 12px; cursor: pointer; }
.sc button.sc-primary { border-color: var(--_accent); color: var(--_accent); }
.sc button:disabled { opacity: .5; cursor: default; }
.sc-bar { height: 6px; background: var(--_panel); border: 1px solid var(--_line); margin: 0 0 6px; }
.sc-bar i { display: block; height: 100%; width: 0; background: var(--_accent); transition: width .2s; }
.sc-status { margin: 0 0 12px; color: var(--_muted); min-height: 1.55em; overflow-wrap: anywhere; }
.sc-sum { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 0 0 12px; }
.sc-sum b { font: 600 18px/1 var(--_mono); margin-left: 4px; }
.sc-sum .is-ok b { color: var(--_ok); } .sc-sum .is-warn b { color: var(--_warn); } .sc-sum .is-bad b { color: var(--_bad); }
.sc-list { list-style: none; margin: 0 0 12px; padding: 0; display: grid; gap: 6px; }
.sc-list li { padding: 8px 10px; background: var(--_panel); border-left: 3px solid var(--_warn); overflow-wrap: anywhere; }
.sc-list li.is-bad { border-left-color: var(--_bad); }
.sc-list code { font: 13px var(--_mono); }
.sc-list button { min-height: 32px; padding: 3px 10px; margin-top: 6px; font-size: 13px; }
.sc-note { color: var(--_muted); font-size: 13px; }
.sc-allok { color: var(--_ok); }
.sc textarea { width: 100%; min-height: 160px; margin-top: 8px; background: var(--_panel); color: var(--_fg); border: 1px solid var(--_line);
font: 12px/1.5 var(--_mono); padding: 8px; }
`;
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const clock = (ms) => { const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
// a task boundary that background tabs do not throttle like timers
const yieldTask = () => new Promise((r) => { const ch = new MessageChannel(); ch.port1.onmessage = () => r(); ch.port2.postMessage(0); });
/** This browser, briefly: the engine family and version. */
function engineName() {
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || '';
const m = (re) => (ua.match(re) || [])[1];
if (m(/Edg\/(\d+)/)) return `Edge ${m(/Edg\/(\d+)/)} (Blink)`;
if (m(/Firefox\/(\d+)/)) return `Firefox ${m(/Firefox\/(\d+)/)} (Gecko)`;
if (/AppleWebKit/.test(ua) && !/Chrome|Chromium|CriOS/.test(ua)) return `Safari ${m(/Version\/([\d.]+)/) || ''} (WebKit)`.replace(' ', ' ');
if (m(/CriOS\/(\d+)/)) return `Chrome iOS ${m(/CriOS\/(\d+)/)} (WebKit)`;
if (m(/Chrom(?:e|ium)\/(\d+)/)) return `Chrome ${m(/Chrom(?:e|ium)\/(\d+)/)} (Blink)`;
return ua || 'unknown';
}
async function copyText(text) {
try { if (navigator.clipboard && globalThis.isSecureContext) { await navigator.clipboard.writeText(text); return true; } } catch { /* the textarea */ }
const ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0;font-size:16px';
document.body.appendChild(ta);
try { ta.focus(); ta.select(); ta.setSelectionRange(0, text.length); return document.activeElement === ta && !!document.execCommand('copy'); } catch { return false; } finally { ta.remove(); }
}
function download(name, text) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
}
/**
* Mount the check into root.
* @param {HTMLElement} root
* @param {{ project: string, title: string, subtitle?: string, adapter: object, scenarios: { id: string, family?: string }[],
* subsets: { id: string, label: string, pick: (sc: object) => boolean }[], reference: { every: number, made?: object,
* scenarios: Record<string, object> }, hz?: number, info?: [string, string][], text?: object }} o
*/
function mountCheck(root, o) {
const T = { ...TEXT, ...(o.text || {}) }, every = o.reference.every || 30, deepEvery = o.reference.deepEvery || every * 5, hz = o.hz || 30;
const q = new URLSearchParams(location.search);
if (!document.getElementById('sc-style')) { const st = document.createElement('style'); st.id = 'sc-style'; st.textContent = CSS; document.head.appendChild(st); }
const engine = engineName(), made = o.reference.made || {};
const info = [[T.engine, engine], [T.reference, [made.engine, made.commit, made.date].filter(Boolean).join(' · ') || '—'], ...(o.info || [])];
root.classList.add('sc');
root.innerHTML = `<h1>${esc(o.title)}</h1>${o.subtitle ? `<p class="sc-sub">${esc(o.subtitle)}</p>` : ''}
<dl class="sc-info">${info.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl>
<div class="sc-controls"><label>${esc(T.subset)} <select class="sc-subset">${o.subsets.map((s) =>
`<option value="${esc(s.id)}">${esc(s.label)} · ${esc(T.scenarios(o.scenarios.filter(s.pick).length))}</option>`).join('')}</select></label>
<button class="sc-primary sc-run">${esc(T.run)}</button><button class="sc-stop" hidden>${esc(T.stop)}</button></div>
<div class="sc-bar"><i></i></div><p class="sc-status" role="status">${esc(T.idle)}</p>
<div class="sc-sum"></div><ol class="sc-list"></ol>
<div class="sc-report" hidden><button class="sc-copy">${esc(T.copy)}</button> <span class="sc-note"></span><textarea readonly hidden></textarea></div>`;
const $ = (s) => root.querySelector(s);
const sel = $('.sc-subset'), runBtn = $('.sc-run'), stopBtn = $('.sc-stop'), bar = $('.sc-bar i'), status = $('.sc-status');
if (q.get('subset') && o.subsets.some((s) => s.id === q.get('subset'))) sel.value = q.get('subset');
sel.onchange = () => q.set('subset', sel.value);
const state = { status: 'idle', results: [], summary: null, report: '', expected: 0, completed: 0 };
globalThis.__simcheck = state;
let stop = false;
const summarize = (list) => {
const s = { n: list.length, ok: 0, result: 0, view: 0, state: 0, deep: 0 };
for (const r of list) {
if (r.c.ok) { s.ok++; continue; }
if (r.c.missing || r.c.result || r.c.ticks) s.result++;
else s[r.c.at.level]++;
}
return s;
};
const describe = (r) => {
const c = r.c, parts = [];
if (c.missing) return T.missing;
if (c.error) return `${T.error}:${c.error}`;
if (c.at) parts.push(T.where(c, hz));
if (c.result) parts.push(T.outcome);
if (c.ticks) parts.push(T.length(c.ticks[0], c.ticks[1]));
return parts.join(';');
};
const render = (list, total, elapsed, current) => {
const s = summarize(list);
bar.style.width = `${total ? (100 * list.length) / total : 0}%`;
$('.sc-sum').innerHTML = [[T.ok, s.ok, 'is-ok'], [T.result, s.result, 'is-bad'], [T.view, s.view, 'is-warn'], [T.state, s.state, 'is-warn'], [T.deep, s.deep, 'is-warn']]
.map(([k, v, cls]) => `<span class="${v && cls !== 'is-ok' ? cls : cls === 'is-ok' ? cls : ''}">${esc(k)}<b>${v}</b></span>`).join('');
const bad = list.filter((r) => !r.c.ok).sort((a, b) => severity(b.c) - severity(a.c));
$('.sc-list').innerHTML = bad.map((r, i) => `<li class="${severity(r.c) === 3 ? 'is-bad' : ''}"><code>${esc(r.id)}</code>${r.family ? ` <span class="sc-note">${esc(r.family)}</span>` : ''}<br>${esc(describe(r))}
${r.c.at ? `<br><button data-i="${i}">${esc(T.dump)}</button>` : ''}</li>`).join('');
$('.sc-list').querySelectorAll('button[data-i]').forEach((b) => { b.onclick = () => dumpOne(bad[Number(b.dataset.i)], b); });
if (current) status.textContent = T.running(list.length + 1, total, current);
state.summary = s;
return s;
};
const reportText = (list, subset, elapsed) => {
const s = summarize(list), lines = [
`simcheck ${VERSION} · ${o.project}`, `${T.engine}:${engine}`, `UA:${(navigator && navigator.userAgent) || ''}`,
`${T.reference}:${info[1][1]}`, `${T.subset}:${subset.label} · ${T.scenarios(list.length)} · ${clock(elapsed)}`,
`${T.ok} ${s.ok} · ${T.result} ${s.result} · ${T.view} ${s.view} · ${T.state} ${s.state} · ${T.deep} ${s.deep}`];
for (const r of list.filter((x) => !x.c.ok).sort((a, b) => severity(b.c) - severity(a.c))) lines.push(`- ${r.id}:${describe(r)}`);
return lines.join('\n');
};
const dumpOne = async (r, btn) => {
const old = btn.textContent;
btn.disabled = true; btn.textContent = T.dumping;
try {
await o.validate?.();
await yieldTask();
const tr = tracer(o.adapter, o.scenarios.find((x) => x.id === r.id), { every, deepEvery, dump: [r.c.at.from, r.c.at.to] });
while (!tr.advance(25)) await yieldTask();
const got = tr.finish();
download(`simcheck-${String(o.project).replace(/[^\w\u4e00-\u9fff]+/g, '-').toLowerCase()}-${r.id}-${r.c.at.from}.json`, JSON.stringify({ simcheck: VERSION, project: o.project, identity: o.reference.identity, id: r.id, window: [r.c.at.from, r.c.at.to],
engine, ua: navigator.userAgent, dumps: got.dumps }));
} catch (e) { status.textContent = `${T.error}:${String(e.message || e)}`; }
finally { btn.disabled = false; btn.textContent = old; }
};
const run = async () => {
stop = false; runBtn.disabled = true; sel.disabled = true; stopBtn.hidden = false; $('.sc-report').hidden = true;
state.status = 'running'; state.results = [];
state.expected = 0; state.completed = 0; state.error = null;
const t0 = performance.now();
try {
await o.validate?.();
if (q.has('subset') && !o.subsets.some(s => s.id === q.get('subset'))) throw new Error(`unknown subset: ${q.get('subset')}`);
const { subset, list } = selectScenarios(o.scenarios, o.subsets, sel.value, q.get('only'));
state.expected = list.length;
for (const sc of list) {
if (stop) break;
render(state.results, list.length, performance.now() - t0, sc.id);
await yieldTask();
const t1 = performance.now(), tr = tracer(o.adapter, sc, { every, deepEvery });
while (!tr.advance(25)) { await yieldTask(); if (stop) break; }
if (stop) break;
const got = tr.finish();
state.results.push({ id: sc.id, family: sc.family, c: compare(o.reference.scenarios[sc.id], got, every, deepEvery), ms: performance.now() - t1 });
state.completed = state.results.length;
}
const elapsed = performance.now() - t0, s = render(state.results, list.length, elapsed, null);
const complete = s.n > 0 && state.completed === state.expected;
status.innerHTML = stop ? esc(T.stopped) : complete && s.ok === s.n ? `<span class="sc-allok">${esc(T.allOk)}</span> ${esc(T.done(s.n, clock(elapsed)))}` : esc(T.done(s.n, clock(elapsed)));
if (complete && !state.results.some(r => !r.c.ok)) $('.sc-list').innerHTML = `<li class="sc-note">${esc(T.none)}</li>`;
state.report = reportText(state.results, subset, elapsed);
state.status = stop ? 'stopped' : 'done';
} catch (e) {
state.status = 'error'; state.error = String(e.message || e); state.report = `${T.error}:${state.error}`;
status.textContent = state.report;
} finally { $('.sc-report').hidden = false; runBtn.disabled = false; sel.disabled = false; stopBtn.hidden = true; }
};
runBtn.onclick = run;
stopBtn.onclick = () => { stop = true; };
$('.sc-copy').onclick = async () => {
const ok = await copyText(state.report), ta = $('.sc-report textarea');
$('.sc-report .sc-note').textContent = ok ? T.copied : T.copyFail;
ta.hidden = ok; if (!ok) { ta.value = state.report; ta.focus(); ta.select(); }
};
if (q.get('auto') === '1') run();
return state;
}
export { mountCheck, engineName };
+4
View File
@@ -141,6 +141,7 @@
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "Callsign (max {NAME_MAX_LEN} chars)",
"开始": "Start",
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "Unofficial fan remake · Game assets © Hypergryph / Yostar",
"项目仓库": "Repository",
"高": "High",
"中": "Medium",
"低": "Low",
@@ -155,6 +156,8 @@
"音效": "Sound Effects",
"静音": "Mute",
"显示伤害数字": "Damage Numbers",
"官方伤害数字": "Official Damage Numbers",
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "Show only red numbers that meet the official crit rule. While off, every hit still shows a number.",
"画面质量": "Graphics Quality",
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "Touch controls: tap a unit to select it (Retreat / Sell) · long-press a unit or card for details · after dragging to deploy, swipe to choose a direction",
"快捷键": "Hotkeys",
@@ -707,6 +710,7 @@
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
"解冻": "Unfreeze",
"刷新商店 · {key}": "Refresh Shop · {key}",
"正在刷新…": "Refreshing…",
"免费 ×{free}": "Free ×{free}",
"查看 {name} 的战场": "View {name}'s Battlefield",
"与你在同一战场": "On your field",
+4
View File
@@ -147,6 +147,7 @@
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "{NAME_MAX_LEN}文字以内で入力",
"开始": "開始",
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非公式ファンリメイク · ゲーム素材の権利は Hypergryph / Yostar に帰属します",
"项目仓库": "リポジトリ",
"高": "高",
"中": "中",
"低": "低",
@@ -161,6 +162,8 @@
"音效": "効果音",
"静音": "ミュート",
"显示伤害数字": "ダメージ数値を表示",
"官方伤害数字": "公式ダメージ数値",
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "公式のクリティカル条件を満たす赤い数値だけを表示します。オフのときは、これまで通り命中ごとに表示します。",
"画面质量": "画質",
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "タッチ操作:ユニットをタップで選択(撤退 / 売却)· ユニットやカードを長押しで詳細表示 · ドラッグで配置後、スワイプで向きを選択",
"快捷键": "ショートカットキー",
@@ -708,6 +711,7 @@
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
"解冻": "解除",
"刷新商店 · {key}": "管理センターを更新 · {key}",
"正在刷新…": "更新中…",
"免费 ×{free}": "無料 ×{free}",
"查看 {name} 的战场": "{name}の戦場を見る",
"与你在同一战场": "あなたと同じ戦場",
+55 -51
View File
@@ -108,7 +108,7 @@
"由创建者选择": "방장이 선택",
"由创建者设置": "방장이 설정",
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 특수 선택 순서에서 AI 팀원보다 먼저 선택합니다.",
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
"离开同盟": "동맹 나가기",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 다른 참가자에게 넘어가거나 동맹이 해산됩니다. 나가시겠습니까?",
@@ -143,6 +143,7 @@
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "코드네임 입력 (최대 {NAME_MAX_LEN}자)",
"开始": "시작",
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "비공식 팬 리메이크 · 게임 리소스 © Hypergryph / Yostar",
"项目仓库": "저장소",
"高": "높음",
"中": "보통",
"低": "낮음",
@@ -157,6 +158,8 @@
"音效": "효과음",
"静音": "음소거",
"显示伤害数字": "대미지 수치 표시",
"官方伤害数字": "공식 대미지 수치",
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "공식 치명타 조건을 만족하는 빨간 숫자만 표시합니다. 끄면 지금까지처럼 모든 타격에 숫자가 표시됩니다.",
"画面质量": "그래픽 품질",
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "터치 조작: 유닛을 탭하여 선택(퇴각 / 판매) · 유닛이나 카드를 길게 눌러 상세 정보 확인 · 드래그로 배치한 뒤 스와이프로 방향 선택",
"快捷键": "단축키",
@@ -241,7 +244,6 @@
"继续作战": "전투 재개",
"正在载入模拟数据…": "시뮬레이션 데이터를 불러오는 중…",
"正在进入模拟…": "시뮬레이션에 진입하는 중…",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다).",
"已配发的装备无法销毁": "장착한 장비는 파기할 수 없습니다.",
"销毁道具": "아이템 파기",
"道具": "아이템",
@@ -387,9 +389,9 @@
"倒计时结束后仍未选定将自动分配": "제한 시간이 끝날 때까지 선택하지 않으면 자동으로 배정됩니다.",
"教鞭选择": "교편 선택",
"教鞭 · 战术特训": "교편 · 전술 특훈",
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요.",
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요.",
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다.",
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
"选择一项(无时间限制)": "하나를 선택하세요(시간 제한 없음).",
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
@@ -688,7 +690,7 @@
" · 未装备模组": " · 모듈 미장착",
"已购买": "구매 완료",
"已招募": "모집 완료",
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다.",
"再次点击确认升级({price} 资金)": "한 번 더 눌러 승급 확인 (자금 {price})",
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
"已满级": "최대 레벨",
@@ -704,6 +706,7 @@
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
"解冻": "동결 해제",
"刷新商店 · {key}": "상점 갱신 · {key}",
"正在刷新…": "갱신 중…",
"免费 ×{free}": "무료 ×{free}",
"查看 {name} 的战场": "{name}의 전장 보기",
"与你在同一战场": "같은 전장",
@@ -816,14 +819,14 @@
"已恢复默认快捷键": "기본 단축키를 복원했습니다.",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 키보드가 필요합니다. 터치 기기에서도 키보드를 연결하면 사용할 수 있습니다.",
"查看 / 更改快捷键": "단축키 보기 / 변경",
"问题反馈": "문제 신고",
"问题反馈": "문제 제보",
"复制诊断信息": "진단 정보 복사",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류 발생 당시의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 치환됩니다.",
"已记录的错误": "기록된 오류",
"附上本场战斗": "이 전투 첨부",
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요",
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요",
"附上本场战斗": "이번 전투 첨부",
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 해당 전투를 재현할 수 있습니다.",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요.",
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 복사할 수 없습니다. 아래 내용을 직접 복사해 주세요.",
"诊断信息": "진단 정보",
"观战中无法进行该操作": "관전 중에는 할 수 없습니다.",
"标准": "표준",
@@ -832,8 +835,8 @@
"终极": "초월",
"rounds::回合": "라운드",
"{who}:获得{name}": "{who}: {name} 획득",
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다",
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다",
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다.",
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다.",
"获得{name}": "{name} 획득",
"联防阶段:{names} 迎战突破防线的敌人": "연합 방어 단계: {names} 님이 방어선을 돌파한 적을 맞아 싸웁니다.",
"地形变化:{names}无法停留在原位置,已撤回整备区": "지형 변화: {names} 오퍼레이터가 원래 위치에 머무를 수 없어 정비 구역으로 돌아갔습니다.",
@@ -857,7 +860,7 @@
"(锁定)": "(고정)",
"(精锐时生效)": "(정예화 시 적용)",
"更换": "변경",
"清空": "비우기",
"清空": "초기화",
"已在{tier}阶自选中": "이미 {tier}티어 선발에 포함됨",
"锁定": "고정",
"6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ 프리셋 오퍼레이터와 보유한 6★ 오퍼레이터, 그리고 4★ 프리셋 오퍼레이터(뱅가드, 스페셜리스트 제외)",
@@ -1103,59 +1106,59 @@
"总局数": "총 판수",
"胜场": "승리",
"胜率": "승률",
"到达隐秘核心": "숨겨진 핵심 도달",
"通关隐秘核心": "숨겨진 핵심 클리어",
"最远回合": "최고 라운드",
"到达隐秘核心": "히든 코어 개방",
"通关隐秘核心": "히든 코어 격파",
"最远回合": "최고 도달 라운드",
"累计时长": "누적 플레이 시간",
"{n} 分": "{n}분",
"难度": "난이도",
"局数": "판수",
"隐秘核心通关": "숨겨진 핵심 클리어",
"隐秘核心通关": "히든 코어 격파",
"使用局数": "사용 판수",
"通过局数": "클리어 판수",
"通过率": "클리어율",
"称号": "평가",
"战斗累计": "전투 누적",
"称号": "칭호",
"战斗累计": "전투 통계",
"时间": "시간",
"模式": "모드",
"结果": "결과",
"点击查看该局结算": "이 판의 정산 보기",
"点击查看该局结算": "해당 판의 정산 보기",
"单人": "독립",
"同盟": "동맹",
"胜": "승",
"负": "패",
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다.",
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}.",
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복).",
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다.",
"已清空本机统计数据": "해당 기기의 통계 데이터를 삭제했습니다.",
"本机数据 · 仅记录本浏览器中你参与的对局": "해당 기기의 데이터 · 브라우저에서 참여한 판만 기록됩니다.",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
"未知": "알 수 없음",
"对局记录": "판 기록",
"对局记录": "전적",
"{n} 小时": "{n}시간",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.",
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다",
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다",
"中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다",
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 1라운드 이상 클리어한 판. 그 외의 기록은 '전적'에만 남습니다.",
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 있습니다.",
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다.",
"中途退出的对局没有结算可看": "중도 포기한 판은 정산을 볼 수 없습니다.",
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다.",
"中途退出": "중도 퇴장",
"不计入统计": "집계 제외",
"显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.",
"返回统计": "통계로 돌아가기",
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다",
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다",
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다.",
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다.",
"({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요",
"清空统计数据": "통계 데이터 비우기",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요.",
"清空统计数据": "통계 데이터 초기화",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.",
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)",
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다.)",
"从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)",
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
"清空本机的统计数据": "이 기기의 통계 데이터 삭제",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다.",
@@ -1174,13 +1177,13 @@
"练度": "육성도",
"潜能与练度": "잠재와 육성도",
"满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다",
"原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다",
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다.",
"原型干员没有潜能与练度": "프리셋 오퍼레이터는 잠재와 육성도가 없습니다.",
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다.",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다.",
"含潜能与练度;": "잠재와 육성도 포함. ",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?",
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다",
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다.",
"精英1": "정예 1",
"精英2": "정예 2",
"精英2 Lv.60": "정예 2 Lv.60",
@@ -1195,7 +1198,7 @@
"跟随全局": "전체 설정 따르기",
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "이 브라우저에만 저장됩니다. 일본어 음성이 없으면 중국어로 재생합니다.",
"添加到桌面": "홈 화면에 추가",
"恢复本机对局": "이 기기의 게임 복귀",
"恢复本机对局": "게임 복귀",
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "다른 창에서 진행 중이거나 이 브라우저에서 안전하게 복귀할 수 없습니다. 원래 창을 닫고 다시 시도하세요.",
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "닫힌 창의 게임을 선택하세요. 서버 재시작 또는 게임 만료 후에는 복귀할 수 없습니다.",
"{name} · 同盟 {code}": "{name} · 동맹 {code}",
@@ -1210,9 +1213,10 @@
"同意重刷": "다시 추첨에 동의",
"拒绝重刷": "다시 추첨 거부",
"取消投票": "투표 취소",
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다",
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다",
"库存不足": "재고 부족"
"只能部署在召唤者相邻四格": "소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다.",
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있어 정비 구역으로 돌아갔습니다.",
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}은(는) 소환자와 상하좌우로 인접한 4칸에만 배치할 수 있습니다. 정비 구역이 가득 차 다음 라운드에 반환됩니다.",
"装备库存不足,未获得该装备": "장비 재고가 부족하여 획득하지 못했습니다.",
"库存不足": "재고 부족",
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "렌더러를 불러오지 못해 간소화 화면으로 전환했습니다(모든 기능을 사용할 수 있으며 새로고침하면 다시 시도합니다)."
}
+4
View File
@@ -145,6 +145,7 @@
"输入你的代号(最多 {NAME_MAX_LEN} 字)": "輸入你的代號(最多 {NAME_MAX_LEN} 字)",
"开始": "開始",
"非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有": "非官方同人復刻 · 遊戲素材版權歸 上海鷹角網絡 / Yostar 所有",
"项目仓库": "專案儲存庫",
"高": "高",
"中": "中",
"低": "低",
@@ -159,6 +160,8 @@
"音效": "音效",
"静音": "靜音",
"显示伤害数字": "顯示傷害數字",
"官方伤害数字": "官方傷害數字",
"只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。": "只顯示達到官方暴擊條件的紅色數字。關閉時仍顯示每一次命中。",
"画面质量": "畫面品質",
"触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向": "觸控操作:點擊單位選中(撤退 / 出售)· 長按單位或卡牌查看詳情 · 拖動部署後滑動選擇朝向",
"快捷键": "快捷鍵",
@@ -706,6 +709,7 @@
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
"解冻": "解凍",
"刷新商店 · {key}": "更新商店 · {key}",
"正在刷新…": "正在更新…",
"免费 ×{free}": "免費 ×{free}",
"查看 {name} 的战场": "查看 {name} 的戰場",
"与你在同一战场": "與你在同一戰場",
+70 -3
View File
@@ -525,6 +525,9 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.bondCueNode = null;
this.bondCueToken = 0;
this.bondCueScope = null;
this.voiceOverrides = {};
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
@@ -666,6 +669,7 @@ export class AudioManager {
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
};
this._applyVolumes();
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
}
/**
@@ -834,6 +838,58 @@ export class AudioManager {
// ---- SFX ------------------------------------------------------------------------------------------------
/**
* True while a cue of `scope` started less than `ms` ago. One preparation update can both add layers (m.layer → layerGain)
* and raise the active-bond count (screens/game.js): the second trigger of that same update keeps the playing cue
* instead of cutting it off and starting it again. A later gain still replaces the cue at once (no cooldown).
*/
bondCueRecent(scope, ms = 400) {
if (this.bondCueScope !== scope || typeof performance === 'undefined') return false;
return performance.now() - (this.bondCueAt || 0) < ms;
}
/** The official ADD_BOND bank has maxSoundAllowed=1 / popOldest=true. No extra cooldown. */
stopBondCue(scope = null) {
if (scope && this.bondCueScope !== scope) return;
this.bondCueToken++;
this.bondCueScope = null;
const cur = this.bondCueNode;
this.bondCueNode = null;
if (!cur) return;
try { cur.src.stop(); } catch { /* already ended */ }
cur.end();
}
_playBondCue(url, volume, scope = 'prep') {
this.stopBondCue();
const token = this.bondCueToken, ctx = this.ctx;
if (!ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
this.bondCueScope = scope;
this.bondCueAt = typeof performance !== 'undefined' ? performance.now() : 0;
this._buffer(url).then((buf) => {
if (!buf || token !== this.bondCueToken || ctx !== this.ctx || this.volumes.muted || this.volumes.sfx <= 0) return;
let src, gain;
const end = () => {
try { src?.disconnect(); gain?.disconnect(); } catch { /* ignore */ }
if (this.bondCueNode?.src === src) this.bondCueNode = null;
};
try {
src = ctx.createBufferSource(); gain = ctx.createGain();
src.buffer = buf;
gain.gain.value = Math.max(0, Math.min(1.5, volume));
src.connect(gain); gain.connect(this.sfxGain);
src.onended = end;
this.bondCueNode = { src, gain, end };
src.start();
} catch { end(); }
}, () => {});
}
/** One cue per real gain, including several layers at once or an inactive bond. */
layerGain(bondId, add, scope = 'prep') {
if (typeof bondId === 'string' && bondId && Number.isFinite(add) && add > 0) this.sfx('bondUp', { volume: 0.7, scope });
}
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
@@ -862,12 +918,15 @@ export class AudioManager {
/**
* UI sound by name (sfx.ui keys). Unknown names are ignored.
* @param {string} name
* @param {{ volume?: number }} [o]
* @param {{ volume?: number, scope?: string }} [o]
*/
sfx(name, o = {}) {
try {
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
if (typeof url === 'string') {
if (name === 'bondUp') this._playBondCue(url, o.volume ?? 0.9, o.scope);
else this._play(url, { volume: o.volume ?? 0.9 });
}
} catch { /* ignore */ }
}
@@ -1013,6 +1072,7 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.stopBondCue();
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
@@ -1056,7 +1116,9 @@ export class AudioManager {
if (!Array.isArray(e)) continue;
const kind = e[0];
if (kind === 'spawn') { this._track(e[1]); continue; }
if (kind === 'atk') {
if (kind === 'layer') {
this.layerGain(e[2], e[3], 'battle');
} else if (kind === 'atk') {
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
const src = this.units.get(e[1]);
@@ -1176,6 +1238,11 @@ export function installAudio(deps) {
const sync = (s) => {
try {
const pub = s.match?.public ?? null;
if (deps.selectRoute(s) !== 'game') audio.stopBondCue();
else if (![PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub?.phase)) {
// A new prep gain can precede its m.public phase update. Cancel only the previous battle's cue.
audio.stopBondCue('battle');
}
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.
+5 -3
View File
@@ -61,9 +61,10 @@
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor?, diy? }] that player's
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId,
// a 自选 operator carries its pick like UnitInfo) | []
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
@@ -883,7 +884,8 @@ export function createBattleRunner(deps) {
.map((u) => {
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
return si ? { ...o, standInFor: si } : o;
const diy = u.def?.diyFor ? u.def.loadout?.diy : null;
return { ...o, ...(si ? { standInFor: si } : {}), ...(diy ? { diy: { ...diy } } : {}) };
});
},
/** Re-show the current battle (the game screen remounted). */
+1 -1
View File
@@ -206,7 +206,7 @@ export function buildDiagnostics(input) {
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
}
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const shown = entries.slice().reverse();
const tot = input.total ?? total;
+5 -1
View File
@@ -44,7 +44,7 @@ import { TitleScreen, sanitizeName } from './screens/title.js';
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
import { RoomScreen } from './screens/room.js';
import { GameScreen } from './screens/game.js';
import { installAudio } from './audio.js';
import { audio, installAudio } from './audio.js';
import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
@@ -229,6 +229,10 @@ function wireNet() {
});
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.layer', (msg) => {
const s = store.get();
if (selectRoute(s) === 'game' && msg.playerId === s.me?.playerId) audio.layerGain(msg.bondId, msg.add);
});
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => {
const res = payload(msg);
+45 -16
View File
@@ -53,7 +53,7 @@
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
// chosen / the move is in flight (null releases; setPrep then settles it)
// view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
// view.setSettings({ damageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
// view.setSettings({ damageNumbers, officialDamageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
// view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf)
//
// Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen
@@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js';
import { ImpostorAtlas } from './impostor.js';
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
import { BoardScene } from './board3d/scene.js';
import { unionAreas } from './board3d/layout.js';
import { layoutPen, penSignature } from './pen.js';
@@ -149,7 +149,7 @@ export { releaseGl } from './app/host.js';
/**
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
@@ -162,7 +162,7 @@ export async function createFieldView(host, options = {}) {
signal?.throwIfAborted();
const assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
const settings = { damageNumbers: true, officialDamageNumbers: false, quality: 'high', ...(opts.settings || {}) };
// the 3D board (three.js + the official art) loads in parallel with everything else
const boardPref = boardPreference(opts.board);
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
@@ -265,6 +265,7 @@ export async function createFieldView(host, options = {}) {
let pendingView = null; // tile band/focus to apply when the camera transition ends
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
let promoBase = [];
@@ -361,6 +362,14 @@ export async function createFieldView(host, options = {}) {
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
/** The stage's official scene, once loaded (the procedural board shows meanwhile and when there is none). */
function useOfficialScene(st) {
const id = st && typeof st.id === 'string' ? st.id : '';
if (!id || !board3d) return;
loadScenePack(assets, id).then((scene) => {
if (scene && !destroyed && board3d && stageRec && stageRec.id === id) board3d.setScenePack(id, scene);
}).catch(() => {});
}
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
@@ -380,7 +389,7 @@ export async function createFieldView(host, options = {}) {
tiles.setExternal(true);
backdrop.visible = false;
b.setArea(boardArea(viewKind(camKind, camOpts)));
if (stageRec) b.setStage(stageRec);
if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); }
b.setFocus(camRect());
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
tiles.project(cam, true);
@@ -630,7 +639,7 @@ export async function createFieldView(host, options = {}) {
stageRec = st;
tiles.setStage(st);
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); }
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); useOfficialScene(st); }
tiles.project(cam, true);
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
// tile heights: lay it out again on this stage
@@ -804,16 +813,17 @@ export async function createFieldView(host, options = {}) {
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
}
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
// The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece.
// Keep its original board-space record for the detail card/range; only the rendered view is transformed to the
// displayed boss half. The partner's units already use field coordinates and are not own prep map characters.
prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
if (!u || !Number.isInteger(u.uid) || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
const p = prepXf.toDisp(u.y, u.x);
const dir = prepXf.dirToDisp(u.dir);
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
return { unit: u, display: { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 } };
}).filter(Boolean) : [];
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
syncReadOnlyPrepUnits([...mapChars, ...mate]);
syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...mate]);
prepPieces = list.filter((e) => e.key && views.has(e.key));
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
@@ -821,8 +831,8 @@ export async function createFieldView(host, options = {}) {
}
/**
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
*/
function syncReadOnlyPrepUnits(units) {
const keep = new Set();
@@ -1016,6 +1026,17 @@ export async function createFieldView(host, options = {}) {
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
}
/** The owner's read-only preparation map character under a canvas point (never a drag target). */
function mapCharAt(x, y) {
if (mode !== 'prep') return null;
const units = [];
for (const e of prepMapChars) {
const u = pickUnitOf(views.get(`m:${e.unit.uid}`));
if (u) { u.unit = e.unit; units.push(u); }
}
return pickOnTile(units, groundTile(x, y))?.unit || null;
}
const drag = createDragController({
hitPiece: (x, y) => pieceAt(x, y),
pickTile: (x, y) => pickBoardTile(x, y),
@@ -1218,6 +1239,12 @@ export async function createFieldView(host, options = {}) {
return;
}
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
const mapChar = mapCharAt(ev.x, ev.y);
if (mapChar) {
emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true,
button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY });
return;
}
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
if (penViews.size && mode === 'prep') {
const pv = penUnitAt(ev.x, ev.y);
@@ -1299,6 +1326,7 @@ export async function createFieldView(host, options = {}) {
clearViews();
if (prepPieces.length) promoBase = prepPieces;
prepPieces = [];
prepMapChars = [];
infos.clear();
gone.clear();
interp.reset();
@@ -1443,7 +1471,7 @@ export async function createFieldView(host, options = {}) {
const m = meleePending.get(v.id);
let src = null;
if (m && now - m.t < 0.35) { src = m.src; meleePending.delete(v.id); fx._slashAt = src.id; }
fx.damage(v, amt, e[3], src);
fx.damage(v, amt, e[3], src, e[4]);
break;
}
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
@@ -1880,6 +1908,7 @@ export async function createFieldView(host, options = {}) {
if (!s || typeof s !== 'object') return;
const q = settings.quality;
if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers;
if (typeof s.officialDamageNumbers === 'boolean') settings.officialDamageNumbers = s.officialDamageNumbers;
if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality;
if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); }
},
@@ -1937,7 +1966,7 @@ export async function createFieldView(host, options = {}) {
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
promotions,
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
+169 -1
View File
@@ -5,6 +5,9 @@
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
// tiles, uv, materials: { theme?, fx? } }
// const scene = await loadScenePack(assets, stageId) // the stage's official scene (tools/local-extract/scenes.py),
// → { key, stage, doc, chunks: [{ material, lightmap, group, bounds, position, uv, uv1, normal, color, index }],
// groups: { name: [x0, y0, x1, y1] }, images: { tex: { name: img }, lm: [img|null] } } null when not extracted
//
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
@@ -158,5 +161,170 @@ export function loadBoardPack(assets) {
return pending;
}
/** Manifest group of a stage's official scene, and of the textures the scenes share. */
export const sceneGroup = (stageId) => `scene/${stageId}`;
export const SCENE_TEX_GROUP = 'scene/tex';
export const SCENE_VERSION = 1;
/** Bytes per vertex of a scene chunk: position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4. */
export const SCENE_VERTEX_BYTES = 36;
/**
* Typed-array views of the chunks of a scene.bin (scenes.py pack_chunk); null when a record does not fit the buffer.
* @param {object} doc scene.json
* @param {ArrayBuffer} buf scene.bin
*/
export function parseSceneChunks(doc, buf) {
if (!isObj(doc) || !Array.isArray(doc.chunks) || !(buf instanceof ArrayBuffer)) return null;
const out = [];
for (const c of doc.chunks) {
const n = c?.verts | 0, m = (c?.tris | 0) * 3, o = c?.offset | 0;
const end = o + n * SCENE_VERTEX_BYTES + m * (c.index32 ? 4 : 2);
if (!(n > 0) || !(m > 0) || o < 0 || o % 4 || end > buf.byteLength || typeof c.material !== 'string') return null;
let p = o;
const position = new Float32Array(buf, p, n * 3); p += n * 12;
const uv = new Float32Array(buf, p, n * 2); p += n * 8;
const uv1 = new Float32Array(buf, p, n * 2); p += n * 8;
const normal = new Int8Array(buf, p, n * 4); p += n * 4;
const color = new Uint8Array(buf, p, n * 4); p += n * 4;
const index = c.index32 ? new Uint32Array(buf, p, m) : new Uint16Array(buf, p, m);
out.push({
material: c.material, lightmap: Number.isInteger(c.lightmap) ? c.lightmap : -1, group: typeof c.group === 'string' ? c.group : '',
bounds: Array.isArray(c.bounds) ? c.bounds : null, position, uv, uv1, normal, color, index,
});
}
return out;
}
/**
* Mesh bucket of a scene chunk. One material and one lightmap page are one draw. Water ignores the page: that shader
* has no lightmap (`scene.js` `sceneWaterMaterial`).
* @param {{ material?: string, lightmap?: number }} chunk
* @param {{ kind?: string }|null} info scene.json materials[name]
*/
export function sceneMaterialKey(chunk, info) {
return `${chunk?.material}|${info?.kind === 'water' ? '' : chunk?.lightmap}`;
}
/**
* One scene chunk from several that share a material bucket (`sceneMaterialKey`). Vertex attributes stay in the
* scene.bin layout (position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4) and indices are rewritten
* with the vertex offset; past 65535 vertices the index is 32-bit (WebGL2). A single chunk is returned as-is, so the
* pack's views of scene.bin stay shared. The caller passes chunks of one bucket that are on screen together: a
* file-level merge would bake a switched-off HIDDEN part into the base mesh.
* @param {object[]|null} chunks
* @returns {object|null}
*/
export function mergeSceneChunks(chunks) {
if (!Array.isArray(chunks) || chunks.length === 0) return null;
if (chunks.length === 1) return chunks[0];
const first = chunks[0];
let nv = 0, ni = 0, index32 = false;
for (const c of chunks) {
const n = c?.position?.length / 3;
if (!Number.isInteger(n) || n <= 0) throw new Error(`scene chunk ${c?.material}: ${c?.position?.length ?? 0} position components`);
if (c.material !== first.material) throw new Error(`scene merge mixes ${first.material} and ${c.material}`);
if (c.uv?.length !== n * 2 || c.uv1?.length !== n * 2 || c.normal?.length !== n * 4 || c.color?.length !== n * 4) {
throw new Error(`scene chunk ${c.material}: attributes do not match ${n} vertices`);
}
if (!c.index || c.index.length === 0) throw new Error(`scene chunk ${c.material}: no indices`);
nv += n;
ni += c.index.length;
if (c.index instanceof Uint32Array) index32 = true;
}
if (nv > 65535) index32 = true;
const position = new Float32Array(nv * 3);
const uv = new Float32Array(nv * 2);
const uv1 = new Float32Array(nv * 2);
const normal = new Int8Array(nv * 4);
const color = new Uint8Array(nv * 4);
const index = index32 ? new Uint32Array(ni) : new Uint16Array(ni);
let vo = 0, io = 0, bounds = null;
for (const c of chunks) {
const n = c.position.length / 3;
position.set(c.position, vo * 3);
uv.set(c.uv, vo * 2);
uv1.set(c.uv1, vo * 2);
normal.set(c.normal, vo * 4);
color.set(c.color, vo * 4);
for (let i = 0; i < c.index.length; i++) {
const v = c.index[i];
if (v < 0 || v >= n) throw new Error(`scene chunk ${c.material}: index ${v} out of ${n}`);
index[io + i] = v + vo;
}
vo += n;
io += c.index.length;
if (Array.isArray(c.bounds) && c.bounds.length >= 6) {
const b = c.bounds;
bounds = bounds
? [Math.min(bounds[0], b[0]), Math.min(bounds[1], b[1]), Math.min(bounds[2], b[2]), Math.max(bounds[3], b[3]), Math.max(bounds[4], b[4]), Math.max(bounds[5], b[5])]
: b.slice();
}
}
return { material: first.material, lightmap: first.lightmap, group: '', bounds, position, uv, uv1, normal, color, index };
}
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
export function sceneGroupRects(chunks) {
const out = {};
for (const c of chunks || []) {
if (!c.group || !c.bounds) continue;
const b = c.bounds, r = out[c.group];
out[c.group] = r ? [Math.min(r[0], b[0]), Math.min(r[1], b[1]), Math.max(r[2], b[3]), Math.max(r[3], b[4])] : [b[0], b[1], b[3], b[4]];
}
return out;
}
async function fetchBuffer(url) {
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.arrayBuffer() : null; } catch { return null; }
}
const sceneCache = new Map();
/** Does the local-art manifest list the official scene of `stageId`? (manifest only) */
export async function sceneListed(assets, stageId) {
if (!stageId || !assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
try { await assets.local(); } catch { return false; }
return !!assets.localUrl(sceneGroup(stageId), 'scene') && !!assets.localUrl(sceneGroup(stageId), 'bin');
}
/**
* Load a stage's official scene (scene.json + scene.bin + lightmap pages + albedo textures). A missing texture or
* lightmap page only drops that map; a missing or malformed scene gives null (→ the procedural board).
* @returns {Promise<object|null>}
*/
export function loadScenePack(assets, stageId) {
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
const pending = (async () => {
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
const group = sceneGroup(stageId);
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
const chunks = parseSceneChunks(doc, buf);
if (!chunks || !chunks.length) return null;
const img = async (u) => {
if (!u) return null;
const i = await assets.image(u, { retry: true }).catch(() => null);
return i && (i.width || i.naturalWidth) > 0 ? i : null;
};
const mats = isObj(doc.materials) ? doc.materials : {};
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
const [texList, lm] = await Promise.all([
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
]);
const tex = {};
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
return pack;
});
sceneCache.set(stageId, pending);
return pending;
}
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
+129 -2
View File
@@ -1,13 +1,14 @@
// render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected
// (the renderer loads /vendor/three.module.js; Node tests pass the npm package).
//
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB,
// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 / DXT5nm →
// RGB, tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
// emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints
// MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
// MT_wind_device → unlit TX_wind_device (the blower mesh)
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
// the materials of an official battlefield scene (load.js loadScenePack) → sceneMaterial / sceneWaterMaterial
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
// geometry when the camera moves.
@@ -343,6 +344,132 @@ export function smogMaterial(THREE, tex, focus) {
});
}
/**
* Tuning of the official scene materials (sceneMaterial). The official shader (Torappu/Scene/StandardRealtimeShadow) is
* not reproduced: the albedo is lit by the baked indirect light of the scene's lightmap plus the scene's key light
* masked by its baked shadow, in linear light (Unity's Shadowmask lighting mode). `range` — the linear light a lightmap
* texel of 1.0 stands for when scene.json has no lightRange; [ASSUMED] `ambient` — the light of a mesh without a
* lightmap page; `direct` / `indirect` — gains of the two terms (1 = the scene's own values).
*/
export const SCENE_LIGHT = Object.freeze({ range: 8.0, ambient: 0.2, direct: 0.8, indirect: 0.4 });
const colorOf = (info, key, fallback = [1, 1, 1, 1]) => {
const c = info && info.colors && Array.isArray(info.colors[key]) ? info.colors[key] : fallback;
return [0, 1, 2, 3].map((i) => (Number.isFinite(c[i]) ? c[i] : fallback[i]));
};
const floatOf = (info, key, fallback) => (info && info.floats && Number.isFinite(info.floats[key]) ? info.floats[key] : fallback);
const hasKeyword = (info, kw) => !!(info && Array.isArray(info.keywords) && info.keywords.includes(kw));
/** Blend class of a scene material record (scene.json materials[name]): 'opaque' | 'cutout' | 'blend'. */
export function sceneBlend(info) {
if (hasKeyword(info, '_ALPHABLEND_ON') || hasKeyword(info, '_ALPHAPREMULTIPLY_ON')) return 'blend';
return hasKeyword(info, '_ALPHATEST_ON') ? 'cutout' : 'opaque';
}
/**
* A lit material of an official scene: albedo (`map`, × _Color) × (the lightmap's baked light + key light · N·L ·
* baked shadow (lightmap A, by _ShadowStrength)), alpha test / blend by the material's keywords, the focus falloff of
* the other board materials. `map` is an sRGB texture; `lightMap` holds (light / lightRange)^(1/2.2) in RGB (no colour
* space). Geometry attributes: position, normal, uv, aLm (lightmap uv), aColor.
* @param {{ map?: any, lightMap?: any, info?: object, lightRange?: number, light?: { dir: number[], color: number[], intensity: number } }} o
*/
export function sceneMaterial(THREE, o, focus) {
const info = o.info || null;
const blend = sceneBlend(info);
const L = o.light || { dir: [0.36, 0.45, 0.82], color: [1, 1, 1], intensity: 1 };
const k = (Number.isFinite(L.intensity) ? L.intensity : 1) * SCENE_LIGHT.direct;
const lin = (v) => Math.pow(Math.max(0, v), 2.2); // the scene's colours are authored in gamma space
const col = colorOf(info, '_Color');
const uniforms = {
uMap: { value: o.map || null }, uLm: { value: o.lightMap || null },
uColor: { value: new THREE.Vector4(lin(col[0]), lin(col[1]), lin(col[2]), col[3]) },
uCutoff: { value: blend === 'cutout' ? floatOf(info, '_Cutoff', 0.5) : 0 },
uLmScale: { value: o.lightMap ? (o.lightRange > 0 ? o.lightRange : SCENE_LIGHT.range) * SCENE_LIGHT.indirect : 0 },
uAmbient: { value: o.lightMap ? 0 : SCENE_LIGHT.ambient },
uShadow: { value: floatOf(info, '_ShadowStrength', 1) },
uLightDir: { value: new THREE.Vector3(...L.dir).normalize() },
uLightColor: { value: new THREE.Vector3(lin(L.color[0]) * k, lin(L.color[1]) * k, lin(L.color[2]) * k) },
...focus,
};
const m = new THREE.ShaderMaterial({
uniforms,
defines: { ...(o.map ? { SCENE_MAP: 1 } : {}), ...(o.lightMap ? { SCENE_LM: 1 } : {}) },
vertexShader: [
'attribute vec2 aLm;',
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
'void main() {',
' vUv = uv; vLm = aLm;',
' vN = normalize(mat3(modelMatrix) * normal);',
' vec4 w = modelMatrix * vec4(position, 1.0);',
' vWorld = w.xyz;',
' gl_Position = projectionMatrix * viewMatrix * w;',
'}',
].join('\n'),
fragmentShader: [
'uniform sampler2D uMap; uniform sampler2D uLm; uniform vec4 uColor; uniform float uCutoff;',
'uniform float uLmScale; uniform float uAmbient; uniform float uShadow; uniform vec3 uLightDir; uniform vec3 uLightColor;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
'void main() {',
' vec4 a = uColor;',
' #ifdef SCENE_MAP',
' a *= texture2D(uMap, vUv);',
' #endif',
' if (a.a < uCutoff) discard;',
' vec3 light = vec3(uAmbient); float shade = 1.0;',
' #ifdef SCENE_LM',
' vec4 lm = texture2D(uLm, vLm);',
' light += pow(lm.rgb, vec3(2.2)) * uLmScale; shade = mix(1.0, lm.a, uShadow);',
' #endif',
' vec3 n = normalize(gl_FrontFacing ? vN : -vN);',
' light += uLightColor * max(dot(n, uLightDir), 0.0) * shade;',
' gl_FragColor = vec4(a.rgb * light * focusMask(vWorld.xy), a.a);',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: blend === 'blend', depthWrite: blend !== 'blend',
});
m.userData.blend = blend;
return m;
}
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
export function sceneWaterMaterial(THREE, tex, focus, info) {
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
const uniforms = {
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
};
return new THREE.ShaderMaterial({
uniforms,
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
vertexShader: TERRAIN_VERT,
fragmentShader: [
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
FOCUS_GLSL,
'varying vec2 vUv; varying vec3 vWorld;',
'void main() {',
' float diff = 0.5, spec = 0.0, ca = 0.0;',
' #ifdef WATER_MAPS',
' vec2 p = vWorld.xy * 0.45;',
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
' diff = clamp(dot(n, L), 0.0, 1.0);',
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
' #endif',
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
' #include <colorspace_fragment>',
'}',
].join('\n'),
transparent: true, depthWrite: false,
});
}
/**
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
+119 -7
View File
@@ -4,6 +4,8 @@
//
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
// board.setScenePack(id, pack) the official scene of stage `id` (load.js loadScenePack): drawn instead of the
// procedural tiles and background while that stage is set (null → procedural)
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
// board.setFocus(rect) the lit field (others fade, animated)
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
@@ -17,15 +19,18 @@
// static crates (merged, same material),
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap
// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay.
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
import { surfaceUV } from './atlas.js';
import {
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
} from './materials.js';
import { syncThreeCamera } from '../projection.js';
import { mergeSceneChunks, sceneMaterialKey } from './load.js';
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
export const LIGHTING = Object.freeze({
@@ -53,6 +58,17 @@ export function gatePulse(t, phase = 0) {
return 0.134 + (0.229 - 0.134) * k;
}
/**
* Is a named part of an official scene (its ground rect [x0, y0, x1, y1]) shown with the built `areas`? The official
* scene keeps each field as a sub-graphic it switches on (HIDDEN@n: the left / right normal field, the boss field);
* a part is shown when its centre lies in one of the areas.
*/
export function sceneGroupVisible(rect, areas) {
if (!Array.isArray(rect) || rect.length < 4) return true;
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
return (areas || []).some((a) => cx >= a.c0 - 0.5 && cx <= a.c1 + 0.5 && cy >= a.r0 - 0.5 && cy <= a.r1 + 0.5);
}
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
const mergeInto = (list) => {
@@ -194,6 +210,10 @@ export class BoardScene {
this.scene.add(this.root, this.dynamic);
this.stageKey = null;
this.stage = null;
this.scenePacks = new Map(); // stage id → official scene pack (setScenePack)
this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds)
this.official = null;
this.sceneWater = [];
this.area = AREAS.normal;
this.areaKey = areaKey(AREAS.normal);
this.battleRect = null;
@@ -267,7 +287,8 @@ export class BoardScene {
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
setStage(stage) {
if (this.destroyed) return;
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
const official = stage ? this.scenePacks.get(stage.id) || null : null;
const key = stage ? `${stage.id || ''}|${official ? official.key : ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
if (key === this.stageKey) return;
this.stageKey = key;
this.stage = stage || null;
@@ -276,19 +297,107 @@ export class BoardScene {
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
this.board = board;
const M = this.meshes = {};
M.board = this._mesh(board.buckets.board, this.mat.board);
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
this.official = official && this._buildScene(official) ? official : null;
if (!this.official) {
M.board = this._mesh(board.buckets.board, this.mat.board);
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
}
this._buildDevices(board);
this._buildGates(board);
this._buildEdges(board);
this._buildTerrain(board);
this._buildBackground(board);
if (!this.official) this._buildBackground(board);
this._fitShadow(board);
this.renderer.shadowMap.needsUpdate = true;
}
/** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */
setScenePack(id, pack) {
if (this.destroyed || typeof id !== 'string' || !id) return false;
const prev = this.scenePacks.get(id) || null;
if (prev === (pack || null)) return false;
if (prev) this._disposeScene(prev);
if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id);
if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); }
return true;
}
/** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */
_sceneResources(pack) {
let res = this.sceneRes.get(pack.key);
if (res) return res;
const T = this.THREE;
const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1);
const tex = {}, lm = [];
for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true });
for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null);
res = { tex, lm, mats: new Map() };
this.sceneRes.set(pack.key, res);
return res;
}
_disposeScene(pack) {
const res = this.sceneRes.get(pack.key);
if (!res) return;
for (const m of res.mats.values()) m.dispose();
for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.();
this.sceneRes.delete(pack.key);
}
/**
* The official scene's meshes: the base plus the fields of the built areas, one mesh per material and lightmap
* page (`mergeSceneChunks`). A switched-off HIDDEN part is left out before the merge.
*/
_buildScene(pack) {
const T = this.THREE;
const res = this._sceneResources(pack);
const infos = pack.doc?.materials || {};
this.sceneWater = [];
const buckets = new Map();
for (const c of pack.chunks || []) {
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
const info = infos[c.material] || null;
const mk = sceneMaterialKey(c, info);
let bucket = buckets.get(mk);
if (!bucket) { bucket = { info, chunks: [] }; buckets.set(mk, bucket); }
bucket.chunks.push(c);
}
let n = 0;
for (const [mk, bucket] of buckets) {
const c = mergeSceneChunks(bucket.chunks);
const info = bucket.info;
const water = info?.kind === 'water';
let mat = res.mats.get(mk);
if (!mat) {
mat = water
? sceneWaterMaterial(T, this.tex, this.focus, info)
: sceneMaterial(T, {
map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info,
light: pack.doc?.light || null, lightRange: pack.doc?.lightRange,
}, this.focus);
res.mats.set(mk, mat);
}
if (water) this.sceneWater.push(mat);
const g = new T.BufferGeometry();
g.setAttribute('position', new T.BufferAttribute(c.position, 3));
g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true));
g.setAttribute('uv', new T.BufferAttribute(c.uv, 2));
g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2));
g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true));
g.setIndex(new T.BufferAttribute(c.index, 1));
g.computeBoundingSphere();
const mesh = new T.Mesh(g, mat);
mesh.castShadow = false; mesh.receiveShadow = false;
mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4;
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
this.root.add(mesh);
this.meshes[`scene${n++}`] = mesh;
}
return n > 0;
}
_buildDevices(board) {
const crates = [], blowers = [];
this.staticCrates = [];
@@ -550,6 +659,7 @@ export class BoardScene {
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t;
this.renderer.render(this.scene, this.camera);
this.frames++;
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
@@ -566,6 +676,8 @@ export class BoardScene {
this.destroyed = true;
for (const d of [...this.devices]) d.destroy();
this._clear();
for (const p of [...this.scenePacks.values()]) this._disposeScene(p);
this.scenePacks.clear();
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
for (const m of Object.values(this.mat)) m?.dispose?.();
for (const t of Object.values(this.tex)) t?.dispose?.();
+1 -1
View File
@@ -14,7 +14,7 @@ export function ensureDamageFonts() {
fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700',
fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7,
dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2,
}, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
}, { chars: st.font === 'sp-dmg-miss' ? [['0', '9'], '+-×!', 'MISS'] : [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
} catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); }
}
fontsReady = true;
+52 -6
View File
@@ -38,8 +38,13 @@ const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths
/** Screen scale of a number's text (24 px font) at `s` px per tile. */
const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1);
/** Characters of a damage number as drawn (heals get a '+'). */
const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0);
/** Characters of a damage number as drawn (heals get a '+'; a yellow MISS is four letters). */
const numChars = (v, style) => (style === 'miss' ? 4 : String(Math.round(v)).length + (style === 'heal' ? 1 : 0));
// [ASSUMED] PRTS says a red number sits at a random spot in a range above the model and gives no pixels.
const OFFICIAL_JITTER_X = 0.7; // tiles, full width (±0.35)
const OFFICIAL_LIFT_MIN = 0.15; // tiles above the head
const OFFICIAL_LIFT_SPAN = 0.4;
/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */
const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']);
@@ -48,7 +53,7 @@ export class FxNumbers {
// ---- hits / numbers -----------------------------------------------------------------------------------------
/** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */
damage(view, amount, type, srcView) {
damage(view, amount, type, srcView, mark) {
if (!view) return;
const style = dmgStyleKey(type);
const p = this._chest(view);
@@ -66,7 +71,13 @@ export class FxNumbers {
}
}
view.onHit?.();
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big);
if (this.ctx.settings?.damageNumbers === false) return;
// Official mode (设置「官方伤害数字」): only a sim-marked red integer. Sparks and the slash stay.
if (this.ctx.settings?.officialDamageNumbers) {
if (mark === 'r') this.officialNumber(view, amount, 'crit');
return;
}
this.number(view, amount, style, big);
}
/**
@@ -96,7 +107,42 @@ export class FxNumbers {
tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08,
});
}
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false);
if (this.ctx.settings?.damageNumbers !== false && !this.ctx.settings?.officialDamageNumbers) this.number(view, amount, 'heal', false);
}
/**
* One official popup: a red integer (including 0) or the yellow MISS. It does not join a running total.
* Position is client-side only — the sim stays deterministic.
*/
officialNumber(view, amount, style) {
if (!view) return;
const miss = style === 'miss';
const n = miss ? 0 : Math.round(amount);
if (!miss && (!Number.isFinite(n) || n < 0)) return;
const now = this.time;
const cam = this.ctx.cam();
const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8;
const a = cam.project(view.x, view.y, base, this._p);
const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100;
const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y;
const tile = pxPerZ > 1e-3 ? pxPerZ : s;
const cx = ax + (Math.random() - 0.5) * OFFICIAL_JITTER_X * tile;
const cy = ay - (OFFICIAL_LIFT_MIN + Math.random() * OFFICIAL_LIFT_SPAN) * tile;
const sc = numScale(s, false, style);
const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX;
const h = 24 * NUM_LINE_EM * sc * NUM_POP;
const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS;
while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift());
const t = this._takeNum(style);
t.text.visible = true;
t.text.alpha = 1;
t.text.text = miss ? 'MISS' : String(n);
Object.assign(t, {
unit: view, style, value: n, life: 0, end: NUM_LIFE, born: now, lastHit: now, pop: 1, big: false, fading: false,
official: true, ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base,
risePx: NUM_RISE * tile * 0.5, _x: cx, _y: cy, _w: w, _h: h,
});
this.nums.push(t);
}
/**
@@ -129,7 +175,7 @@ export class FxNumbers {
// 1. merge into this unit's running total of the same style
let mine = 0, youngest = null, oldest = null;
for (const t of this.nums) {
if (t.unit !== view || t.fading) continue;
if (t.unit !== view || t.fading || t.official) continue;
mine++;
if (!oldest || t.born < oldest.born) oldest = t;
if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t;
+4
View File
@@ -258,6 +258,10 @@ export class FxSim {
case 'dodge': {
this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 });
this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 });
// num:'m' — the sim already decided this dodge would have been a red number (PRTS yellow MISS, #ff9000).
if (ex.num === 'm' && at.v && this.ctx.settings?.damageNumbers !== false && this.ctx.settings?.officialDamageNumbers) {
this.officialNumber(at.v, 0, 'miss');
}
break;
}
case 'counter': {
+1 -1
View File
@@ -159,7 +159,7 @@ export class FxSystem {
}
const FX_PARTS = [FxParticles, FxProjectiles, FxBeams, FxLocks, FxNumbers, FxRings, FxArrivals, FxSim, FxZones];
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","officialNumber","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
for (const key of FX_METHOD_ORDER) {
let found = null;
+20 -23
View File
@@ -6,8 +6,8 @@
//
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
// attack(interval, once) one attack happened now (b.ev 'atk'): consumes its wind-up or starts at
// OnAttack, once; operator timeScale = loopDuration / interval;
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
// clip plays once at its own speed, then base; `clipPerAttack` (enemies,
// GitHub #58): every attack plays the clip once — at its own speed, faster
@@ -33,8 +33,7 @@
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
// redraws the model, only advances `clock`)
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
// attacks while it runs.
@@ -158,10 +157,10 @@ export class SpineActor {
/**
* Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when
* the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that
* clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm.
* clip (server/sim/ai.js attackStand). Off (operators): retain rhythm scaling, but still play each attack once.
*/
this.clipPerAttack = false;
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
this.wound = false; // wound up for the coming attack (windUp → attack)
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
this._play(this._idleName(), true);
@@ -366,9 +365,8 @@ export class SpineActor {
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
const clip = this._attackClip();
if (!clip) return false;
const single = once || this.clipPerAttack;
// in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it
if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false;
// One queued attack owns one wind-up. A later attack may begin while the previous recovery is still playing.
if (this.mode === 'attack' && this.current === clip.loop && (once || this.wound)) return false;
const loopDur = this.dur(clip.loop);
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
@@ -378,8 +376,8 @@ export class SpineActor {
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
if (!once) this.interval = iv;
this.mode = 'attack';
this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.attackUntil = this.clock + lead + Math.max(0, loopDur - hit) / plan.ts;
this._play(clip.loop, false, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
this.windTs = plan.ts;
this.windUntil = this.clock + lead;
this.wound = true;
@@ -397,14 +395,13 @@ export class SpineActor {
if (!clip) return;
const loopDur = this.dur(clip.loop);
const per = this.clipPerAttack && !once;
const single = once || per;
// clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip
const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4);
const hit = this._hitTime(clip.loop, loopDur);
// (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one)
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound);
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && this.wound;
this.mode = 'attack';
this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
this.attackUntil = this.clock + Math.max(0, loopDur - hit) / ts;
this.windUntil = null;
this.wound = false;
if (!wasAttacking) {
@@ -414,19 +411,19 @@ export class SpineActor {
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
const whole = per && this.interval >= loopDur;
if (whole) this.attackUntil = this.clock + loopDur / ts;
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
} else if (single) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
this._play(clip.loop, false, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
} else {
const e = this.spine.state.tracks[0];
if (e) {
e.timeScale = ts;
// re-phase gently so the strike frame lines up with this attack
const t = e.trackTime % loopDur;
let d = hit - t;
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
e.trackTime += d * 0.8;
// Preserve operator hit-phase correction when a coarse/batched update crossed windUntil.
// This adjusts the prepared single clip; it does not start another visual swing.
if (!once && !per) {
const t = e.trackTime % loopDur;
let d = hit - t;
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
e.trackTime += d * 0.8;
}
}
}
}
+3
View File
@@ -112,6 +112,9 @@ export const DMG_STYLE = Object.freeze({
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
// Official red integer and yellow MISS (PRTS 作战机制 「伤害红字(暴击)」, MISS #ff9000). Default numbers never use these.
crit: { font: 'sp-dmg-crit', fill: ['#ffd0d0', '#ff2a2a'], stroke: '#4a0000' },
miss: { font: 'sp-dmg-miss', fill: ['#ffe0a8', '#ff9000'], stroke: '#4a2400' },
});
/** Map b.ev dmg types to a style key. */
+40 -7
View File
@@ -228,6 +228,38 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/** The share of a model's height a bar is placed above (the head is not the very top of the silhouette). */
export const HEAD_TOP_K = 0.92;
/** Enemies whose model is taller than this many tiles keep their bar at the cap — a bar must stay on screen. */
export const HEAD_TILES_CAP = 2.2;
export const HEAD_TILES_CAP_BOSS = 3.2;
/**
* Tiles above the feet where a unit's HP bar / name tag sits.
*
* Operators and tokens are uniform chibis (UNIT.headroom). Enemies vary: with a known model they use **how far the model
* reaches above its own origin** (spine.js `top` = `bounds.y + bounds.height`, not the box's raw height — a body whose
* origin sits mid-model, 逐腐兽, would otherwise push its bar ~a tile too high), scaled by the official model factor and
* the vertical-stretch quirk; without bounds they fall back to the chibi headroom × that factor.
* @param {{ isEnemy:boolean, isBoss:boolean, spineShown:boolean, bounds?:{y?:number,height?:number}|null, modelK:number, modelKY:number }} o
* @returns {number} tiles
*/
export function headTilesForUnit(o) {
const k = (Number.isFinite(o.modelK) ? o.modelK : 1) * (Number.isFinite(o.modelKY) ? o.modelKY : 1);
if (!o.isEnemy) return UNIT.headroom;
const cap = o.isBoss ? HEAD_TILES_CAP_BOSS : HEAD_TILES_CAP;
const b = o.spineShown ? o.bounds : null;
if (b && Number.isFinite(b.height) && b.height > 20) {
// how far the box reaches *above the origin*: 猎狗pro −3.7, 大鲍勃 −2.6 (feet-anchored, ≈ height), 逐腐兽 −404.7 of
// 722.6 and 锏 −798 of 1183 (origin mid-body). Using the raw height there put the bar about a tile too high.
const top = Number.isFinite(b.y) ? b.y + b.height : b.height;
return clamp(Math.min(Math.max(top, 20), 900) * UNIT.modelScale * k * HEAD_TOP_K, 0.55, cap);
}
if (o.isBoss) return HEAD_TILES_CAP;
if (o.spineShown) return clamp(UNIT.headroom * k, 0.55, HEAD_TILES_CAP);
return UNIT.headroom;
}
/**
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
@@ -1298,13 +1330,14 @@ export class UnitView {
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
}
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
// headroom × their official model factor)
let headTiles = UNIT.headroom;
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * this.modelKY * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK * this.modelKY, 0.55, 2.2);
this._headTiles = headTiles;
// keep the original short-circuit: many views never build an actor (prep chips, diamonds, tests), so the model
// only counts when a spine is actually shown
const actor = spineShown ? this.actor : null;
const headTiles = this._headTiles = headTilesForUnit({
isEnemy: this.isEnemy, isBoss: this.isBoss, spineShown,
bounds: actor && actor.entry ? actor.entry.bounds : null,
modelK: this.modelK, modelKY: this.modelKY,
});
this.screen.top = by - headTiles * s;
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
}
+11 -5
View File
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
import { SettingsModal, settingsStore } from '../ui/settings.js';
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
import { openGuide } from '../ui/guide.js';
import { actions } from '../ui/gameActions.js';
import { actions, onRefreshBusy } from '../ui/gameActions.js';
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
import { StandInTags } from './game/standInTags.js';
@@ -183,6 +183,10 @@ function MatchScreen() {
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false);
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
// single-flights the intent; the R hotkey is dropped by the same guard)
const [refreshBusy, setRefreshBusy] = useState(false);
useEffect(() => onRefreshBusy(setRefreshBusy), []);
const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
@@ -428,6 +432,7 @@ function MatchScreen() {
if (showPrep) {
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
if (viewModeRef.current !== 'prep') {
audio.stopBondCue('battle');
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
staleFieldRef.current = field;
@@ -759,7 +764,8 @@ function MatchScreen() {
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
const prevActive = useRef(activeBonds);
useEffect(() => {
if (activeBonds > prevActive.current && phase === PHASE.PREP) audio.sfx('bondUp', { volume: 0.7 });
// a layer gain of the same update already started the cue (audio.bondCueRecent): keep it, don't restart it
if (activeBonds > prevActive.current && phase === PHASE.PREP && !audio.bondCueRecent('prep')) audio.sfx('bondUp', { volume: 0.7, scope: 'prep' });
prevActive.current = activeBonds;
}, [activeBonds]);
@@ -998,7 +1004,7 @@ function MatchScreen() {
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
const penKey = previewEnemyKey(e);
if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; }
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; }
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); return; }
if (!Number.isInteger(e.uid)) return;
setDetail({ kind: 'piece', uid: e.uid });
// a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on
@@ -1398,7 +1404,7 @@ function MatchScreen() {
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
// an own board piece or read-only prepared map character in prep; in battle or on a scouted board an ally
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
// draw their own
@@ -1468,7 +1474,7 @@ function MatchScreen() {
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
</div>` : null}
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed}
barRef=${barRef} offBonds=${offBonds}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
+8 -5
View File
@@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field,
const e = pieces?.get(target.uid);
if (!e || e.area !== 'board') return null;
row = e.row; col = e.col; dir = normDir(e.piece.dir);
} else if (target?.kind === 'unit' && !showPrep) {
} else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) {
const unit = target.unit;
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
if (unit.area != null && unit.area !== 'board') return null;
// A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from
// m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R.
if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) return null;
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(snapshot ? snapshot[2] : unit.y);
col = Math.round(snapshot ? snapshot[1] : unit.x);
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y);
col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x);
dir = (!showPrep && normDir(live?.dir, null)) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
} else return null;
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
+19 -1
View File
@@ -451,6 +451,20 @@ export function importRefusal(kind, ready, status = data.status) {
return null;
}
/**
* A loadout payload that carries `ops` (including empty `ops`) replaces 潜能 / 练度. 自选 operators are in that list
* and only backups.json names them, so the refusal is the 自选编队 one: chess.json and backups.json both loaded.
* Null when the payload has no `ops` (an older file leaves the current 练度 alone) or that data is ready (GitHub #538).
* @param {Record<string, any> | null | undefined} ops
* @param {boolean} ready
* @param {(name: string) => string} [status]
* @returns {{ text: string, tone: 'warn'|'error' } | null}
*/
export function loadoutOpsImportRefusal(ops, ready, status = data.status) {
if (ops == null) return null;
return importRefusal('diy', ready, status);
}
/** In place of a tab whose game data did not load (missingGameData): which files, and what to try (GitHub #173). */
export function DataMissing({ files }) {
const urls = files.map((name) => `/data/${DATA_FILES[name] || `${name}.json`}`);
@@ -586,6 +600,10 @@ function LoadoutScreen({ st }) {
}
const res = parseImport(ioText);
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
// ops replaces 潜能 / 练度, including 自选 operators that only backups.json names. Missing data used to drop
// them and still save, clearing a 练度 the file did not mean to erase (GitHub #538).
const opsRefused = loadoutOpsImportRefusal(res.ops, ready);
if (opsRefused) { toast(opsRefused.text, opsRefused.tone); return; }
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
@@ -675,7 +693,7 @@ function LoadoutScreen({ st }) {
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null}
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${t(syncText)}</span>` : null}
<span class="lo-count">${t('已调整')} <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged && !Object.keys(st.ops || {}).length} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')}<//>
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')}<//>
</div>`}
+5 -2
View File
@@ -291,8 +291,11 @@ export function TitleScreen() {
<${ResumeMatchButton} />
<${PwaInstallButton} />
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
<span class="title-foot__meta">
<a class="title-repo" href="https://github.com/sganggs/Stronghold-Protocol" target="_blank" rel="noopener noreferrer">${t('项目仓库')}</a>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
</span>
</footer>
</div>`;
}
+35 -12
View File
@@ -534,24 +534,27 @@ export function DialogHost() {
// ---- Tooltip -----------------------------------------------------------------------------------
let tipState = null; // { id, content, rect, placement }
let tipState = null; // { id, content, aside, rect, placement }
let tipSeq = 0;
const tipListeners = new Set();
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
/**
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
* `aside`: a second section in the same clamped, scrollable box; stacked on narrow screens.
* @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
*/
const TOUCH_TIP_MS = 450;
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
const ref = useRef(null);
const idRef = useRef(0);
const timer = useRef(null);
const textRef = useRef(text);
const asideRef = useRef(aside);
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
textRef.current = text;
asideRef.current = aside;
if (!idRef.current) idRef.current = ++tipSeq;
const show = () => {
clearTimeout(timer.current);
@@ -560,7 +563,8 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
const el = ref.current;
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
if (!el || !el.isConnected || content == null || content === '') return;
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement,
hold: () => clearTimeout(timer.current), hide });
}, delay);
};
const hide = () => {
@@ -570,7 +574,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
// (≥ 450 ms) does. Ordinary tips expire after release; compound lists stay open for scrolling until dismissed.
const touchAt = useRef(0);
const longTimer = useRef(null);
const onPointerDown = (e) => {
@@ -586,18 +590,24 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
if (!e || e.pointerType === 'mouse') return;
touchAt.current = Date.now();
clearTimeout(longTimer.current);
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
if (tipState?.id === idRef.current && !asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
};
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
const onLeave = () => {
pressed.current = false;
if (Date.now() - touchAt.current > 900) {
if (asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 250); }
else hide();
}
};
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
useEffect(() => {
if (tipState?.id !== idRef.current) return;
if (text == null || text === '') setTip(null);
else setTip({ ...tipState, content: text });
}, [text]);
else setTip({ ...tipState, content: text, aside: asideRef.current });
}, [text, aside]);
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
@@ -612,7 +622,12 @@ export function TooltipLayer() {
const [pos, setPos] = useState(null);
useEffect(() => {
tipListeners.add(force);
return () => tipListeners.delete(force);
const outside = (e) => { if (tipState?.aside && !e.target?.closest?.('.tooltip')) tipState.hide?.(); };
const escape = (e) => { if (e.key === 'Escape') tipState?.hide?.(); };
document.addEventListener('pointerdown', outside);
document.addEventListener('keydown', escape);
window.addEventListener('resize', force);
return () => { tipListeners.delete(force); document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); window.removeEventListener('resize', force); };
}, []);
useLayoutEffect(() => {
const el = boxRef.current;
@@ -628,13 +643,21 @@ export function TooltipLayer() {
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
let left = r.left + r.width / 2 - b.width / 2;
left = Math.max(m, Math.min(vw - b.width - m, left));
top = Math.max(m, Math.min(vh - b.height - m, top));
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
});
if (!tipState) return null;
const ready = pos && pos.id === tipState.id;
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
const hasAside = tipState.aside != null && tipState.aside !== '';
const off = 'top:-9999px;left:-9999px';
return html`<div ref=${boxRef} class=${cx('tooltip', hasAside && 'tooltip--compound', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
tabIndex=${hasAside ? 0 : undefined} onMouseEnter=${() => tipState?.hold?.()} onMouseLeave=${() => tipState?.hide?.()}
onFocusIn=${() => tipState?.hold?.()} onPointerDown=${() => tipState?.hold?.()}
onFocusOut=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) tipState?.hide?.(); }}
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>
${hasAside ? html`<div class="tooltip__main">${tipState.content}</div><div class="tooltip--aside">${tipState.aside}</div>` : tipState.content}
</div>`;
}
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
+31 -7
View File
@@ -60,6 +60,30 @@ export function fallbackTileClass(stage, row, col) {
return 'floor';
}
/**
* The simplified board's floating label for one event, or null when that event draws no digits.
* Official mode keeps only a marked red integer (including 0) and a yellow MISS. The slot stays above the unit;
* the random offset of the Pixi numbers is not repeated here [ASSUMED].
* @param {any[]} e
* @param {{ damageNumbers?: boolean, officialDamageNumbers?: boolean }} settings
* @returns {{ id: any, text: string, type: string }|null}
*/
export function fallbackFloat(e, settings) {
if (!settings?.damageNumbers || !Array.isArray(e)) return null;
if (settings.officialDamageNumbers === true) {
if (e[0] === 'fx' && e[1] === 'dodge' && e[4] && e[4].num === 'm') return { id: e[4].id, text: 'MISS', type: 'miss' };
if (e[0] !== 'dmg' || e[4] !== 'r') return null;
const amount = Math.round(Number(e[2]) || 0);
if (!Number.isFinite(amount) || amount < 0) return null;
return { id: e[1], text: String(amount), type: 'crit' };
}
if (e[0] !== 'dmg' && e[0] !== 'heal') return null;
const amount = Math.round(Number(e[2]) || 0);
if (amount <= 0) return null;
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
return { id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type };
}
/**
* Create the DOM fallback view.
* @param {HTMLElement} host
@@ -78,7 +102,7 @@ export function createFallbackView(host, opts = {}) {
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L', deployField: 'normal',
mode: 'prep', priv: null, editable: false, canPlace: null,
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, officialDamageNumbers: false, ...(opts.settings || {}) },
size: { w: 0, h: 0 }, destroyed: false, pen: [],
};
@@ -337,7 +361,9 @@ export function createFallbackView(host, opts = {}) {
const pos = tilePos(L, u.y, u.x);
const src = tokenAvatarUrl(m(), u.defId);
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }}
title=${u.name || u.defId}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
</div>`);
@@ -456,11 +482,9 @@ export function createFallbackView(host, opts = {}) {
if (e[0] === 'spawn' && e[1] && e[1].id != null) {
st.units.set(e[1].id, e[1]);
if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]);
} else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) {
const amount = Math.round(Number(e[2]) || 0);
if (amount <= 0) continue;
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now });
} else if (st.floats.length < 60) {
const spec = fallbackFloat(e, st.settings);
if (spec) st.floats.push({ key: `${now}:${Math.random()}`, id: spec.id, text: spec.text, type: spec.type, at: now });
}
}
schedule();
+23 -1
View File
@@ -20,6 +20,22 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
let refreshPending = false;
const refreshBusyListeners = new Set();
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
/**
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
* @param {(busy: boolean) => void} fn
* @returns {() => void} unsubscribe
*/
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
/**
* Send an intent. Resolves true on `ok`, false on error (already toasted).
* @param {string} t
@@ -55,7 +71,13 @@ export const actions = {
band: (bandId) => act('g.band', { bandId }),
bandSkip: () => act('g.bandSkip'),
buy: (slot) => act('g.buy', { slot }),
refresh: () => act('g.refresh'),
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
refresh: () => {
if (refreshPending) return Promise.resolve(false);
refreshPending = true;
emitRefreshBusy();
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
},
freeze: () => act('g.freeze'),
levelUp: () => act('g.levelUp'),
sell: (uid) => act('g.sell', { uid }),
+4 -3
View File
@@ -201,8 +201,9 @@ export function GIcon({ name, class: cls, title }) {
/**
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
* shown in red with a −N tick that pops again on every change (keyed); `note` a
* small tag after it (联防中); `tip` the title text.
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
*/
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
@@ -211,7 +212,7 @@ export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, not
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
data-pending=${p > 0 ? p : null}>
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
${note ? html`<span class="lp__note">${note}</span>` : null}
</span>`;
+15 -8
View File
@@ -4,12 +4,23 @@ import { GEO, PHASE } from '../../../../shared/constants.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
import { int, isObj } from './shared.js';
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
function prepPair(pub, myId) {
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
if (!alive.some(p => p.playerId === myId)) return [];
if (pub.bossPairs !== undefined) {
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
}
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
const i = ids.indexOf(myId);
return ids.slice(i - i % 2, i - i % 2 + 2);
}
/**
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
* finalAssault.js pairPlayers, sent in m.public.bossPairs; the second of a pair is on the mirrored
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
* @param {any} pub m.public
* @param {string|null} myId
@@ -19,9 +30,7 @@ export function prepCamera(pub, myId) {
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
const r = pub?.round;
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
const idx = alive.findIndex((p) => p.playerId === myId);
const idx = prepPair(pub, myId).indexOf(myId);
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
}
@@ -32,7 +41,7 @@ const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
* in the server's saved pairs — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
* round. PR #191 framed the partner alone, in gold (not merged).
* @param {any} pub m.public @param {string|null} myId
@@ -51,9 +60,7 @@ export function teamFrameIds(pub, myId) {
return f && f.players.length > 1 ? new Set(f.players) : none;
}
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
const idx = alive.indexOf(myId);
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
const pair = prepPair(pub, myId);
return pair.length > 1 ? new Set(pair) : none;
}
+3 -2
View File
@@ -24,13 +24,13 @@ export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, quality: 'high'|'medium'|'low',
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
*/
export function sanitizeSettings(raw) {
@@ -44,6 +44,7 @@ export function sanitizeSettings(raw) {
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
officialDamageNumbers: typeof r.officialDamageNumbers === 'boolean' ? r.officialDamageNumbers : false,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
keys: sanitizeHotkeys(r.keys),
+9 -7
View File
@@ -12,13 +12,14 @@
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
// Normal rounds (user playtest #3 item 2): the LP tower marks the own battle's leaks live as its enemies enter the blue
// gate — the settled lp stays until settlement, in red with a −min(lpCapPerRound, counted leaks) tick (GitHub #479: the
// official keeps the current value until settlement), 联防中 while a 联防 may still save part of it (liveLp;
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower marks lp with −min(lpCapPerRound, N).
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
@@ -183,9 +184,10 @@ export function uniteRemaining(local, server) {
}
/**
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is marked at once (red, −N) instead
* of only at settlement — the number itself keeps the settled LP until settlement (GitHub #479: the official shows the
* current value, not the deduction, until the round settles). `base` — kept by the caller between renders — is the
* settled m.private state the pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
@@ -352,7 +354,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
* live: the own battle's pending LP loss (liveLp): the tower keeps the settled lp and adds a red −N tick, 联防中 during 联防;
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
*/
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
+8 -3
View File
@@ -169,6 +169,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
const tell = notify || ((text) => toast(t(text), 'warn'));
let timer = null;
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
let revision = 0; // edits and welcome invalidate a preparation still awaiting game data (GitHub #540)
let pendingJson = null; // JSON of the newest request still awaiting its reply
let lastSent = null; // JSON of the last payload the server accepted (on this session)
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
@@ -189,9 +190,12 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
async function flush() {
if (disposed) return;
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
const preparing = revision;
try {
const payload = await prepare();
if (disposed) return;
// a newer edit or welcome happened while prepare awaited chess/backups: do not send, and do not touch the
// sync state that newer flush owns (an older skill must not overwrite a restore-defaults)
if (disposed || preparing !== revision) return;
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
if (payload == null) { setState('error'); return; }
const json = JSON.stringify(payload);
@@ -221,15 +225,16 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
}
} catch (e) {
if (disposed || preparing !== revision) return;
console.warn(`[${tag}] sync failed`, e);
setState('error');
}
}
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
const offWelcome = net.on('welcome', () => { revision++; lastSent = null; pendingJson = null; seq++; schedule(50); });
const keys = Array.isArray(key) ? key : [key];
const offStore = target.subscribe((s, prev) => {
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
if (keys.some((k) => s[k] !== prev[k])) { revision++; edited = true; schedule(); }
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
+27 -2
View File
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
}
/**
* The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
* one highlighted); null when none are banned.
* @param {any} bond @param {MatchInfoModel} model @param {Set<string>} banned
*/
function BondBannedAside(bond, model, banned) {
const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
if (!ids.length) return null;
const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
return html`<div class="brief-bantip">
<div class="brief-bantip__h">${t('本局禁用干员')}</div>
${ids.map((id) => {
const c = data.lookup('chess', id);
const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
return html`<div key=${id} class="brief-bantip__op" data-chess=${id}>
<${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
<div class="brief-bantip__txt"><b>${c?.name || id}</b>
<span>${bonds.map((b, i) => html`${i ? ' · ' : ''}<span class=${cx(b === bond.bondId && 'is-this')}>${nameOf.get(b)}</span>`)}</span></div>
</div>`;
})}
</div>`;
}
/**
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
* which (briefingBondTip).
* which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
*/
export function MatchBondRow({ title, micro, bonds, model }) {
const m = data.get('assets');
const banned = new Set(model.banned);
return html`<div class="brief-bonds">
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
<div class="brief-bonds__row">
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
const state = model.stateOf(b.bondId);
const off = !!state;
const bannedN = model.perBond.get(b.bondId) || 0;
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
+2
View File
@@ -263,6 +263,8 @@ export function SettingsModal({ open, onClose }) {
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
<${Toggle} label=${t('官方伤害数字')} micro="OFFICIAL NUMBERS" value=${s.officialDamageNumbers} onChange=${(v) => updateSettings({ officialDamageNumbers: v })} />
<p class="set-hint">${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}</p>
<div class="set-row">
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
<div class="set-seg" role="radiogroup">
+7 -5
View File
@@ -10,7 +10,9 @@
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
/**
* The bar.
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
*/
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
const shop = priv?.shop || {};
const slots = Array.isArray(shop.slots) ? shop.slots : [];
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
</button>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
<span>${t('刷新')}</span>
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
<kbd>${hk.refresh}</kbd>
</button>
</div>