/* ============================================================ * 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 }; })();