将聊天室特效脚本纳入 Vite 打包
This commit is contained in:
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 文件功能:聊天室全屏特效 Vite 入口
|
||||
*
|
||||
* 按原 public script 顺序加载所有全屏特效模块,让生产环境由 Vite 统一压缩、加 hash 并减少请求数。
|
||||
*/
|
||||
|
||||
import "./effects/effect-sounds.js";
|
||||
import "./effects/effect-manager.js";
|
||||
import "./effects/fireworks.js";
|
||||
import "./effects/rain.js";
|
||||
import "./effects/lightning.js";
|
||||
import "./effects/snow.js";
|
||||
import "./effects/sakura.js";
|
||||
import "./effects/meteors.js";
|
||||
import "./effects/gold-rain.js";
|
||||
import "./effects/hearts.js";
|
||||
import "./effects/confetti.js";
|
||||
import "./effects/fireflies.js";
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 文件功能:聊天室彩带庆典特效
|
||||
*
|
||||
* 通过大量彩纸碎片与飘带在空中散落、翻转,形成明显的庆典氛围,
|
||||
* 适合活动开始、中奖提示和管理员公告等场景。
|
||||
*/
|
||||
|
||||
const ConfettiEffect = (() => {
|
||||
class Piece {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化彩带碎片参数。
|
||||
*/
|
||||
reset() {
|
||||
this.x = this.w * (0.15 + Math.random() * 0.7);
|
||||
this.y = -Math.random() * this.h * 0.2;
|
||||
this.vx = Math.random() * 4.4 - 2.2;
|
||||
this.vy = Math.random() * 1.6 + 0.8;
|
||||
this.gravity = Math.random() * 0.03 + 0.025;
|
||||
this.rot = Math.random() * Math.PI * 2;
|
||||
this.rotSpeed = (Math.random() - 0.5) * 0.16;
|
||||
this.width = Math.random() * 10 + 6;
|
||||
this.height = Math.random() * 18 + 8;
|
||||
this.wave = Math.random() * Math.PI * 2;
|
||||
this.waveSpeed = Math.random() * 0.06 + 0.03;
|
||||
this.alpha = Math.random() * 0.2 + 0.75;
|
||||
this.color = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899"][
|
||||
Math.floor(Math.random() * 6)
|
||||
];
|
||||
this.isRibbon = Math.random() > 0.72;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新碎片运动状态。
|
||||
*/
|
||||
update() {
|
||||
this.wave += this.waveSpeed;
|
||||
this.vy += this.gravity;
|
||||
this.vx *= 0.995;
|
||||
this.x += this.vx + Math.sin(this.wave) * 0.65;
|
||||
this.y += this.vy;
|
||||
this.rot += this.rotSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断彩带是否仍在画布内。
|
||||
*/
|
||||
get alive() {
|
||||
return this.y < this.h + 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单个彩带碎片。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
const scaleX = Math.max(0.18, Math.abs(Math.cos(this.rot)));
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
ctx.rotate(this.rot);
|
||||
ctx.scale(scaleX, 1);
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowColor = `${this.color}66`;
|
||||
ctx.shadowBlur = 6;
|
||||
|
||||
if (this.isRibbon) {
|
||||
ctx.fillRect(-this.width * 0.2, -this.height / 2, this.width * 0.4, this.height);
|
||||
} else {
|
||||
ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动彩带庆典特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 7800;
|
||||
let pieces = Array.from({ length: 90 }, () => new Piece(w, h));
|
||||
const startTime = performance.now();
|
||||
let lastSpawnAt = startTime;
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
|
||||
/**
|
||||
* 统一结束彩纸动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
pieces = [];
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
pieces = pieces.filter((piece) => {
|
||||
piece.update();
|
||||
piece.draw(ctx);
|
||||
return piece.alive;
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION * 0.9 && now - lastSpawnAt >= 120) {
|
||||
pieces.push(...Array.from({ length: 10 }, () => new Piece(w, h)));
|
||||
lastSpawnAt = now;
|
||||
}
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.ConfettiEffect = ConfettiEffect;
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* 文件功能:聊天室特效管理器
|
||||
*
|
||||
* 统一管理全屏 Canvas 特效的入口、防重入和资源清理。
|
||||
* 播放期间用户点击屏幕任意位置可立即结束当前全屏特效。
|
||||
* 使用方式:EffectManager.play('fireworks' | 'rain' | 'lightning' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies')
|
||||
*/
|
||||
|
||||
const EffectManager = (() => {
|
||||
// 当前正在播放的特效名称(防止同时播放两个特效)
|
||||
let _current = null;
|
||||
// 当前特效返回的取消函数,用于手动停止时真正停掉内部 RAF / 定时器
|
||||
let _currentCancel = null;
|
||||
// 全屏 Canvas 元素引用
|
||||
let _canvas = null;
|
||||
// 待播放特效队列,避免多个进场效果互相打断
|
||||
const _queue = [];
|
||||
// 队列最多保留 3 个待播特效,避免高频触发后播放过期动画
|
||||
const MAX_QUEUE_LENGTH = 3;
|
||||
// 当前特效播放批次,用于忽略手动停止后的旧回调
|
||||
let _playToken = 0;
|
||||
// 是否已经绑定本轮点击停止监听
|
||||
let _clickStopBound = false;
|
||||
// 延迟绑定定时器,避免触发播放的同一次点击立即停止特效
|
||||
let _clickStopTimer = null;
|
||||
// 当前画布像素倍率固定为 1,避免高清手机放大绘制面积拖慢特效
|
||||
const MAX_DPR = 1;
|
||||
|
||||
/**
|
||||
* 按窗口尺寸重置画布像素尺寸。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
*/
|
||||
function _resizeCanvas(canvas) {
|
||||
const ratio = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
canvas.width = Math.floor(window.innerWidth * ratio);
|
||||
canvas.height = Math.floor(window.innerHeight * ratio);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建全屏 Canvas 元素
|
||||
* 属性:fixed 定位,覆盖全屏,播放期间接收点击用于立即停止特效。
|
||||
*/
|
||||
function _getCanvas() {
|
||||
if (_canvas && document.body.contains(_canvas)) {
|
||||
return _canvas;
|
||||
}
|
||||
const c = document.createElement("canvas");
|
||||
c.id = "effect-canvas";
|
||||
c.style.cssText = [
|
||||
"position:fixed",
|
||||
"top:0",
|
||||
"left:0",
|
||||
"width:100vw",
|
||||
"height:100vh",
|
||||
"z-index:99999",
|
||||
"pointer-events:auto",
|
||||
"cursor:pointer",
|
||||
"touch-action:manipulation",
|
||||
].join(";");
|
||||
_resizeCanvas(c);
|
||||
document.body.appendChild(c);
|
||||
_canvas = c;
|
||||
window.addEventListener("resize", _handleResize);
|
||||
window.addEventListener("orientationchange", _handleResize);
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应窗口尺寸变化,确保手机横竖屏切换后覆盖范围正确。
|
||||
*/
|
||||
function _handleResize() {
|
||||
if (_current) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_canvas) {
|
||||
_resizeCanvas(_canvas);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定点击屏幕立即停止当前特效的监听。
|
||||
*
|
||||
* 延后一帧绑定,避免触发特效的同一次点击被误判为结束点击。
|
||||
*/
|
||||
function _bindClickStop() {
|
||||
if (_clickStopBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
_clickStopTimer = window.setTimeout(() => {
|
||||
if (!_current || _clickStopBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
_clickStopBound = true;
|
||||
if (_canvas) {
|
||||
_canvas.addEventListener("pointerdown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
_canvas.addEventListener("mousedown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
document.addEventListener("pointerdown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.addEventListener("mousedown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
}, 120);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑点击停止监听,避免特效结束后影响正常聊天操作。
|
||||
*/
|
||||
function _unbindClickStop() {
|
||||
if (_clickStopTimer) {
|
||||
window.clearTimeout(_clickStopTimer);
|
||||
_clickStopTimer = null;
|
||||
}
|
||||
|
||||
if (!_clickStopBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_canvas) {
|
||||
_canvas.removeEventListener("pointerdown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
_canvas.removeEventListener("mousedown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
document.removeEventListener("pointerdown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener("mousedown", _handleStopClick, {
|
||||
capture: true,
|
||||
});
|
||||
_clickStopBound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理屏幕点击结束特效。
|
||||
*/
|
||||
function _handleStopClick(event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 特效结束后清理 Canvas,重置状态,并停止音效。
|
||||
*
|
||||
* @param {Object} options 清理选项
|
||||
* @param {boolean} options.playNext 是否继续播放队列中的下一个特效
|
||||
* @param {number|null} options.token 当前特效播放批次
|
||||
*/
|
||||
function _cleanup({ playNext = true, token = null, cancelCurrent = false } = {}) {
|
||||
if (token !== null && token !== _playToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
_playToken++;
|
||||
_unbindClickStop();
|
||||
|
||||
if (cancelCurrent && typeof _currentCancel === "function") {
|
||||
_currentCancel();
|
||||
}
|
||||
_currentCancel = null;
|
||||
|
||||
if (_canvas && document.body.contains(_canvas)) {
|
||||
document.body.removeChild(_canvas);
|
||||
}
|
||||
window.removeEventListener("resize", _handleResize);
|
||||
window.removeEventListener("orientationchange", _handleResize);
|
||||
_canvas = null;
|
||||
_current = null;
|
||||
// 通知音效引擎停止(兜底:正常情况下音效会自行计时结束)
|
||||
if (typeof window.EffectSounds !== "undefined") {
|
||||
window.EffectSounds.stop();
|
||||
}
|
||||
|
||||
if (playNext && _queue.length > 0) {
|
||||
const nextType = _queue.shift();
|
||||
if (nextType) {
|
||||
play(nextType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将特效加入有限队列,同类型短时间重复触发时只保留一份。
|
||||
*
|
||||
* @param {string} type 待播放特效类型
|
||||
*/
|
||||
function _enqueue(type) {
|
||||
const existingIndex = _queue.indexOf(type);
|
||||
if (existingIndex !== -1) {
|
||||
_queue.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
_queue.push(type);
|
||||
while (_queue.length > MAX_QUEUE_LENGTH) {
|
||||
_queue.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录具体特效返回的取消句柄。
|
||||
*
|
||||
* @param {Object|undefined} controller 特效启动返回值
|
||||
*/
|
||||
function _bindEffectController(controller) {
|
||||
_currentCancel = typeof controller?.cancel === "function"
|
||||
? controller.cancel
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动具体特效并保存取消句柄。
|
||||
*
|
||||
* @param {Object|undefined} effectObject 特效全局对象
|
||||
* @param {HTMLCanvasElement} canvas 全屏特效画布
|
||||
* @param {Function} finishCurrent 当前特效结束回调
|
||||
* @param {string} startMethod 启动方法名称
|
||||
* @returns {boolean} 是否成功找到并启动特效
|
||||
*/
|
||||
function _startEffect(effectObject, canvas, finishCurrent, startMethod = "start") {
|
||||
if (!effectObject || typeof effectObject[startMethod] !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
_bindEffectController(effectObject[startMethod](canvas, finishCurrent));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放指定特效
|
||||
*
|
||||
* @param {string} type 特效类型:fireworks / rain / lightning / snow / sakura / meteors / gold-rain / hearts / confetti / fireflies
|
||||
*/
|
||||
function play(type) {
|
||||
// 防重入:同时只允许一个特效
|
||||
if (_current) {
|
||||
console.log(`[EffectManager] 特效 ${_current} 正在播放,加入队列 ${type}`);
|
||||
_enqueue(type);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = _getCanvas();
|
||||
_current = type;
|
||||
const token = _playToken;
|
||||
const finishCurrent = () => _cleanup({ token });
|
||||
_bindClickStop();
|
||||
|
||||
// 同步触发对应音效
|
||||
if (typeof window.EffectSounds !== "undefined") {
|
||||
window.EffectSounds.play(type);
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case "fireworks":
|
||||
started = _startEffect(window.FireworksEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "wedding-fireworks":
|
||||
// 婚礼专属:双倍礼花,粉金浪漫配色,持续 12 秒
|
||||
started = _startEffect(window.FireworksEffect, canvas, finishCurrent, "startDouble");
|
||||
break;
|
||||
case "rain":
|
||||
started = _startEffect(window.RainEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "lightning":
|
||||
started = _startEffect(window.LightningEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "snow":
|
||||
started = _startEffect(window.SnowEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "sakura":
|
||||
started = _startEffect(window.SakuraEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "meteors":
|
||||
started = _startEffect(window.MeteorsEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "gold-rain":
|
||||
started = _startEffect(window.GoldRainEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "hearts":
|
||||
started = _startEffect(window.HeartsEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "confetti":
|
||||
started = _startEffect(window.ConfettiEffect, canvas, finishCurrent);
|
||||
break;
|
||||
case "fireflies":
|
||||
started = _startEffect(window.FirefliesEffect, canvas, finishCurrent);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[EffectManager] 未知特效类型:${type}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[EffectManager] 启动特效失败:${type}`, error);
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
finishCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户手动停止当前全屏特效。
|
||||
*
|
||||
* 会立即移除画布、停止音效,并清空排队中的后续特效。
|
||||
*/
|
||||
function stop() {
|
||||
if (!_current) {
|
||||
return;
|
||||
}
|
||||
|
||||
_queue.length = 0;
|
||||
_cleanup({ playNext: false, cancelCurrent: true });
|
||||
}
|
||||
|
||||
return { play, stop };
|
||||
})();
|
||||
|
||||
window.EffectManager = EffectManager;
|
||||
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* 文件功能:聊天室特效音效引擎(Web Audio API 实时合成)
|
||||
*
|
||||
* 所有音效通过 Web Audio API 实时合成,无需外部音频文件。
|
||||
*
|
||||
* 对外 API:
|
||||
* EffectSounds.play(type) 播放指定特效的背景音效
|
||||
* EffectSounds.stop() 停止并释放当前音效资源
|
||||
* EffectSounds.ding() 播放简短叮咚通知音(大/小卡片弹出时使用)
|
||||
*
|
||||
* 支持的 type:
|
||||
* lightning 雷鸣闪电(三层合成:放电啪声 + 低频轰鸣 + 极低频滚动余韵)
|
||||
* fireworks 烟花(发射滑音 + 高频爆炸噪声)
|
||||
* rain 下雨(双层带通白噪声,低音量,与视觉 8s 同步)
|
||||
* snow 下雪(仅五声音阶铃音,无风声)
|
||||
* sakura 樱花(轻柔风铃 + 微风扫过)
|
||||
* meteors 流星(高速掠空呼啸)
|
||||
* gold-rain 金币雨(金属叮当)
|
||||
* hearts 爱心飘落(温暖双音)
|
||||
* confetti 彩带庆典(礼炮碎响 + 清亮点缀)
|
||||
* fireflies 萤火虫(稀疏微光铃音)
|
||||
*/
|
||||
|
||||
const EffectSounds = (() => {
|
||||
/** @type {AudioContext|null} */
|
||||
let _ctx = null;
|
||||
/** @type {Function|null} 当前音效的停止函数 */
|
||||
let _stopFn = null;
|
||||
|
||||
// ─── 工具方法 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 懒加载并返回 AudioContext。
|
||||
* 浏览器要求首次创建必须在用户手势后,聊天室点击任何按钮即可解锁。
|
||||
*
|
||||
* @returns {AudioContext}
|
||||
*/
|
||||
function _getCtx() {
|
||||
if (!_ctx || _ctx.state === "closed") {
|
||||
_ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (_ctx.state === "suspended") {
|
||||
_ctx.resume();
|
||||
}
|
||||
return _ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建包含白噪声的 AudioBuffer。
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {number} duration 时长(秒)
|
||||
* @returns {AudioBuffer}
|
||||
*/
|
||||
function _makeNoise(ctx, duration) {
|
||||
const len = Math.ceil(ctx.sampleRate * duration);
|
||||
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度一个带包络的简单音符。
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {GainNode} masterGain
|
||||
* @param {{delay?:number, duration?:number, freq:number, endFreq?:number, volume?:number, type?:OscillatorType}} options
|
||||
*/
|
||||
function _scheduleTone(ctx, masterGain, options) {
|
||||
const {
|
||||
delay = 0,
|
||||
duration = 0.8,
|
||||
freq,
|
||||
endFreq = freq,
|
||||
volume = 0.18,
|
||||
type = "sine",
|
||||
} = options;
|
||||
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const osc = ctx.createOscillator();
|
||||
const env = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(20, endFreq),
|
||||
t0 + duration,
|
||||
);
|
||||
env.gain.setValueAtTime(Math.max(0.0001, volume), t0);
|
||||
env.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
|
||||
osc.connect(env);
|
||||
env.connect(masterGain);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + duration + 0.03);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度一段带滤波扫频的噪声音色,用于呼啸、爆裂等环境声。
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {GainNode} masterGain
|
||||
* @param {{delay?:number, duration?:number, startFreq?:number, endFreq?:number, volume?:number, q?:number, filterType?:BiquadFilterType}} options
|
||||
*/
|
||||
function _scheduleNoiseSweep(ctx, masterGain, options) {
|
||||
const {
|
||||
delay = 0,
|
||||
duration = 0.6,
|
||||
startFreq = 2200,
|
||||
endFreq = 500,
|
||||
volume = 0.16,
|
||||
q = 0.8,
|
||||
filterType = "bandpass",
|
||||
} = options;
|
||||
|
||||
const t0 = ctx.currentTime + delay;
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = _makeNoise(ctx, duration + 0.15);
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = filterType;
|
||||
filter.Q.value = q;
|
||||
filter.frequency.setValueAtTime(startFreq, t0);
|
||||
filter.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(60, endFreq),
|
||||
t0 + duration,
|
||||
);
|
||||
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0.001, t0);
|
||||
env.gain.linearRampToValueAtTime(volume, t0 + duration * 0.18);
|
||||
env.gain.exponentialRampToValueAtTime(0.001, t0 + duration);
|
||||
|
||||
src.connect(filter);
|
||||
filter.connect(env);
|
||||
env.connect(masterGain);
|
||||
src.start(t0);
|
||||
src.stop(t0 + duration + 0.08);
|
||||
}
|
||||
|
||||
// ─── 雷电音效(三层合成,贴近真实雷声)────────────────────────
|
||||
|
||||
/**
|
||||
* 单次真实雷声 = 放电啪声 + 低频轰鸣 + 极低频滚动余韵。
|
||||
*
|
||||
* 物理模型:
|
||||
* crack — 闪电通道瞬间放电产生的高频尖啪(30~50ms)
|
||||
* boom — 空气急速膨胀产生的低频冲击(40~120Hz,~150ms 达峰值,衰减 2s)
|
||||
* rumble — 回声与地面反射产生的极低频滚动(20~60Hz,缓慢衰减 3.5s)
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {GainNode} masterGain
|
||||
* @param {number} delay 延迟(秒)
|
||||
*/
|
||||
function _thunderCrack(ctx, masterGain, delay) {
|
||||
const t0 = ctx.currentTime + delay;
|
||||
|
||||
// ① 放电啪声(crack):带通白噪声,极短 ~50ms
|
||||
const snap = ctx.createBufferSource();
|
||||
snap.buffer = _makeNoise(ctx, 0.12);
|
||||
const snapBpf = ctx.createBiquadFilter();
|
||||
snapBpf.type = "bandpass";
|
||||
snapBpf.frequency.value = 2800 + Math.random() * 800;
|
||||
snapBpf.Q.value = 0.7;
|
||||
const snapEnv = ctx.createGain();
|
||||
snapEnv.gain.setValueAtTime(0.55, t0);
|
||||
snapEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 0.055);
|
||||
snap.connect(snapBpf);
|
||||
snapBpf.connect(snapEnv);
|
||||
snapEnv.connect(masterGain);
|
||||
snap.start(t0);
|
||||
snap.stop(t0 + 0.12);
|
||||
|
||||
// ② 主轰鸣(boom):低通白噪声,120→40Hz 扫频,快冲击慢衰减 ~2s
|
||||
const boom = ctx.createBufferSource();
|
||||
boom.buffer = _makeNoise(ctx, 2.2);
|
||||
const boomLpf = ctx.createBiquadFilter();
|
||||
boomLpf.type = "lowpass";
|
||||
boomLpf.frequency.setValueAtTime(130, t0 + 0.03);
|
||||
boomLpf.frequency.exponentialRampToValueAtTime(38, t0 + 2.0);
|
||||
const boomEnv = ctx.createGain();
|
||||
boomEnv.gain.setValueAtTime(0, t0 + 0.03);
|
||||
boomEnv.gain.linearRampToValueAtTime(1.0, t0 + 0.12); // 快速冲击
|
||||
boomEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 2.1);
|
||||
boom.connect(boomLpf);
|
||||
boomLpf.connect(boomEnv);
|
||||
boomEnv.connect(masterGain);
|
||||
boom.start(t0 + 0.03);
|
||||
boom.stop(t0 + 2.2);
|
||||
|
||||
// ③ 滚动余韵(rumble):极低频,缓慢堆积后长衰减 ~3.5s
|
||||
const rumble = ctx.createBufferSource();
|
||||
rumble.buffer = _makeNoise(ctx, 3.8);
|
||||
const rumbleLpf = ctx.createBiquadFilter();
|
||||
rumbleLpf.type = "lowpass";
|
||||
rumbleLpf.frequency.setValueAtTime(55, t0);
|
||||
rumbleLpf.frequency.exponentialRampToValueAtTime(22, t0 + 3.5);
|
||||
const rumbleEnv = ctx.createGain();
|
||||
rumbleEnv.gain.setValueAtTime(0, t0 + 0.05);
|
||||
rumbleEnv.gain.linearRampToValueAtTime(0.65, t0 + 0.35); // 缓慢堆积
|
||||
rumbleEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 3.6);
|
||||
rumble.connect(rumbleLpf);
|
||||
rumbleLpf.connect(rumbleEnv);
|
||||
rumbleEnv.connect(masterGain);
|
||||
rumble.start(t0 + 0.05);
|
||||
rumble.stop(t0 + 3.8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动雷电音效:与视觉特效同步触发 10 次雷声,总时长约 7 秒。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startLightning() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.8;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
// 10 次闪电,间隔 400~800ms,雷声滞后闪电 50~300ms(光速>声速)
|
||||
let t = 0.3;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
_thunderCrack(ctx, master, t + 0.06 + Math.random() * 0.25);
|
||||
t += 0.4 + Math.random() * 0.4;
|
||||
}
|
||||
|
||||
// 视觉特效 ~7 秒,预留尾声淡出
|
||||
const timer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 1100);
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 烟花音效 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 播放单颗烟花:发射滑音 + 空中爆炸噪声。
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {GainNode} masterGain
|
||||
* @param {number} delay 延迟(秒)
|
||||
*/
|
||||
function _fireworkPop(ctx, masterGain, delay) {
|
||||
const t0 = ctx.currentTime + delay;
|
||||
|
||||
// 发射音:200→700Hz 上升滑音
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(200, t0);
|
||||
osc.frequency.exponentialRampToValueAtTime(700, t0 + 0.18);
|
||||
const launchEnv = ctx.createGain();
|
||||
launchEnv.gain.setValueAtTime(0.25, t0);
|
||||
launchEnv.gain.exponentialRampToValueAtTime(0.001, t0 + 0.22);
|
||||
osc.connect(launchEnv);
|
||||
launchEnv.connect(masterGain);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + 0.23);
|
||||
|
||||
// 爆炸音:带通噪声
|
||||
const boom = ctx.createBufferSource();
|
||||
boom.buffer = _makeNoise(ctx, 0.9);
|
||||
const bpf = ctx.createBiquadFilter();
|
||||
bpf.type = "bandpass";
|
||||
bpf.frequency.value = 1000 + Math.random() * 1500;
|
||||
bpf.Q.value = 0.5;
|
||||
const boomEnv = ctx.createGain();
|
||||
const t1 = t0 + 0.18;
|
||||
boomEnv.gain.setValueAtTime(0, t1);
|
||||
boomEnv.gain.linearRampToValueAtTime(0.45, t1 + 0.02);
|
||||
boomEnv.gain.exponentialRampToValueAtTime(0.001, t1 + 0.75);
|
||||
boom.connect(bpf);
|
||||
bpf.connect(boomEnv);
|
||||
boomEnv.connect(masterGain);
|
||||
boom.start(t1);
|
||||
boom.stop(t1 + 0.9);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动烟花音效。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startFireworks() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.6;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
let t = 0.2;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
_fireworkPop(ctx, master, t);
|
||||
t += 0.5 + Math.random() * 1.0;
|
||||
}
|
||||
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
},
|
||||
(t + 2) * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 下雨音效(低音量,与视觉 8000ms 对齐)─────────────────────
|
||||
|
||||
/**
|
||||
* 启动下雨音效:双层带通白噪声,主音量 0.15,随视觉效果结束淡出。
|
||||
* 视觉雨效持续 8000ms,音效在相同时间开始淡出。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startRain() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
// 主雨声:中频 1200Hz 沙沙感
|
||||
const src1 = ctx.createBufferSource();
|
||||
src1.buffer = _makeNoise(ctx, 3);
|
||||
src1.loop = true;
|
||||
const bpf1 = ctx.createBiquadFilter();
|
||||
bpf1.type = "bandpass";
|
||||
bpf1.frequency.value = 1200;
|
||||
bpf1.Q.value = 0.3;
|
||||
|
||||
// 细密层:高频 3500Hz,增加雨点密度感
|
||||
const src2 = ctx.createBufferSource();
|
||||
src2.buffer = _makeNoise(ctx, 3);
|
||||
src2.loop = true;
|
||||
const bpf2 = ctx.createBiquadFilter();
|
||||
bpf2.type = "bandpass";
|
||||
bpf2.frequency.value = 3500;
|
||||
bpf2.Q.value = 1;
|
||||
const g2 = ctx.createGain();
|
||||
g2.gain.value = 0.35;
|
||||
|
||||
src1.connect(bpf1);
|
||||
bpf1.connect(master);
|
||||
src2.connect(bpf2);
|
||||
bpf2.connect(g2);
|
||||
g2.connect(master);
|
||||
src1.start();
|
||||
src2.start();
|
||||
|
||||
// 淡入 1.5s → 最高音量 0.15(比之前降低约 60%)
|
||||
master.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 1.5);
|
||||
|
||||
// 与视觉雨效对齐:8000ms 后开始 2s 淡出
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.0);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
src1.stop();
|
||||
src2.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 2200);
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
src1.stop();
|
||||
src2.stop();
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 下雪音效(仅五声音阶铃音,无风声)─────────────────────────
|
||||
|
||||
/**
|
||||
* 播放单次铃声(五声音阶 C/E/G/C,含泛音模拟铃铛共鸣)。
|
||||
*
|
||||
* @param {AudioContext} ctx
|
||||
* @param {GainNode} masterGain
|
||||
* @param {number} delay 延迟(秒)
|
||||
*/
|
||||
function _snowBell(ctx, masterGain, delay) {
|
||||
// 五声音阶:C5 E5 G5 C6
|
||||
const freqs = [523.25, 659.25, 783.99, 1046.5];
|
||||
const freq = freqs[Math.floor(Math.random() * freqs.length)];
|
||||
const t0 = ctx.currentTime + delay;
|
||||
|
||||
// 基音
|
||||
const osc1 = ctx.createOscillator();
|
||||
osc1.type = "sine";
|
||||
osc1.frequency.value = freq;
|
||||
|
||||
// 第二泛音(5倍频,音量很低,增加金属铃铛感)
|
||||
const osc2 = ctx.createOscillator();
|
||||
osc2.type = "sine";
|
||||
osc2.frequency.value = freq * 2.76; // 铃铛典型泛音比
|
||||
const g2 = ctx.createGain();
|
||||
g2.gain.value = 0.12;
|
||||
|
||||
// 共用包络:快冲击,缓慢衰减 2s
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(0.22, t0);
|
||||
env.gain.exponentialRampToValueAtTime(0.001, t0 + 2.0);
|
||||
|
||||
osc1.connect(env);
|
||||
osc2.connect(g2);
|
||||
g2.connect(env);
|
||||
env.connect(masterGain);
|
||||
|
||||
osc1.start(t0);
|
||||
osc1.stop(t0 + 2.1);
|
||||
osc2.start(t0);
|
||||
osc2.stop(t0 + 2.1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动下雪音效:在 10 秒内随机播放 8 次铃音,无背景风声。
|
||||
* 与视觉雪效持续时间(10000ms)对齐。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startSnow() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.9;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
// 随机分布 8 次铃声,间隔在 0.5~9.5s 之间
|
||||
const bellTimers = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const d = 0.3 + Math.random() * 9.0;
|
||||
bellTimers.push(
|
||||
setTimeout(() => _snowBell(ctx, master, 0), d * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.5);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 600);
|
||||
}, 10800);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
bellTimers.forEach((t) => clearTimeout(t));
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 樱花 / 流星 / 金币雨 / 爱心 / 彩带 / 萤火虫音效 ────────────
|
||||
|
||||
/**
|
||||
* 启动樱花音效:低音量风铃加轻微风声。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startSakura() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.42;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const notes = [523.25, 659.25, 783.99, 880];
|
||||
const plan = [0.35, 1.2, 2.1, 3.6, 5.1, 6.8, 8.2];
|
||||
plan.forEach((delay, index) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 1.8,
|
||||
freq: notes[index % notes.length],
|
||||
endFreq: notes[index % notes.length] * 0.98,
|
||||
volume: 0.11,
|
||||
type: "sine",
|
||||
});
|
||||
});
|
||||
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 0.2,
|
||||
duration: 3.2,
|
||||
startFreq: 1400,
|
||||
endFreq: 500,
|
||||
volume: 0.04,
|
||||
q: 0.35,
|
||||
});
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay: 4.8,
|
||||
duration: 2.8,
|
||||
startFreq: 1200,
|
||||
endFreq: 420,
|
||||
volume: 0.035,
|
||||
q: 0.3,
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.8);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 900);
|
||||
}, 9800);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动流星音效:多段高速呼啸掠空。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startMeteors() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.55;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
[0.4, 1.5, 2.8, 4.2, 5.7, 7.1].forEach((delay) => {
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay,
|
||||
duration: 0.85,
|
||||
startFreq: 3600,
|
||||
endFreq: 320,
|
||||
volume: 0.14,
|
||||
q: 1.1,
|
||||
});
|
||||
_scheduleTone(ctx, master, {
|
||||
delay: delay + 0.08,
|
||||
duration: 0.55,
|
||||
freq: 1100,
|
||||
endFreq: 420,
|
||||
volume: 0.06,
|
||||
type: "triangle",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 9300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动金币雨音效:连续金属叮当声。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startGoldRain() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.62;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
const notes = [880, 987.77, 1174.66, 1318.51];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const delay = 0.25 + i * 0.42;
|
||||
const freq = notes[i % notes.length];
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.5,
|
||||
freq,
|
||||
endFreq: freq * 0.92,
|
||||
volume: 0.16,
|
||||
type: "triangle",
|
||||
});
|
||||
_scheduleTone(ctx, master, {
|
||||
delay: delay + 0.02,
|
||||
duration: 0.38,
|
||||
freq: freq * 2.1,
|
||||
endFreq: freq * 1.9,
|
||||
volume: 0.06,
|
||||
type: "sine",
|
||||
});
|
||||
}
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 0.6);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 700);
|
||||
}, 7600);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动爱心飘落音效:柔和双音和声。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startHearts() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.5;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
[0.35, 1.5, 2.9, 4.2, 5.8, 7.2].forEach((delay) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 0.9,
|
||||
freq: 523.25,
|
||||
endFreq: 493.88,
|
||||
volume: 0.12,
|
||||
type: "triangle",
|
||||
});
|
||||
_scheduleTone(ctx, master, {
|
||||
delay: delay + 0.12,
|
||||
duration: 1.05,
|
||||
freq: 659.25,
|
||||
endFreq: 622.25,
|
||||
volume: 0.1,
|
||||
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);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动彩带庆典音效:礼炮碎响与亮点装饰音。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startConfetti() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.58;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
[0.2, 1.4, 2.8, 4.1, 5.6].forEach((delay) => {
|
||||
_scheduleNoiseSweep(ctx, master, {
|
||||
delay,
|
||||
duration: 0.38,
|
||||
startFreq: 1800,
|
||||
endFreq: 700,
|
||||
volume: 0.2,
|
||||
q: 0.7,
|
||||
});
|
||||
_scheduleTone(ctx, master, {
|
||||
delay: delay + 0.04,
|
||||
duration: 0.42,
|
||||
freq: 1046.5,
|
||||
endFreq: 880,
|
||||
volume: 0.08,
|
||||
type: "square",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 7200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动萤火虫音效:低音量稀疏闪烁音。
|
||||
*
|
||||
* @returns {Function} 停止函数
|
||||
*/
|
||||
function _startFireflies() {
|
||||
const ctx = _getCtx();
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.28;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
[0.6, 1.8, 2.7, 3.9, 5.2, 6.4, 7.6, 8.8].forEach((delay, index) => {
|
||||
_scheduleTone(ctx, master, {
|
||||
delay,
|
||||
duration: 1.4,
|
||||
freq: [659.25, 783.99, 987.77][index % 3],
|
||||
endFreq: [659.25, 783.99, 987.77][index % 3] * 0.99,
|
||||
volume: 0.06,
|
||||
type: "sine",
|
||||
});
|
||||
});
|
||||
|
||||
const endTimer = setTimeout(() => {
|
||||
master.gain.linearRampToValueAtTime(0, ctx.currentTime + 1.0);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 1100);
|
||||
}, 10200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(endTimer);
|
||||
try {
|
||||
master.gain.setValueAtTime(0, ctx.currentTime);
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 公开 API ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 播放指定特效对应的音效(自动停止上一个)。
|
||||
* 静音状态下直接跳过,不做任何音频操作。
|
||||
* 当 AudioContext 处于 suspended 状态时,先 resume() 再播放,
|
||||
* 解决页面无用户手势时的自动静音问题(如管理员进房自动烟花)。
|
||||
*
|
||||
* @param {string} type 'lightning' | 'fireworks' | 'rain' | 'snow' | 'sakura' | 'meteors' | 'gold-rain' | 'hearts' | 'confetti' | 'fireflies'
|
||||
*/
|
||||
function play(type) {
|
||||
// 用户开启禁音则跳过
|
||||
if (localStorage.getItem("chat_sound_muted") === "1") return;
|
||||
stop();
|
||||
|
||||
try {
|
||||
const ctx = _getCtx();
|
||||
|
||||
const _doPlay = () => {
|
||||
try {
|
||||
switch (type) {
|
||||
case "lightning":
|
||||
_stopFn = _startLightning();
|
||||
break;
|
||||
case "fireworks":
|
||||
_stopFn = _startFireworks();
|
||||
break;
|
||||
case "rain":
|
||||
_stopFn = _startRain();
|
||||
break;
|
||||
case "snow":
|
||||
_stopFn = _startSnow();
|
||||
break;
|
||||
case "sakura":
|
||||
_stopFn = _startSakura();
|
||||
break;
|
||||
case "meteors":
|
||||
_stopFn = _startMeteors();
|
||||
break;
|
||||
case "gold-rain":
|
||||
_stopFn = _startGoldRain();
|
||||
break;
|
||||
case "hearts":
|
||||
_stopFn = _startHearts();
|
||||
break;
|
||||
case "confetti":
|
||||
_stopFn = _startConfetti();
|
||||
break;
|
||||
case "fireflies":
|
||||
_stopFn = _startFireflies();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[EffectSounds] 音效内部错误:", e);
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
// AudioContext 尚未被用户手势激活,先 resume 再播放
|
||||
ctx.resume()
|
||||
.then(_doPlay)
|
||||
.catch(() => {
|
||||
// 浏览器拒绝 resume(无用户手势),静默处理
|
||||
});
|
||||
} else {
|
||||
_doPlay();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"[EffectSounds] 音效播放失败(可能未能解锁 AudioContext):",
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 叮咚通知音(大卡片 / 小卡片弹出时调用)─────────────────
|
||||
|
||||
/**
|
||||
* 播放简短的叮咚两音通知音效。
|
||||
*
|
||||
* 音型:A5(880Hz)→ 间隔 110ms → E5(659Hz),均为正弦波,
|
||||
* 快速冲击 + 铃铛式缓慢衰减,总时长约 0.5 秒。
|
||||
* 禁音状态下自动跳过。
|
||||
*/
|
||||
function ding() {
|
||||
if (localStorage.getItem("chat_sound_muted") === "1") return;
|
||||
try {
|
||||
const ctx = _getCtx();
|
||||
|
||||
const _doDing = () => {
|
||||
try {
|
||||
const master = ctx.createGain();
|
||||
master.gain.value = 0.45;
|
||||
master.connect(ctx.destination);
|
||||
|
||||
function _tone(freq, t0, decay) {
|
||||
const osc = ctx.createOscillator();
|
||||
osc.type = "sine";
|
||||
osc.frequency.value = freq;
|
||||
const osc2 = ctx.createOscillator();
|
||||
osc2.type = "sine";
|
||||
osc2.frequency.value = freq * 2.76;
|
||||
const g2 = ctx.createGain();
|
||||
g2.gain.value = 0.1;
|
||||
const env = ctx.createGain();
|
||||
env.gain.setValueAtTime(1.0, t0);
|
||||
env.gain.exponentialRampToValueAtTime(
|
||||
0.001,
|
||||
t0 + decay,
|
||||
);
|
||||
osc.connect(env);
|
||||
osc2.connect(g2);
|
||||
g2.connect(env);
|
||||
env.connect(master);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + decay + 0.05);
|
||||
osc2.start(t0);
|
||||
osc2.stop(t0 + decay + 0.05);
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
_tone(880, now, 0.35); // 叮:A5
|
||||
_tone(659, now + 0.11, 0.4); // 咚:E5
|
||||
|
||||
setTimeout(() => {
|
||||
try {
|
||||
master.disconnect();
|
||||
} catch (_) {}
|
||||
}, 700);
|
||||
} catch (e) {
|
||||
console.warn("[EffectSounds.ding] 通知音内部错误:", e);
|
||||
}
|
||||
};
|
||||
|
||||
if (ctx.state === "suspended") {
|
||||
ctx.resume()
|
||||
.then(_doDing)
|
||||
.catch(() => {});
|
||||
} else {
|
||||
_doDing();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[EffectSounds.ding] 通知音播放失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止当前音效并释放资源。
|
||||
*/
|
||||
function stop() {
|
||||
if (_stopFn) {
|
||||
try {
|
||||
_stopFn();
|
||||
} catch (_) {}
|
||||
_stopFn = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { play, stop, ding };
|
||||
})();
|
||||
|
||||
window.EffectSounds = EffectSounds;
|
||||
// 将叮咚通知音暴露为独立全局变量,供 toast/banner 等组件直接调用
|
||||
window.chatSound = { ding: () => EffectSounds.ding() };
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 文件功能:聊天室萤火虫特效
|
||||
*
|
||||
* 使用高亮柔光粒子模拟夜色中的萤火虫,让光点在屏幕中缓慢游走、
|
||||
* 呼吸闪烁,适合常驻氛围和安静主题房间。
|
||||
*/
|
||||
|
||||
const FirefliesEffect = (() => {
|
||||
class Firefly {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置萤火虫位置与呼吸参数。
|
||||
*/
|
||||
reset() {
|
||||
this.x = Math.random() * this.w;
|
||||
this.y = Math.random() * this.h;
|
||||
this.baseRadius = Math.random() * 3 + 2.4;
|
||||
this.alpha = Math.random() * 0.24 + 0.36;
|
||||
this.phase = Math.random() * Math.PI * 2;
|
||||
this.phaseSpeed = Math.random() * 0.05 + 0.015;
|
||||
this.angle = Math.random() * Math.PI * 2;
|
||||
this.speed = Math.random() * 0.55 + 0.22;
|
||||
this.flutter = Math.random() * Math.PI * 2;
|
||||
this.flutterSpeed = Math.random() * 0.18 + 0.08;
|
||||
this.trail = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新萤火虫轨迹。
|
||||
*/
|
||||
update() {
|
||||
this.phase += this.phaseSpeed;
|
||||
this.flutter += this.flutterSpeed;
|
||||
this.trail.push({ x: this.x, y: this.y });
|
||||
if (this.trail.length > 7) {
|
||||
this.trail.shift();
|
||||
}
|
||||
|
||||
this.angle += (Math.random() - 0.5) * 0.08 + Math.sin(this.flutter) * 0.012;
|
||||
this.x += Math.cos(this.angle) * this.speed;
|
||||
this.y += Math.sin(this.angle) * this.speed;
|
||||
|
||||
if (this.x < -20) this.x = this.w + 20;
|
||||
if (this.x > this.w + 20) this.x = -20;
|
||||
if (this.y < -20) this.y = this.h + 20;
|
||||
if (this.y > this.h + 20) this.y = -20;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制发光萤火虫。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
const pulse = 0.38 + (Math.sin(this.phase) + 1) * 0.42;
|
||||
const radius = this.baseRadius * pulse;
|
||||
const wingSwing = Math.sin(this.flutter) * 1.9;
|
||||
|
||||
this.trail.forEach((point, index) => {
|
||||
const alpha = ((index + 1) / this.trail.length) * 0.08;
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(250, 204, 21, ${alpha})`;
|
||||
ctx.shadowColor = "rgba(250, 204, 21, 0.45)";
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x, point.y, Math.max(0.8, radius * 0.55), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
ctx.rotate(this.angle);
|
||||
|
||||
// 先画半透明翅膀,再画虫身和发光尾部,让画面更像“萤火虫”而不是单纯光点。
|
||||
ctx.globalAlpha = 0.18 + pulse * 0.08;
|
||||
ctx.fillStyle = "#f8fafc";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(-radius * 1.15, -radius * 0.45, radius * 1.05, radius * 0.55, wingSwing * 0.05, 0, Math.PI * 2);
|
||||
ctx.ellipse(-radius * 0.1, radius * 0.45, radius * 1.05, radius * 0.55, -wingSwing * 0.05, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.globalAlpha = this.alpha + pulse * 0.3;
|
||||
ctx.fillStyle = "#fde047";
|
||||
ctx.shadowColor = "rgba(250, 204, 21, 0.85)";
|
||||
ctx.shadowBlur = 18 + pulse * 12;
|
||||
ctx.beginPath();
|
||||
ctx.arc(radius * 0.75, 0, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = "rgba(51, 65, 85, 0.9)";
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(-radius * 0.25, 0, radius * 1.05, Math.max(1.4, radius * 0.5), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(248, 250, 252, 0.35)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-radius * 1.1, -radius * 0.2);
|
||||
ctx.lineTo(-radius * 1.65, -radius * 0.8);
|
||||
ctx.moveTo(-radius * 1.1, radius * 0.2);
|
||||
ctx.lineTo(-radius * 1.65, radius * 0.8);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动萤火虫特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 10500;
|
||||
const COUNT = Math.min(52, Math.max(24, Math.floor(w / 34)));
|
||||
const fireflies = Array.from({ length: COUNT }, () => new Firefly(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();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
fireflies.forEach((firefly) => {
|
||||
firefly.update();
|
||||
firefly.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.FirefliesEffect = FirefliesEffect;
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* 文件功能:聊天室烟花特效(真实感版本)
|
||||
*
|
||||
* 完整模拟真实烟花流程:
|
||||
* 1. 火箭从底部带着尾迹飞向目标高度
|
||||
* 2. 到达位置后爆炸,产生 3 种形态:球形/柳叶/星形
|
||||
* 3. 每颗粒子带历史轨迹尾巴,随重力下坠并消散
|
||||
*
|
||||
* 全程透明 Canvas,不遮挡聊天背景。
|
||||
*/
|
||||
|
||||
const FireworksEffect = (() => {
|
||||
// ─── 火箭类 ──────────────────────────────────────────
|
||||
class Rocket {
|
||||
/**
|
||||
* @param {number} x 发射 x 位置
|
||||
* @param {number} targetY 爆炸目标高度(距顶部比例)
|
||||
* @param {string} color 爆炸颜色
|
||||
* @param {string} type 爆炸类型:sphere / willow / ring
|
||||
*/
|
||||
constructor(x, targetY, color, type, canvasHeight, drift = 0) {
|
||||
this.x = x;
|
||||
this.y = canvasHeight; // 从画布底部出发
|
||||
this.targetY = targetY;
|
||||
this.color = color;
|
||||
this.type = type;
|
||||
this.vx = drift;
|
||||
|
||||
// 根据飞行距离动态计算初始速度,保证必然到达目标高度
|
||||
// 等比级数求和:total = vy / (1 - 0.98) = vy × 50
|
||||
// 加 10%~20% 余量,使火箭略微超过目标再触发爆炸(更真实)
|
||||
const dist = canvasHeight - targetY;
|
||||
this.vy = -(dist / 50) * (1.1 + Math.random() * 0.15);
|
||||
|
||||
this.trail = []; // 尾迹历史坐标
|
||||
this.exploded = false;
|
||||
this.done = false;
|
||||
}
|
||||
|
||||
/** 更新火箭位置,到达目标高度后标记为已爆炸 */
|
||||
update() {
|
||||
this.trail.push({ x: this.x, y: this.y });
|
||||
if (this.trail.length > 12) {
|
||||
this.trail.shift();
|
||||
}
|
||||
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vx *= 0.992;
|
||||
this.vy *= 0.98; // 轻微减速(仿真阻力)
|
||||
if (this.y <= this.targetY) {
|
||||
this.exploded = true;
|
||||
this.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制火箭(白色头部 + 橙色尾迹) */
|
||||
draw(ctx) {
|
||||
// 尾迹(由新到旧逐渐变淡)
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const a = (i / this.trail.length) * 0.6;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a;
|
||||
ctx.fillStyle = "#ffaa44";
|
||||
ctx.shadowColor = "#ff6600";
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.trail[i].x, this.trail[i].y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
// 火箭头部(亮白色光点)
|
||||
ctx.save();
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.shadowColor = "#ffcc88";
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 爆炸粒子类 ──────────────────────────────────────
|
||||
class Particle {
|
||||
/**
|
||||
* @param {number} x 爆炸中心 x
|
||||
* @param {number} y 爆炸中心 y
|
||||
* @param {string} color 粒子颜色
|
||||
* @param {string} type 爆炸类型
|
||||
* @param {number} angle 发射角度(ring 类型用)
|
||||
*/
|
||||
constructor(x, y, color, type, angle, options = {}) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.trail = [];
|
||||
this.innerColor = options.innerColor ?? "#ffffff";
|
||||
this.trailLimit = options.trailLimit ?? 8;
|
||||
this.drag = options.drag ?? 0.985;
|
||||
this.radiusScale = options.radiusScale ?? 1;
|
||||
|
||||
let speed;
|
||||
if (type === "ring") {
|
||||
// 环形:均匀角度,固定速度
|
||||
speed = 5.8 + Math.random() * 2.2;
|
||||
this.vx = Math.cos(angle) * speed;
|
||||
this.vy = Math.sin(angle) * speed;
|
||||
this.gravity = 0.06;
|
||||
this.decay = 0.012;
|
||||
this.radius = 2.2 * this.radiusScale;
|
||||
} else if (type === "willow") {
|
||||
// 柳叶:慢速,在空中下垂
|
||||
speed = Math.random() * 3.8 + 1.4;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
this.vx = Math.cos(a) * speed;
|
||||
this.vy = Math.sin(a) * speed - 2.4; // 初速稍微向上
|
||||
this.gravity = 0.072;
|
||||
this.decay = 0.0075; // 衰减慢,拖出长尾
|
||||
this.radius = 1.7 * this.radiusScale;
|
||||
} else {
|
||||
// sphere:标准球形爆炸
|
||||
speed = Math.random() * 6.8 + 2.4;
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
this.vx = Math.cos(a) * speed;
|
||||
this.vy = Math.sin(a) * speed;
|
||||
this.gravity = 0.095;
|
||||
this.decay = 0.0135;
|
||||
this.radius = (Math.random() * 2.2 + 1.7) * this.radiusScale;
|
||||
}
|
||||
|
||||
this.alpha = 1;
|
||||
// 部分粒子有闪烁效果
|
||||
this.sparkle = Math.random() > 0.45;
|
||||
this.frame = 0;
|
||||
}
|
||||
|
||||
/** 更新粒子物理状态 */
|
||||
update() {
|
||||
this.frame++;
|
||||
// 保存轨迹历史(尾迹长度由透明度控制)
|
||||
this.trail.push({ x: this.x, y: this.y });
|
||||
if (this.trail.length > this.trailLimit) {
|
||||
this.trail.shift();
|
||||
}
|
||||
|
||||
this.vy += this.gravity;
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vx *= this.drag;
|
||||
this.vy *= this.drag;
|
||||
|
||||
// 闪烁:每隔几帧透明度轻微抖动
|
||||
if (this.sparkle && this.frame % 4 === 0) {
|
||||
this.alpha = Math.max(
|
||||
0,
|
||||
this.alpha - this.decay * (0.5 + Math.random()),
|
||||
);
|
||||
} else {
|
||||
this.alpha -= this.decay;
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制粒子及其运动轨迹尾迹 */
|
||||
draw(ctx) {
|
||||
// 绘制尾迹
|
||||
for (let i = 0; i < this.trail.length; i++) {
|
||||
const a = (i / this.trail.length) * this.alpha * 0.45;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = a;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
this.trail[i].x,
|
||||
this.trail[i].y,
|
||||
this.radius * 0.55,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
// 绘制粒子主体(带发光)
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, this.alpha);
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 用白色内核强化“炸点”质感,避免颜色过闷。
|
||||
ctx.fillStyle = this.innerColor;
|
||||
ctx.globalAlpha = Math.max(0, this.alpha * 0.35);
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius * 0.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
get alive() {
|
||||
return this.alpha > 0.01;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 爆炸光晕类 ──────────────────────────────────────
|
||||
class Halo {
|
||||
/**
|
||||
* @param {number} x 爆炸中心 x
|
||||
* @param {number} y 爆炸中心 y
|
||||
* @param {string} color 爆炸主色
|
||||
* @param {number} radius 最大光晕半径
|
||||
*/
|
||||
constructor(x, y, color, radius) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.color = color;
|
||||
this.radius = radius * 0.32;
|
||||
this.maxRadius = radius;
|
||||
this.alpha = 0.34;
|
||||
}
|
||||
|
||||
/** 更新光晕扩散与淡出 */
|
||||
update() {
|
||||
this.radius += (this.maxRadius - this.radius) * 0.16 + 1.8;
|
||||
this.alpha *= 0.88;
|
||||
}
|
||||
|
||||
/** 绘制爆炸余辉,让烟花更有层次和氛围 */
|
||||
draw(ctx) {
|
||||
// 使用发光圆替代每帧渐变重建,尽量保留余辉质感同时降低爆炸高峰的绘制成本。
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "lighter";
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.shadowBlur = this.radius * 0.45;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.radius * 0.62, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
get alive() {
|
||||
return this.alpha > 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 预定义颜色 / 类型 ───────────────────────────────
|
||||
const COLORS = [
|
||||
"#ff2200",
|
||||
"#ff7700",
|
||||
"#ffcc00",
|
||||
"#00dd44",
|
||||
"#cc00ff",
|
||||
"#ff0088",
|
||||
"#00bbff",
|
||||
"#ff4488",
|
||||
"#ffaa00",
|
||||
];
|
||||
const TYPES = ["sphere", "willow", "ring"];
|
||||
const FLASH_COLORS = ["#ffffff", "#ffe3a3", "#ffd4f0", "#cfe9ff"];
|
||||
|
||||
/**
|
||||
* 随机取数组中的一个元素
|
||||
*
|
||||
* @param {Array} items 候选数组
|
||||
* @returns {*}
|
||||
*/
|
||||
function _pick(items) {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 为画布做渐隐,而不是硬清屏。
|
||||
*
|
||||
* 这里使用 destination-out 只擦除旧像素,不会给聊天室背景额外盖一层黑幕。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
*/
|
||||
function _fadeFrame(ctx, w, h) {
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = "destination-out";
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.24)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一批爆炸粒子
|
||||
*
|
||||
* @param {number} x 爆炸中心 x
|
||||
* @param {number} y 爆炸中心 y
|
||||
* @param {string} color 颜色
|
||||
* @param {string} type 爆炸类型
|
||||
* @param {number} density 粒子密度倍率
|
||||
* @returns {Particle[]}
|
||||
*/
|
||||
function _burst(x, y, color, type, density = 1) {
|
||||
const particles = [];
|
||||
const baseCount = type === "ring" ? 120 : type === "willow" ? 170 : 145;
|
||||
const count = Math.round(baseCount * density);
|
||||
const accentColor = _pick(FLASH_COLORS);
|
||||
|
||||
if (type === "ring") {
|
||||
// 环形:均匀分布
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = ((Math.PI * 2) / count) * i;
|
||||
particles.push(new Particle(x, y, color, type, angle));
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push(new Particle(x, y, color, type, 0));
|
||||
}
|
||||
|
||||
// 核心补一圈亮色星火,让爆点更饱满。
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const p = new Particle(x, y, accentColor, "sphere", 0, {
|
||||
trailLimit: 4,
|
||||
drag: 0.978,
|
||||
radiusScale: 0.7,
|
||||
});
|
||||
p.decay *= 1.6;
|
||||
p.radius *= 0.7;
|
||||
particles.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// 再补一层高亮碎火,提升烟花“炸开”的亮度与体积感。
|
||||
const sparkleCount = Math.max(10, Math.round(count * 0.12));
|
||||
for (let i = 0; i < sparkleCount; i++) {
|
||||
const sparkle = new Particle(x, y, accentColor, "sphere", 0, {
|
||||
trailLimit: 3,
|
||||
drag: 0.972,
|
||||
radiusScale: 0.58,
|
||||
innerColor: "#ffffff",
|
||||
});
|
||||
sparkle.decay *= 1.9;
|
||||
sparkle.gravity *= 0.8;
|
||||
particles.push(sparkle);
|
||||
}
|
||||
|
||||
return particles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量把新增粒子追加到现有数组,避免频繁 concat 产生新数组。
|
||||
*
|
||||
* @param {Particle[]} target
|
||||
* @param {Particle[]} incoming
|
||||
*/
|
||||
function _appendParticles(target, incoming) {
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
target.push(incoming[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在粒子预算内追加粒子,避免主爆炸阶段瞬间超量。
|
||||
*
|
||||
* @param {Particle[]} target
|
||||
* @param {Particle[]} incoming
|
||||
* @param {number} budget
|
||||
*/
|
||||
function _appendParticlesWithinBudget(target, incoming, budget) {
|
||||
const remaining = Math.max(0, budget - target.length);
|
||||
if (remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
_appendParticles(target, incoming.slice(0, remaining));
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一发射一枚火箭。
|
||||
*
|
||||
* @param {Rocket[]} rockets
|
||||
* @param {number} w
|
||||
* @param {number} h
|
||||
* @param {string[]} colors
|
||||
* @param {Function} getX
|
||||
* @param {Function} getTargetY
|
||||
*/
|
||||
function _launchRocket(rockets, w, h, colors, getX, getTargetY) {
|
||||
const x = getX(w);
|
||||
const ty = getTargetY(h);
|
||||
const color = _pick(colors);
|
||||
const type = _pick(TYPES);
|
||||
const drift = (Math.random() - 0.5) * 0.9;
|
||||
rockets.push(new Rocket(x, ty, color, type, h, drift));
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用烟花演出引擎。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
* @param {object} config
|
||||
*/
|
||||
function _runShow(canvas, onEnd, config) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const isMobile = window.matchMedia?.("(max-width: 640px)")?.matches || window.innerWidth <= 640;
|
||||
const mobileScale = isMobile ? 0.72 : 1;
|
||||
const duration = config.duration;
|
||||
const hardStopAt = duration + 2600;
|
||||
const peakParticleBudget = Math.round((config.peakParticleBudget ?? 1650) * mobileScale);
|
||||
const maxLaunches = Math.max(8, Math.round(config.maxLaunches * mobileScale));
|
||||
const particleDensity = config.particleDensity * mobileScale;
|
||||
const secondaryDensity = config.secondaryDensity * mobileScale;
|
||||
|
||||
let rockets = [];
|
||||
let particles = [];
|
||||
let halos = [];
|
||||
let scheduledBursts = [];
|
||||
let animId = null;
|
||||
let launchCnt = 0;
|
||||
let finished = false;
|
||||
const timers = [];
|
||||
|
||||
const launchInterval = setInterval(() => {
|
||||
if (launchCnt >= maxLaunches) {
|
||||
clearInterval(launchInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const batchSize = config.getBatchSize(launchCnt);
|
||||
for (let i = 0; i < batchSize && launchCnt < maxLaunches; i++) {
|
||||
_launchRocket(
|
||||
rockets,
|
||||
w,
|
||||
h,
|
||||
config.colors,
|
||||
config.getLaunchX,
|
||||
config.getTargetY,
|
||||
);
|
||||
launchCnt++;
|
||||
}
|
||||
}, config.launchEvery);
|
||||
|
||||
// 开场礼炮先把气氛撑起来,避免一开始太空。
|
||||
if (typeof config.openingVolley === "function") {
|
||||
timers.push(setTimeout(() => {
|
||||
config.openingVolley(rockets, w, h);
|
||||
}, 120));
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
function animate(now) {
|
||||
_fadeFrame(ctx, w, h);
|
||||
|
||||
halos = halos.filter((halo) => halo.alive);
|
||||
halos.forEach((halo) => {
|
||||
halo.update();
|
||||
halo.draw(ctx);
|
||||
});
|
||||
|
||||
for (let i = scheduledBursts.length - 1; i >= 0; i--) {
|
||||
if (scheduledBursts[i].triggerAt <= now) {
|
||||
const burst = scheduledBursts[i];
|
||||
_appendParticlesWithinBudget(
|
||||
particles,
|
||||
_burst(burst.x, burst.y, burst.color, burst.type, burst.density),
|
||||
peakParticleBudget,
|
||||
);
|
||||
halos.push(new Halo(burst.x, burst.y, burst.color, burst.haloRadius));
|
||||
scheduledBursts.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = rockets.length - 1; i >= 0; i--) {
|
||||
const rocket = rockets[i];
|
||||
if (rocket.done) {
|
||||
_appendParticlesWithinBudget(
|
||||
particles,
|
||||
_burst(
|
||||
rocket.x,
|
||||
rocket.y,
|
||||
rocket.color,
|
||||
rocket.type,
|
||||
particleDensity,
|
||||
),
|
||||
peakParticleBudget,
|
||||
);
|
||||
halos.push(new Halo(rocket.x, rocket.y, rocket.color, config.primaryHaloRadius));
|
||||
|
||||
// 粒子高峰时先保主爆炸观感,压掉一部分二次爆裂来避免卡顿。
|
||||
if (
|
||||
particles.length < peakParticleBudget
|
||||
&& Math.random() < config.secondaryBurstChance
|
||||
) {
|
||||
scheduledBursts.push({
|
||||
triggerAt: now + 90 + Math.random() * 140,
|
||||
x: rocket.x + (Math.random() - 0.5) * 34,
|
||||
y: rocket.y + (Math.random() - 0.5) * 26,
|
||||
color: _pick(config.colors),
|
||||
type: Math.random() > 0.5 ? "sphere" : "ring",
|
||||
density: secondaryDensity,
|
||||
haloRadius: config.secondaryHaloRadius,
|
||||
});
|
||||
}
|
||||
|
||||
rockets.splice(i, 1);
|
||||
} else {
|
||||
rocket.update();
|
||||
rocket.draw(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
particles = particles.filter((particle) => particle.alive);
|
||||
particles.forEach((particle) => {
|
||||
particle.update();
|
||||
particle.draw(ctx);
|
||||
});
|
||||
|
||||
const elapsed = now - startTime;
|
||||
const shouldContinue = elapsed < duration
|
||||
|| rockets.length > 0
|
||||
|| particles.length > 0
|
||||
|| scheduledBursts.length > 0;
|
||||
|
||||
if (shouldContinue && elapsed < hardStopAt) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
|
||||
/**
|
||||
* 统一结束烟花演出,取消时不再回调管理器。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
clearInterval(launchInterval);
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
rockets = [];
|
||||
particles = [];
|
||||
halos = [];
|
||||
scheduledBursts = [];
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动烟花特效(普通版)
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
return _runShow(canvas, onEnd, {
|
||||
duration: 10500,
|
||||
launchEvery: 340,
|
||||
maxLaunches: 24,
|
||||
particleDensity: 1.08,
|
||||
peakParticleBudget: 1500,
|
||||
secondaryDensity: 0.42,
|
||||
primaryHaloRadius: 150,
|
||||
secondaryHaloRadius: 84,
|
||||
secondaryBurstChance: 0.54,
|
||||
colors: COLORS,
|
||||
getBatchSize(launchCnt) {
|
||||
return launchCnt % 5 === 0 ? 2 : 1;
|
||||
},
|
||||
getLaunchX(width) {
|
||||
return width * (0.1 + Math.random() * 0.8);
|
||||
},
|
||||
getTargetY(height) {
|
||||
return height * (0.08 + Math.random() * 0.42);
|
||||
},
|
||||
openingVolley(rockets, width, height) {
|
||||
[0.18, 0.5, 0.82].forEach((ratio) => {
|
||||
rockets.push(new Rocket(
|
||||
width * ratio,
|
||||
height * (0.12 + Math.random() * 0.12),
|
||||
_pick(COLORS),
|
||||
"sphere",
|
||||
height,
|
||||
(Math.random() - 0.5) * 0.6,
|
||||
));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动婚礼加倍烟花特效(双侧轮流发射,粒子增倍,持续更久)
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function startDouble(canvas, onEnd) {
|
||||
// 婚礼专属浪漫色组(增加金色/粉色)
|
||||
const WEDDING_COLORS = [
|
||||
"#ff2266",
|
||||
"#ff66aa",
|
||||
"#ff99cc", // 粉红系
|
||||
"#ffcc00",
|
||||
"#ffdd44",
|
||||
"#fff066", // 金黄系
|
||||
"#cc44ff",
|
||||
"#ff44cc",
|
||||
"#aa00ff", // 紫色系
|
||||
"#ff4400",
|
||||
"#ff8800",
|
||||
"#00ddff", // 其他
|
||||
];
|
||||
|
||||
return _runShow(canvas, onEnd, {
|
||||
duration: 12400,
|
||||
launchEvery: 280,
|
||||
maxLaunches: 34,
|
||||
particleDensity: 1.3,
|
||||
peakParticleBudget: 1850,
|
||||
secondaryDensity: 0.56,
|
||||
primaryHaloRadius: 176,
|
||||
secondaryHaloRadius: 96,
|
||||
secondaryBurstChance: 0.72,
|
||||
colors: WEDDING_COLORS,
|
||||
getBatchSize(launchCnt) {
|
||||
return launchCnt % 4 === 0 ? 2 : 1;
|
||||
},
|
||||
getLaunchX(width) {
|
||||
const fromLeft = Math.random() > 0.5;
|
||||
return fromLeft
|
||||
? width * (0.04 + Math.random() * 0.38)
|
||||
: width * (0.58 + Math.random() * 0.38);
|
||||
},
|
||||
getTargetY(height) {
|
||||
return height * (0.05 + Math.random() * 0.38);
|
||||
},
|
||||
openingVolley(rockets, width, height) {
|
||||
[0.12, 0.32, 0.5, 0.68, 0.88].forEach((ratio, index) => {
|
||||
rockets.push(new Rocket(
|
||||
width * ratio,
|
||||
height * (index % 2 === 0 ? 0.1 : 0.16),
|
||||
_pick(WEDDING_COLORS),
|
||||
index % 2 === 0 ? "sphere" : "ring",
|
||||
height,
|
||||
(Math.random() - 0.5) * 0.7,
|
||||
));
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { start, startDouble };
|
||||
})();
|
||||
|
||||
window.FireworksEffect = FireworksEffect;
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 文件功能:聊天室金币雨特效
|
||||
*
|
||||
* 使用 Canvas 绘制翻转下落的金币,让金币带着高光与闪烁效果从天而降,
|
||||
* 用于活动奖励、红包雨等庆祝场景。
|
||||
*/
|
||||
|
||||
const GoldRainEffect = (() => {
|
||||
class Coin {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置金币位置与翻转参数。
|
||||
*
|
||||
* @param {boolean} initial 是否首次初始化
|
||||
*/
|
||||
reset(initial = false) {
|
||||
this.x = Math.random() * this.w;
|
||||
this.y = initial ? Math.random() * this.h : -30 - Math.random() * 160;
|
||||
this.radius = Math.random() * 8 + 12;
|
||||
this.speedY = Math.random() * 1.2 + 1.2;
|
||||
this.speedX = Math.random() * 0.9 - 0.45;
|
||||
this.gravity = Math.random() * 0.035 + 0.015;
|
||||
this.spin = Math.random() * Math.PI * 2;
|
||||
this.spinSpeed = Math.random() * 0.16 + 0.1;
|
||||
this.alpha = Math.random() * 0.25 + 0.72;
|
||||
this.sparkle = Math.random() * Math.PI * 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新金币状态。
|
||||
*/
|
||||
update() {
|
||||
this.speedY = Math.min(this.speedY + this.gravity, 2.8);
|
||||
this.y += this.speedY;
|
||||
this.x += this.speedX;
|
||||
this.spin += this.spinSpeed;
|
||||
this.sparkle += 0.08;
|
||||
|
||||
if (this.y > this.h + 40) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单枚金币。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
const scaleX = Math.max(0.22, Math.abs(Math.cos(this.spin)));
|
||||
const glow = 0.28 + Math.max(0, Math.sin(this.sparkle)) * 0.18;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
ctx.scale(scaleX, 1);
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.shadowColor = "rgba(250, 204, 21, 0.55)";
|
||||
ctx.shadowBlur = 10;
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, -this.radius, 0, this.radius);
|
||||
gradient.addColorStop(0, "#fef08a");
|
||||
gradient.addColorStop(0.45, "#facc15");
|
||||
gradient.addColorStop(1, "#ca8a04");
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, this.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeStyle = "rgba(161, 98, 7, 0.7)";
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = `rgba(255,255,255,${glow})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(-this.radius * 0.28, -this.radius * 0.32, this.radius * 0.26, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.scale(1 / scaleX, 1);
|
||||
ctx.fillStyle = "rgba(120, 53, 15, 0.8)";
|
||||
ctx.font = `${Math.max(10, this.radius)}px Arial`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText("¥", 0, 1);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动金币雨特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 8600;
|
||||
const COIN_COUNT = Math.min(58, Math.max(28, Math.floor(w / 28)));
|
||||
const coins = Array.from({ length: COIN_COUNT }, () => new Coin(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();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
coins.forEach((coin) => {
|
||||
coin.update();
|
||||
coin.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.GoldRainEffect = GoldRainEffect;
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 文件功能:聊天室爱心飘落特效
|
||||
*
|
||||
* 在透明 Canvas 上绘制成组心形,从上方向下飘落并轻微摇摆,
|
||||
* 适用于表白、婚礼、节日祝福等氛围场景。
|
||||
*/
|
||||
|
||||
const HeartsEffect = (() => {
|
||||
class Heart {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置爱心粒子。
|
||||
*
|
||||
* @param {boolean} initial 是否首次初始化
|
||||
*/
|
||||
reset(initial = false) {
|
||||
this.x = Math.random() * this.w;
|
||||
this.y = initial ? Math.random() * this.h : -24;
|
||||
this.size = Math.random() * 10 + 10;
|
||||
this.speedY = Math.random() * 1.5 + 1.1;
|
||||
this.swing = Math.random() * Math.PI * 2;
|
||||
this.swingSpeed = Math.random() * 0.04 + 0.015;
|
||||
this.rotation = (Math.random() - 0.5) * 0.35;
|
||||
this.rotationSpeed = (Math.random() - 0.5) * 0.01;
|
||||
this.alpha = Math.random() * 0.28 + 0.62;
|
||||
this.color = ["#fb7185", "#f43f5e", "#ec4899", "#fda4af"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新爱心位置。
|
||||
*/
|
||||
update() {
|
||||
this.swing += this.swingSpeed;
|
||||
this.rotation += this.rotationSpeed;
|
||||
this.x += Math.sin(this.swing) * 1.1;
|
||||
this.y += this.speedY;
|
||||
|
||||
if (this.y > this.h + 28) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制爱心形状。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
const s = this.size;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
ctx.rotate(this.rotation);
|
||||
ctx.scale(s / 16, s / 16);
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowColor = "rgba(244, 63, 94, 0.45)";
|
||||
ctx.shadowBlur = 10;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -4);
|
||||
ctx.bezierCurveTo(12, -18, 26, -2, 0, 18);
|
||||
ctx.bezierCurveTo(-26, -2, -12, -18, 0, -4);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动爱心飘落特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 9000;
|
||||
const HEART_COUNT = Math.min(48, Math.max(24, Math.floor(w / 34)));
|
||||
const hearts = Array.from({ length: HEART_COUNT }, () => new Heart(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();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
hearts.forEach((heart) => {
|
||||
heart.update();
|
||||
heart.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.HeartsEffect = HeartsEffect;
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 文件功能:聊天室雷电特效
|
||||
*
|
||||
* 使用递归分叉算法叠加云层闪光、主闪电、余辉残影,
|
||||
* 在聊天室中模拟更有压迫感的雷暴闪电效果。
|
||||
*/
|
||||
|
||||
const LightningEffect = (() => {
|
||||
/**
|
||||
* 递归绘制闪电路径(分裂算法)
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x1 起点 x
|
||||
* @param {number} y1 起点 y
|
||||
* @param {number} x2 终点 x
|
||||
* @param {number} y2 终点 y
|
||||
* @param {number} depth 当前递归深度(控制分叉层数)
|
||||
* @param {number} width 线条宽度
|
||||
*/
|
||||
function _drawBolt(ctx, x1, y1, x2, y2, depth, width) {
|
||||
if (depth <= 0) return;
|
||||
|
||||
// 中点随机偏移(越深层偏移越小,产生流畅感)
|
||||
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * 80 * depth;
|
||||
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * 20 * depth;
|
||||
const glow = ctx.createLinearGradient(x1, y1, x2, y2);
|
||||
glow.addColorStop(0, "rgba(200, 220, 255, 0.9)");
|
||||
glow.addColorStop(1, "rgba(150, 180, 255, 0.6)");
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = glow;
|
||||
ctx.lineWidth = width;
|
||||
ctx.shadowColor = "#aaccff";
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.quadraticCurveTo(mx, my, x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// 递归绘制两段子路径
|
||||
_drawBolt(ctx, x1, y1, mx, my, depth - 1, width * 0.65);
|
||||
_drawBolt(ctx, mx, my, x2, y2, depth - 1, width * 0.65);
|
||||
|
||||
// 随机在中途分叉一条小支路(50% 概率)
|
||||
if (depth > 1 && Math.random() > 0.5) {
|
||||
const bx = mx + (Math.random() - 0.5) * 120;
|
||||
const by = my + Math.random() * 80 + 40;
|
||||
_drawBolt(ctx, mx, my, bx, by, depth - 2, width * 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制顶部乌云压光层,让闪电更有“雷暴”氛围。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
function _drawStormGlow(canvas, ctx) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const sky = ctx.createLinearGradient(0, 0, 0, h * 0.8);
|
||||
sky.addColorStop(0, "rgba(7, 18, 38, 0.34)");
|
||||
sky.addColorStop(0.45, "rgba(15, 23, 42, 0.18)");
|
||||
sky.addColorStop(1, "rgba(15, 23, 42, 0)");
|
||||
ctx.fillStyle = sky;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const cloudX = w * (0.12 + Math.random() * 0.76);
|
||||
const cloudY = h * (0.05 + Math.random() * 0.22);
|
||||
const cloudR = 120 + Math.random() * 160;
|
||||
const cloud = ctx.createRadialGradient(cloudX, cloudY, 0, cloudX, cloudY, cloudR);
|
||||
cloud.addColorStop(0, "rgba(210, 226, 255, 0.18)");
|
||||
cloud.addColorStop(0.38, "rgba(168, 196, 255, 0.1)");
|
||||
cloud.addColorStop(1, "rgba(168, 196, 255, 0)");
|
||||
ctx.fillStyle = cloud;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cloudX, cloudY, cloudR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一次闪电 + 闪屏效果。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
function _flash(canvas, ctx, timers) {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 先铺一层压暗天空,再叠加闪白,让明暗反差更明显。
|
||||
_drawStormGlow(canvas, ctx);
|
||||
ctx.fillStyle = "rgba(228, 239, 255, 0.46)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 绘制 1-3 条主闪电,并给出明显的白色核心线。
|
||||
const boltCount = Math.floor(Math.random() * 3) + 1;
|
||||
for (let i = 0; i < boltCount; i++) {
|
||||
const x1 = w * (0.12 + Math.random() * 0.76);
|
||||
const y1 = 0;
|
||||
const x2 = x1 + (Math.random() - 0.5) * 360;
|
||||
const y2 = h * (0.55 + Math.random() * 0.35);
|
||||
const width = 3.6 + Math.random() * 1.8;
|
||||
_drawBolt(ctx, x1, y1, x2, y2, 5, width);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.92)";
|
||||
ctx.lineWidth = Math.max(1.2, width * 0.34);
|
||||
ctx.shadowColor = "rgba(255,255,255,0.95)";
|
||||
ctx.shadowBlur = 22;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 短促残影:闪电消失后保留一层很淡的余辉,避免“闪一下就没了”。
|
||||
timers.push(setTimeout(() => {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
_drawStormGlow(canvas, ctx);
|
||||
ctx.fillStyle = "rgba(185, 205, 255, 0.12)";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}, 90));
|
||||
|
||||
timers.push(setTimeout(() => {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
}, 190));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动雷电特效
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const FLASHES = 9;
|
||||
const DURATION = 7600;
|
||||
let count = 0;
|
||||
let finished = false;
|
||||
const timers = [];
|
||||
|
||||
/**
|
||||
* 统一结束特效,避免多次触发 onEnd。
|
||||
*/
|
||||
function finish() {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
onEnd();
|
||||
}
|
||||
|
||||
// 间隔不规则触发多次闪电(模拟真实雷电节奏)
|
||||
function nextFlash() {
|
||||
if (count >= FLASHES) {
|
||||
timers.push(setTimeout(() => {
|
||||
finish();
|
||||
}, 520));
|
||||
return;
|
||||
}
|
||||
|
||||
_flash(canvas, ctx, timers);
|
||||
count++;
|
||||
|
||||
// 让雷电节奏有“成组爆发”的感觉:有时连续两下,有时间隔更久。
|
||||
const delay = Math.random() > 0.65
|
||||
? 140 + Math.random() * 140
|
||||
: 420 + Math.random() * 520;
|
||||
timers.push(setTimeout(nextFlash, delay));
|
||||
}
|
||||
|
||||
// 短暂延迟后开始第一次闪电
|
||||
timers.push(setTimeout(nextFlash, 300));
|
||||
|
||||
// 安全兜底:超时强制结束
|
||||
timers.push(setTimeout(() => {
|
||||
finish();
|
||||
}, DURATION + 500));
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.LightningEffect = LightningEffect;
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 文件功能:聊天室流星特效
|
||||
*
|
||||
* 在透明 Canvas 上绘制夜空光点和多枚斜向掠过的流星,
|
||||
* 通过长尾渐变与随机节奏制造快速划空的视觉效果。
|
||||
*/
|
||||
|
||||
const MeteorsEffect = (() => {
|
||||
class Meteor {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置单颗流星的出发状态。
|
||||
*
|
||||
* @param {boolean} initial 是否首次初始化
|
||||
*/
|
||||
reset(initial = false) {
|
||||
this.x = initial ? Math.random() * this.w : this.w + Math.random() * 160;
|
||||
this.y = initial ? Math.random() * this.h * 0.45 : Math.random() * this.h * 0.52;
|
||||
this.vx = -(12 + Math.random() * 7);
|
||||
this.vy = 4.4 + Math.random() * 2.6;
|
||||
this.length = 170 + Math.random() * 170;
|
||||
this.alpha = 0;
|
||||
this.maxAlpha = Math.random() * 0.28 + 0.72;
|
||||
this.delay = Math.random() * 1500;
|
||||
this.birth = performance.now();
|
||||
this.life = 1800 + Math.random() * 1000;
|
||||
this.width = Math.random() * 2.4 + 1.8;
|
||||
this.tint = [
|
||||
[255, 255, 255],
|
||||
[191, 219, 254],
|
||||
[125, 211, 252],
|
||||
[253, 224, 71],
|
||||
][Math.floor(Math.random() * 4)];
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新流星位置。
|
||||
*
|
||||
* @param {number} now
|
||||
*/
|
||||
update(now) {
|
||||
if (!this.active) {
|
||||
if (now - this.birth >= this.delay) {
|
||||
this.active = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
|
||||
const progress = (now - this.birth - this.delay) / this.life;
|
||||
if (progress < 0.2) {
|
||||
this.alpha = this.maxAlpha * (progress / 0.2);
|
||||
} else if (progress > 0.78) {
|
||||
this.alpha = this.maxAlpha * Math.max(0, (1 - progress) / 0.22);
|
||||
} else {
|
||||
this.alpha = this.maxAlpha;
|
||||
}
|
||||
|
||||
if (progress >= 1 || this.x < -this.length || this.y > this.h + this.length) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制流星主体和尾迹。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
if (!this.active || this.alpha <= 0.01) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tailX = this.x - this.vx * 9;
|
||||
const tailY = this.y - this.vy * 9;
|
||||
const [r, g, b] = this.tint;
|
||||
const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY);
|
||||
gradient.addColorStop(0, `rgba(255,255,255,${Math.min(1, this.alpha + 0.12)})`);
|
||||
gradient.addColorStop(0.18, `rgba(${r},${g},${b},${this.alpha})`);
|
||||
gradient.addColorStop(0.62, `rgba(${r},${g},${b},${this.alpha * 0.42})`);
|
||||
gradient.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = this.width;
|
||||
ctx.lineCap = "round";
|
||||
ctx.shadowColor = `rgba(${r},${g},${b},0.95)`;
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.x, this.y);
|
||||
ctx.lineTo(tailX, tailY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = `rgba(255,255,255,${this.alpha * 0.65})`;
|
||||
ctx.lineWidth = this.width * 0.42;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.x, this.y);
|
||||
ctx.lineTo(this.x - this.vx * 2.2, this.y - this.vy * 2.2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = `rgba(255,255,255,${Math.min(1, this.alpha + 0.18)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.width * 1.55, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},${this.alpha * 0.55})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.width * 3.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动流星特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 9000;
|
||||
const stars = Array.from({ length: 48 }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h * 0.62,
|
||||
r: Math.random() * 1.9 + 0.6,
|
||||
alpha: Math.random() * 0.42 + 0.2,
|
||||
}));
|
||||
const meteors = Array.from({ length: 14 }, () => new Meteor(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();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
stars.forEach((star) => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = `rgba(248,250,252,${star.alpha})`;
|
||||
ctx.shadowColor = "rgba(255,255,255,0.6)";
|
||||
ctx.shadowBlur = 7;
|
||||
ctx.beginPath();
|
||||
ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
meteors.forEach((meteor) => {
|
||||
meteor.update(now);
|
||||
meteor.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.MeteorsEffect = MeteorsEffect;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 文件功能:聊天室下雨特效
|
||||
*
|
||||
* 使用 Canvas 绘制斜向雨线,模拟真实降雨视觉效果。
|
||||
* 加粗加深雨线颜色,在浅色背景上清晰可见。
|
||||
* 特效总时长约 8 秒,结束后自动清理并回调。
|
||||
*/
|
||||
|
||||
const RainEffect = (() => {
|
||||
// 雨滴类:一条从顶部往下落的斜线
|
||||
class Drop {
|
||||
constructor(w, h) {
|
||||
this.reset(w, h);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置/初始化雨滴位置
|
||||
*/
|
||||
reset(w, h) {
|
||||
this.x = Math.random() * w;
|
||||
this.y = Math.random() * -h;
|
||||
this.len = Math.random() * 25 + 12; // 雨线长度(稍加长)
|
||||
this.speed = Math.random() * 10 + 7; // 下落速度(加快)
|
||||
this.angle = (Math.PI / 180) * (75 + Math.random() * 10);
|
||||
this.alpha = Math.random() * 0.5 + 0.4; // 提高透明度上限 (0.4-0.9,原 0.2-0.5)
|
||||
this.strokeW = Math.random() * 1.5 + 0.8; // 线条宽度随机(原 0.8 固定)
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
/** 每帧更新雨滴位置 */
|
||||
update() {
|
||||
this.x += Math.cos(this.angle) * this.speed * 0.3;
|
||||
this.y += Math.sin(this.angle) * this.speed;
|
||||
if (this.y > this.h + this.len) {
|
||||
this.reset(this.w, this.h);
|
||||
}
|
||||
}
|
||||
|
||||
/** 绘制雨滴线段(加深蓝色,在浅色背景上更明显) */
|
||||
draw(ctx) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = `rgba(50, 130, 220, ${this.alpha})`; // 加深蓝色(原浅蓝 155,200,255)
|
||||
ctx.lineWidth = this.strokeW;
|
||||
ctx.shadowColor = "rgba(30, 100, 200, 0.4)";
|
||||
ctx.shadowBlur = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(this.x, this.y);
|
||||
ctx.lineTo(
|
||||
this.x + Math.cos(this.angle) * this.len,
|
||||
this.y + Math.sin(this.angle) * this.len,
|
||||
);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动下雨特效
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 8000;
|
||||
const DROP_COUNT = 200; // 增加雨滴数量(原 180)
|
||||
|
||||
const drops = Array.from({ length: DROP_COUNT }, () => {
|
||||
const d = new Drop(w, h);
|
||||
d.y = Math.random() * h;
|
||||
return d;
|
||||
});
|
||||
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
const startTime = performance.now();
|
||||
|
||||
/**
|
||||
* 统一结束雨滴动画,手动取消时不触发队列续播。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
// 清除画布(透明,不遮挡聊天背景)
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
drops.forEach((d) => {
|
||||
d.update();
|
||||
d.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.RainEffect = RainEffect;
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 文件功能:聊天室樱花飘落特效
|
||||
*
|
||||
* 使用 Canvas 绘制多层粉色花瓣,让花瓣在屏幕上方生成后缓慢下落、
|
||||
* 左右摆动并带轻微旋转,营造柔和的春日氛围。
|
||||
*/
|
||||
|
||||
const SakuraEffect = (() => {
|
||||
class Petal {
|
||||
constructor(w, h) {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置花瓣位置与运动参数。
|
||||
*
|
||||
* @param {boolean} initial 是否首次初始化
|
||||
*/
|
||||
reset(initial = false) {
|
||||
this.x = Math.random() * this.w;
|
||||
this.y = initial ? Math.random() * this.h : -20;
|
||||
this.size = Math.random() * 7 + 8;
|
||||
this.speedY = Math.random() * 1.1 + 0.7;
|
||||
this.speedX = Math.random() * 0.6 - 0.3;
|
||||
this.swing = Math.random() * Math.PI * 2;
|
||||
this.swingSpeed = Math.random() * 0.03 + 0.01;
|
||||
this.rotation = Math.random() * Math.PI * 2;
|
||||
this.rotationSpeed = (Math.random() - 0.5) * 0.03;
|
||||
this.alpha = Math.random() * 0.25 + 0.55;
|
||||
this.color = ["#fbcfe8", "#f9a8d4", "#fda4af", "#fce7f3"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新花瓣运动状态。
|
||||
*/
|
||||
update() {
|
||||
this.swing += this.swingSpeed;
|
||||
this.rotation += this.rotationSpeed;
|
||||
this.x += this.speedX + Math.sin(this.swing) * 0.75;
|
||||
this.y += this.speedY;
|
||||
|
||||
if (this.y > this.h + 30 || this.x < -30 || this.x > this.w + 30) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制单片樱花花瓣。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
*/
|
||||
draw(ctx) {
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
ctx.rotate(this.rotation);
|
||||
ctx.globalAlpha = this.alpha;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowColor = "rgba(244, 114, 182, 0.35)";
|
||||
ctx.shadowBlur = 8;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -this.size * 0.9);
|
||||
ctx.quadraticCurveTo(this.size * 0.9, -this.size * 0.25, 0, this.size);
|
||||
ctx.quadraticCurveTo(-this.size * 0.9, -this.size * 0.25, 0, -this.size * 0.9);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "rgba(190, 24, 93, 0.25)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -this.size * 0.55);
|
||||
ctx.lineTo(0, this.size * 0.7);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动樱花特效。
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {Function} onEnd
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 10000;
|
||||
const PETAL_COUNT = Math.min(58, Math.max(34, Math.floor(w / 28)));
|
||||
const petals = Array.from({ length: PETAL_COUNT }, () => new Petal(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();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
petals.forEach((petal) => {
|
||||
petal.update();
|
||||
petal.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.SakuraEffect = SakuraEffect;
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 文件功能:聊天室下雪特效
|
||||
*
|
||||
* 使用 Canvas 同时绘制远景小雪与近景六角雪花,
|
||||
* 通过层次、大小、速度差营造更饱满的飘雪效果。
|
||||
*/
|
||||
|
||||
const SnowEffect = (() => {
|
||||
/**
|
||||
* 在指定位置绘制一朵六角雪花(深色轮廓 + 白色主体)
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x 中心 x
|
||||
* @param {number} y 中心 y
|
||||
* @param {number} r 主臂长度
|
||||
* @param {number} alpha 透明度
|
||||
* @param {number} rot 旋转角度(弧度)
|
||||
*/
|
||||
function _drawFlake(ctx, x, y, r, alpha, rot) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineCap = "round";
|
||||
ctx.translate(x, y);
|
||||
ctx.rotate(rot);
|
||||
|
||||
// 两遍绘制:先深蓝色粗描边,再白色细线覆盖
|
||||
// 这样在浅蓝、白色等背景上都清晰可辨
|
||||
const passes = [
|
||||
{ color: "rgba(30, 60, 140, 0.8)", lw: r * 0.22 + 2.5 }, // 深蓝粗描边
|
||||
{ color: "rgba(255, 255, 255, 1.0)", lw: Math.max(1, r * 0.11) }, // 白色主体
|
||||
];
|
||||
|
||||
passes.forEach(({ color, lw }) => {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = lw;
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
ctx.save();
|
||||
ctx.rotate((Math.PI / 3) * i);
|
||||
|
||||
// 主臂
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(r, 0);
|
||||
ctx.stroke();
|
||||
|
||||
// 斜向分叉(0.4r 和 0.65r 处各一对)
|
||||
const branchLen = r * 0.35;
|
||||
const branchAngle = Math.PI / 4; // 45°
|
||||
|
||||
[0.4, 0.65].forEach((pos) => {
|
||||
const bx = r * pos;
|
||||
// 上分叉
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, 0);
|
||||
ctx.lineTo(
|
||||
bx + Math.cos(branchAngle) * branchLen,
|
||||
Math.sin(branchAngle) * branchLen,
|
||||
);
|
||||
ctx.stroke();
|
||||
// 下分叉
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, 0);
|
||||
ctx.lineTo(
|
||||
bx + Math.cos(branchAngle) * branchLen,
|
||||
-Math.sin(branchAngle) * branchLen,
|
||||
);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制远景小雪点,让画面更密实,不会只看到零散大雪花。
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {number} radius
|
||||
* @param {number} alpha
|
||||
*/
|
||||
function _drawSoftSnow(ctx, x, y, radius, alpha) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fillStyle = "rgba(255,255,255,0.95)";
|
||||
ctx.shadowColor = "rgba(255,255,255,0.8)";
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 雪花粒子类
|
||||
class Flake {
|
||||
constructor(w, h, layer = "front") {
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.layer = layer;
|
||||
this.reset(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置雪花,让前景和背景使用不同参数。
|
||||
*
|
||||
* @param {boolean} initial
|
||||
*/
|
||||
reset(initial = false) {
|
||||
this.x = Math.random() * this.w;
|
||||
this.y = initial ? Math.random() * this.h : -20;
|
||||
if (this.layer === "back") {
|
||||
this.r = Math.random() * 2 + 1.1;
|
||||
this.speed = Math.random() * 0.55 + 0.28;
|
||||
this.drift = Math.random() * 0.28 - 0.14;
|
||||
this.alpha = Math.random() * 0.25 + 0.28;
|
||||
} else {
|
||||
this.r = Math.random() * 9 + 6.5;
|
||||
this.speed = Math.random() * 1.05 + 0.48;
|
||||
this.drift = Math.random() * 0.7 - 0.35;
|
||||
this.alpha = Math.random() * 0.25 + 0.68;
|
||||
}
|
||||
this.rot = Math.random() * Math.PI * 2;
|
||||
this.rotSpd = (Math.random() - 0.5) * (this.layer === "back" ? 0.008 : 0.018);
|
||||
this.wobble = 0;
|
||||
this.wobSpd = Math.random() * (this.layer === "back" ? 0.02 : 0.028) + 0.008;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.wobble += this.wobSpd;
|
||||
this.x += Math.sin(this.wobble) * (this.layer === "back" ? 0.28 : 0.58) + this.drift;
|
||||
this.y += this.speed;
|
||||
this.rot += this.rotSpd;
|
||||
if (this.y > this.h + 20) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
if (this.layer === "back") {
|
||||
_drawSoftSnow(ctx, this.x, this.y, this.r, this.alpha);
|
||||
return;
|
||||
}
|
||||
|
||||
_drawFlake(ctx, this.x, this.y, this.r, this.alpha, this.rot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动下雪特效
|
||||
*
|
||||
* @param {HTMLCanvasElement} canvas 全屏 Canvas
|
||||
* @param {Function} onEnd 特效结束回调
|
||||
*/
|
||||
function start(canvas, onEnd) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const DURATION = 10000;
|
||||
const flakes = [
|
||||
...Array.from(
|
||||
{ length: Math.min(120, Math.max(70, Math.floor(w / 18))) },
|
||||
() => new Flake(w, h, "back"),
|
||||
),
|
||||
...Array.from(
|
||||
{ length: Math.min(64, Math.max(34, Math.floor(w / 42))) },
|
||||
() => new Flake(w, h, "front"),
|
||||
),
|
||||
];
|
||||
|
||||
const breezeBands = Array.from({ length: 2 }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
radius: 180 + Math.random() * 140,
|
||||
alpha: Math.random() * 0.05 + 0.025,
|
||||
drift: Math.random() * 0.3 + 0.08,
|
||||
}));
|
||||
|
||||
let animId = null;
|
||||
let finished = false;
|
||||
const startTime = performance.now();
|
||||
|
||||
/**
|
||||
* 统一结束雪花动画,手动取消时只清理不回调。
|
||||
*
|
||||
* @param {boolean} canceled 是否为手动取消
|
||||
*/
|
||||
function finish(canceled) {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
finished = true;
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
}
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!canceled) {
|
||||
onEnd();
|
||||
}
|
||||
}
|
||||
|
||||
function animate(now) {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// 加一层极淡的冷白雾感,让雪景更有氛围但不遮挡聊天内容。
|
||||
const mist = ctx.createLinearGradient(0, 0, 0, h);
|
||||
mist.addColorStop(0, "rgba(226,240,255,0.08)");
|
||||
mist.addColorStop(0.4, "rgba(226,240,255,0.03)");
|
||||
mist.addColorStop(1, "rgba(226,240,255,0)");
|
||||
ctx.fillStyle = mist;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
breezeBands.forEach((band) => {
|
||||
band.x += band.drift;
|
||||
if (band.x - band.radius > w) {
|
||||
band.x = -band.radius;
|
||||
band.y = Math.random() * h;
|
||||
}
|
||||
|
||||
const breeze = ctx.createRadialGradient(
|
||||
band.x,
|
||||
band.y,
|
||||
0,
|
||||
band.x,
|
||||
band.y,
|
||||
band.radius,
|
||||
);
|
||||
breeze.addColorStop(0, `rgba(255,255,255,${band.alpha})`);
|
||||
breeze.addColorStop(1, "rgba(255,255,255,0)");
|
||||
ctx.fillStyle = breeze;
|
||||
ctx.beginPath();
|
||||
ctx.arc(band.x, band.y, band.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
flakes.forEach((f) => {
|
||||
f.update();
|
||||
f.draw(ctx);
|
||||
});
|
||||
|
||||
if (now - startTime < DURATION) {
|
||||
animId = requestAnimationFrame(animate);
|
||||
} else {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
return {
|
||||
cancel() {
|
||||
finish(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { start };
|
||||
})();
|
||||
|
||||
window.SnowEffect = SnowEffect;
|
||||
Reference in New Issue
Block a user