Files
Toolbox/dev_test_scripts/integration/test_retro_pinball_integration.js
yangxiangyuan 4492b4106a feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
2026-08-07 17:49:24 +08:00

223 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// dev_test_scripts/integration/test_retro_pinball_integration.js
// 怀旧弹珠台 · 台面几何 + 物理核心 集成测试(无头模拟)
// 从 index.html 提取 PHYSICS_CORE 与 TABLE_GEOM 两个纯函数块,
// 模拟真实玩法流程,验证台面几何与物理手感:
// A. 满力发射:球沿发射道上行 -> 绕过弧形导轨 -> 左侧落入钉阵 -> 静止在落球区
// B. 弱力发射:球到不了顶部,落回弹簧待发射(同真实玩具)
// C. 连续多球:不爆炸(无 NaN)、可堆叠静止
// 运行:node dev_test_scripts/integration/test_retro_pinball_integration.js
// ============================================================
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HTML_PATH = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'retro_pinball', 'index.html');
function loadBlocks() {
const html = fs.readFileSync(HTML_PATH, 'utf-8');
const grab = (a, b) => {
const s = html.indexOf(a), e = html.indexOf(b);
if (s < 0 || e < 0 || e <= s) throw new Error('未找到标记块: ' + a);
return html.slice(s, e);
};
const code =
grab('/* ==PHYSICS_CORE_START== */', '/* ==PHYSICS_CORE_END== */') + '\n' +
grab('/* ==TABLE_GEOM_START== */', '/* ==TABLE_GEOM_END== */');
const sandbox = { Math, console };
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
return sandbox;
}
const sb = loadBlocks();
const PC = sb.PhysicsCore;
const { W, H, LANE_X, BALL_R, SLOT_TOP, SLOT_X0, SLOT_X1, SPRING_X, SPRING_TOP, G_PX, buildTableStatics, slotIndexAt, WIPERS, makeWiperSegs, updateWiperSeg } = sb;
let passed = 0, failed = 0;
function check(name, cond, detail) {
if (cond) { passed++; console.log(` [PASS] ${name}`); }
else { failed++; console.error(` [FAIL] ${name}${detail ? ' -> ' + detail : ''}`); }
}
const DT = 1 / 360;
function defaultParams() {
return {
g: G_PX * Math.sin(14 * Math.PI / 180),
e: 0.55, mu: 0.12, airK: 0.02, rollR: 0.25,
restV: 50, iterations: 8, slop: 0.3, correction: 0.8, maxV: 4200
};
}
function simulate(v0, seconds, seedVx) {
const table = buildTableStatics(5, 1);
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -v0;
b.vx = seedVx || 0;
const trace = { minY: 1e9, enteredLeft: false, maxX: -1e9 };
for (let t = 0; t < seconds; t += DT) {
PC.stepWorld([b], table.statics, defaultParams(), DT);
trace.minY = Math.min(trace.minY, b.y);
trace.maxX = Math.max(trace.maxX, b.x);
if (b.x < 90 && b.y > 200 && b.y < 450) trace.enteredLeft = true;
if (!isFinite(b.x) || !isFinite(b.y)) { trace.nan = true; break; }
}
trace.ball = b;
return trace;
}
// ---------- A. 满力发射完整一圈 ----------
(function testFullLaunch() {
console.log('A) 满力发射:绕弧 -> 左侧落入 -> 静止落球区');
const tr = simulate(2900, 10, 5);
check('无 NaN', !tr.nan);
// 球沿拱形内侧绕行,满力时球心最低约 y≈90(拱顶 y=50 + 球半径 + 脱弧抛体),
// 故以「越过发射道顶(y=190)进入拱形区」为到达判据
check(`到达拱形区 minY=${tr.minY.toFixed(0)} < 150`, tr.minY < 150, `minY=${tr.minY}`);
check('从左侧落入钉阵', tr.enteredLeft);
const b = tr.ball;
const sp = Math.hypot(b.vx, b.vy);
check(`10s 后基本静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
check(`静止在落球区 y=${b.y.toFixed(0)} > 500`, b.y > 500, `y=${b.y}`);
check(`静止在台面内 x=${b.x.toFixed(0)} in [18,378]`, b.x >= SLOT_X0 && b.x <= SLOT_X1, `x=${b.x}`);
console.log(` -> 落点坑位: ${slotIndexAt(b.x, b.y)}`);
})();
// ---------- B. 弱力发射落回弹簧 ----------
(function testWeakLaunch() {
console.log('B) 弱力发射:到不了顶部,落回发射道');
const tr = simulate(1400, 5, 0);
check('无 NaN', !tr.nan);
check(`到不了顶部 minY=${tr.minY.toFixed(0)} > 200`, tr.minY > 200, `minY=${tr.minY}`);
const b = tr.ball;
check(`落回发射道 x=${b.x.toFixed(0)} > ${LANE_X}`, b.x > LANE_X, `x=${b.x}`);
check(`停在弹簧附近 y=${b.y.toFixed(0)} > 650`, b.y > 650, `y=${b.y}`);
})();
// ---------- C. 连续多球不爆炸、可堆叠 ----------
(function testMultiBall() {
console.log('C) 连续 4 球:数值稳定、落坑堆叠');
const table = buildTableStatics(5, 1);
const balls = [];
let nan = false;
for (let n = 0; n < 4; n++) {
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -2900;
b.vx = (n % 2 === 0 ? -1 : 1) * 8; // 微小扰动让落点分散
balls.push(b);
// 模拟到该球基本静止(最多 9s),期间所有球一起步进
for (let t = 0; t < 9; t += DT) {
PC.stepWorld(balls, table.statics, defaultParams(), DT);
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
const sp = Math.hypot(b.vx, b.vy);
if (t > 2 && sp < 15) break;
}
}
check('无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1);
check(`至少 3 球静止在落球区(实际 ${settled.length})`, settled.length >= 3, `settled=${settled.length}`);
// 堆叠检查:同一坑允许多球且互不重叠穿透
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
const d = Math.hypot(balls[i].x - balls[j].x, balls[i].y - balls[j].y);
if (d < (BALL_R * 2) - 2) check(`球${i}/${j} 不深叠 d=${d.toFixed(1)}`, false, `d=${d}`);
}
}
check('多球间距正常', true);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- E. 力度曲线:20%/60% 蓄力都能进盘面(与游戏层 launch() 同式映射) ----------
(function testChargeCurve() {
console.log('E) 力度曲线:20%/60% 蓄力均进盘面');
const L = 2900, ENTER_V = 2400;
const map = ch => ch <= 0.2 ? 1000 + (ch / 0.2) * (ENTER_V - 1000) : ENTER_V + ((ch - 0.2) / 0.8) * (L - ENTER_V);
for (const ch of [0.2, 0.6]) {
const tr = simulate(map(ch), 10, 5);
const b = tr.ball;
// 进盘面判据:越过发射道顶(y=190);弱力对照 B 的 minY=491 远大于此
check(`蓄力${(ch * 100).toFixed(0)}% v0=${map(ch).toFixed(0)} 进入盘面`, tr.minY < 190, `minY=${tr.minY}`);
check(`蓄力${(ch * 100).toFixed(0)}% 静止落球区`, Math.hypot(b.vx, b.vy) < 30 && b.y > 500 && b.x <= SLOT_X1, `(${b.x.toFixed(0)},${b.y.toFixed(0)})`);
}
})();
// 拨片回合模拟:3 连满力发射 + 3s 沉降冷却(同真实回合等静止结算)
function runWiperRound(spin) {
const table = buildTableStatics(5, 1);
const wsegs = makeWiperSegs();
const statics = table.statics.concat(wsegs);
const balls = [];
let nan = false;
let t = 0;
const step = () => {
t += DT;
for (let i = 0; i < wsegs.length; i++) updateWiperSeg(wsegs[i], WIPERS[i], t, spin);
PC.stepWorld(balls, statics, defaultParams(), DT);
for (const x of balls) if (!isFinite(x.x) || !isFinite(x.y)) nan = true;
};
for (let n = 0; n < 3; n++) {
const b = PC.createBall(SPRING_X, SPRING_TOP - BALL_R, BALL_R);
b.vy = -2900; b.vx = 5;
balls.push(b);
for (let s = 0; s < 10; s += DT) {
step();
const sp = Math.hypot(b.vx, b.vy);
if (s > 2 && sp < 15) break;
}
}
for (let s = 0; s < 3; s += DT) step(); // 沉降冷却
return { balls, nan };
}
// ---------- F. 雨刮拨片:运动碰撞体数值稳定、球最终落下静止 ----------
(function testWipers() {
console.log('F) 雨刮拨片开:3 连满力发射稳定、无 NaN、落底静止');
const { balls, nan } = runWiperRound(false);
check('拨片无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
check(`拨片下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- G. 防穿透回归:maxV=4200 垂直砸向胶囊拨片不得穿模 ----------
(function testNoTunnel() {
console.log('G) 4200px/s 垂直砸拨片不穿透');
const seg = { type: 'seg', x1: 90, y1: 300, x2: 210, y2: 300, thk: 4, mov: { px: 150, py: 300, omega: 0 } };
const b = PC.createBall(150, 100, BALL_R);
b.vy = 4200;
let maxY = -1e9;
for (let t = 0; t < 1.5; t += DT) {
PC.stepWorld([b], [seg], defaultParams(), DT);
maxY = Math.max(maxY, b.y);
}
// 未穿透:球心不得越过 线y-(r+thk)+slop 容忍带(300-15+0.3≈285)
check(`maxY=${maxY.toFixed(1)} < 292(未穿到线下)`, maxY < 292, `maxY=${maxY}`);
})();
// ---------- H. 转动模式:3 连满力发射稳定、无 NaN、落底静止 ----------
(function testSpin() {
console.log('H) 转动模式:3 连满力发射稳定');
const { balls, nan } = runWiperRound(true);
check('转动无 NaN', !nan);
const settled = balls.filter(b => Math.hypot(b.vx, b.vy) < 30 && b.y > 400);
check(`转动下 3 球全部落底静止(实际 ${settled.length}/3)`, settled.length === 3, `settled=${settled.length}`);
console.log(' -> 落点: ' + balls.map(b => `(${b.x.toFixed(0)},${b.y.toFixed(0)})`).join(' '));
})();
// ---------- D. 无卡球缝:旧缝隙位置(378..398)垂直落球必须落底静止 ----------
(function testNoWedge() {
console.log('D) 无卡球缝:旧缝隙 x=388/395 垂直落球落底静止');
const table = buildTableStatics(5, 1);
for (const x0 of [388, 395]) {
const b = PC.createBall(x0, 400, BALL_R);
for (let t = 0; t < 5; t += DT) PC.stepWorld([b], table.statics, defaultParams(), DT);
const sp = Math.hypot(b.vx, b.vy);
check(`x0=${x0} 落底 y=${b.y.toFixed(0)} > 600`, b.y > 600, `y=${b.y}`);
check(`x0=${x0} 静止 sp=${sp.toFixed(1)} < 30`, sp < 30, `sp=${sp}`);
}
})();
console.log(`\n结果: ${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);