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