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,456 @@
/* ============================================================
* pb_physics.js · 物理弹球 Web 复刻版 · 刚体引擎
* 纯函数区:不依赖 DOM / 渲染,可被
* dev_test_scripts/unit/test_physics_balls_physics.js 直接加载做单元测试。
*
* 设计:
* - 刚体两类:circle / 凸多边形 poly(verts 为局部坐标,逆时针)
* - 碰撞检测:circle-circle / circle-poly / poly-poly(SAT 分离轴 +
* 顶点穿透流形,最多 2 接触点,足够砖块堆叠稳定)
* - 求解:序贯冲量 + 累积法向冲量钳制(≥0)+ 库仑摩擦累积钳制 +
* 恢复系数 bias(低速阈值内 e=0 防抖)+ Baumgarte 位置校正
* - 休眠:低速稳定持续一段时间 → sleeping(视同静态);
* 被高速接触/外力唤醒。砖块堆叠靠休眠防抖、省性能
* - 事件:stepWorld 返回 events(新接触对且接近速度超阈值),
* 供游戏层做「击中砖块 +分/扣血」与音效
* 单位:px、s。y 向下。
* ============================================================ */
var PBPhysics = (function () {
'use strict';
var UID = 1;
// ---------- 刚体工厂 ----------
function baseBody(o) {
var b = {
id: UID++,
shape: o.shape,
x: o.x || 0, y: o.y || 0, angle: o.angle || 0,
vx: 0, vy: 0, omega: 0,
fx: 0, fy: 0, // 累积冲量引起的速度变化由求解器直接改 vx/vy
r: o.r || 0,
verts: o.verts || null, // 局部坐标
friction: o.friction != null ? o.friction : 0.2,
restitution: o.restitution || 0,
isStatic: !!o.isStatic,
sleeping: false, sleepTimer: 0,
tag: o.tag || '', // 'ball' | 'block' | 'item' | 'wall'
data: o.data || null,
aabb: { x0: 0, y0: 0, x1: 0, y1: 0 }
};
if (b.isStatic) { b.m = 0; b.invM = 0; b.I = 0; b.invI = 0; }
return b;
}
function makeCircle(o) {
var b = baseBody(o);
b.shape = 'circle';
var m = o.r * o.r * 0.06;
if (!b.isStatic) {
b.m = m; b.invM = 1 / m;
b.I = 0.5 * m * o.r * o.r; b.invI = 1 / b.I;
}
return b;
}
function polyAreaInertia(verts) {
var area = 0, I = 0, i;
for (i = 0; i < verts.length; i++) {
var p1 = verts[i], p2 = verts[(i + 1) % verts.length];
var cross = p1.x * p2.y - p2.x * p1.y;
area += cross / 2;
I += cross / 12 * (p1.x * p1.x + p1.x * p2.x + p2.x * p2.x + p1.y * p1.y + p1.y * p2.y + p2.y * p2.y);
}
return { area: Math.abs(area), I: Math.abs(I) };
}
function makePoly(o) {
var b = baseBody(o);
b.shape = 'poly';
var ai = polyAreaInertia(o.verts);
if (!b.isStatic) {
var m = Math.max(0.5, ai.area * 0.05) * (o.massScale || 1);
b.m = m; b.invM = 1 / m;
b.I = Math.max(1, ai.I * 0.05 * (o.massScale || 1)); b.invI = 1 / b.I;
}
return b;
}
// ---------- 几何工具 ----------
function worldVerts(b) {
var c = Math.cos(b.angle), s = Math.sin(b.angle);
var out = [];
for (var i = 0; i < b.verts.length; i++) {
var v = b.verts[i];
out.push({ x: b.x + v.x * c - v.y * s, y: b.y + v.x * s + v.y * c });
}
return out;
}
function updateAabb(b) {
var a = b.aabb;
if (b.shape === 'circle') {
a.x0 = b.x - b.r; a.y0 = b.y - b.r; a.x1 = b.x + b.r; a.y1 = b.y + b.r;
} else {
var vs = worldVerts(b);
var x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9;
for (var i = 0; i < vs.length; i++) {
if (vs[i].x < x0) x0 = vs[i].x;
if (vs[i].y < y0) y0 = vs[i].y;
if (vs[i].x > x1) x1 = vs[i].x;
if (vs[i].y > y1) y1 = vs[i].y;
}
a.x0 = x0; a.y0 = y0; a.x1 = x1; a.y1 = y1;
}
}
function aabbOverlap(a, b) {
return a.aabb.x0 <= b.aabb.x1 && a.aabb.x1 >= b.aabb.x0 &&
a.aabb.y0 <= b.aabb.y1 && a.aabb.y1 >= b.aabb.y0;
}
function pointInPoly(px, py, vs) {
var sign = 0;
for (var i = 0; i < vs.length; i++) {
var a = vs[i], b = vs[(i + 1) % vs.length];
var cr = (b.x - a.x) * (py - a.y) - (b.y - a.y) * (px - a.x);
if (cr !== 0) {
var sg = cr > 0 ? 1 : -1;
if (sign === 0) sign = sg;
else if (sg !== sign) return false;
}
}
return true;
}
// ---------- 碰撞检测:返回流形 {a,b,nx,ny,points:[{x,y,pen,bias}] },法线 a→b ----------
function collideCircleCircle(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var R = a.r + b.r;
var d2 = dx * dx + dy * dy;
if (d2 >= R * R) return null;
var d = Math.sqrt(d2) || 1e-9;
var nx = dx / d, ny = dy / d;
return {
a: a, b: b, nx: nx, ny: ny,
points: [{ x: a.x + nx * a.r, y: a.y + ny * a.r, pen: R - d }]
};
}
function closestOnPoly(px, py, vs) {
var best = null, bestD2 = 1e18;
for (var i = 0; i < vs.length; i++) {
var a = vs[i], b = vs[(i + 1) % vs.length];
var dx = b.x - a.x, dy = b.y - a.y;
var L2 = dx * dx + dy * dy || 1e-9;
var t = ((px - a.x) * dx + (py - a.y) * dy) / L2;
t = t < 0 ? 0 : (t > 1 ? 1 : t);
var cx = a.x + dx * t, cy = a.y + dy * t;
var d2 = (px - cx) * (px - cx) + (py - cy) * (py - cy);
if (d2 < bestD2) { bestD2 = d2; best = { x: cx, y: cy }; }
}
return best;
}
function collideCirclePoly(circle, poly, swap) {
var vs = worldVerts(poly);
var inside = pointInPoly(circle.x, circle.y, vs);
var c = closestOnPoly(circle.x, circle.y, vs);
var dx = circle.x - c.x, dy = circle.y - c.y;
var d2 = dx * dx + dy * dy;
var d = Math.sqrt(d2) || 1e-9;
var pnx, pny, pen;
if (inside) {
// 圆心在多边形内:推出方向 = 圆心指向最近边界点
pnx = -dx / d; pny = -dy / d; pen = circle.r + d;
} else {
if (d >= circle.r) return null;
// 多边形→圆 = 最近点指向圆心
pnx = dx / d; pny = dy / d; pen = circle.r - d;
}
// 法线约定 a→b;swap=true 表示 a=circle,b=poly
var m = { points: [{ x: c.x, y: c.y, pen: pen }] };
if (!swap) { m.a = poly; m.b = circle; m.nx = pnx; m.ny = pny; }
else { m.a = circle; m.b = poly; m.nx = -pnx; m.ny = -pny; }
return m;
}
function centroid(vs) {
var cx = 0, cy = 0;
for (var i = 0; i < vs.length; i++) { cx += vs[i].x; cy += vs[i].y; }
return { x: cx / vs.length, y: cy / vs.length };
}
// 第 i 条边的单位外法线(与顶点绕序无关)
function edgeOutNormal(vs, i) {
var a1 = vs[i], a2 = vs[(i + 1) % vs.length];
var nx = -(a2.y - a1.y), ny = a2.x - a1.x;
var L = Math.sqrt(nx * nx + ny * ny) || 1e-9;
nx /= L; ny /= L;
var c = centroid(vs);
var mx = (a1.x + a2.x) / 2, my = (a1.y + a2.y) / 2;
if ((mx - c.x) * nx + (my - c.y) * ny < 0) { nx = -nx; ny = -ny; }
return { x: nx, y: ny };
}
// SAT 最大分离轴:返回 {sep, i}(A 的第 i 条边外法线方向上 B 相对 A 的分离量;sep>0 已分离)
function maxSeparation(va, vb) {
var best = -1e18, bi = 0;
for (var i = 0; i < va.length; i++) {
var n = edgeOutNormal(va, i);
var a1 = va[i];
var minProj = 1e18;
for (var j = 0; j < vb.length; j++) {
var pr = (vb[j].x - a1.x) * n.x + (vb[j].y - a1.y) * n.y;
if (pr < minProj) minProj = pr;
}
if (minProj > best) { best = minProj; bi = i; }
}
return { sep: best, i: bi };
}
function collidePolyPoly(a, b) {
var va = worldVerts(a), vb = worldVerts(b);
var sa = maxSeparation(va, vb);
if (sa.sep > 0) return null;
var sb = maxSeparation(vb, va);
if (sb.sep > 0) return null;
// 流形法线约定 a→b(A→B)
var nx, ny, refPt, refA, refB;
if (sb.sep > sa.sep + 0.01) {
var nB = edgeOutNormal(vb, sb.i); // B 外法线 = B→A
nx = -nB.x; ny = -nB.y; // 取反 = A→B
refPt = vb[sb.i];
refA = vb[sb.i]; refB = vb[(sb.i + 1) % vb.length];
} else {
var nA = edgeOutNormal(va, sa.i); // A 外法线 = A→B
nx = nA.x; ny = nA.y;
refPt = va[sa.i];
refA = va[sa.i]; refB = va[(sa.i + 1) % va.length];
}
// 顶点穿透流形:收集互相穿透的顶点
var pts = [];
var i;
for (i = 0; i < vb.length; i++) {
if (pointInPoly(vb[i].x, vb[i].y, va)) {
// B 顶点穿入 A:在 A→B 法线上投影为负
pts.push({ x: vb[i].x, y: vb[i].y, pen: Math.max(0.1, -((vb[i].x - refPt.x) * nx + (vb[i].y - refPt.y) * ny)) });
}
}
for (i = 0; i < va.length; i++) {
if (pointInPoly(va[i].x, va[i].y, vb)) {
// A 顶点穿入 B:在 A→B 法线上投影为正
pts.push({ x: va[i].x, y: va[i].y, pen: Math.max(0.1, (va[i].x - refPt.x) * nx + (va[i].y - refPt.y) * ny) });
}
}
if (!pts.length) {
// 边-边接触:沿参考边生成 2 个接触点(面接触近似,提供抗旋转扭矩,防堆叠倾覆)
var ex = refB.x - refA.x, ey = refB.y - refA.y;
var el = Math.sqrt(ex * ex + ey * ey) || 1e-9;
ex /= el; ey /= el;
var oc = centroid(sb.sep > sa.sep + 0.01 ? va : vb);
var tMid = (oc.x - refA.x) * ex + (oc.y - refA.y) * ey;
var half = Math.min(el * 0.4, 14);
var pen0 = Math.max(0.1, -Math.max(sa.sep, sb.sep));
pts.push({ x: refA.x + ex * (tMid - half), y: refA.y + ey * (tMid - half), pen: pen0 });
pts.push({ x: refA.x + ex * (tMid + half), y: refA.y + ey * (tMid + half), pen: pen0 });
}
if (pts.length > 2) pts.length = 2;
return { a: a, b: b, nx: nx, ny: ny, points: pts };
}
function collide(a, b) {
if (a.shape === 'circle' && b.shape === 'circle') return collideCircleCircle(a, b);
if (a.shape === 'circle' && b.shape === 'poly') return collideCirclePoly(a, b, true);
if (a.shape === 'poly' && b.shape === 'circle') return collideCirclePoly(b, a, false);
return collidePolyPoly(a, b);
}
// ---------- 求解 ----------
function prepareManifold(m, p) {
// 记录每点初始接近速度,用于恢复系数 bias
for (var i = 0; i < m.points.length; i++) {
var cp = m.points[i];
cp.rax = cp.x - m.a.x; cp.ray = cp.y - m.a.y;
cp.rbx = cp.x - m.b.x; cp.rby = cp.y - m.b.y;
var rvx = (m.b.vx - m.b.omega * cp.rby) - (m.a.vx - m.a.omega * cp.ray);
var rvy = (m.b.vy + m.b.omega * cp.rbx) - (m.a.vy + m.a.omega * cp.rax);
var vn = rvx * m.nx + rvy * m.ny;
cp.bias = (-vn > p.restV) ? -p.e * vn : 0;
cp.accN = 0; cp.accT = 0;
}
m.friction = Math.min(m.a.friction, m.b.friction);
m.rest = p.e;
}
function solveManifold(m, p) {
var a = m.a, b = m.b;
var nx = m.nx, ny = m.ny;
var tx = -ny, ty = nx;
for (var i = 0; i < m.points.length; i++) {
var cp = m.points[i];
// ---- 法向 ----
var rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
var rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
var vn = rvx * nx + rvy * ny;
var raxn = cp.rax * ny - cp.ray * nx;
var rbxn = cp.rbx * ny - cp.rby * nx;
var kn = a.invM + b.invM + raxn * raxn * a.invI + rbxn * rbxn * b.invI;
if (kn > 1e-9) {
var lambda = (cp.bias - vn) / kn;
var newAcc = Math.max(cp.accN + lambda, 0);
lambda = newAcc - cp.accN;
cp.accN = newAcc;
var px_ = nx * lambda, py_ = ny * lambda;
a.vx -= px_ * a.invM; a.vy -= py_ * a.invM;
a.omega -= a.invI * (cp.rax * py_ - cp.ray * px_);
b.vx += px_ * b.invM; b.vy += py_ * b.invM;
b.omega += b.invI * (cp.rbx * py_ - cp.rby * px_);
}
// ---- 切向(库仑摩擦,累积钳制) ----
rvx = (b.vx - b.omega * cp.rby) - (a.vx - a.omega * cp.ray);
rvy = (b.vy + b.omega * cp.rbx) - (a.vy + a.omega * cp.rax);
var vt = rvx * tx + rvy * ty;
var raxt = cp.rax * ty - cp.ray * tx;
var rbxt = cp.rbx * ty - cp.rby * tx;
var kt = a.invM + b.invM + raxt * raxt * a.invI + rbxt * rbxt * b.invI;
if (kt > 1e-9) {
var jt = -vt / kt;
var maxF = m.friction * cp.accN;
var newT = cp.accT + jt;
if (newT > maxF) newT = maxF; else if (newT < -maxF) newT = -maxF;
jt = newT - cp.accT;
cp.accT = newT;
var fx_ = tx * jt, fy_ = ty * jt;
a.vx -= fx_ * a.invM; a.vy -= fy_ * a.invM;
a.omega -= a.invI * (cp.rax * fy_ - cp.ray * fx_);
b.vx += fx_ * b.invM; b.vy += fy_ * b.invM;
b.omega += b.invI * (cp.rbx * fy_ - cp.rby * fx_);
}
}
}
function positionCorrection(m, p) {
var maxPen = 0;
for (var i = 0; i < m.points.length; i++) if (m.points[i].pen > maxPen) maxPen = m.points[i].pen;
var corr = Math.max(maxPen - p.slop, 0) * p.correction;
if (corr <= 0) return;
var tm = m.a.invM + m.b.invM;
if (tm <= 1e-9) return;
m.a.x -= m.nx * corr * (m.a.invM / tm);
m.a.y -= m.ny * corr * (m.a.invM / tm);
m.b.x += m.nx * corr * (m.b.invM / tm);
m.b.y += m.ny * corr * (m.b.invM / tm);
}
// ---------- 休眠 ----------
function updateSleep(b, dt, p) {
if (b.isStatic) return;
// 弹球永不休眠:必须保持可滚动/可被滞回收检测,防回合软锁
if (b.tag === 'ball') return;
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 < p.sleepV * p.sleepV && Math.abs(b.omega) < p.sleepW) b.sleepTimer += dt;
else b.sleepTimer = 0;
if (!b.sleeping && b.sleepTimer > 0.6) {
b.sleeping = true;
b.vx = 0; b.vy = 0; b.omega = 0;
}
}
function wake(b) {
if (b && b.sleeping) { b.sleeping = false; b.sleepTimer = 0; }
}
// ---------- 世界推进 ----------
// 返回 { manifolds, events };events = 新接触对强碰撞 [{a,b,speed}]
function stepWorld(bodies, p, dt, prevPairs) {
var i, j, b;
var events = [];
var newPairs = {};
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (b.isStatic || b.sleeping) continue;
b.vy += p.g * dt;
if (p.airK > 0) { var f = Math.exp(-p.airK * dt); b.vx *= f; b.vy *= f; }
if (p.omegaK > 0) b.omega *= Math.exp(-p.omegaK * dt); // 角速度阻尼,防砖块被击飞后永转
var sp2 = b.vx * b.vx + b.vy * b.vy;
if (sp2 > p.maxV * p.maxV) { var s = p.maxV / Math.sqrt(sp2); b.vx *= s; b.vy *= s; }
b.x += b.vx * dt;
b.y += b.vy * dt;
b.angle += b.omega * dt;
}
for (i = 0; i < bodies.length; i++) updateAabb(bodies[i]);
var manifolds = [];
for (i = 0; i < bodies.length; i++) {
var a = bodies[i];
for (j = i + 1; j < bodies.length; j++) {
var c = bodies[j];
if (a.isStatic && c.isStatic) continue;
if (a.sleeping && c.sleeping) continue;
if (!aabbOverlap(a, c)) continue;
var m = collide(a, c);
if (!m) continue;
prepareManifold(m, p);
var key = a.id + '_' + c.id;
newPairs[key] = true;
// 接近速度(质心)
var rvx = c.vx - a.vx, rvy = c.vy - a.vy;
var speed = Math.sqrt(rvx * rvx + rvy * rvy);
// 唤醒:休眠体被高速接触
if (a.sleeping && !c.sleeping && speed > p.wakeV) wake(a);
if (c.sleeping && !a.sleeping && speed > p.wakeV) wake(c);
// 事件:新接触对 + 足够强的接近速度
if (!prevPairs[key] && speed > p.hitV) events.push({ a: a, b: c, speed: speed });
manifolds.push(m);
}
}
for (var it = 0; it < p.iterations; it++) {
for (j = 0; j < manifolds.length; j++) solveManifold(manifolds[j], p);
}
for (j = 0; j < manifolds.length; j++) positionCorrection(manifolds[j], p);
for (i = 0; i < bodies.length; i++) {
b = bodies[i];
if (!b.isStatic && !b.sleeping) updateSleep(b, dt, p);
}
return { manifolds: manifolds, events: events, pairs: newPairs };
}
// 凸正多边形局部顶点(逆时针)
function regularVerts(n, radius, rot0) {
var vs = [];
for (var i = 0; i < n; i++) {
var a = (rot0 || -Math.PI / 2) + i * 2 * Math.PI / n;
vs.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
}
return vs;
}
// 矩形局部顶点
function boxVerts(w, h) {
return [
{ x: -w / 2, y: -h / 2 },
{ x: w / 2, y: -h / 2 },
{ x: w / 2, y: h / 2 },
{ x: -w / 2, y: h / 2 }
];
}
return {
makeCircle: makeCircle,
makePoly: makePoly,
worldVerts: worldVerts,
collide: collide,
stepWorld: stepWorld,
wake: wake,
regularVerts: regularVerts,
boxVerts: boxVerts,
pointInPoly: pointInPoly
};
})();