Files
chatroom/resources/js/effects/99a.js
T
2026-04-30 11:07:46 +08:00

586 lines
18 KiB
JavaScript

/**
* 文件功能:聊天室 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 播放进度
* @param {string} title 入场标题
* @param {string} userInfo 用户身份信息
*/
function drawHud(ctx, w, h, progress, title, userInfo) {
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 - 340, y - 56, 680, 120, 18);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "#fef3c7";
ctx.font = "700 16px serif";
ctx.fillText("ZTZ-99A ARMORED FORCE", w * 0.5, y - 24);
ctx.fillStyle = "#fde68a";
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();
}
/**
* 启动 99A 主战坦克重装入场特效。
*
* @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 dust = createDust(w, h);
const title = String(options.effect_title || "99A主战坦克 重装入场").trim() || "99A主战坦克 重装入场";
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.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, title, userInfo);
if (progress < 1) {
animId = requestAnimationFrame(animate);
} else {
finish(false);
}
}
animId = requestAnimationFrame(animate);
return {
cancel() {
finish(true);
},
};
}
return { start };
})();
window.Type99AEffect = Type99AEffect;