新增聊天室座驾系统

This commit is contained in:
pllx
2026-04-30 09:40:50 +08:00
parent 45ce8b2b2d
commit 3c95478097
32 changed files with 3982 additions and 53 deletions
+577
View File
@@ -0,0 +1,577 @@
/**
* 文件功能:聊天室 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<Record<string, number>>}
*/
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<Record<string, number>>} 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;
+399
View File
@@ -0,0 +1,399 @@
/**
* 文件功能:聊天室东风-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 播放进度
*/
function drawHud(ctx, w, h, progress) {
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 - 246, y - 42, 492, 88, 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 - 12);
ctx.fillStyle = "#ffffff";
ctx.font = "900 38px serif";
ctx.fillText("东风-5C 洲际导弹 升空", 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();
}
/**
* 启动东风-5C洲际导弹发射预览特效。
*
* @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 particles = createParticles(120);
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);
if (progress < 1) {
animId = requestAnimationFrame(animate);
} else {
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
})();
window.Df5cEffect = Df5cEffect;
+18 -2
View File
@@ -3,7 +3,7 @@
*
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies')
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian')
*/
const EffectManager = (() => {
@@ -22,6 +22,10 @@ const EffectManager = (() => {
hearts: { key: "hearts", load: () => import("./hearts.js") },
confetti: { key: "confetti", load: () => import("./confetti.js") },
fireflies: { key: "fireflies", load: () => import("./fireflies.js") },
j35: { key: "j35", load: () => import("./j35.js") },
"99a": { key: "99a", load: () => import("./99a.js") },
df5c: { key: "df5c", load: () => import("./df5c.js") },
fujian: { key: "fujian", load: () => import("./fujian.js") },
};
// 特效模块 Promise 缓存,同类型重复触发时复用同一次加载
const _effectModulePromises = new Map();
@@ -340,7 +344,7 @@ const EffectManager = (() => {
/**
* 播放指定特效
*
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies / j35 / 99a / df5c / fujian
*/
function play(type) {
if (document.hidden) {
@@ -434,6 +438,18 @@ const EffectManager = (() => {
case "fireflies":
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
break;
case "j35":
started = _startEffect(window.J35Effect, canvas, finishCurrent);
break;
case "99a":
started = _startEffect(window.Type99AEffect, canvas, finishCurrent);
break;
case "df5c":
started = _startEffect(window.Df5cEffect, canvas, finishCurrent);
break;
case "fujian":
started = _startEffect(window.FujianEffect, canvas, finishCurrent);
break;
default:
console.warn(`[EffectManager] 未知特效类型:${type}`);
}
+396 -1
View File
@@ -19,6 +19,10 @@
* hearts 爱心飘落(温暖双音)
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
* fireflies 萤火虫(稀疏微光铃音)
* j35 歼-35 战机(喷气低频 + 高速呼啸 + 音爆扫频)
* 99a 99A 主战坦克(履带低频 + 炮击冲击 + 金属震动)
* df5c 东风-5C(发射低频 + 尾焰轰鸣 + 高空呼啸)
* fujian 福建舰(海浪低频 + 舰载机掠过 + 甲板提示音)
*/
const EffectSounds = (() => {
@@ -762,6 +766,385 @@ const EffectSounds = (() => {
};
}
/**
* 启动歼-35 战机音效:喷气低频、空中掠过和音爆扫频。
*
* @returns {Function} 停止函数
*/
function _startJ35() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.58;
master.connect(ctx.destination);
const turbine = ctx.createOscillator();
const subRumble = ctx.createOscillator();
const turbineFilter = ctx.createBiquadFilter();
const turbineGain = ctx.createGain();
turbine.type = "sawtooth";
subRumble.type = "triangle";
turbine.frequency.setValueAtTime(72, ctx.currentTime);
turbine.frequency.exponentialRampToValueAtTime(210, ctx.currentTime + 1.8);
turbine.frequency.exponentialRampToValueAtTime(96, ctx.currentTime + 7.6);
subRumble.frequency.setValueAtTime(34, ctx.currentTime);
subRumble.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.8);
subRumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.6);
turbineFilter.type = "lowpass";
turbineFilter.frequency.setValueAtTime(420, ctx.currentTime);
turbineFilter.frequency.exponentialRampToValueAtTime(2600, ctx.currentTime + 1.6);
turbineFilter.frequency.exponentialRampToValueAtTime(520, ctx.currentTime + 7.8);
turbineGain.gain.setValueAtTime(0.001, ctx.currentTime);
turbineGain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
turbineGain.gain.linearRampToValueAtTime(0.42, ctx.currentTime + 1.6);
turbineGain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
turbine.connect(turbineFilter);
subRumble.connect(turbineFilter);
turbineFilter.connect(turbineGain);
turbineGain.connect(master);
turbine.start(ctx.currentTime);
subRumble.start(ctx.currentTime);
turbine.stop(ctx.currentTime + 8.2);
subRumble.stop(ctx.currentTime + 8.2);
_scheduleNoiseSweep(ctx, master, {
delay: 0.12,
duration: 2.6,
startFreq: 180,
endFreq: 6200,
volume: 0.24,
q: 0.9,
});
_scheduleNoiseSweep(ctx, master, {
delay: 2.05,
duration: 0.72,
startFreq: 9000,
endFreq: 1200,
volume: 0.34,
q: 1.4,
});
_scheduleNoiseSweep(ctx, master, {
delay: 4.2,
duration: 1.05,
startFreq: 7200,
endFreq: 280,
volume: 0.2,
q: 1.8,
filterType: "highpass",
});
[0.42, 0.9, 1.32, 5.1].forEach((delay, index) => {
_scheduleTone(ctx, master, {
delay,
duration: 0.11,
freq: [1046.5, 1318.5, 1567.98, 2093][index % 4],
endFreq: [1567.98, 1975.53, 2349.32, 3135.96][index % 4],
volume: 0.045,
type: "square",
});
});
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 900);
}, 8800);
return () => {
clearTimeout(endTimer);
try {
master.gain.setValueAtTime(0, ctx.currentTime);
turbine.stop();
subRumble.stop();
master.disconnect();
} catch (_) {}
};
}
/**
* 启动 99A 主战坦克音效:履带低频、炮击冲击与金属震动。
*
* @returns {Function} 停止函数
*/
function _startType99A() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.52;
master.connect(ctx.destination);
const engine = ctx.createOscillator();
const track = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
engine.type = "sawtooth";
track.type = "square";
engine.frequency.setValueAtTime(42, ctx.currentTime);
engine.frequency.exponentialRampToValueAtTime(68, ctx.currentTime + 1.1);
engine.frequency.exponentialRampToValueAtTime(46, ctx.currentTime + 7.5);
track.frequency.setValueAtTime(18, ctx.currentTime);
track.frequency.linearRampToValueAtTime(24, ctx.currentTime + 2.0);
track.frequency.linearRampToValueAtTime(18, ctx.currentTime + 7.5);
filter.type = "lowpass";
filter.frequency.setValueAtTime(220, ctx.currentTime);
filter.frequency.linearRampToValueAtTime(520, ctx.currentTime + 1.4);
filter.frequency.linearRampToValueAtTime(260, ctx.currentTime + 7.8);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.32, ctx.currentTime + 0.35);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.1);
engine.connect(filter);
track.connect(filter);
filter.connect(gain);
gain.connect(master);
engine.start(ctx.currentTime);
track.start(ctx.currentTime);
engine.stop(ctx.currentTime + 8.2);
track.stop(ctx.currentTime + 8.2);
_scheduleNoiseSweep(ctx, master, {
delay: 0.2,
duration: 2.8,
startFreq: 80,
endFreq: 420,
volume: 0.18,
q: 0.8,
filterType: "lowpass",
});
_scheduleNoiseSweep(ctx, master, {
delay: 4.02,
duration: 0.5,
startFreq: 180,
endFreq: 65,
volume: 0.42,
q: 0.7,
filterType: "lowpass",
});
_scheduleNoiseSweep(ctx, master, {
delay: 4.05,
duration: 0.18,
startFreq: 4800,
endFreq: 900,
volume: 0.16,
q: 1.2,
});
[0.5, 1.1, 1.7, 2.25, 3.0, 3.55, 5.2, 5.85].forEach((delay) => {
_scheduleTone(ctx, master, {
delay,
duration: 0.08,
freq: 72,
endFreq: 44,
volume: 0.045,
type: "square",
});
});
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 900);
}, 8400);
return () => {
clearTimeout(endTimer);
try {
master.gain.setValueAtTime(0, ctx.currentTime);
engine.stop();
track.stop();
master.disconnect();
} catch (_) {}
};
}
/**
* 启动东风-5C预览音效:发射低频、尾焰轰鸣与高空呼啸。
*
* @returns {Function} 停止函数
*/
function _startDf5c() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.58;
master.connect(ctx.destination);
const rumble = ctx.createOscillator();
const flame = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
rumble.type = "sawtooth";
flame.type = "triangle";
rumble.frequency.setValueAtTime(28, ctx.currentTime);
rumble.frequency.exponentialRampToValueAtTime(76, ctx.currentTime + 2.2);
rumble.frequency.exponentialRampToValueAtTime(38, ctx.currentTime + 7.4);
flame.frequency.setValueAtTime(96, ctx.currentTime);
flame.frequency.exponentialRampToValueAtTime(220, ctx.currentTime + 2.8);
flame.frequency.exponentialRampToValueAtTime(112, ctx.currentTime + 7.2);
filter.type = "lowpass";
filter.frequency.setValueAtTime(180, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(1800, ctx.currentTime + 2.8);
filter.frequency.exponentialRampToValueAtTime(420, ctx.currentTime + 7.8);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.4, ctx.currentTime + 0.9);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.0);
rumble.connect(filter);
flame.connect(filter);
filter.connect(gain);
gain.connect(master);
rumble.start(ctx.currentTime);
flame.start(ctx.currentTime);
rumble.stop(ctx.currentTime + 8.1);
flame.stop(ctx.currentTime + 8.1);
_scheduleNoiseSweep(ctx, master, {
delay: 0.08,
duration: 3.4,
startFreq: 90,
endFreq: 2600,
volume: 0.28,
q: 0.8,
filterType: "lowpass",
});
_scheduleNoiseSweep(ctx, master, {
delay: 2.1,
duration: 2.6,
startFreq: 720,
endFreq: 8200,
volume: 0.22,
q: 1.3,
});
_scheduleNoiseSweep(ctx, master, {
delay: 4.8,
duration: 1.1,
startFreq: 9600,
endFreq: 1600,
volume: 0.18,
q: 1.8,
filterType: "highpass",
});
[0.2, 0.46, 0.74, 1.04, 1.34].forEach((delay) => {
_scheduleTone(ctx, master, {
delay,
duration: 0.12,
freq: 58,
endFreq: 32,
volume: 0.07,
type: "square",
});
});
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 900);
}, 8500);
return () => {
clearTimeout(endTimer);
try {
master.gain.setValueAtTime(0, ctx.currentTime);
rumble.stop();
flame.stop();
master.disconnect();
} catch (_) {}
};
}
/**
* 启动福建舰预览音效:海浪低频、舰载机掠过与甲板提示音。
*
* @returns {Function} 停止函数
*/
function _startFujian() {
const ctx = _getCtx();
const master = ctx.createGain();
master.gain.value = 0.42;
master.connect(ctx.destination);
const engine = ctx.createOscillator();
const wake = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const gain = ctx.createGain();
engine.type = "sawtooth";
wake.type = "triangle";
engine.frequency.setValueAtTime(38, ctx.currentTime);
engine.frequency.exponentialRampToValueAtTime(52, ctx.currentTime + 2.4);
engine.frequency.exponentialRampToValueAtTime(34, ctx.currentTime + 8.2);
wake.frequency.setValueAtTime(74, ctx.currentTime);
wake.frequency.exponentialRampToValueAtTime(92, ctx.currentTime + 2.2);
wake.frequency.exponentialRampToValueAtTime(60, ctx.currentTime + 8.2);
filter.type = "lowpass";
filter.frequency.setValueAtTime(240, ctx.currentTime);
filter.frequency.exponentialRampToValueAtTime(720, ctx.currentTime + 2.0);
filter.frequency.exponentialRampToValueAtTime(260, ctx.currentTime + 8.4);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.linearRampToValueAtTime(0.26, ctx.currentTime + 0.55);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 8.6);
engine.connect(filter);
wake.connect(filter);
filter.connect(gain);
gain.connect(master);
engine.start(ctx.currentTime);
wake.start(ctx.currentTime);
engine.stop(ctx.currentTime + 8.7);
wake.stop(ctx.currentTime + 8.7);
_scheduleNoiseSweep(ctx, master, {
delay: 0.1,
duration: 7.8,
startFreq: 120,
endFreq: 520,
volume: 0.18,
q: 0.7,
filterType: "lowpass",
});
_scheduleNoiseSweep(ctx, master, {
delay: 3.15,
duration: 1.35,
startFreq: 420,
endFreq: 7600,
volume: 0.22,
q: 1.5,
});
[0.8, 1.35, 2.0, 3.0, 4.7, 5.45].forEach((delay, index) => {
_scheduleTone(ctx, master, {
delay,
duration: 0.12,
freq: [880, 1174.66, 1567.98][index % 3],
endFreq: [987.77, 1318.51, 1760][index % 3],
volume: 0.045,
type: "sine",
});
});
const endTimer = setTimeout(() => {
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
setTimeout(() => {
try {
master.disconnect();
} catch (_) {}
}, 900);
}, 9000);
return () => {
clearTimeout(endTimer);
try {
master.gain.setValueAtTime(0, ctx.currentTime);
engine.stop();
wake.stop();
master.disconnect();
} catch (_) {}
};
}
// ─── 公开 API ──────────────────────────────────────────────────
/**
@@ -770,7 +1153,7 @@ const EffectSounds = (() => {
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
*
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies'
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies' | 'j35' | '99a' | 'df5c' | 'fujian'
*/
function play(type) {
// 用户开启禁音则跳过
@@ -813,6 +1196,18 @@ const EffectSounds = (() => {
case "fireflies":
_stopFn = _startFireflies();
break;
case "j35":
_stopFn = _startJ35();
break;
case "99a":
_stopFn = _startType99A();
break;
case "df5c":
_stopFn = _startDf5c();
break;
case "fujian":
_stopFn = _startFujian();
break;
default:
break;
}
+521
View File
@@ -0,0 +1,521 @@
/**
* 文件功能:聊天室福建舰航空母舰入场预览特效
*
* 使用全屏透明 Canvas 绘制福建舰航母破浪入场、甲板灯带、舰岛、编号 18、
* 弹射轨道、舰载机剪影起飞和海面尾流。该效果只用于本地视觉预览。
*/
const FujianEffect = (() => {
const DURATION = 8800;
const SEA = "#0f766e";
const DECK = "#334155";
const HULL = "#1f2937";
const LIGHT = "#67e8f9";
/**
* 缓入缓出曲线,让航母移动有重量感。
*
* @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} w 画布宽度
* @param {number} h 画布高度
* @returns {Array<Record<string, number>>}
*/
function createWaves(w, h) {
return Array.from({ length: 72 }, () => ({
x: Math.random() * w,
y: h * (0.62 + Math.random() * 0.28),
speed: 0.7 + Math.random() * 2.6,
width: 24 + Math.random() * 96,
alpha: 0.1 + Math.random() * 0.32,
}));
}
/**
* 绘制海面背景、雷达线和远处光带。
*
* @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(15,23,42,${0.68 * fade})`);
sky.addColorStop(0.5, `rgba(30,64,175,${0.18 * fade})`);
sky.addColorStop(1, `rgba(15,118,110,${0.42 * fade})`);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
ctx.save();
ctx.globalAlpha = fade;
ctx.strokeStyle = "rgba(103,232,249,0.16)";
ctx.lineWidth = 1.2;
for (let y = h * 0.56; y < h; y += 38) {
ctx.beginPath();
ctx.moveTo(0, y + Math.sin(progress * 16 + y) * 6);
ctx.lineTo(w, y + Math.cos(progress * 12 + y) * 6);
ctx.stroke();
}
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = "rgba(251,191,36,0.2)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(w * 0.12, h * 0.22);
ctx.lineTo(w * 0.88, h * 0.2 + Math.sin(progress * 10) * 5);
ctx.stroke();
ctx.strokeStyle = "rgba(34,211,238,0.18)";
for (let i = 0; i < 5; i++) {
ctx.beginPath();
ctx.arc(w * 0.8, h * 0.32, w * (0.08 + i * 0.07 + progress * 0.02), 0, Math.PI * 2);
ctx.stroke();
}
ctx.restore();
}
/**
* 绘制动态海浪和航母尾流。
*
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
* @param {Array<Record<string, number>>} waves 海浪粒子
* @param {number} w 画布宽度
* @param {number} progress 播放进度
* @param {number} carrierX 航母中心 x
* @param {number} carrierY 航母中心 y
* @param {number} scale 缩放比例
*/
function drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale) {
ctx.save();
ctx.lineCap = "round";
waves.forEach((wave, index) => {
const x = (wave.x - progress * 420 * wave.speed + index * 37) % (w + 220);
ctx.globalAlpha = wave.alpha;
ctx.strokeStyle = index % 3 === 0 ? "rgba(240,253,250,0.65)" : "rgba(125,211,252,0.44)";
ctx.lineWidth = index % 3 === 0 ? 3 : 2;
ctx.beginPath();
ctx.moveTo(x - 110, wave.y);
ctx.lineTo(x - 110 + wave.width, wave.y + Math.sin(progress * 20 + index) * 5);
ctx.stroke();
});
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = 0.7;
ctx.strokeStyle = "rgba(255,255,255,0.72)";
ctx.lineWidth = 8 * scale;
ctx.beginPath();
ctx.moveTo(carrierX - 290 * scale, carrierY + 88 * scale);
ctx.bezierCurveTo(
carrierX - 460 * scale,
carrierY + 118 * scale,
carrierX - 580 * scale,
carrierY + 72 * scale,
carrierX - 720 * scale,
carrierY + 128 * scale,
);
ctx.stroke();
ctx.globalAlpha = 0.36;
ctx.lineWidth = 18 * scale;
ctx.stroke();
ctx.restore();
}
/**
* 绘制福建舰航母主体。
*
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
* @param {number} x 航母中心 x
* @param {number} y 航母中心 y
* @param {number} scale 缩放比例
* @param {number} progress 播放进度
*/
function drawCarrier(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 = 22;
ctx.fillStyle = "rgba(0,0,0,0.34)";
ctx.beginPath();
ctx.ellipse(0, 112, 500, 32, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 舰体侧面和上翘舰艏,整体拉长压低,更接近参考图的侧视航母比例。
const hull = ctx.createLinearGradient(-520, 22, 520, 126);
hull.addColorStop(0, "#0f172a");
hull.addColorStop(0.42, HULL);
hull.addColorStop(1, "#475569");
ctx.fillStyle = hull;
ctx.beginPath();
ctx.moveTo(-540, 24);
ctx.lineTo(444, 22);
ctx.lineTo(522, 48);
ctx.lineTo(458, 112);
ctx.lineTo(-438, 122);
ctx.lineTo(-520, 68);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(148,163,184,0.34)";
ctx.lineWidth = 3;
for (let i = 0; i < 17; i++) {
const px = -420 + i * 54;
ctx.beginPath();
ctx.moveTo(px, 42);
ctx.lineTo(px + 22, 42);
ctx.stroke();
}
// 大型平直飞行甲板:长矩形甲板与左侧上翘舰艏是主要识别点。
const deck = ctx.createLinearGradient(-520, -70, 512, 56);
deck.addColorStop(0, "#475569");
deck.addColorStop(0.48, DECK);
deck.addColorStop(1, "#64748b");
ctx.fillStyle = deck;
ctx.beginPath();
ctx.moveTo(-520, -52);
ctx.lineTo(352, -62);
ctx.lineTo(512, -24);
ctx.lineTo(468, 42);
ctx.lineTo(-456, 58);
ctx.lineTo(-548, 6);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = "rgba(226,232,240,0.46)";
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(-492, -38);
ctx.lineTo(474, -28);
ctx.stroke();
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(-500, 48);
ctx.lineTo(442, 30);
ctx.stroke();
ctx.strokeStyle = "rgba(226,232,240,0.5)";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-440, 4);
ctx.lineTo(398, -18);
ctx.stroke();
// 三条弹射轨道和甲板灯带,突出福建舰电磁弹射视觉。
ctx.strokeStyle = "rgba(103,232,249,0.82)";
ctx.lineWidth = 3;
[-38, -15, 8].forEach((offset, index) => {
ctx.beginPath();
ctx.moveTo(-348, offset);
ctx.lineTo(368, offset - 22 - index * 4);
ctx.stroke();
});
ctx.strokeStyle = "rgba(251,191,36,0.72)";
ctx.lineWidth = 2;
for (let i = 0; i < 24; i++) {
const lx = -436 + i * 38;
ctx.beginPath();
ctx.moveTo(lx, 44 + Math.sin(progress * 20 + i) * 2);
ctx.lineTo(lx + 14, 43 + Math.sin(progress * 20 + i) * 2);
ctx.stroke();
}
// 舰岛、雷达桅杆和红旗标识,位置靠中右,贴近参考图。
ctx.fillStyle = "#1e293b";
ctx.beginPath();
ctx.moveTo(106, -112);
ctx.lineTo(196, -124);
ctx.lineTo(226, -74);
ctx.lineTo(178, -36);
ctx.lineTo(92, -46);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#475569";
ctx.beginPath();
ctx.moveTo(126, -176);
ctx.lineTo(182, -166);
ctx.lineTo(172, -118);
ctx.lineTo(106, -126);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#cbd5e1";
for (let i = 0; i < 4; i++) {
ctx.fillRect(122 + i * 14, -154, 8, 8);
ctx.fillRect(118 + i * 14, -138, 8, 8);
}
ctx.strokeStyle = "rgba(226,232,240,0.5)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(154, -174);
ctx.lineTo(154, -220);
ctx.moveTo(134, -204);
ctx.lineTo(188, -216);
ctx.stroke();
ctx.fillStyle = "rgba(239,68,68,0.95)";
ctx.fillRect(184, -104, 34, 22);
ctx.fillStyle = "#fde68a";
ctx.font = "900 13px serif";
ctx.fillText("★", 192, -88);
ctx.fillStyle = "rgba(255,255,255,0.92)";
ctx.font = "900 46px serif";
ctx.fillText("18", -456, 24);
ctx.fillStyle = "rgba(203,213,225,0.92)";
ctx.font = "900 28px serif";
ctx.fillText("福建舰", -72, -34);
drawDeckAircraft(ctx, progress);
drawBowspray(ctx, progress);
ctx.restore();
}
/**
* 绘制甲板飞机剪影和一架起飞中的舰载机。
*
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
* @param {number} progress 播放进度
*/
function drawDeckAircraft(ctx, progress) {
[
[-356, -30, -0.03, 0.34],
[-296, -7, -0.02, 0.36],
[-244, 24, 0.02, 0.34],
[-188, -34, -0.04, 0.36],
[-132, -8, -0.02, 0.38],
[-76, 22, 0.02, 0.36],
[-22, -35, -0.05, 0.38],
[34, -12, -0.02, 0.36],
[92, 16, 0.02, 0.34],
[156, -26, -0.04, 0.34],
[214, 0, -0.02, 0.32],
[276, -30, -0.04, 0.3],
].forEach(([x, y, rotation, scale]) => {
drawJet(ctx, x, y, scale, rotation, "rgba(15,23,42,0.86)");
});
const takeoff = Math.max(0, Math.min(1, (progress - 0.34) / 0.42));
if (takeoff <= 0 || takeoff >= 1) {
return;
}
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.globalAlpha = Math.sin(takeoff * Math.PI);
ctx.strokeStyle = "rgba(103,232,249,0.7)";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(248, -36);
ctx.lineTo(248 + takeoff * 270, -36 - takeoff * 132);
ctx.stroke();
drawJet(ctx, 248 + takeoff * 270, -36 - takeoff * 132, 0.56, -0.34, "rgba(226,232,240,0.95)");
ctx.restore();
}
/**
* 绘制简化舰载机剪影。
*
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
* @param {number} x 中心 x
* @param {number} y 中心 y
* @param {number} scale 缩放比例
* @param {number} rotation 旋转角度
* @param {string} fill 填充颜色
*/
function drawJet(ctx, x, y, scale, rotation, fill) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.scale(scale, scale);
ctx.fillStyle = fill;
ctx.beginPath();
ctx.moveTo(68, 0);
ctx.lineTo(-46, -18);
ctx.lineTo(-22, -3);
ctx.lineTo(-66, 0);
ctx.lineTo(-22, 3);
ctx.lineTo(-46, 18);
ctx.closePath();
ctx.fill();
ctx.restore();
}
/**
* 绘制舰艏破浪飞沫。
*
* @param {CanvasRenderingContext2D} ctx Canvas 上下文
* @param {number} progress 播放进度
*/
function drawBowspray(ctx, progress) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = "rgba(240,253,250,0.72)";
ctx.lineWidth = 4;
for (let i = 0; i < 6; i++) {
const spread = 20 + i * 16 + Math.sin(progress * 18 + i) * 8;
ctx.beginPath();
ctx.moveTo(470, 48 + i * 5);
ctx.quadraticCurveTo(520 + spread, 60 + i * 14, 570 + spread, 38 + i * 4);
ctx.stroke();
}
ctx.restore();
}
/**
* 绘制福建舰测试 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.12) / 0.2));
const leave = Math.min(1, Math.max(0, (1 - progress) / 0.14));
const alpha = easeInOutCubic(enter) * leave;
const y = h * 0.17 - (1 - enter) * 22;
ctx.save();
ctx.globalAlpha = alpha;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(15,23,42,0.68)";
ctx.strokeStyle = "rgba(103,232,249,0.72)";
ctx.lineWidth = 2;
roundRect(ctx, w * 0.5 - 236, y - 42, 472, 88, 18);
ctx.fill();
ctx.stroke();
ctx.shadowColor = "rgba(103,232,249,0.95)";
ctx.shadowBlur = 20;
ctx.fillStyle = "#cffafe";
ctx.font = "700 16px serif";
ctx.fillText("FUJIAN AIRCRAFT CARRIER PREVIEW", w * 0.5, y - 12);
ctx.fillStyle = "#ffffff";
ctx.font = "900 38px serif";
ctx.fillText("福建舰 航母入场", 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();
}
/**
* 启动福建舰航母入场预览特效。
*
* @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 waves = createWaves(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 progress = Math.min(1, (now - startTime) / DURATION);
const enter = easeInOutCubic(Math.min(1, progress / 0.72));
const exit = easeInOutCubic(Math.max(0, (progress - 0.78) / 0.22));
const scale = Math.min(1.02, Math.max(0.64, w / 1280));
const carrierX = -w * 0.24 + enter * w * 0.76 + exit * w * 0.54;
const carrierY = h * 0.66 + Math.sin(progress * 18) * 3;
ctx.clearRect(0, 0, w, h);
drawBackdrop(ctx, w, h, progress);
drawWaves(ctx, waves, w, progress, carrierX, carrierY, scale);
drawCarrier(ctx, carrierX, carrierY, 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.FujianEffect = FujianEffect;
+467
View File
@@ -0,0 +1,467 @@
/**
* 文件功能:聊天室歼-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 播放进度
*/
function drawHud(ctx, w, h, progress) {
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 - 230, y - 44, 460, 92, 18);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#bae6fd";
ctx.font = "700 16px serif";
ctx.fillText("STEALTH FIGHTER ARRIVAL", w * 0.5, y - 12);
ctx.fillStyle = "#ffffff";
ctx.font = "900 42px serif";
ctx.fillText("中国歼-35 破空入场", 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();
}
/**
* 启动歼-35 战机入场特效。
*
* @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 speedLines = createSpeedLines(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.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);
if (progress < 1) {
animId = requestAnimationFrame(animate);
} else {
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
})();
window.J35Effect = J35Effect;