476 lines
15 KiB
JavaScript
476 lines
15 KiB
JavaScript
/**
|
|
* 文件功能:聊天室歼-35 战机入场特效
|
|
*
|
|
* 使用全屏透明 Canvas 绘制隐身战机高速掠过、喷口尾焰、音爆环、
|
|
* 流光航迹与战术 HUD 字幕,作为座驾/载具入场的战机版本原型。
|
|
*/
|
|
|
|
const J35Effect = (() => {
|
|
const DURATION = 8200;
|
|
const STEEL = "#9ca3af";
|
|
const DARK_STEEL = "#1f2937";
|
|
const COCKPIT = "#0f172a";
|
|
const AFTERBURNER = "#38bdf8";
|
|
const WARNING_RED = "#ef4444";
|
|
|
|
/**
|
|
* 缓出曲线,用于战机高速进场后略微减速展示。
|
|
*
|
|
* @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<Record<string, number|string>>}
|
|
*/
|
|
function createSpeedLines(w, h) {
|
|
return Array.from({ length: 110 }, () => ({
|
|
x: Math.random() * w,
|
|
y: h * (0.18 + Math.random() * 0.68),
|
|
speed: 2 + Math.random() * 5,
|
|
length: 90 + Math.random() * 190,
|
|
alpha: 0.18 + Math.random() * 0.42,
|
|
color: [AFTERBURNER, "#ffffff", "#fde68a", "#60a5fa"][Math.floor(Math.random() * 4)],
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 绘制夜航背景与扫描网格。
|
|
*
|
|
* @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.48, 0, w * 0.5, h * 0.5, Math.max(w, h) * 0.82);
|
|
gradient.addColorStop(0, `rgba(15,23,42,${0.52 * fade})`);
|
|
gradient.addColorStop(0.55, `rgba(8,47,73,${0.26 * 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 < 10; i++) {
|
|
const y = h * (0.18 + i * 0.07);
|
|
ctx.strokeStyle = `rgba(56,189,248,${0.08 * fade})`;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y + Math.sin(progress * 18 + i) * 6);
|
|
ctx.lineTo(w, y + Math.cos(progress * 15 + i) * 6);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制高速运动线,方向与战机飞行一致。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {Array<Record<string, number|string>>} lines 流光线集合
|
|
* @param {number} w 画布宽度
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawSpeedLines(ctx, lines, w, progress) {
|
|
const fade = Math.min(1, progress / 0.12) * Math.min(1, (1 - progress) / 0.1);
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
lines.forEach((line, index) => {
|
|
const travel = (progress * (900 + line.speed * 120) + index * 71) % (w + 520);
|
|
const x = w + 260 - travel;
|
|
ctx.globalAlpha = line.alpha * fade;
|
|
ctx.strokeStyle = line.color;
|
|
ctx.lineWidth = 1.4 + line.speed * 0.18;
|
|
ctx.shadowColor = line.color;
|
|
ctx.shadowBlur = 12;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, line.y);
|
|
ctx.lineTo(x + line.length, line.y + Math.sin(progress * 22 + index) * 4);
|
|
ctx.stroke();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制战机尾部双发喷口尾焰。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawAfterburners(ctx, progress) {
|
|
const pulse = 0.78 + Math.sin(progress * 70) * 0.16;
|
|
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
[[168, -12], [168, 12]].forEach(([x, y]) => {
|
|
const flame = ctx.createLinearGradient(x, y, x + 170, y);
|
|
flame.addColorStop(0, `rgba(255,255,255,${0.9 * pulse})`);
|
|
flame.addColorStop(0.18, `rgba(56,189,248,${0.78 * pulse})`);
|
|
flame.addColorStop(0.58, `rgba(59,130,246,${0.3 * pulse})`);
|
|
flame.addColorStop(1, "rgba(59,130,246,0)");
|
|
ctx.fillStyle = flame;
|
|
ctx.shadowColor = AFTERBURNER;
|
|
ctx.shadowBlur = 26;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y - 9);
|
|
ctx.bezierCurveTo(x + 58, y - 18, x + 115, y - 18, x + 178, y);
|
|
ctx.bezierCurveTo(x + 115, y + 18, x + 58, y + 18, x, y + 9);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
});
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制歼-35 风格隐身战机主体。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} x 战机中心 x
|
|
* @param {number} y 战机中心 y
|
|
* @param {number} scale 缩放比例
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawJet(ctx, x, y, scale, progress) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.scale(scale, scale);
|
|
ctx.rotate(Math.sin(progress * 10) * 0.018);
|
|
|
|
drawAfterburners(ctx, progress);
|
|
|
|
const bodyGradient = ctx.createLinearGradient(-210, -62, 190, 62);
|
|
bodyGradient.addColorStop(0, "#d1d5db");
|
|
bodyGradient.addColorStop(0.34, "#6b7280");
|
|
bodyGradient.addColorStop(0.64, "#374151");
|
|
bodyGradient.addColorStop(1, "#111827");
|
|
|
|
ctx.save();
|
|
ctx.shadowColor = "rgba(148,163,184,0.9)";
|
|
ctx.shadowBlur = 18;
|
|
|
|
// 主翼与机体采用多边形硬折线,体现隐身战机边缘。
|
|
ctx.fillStyle = bodyGradient;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-230, 0);
|
|
ctx.lineTo(-96, -44);
|
|
ctx.lineTo(12, -120);
|
|
ctx.lineTo(58, -42);
|
|
ctx.lineTo(170, -70);
|
|
ctx.lineTo(142, -18);
|
|
ctx.lineTo(202, 0);
|
|
ctx.lineTo(142, 18);
|
|
ctx.lineTo(170, 70);
|
|
ctx.lineTo(58, 42);
|
|
ctx.lineTo(12, 120);
|
|
ctx.lineTo(-96, 44);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
const spineGradient = ctx.createLinearGradient(-200, -16, 178, 16);
|
|
spineGradient.addColorStop(0, "#e5e7eb");
|
|
spineGradient.addColorStop(0.45, "#6b7280");
|
|
spineGradient.addColorStop(1, "#1f2937");
|
|
ctx.fillStyle = spineGradient;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-222, 0);
|
|
ctx.lineTo(-80, -24);
|
|
ctx.lineTo(92, -18);
|
|
ctx.lineTo(184, 0);
|
|
ctx.lineTo(92, 18);
|
|
ctx.lineTo(-80, 24);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// 机头下方深色进气道。
|
|
ctx.fillStyle = "rgba(3,7,18,0.72)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(-92, -33);
|
|
ctx.lineTo(-38, -74);
|
|
ctx.lineTo(-8, -55);
|
|
ctx.lineTo(-60, -24);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(-92, 33);
|
|
ctx.lineTo(-38, 74);
|
|
ctx.lineTo(-8, 55);
|
|
ctx.lineTo(-60, 24);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// 座舱盖。
|
|
const canopy = ctx.createLinearGradient(-148, -18, -58, 18);
|
|
canopy.addColorStop(0, "#020617");
|
|
canopy.addColorStop(0.55, "#1e3a8a");
|
|
canopy.addColorStop(1, "#111827");
|
|
ctx.fillStyle = canopy;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-160, 0);
|
|
ctx.bezierCurveTo(-130, -28, -78, -28, -48, 0);
|
|
ctx.bezierCurveTo(-78, 26, -130, 26, -160, 0);
|
|
ctx.fill();
|
|
|
|
// 双垂尾。
|
|
ctx.fillStyle = DARK_STEEL;
|
|
ctx.beginPath();
|
|
ctx.moveTo(78, -44);
|
|
ctx.lineTo(154, -120);
|
|
ctx.lineTo(132, -38);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.beginPath();
|
|
ctx.moveTo(78, 44);
|
|
ctx.lineTo(154, 120);
|
|
ctx.lineTo(132, 38);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// 中国战机识别元素:低调红星,不做过大以免影响隐身外形。
|
|
drawRedStar(ctx, 18, -47, 15);
|
|
drawRedStar(ctx, 18, 47, 15);
|
|
|
|
// 面板高光线。
|
|
ctx.strokeStyle = "rgba(229,231,235,0.34)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-205, 0);
|
|
ctx.lineTo(168, 0);
|
|
ctx.moveTo(-70, -22);
|
|
ctx.lineTo(86, -16);
|
|
ctx.moveTo(-70, 22);
|
|
ctx.lineTo(86, 16);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制低调红星机徽。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} x 中心 x
|
|
* @param {number} y 中心 y
|
|
* @param {number} radius 半径
|
|
*/
|
|
function drawRedStar(ctx, x, y, radius) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
ctx.fillStyle = "rgba(239,68,68,0.9)";
|
|
ctx.strokeStyle = "rgba(254,202,202,0.68)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < 10; i++) {
|
|
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
|
|
const r = i % 2 === 0 ? radius : radius * 0.42;
|
|
const px = Math.cos(angle) * r;
|
|
const py = Math.sin(angle) * r;
|
|
if (i === 0) {
|
|
ctx.moveTo(px, py);
|
|
} else {
|
|
ctx.lineTo(px, py);
|
|
}
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* 绘制高速掠过时的音爆环。
|
|
*
|
|
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
|
|
* @param {number} w 画布宽度
|
|
* @param {number} h 画布高度
|
|
* @param {number} progress 播放进度
|
|
*/
|
|
function drawSonicRing(ctx, w, h, progress) {
|
|
const ringProgress = Math.max(0, Math.min(1, (progress - 0.38) / 0.22));
|
|
if (ringProgress <= 0 || ringProgress >= 1) {
|
|
return;
|
|
}
|
|
|
|
const alpha = Math.sin(ringProgress * Math.PI);
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "lighter";
|
|
ctx.strokeStyle = `rgba(224,242,254,${0.42 * alpha})`;
|
|
ctx.lineWidth = 5;
|
|
ctx.beginPath();
|
|
ctx.ellipse(w * 0.5, h * 0.54, w * (0.08 + ringProgress * 0.32), h * (0.03 + ringProgress * 0.11), 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.strokeStyle = `rgba(56,189,248,${0.2 * alpha})`;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(w * 0.5, h * 0.54, w * (0.12 + ringProgress * 0.44), h * (0.05 + ringProgress * 0.16), 0, 0, Math.PI * 2);
|
|
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.13) / 0.18));
|
|
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.16));
|
|
const alpha = easeInOutSine(enter) * leave;
|
|
const y = h * 0.18 - (1 - enter) * 26;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = alpha;
|
|
ctx.textAlign = "center";
|
|
ctx.shadowColor = "rgba(56,189,248,0.95)";
|
|
ctx.shadowBlur = 22;
|
|
ctx.fillStyle = "rgba(2,6,23,0.62)";
|
|
ctx.strokeStyle = "rgba(56,189,248,0.72)";
|
|
ctx.lineWidth = 2;
|
|
roundRect(ctx, w * 0.5 - 340, y - 56, 680, 120, 18);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = "#bae6fd";
|
|
ctx.font = "700 16px serif";
|
|
ctx.fillText("STEALTH FIGHTER ARRIVAL", w * 0.5, y - 24);
|
|
ctx.fillStyle = "#e0f2fe";
|
|
ctx.font = "700 18px serif";
|
|
ctx.fillText(userInfo, w * 0.5, y + 8, 620);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.font = "900 34px serif";
|
|
ctx.fillText(title, w * 0.5, y + 45, 620);
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 启动歼-35 战机入场特效。
|
|
*
|
|
* @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 speedLines = createSpeedLines(w, h);
|
|
const title = String(options.effect_title || "中国歼-35 破空入场").trim() || "中国歼-35 破空入场";
|
|
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 elapsed = now - startTime;
|
|
const progress = Math.min(1, elapsed / DURATION);
|
|
const entry = easeOutCubic(Math.min(1, progress / 0.62));
|
|
const exit = easeInOutSine(Math.max(0, (progress - 0.76) / 0.24));
|
|
const jetX = w * 1.2 - entry * w * 0.82 - exit * w * 0.58;
|
|
const jetY = h * 0.58 + Math.sin(progress * 18) * 10;
|
|
const scale = Math.min(1.14, Math.max(0.68, w / 1180));
|
|
|
|
ctx.clearRect(0, 0, w, h);
|
|
drawBackdrop(ctx, w, h, progress);
|
|
drawSpeedLines(ctx, speedLines, w, progress);
|
|
drawSonicRing(ctx, w, h, progress);
|
|
drawJet(ctx, jetX, jetY, 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.J35Effect = J35Effect;
|