// ============================================================ // 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);