/** * 文件功能:聊天室 99A 主战坦克重装入场特效 * * 使用全屏透明 Canvas 绘制中国 99A 主战坦克横穿屏幕、履带滚动、 * 长炮管炮击、楔形复合装甲、侧裙、尘土冲击波与重装入场 HUD。 */ const Type99AEffect = (() => { const DURATION = 8200; const ARMOR = "#5f6f3a"; const DARK_ARMOR = "#1f2a1d"; const CAMO = "#7c6a36"; const DUST = "#fde68a"; const FIRE = "#f97316"; /** * 缓出曲线,让坦克进场有重量感。 * * @param {number} t 0 到 1 的进度 * @returns {number} */ function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } /** * 缓入缓出曲线,用于 HUD 和冲击波。 * * @param {number} t 0 到 1 的进度 * @returns {number} */ function easeInOutSine(t) { return -(Math.cos(Math.PI * t) - 1) / 2; } /** * 创建地面尘土粒子。 * * @param {number} w 画布宽度 * @param {number} h 画布高度 * @returns {Array>} */ function createDust(w, h) { return Array.from({ length: 90 }, () => ({ x: Math.random() * w, y: h * (0.66 + Math.random() * 0.18), speed: 1.6 + Math.random() * 4.8, size: 2 + Math.random() * 8, alpha: 0.12 + Math.random() * 0.34, })); } /** * 绘制战场式地面背景。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} w 画布宽度 * @param {number} h 画布高度 * @param {number} progress 播放进度 */ function drawBackdrop(ctx, w, h, progress) { const fade = Math.min(1, progress / 0.16) * Math.min(1, (1 - progress) / 0.12); const gradient = ctx.createRadialGradient(w * 0.5, h * 0.62, 0, w * 0.5, h * 0.62, Math.max(w, h) * 0.76); gradient.addColorStop(0, `rgba(41,37,36,${0.42 * fade})`); gradient.addColorStop(0.55, `rgba(63,98,18,${0.18 * fade})`); gradient.addColorStop(1, "rgba(0,0,0,0)"); ctx.save(); ctx.fillStyle = gradient; ctx.fillRect(0, 0, w, h); ctx.globalCompositeOperation = "lighter"; for (let i = 0; i < 8; i++) { const y = h * (0.7 + i * 0.028); ctx.strokeStyle = `rgba(253,230,138,${0.1 * fade})`; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, y + Math.sin(progress * 12 + i) * 4); ctx.lineTo(w, y + Math.cos(progress * 9 + i) * 4); ctx.stroke(); } ctx.restore(); } /** * 绘制履带带起的尘土。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {Array>} dust 尘土粒子 * @param {number} w 画布宽度 * @param {number} progress 播放进度 */ function drawDust(ctx, dust, w, progress) { const fade = Math.min(1, progress / 0.18) * Math.min(1, (1 - progress) / 0.12); ctx.save(); ctx.globalCompositeOperation = "lighter"; dust.forEach((particle, index) => { const travel = (progress * (620 + particle.speed * 80) + index * 47) % (w + 420); const x = w + 210 - travel; ctx.globalAlpha = particle.alpha * fade; ctx.fillStyle = DUST; ctx.shadowColor = DUST; ctx.shadowBlur = particle.size * 1.4; ctx.beginPath(); ctx.ellipse(x, particle.y, particle.size * 1.8, particle.size * 0.7, 0, 0, Math.PI * 2); ctx.fill(); }); ctx.restore(); } /** * 绘制炮击冲击波。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} w 画布宽度 * @param {number} h 画布高度 * @param {number} progress 播放进度 */ function drawShockwave(ctx, w, h, progress) { const shot = Math.max(0, Math.min(1, (progress - 0.5) / 0.2)); if (shot <= 0 || shot >= 1) { return; } const alpha = Math.sin(shot * Math.PI); ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.strokeStyle = `rgba(253,230,138,${0.38 * alpha})`; ctx.lineWidth = 5; ctx.beginPath(); ctx.ellipse(w * 0.48, h * 0.68, w * (0.08 + shot * 0.38), h * (0.03 + shot * 0.08), 0, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } /** * 绘制 99A 主炮炮口火焰。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} progress 播放进度 */ function drawMuzzleFlash(ctx, progress) { const flash = Math.max(0, 1 - Math.abs(progress - 0.5) / 0.045); if (flash <= 0) { return; } ctx.save(); ctx.globalCompositeOperation = "lighter"; ctx.shadowColor = FIRE; ctx.shadowBlur = 28; ctx.fillStyle = `rgba(249,115,22,${0.78 * flash})`; ctx.beginPath(); ctx.moveTo(408, -118); ctx.lineTo(528, -156); ctx.lineTo(468, -112); ctx.lineTo(532, -76); ctx.lineTo(408, -100); ctx.closePath(); ctx.fill(); ctx.fillStyle = `rgba(255,255,255,${0.74 * flash})`; ctx.beginPath(); ctx.moveTo(414, -114); ctx.lineTo(486, -132); ctx.lineTo(452, -108); ctx.lineTo(492, -92); ctx.lineTo(414, -102); ctx.closePath(); ctx.fill(); ctx.restore(); } /** * 绘制 99A 主战坦克主体。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} x 坦克中心 x * @param {number} y 坦克中心 y * @param {number} scale 缩放比例 * @param {number} progress 播放进度 */ function drawTank(ctx, x, y, scale, progress) { ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.72)"; ctx.shadowBlur = 16; ctx.fillStyle = "rgba(0,0,0,0.45)"; ctx.beginPath(); ctx.ellipse(0, 54, 250, 22, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore(); drawMuzzleFlash(ctx, progress); // 99A 履带底盘:右侧前导轮更大,模拟参考图的右前方视角。 const track = ctx.createLinearGradient(-246, 24, 246, 94); track.addColorStop(0, "#0a0a0a"); track.addColorStop(0.42, DARK_ARMOR); track.addColorStop(1, "#030712"); ctx.fillStyle = track; ctx.beginPath(); ctx.moveTo(-250, 32); ctx.lineTo(-206, 10); ctx.lineTo(184, 12); ctx.lineTo(252, 38); ctx.lineTo(218, 94); ctx.lineTo(-226, 96); ctx.lineTo(-270, 70); ctx.closePath(); ctx.fill(); ctx.fillStyle = "rgba(15,23,42,0.74)"; ctx.beginPath(); ctx.moveTo(-236, 22); ctx.lineTo(180, 23); ctx.lineTo(236, 43); ctx.lineTo(204, 62); ctx.lineTo(-218, 58); ctx.lineTo(-256, 42); ctx.closePath(); ctx.fill(); for (let i = 0; i < 7; i++) { const wheelX = -180 + i * 58; const wheelY = 62 + (i > 4 ? 2 : 0); drawRoadWheel(ctx, wheelX, wheelY, progress + i * 0.05); } ctx.strokeStyle = "rgba(253,230,138,0.14)"; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(-218, 82); ctx.lineTo(212, 82); ctx.stroke(); // 99A 车体:右侧为前装甲,首上装甲呈明显楔形下压。 const hull = ctx.createLinearGradient(-238, -50, 250, 36); hull.addColorStop(0, "#42512b"); hull.addColorStop(0.38, ARMOR); hull.addColorStop(0.66, CAMO); hull.addColorStop(1, "#253018"); ctx.fillStyle = hull; ctx.beginPath(); ctx.moveTo(-238, 24); ctx.lineTo(-212, -30); ctx.lineTo(86, -52); ctx.lineTo(226, -24); ctx.lineTo(252, 14); ctx.lineTo(202, 38); ctx.lineTo(-216, 36); ctx.closePath(); ctx.fill(); ctx.fillStyle = "rgba(15,23,42,0.34)"; ctx.beginPath(); ctx.moveTo(68, -44); ctx.lineTo(226, -22); ctx.lineTo(248, 9); ctx.lineTo(138, 18); ctx.lineTo(86, -5); ctx.closePath(); ctx.fill(); // 侧裙装甲模块和数码迷彩块,增强 99A 识别度。 for (let i = 0; i < 8; i++) { const panelX = -206 + i * 50; ctx.fillStyle = i % 2 === 0 ? "rgba(54,83,20,0.86)" : "rgba(120,113,55,0.82)"; ctx.fillRect(panelX, -2, 42, 24); ctx.strokeStyle = "rgba(15,23,42,0.34)"; ctx.lineWidth = 1.4; ctx.strokeRect(panelX, -2, 42, 24); } [ [-186, -24, 34, 18, "#7f8f57"], [-118, -34, 42, 20, "#b9855a"], [-32, -39, 48, 22, "#344329"], [52, -46, 44, 20, "#8a9b61"], [118, -30, 38, 19, "#a36f52"], [182, -14, 46, 21, "#415329"], [-220, 2, 30, 20, "#27351f"], [-72, 4, 36, 18, "#718246"], [22, 2, 42, 20, "#ac7654"], ].forEach(([px, py, pw, ph, color]) => { ctx.fillStyle = color; ctx.fillRect(px, py, pw, ph); }); // 参考图里的大号车号、国旗和前车灯。 ctx.fillStyle = "rgba(255,255,255,0.9)"; ctx.font = "900 31px serif"; ctx.fillText("807", -216, -12); ctx.fillStyle = "rgba(220,38,38,0.95)"; ctx.fillRect(-128, -31, 34, 22); ctx.fillStyle = "rgba(253,224,71,0.95)"; ctx.font = "900 12px serif"; ctx.fillText("★", -122, -16); ctx.fillStyle = "rgba(254,242,242,0.88)"; roundRect(ctx, 178, -17, 18, 9, 5); ctx.fill(); roundRect(ctx, 214, -8, 18, 9, 5); ctx.fill(); // 低矮楔形炮塔和 125mm 长炮管:炮管朝右并略微上扬。 const turret = ctx.createLinearGradient(-128, -108, 156, -28); turret.addColorStop(0, "#66734a"); turret.addColorStop(0.46, "#87905d"); turret.addColorStop(1, "#24301d"); ctx.fillStyle = turret; ctx.beginPath(); ctx.moveTo(-132, -36); ctx.lineTo(-82, -96); ctx.lineTo(86, -108); ctx.lineTo(158, -72); ctx.lineTo(112, -32); ctx.lineTo(-118, -20); ctx.closePath(); ctx.fill(); [ [-98, -76, 42, 22, "#314222"], [-28, -90, 50, 22, "#a36f52"], [46, -92, 48, 20, "#73844d"], [98, -66, 34, 20, "#2f3f24"], ].forEach(([px, py, pw, ph, color]) => { ctx.fillStyle = color; ctx.fillRect(px, py, pw, ph); }); ctx.save(); ctx.strokeStyle = "rgba(17,24,39,0.96)"; ctx.lineWidth = 18; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(106, -70); ctx.lineTo(410, -120); ctx.stroke(); ctx.strokeStyle = "#64748b"; ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(114, -75); ctx.lineTo(404, -123); ctx.stroke(); ctx.fillStyle = "#1f2937"; roundRect(ctx, 392, -132, 30, 24, 8); ctx.fill(); ctx.fillStyle = "#40513a"; roundRect(ctx, 250, -107, 24, 20, 5); ctx.fill(); roundRect(ctx, 322, -120, 24, 20, 5); ctx.fill(); ctx.restore(); ctx.fillStyle = "rgba(17,24,39,0.95)"; roundRect(ctx, 86, -80, 62, 24, 8); ctx.fill(); // 炮塔前装甲、烟幕弹发射器和车长机枪。 ctx.fillStyle = "rgba(15,23,42,0.5)"; ctx.beginPath(); ctx.moveTo(42, -48); ctx.lineTo(96, -78); ctx.lineTo(154, -66); ctx.lineTo(112, -36); ctx.closePath(); ctx.fill(); for (let i = 0; i < 4; i++) { ctx.fillStyle = "rgba(15,23,42,0.9)"; roundRect(ctx, -124 + i * 13, -58 + (i % 2) * 10, 10, 18, 4); ctx.fill(); } ctx.strokeStyle = "rgba(15,23,42,0.9)"; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(-24, -102); ctx.lineTo(-18, -132); ctx.lineTo(40, -138); ctx.stroke(); ctx.fillStyle = "rgba(15,23,42,0.84)"; roundRect(ctx, -32, -108, 58, 14, 7); ctx.fill(); // 装甲高光。 ctx.strokeStyle = "rgba(226,232,240,0.28)"; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(-184, -22); ctx.lineTo(92, -40); ctx.moveTo(-142, 8); ctx.lineTo(172, -2); ctx.moveTo(92, -82); ctx.lineTo(144, -66); ctx.stroke(); ctx.restore(); } /** * 绘制坦克负重轮。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} x 中心 x * @param {number} y 中心 y * @param {number} progress 播放进度 */ function drawRoadWheel(ctx, x, y, progress) { ctx.save(); ctx.translate(x, y); ctx.rotate(progress * 30); ctx.fillStyle = "#111827"; ctx.beginPath(); ctx.arc(0, 0, 21, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "#475569"; ctx.lineWidth = 3; ctx.stroke(); ctx.strokeStyle = "#94a3b8"; ctx.lineWidth = 2; for (let i = 0; i < 6; i++) { ctx.rotate(Math.PI / 3); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(16, 0); ctx.stroke(); } ctx.fillStyle = "#cbd5e1"; ctx.beginPath(); ctx.arc(0, 0, 5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } /** * 绘制 99A 重装入场 HUD 字幕。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} w 画布宽度 * @param {number} h 画布高度 * @param {number} progress 播放进度 */ function drawHud(ctx, w, h, progress) { const enter = Math.min(1, Math.max(0, (progress - 0.14) / 0.2)); const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16)); const alpha = easeInOutSine(enter) * leave; const y = h * 0.17 - (1 - enter) * 24; ctx.save(); ctx.globalAlpha = alpha; ctx.textAlign = "center"; ctx.shadowColor = "rgba(253,230,138,0.95)"; ctx.shadowBlur = 22; ctx.fillStyle = "rgba(28,25,23,0.66)"; ctx.strokeStyle = "rgba(253,230,138,0.72)"; ctx.lineWidth = 2; roundRect(ctx, w * 0.5 - 226, y - 42, 452, 88, 18); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#fef3c7"; ctx.font = "700 16px serif"; ctx.fillText("ZTZ-99A ARMORED FORCE", w * 0.5, y - 12); ctx.fillStyle = "#ffffff"; ctx.font = "900 40px serif"; ctx.fillText("99A主战坦克 重装入场", w * 0.5, y + 28); ctx.restore(); } /** * 绘制圆角矩形路径。 * * @param {CanvasRenderingContext2D} ctx Canvas 上下文 * @param {number} x 左上角 x * @param {number} y 左上角 y * @param {number} w 宽度 * @param {number} h 高度 * @param {number} r 圆角半径 */ function roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h); ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); } /** * 启动 99A 主战坦克重装入场特效。 * * @param {HTMLCanvasElement} canvas 全屏特效画布 * @param {Function} onEnd 结束回调 * @returns {{cancel: Function}} */ function start(canvas, onEnd) { const ctx = canvas.getContext("2d"); const w = canvas.width; const h = canvas.height; const dust = createDust(w, h); const startTime = performance.now(); let animId = null; let finished = false; /** * 统一结束动画,手动取消时只清理不回调。 * * @param {boolean} canceled 是否为手动取消 */ function finish(canceled) { if (finished) { return; } finished = true; if (animId) { cancelAnimationFrame(animId); } ctx.clearRect(0, 0, w, h); if (!canceled) { onEnd(); } } /** * 逐帧绘制坦克入场动画。 * * @param {number} now 当前高精度时间 */ function animate(now) { const elapsed = now - startTime; const progress = Math.min(1, elapsed / DURATION); const entry = easeOutCubic(Math.min(1, progress / 0.64)); const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24)); const tankX = -w * 0.24 + entry * w * 0.92 + exit * w * 0.62; const tankY = h * 0.66 + Math.sin(progress * 24) * 2.5; const scale = Math.min(1.12, Math.max(0.68, w / 1180)); ctx.clearRect(0, 0, w, h); drawBackdrop(ctx, w, h, progress); drawDust(ctx, dust, w, progress); drawShockwave(ctx, w, h, progress); drawTank(ctx, tankX, tankY, scale, progress); drawHud(ctx, w, h, progress); if (progress < 1) { animId = requestAnimationFrame(animate); } else { finish(false); } } animId = requestAnimationFrame(animate); return { cancel() { finish(true); }, }; } return { start }; })(); window.Type99AEffect = Type99AEffect;