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,260 @@
/* ============================================================
* pb_render.js · 物理弹球 Web 复刻版 · Canvas 渲染层
* 纯绘制:场地 / 漏斗 / 输送带箭头 / 砖块(形状+数字+配色) /
* 道具 / 待发队列 / 球(棒球质感) / 粒子 / 轨迹预览(墙+砖块反射)
* ============================================================ */
var PBRender = (function () {
'use strict';
var R = PBRules, F = R.FIELD;
// ---------- 轨迹预览:无头物理仿真 ----------
// 用同一引擎/同初速/同参数克隆一颗球实跑 1.8s,预览线 = 真实轨迹。
// 墙/砖块本就静态;道具克隆为静态参与碰撞(不改动真实道具)。
var pvCache = { key: '', pts: null };
function simulatePath(g) {
var key = g.aimAngle.toFixed(4) + '|' + g.turn + '|' + g.blocks.length + '|' + g.items.length + '|' + ((g.queue[0] || g.bigTurn) ? 1 : 0);
if (pvCache.key === key) return pvCache.pts;
var P = PBPhysics;
var bodies = g.walls.concat(g.blocks);
var i;
for (i = 0; i < g.items.length; i++) {
bodies.push(P.makeCircle({ shape: 'circle', x: g.items[i].x, y: g.items[i].y, r: g.items[i].r, isStatic: true, friction: 0.3, restitution: 0.2, tag: 'item' }));
}
var r = (g.queue[0] || g.bigTurn) ? R.BIG_R : R.BALL_R; // 与 launchOne 口径一致:队首标记或大球回合
var ball = P.makeCircle({
shape: 'circle', x: F.arcCx, y: F.checkY - 6,
r: r, friction: 0.08, restitution: 0.95, tag: 'ball'
});
ball.vx = Math.sin(g.aimAngle) * R.FIRE_SPEED;
ball.vy = Math.cos(g.aimAngle) * R.FIRE_SPEED;
bodies.push(ball);
var pts = [{ x: ball.x, y: ball.y }];
var pairs = {};
var n = Math.floor(1.8 / R.PHYS.subDt);
for (i = 0; i < n; i++) {
var res = P.stepWorld(bodies, R.PHYS, R.PHYS.subDt, pairs);
pairs = res.pairs;
if (i % 4 === 3) pts.push({ x: ball.x, y: ball.y });
if (ball.sleeping) break;
}
pvCache.key = key;
pvCache.pts = pts;
return pts;
}
// ---------- 绘制部件 ----------
function drawBall(ctx, x, y, r, big) {
if (big) {
// 大球 = ⚽️ 图标,直径 2 倍于小球,一眼可辨
ctx.font = Math.round(r * 2) + 'px system-ui, "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('⚽️', x, y + r * 0.08);
return;
}
var grad = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.2, x, y, r);
grad.addColorStop(0, big ? '#d8a468' : '#c08a52');
grad.addColorStop(1, big ? '#8a5a2c' : '#7a4c24');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
// 棒球缝线
ctx.strokeStyle = 'rgba(255,244,220,0.85)';
ctx.lineWidth = Math.max(1, r * 0.16);
ctx.beginPath();
ctx.arc(x - r * 0.5, y, r * 0.9, -0.6, 0.6);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + r * 0.5, y, r * 0.9, Math.PI - 0.6, Math.PI + 0.6);
ctx.stroke();
}
function drawBlock(ctx, b) {
var d = b.data;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.angle);
ctx.fillStyle = R.hpColor(d.hp);
ctx.beginPath();
if (d.shape === 'circle') {
ctx.arc(0, 0, d.r, 0, Math.PI * 2);
} else {
var vs = d.shape === 'box' ? PBPhysics.boxVerts(d.r * 1.7, d.r * 1.7) : PBPhysics.regularVerts(d.shape === 'tri' ? 3 : 5, d.r, 0);
ctx.moveTo(vs[0].x, vs[0].y);
for (var i = 1; i < vs.length; i++) ctx.lineTo(vs[i].x, vs[i].y);
ctx.closePath();
}
ctx.fill();
ctx.fillStyle = d.hp <= 29 ? '#20180a' : '#ffffff';
ctx.font = 'bold ' + Math.round(d.r * 0.95) + 'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(String(d.hp), 0, 1);
ctx.restore();
}
function drawItem(ctx, it, t) {
ctx.save();
ctx.translate(it.x, it.y);
ctx.strokeStyle = '#e8e8e8';
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.lineDashOffset = -t * 20;
ctx.beginPath();
ctx.arc(0, 0, it.r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
if (it.data.type === 'ball') {
// +1 球道具:迷你棒球 + 加号
drawBall(ctx, -it.r * 0.12, it.r * 0.12, it.r * 0.36, false);
ctx.strokeStyle = '#e8e8e8';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(it.r * 0.35, -it.r * 0.55); ctx.lineTo(it.r * 0.8, -it.r * 0.55);
ctx.moveTo(it.r * 0.575, -it.r * 0.775); ctx.lineTo(it.r * 0.575, -it.r * 0.325);
ctx.stroke();
} else {
// 大球道具:⚽ 图标(与实际大球外观一致)
ctx.font = Math.round(it.r * 1.3) + 'px system-ui, "Segoe UI Emoji", sans-serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('⚽️', 0, 1);
}
ctx.restore();
}
function drawField(ctx, t) {
ctx.strokeStyle = '#8a8a8a';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
// 左右壁
ctx.beginPath();
ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallL, F.H - 60);
ctx.moveTo(F.wallR, F.topY); ctx.lineTo(F.wallR, F.H - 60);
ctx.stroke();
// 顶 + 漏斗
var fn = R.funnelSegs();
ctx.beginPath();
ctx.moveTo(F.wallL, F.topY); ctx.lineTo(F.wallR, F.topY);
ctx.moveTo(fn[0].x1, fn[0].y1); ctx.lineTo(fn[0].x2, fn[0].y2);
ctx.moveTo(fn[1].x1, fn[1].y1); ctx.lineTo(fn[1].x2, fn[1].y2);
ctx.stroke();
// 底部弧
ctx.beginPath();
ctx.arc(F.arcCx, F.arcCy, F.arcR, Math.PI * 1.24, Math.PI * 1.76);
ctx.stroke();
// checkline 虚线
ctx.strokeStyle = '#5a5a5a';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 6]);
ctx.beginPath();
ctx.moveTo(F.wallL, F.checkY); ctx.lineTo(F.wallR, F.checkY);
ctx.stroke();
ctx.setLineDash([]);
// 输送带箭头(两角,向上闪动)
for (var side = 0; side < 2; side++) {
var ax = side === 0 ? F.wallL + 13 : F.wallR - 13;
for (var k = 0; k < 3; k++) {
var ay = F.H - 46 - k * 16 - ((t * 40) % 16);
var alpha = 0.25 + 0.55 * ((k + Math.floor(t * 3)) % 3) / 2;
ctx.strokeStyle = 'rgba(200,200,200,' + alpha.toFixed(2) + ')';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(ax - 7, ay + 6); ctx.lineTo(ax, ay); ctx.lineTo(ax + 7, ay + 6);
ctx.stroke();
}
}
}
function drawQueue(ctx, g) {
for (var i = 0; i < Math.min(g.queue.length, 10); i++) {
var p = R.queuePos(i);
// 大球回合徽章生效中:待发全部按 ⚽️ 显示;否则按逐球标记(回收的大球仍为 ⚽️)
var isBig = !!g.queue[i] || !!g.bigTurn;
drawBall(ctx, p.x, p.y, isBig ? 11 : R.BALL_R, isBig);
}
if (g.queue.length > 10) {
ctx.fillStyle = '#bbb';
ctx.font = '10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('+' + (g.queue.length - 10), F.arcCx, F.checkY - 22 - 4 * 15 - 10 * 14);
}
}
function drawPreview(ctx, g) {
if (g.state !== 'aim' || !g.queue.length) return;
var dx = Math.sin(g.aimAngle), dy = Math.cos(g.aimAngle);
var pts = simulatePath(g);
ctx.fillStyle = 'rgba(210,160,90,0.9)';
for (var i = 0; i < pts.length; i++) {
var fade = 1 - i / pts.length; // 越远越淡
ctx.globalAlpha = 0.35 + 0.6 * fade;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, 1.6 + 1.8 * fade, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 发射方向小箭头
ctx.fillStyle = '#ffffff';
ctx.save();
ctx.translate(F.arcCx + dx * 26, F.checkY - 6 + dy * 26);
ctx.rotate(Math.atan2(dy, dx) - Math.PI / 2);
ctx.beginPath();
ctx.moveTo(0, 8); ctx.lineTo(-5, -4); ctx.lineTo(5, -4);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawParticles(ctx, g) {
for (var i = 0; i < g.particles.length; i++) {
var p = g.particles[i];
var a = 1 - p.t / p.life;
if (p.kind === 'text') {
ctx.globalAlpha = a;
ctx.fillStyle = p.color;
ctx.font = 'bold ' + p.size + 'px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(p.text, p.x, p.y);
ctx.globalAlpha = 1;
} else {
ctx.save();
ctx.globalAlpha = a;
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.moveTo(0, -p.size);
ctx.lineTo(p.size * 0.9, p.size * 0.7);
ctx.lineTo(-p.size * 0.9, p.size * 0.7);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
}
// ---------- 主绘制 ----------
function draw(ctx, g, t) {
ctx.clearRect(0, 0, F.W, F.H);
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, F.W, F.H);
drawField(ctx, t);
drawPreview(ctx, g);
var i;
for (i = 0; i < g.items.length; i++) drawItem(ctx, g.items[i], t);
for (i = 0; i < g.blocks.length; i++) drawBlock(ctx, g.blocks[i]);
drawQueue(ctx, g);
for (i = 0; i < g.balls.length; i++) drawBall(ctx, g.balls[i].x, g.balls[i].y, g.balls[i].r, g.balls[i].data.big);
ctx.globalAlpha = 0.75; // 输送带上的球半透明,与场内球区分
for (i = 0; i < g.recycle.length; i++) drawBall(ctx, g.recycle[i].x, g.recycle[i].y, g.recycle[i].big ? R.BIG_R : R.BALL_R, g.recycle[i].big);
ctx.globalAlpha = 1;
drawParticles(ctx, g);
}
return { draw: draw, simulatePath: simulatePath, drawBall: drawBall };
})();