530 lines
17 KiB
JavaScript
530 lines
17 KiB
JavaScript
/**
|
|
* 文件功能:聊天室福建舰航空母舰入场预览特效
|
|
*
|
|
* 使用全屏透明 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 播放进度
|
|
* @param {string} title 入场标题
|
|
* @param {string} userInfo 用户身份信息
|
|
*/
|
|
function drawHud(ctx, w, h, progress, title, userInfo) {
|
|
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 - 340, y - 56, 680, 120, 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 - 24);
|
|
ctx.fillStyle = "#a5f3fc";
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 启动福建舰航母入场预览特效。
|
|
*
|
|
* @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 waves = createWaves(w, h);
|
|
const title = String(options.effect_title || "福建舰 航母入场").trim() || "福建舰 航母入场";
|
|
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 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, title, userInfo);
|
|
|
|
if (progress < 1) {
|
|
animId = requestAnimationFrame(animate);
|
|
} else {
|
|
finish(false);
|
|
}
|
|
}
|
|
|
|
animId = requestAnimationFrame(animate);
|
|
|
|
return {
|
|
cancel() {
|
|
finish(true);
|
|
},
|
|
};
|
|
}
|
|
|
|
return { start };
|
|
})();
|
|
|
|
window.FujianEffect = FujianEffect;
|