Files
Toolbox/dev_test_scripts/unit/test_physics_balls_physics.js
yangxiangyuan 91148fe064 feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI
2. 新增物理弹球相关调试与单元测试脚本
3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示
4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
2026-08-08 22:31:33 +08:00

237 lines
9.6 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.
/* ============================================================
* 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);