feat: 新增物理弹球实验室及配套功能
1. 新增物理弹球游戏全套代码,包含物理引擎、规则、渲染、游戏逻辑、存档、音效、UI 2. 新增物理弹球相关调试与单元测试脚本 3. 优化私有剪贴上传功能:增加200MB上传限制与错误提示 4. 修复复古弹球桌的光滑面防粘附与休眠逻辑
This commit is contained in:
@@ -88,11 +88,12 @@
|
||||
<li>积分:半隐式欧拉,固定子步 <code>dt=1/360s</code>,高速发射不穿透薄木条。</li>
|
||||
<li>碰撞:序贯冲量法。法向冲量 <code>j=-(1+e)·v_n/m⁻¹</code>,低速接触时 <code>e=0</code> 防抖动。</li>
|
||||
<li>摩擦:切向冲量按库仑摩擦 <code>|j_t| ≤ μ·j_n</code> 钳制,同时驱动角速度,弹珠会真实地「滚动」而非滑动。</li>
|
||||
<li>光滑面:圆钉 / 拨片 / 落坑隔板 / 发射道壁 / 弧形导轨摩擦恒为 0,且带防粘附微推力——任何光滑面都<strong>不能存放球</strong>(包括隔板顶、钉顶),球必须滑落。</li>
|
||||
<li>倾角:真实玩具斜放在桌面上,沿台面有效重力 <code>g·sinθ</code>,θ 即倾角滑块。</li>
|
||||
<li>弹簧发射:蓄力压缩量线性映射初速(胡克定律手感),松手即发射;力度不足掉回发射道的球<strong>不消耗机会</strong>,落在弹簧上可重新蓄力再弹——只有真正进入盘面的球才计一次机会。</li>
|
||||
<li>力度曲线:蓄力 20% 恰好进盘面,20%~100% 线性到最大力,低力度也有用;蓄力条<strong>红区(>85%)</strong>弹簧过压缩——初速随机损耗、横向抖动剧增,<strong>满力≠高分</strong>,甜区约 80%~90%。</li>
|
||||
<li>雨刮拨片(可选):勾选后两根摆杆像汽车雨刮器一样刮来刮去;再勾「转动」改为绕轴连续旋转(两杆反向)。拨片是<strong>运动碰撞体</strong>,表面线速度 v=ω×r 参与冲量求解,物理上真实地「扇飞」弹珠;拨片带 4px 胶囊厚度,高速球也不会穿透。</li>
|
||||
<li>静止判定:速度持续低于阈值且存在接触 → 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片)。</li>
|
||||
<li>静止判定:速度持续低于阈值且存在<strong>非光滑</strong>接触(坑底横挡等)→ 休眠为静态碰撞体,落球坑内可堆叠(如同实物照片);只接触光滑面的球永不休眠,必然落下。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +116,7 @@ var PhysicsCore = (function () {
|
||||
angle: 0, omega: 0,
|
||||
m: m, invM: 1 / m,
|
||||
I: I, invI: 1 / I,
|
||||
sleeping: false, sleepTimer: 0, contacts: 0
|
||||
sleeping: false, sleepTimer: 0, contacts: 0, slipContacts: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ var PhysicsCore = (function () {
|
||||
d2 = dx * dx + dy * dy;
|
||||
if (d2 >= R * R) return null;
|
||||
d = Math.sqrt(d2) || 1e-9;
|
||||
return { nx: dx / d, ny: dy / d, pen: R - d };
|
||||
return { nx: dx / d, ny: dy / d, pen: R - d, slip: !!s.slip };
|
||||
}
|
||||
var c = closestOnSeg(b.x, b.y, s);
|
||||
dx = b.x - c.x; dy = b.y - c.y;
|
||||
@@ -147,7 +148,7 @@ var PhysicsCore = (function () {
|
||||
var R2 = b.r + (s.thk || 0);
|
||||
if (d2 >= R2 * R2) return null;
|
||||
d = Math.sqrt(d2) || 1e-9;
|
||||
var ct = { nx: dx / d, ny: dy / d, pen: R2 - d };
|
||||
var ct = { nx: dx / d, ny: dy / d, pen: R2 - d, slip: !!s.slip };
|
||||
// 运动碰撞体(雨刮拨片):按接触点附表面线速度 v=ω×r,供相对速度冲量求解
|
||||
if (s.mov) { ct.svx = -s.mov.omega * (c.y - s.mov.py); ct.svy = s.mov.omega * (c.x - s.mov.px); }
|
||||
return ct;
|
||||
@@ -206,14 +207,19 @@ var PhysicsCore = (function () {
|
||||
var vt0 = rvx * tx + rvy * ty - a.omega * a.r;
|
||||
var Kt0 = a.invM + a.r * a.r * a.invI;
|
||||
var jt0 = -vt0 / Kt0;
|
||||
var maxF0 = p.mu * jnAcc0;
|
||||
// 光滑面(钉/拨片/隔板等,slip)摩擦恒为 0:理论上不产生任何切向摩擦冲量
|
||||
var maxF0 = (c.slip ? 0 : p.mu) * jnAcc0;
|
||||
if (jt0 > maxF0) jt0 = maxF0; else if (jt0 < -maxF0) jt0 = -maxF0;
|
||||
a.vx += tx * jt0 * a.invM; a.vy += ty * jt0 * a.invM;
|
||||
a.omega -= a.r * jt0 * a.invI;
|
||||
}
|
||||
}
|
||||
|
||||
// 推进一步 dt:积分 -> 收集接触 -> 冲量迭代 -> 位置校正 -> 滚动阻力
|
||||
// 防粘附推力:光滑面上「正上方平衡」是不稳定平衡,离散求解器可能维持完美对称
|
||||
// 导致球视觉冻结在钉顶/隔板顶。对法线朝上的光滑接触施加微小水平加速度,必滑落。
|
||||
var STICK_K = 240;
|
||||
|
||||
// 推进一步 dt:积分 -> 收集接触 -> 防粘附 -> 冲量迭代 -> 位置校正 -> 滚动阻力
|
||||
function stepWorld(balls, statics, p, dt) {
|
||||
var i, j, b, o, c;
|
||||
for (i = 0; i < balls.length; i++) {
|
||||
@@ -227,6 +233,7 @@ var PhysicsCore = (function () {
|
||||
b.y += b.vy * dt;
|
||||
b.angle += b.omega * dt;
|
||||
b.contacts = 0;
|
||||
b.slipContacts = 0;
|
||||
}
|
||||
var contacts = [];
|
||||
for (i = 0; i < balls.length; i++) {
|
||||
@@ -234,7 +241,7 @@ var PhysicsCore = (function () {
|
||||
if (b.sleeping) continue;
|
||||
for (j = 0; j < statics.length; j++) {
|
||||
c = contactBallStatic(b, statics[j]);
|
||||
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; }
|
||||
if (c) { c.a = b; c.b = null; c.jn = 0; contacts.push(c); b.contacts++; if (c.slip) b.slipContacts++; }
|
||||
}
|
||||
for (j = 0; j < balls.length; j++) {
|
||||
o = balls[j];
|
||||
@@ -248,6 +255,13 @@ var PhysicsCore = (function () {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 防粘附:球坐在光滑面顶部(法线朝上)时施加微小水平推力,打破平衡使其滑落
|
||||
for (j = 0; j < contacts.length; j++) {
|
||||
c = contacts[j];
|
||||
if (!c.slip || c.b || c.ny > -0.6) continue;
|
||||
var dir = c.nx > 0.02 ? 1 : (c.nx < -0.02 ? -1 : 1);
|
||||
c.a.vx += dir * STICK_K * dt;
|
||||
}
|
||||
for (var it = 0; it < p.iterations; it++) {
|
||||
for (j = 0; j < contacts.length; j++) solveContact(contacts[j], p);
|
||||
}
|
||||
@@ -268,7 +282,8 @@ var PhysicsCore = (function () {
|
||||
}
|
||||
for (i = 0; i < balls.length; i++) {
|
||||
b = balls[i];
|
||||
if (b.sleeping || b.contacts === 0) continue;
|
||||
// 全部接触都是光滑面时不加滚动阻力:球必须继续滑动/下落,不能被“磨”停
|
||||
if (b.sleeping || b.contacts === 0 || b.slipContacts >= b.contacts) continue;
|
||||
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
|
||||
if (sp > 1e-6) {
|
||||
var dec = Math.min(sp, p.rollR * p.g * dt);
|
||||
@@ -322,8 +337,11 @@ function buildTableStatics(pegR, pegSpacing) {
|
||||
// 外框四壁
|
||||
statics.push(seg(L, T, R, T), seg(L, B, R, B), seg(L, T, L, B), seg(R, T, R, B));
|
||||
// 发射道左壁(顶端接弧形导轨右端,底端接底壁封死,杜绝壁底缺口侧逃)
|
||||
statics.push(seg(LANE_X, 190, LANE_X, B));
|
||||
// 弧形导轨(离散为线段链)
|
||||
// slip:光滑壁,顶端不得存球(它同时是最右坑的右隔板)
|
||||
var lane = seg(LANE_X, 190, LANE_X, B);
|
||||
lane.slip = true;
|
||||
statics.push(lane);
|
||||
// 弧形导轨(离散为线段链,slip:抛光导轨摩擦为 0,弧顶不存球)
|
||||
var steps = 40;
|
||||
var a0 = ARCH.a0 * Math.PI / 180, a1 = ARCH.a1 * Math.PI / 180;
|
||||
var px = 0, py = 0;
|
||||
@@ -331,12 +349,17 @@ function buildTableStatics(pegR, pegSpacing) {
|
||||
var a = a0 + (a1 - a0) * (i / steps);
|
||||
var x = ARCH.cx + ARCH.r * Math.cos(a);
|
||||
var y = ARCH.cy + ARCH.r * Math.sin(a);
|
||||
if (i > 0) statics.push(seg(px, py, x, y));
|
||||
if (i > 0) { var as = seg(px, py, x, y); as.slip = true; statics.push(as); }
|
||||
px = x; py = y;
|
||||
}
|
||||
// 落球坑:5 根分隔木条(右边界复用发射道左壁,不另设右长条,避免与道壁重叠/留缝)
|
||||
// slip:隔板摩擦为 0,隔板顶不得存球,球必须落入坑内
|
||||
var slotW = (SLOT_X1 - SLOT_X0) / SLOT_N;
|
||||
for (i = 1; i < SLOT_N; i++) statics.push(seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT));
|
||||
for (i = 1; i < SLOT_N; i++) {
|
||||
var dv = seg(SLOT_X0 + slotW * i, SLOT_TOP, SLOT_X0 + slotW * i, SLOT_BOT);
|
||||
dv.slip = true;
|
||||
statics.push(dv);
|
||||
}
|
||||
// 底部横挡木条(球停在它上面)
|
||||
statics.push(seg(SLOT_X0, BAR_Y, SLOT_X1, BAR_Y));
|
||||
// 圆钉阵(交错,间距/尺寸可调)
|
||||
@@ -351,7 +374,8 @@ function buildTableStatics(pegR, pegSpacing) {
|
||||
var yy = cy + (row.y - cy) * pegSpacing;
|
||||
for (var xI = 0; xI < row.xs.length; xI++) {
|
||||
var xx = cx + (row.xs[xI] - cx) * pegSpacing;
|
||||
statics.push({ type: 'circle', x: xx, y: yy, r: pegR });
|
||||
// slip:圆钉摩擦为 0,钉顶不得存球,球必须滑落
|
||||
statics.push({ type: 'circle', x: xx, y: yy, r: pegR, slip: true });
|
||||
pegs.push({ x: xx, y: yy, r: pegR, metal: ((rI + xI) % 3 === 2) });
|
||||
}
|
||||
}
|
||||
@@ -378,7 +402,8 @@ function makeWiperSegs() {
|
||||
var out = [];
|
||||
for (var i = 0; i < WIPERS.length; i++) {
|
||||
// thk=4 胶囊厚度:防高速球穿透(见 contactBallStatic 注释)
|
||||
out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, mov: { px: 0, py: 0, omega: 0 } });
|
||||
// slip:拨片摩擦为 0,不得存球
|
||||
out.push({ type: 'seg', x1: 0, y1: 0, x2: 0, y2: 0, thk: 4, slip: true, mov: { px: 0, py: 0, omega: 0 } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -571,7 +596,9 @@ function updateWiperSeg(sg, w, t, spin) {
|
||||
if (!b.counted && !b.ready && !b.sleeping && b.x < LANE_X - b.r) { b.counted = true; launched++; }
|
||||
if (b.sleeping) continue;
|
||||
var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy);
|
||||
if (sp < 15 && b.contacts > 0) b.sleepTimer += dt; else b.sleepTimer = 0;
|
||||
// 仅当存在非光滑接触(坑底横挡/外框等)才允许休眠;
|
||||
// 全光滑接触(钉/拨片/隔板顶)永不休眠,球必须落下
|
||||
if (sp < 15 && b.contacts > 0 && b.slipContacts < b.contacts) b.sleepTimer += dt; else b.sleepTimer = 0;
|
||||
if (b.sleepTimer > 0.5) settleBall(b);
|
||||
if (b.y > B + BALL_R + 8 || b.y > H + 40 || b.x < -40 || b.x > W + 40) { // 出界保护:回到弹簧
|
||||
b.x = SPRING_X; b.y = SPRING_TOP - BALL_R;
|
||||
|
||||
Reference in New Issue
Block a user