/* ============================================================ * test_physics_balls_physics.js · 物理弹球 Web 复刻版 · 物理核心单元测试 * 运行:node dev_test_scripts/unit/test_physics_balls_physics.js * 覆盖:静止不沉 / 能量不增 / 堆叠稳定 / 高速不穿透 / 休眠 / 碰撞流形 / 整局模拟无 NaN * ============================================================ */ 'use strict'; const fs = require('fs'); const path = require('path'); const vm = require('vm'); const LAB_DIR = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls'); function load(name, sandbox) { const src = fs.readFileSync(path.join(LAB_DIR, name), 'utf-8'); vm.runInContext(src, sandbox, { filename: name }); } // pb_game 运行时依赖 PBAudio,注入静音桩 const sandbox = { console, Math, Date, PBAudio: { fire() {}, hit() {}, wall() {}, breakBlock() {}, item() {}, over() {}, claim() {}, unlock() {} }, PBStorage: { getEscape() { return 50; } } }; vm.createContext(sandbox); load('pb_physics.js', sandbox); load('pb_rules.js', sandbox); load('pb_game.js', sandbox); const P = sandbox.PBPhysics; const R = sandbox.PBRules; const G = sandbox.PBGame; const P2 = P, G2 = G, R2 = R; let pass = 0, fail = 0; function t(name, fn) { try { fn(); pass++; console.log(' ✓ ' + name); } catch (e) { fail++; console.error(' ✗ ' + name + ' -> ' + e.message); } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'assert failed'); } function near(a, b, eps, msg) { if (Math.abs(a - b) > eps) throw new Error((msg || 'near') + `: ${a} vs ${b}`); } const PHYS = R.PHYS; console.log('== 物理弹球 物理核心单元测试 =='); t('球落在静态地面上:5s 后静止且不沉陷', () => { const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1500, r: 1000, isStatic: true, friction: 0.3 }); const ball = P.makeCircle({ shape: 'circle', x: 200, y: 400, r: 7, friction: 0.2, restitution: 0.3 }); for (let i = 0; i < 360 * 5; i++) P.stepWorld([ground, ball], PHYS, 1 / 360, {}); const surf = 1500 - 1000; assert(Math.abs(ball.y - (surf - 7)) < 3, 'ball y=' + ball.y + ' expect ~' + (surf - 7)); assert(ball.sleeping, 'ball should sleep'); }); t('能量不增:自由落体反弹后机械能 <= 初始', () => { const ground = P.makeCircle({ shape: 'circle', x: 200, y: 1600, r: 1000, isStatic: true }); const ball = P.makeCircle({ shape: 'circle', x: 200, y: 300, r: 7, restitution: 0.5 }); const E0 = PHYS.g * (1600 - 1000 - 7 - 300); let maxE = -1e9; for (let i = 0; i < 360 * 3; i++) { P.stepWorld([ground, ball], PHYS, 1 / 360, {}); const h = (600 - 7) - ball.y; const E = PHYS.g * h + 0.5 * (ball.vx * ball.vx + ball.vy * ball.vy); if (E > maxE) maxE = E; } assert(maxE <= E0 * 1.05, 'energy grew: ' + maxE + ' > ' + E0); }); t('静态砖块受球撞击后位置不变(锁死固定)', () => { const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true, friction: 0.35, restitution: 0.7 }); const ball = P.makeCircle({ shape: 'circle', x: 200, y: 250, r: 7, restitution: 0.7 }); ball.vy = 800; let pairs = {}; for (let i = 0; i < 360; i++) pairs = P.stepWorld([box, ball], PHYS, 1 / 360, pairs).pairs; assert(Math.abs(box.x - 200) < 0.01 && Math.abs(box.y - 300) < 0.01, 'static block moved'); // 球可能多次反弹后停在砖块顶部(物理正确),但不应穿透 assert(ball.y > 250, 'ball should not penetrate block, y=' + ball.y); }); t('高速球不穿透薄墙(3600px/s)', () => { const wall = P.makePoly({ shape: 'poly', verts: P.boxVerts(8, 400), x: 16, y: 400, angle: 0, isStatic: true }); const ball = P.makeCircle({ shape: 'circle', x: 300, y: 400, r: 7 }); ball.vx = -3600; let pairs = {}; for (let i = 0; i < 360 * 2; i++) pairs = P.stepWorld([wall, ball], PHYS, 1 / 360, pairs).pairs; assert(ball.x > 16 + 4, 'ball tunneled: x=' + ball.x); }); t('circle-poly 流形法线方向正确(圆在多边形上方)', () => { const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(60, 20), x: 100, y: 100, isStatic: true }); const ball = P.makeCircle({ shape: 'circle', x: 100, y: 85, r: 7 }); const m = P.collide(ball, box); assert(m, 'should collide'); // 法线 a→b:ball 是 a,box 是 b,法线应向下(ny>0) assert(m.ny > 0.9, 'normal ny=' + m.ny); }); t('poly-poly 接触产生流形(两方块叠放)', () => { const a = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 100, isStatic: true }); const b = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 100, y: 71 }); const m = P.collide(a, b); assert(m, 'should collide'); assert(m.points.length >= 1, 'manifold points'); }); t('新接触事件:球撞静态块触发一次 event', () => { const box = P.makePoly({ shape: 'poly', verts: P.boxVerts(30, 30), x: 200, y: 300, isStatic: true }); const ball = P.makeCircle({ shape: 'circle', x: 200, y: 100, r: 7 }); ball.vy = 800; let pairs = {}, events = 0; for (let i = 0; i < 360; i++) { const res = P.stepWorld([box, ball], PHYS, 1 / 360, pairs); pairs = res.pairs; events += res.events.length; } assert(events >= 1, 'no hit event'); }); t('规则层:makeRow 的 hp 在回合区间内', () => { for (let n = 1; n <= 30; n++) { const [lo, hi] = R.hpRangeForTurn(n); const row = R.makeRow(n, 700, 5); row.forEach(d => { if (d.item) return; assert(d.hp >= lo && d.hp <= hi, 'hp out of range n=' + n); }); } }); t('规则层:两侧随机贴边(有封边也有留口,块不穿墙)', () => { const WL = R.FIELD.wallL, WR = R.FIELD.wallR; let leftClosed = 0, rightClosed = 0, leftOpen = 0, rightOpen = 0; for (let n = 1; n <= 80; n++) { const row = R.makeRow(n, 700, 5); const hasL = row.some(d => !d.item && d.x <= WL + 26); const hasR = row.some(d => !d.item && d.x >= WR - 26); if (hasL) leftClosed++; else leftOpen++; if (hasR) rightClosed++; else rightOpen++; row.forEach(d => { if (d.item) return; assert(d.x >= WL + 19.5 && d.x <= WR - 19.5, 'block out of wall x=' + d.x); }); } assert(leftClosed > 10 && rightClosed > 10, 'sides never closed'); assert(leftOpen > 5 && rightOpen > 5, 'sides never open'); }); t('规则层:同行块严格无重叠(允许紧贴,含道具)', () => { for (let n = 1; n <= 60; n++) { const row = R.makeRow(n, 700, 5); for (let i = 0; i < row.length; i++) { for (let j = i + 1; j < row.length; j++) { const a = row[i], b = row[j]; const ca = a.item ? 12 : R.boundRadius(a.shape); const cb = b.item ? 12 : R.boundRadius(b.shape); const d = Math.hypot(a.x - b.x, a.y - b.y); assert(d >= ca + cb - 1e-6, 'overlap d=' + d.toFixed(1) + ' need>=' + (ca + cb).toFixed(1)); } } } }); t('规则层:相邻行块也无重叠(行距52,含抖动)', () => { for (let round = 0; round < 10; round++) { const avoid = [], all = []; for (let n = 1; n <= 6; n++) { const y = 792 - (n - 1) * 52; const row = R.makeRow(n, y, 5, avoid); row.forEach(d => { const cr = d.item ? 12 : R.boundRadius(d.shape); avoid.push({ x: d.x, y: d.y, cr }); all.push({ x: d.x, y: d.y, cr }); }); } for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) { const d = Math.hypot(all[i].x - all[j].x, all[i].y - all[j].y); assert(d >= all[i].cr + all[j].cr - 1e-6, 'cross-row overlap'); } } }); t('规则层:奖励球紧贴混排在卡片旁(>=80% 相邻)', () => { let items = 0, adjacent = 0; for (let n = 1; n <= 60; n++) { const row = R.makeRow(n, 700, 5); const blocks = row.filter(d => !d.item); row.forEach(d => { if (!d.item) return; items++; if (blocks.some(b => Math.hypot(b.x - d.x, b.y - d.y) < R.boundRadius(b.shape) + 12 + 8)) adjacent++; }); } assert(items > 10, 'too few items: ' + items); assert(adjacent / items >= 0.8, 'adjacent ratio ' + (adjacent / items).toFixed(2)); }); t('游戏层:奖励球静态不滚落(3s 位置不变)', () => { const g = G2.create(); G2.startGame(g); const before = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|'); for (let i = 0; i < 180; i++) G2.update(g, 1 / 60); const after = g.items.map(it => it.x.toFixed(1) + ',' + it.y.toFixed(1)).join('|'); assert(before === after, 'items moved: ' + after); }); t('整局模拟 20s:发射/回收/回合推进无 NaN 无异常', () => { const g = G2.create(); G2.startGame(g); g.aimAngle = 0.3; let fired = 0; for (let i = 0; i < 60 * 40; i++) { if (g.state === 'aim' && g.queue.length && i % 60 === 0) { g.aimAngle = (Math.random() - 0.5) * 1.6; G2.fire(g); fired++; } G2.update(g, 1 / 60); if (g.state === 'over') break; } assert(fired >= 3, 'should fire several turns, fired=' + fired); const all = g.blocks.concat(g.items, g.balls); assert(all.every(b => isFinite(b.x) && isFinite(b.y) && isFinite(b.angle)), 'NaN bodies'); assert(g.turn >= 3, 'turns should advance, turn=' + g.turn); }); t('回收机制:角落球进入输送带并回到队列', () => { const g = G2.create(); G2.startGame(g); // 手动塞一个球到左角落 const b = P2.makeCircle({ shape: 'circle', x: R2.FIELD.wallL + 10, y: 800, r: 7, tag: 'ball' }); b.data = { big: false }; g.balls.push(b); g.state = 'fly'; for (let i = 0; i < 60 * 3; i++) G2.update(g, 1 / 60); assert(g.recycle.length === 0 && g.balls.length === 0, 'ball should be recycled back to queue'); assert(g.queue.length >= 1, 'queue should get ball back'); }); console.log(`\n结果: ${pass} 通过, ${fail} 失败`); process.exit(fail ? 1 : 0);