feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco)

Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here.

Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset).
This commit is contained in:
Cloudnyco authored and sganggs committed 2026-10-08 18:48:42 +08:00
1 parent 1f147a288f
commit e8ba091298
13 files changed
+984

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
// Battle perf page (dev): one real battle in the browser, run the way the match screen runs it — the client-side battle
// runner (js/battle/runner.js: the shared sim at the battle speed) feeding the field view (js/render/app.js) — from a
// BattleSpec captured off a bot match (tools/capture-specs.mjs → /dev/perf/<name>.json). Players can open it on a
// phone and report the numbers (「开始测量」→ a verdict and a copyable report); tools/perfbench.mjs drives it headlessly.
//
// Query: ?spec=<name> (default: the first of /dev/perf/index.json) &quality=high|medium|low &panel=0 (hide the panel)
// &board=2d|3d (render/app.js reads it)
// Puppeteer hook: window.__perf = { ready, error, view, runner, sample(ms) → figures over `ms` of animation frames }
//
// The panel must not disturb what it measures: frame times go into a ring buffer (one store per frame), the strip is
// redrawn 4× a second from it, binned to pixel columns, and both the strip and the live figures stand still during a
// measurement (only a CSS progress animation runs).
import { createFieldView } from '../js/render/app.js';
import { data } from '../js/data.js';
import { assets } from '../js/assets.js';
import { createBattleRunner } from '../js/battle/runner.js';
import { round, frameFigures } from './frame-stats.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
const perf = { ready: false, error: null };
window.__perf = perf;
const longTasks = [];
try {
new PerformanceObserver((list) => { for (const e of list.getEntries()) longTasks.push(e.duration); }).observe({ type: 'longtask', buffered: true });
} catch { /* not supported */ }
// Frame-time budgets (ms) shared by the verdict, the strip colours and the stutter share: 16.7 / 33.3 ms are 60 / 30 FPS.
// The 60 FPS budget allows 5% for vsync jitter (a 60 Hz display measures frames of 16.6–17.4 ms); a frame over the 30 FPS
// budget counts as a stutter frame.
export const FRAME_60_MS = 17.5;
export const FRAME_30_MS = 33.4;
const QUALITY_NAME = { high: '高', medium: '中', low: '低' };
/**
* Frame figures over `ms` of animation frames: average fps, frame-time percentiles, the share of frames over 33.4 ms
* (below 30 fps), long tasks, the sim's ticks and their cost (runner.stats), and the view's own CPU / render times.
* The first frame time is partial (from the call to the first frame) and dropped, so the sample runs for at least two
* frames whatever `ms` is: a sample that saw a single frame (a short `ms`, a frame longer than `ms`) had no frame time
* left and divided by zero.
*/
perf.sample = (ms = 10000) => new Promise((resolve) => {
const deltas = [];
let last = performance.now();
const t0 = last;
const r0 = { ...perf.runner.stats() };
const lt0 = longTasks.length;
const step = (t) => {
deltas.push(t - last);
last = t;
if (t - t0 < ms || deltas.length < 2) { requestAnimationFrame(step); return; }
deltas.shift();
const r1 = perf.runner.stats();
const v = perf.view.stats();
const lt = longTasks.slice(lt0);
const ticks = r1.ticks - r0.ticks;
resolve({
...frameFigures(deltas, FRAME_30_MS),
longTasks: lt.length, longTaskMs: Math.round(lt.reduce((a, b) => a + b, 0)),
ticks, simMs: round(r1.stepMs - r0.stepMs), simMsPerTick: ticks > 0 ? round((r1.stepMs - r0.stepMs) / ticks, 3) : null,
viewCpuMs: v.cpuMs, renderMs: v.renderMs, units: v.units, lod: v.lod, particles: v.particles,
board: v.board3d?.on ? '3d' : '2d', done: !!perf.runner.state()?.done,
});
};
requestAnimationFrame(step);
});
/** The device line of a report: renderer, screen, cores (what a phone report needs to be comparable). */
function deviceInfo() {
let gpu = '?';
try {
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : 'no WebGL');
} catch { /* ignore */ }
return {
ua: navigator.userAgent, gpu, cores: navigator.hardwareConcurrency || null, memoryGB: navigator.deviceMemory || null,
viewport: `${innerWidth}×${innerHeight}`, dpr: devicePixelRatio,
};
}
/** The copyable report: one item per line, the panel's terms and units (pasted into feedback as is). */
function report(spec, title, quality, s) {
const d = deviceInfo();
const lines = [
'战斗性能测试报告(/dev/battle-perf.html)',
`场景:${title}(${spec})`,
`画质:${QUALITY_NAME[quality] || quality};棋盘:${s.board === '3d' ? '3D' : '2D'}`,
`帧率:${s.fps} FPS`,
`帧耗时:P50 ${s.p50} ms,P95 ${s.p95} ms,P99 ${s.p99} ms`,
`卡顿帧占比(单帧 > 33.3 ms):${s.over33}%`,
`模拟单步:${s.simMsPerTick} ms(共 ${s.ticks} 步)`,
`渲染 CPU:${s.viewCpuMs} ms;渲染提交:${s.renderMs} ms`,
`场上单位:${s.units};自适应负载档位:${s.lod}`,
`长任务:${s.longTasks} 次,共 ${s.longTaskMs} ms`,
`GPU:${d.gpu}`,
`设备:${d.cores ?? '未知'} 个 CPU 线程${d.memoryGB ? `,内存约 ${d.memoryGB} GB` : ''};视口 ${d.viewport},像素比 ${d.dpr}`,
`浏览器:${d.ua}`,
];
if (s.done) lines.splice(10, 0, '注意:战斗在测量期间结束,结果仅供参考');
return lines.join('\n');
}
/** The verdict a player reads first, on the frame-time budgets of the strip (60 / 30 FPS). */
export function verdictOf(s) {
if (s.p95 <= FRAME_60_MS && s.over33 < 1) return { label: '流畅', note: `P95 帧耗时 ${s.p95} ms,达到 60 FPS`, color: '--mint-500' };
if (s.p95 <= FRAME_30_MS && s.over33 < 1) return { label: '基本流畅', note: `P95 帧耗时 ${s.p95} ms,介于 30–60 FPS`, color: '--amber' };
return { label: '卡顿', note: `卡顿帧占比 ${s.over33}%,P95 帧耗时 ${s.p95} ms`, color: '--red' };
}
// ---- frame ring buffer + strip ------------------------------------------------------------------------------------
const RING = 4096;
const ringT = new Float64Array(RING); // frame end (performance.now)
const ringD = new Float32Array(RING); // frame time (ms)
let ringN = 0;
let lastT = 0;
function recordFrames(t) {
if (lastT) { const i = ringN % RING; ringT[i] = t; ringD[i] = t - lastT; ringN++; }
lastT = t;
requestAnimationFrame(recordFrames);
}
const SPAN_MS = 10000; // the strip shows the last 10 s
const TOP_MS = 50; // frame times above clip to the top (a red spike)
let palette = null;
function colors() {
if (!palette) {
const cs = getComputedStyle(document.documentElement);
const v = (n) => cs.getPropertyValue(n).trim();
palette = { good: v('--mint-500'), warn: v('--amber'), bad: v('--red'), line: v('--line-2'), text: v('--text-lo') };
}
return palette;
}
const band = (ms) => (ms <= FRAME_60_MS ? 'good' : ms <= FRAME_30_MS ? 'warn' : 'bad');
/** Draw the last SPAN_MS of frame times into `cv`: one column per pixel (its slowest frame), budget lines at 60 / 30 fps. */
function drawStrip(cv, { labels = true } = {}) {
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
const dpr = Math.min(2, devicePixelRatio || 1);
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const c = colors();
const pad = labels ? 4 : 1;
const y = (ms) => h - pad - (Math.min(ms, TOP_MS) / TOP_MS) * (h - pad * 2);
g.fillStyle = c.line;
for (const ms of [16.7, 33.3]) g.fillRect(0, Math.round(y(ms)), w, 1);
if (labels) {
g.fillStyle = c.text;
g.font = '10px "Noto Sans SC", sans-serif';
g.textAlign = 'right';
g.fillText('60 FPS', w - 3, y(16.7) - 3);
g.fillText('30 FPS', w - 3, y(33.3) - 3);
}
const now = performance.now();
const cols = new Float32Array(Math.ceil(w));
for (let k = 1; k <= Math.min(ringN, RING); k++) {
const i = (ringN - k) % RING;
const age = now - ringT[i];
if (age > SPAN_MS) break;
const x = Math.floor(w - 1 - (age / SPAN_MS) * w);
if (x >= 0 && ringD[i] > cols[x]) cols[x] = ringD[i];
}
const barW = labels ? 1 : 1.5;
for (let x = 0; x < cols.length; x++) {
const ms = cols[x];
if (!ms) continue;
g.fillStyle = c[band(ms)];
const top = y(ms);
g.fillRect(x, top, barW, h - pad - top);
}
}
// ---- panel ----------------------------------------------------------------------------------------------------------
const PANEL_KEY = 'sp.dev.battlePerfPanel';
function loadPanelPref() {
try { return localStorage.getItem(PANEL_KEY); } catch { return null; }
}
function savePanelPref(v) {
try { localStorage.setItem(PANEL_KEY, v); } catch { /* private mode */ }
}
function setupPanel() {
const panel = $('panel');
const set = (collapsed, save = true) => {
panel.classList.toggle('is-collapsed', collapsed);
$('expand').setAttribute('aria-expanded', String(!collapsed));
$('collapse').setAttribute('aria-expanded', String(!collapsed));
if (save) savePanelPref(collapsed ? 'collapsed' : 'open');
};
const pref = loadPanelPref();
// phones (a landscape phone is short): start folded, the battle is what is being measured
set(pref ? pref === 'collapsed' : (innerWidth < 900 || innerHeight < 500), false);
$('collapse').onclick = () => { set(true); $('expand').focus(); };
$('expand').onclick = () => { set(false); $('collapse').focus(); };
}
async function main() {
if (q.get('panel') === '0') $('panel').classList.add('is-hidden');
setupPanel();
const index = await fetch('/dev/perf/index.json').then((r) => (r.ok ? r.json() : []), () => []);
const specName = q.get('spec') || index[0]?.name;
if (!specName) throw new Error('未找到战斗数据。请先运行 node tools/capture-specs.mjs 生成。');
const quality = ['high', 'medium', 'low'].includes(q.get('quality')) ? q.get('quality') : 'high';
for (const s of index) $('spec').append(new Option(s.title || s.name, s.name));
$('spec').value = specName;
$('quality').value = quality;
const reload = () => {
const p = new URLSearchParams(location.search);
p.set('spec', $('spec').value);
p.set('quality', $('quality').value);
location.search = p.toString();
};
$('restart').onclick = reload;
$('spec').onchange = reload;
$('quality').onchange = reload;
await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config');
await assets.ready();
const msg = await fetch(`/dev/perf/${encodeURIComponent(specName)}.json`).then((r) => {
if (!r.ok) throw new Error(`战斗数据 ${specName} 加载失败(HTTP ${r.status})。请确认该文件位于 public/dev/perf/。`);
return r.json();
});
const view = await createFieldView($('field'), { data, assets, settings: { quality, damageNumbers: true } });
view.setStage(data.lookup('stages', msg.spec.stageId));
// the runner needs no server here: reports go nowhere, the battle runs from its start
const net = { on: () => () => {}, send() {}, request: async () => ({ ok: true }) };
const runner = createBattleRunner({ net, store: null });
const camKind = msg.kind === 'hidden' ? 'boss' : msg.kind;
runner.on('field', (field) => { view.enterBattle(field); view.setCamera(camKind, { rect: field.rect, side: 'L', instant: true }); });
runner.on('snap', (snap) => view.pushSnapshot(snap));
runner.on('ev', (ev) => view.pushEvents(ev));
perf.view = view;
perf.runner = runner;
await runner.onStart({ ...msg, elapsed: 0, authoritative: true, watch: false, done: false });
perf.ready = true;
// frame times from here on: the loading hitches before the battle are not the battle's
requestAnimationFrame(recordFrames);
let measuring = false;
let prev = { ...runner.stats() };
const live = () => {
if (measuring) return;
const v = view.stats();
const r = runner.stats();
const ticks = r.ticks - prev.ticks;
const simMs = ticks > 0 ? (r.stepMs - prev.stepMs) / ticks : 0;
prev = { ...r };
const fps = Math.round(v.fps);
$('fps').textContent = fps;
$('f-frame').textContent = `${v.frameMs.toFixed(1)} ms`;
$('f-view').textContent = `${v.cpuMs.toFixed(1)} ms`;
$('f-sim').textContent = `${simMs.toFixed(2)} ms`;
$('chip-fps').textContent = fps;
$('chip-ms').textContent = `FPS / ${v.frameMs.toFixed(1)} ms`;
const st = runner.state();
$('meta').textContent = st?.done
? '战斗已结束。如需继续测量,请点击「重新开始」。'
: `场上单位 ${v.units} 个,自适应负载 ${v.lod} 档,${v.board3d?.on ? '3D' : '2D'} 棋盘`;
};
setInterval(live, 500);
setInterval(() => {
if (measuring) return;
if ($('panel').classList.contains('is-collapsed')) drawStrip($('chip-strip'), { labels: false });
else drawStrip($('strip'));
}, 250);
const btn = $('measure');
btn.disabled = false;
btn.onclick = async () => {
if (measuring) return;
measuring = true;
btn.classList.add('is-running');
btn.setAttribute('aria-disabled', 'true');
$('freeze').hidden = false;
const t0 = Date.now();
const tick = setInterval(() => { $('measure-label').textContent = `测量中(剩余 ${Math.max(1, 10 - Math.floor((Date.now() - t0) / 1000))} 秒)`; }, 1000);
$('measure-label').textContent = '测量中(剩余 10 秒)';
const s = await perf.sample(10000);
clearInterval(tick);
measuring = false;
btn.classList.remove('is-running');
btn.removeAttribute('aria-disabled');
$('freeze').hidden = true;
$('measure-label').textContent = '重新测量(10 秒)';
const v = verdictOf(s);
const verdict = $('verdict');
verdict.style.setProperty('--v', `var(${v.color})`);
verdict.replaceChildren(document.createTextNode(v.label));
const note = document.createElement('small');
note.textContent = v.note;
verdict.append(note);
$('r-fps').textContent = s.fps;
$('r-p95').textContent = `${s.p95} ms`;
$('r-over').textContent = `${s.over33}%`;
const text = report(specName, index.find((x) => x.name === specName)?.title || specName, quality, s);
$('out').textContent = text;
$('result').hidden = false;
const copy = $('copy');
copy.textContent = '复制报告';
copy.onclick = () => {
const done = () => { copy.textContent = '已复制到剪贴板'; };
const fallback = () => {
const d = $('result').querySelector('details');
d.open = true;
const range = document.createRange();
range.selectNodeContents($('out'));
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
copy.textContent = '无法写入剪贴板,已选中报告文本,请手动复制';
};
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, fallback);
else fallback();
};
};
}
main().catch((err) => {
perf.error = String(err?.stack || err);
console.error(err);
const meta = document.getElementById('meta');
if (meta) meta.textContent = `加载失败:${err?.message || err}`;
});