91148fe064
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
457 lines
16 KiB
JavaScript
457 lines
16 KiB
JavaScript
/* ============================================================
|
||
* 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
|
||
};
|
||
})();
|