feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
Reference in New Issue
Block a user