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,430 @@
/* ============================================================
* pb_game.js · 物理弹球 Web 复刻版 · 游戏状态机
* 回合流程:aim(拖拽瞄准) -> firing(连发) -> fly(等全部回收)
* -> rise(砖块上升+生成新行) -> aim ... 砖块越顶线 = over
* 球回收:滚入底部两角 -> 侧边输送带沿壁推回顶部 -> 回到待发队列
* 计分:小球击中 +1 / 大球击中 +2(同时为砖块伤害)
* ============================================================ */
var PBGame = (function () {
'use strict';
var R = PBRules, P = PBPhysics, F = R.FIELD;
var RISE_PX = 52; // 每回合上升高度
var RISE_TIME = 0.5; // 上升动画时长
var ROW_Y = 792; // 新行生成中心 y
var RECYCLE_V = 950; // 输送带上升速度
var CORNER_X = 34; // 角落回收判定宽
var CORNER_Y = 770; // 角落回收判定 y
function create() {
var g = {
state: 'idle', // idle|aim|firing|fly|rise|over
turn: 1,
score: 0,
secs: 0,
paused: false,
bigTurn: false, // 大球生效中:此后入队的球为大球 ⚽️(伤害2)
queue: [], // 待发队列:每元素为大小标记(false=小球 true=大球⚽️)
queueBonus: 0,
balls: [], // 场内飞行球 body
recycle: [], // {x,y,side,phase}
blocks: [], // body
items: [], // body
walls: [],
particles: [],
pairs: {},
riseT: 0,
fireTimer: 0,
aimAngle: 0, // 相对垂直方向(rad,左负右正)
aiming: false,
overInfo: null,
volley: 0, // 本回合待发快照数(防回收回队造成无限连发)
time: 0
};
buildWalls(g);
return g;
}
// ---- 静态边界:左右壁 + 顶壁 + 漏斗斜壁 + 底部大圆弧 ----
function buildWalls(g) {
var seg = function (x1, y1, x2, y2) {
// 细长按矩形刚体表示,保证多边形碰撞稳定
var dx = x2 - x1, dy = y2 - y1;
var len = Math.sqrt(dx * dx + dy * dy);
var b = P.makePoly({
shape: 'poly',
verts: P.boxVerts(len, 8),
x: (x1 + x2) / 2, y: (y1 + y2) / 2,
angle: Math.atan2(dy, dx),
isStatic: true, friction: 0.1, restitution: 0.3, tag: 'wall'
});
return b;
};
g.walls.push(seg(F.wallL - 4, F.topY, F.wallL - 4, F.H + 40)); // 左壁
g.walls.push(seg(F.wallR + 4, F.topY, F.wallR + 4, F.H + 40)); // 右壁
g.walls.push(seg(F.wallL, F.topY - 4, F.wallR, F.topY - 4)); // 顶壁
var fn = R.funnelSegs();
g.walls.push(seg(fn[0].x1, fn[0].y1, fn[0].x2, fn[0].y2)); // 漏斗左
g.walls.push(seg(fn[1].x1, fn[1].y1, fn[1].x2, fn[1].y2)); // 漏斗右
// 底部大圆弧(静态圆)
g.walls.push(P.makeCircle({ shape: 'circle', x: F.arcCx, y: F.arcCy, r: F.arcR, isStatic: true, friction: 0.15, restitution: 0.25, tag: 'wall' }));
}
// ---- 砖块 / 道具 刚体 ----
function makeBlock(desc) {
var r = R.shapeRadius(desc.shape);
var body;
if (desc.shape === 'circle') {
body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: r, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
} else {
var n = desc.shape === 'box' ? 4 : (desc.shape === 'tri' ? 3 : 5);
var verts = n === 4 ? P.boxVerts(r * 1.7, r * 1.7) : P.regularVerts(n, r, 0);
body = P.makePoly({ shape: 'poly', verts: verts, x: desc.x, y: desc.y, angle: desc.angle || 0, isStatic: true, friction: 0.35, restitution: 0.95, tag: 'block' });
}
body.data = { hp: desc.hp, shape: desc.shape, r: r };
return body;
}
function makeItem(desc) {
// 静态刚体:奖励球固定在卡片中间,不随重力滚落
var body = P.makeCircle({ shape: 'circle', x: desc.x, y: desc.y, r: R.ITEMS[desc.item].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' });
body.data = { type: desc.item };
return body;
}
function spawnRow(g, n, y) {
// 已有块作为 avoid,保证新行与旧行严格无重叠
var avoid = [];
var i;
for (i = 0; i < g.blocks.length; i++) avoid.push({ x: g.blocks[i].x, y: g.blocks[i].y, cr: R.boundRadius(g.blocks[i].data.shape) });
for (i = 0; i < g.items.length; i++) avoid.push({ x: g.items[i].x, y: g.items[i].y, cr: g.items[i].r });
var descs = R.makeRow(n, y, R.randInt(4, 6), avoid);
for (i = 0; i < descs.length; i++) {
if (descs[i].item) g.items.push(makeItem(descs[i]));
else g.blocks.push(makeBlock(descs[i]));
}
}
// ---- 开局 ----
function startGame(g) {
g.state = 'aim';
g.turn = 1;
g.score = 0;
g.secs = 0;
g.bigTurn = false;
g.queue = [false];
g.queueBonus = 0;
g.balls = [];
g.recycle = [];
g.blocks = [];
g.items = [];
g.particles = [];
g.pairs = {};
g.overInfo = null;
// 开局 4 行(复刻原版开局视觉密度),行距 56
for (var n = 1; n <= 4; n++) spawnRow(g, n, ROW_Y - (n - 1) * 56);
}
// ---- 发射 ----
function fire(g) {
if (g.state !== 'aim' || !g.queue.length || g.paused) return;
g.state = 'firing';
g.fireTimer = 0;
g.volley = g.queue.length; // 快照本回合发射数:期间回收的球存入下回合,不续进本 volley
}
function launchOne(g) {
var big = !!g.queue.shift() || g.bigTurn; // 逐球标记或大球回合徽章,与预览/待发显示同口径
var r = big ? R.BIG_R : R.BALL_R;
var dirX = Math.sin(g.aimAngle), dirY = Math.cos(g.aimAngle);
var b = P.makeCircle({
shape: 'circle',
x: F.arcCx, y: F.checkY - 6,
r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
});
b.vx = dirX * R.FIRE_SPEED;
b.vy = dirY * R.FIRE_SPEED;
b.data = { big: big };
g.balls.push(b);
PBAudio.fire();
}
// ---- 砖块伤害(击中 / 夹角研磨 共用) ----
function damageBlock(g, block, dmg, showFloat) {
block.data.hp -= dmg;
g.score += dmg;
if (showFloat) floatText(g, block.x, block.y - block.data.r - 8, '+' + dmg);
if (block.data.hp <= 0) {
var idx = g.blocks.indexOf(block);
if (idx >= 0) g.blocks.splice(idx, 1);
burst(g, block.x, block.y, PBRules.hpColor(1));
PBAudio.breakBlock();
return true;
}
return false;
}
// ---- 击中处理 ----
function onHit(g, ball, other, speed) {
if (other.tag === 'block') {
damageBlock(g, other, ball.data.big ? 2 : 1, true);
PBAudio.hit(speed);
} else if (other.tag === 'item') {
var j = g.items.indexOf(other);
if (j >= 0) g.items.splice(j, 1);
if (other.data.type === 'ball') {
g.queue.push(!!g.bigTurn); // 大球生效中入队即为 ⚽️
floatText(g, other.x, other.y, '+1 球');
} else {
g.bigTurn = true;
floatText(g, other.x, other.y, '大球!');
}
burst(g, other.x, other.y, '#ffffff');
PBAudio.item();
} else if (other.tag === 'wall') {
PBAudio.wall();
}
}
function burst(g, x, y, color) {
for (var i = 0; i < 10; i++) {
var a = Math.random() * Math.PI * 2;
var sp = 120 + Math.random() * 260;
g.particles.push({
x: x, y: y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 60,
life: 0.5 + Math.random() * 0.3, t: 0,
size: 3 + Math.random() * 5, rot: Math.random() * 6, vr: (Math.random() - 0.5) * 10,
color: color, kind: 'tri'
});
}
}
function floatText(g, x, y, text) {
g.particles.push({ x: x, y: y, vx: 0, vy: -70, life: 0.9, t: 0, size: 13, color: '#ffd83d', kind: 'text', text: text });
}
// ---- 卡球逃脱(只许下落,不许上天) ----
// 球陷砖块缝隙/谷地减速 0.5s 后,朝更开阔的一侧水平推挤,让球滑出缺口继续下落。
// 禁止向上弹射——球唯一的"上去"方式是落到底部后由输送带送回。
function gapDir(g, b) {
var left = b.x - F.wallL, right = F.wallR - b.x;
for (var i = 0; i < g.blocks.length; i++) {
var o = g.blocks[i];
var cr = R.boundRadius(o.data.shape) + b.r + 2;
var dy = Math.abs(o.y - b.y);
if (dy >= cr) continue;
var dx = Math.sqrt(cr * cr - dy * dy);
if (o.x < b.x) left = Math.min(left, b.x - o.x - dx);
else right = Math.min(right, o.x - b.x - dx);
}
if (Math.abs(left - right) < 24) return Math.random() < 0.5 ? -1 : 1; // 势均力敌时随机
return left > right ? -1 : 1;
}
function checkStuck(g, dt) {
for (var i = 0; i < g.balls.length; i++) {
var b = g.balls[i];
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
b.stuckT = sp < 60 ? (b.stuckT || 0) + dt : 0;
if (b.stuckT > 0.5 && b._touchBlock) {
// 夹角(>=2 接触面)交给研磨开空间,不施加横向推力,避免挤压上弹
var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
if (surfaces >= 2) continue;
var dir = gapDir(g, b);
// 逃逸强度(设置页滑块 0-100):水平推力 250~1750
var hs = 250 + 12 * PBStorage.getEscape() + Math.random() * 300;
b.vx = dir * hs + (Math.random() - 0.5) * 120;
b.vy = (Math.random() - 0.5) * 120; // 仅微小扰动,不向上弹射
b.stuckT = 0;
P.wake(b);
}
}
}
// ---- 夹角研磨:陷入夹角后以最高击碎能力快速消耗接触砖块 ----
// 楔入条件:低速且同时接触 >=2 个面(块+块 或 块+墙)。
// 每 0.1s 对每块接触砖造成高额研磨伤害(随逃逸强度 2~6),
// 快速打碎砖块留出空间,球自然下落逃逸。
var GRIND_TICK = 0.1;
function checkGrind(g, dt) {
var dmg = 2 + Math.round(PBStorage.getEscape() / 25);
for (var i = 0; i < g.balls.length; i++) {
var b = g.balls[i];
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
var surfaces = b._touchBlocks.length + (b._touchWall ? 1 : 0);
var inWedge = sp < 80 && b._touchBlocks.length >= 1 && surfaces >= 2;
if (!inWedge) { b.grindT = 0; continue; }
b.grindT = (b.grindT || 0) + dt;
while (b.grindT >= GRIND_TICK) {
b.grindT -= GRIND_TICK;
for (var k = 0; k < b._touchBlocks.length; k++) {
damageBlock(g, b._touchBlocks[k], dmg, false); // 研磨不飘字,避免刷屏
}
PBAudio.hit(300);
}
}
}
// ---- 回收 ----
// 入角即收;停在底部弧面(不碰砖块)超 1.2s 也收,保证回合能结束
function checkRecycle(g, dt) {
for (var i = g.balls.length - 1; i >= 0; i--) {
var b = g.balls[i];
var left = b.x < F.wallL + CORNER_X;
var right = b.x > F.wallR - CORNER_X;
var bottomRest = (b.stuckT || 0) > 1.2 && b.y > CORNER_Y && !b._touchBlock;
if (((left || right) && b.y > CORNER_Y) || bottomRest) {
var side = left ? 0 : (right ? 1 : (b.x < F.arcCx ? 0 : 1));
g.recycle.push({ x: b.x, y: b.y, side: side, big: b.data.big, phase: 0 });
g.balls.splice(i, 1);
}
}
}
function updateRecycle(g, dt) {
for (var i = g.recycle.length - 1; i >= 0; i--) {
var r = g.recycle[i];
var off = r.big ? 17 : 13; // 大球半径大,输送带偏移相应加大
var laneX = r.side === 0 ? F.wallL + off : F.wallR - off;
if (r.phase === 0) {
// 先水平滑入输送带
var dx = laneX - r.x;
var step = 700 * dt;
r.x += Math.abs(dx) <= step ? dx : Math.sign(dx) * step;
if (r.x === laneX) r.phase = 1;
} else {
r.y -= RECYCLE_V * dt;
}
if (r.phase === 1 && r.y < F.checkY + 6) {
g.recycle.splice(i, 1);
g.queue.push(!!(r.big || g.bigTurn)); // 大球回收仍为大球;小球在大球生效中回队升级
}
}
}
// ---- 回合结束 ----
function endTurn(g) {
g.turn++;
g.bigTurn = false;
g.state = 'rise';
g.riseT = 0;
}
function checkOver(g) {
for (var i = 0; i < g.blocks.length; i++) {
var b = g.blocks[i];
var top = b.shape === 'circle' ? b.y - b.r : aabbTop(b);
if (top < F.checkY) return true;
}
return false;
}
function aabbTop(b) {
var vs = P.worldVerts(b);
var m = 1e9;
for (var i = 0; i < vs.length; i++) if (vs[i].y < m) m = vs[i].y;
return m;
}
function gameOver(g) {
g.state = 'over';
PBAudio.over();
}
// ---- 主更新 ----
function update(g, dt) {
if (g.paused || g.state === 'idle' || g.state === 'over') return;
g.secs += dt;
if (g.state === 'firing') {
g.fireTimer -= dt;
if (g.fireTimer <= 0 && g.volley > 0) {
launchOne(g);
g.volley--;
g.fireTimer = R.FIRE_GAP_MS / 1000;
}
if (g.volley <= 0) g.state = 'fly';
}
if (g.state === 'rise') {
g.riseT += dt;
var k = Math.min(1, g.riseT / RISE_TIME);
var dy = RISE_PX * dt / RISE_TIME;
var i;
for (i = 0; i < g.blocks.length; i++) { g.blocks[i].y -= dy; }
for (i = 0; i < g.items.length; i++) g.items[i].y -= dy; // 静态道具随回合整体上升
if (k >= 1) {
spawnRow(g, g.turn, ROW_Y);
if (checkOver(g)) { gameOver(g); return; }
g.state = 'aim';
}
}
// 物理子步
var bodies = g.walls.concat(g.blocks, g.items, g.balls);
var sub = R.PHYS.subDt;
var acc = dt;
var bi;
for (bi = 0; bi < g.balls.length; bi++) {
g.balls[bi]._touchBlock = false;
g.balls[bi]._touchBlocks = [];
g.balls[bi]._touchWall = false;
}
while (acc > 1e-6) {
var h = Math.min(sub, acc);
var res = P.stepWorld(bodies, R.PHYS, h, g.pairs);
g.pairs = res.pairs;
// 记录本帧球接触面(砖块列表 / 墙标记,供卡球逃脱与夹角研磨)
for (var mi = 0; mi < res.manifolds.length; mi++) {
var mf = res.manifolds[mi];
var bm = mf.a.tag === 'ball' ? mf.a : (mf.b.tag === 'ball' ? mf.b : null);
var om = bm === mf.a ? mf.b : mf.a;
if (!bm) continue;
if (om.tag === 'block') {
bm._touchBlock = true;
if (bm._touchBlocks.indexOf(om) < 0) bm._touchBlocks.push(om);
} else if (om.tag === 'wall') {
bm._touchWall = true;
}
}
for (var e = 0; e < res.events.length; e++) {
var ev = res.events[e];
var ball = ev.a.tag === 'ball' ? ev.a : (ev.b.tag === 'ball' ? ev.b : null);
var other = ball === ev.a ? ev.b : ev.a;
if (ball) onHit(g, ball, other, ev.speed);
}
acc -= h;
}
checkGrind(g, dt);
checkStuck(g, dt);
checkRecycle(g, dt);
updateRecycle(g, dt);
// 粒子
for (var pi = g.particles.length - 1; pi >= 0; pi--) {
var pt = g.particles[pi];
pt.t += dt;
if (pt.t >= pt.life) { g.particles.splice(pi, 1); continue; }
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
if (pt.kind === 'tri') pt.vy += 900 * dt;
if (pt.rot !== undefined) pt.rot += pt.vr * dt;
}
if (checkOver(g)) { gameOver(g); return; }
if (g.state === 'fly' && !g.balls.length && !g.recycle.length) {
endTurn(g);
}
}
return {
create: create,
startGame: startGame,
fire: fire,
update: update,
aabbTop: aabbTop,
constants: { RISE_PX: RISE_PX, ROW_Y: ROW_Y }
};
})();