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