feat: 新增物理弹球实验室及配套功能

1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI
2. 新增物理弹球相关调试与单元测试脚本
3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示
4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
yangxiangyuan
2026-08-08 22:31:33 +08:00
parent 4492b4106a
commit 91148fe064
17 changed files with 2690 additions and 22 deletions
@@ -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);