Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners

- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight
  (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s
- #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks,
  hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped
  at one per frame
- #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates'
  pending LP in the team panel
- #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue);
  temp-row warning and ready-button reason
- #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx
- #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught)
- #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost
- #8 特质 right under the detail card header, with its official type icon
- #9 no spinning busy cursors / spinners next to texts; game data warmed before the match
- docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-29 23:42:55 +08:00
1 parent 01a44cd288
commit 0aba602ecf
72 files changed
+5132 -571

No files matched your search

+46 -3
View File
@@ -20,13 +20,17 @@
// authoritative (the server is waiting for it); the server takes a duplicate idempotently. A server refusal is final.
// Solo pause (g.pause, DESIGN §14): while `m.public.paused` is true every local battle clock stands still (no ticks, no
// reports); on resume the clocks move on by the paused time, like the server's field clock.
// Live leaks (user playtest #3 item 2): every normal field simulated here keeps its counted leaks so far — the settle
// rule's count (leaked entries with counted !== false, /sim/spec.js battleProgress) — and publishes them as
// state().leaks { [fieldId]: n } whenever one changes, authoritative or display replica alike; the top bar shows the
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp).
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
//
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused } | null
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
@@ -133,6 +137,8 @@ export function createBattleRunner(deps) {
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
/** a normal field's leak count changed since the last publishState() */
let leaksDirty = false;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
const hidden = () => !!(doc && doc.hidden);
@@ -147,21 +153,48 @@ export function createBattleRunner(deps) {
return simP;
}
/** Counted leaks so far of every normal field simulated here: { [fieldId]: n } (user playtest #3 item 2). */
function leakMap() {
const out = {};
for (const e of entries.values()) if (e.kind === 'normal' && e.fieldId) out[e.fieldId] = e.leaks;
return out;
}
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind } : null;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap() } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
leaks: leakMap(),
};
}
function publishState() {
leaksDirty = false;
const s = state();
try { store.patch('match', { battle: s }); } catch { /* store without a match slice */ }
emit('state', s);
}
/**
* Re-count an entry's leaks when its battle recorded a new one (Battle.leakedCount) or ended (timeout leaks, and the
* final result): the settle rule's count, leaked entries with counted !== false (/sim/spec.js battleProgress).
*/
function noteLeaks(e) {
if (e.kind !== 'normal') return;
const b = e.battle;
const mark = `${Number(b.leakedCount) || 0}:${b.finished ? 1 : 0}`;
if (mark === e.leakMark) return;
e.leakMark = mark;
let n = e.leaks;
try { n = Math.max(0, Math.trunc(Number(e.sim.spec.battleProgress(b).leaks) || 0)); } catch { /* keep the last count */ }
if (n !== e.leaks) { e.leaks = n; leaksDirty = true; }
}
/** Publish the state when a leak count changed since the last publish. */
function flushLeaks() { if (leaksDirty) publishState(); }
/** Target tick of an entry on its clock. */
const targetTick = (e, t) => Math.max(0, Math.floor((((t - e.t0) / 1000) * e.speed) / TICK + 1e-9));
@@ -225,6 +258,7 @@ export function createBattleRunner(deps) {
function finished(e) {
if (e.done) return;
e.done = true;
noteLeaks(e);
if (e.authoritative && !e.resultSent && net) {
progress(e, true);
e.resultSent = true;
@@ -240,6 +274,7 @@ export function createBattleRunner(deps) {
}
}
if (e === cur) publishState();
else flushLeaks();
}
/**
@@ -302,6 +337,7 @@ export function createBattleRunner(deps) {
if (dt > stats.maxFrameMs) stats.maxFrameMs = dt;
if (render) emitFrame(e, catchingUp);
else { try { e.battle.drainEvents(); } catch { /* ignore */ } }
noteLeaks(e);
progress(e);
if (e.battle.finished) finished(e);
}
@@ -313,6 +349,7 @@ export function createBattleRunner(deps) {
stats.frames++;
const t = clock();
for (const e of [...entries.values()]) if (running(e)) advance(e, t, e === cur);
flushLeaks();
schedule();
}
@@ -320,6 +357,7 @@ export function createBattleRunner(deps) {
// hidden tab: no animation frames — keep authoritative battles on their clock (no rendering)
if (!hidden()) return;
for (const e of [...entries.values()]) if (e.authoritative && running(e)) advance(e, clock(), false);
flushLeaks();
}
function schedule() {
@@ -411,6 +449,8 @@ export function createBattleRunner(deps) {
t0: clock() - ((Number(msg.elapsed) || 0) / speed) * 1000, lastProgressAt: -Infinity, done: false, resultSent: false,
result: null, delivery: null,
meter: sim.spec.attachLpMeter(battle),
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at
leaks: 0, leakMark: '',
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
@@ -420,17 +460,19 @@ export function createBattleRunner(deps) {
const n = Math.min(PREPARE_SLICE, targetTick(e, clock()) - battle.tickCount);
stepEntry(e, n);
try { battle.drainEvents(); } catch { /* ignore */ }
noteLeaks(e);
if (e.authoritative) progress(e);
await yieldFrame();
if (seq !== startSeq) {
// superseded while preparing: an authoritative battle must still finish (it is kept), a replica is dropped
if (e.authoritative) { entries.set(e.battleId, e); evict(); schedule(); }
if (e.authoritative) { entries.set(e.battleId, e); evict(); if (e.leaks) leaksDirty = true; schedule(); }
return;
}
}
entries.set(e.battleId, e);
evict();
loading = null;
noteLeaks(e);
show(e);
if (battle.finished) finished(e);
}
@@ -459,6 +501,7 @@ export function createBattleRunner(deps) {
}
if (e === cur) emitFrame(e, false);
finished(e);
flushLeaks();
schedule();
}