feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_dom_smoke.js · 物理弹球 · 前端 DOM 桩冒烟
|
||||
* 无浏览器环境下用最小 DOM/Canvas 桩加载全部前端脚本,
|
||||
* 驱动主循环 + 模拟拖拽发射/暂停/记录等交互,捕获运行时错误。
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_dom_smoke.js
|
||||
* ============================================================ */
|
||||
'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');
|
||||
const FILES = ['pb_physics.js', 'pb_rules.js', 'pb_storage.js', 'pb_audio.js', 'pb_render.js', 'pb_game.js', 'pb_ui.js'];
|
||||
|
||||
function makeCtx() {
|
||||
const store = {};
|
||||
return new Proxy(store, {
|
||||
get(t, k) {
|
||||
if (k === 'createRadialGradient') return () => ({ addColorStop() {} });
|
||||
if (k === 'measureText') return () => ({ width: 10 });
|
||||
if (k in t) return t[k];
|
||||
return function () {};
|
||||
},
|
||||
set(t, k, v) { t[k] = v; return true; }
|
||||
});
|
||||
}
|
||||
|
||||
function makeEl(id) {
|
||||
const listeners = {};
|
||||
return {
|
||||
id,
|
||||
style: {},
|
||||
dataset: {},
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
|
||||
children: [],
|
||||
_html: '', _text: '',
|
||||
set innerHTML(v) { this._html = v; this.children = []; },
|
||||
get innerHTML() { return this._html; },
|
||||
set textContent(v) { this._text = String(v); },
|
||||
get textContent() { return this._text; },
|
||||
appendChild(c) { this.children.push(c); },
|
||||
addEventListener(t, f) { (listeners[t] = listeners[t] || []).push(f); },
|
||||
dispatch(t, ev) { (listeners[t] || []).forEach(f => f(ev || {})); },
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 420, height: 880 }; },
|
||||
getContext() { return makeCtx(); },
|
||||
checked: true,
|
||||
width: 420, height: 880
|
||||
};
|
||||
}
|
||||
|
||||
const els = {};
|
||||
function getEl(id) { if (!els[id]) els[id] = makeEl(id); return els[id]; }
|
||||
|
||||
let rafCb = null;
|
||||
const winListeners = {};
|
||||
const sandbox = {
|
||||
console, Math, Date, setTimeout, clearTimeout,
|
||||
JSON, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
navigator: { clipboard: { writeText: () => ({ then: (a) => a && a() }) } },
|
||||
requestAnimationFrame: (cb) => { rafCb = cb; return 1; },
|
||||
document: {
|
||||
getElementById: getEl,
|
||||
createElement: (tag) => makeEl('dyn_' + tag)
|
||||
},
|
||||
addEventListener: (t, f) => { (winListeners[t] = winListeners[t] || []).push(f); },
|
||||
dispatchWin: (t, ev) => { (winListeners[t] || []).forEach(f => f(ev || {})); },
|
||||
window: null
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
|
||||
for (const f of FILES) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB_DIR, f), 'utf-8'), sandbox, { filename: f });
|
||||
}
|
||||
|
||||
function frames(n, t0) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cb = rafCb; rafCb = null;
|
||||
if (!cb) throw new Error('rAF chain broken');
|
||||
cb(t0 + i * 16.7);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('== 物理弹球 DOM 冒烟 ==');
|
||||
// init(load 事件不会在 vm 里触发,手动调)
|
||||
sandbox.PBUi.init();
|
||||
frames(30, 0);
|
||||
console.log(' ✓ init + 30 帧主菜单渲染');
|
||||
|
||||
// 模拟拖拽发射(pointermove/up 注册在 window 级)
|
||||
const canvas = getEl('pb-canvas');
|
||||
canvas.dispatch('pointerdown', { clientX: 260, clientY: 600 });
|
||||
frames(5, 600);
|
||||
sandbox.dispatchWin('pointermove', { clientX: 300, clientY: 650 });
|
||||
sandbox.dispatchWin('pointerup', {});
|
||||
frames(60 * 6, 700);
|
||||
console.log(' ✓ 拖拽发射 + 6s 模拟');
|
||||
|
||||
// 暂停 / 继续
|
||||
getEl('pb-pause-btn').dispatch('click');
|
||||
getEl('pb-resume').dispatch('click');
|
||||
frames(60, 60 * 7 * 16.7);
|
||||
console.log(' ✓ 暂停/继续');
|
||||
|
||||
// 记录页 / 指南 / 设置 / 每日领取
|
||||
getEl('pb-side-records').dispatch('click');
|
||||
getEl('pb-records-close').dispatch('click');
|
||||
getEl('pb-help').dispatch('click');
|
||||
getEl('pb-guide-ok').dispatch('click');
|
||||
getEl('pb-gear').dispatch('click');
|
||||
getEl('pb-settings-close').dispatch('click');
|
||||
getEl('pb-daily').dispatch('click');
|
||||
console.log(' ✓ 记录/指南/设置/每日领取交互');
|
||||
|
||||
// 长时模拟 60s,覆盖回合推进/上升/回收
|
||||
for (let s = 0; s < 60; s++) {
|
||||
if (s % 7 === 0) {
|
||||
canvas.dispatch('pointerdown', { clientX: 150 + (s * 13) % 200, clientY: 620 });
|
||||
sandbox.dispatchWin('pointermove', { clientX: 190 + (s * 13) % 160, clientY: 660 });
|
||||
sandbox.dispatchWin('pointerup', {});
|
||||
}
|
||||
frames(60, (10 + s) * 1000);
|
||||
}
|
||||
console.log(' ✓ 60s 长时模拟(含多次发射)');
|
||||
console.log('\n冒烟通过,无运行时异常');
|
||||
@@ -0,0 +1,52 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_preview_match.js · 物理弹球 · 预览=实际轨迹验证
|
||||
* 清空砖块后以固定角度发射,对比 simulatePath 预览点与真实球位置:
|
||||
* 瞄准折线必须与球实际运动线路一致(误差 < 2px)。
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_preview_match.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {}, claim() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js', 'pb_render.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const G = ctx.PBGame, RD = ctx.PBRender;
|
||||
|
||||
let ok = true;
|
||||
for (const ang of [0, 0.35, -0.6, 1.0]) {
|
||||
const g = G.create();
|
||||
G.startGame(g);
|
||||
g.blocks = []; g.items = []; // 清空砖块,保证对比确定
|
||||
g.aimAngle = ang;
|
||||
|
||||
const pts = RD.simulatePath(g); // 预览点:pts[k] 对应 t = k/60 s(subDt=1/240,每 4 子步采样)
|
||||
|
||||
G.fire(g);
|
||||
let maxErr = 0, checked = 0;
|
||||
for (let f = 1; f <= 30; f++) { // 前 0.5s,每帧 4 子步与预览采样对齐
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) break;
|
||||
if (f % 2) continue;
|
||||
if (f >= pts.length) break;
|
||||
const b = g.balls[0];
|
||||
const err = Math.hypot(b.x - pts[f].x, b.y - pts[f].y);
|
||||
maxErr = Math.max(maxErr, err);
|
||||
checked++;
|
||||
}
|
||||
const pass = checked >= 10 && maxErr < 2;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + 'angle=' + ang + ' checked=' + checked + ' maxErr=' + maxErr.toFixed(2) + 'px');
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,127 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_stuck_escape.js · 物理弹球 · 卡球逃脱验证
|
||||
* 规则:球陷缝隙 0.5s 后横向滑出缺口继续下落;禁止向上弹射
|
||||
* ("必须落下后才能上去",唯一上升途径是底部输送带)。
|
||||
* 验证:1) 卡球能逃脱并下落/回收 2) 上升幅度 <=120px 3) 左右方向有随机性
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_stuck_escape.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const P = ctx.PBPhysics, G = ctx.PBGame;
|
||||
|
||||
// 跑 8s:成功 = 被回收 或 下落到 startY+200 以下;同时记录最小 y(上升幅度)
|
||||
function runScenario(setup, startY) {
|
||||
const g = G.create();
|
||||
g.state = 'fly';
|
||||
setup(g);
|
||||
let minY = 1e9, maxX = 0, minX = 1e9, outcome = 'stuck';
|
||||
for (let i = 0; i < 60 * 8; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) { outcome = 'recycled'; break; }
|
||||
const b = g.balls[0];
|
||||
minY = Math.min(minY, b.y);
|
||||
maxX = Math.max(maxX, b.x);
|
||||
minX = Math.min(minX, b.x);
|
||||
if (b.y > startY + 200) { outcome = 'fell'; break; }
|
||||
}
|
||||
return { outcome, minY, spread: maxX - minX, g };
|
||||
}
|
||||
|
||||
let ok = true;
|
||||
function check(name, res, startY) {
|
||||
const escaped = res.outcome !== 'stuck';
|
||||
const noLaunch = res.minY > startY - 120; // 不允许向上弹射超过 120px
|
||||
const pass = escaped && noLaunch;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + name +
|
||||
' outcome=' + res.outcome + ' minY=' + (res.minY === 1e9 ? 'recycled' : res.minY.toFixed(1)) +
|
||||
' hSpread=' + res.spread.toFixed(0));
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
// rest 默认 0.2:隔离"逃脱冲量本身",排除高弹块自然反弹的干扰
|
||||
const mkCircle = (g, x, y, r, rest) => {
|
||||
const b = P.makeCircle({ shape: 'circle', x, y, r, isStatic: true, friction: 0.35, restitution: rest === undefined ? 0.2 : rest, tag: 'block' });
|
||||
b.data = { hp: 99, shape: 'circle', r };
|
||||
g.blocks.push(b);
|
||||
};
|
||||
const mkBall = (g, x, y) => {
|
||||
const ball = P.makeCircle({ shape: 'circle', x, y, r: 7, friction: 0.08, restitution: 0.95, tag: 'ball' });
|
||||
ball.data = { big: false };
|
||||
g.balls.push(ball);
|
||||
};
|
||||
|
||||
// 场景1:球静止放在两圆块谷地 → 应横向滑出后下落,不上天
|
||||
check('谷地卡球逃脱', runScenario((g) => {
|
||||
mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22);
|
||||
mkBall(g, 202, 381);
|
||||
}, 381), 381);
|
||||
|
||||
// 场景2:球静止放在单块顶部 → 应被推离块顶后下落;且单接触不触发研磨(块 HP 不变)
|
||||
{
|
||||
const res = runScenario((g) => {
|
||||
mkCircle(g, 200, 400, 22);
|
||||
mkBall(g, 200, 371);
|
||||
}, 371);
|
||||
check('块顶停球逃脱', res, 371);
|
||||
const intact = res.g.blocks.length === 1 && res.g.blocks[0].data.hp === 99;
|
||||
console.log((intact ? 'PASS ' : 'FAIL ') + '单接触不研磨 hp=' + (res.g.blocks[0] ? res.g.blocks[0].data.hp : 'gone'));
|
||||
ok = intact && ok;
|
||||
}
|
||||
|
||||
// 场景5:球楔入两低血块夹角 → 研磨快速打碎砖块、留出空间后下落
|
||||
{
|
||||
const res = runScenario((g) => {
|
||||
mkCircle(g, 180, 400, 22); mkCircle(g, 224, 400, 22);
|
||||
g.blocks.forEach(b => { b.data.hp = 8; });
|
||||
mkBall(g, 202, 381);
|
||||
}, 381);
|
||||
const ground = res.g.blocks.length === 0;
|
||||
const pass = ground && res.outcome !== 'stuck';
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + '夹角研磨开空间 outcome=' + res.outcome + ' blocksLeft=' + res.g.blocks.length);
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
// 场景3:球停底部弧面(无砖块接触)→ 1.2s 后应被回收
|
||||
check('底部停球回收', runScenario((g) => {
|
||||
mkBall(g, 210, 790);
|
||||
}, 790), 790);
|
||||
|
||||
// 场景4:单接触停球逃脱,横向方向应有随机性(左右都出现)
|
||||
{
|
||||
const dirs = [];
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const g = G.create();
|
||||
g.state = 'fly';
|
||||
mkCircle(g, 200, 400, 22);
|
||||
mkBall(g, 200 + (Math.random() * 4 - 2), 371); // 真实落点总有微小偏移
|
||||
let firstVx = null;
|
||||
for (let i = 0; i < 60 * 3; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (!g.balls.length) break;
|
||||
const b = g.balls[0];
|
||||
if (firstVx === null && Math.abs(b.vx) > 200) { firstVx = b.vx; break; }
|
||||
}
|
||||
if (firstVx !== null) dirs.push(firstVx > 0 ? 'R' : 'L');
|
||||
}
|
||||
const hasL = dirs.includes('L'), hasR = dirs.includes('R');
|
||||
const pass = dirs.length >= 6 && hasL && hasR;
|
||||
console.log((pass ? 'PASS ' : 'FAIL ') + '逃逸方向随机性 ' + dirs.join(''));
|
||||
ok = pass && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,47 @@
|
||||
/* ============================================================
|
||||
* debug_physics_balls_volley_loop.js · 物理弹球 · 大连发死循环回归
|
||||
* 复现场景:队列 15 球连发。旧 bug:连发期间回收球回队使 queue 永不空,
|
||||
* 状态永远停在 firing(无限连发循环,回合不推进)。
|
||||
* 断言:1) firing 在 ~3s 内结束 2) 60s 内回合推进(turn>=2 或 over)
|
||||
* 运行:node dev_test_scripts/debug/debug_physics_balls_volley_loop.js
|
||||
* ============================================================ */
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const LAB = path.join(__dirname, '..', '..', 'public', 'tools', 'thought_lab', 'labs', 'physics_balls');
|
||||
const ctx = {
|
||||
Math,
|
||||
console,
|
||||
PBAudio: { fire() {}, hit() {}, wall() {}, item() {}, breakBlock() {}, over() {} },
|
||||
PBStorage: { getEscape() { return 50; } }
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
for (const f of ['pb_physics.js', 'pb_rules.js', 'pb_game.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(LAB, f), 'utf8'), ctx, { filename: f });
|
||||
}
|
||||
|
||||
const G = ctx.PBGame;
|
||||
let ok = true;
|
||||
|
||||
for (const n of [15, 25]) {
|
||||
const g = G.create();
|
||||
G.startGame(g);
|
||||
g.queue = Array(n).fill(true); // 15/25 个大球,复现截图局面
|
||||
g.aimAngle = 0.2;
|
||||
G.fire(g);
|
||||
|
||||
let firingEnd = -1, turnAdvanced = false;
|
||||
for (let i = 1; i <= 60 * 60; i++) {
|
||||
G.update(g, 1 / 60);
|
||||
if (firingEnd < 0 && g.state !== 'firing') firingEnd = i / 60;
|
||||
if (g.turn >= 2 || g.state === 'over') { turnAdvanced = true; break; }
|
||||
}
|
||||
const p1 = firingEnd > 0 && firingEnd <= n * 0.13 + 1.5;
|
||||
console.log((p1 ? 'PASS ' : 'FAIL ') + n + '球连发 firing 结束于 ' + (firingEnd < 0 ? '未结束(死循环)' : firingEnd.toFixed(1) + 's'));
|
||||
console.log((turnAdvanced ? 'PASS ' : 'FAIL ') + n + '球连发 回合推进 turn=' + g.turn + ' state=' + g.state + ' queue=' + g.queue.length);
|
||||
ok = p1 && turnAdvanced && ok;
|
||||
}
|
||||
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -0,0 +1,236 @@
|
||||
/* ============================================================
|
||||
* 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);
|
||||
Reference in New Issue
Block a user