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

457 lines
16 KiB
JavaScript
Raw 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.
/* ============================================================
* 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
};
})();