408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
/**
|
|
* 文件功能:聊天室东风-5C洲际导弹发射预览特效
|
|
*
|
|
* 使用全屏透明 Canvas 绘制风格化洲际导弹升空、尾焰、烟尘冲击波、
|
|
* 雷达扫描网格和测试 HUD。该效果只用于聊天室视觉预览,不表达真实装备参数。
|
|
*/
|
|
|
|
const Df5cEffect = (() => {
|
|
const DURATION = 8200;
|
|
const FIRE = "#fb923c";
|
|
const HOT = "#fef3c7";
|
|
const RED = "#dc2626";
|
|
const BODY = "#e5e7eb";
|
|
const BODY_DARK = "#64748b";
|
|
|
|
/**
|
|
* 缓入缓出曲线,用于导弹升空和 HUD 动画。
|
|
*
|
|
* @param {number} t 0 到 1 的进度
|
|
* @returns {number}
|
|
*/
|
|
function easeInOutCubic(t) {
|
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
}
|
|
|
|
/**
|
|
* 缓出曲线,用于烟尘扩散。
|
|
*
|
|
* @param {number} t 0 到 1 的进度
|
|
* @returns {number}
|
|
*/
|
|
function easeOutCubic(t) {
|
|
return 1 - Math.pow(1 - t, 3);
|
|
}
|
|
|
|
/**
|
|
* 创建尾焰和烟尘粒子。
|
|
*
|
|
* @param {number} count 粒子数量
|
|
* @returns {Array<Record<string, number>>}
|
|
*/
|
|
function createParticles(count) {
|
|
return Array.from({ length: count }, () => ({
|
|
angle: Math.random() * Math.PI * 2,
|
|
spread: 0.3 + Math.random() * 1.3,
|
|
speed: 0.4 + Math.random() * 2.4,
|
|
size: 4 + Math.random() * 18,
|
|
alpha: 0.12 + Math.random() * 0.6,
|
|
phase: Math.random() * Math.PI * 2,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 绘制夜空、雷达网格和扫描线。
|
|
*
|
|
* @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.14) * Math.min(1, (1 - progress) / 0.12);
|
|
const sky = ctx.createLinearGradient(0, 0, 0, h);
|
|
sky.addColorStop(0, `rgba(2,6,23,${0.86 * fade})`);
|
|
sky.addColorStop(0.58, `rgba(15,23,42,${0.62 * fade})`);
|
|
sky.addColorStop(1, `rgba(30,41,59,${0.26 * fade})`);
|
|
ctx.fillStyle = sky;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = fade;
|
|
ctx.strokeStyle = "rgba(56,189,248,0.16)";
|
|
ctx.lineWidth = 1;
|
|
for (let x = -w; x < w * 2; x += 72) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + progress * 120, 0);
|
|
ctx.lineTo(x - h * 0.55 + progress * 120, h);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = h * 0.2; y < h; y += 46) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y + Math.sin(progress * 18 + y) * 2);
|
|
ctx.lineTo(w, y + Math.cos(progress * 14 + y) * 2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.strokeStyle = "rgba(248,113,113,0.34)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(w * 0.16, h * 0.76, w * (0.18 + progress * 0.22), -Math.PI * 0.95, -Math.PI * 0.12);
|
|
ctx.stroke();
|
|
|
|
const beamAngle = -Math.PI * 0.85 + progress * Math.PI * 1.15;
|
|
ctx.strokeStyle = "rgba(34,211,238,0.34)";
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(w * 0.16, h * 0.76);
|
|
ctx.lineTo(w * 0.16 + Math.cos(beamAngle) * w * 0.42, h * 0.76 + Math.sin(beamAngle) * w * 0.42);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制发射井底座、光柱和冲击波。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} w 画布宽度
|
|
* @param {number} h 画布高度
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawLaunchPad(ctx, w, h, progress) {
|
|
const ignition = Math.min(1, progress / 0.24);
|
|
const pulse = Math.sin(progress * Math.PI * 12) * 0.5 + 0.5;
|
|
const cx = w * 0.18;
|
|
const cy = h * 0.78;
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
const beam = ctx.createRadialGradient(cx, cy, 0, cx, cy, h * 0.42);
|
|
beam.addColorStop(0, `rgba(251,146,60,${0.54 * ignition})`);
|
|
beam.addColorStop(0.3, `rgba(254,243,199,${0.18 * ignition})`);
|
|
beam.addColorStop(1, "rgba(0,0,0,0)");
|
|
ctx.fillStyle = beam;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
ctx.strokeStyle = `rgba(251,146,60,${(0.32 + pulse * 0.2) * ignition})`;
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, w * (0.06 + progress * 0.28), h * (0.025 + progress * 0.08), 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = "rgba(15,23,42,0.82)";
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy + 18, w * 0.12, h * 0.035, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = "rgba(148,163,184,0.7)";
|
|
ctx.lineWidth = 3;
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制尾焰和烟尘。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {Array<Record<string, number>>} particles 粒子数组
|
|
* @param {number} tailX 尾部 x
|
|
* @param {number} tailY 尾部 y
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawExhaust(ctx, particles, tailX, tailY, progress) {
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
particles.forEach((particle, index) => {
|
|
const t = (progress * 3.2 + index * 0.013) % 1;
|
|
const spread = easeOutCubic(t) * 118 * particle.spread;
|
|
const x = tailX - spread * 0.62 + Math.cos(particle.angle) * spread * 0.36;
|
|
const y = tailY + spread * 0.86 + Math.sin(particle.angle + particle.phase) * spread * 0.24;
|
|
const alpha = particle.alpha * (1 - t);
|
|
const radius = particle.size * (0.7 + t * 2.4);
|
|
|
|
ctx.globalAlpha = alpha;
|
|
ctx.fillStyle = t < 0.34 ? HOT : t < 0.62 ? FIRE : "rgba(148,163,184,0.9)";
|
|
ctx.shadowColor = t < 0.55 ? FIRE : "rgba(148,163,184,0.8)";
|
|
ctx.shadowBlur = radius * 1.2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(x, y, radius * 0.9, radius * 1.35, -0.38, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制东风-5C风格化导弹。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} x 导弹中心 x
|
|
* @param {number} y 导弹中心 y
|
|
* @param {number} scale 缩放比例
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawMissile(ctx, x, y, scale, progress) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
// 导弹沿左下到右上的轨迹飞行,箭体头部必须朝右上,尾焰才会落在后方。
|
|
ctx.rotate(0.62 + Math.sin(progress * 7) * 0.012);
|
|
ctx.scale(scale, scale);
|
|
|
|
ctx.save();
|
|
ctx.shadowColor = "rgba(251,146,60,0.9)";
|
|
ctx.shadowBlur = 26;
|
|
ctx.fillStyle = "rgba(251,146,60,0.86)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(-28, 170);
|
|
ctx.lineTo(0, 260);
|
|
ctx.lineTo(28, 170);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.fillStyle = "rgba(254,243,199,0.86)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(-12, 176);
|
|
ctx.lineTo(0, 236);
|
|
ctx.lineTo(12, 176);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
const body = ctx.createLinearGradient(-44, -178, 44, 168);
|
|
body.addColorStop(0, "#f8fafc");
|
|
body.addColorStop(0.45, BODY);
|
|
body.addColorStop(1, BODY_DARK);
|
|
ctx.fillStyle = body;
|
|
roundRect(ctx, -42, -156, 84, 326, 40);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = RED;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-42, -126);
|
|
ctx.quadraticCurveTo(0, -214, 42, -126);
|
|
ctx.lineTo(42, -92);
|
|
ctx.lineTo(-42, -92);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = "#111827";
|
|
ctx.fillRect(-42, -74, 84, 10);
|
|
ctx.fillRect(-42, 74, 84, 10);
|
|
ctx.fillStyle = "rgba(239,68,68,0.92)";
|
|
ctx.fillRect(-42, -22, 84, 30);
|
|
|
|
ctx.fillStyle = "#111827";
|
|
ctx.font = "900 30px serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText("DF-5C", 0, 50);
|
|
ctx.fillStyle = "#fef3c7";
|
|
ctx.font = "900 20px serif";
|
|
ctx.fillText("★", 0, -1);
|
|
|
|
ctx.fillStyle = "#334155";
|
|
ctx.beginPath();
|
|
ctx.moveTo(-42, 102);
|
|
ctx.lineTo(-104, 166);
|
|
ctx.lineTo(-42, 152);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(42, 102);
|
|
ctx.lineTo(104, 166);
|
|
ctx.lineTo(42, 152);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = "rgba(255,255,255,0.42)";
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-20, -112);
|
|
ctx.lineTo(-20, 130);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制测试 HUD 文案。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} w 画布宽度
|
|
* @param {number} h 画布高度
|
|
* @param {number} progress 播放进度
|
|
* @param {string} title 入场标题
|
|
* @param {string} userInfo 用户身份信息
|
|
*/
|
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
|
const enter = Math.min(1, Math.max(0, (progress - 0.1) / 0.18));
|
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
|
|
const alpha = easeInOutCubic(enter) * leave;
|
|
const y = h * 0.16 - (1 - enter) * 20;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.textAlign = "center";
|
|
ctx.fillStyle = "rgba(15,23,42,0.68)";
|
|
ctx.strokeStyle = "rgba(248,113,113,0.72)";
|
|
ctx.lineWidth = 2;
|
|
roundRect(ctx, w * 0.5 - 350, y - 56, 700, 120, 18);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.shadowColor = "rgba(248,113,113,0.95)";
|
|
ctx.shadowBlur = 22;
|
|
ctx.fillStyle = "#fee2e2";
|
|
ctx.font = "700 16px serif";
|
|
ctx.fillText("DF-5C STRATEGIC LAUNCH PREVIEW", w * 0.5, y - 24);
|
|
ctx.fillStyle = "#fecaca";
|
|
ctx.font = "700 18px serif";
|
|
ctx.fillText(userInfo, w * 0.5, y + 8, 640);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.font = "900 34px serif";
|
|
ctx.fillText(title, w * 0.5, y + 45, 640);
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 启动东风-5C洲际导弹发射预览特效。
|
|
*
|
|
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
|
* @param {Function} onEnd 结束回调
|
|
* @param {object} options 特效附加参数
|
|
* @returns {{cancel: Function}}
|
|
*/
|
|
function start(canvas, onEnd, options = {}) {
|
|
const ctx = canvas.getContext("2d");
|
|
const w = canvas.width;
|
|
const h = canvas.height;
|
|
const particles = createParticles(120);
|
|
const title = String(options.effect_title || "东风-5C 洲际导弹 升空").trim() || "东风-5C 洲际导弹 升空";
|
|
const userInfo = String(options.effect_user_info || "").trim();
|
|
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 progress = Math.min(1, (now - startTime) / DURATION);
|
|
const launch = easeInOutCubic(Math.min(1, progress / 0.78));
|
|
const launchX = w * (0.18 + launch * 0.66);
|
|
const launchY = h * (0.78 - launch * 0.95);
|
|
const scale = Math.min(1.08, Math.max(0.7, w / 1240));
|
|
const tailX = launchX - Math.sin(0.62) * 168 * scale;
|
|
const tailY = launchY + Math.cos(0.62) * 168 * scale;
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
drawBackdrop(ctx, w, h, progress);
|
|
drawLaunchPad(ctx, w, h, progress);
|
|
drawExhaust(ctx, particles, tailX, tailY, progress);
|
|
drawMissile(ctx, launchX, launchY, scale, progress);
|
|
drawHud(ctx, w, h, progress, title, userInfo);
|
|
|
|
if (progress < 1) {
|
|
animId = requestAnimationFrame(animate);
|
|
} else {
|
|
finish(false);
|
|
}
|
|
}
|
|
|
|
animId = requestAnimationFrame(animate);
|
|
|
|
return {
|
|
cancel() {
|
|
finish(true);
|
|
},
|
|
};
|
|
}
|
|
|
|
return { start };
|
|
})();
|
|
|
|
window.Df5cEffect = Df5cEffect;
|