uniapp之微信小程序+App端实现烟花升空绽放、文字烟花(可以直接使用)
·
简要说明:
分为两版,一版比较粗糙的,临时接到需求,加工改造的;另一版,比较细腻一点;
核心方法思想,参考的项目:
https://github.com/liujie2345/-
需要注意的点:
- 小程序中不支持 requestAnimationFrame;
- 考虑到性能问题,画布最好使用 type="2d" 的格式;
- app端(app-vue)想要使用画布实现同样的效果,app端不支持离屏画布(文字烟花需要),需要借助 renderjs:
- 画布阻止默认事件(pointer-events)对于 IOS 是不生效的,参考下面文档以及类似的问题:

- ...
粗糙版的:
这个文件我是作为组件来使用的,如果影响到主包的体积,可以尝试跨分包加载;
需求是:作为一个蒙版盖在首页上的,这就要求有合适的透明度、不能妨碍首页的点击事件的触发——上面的简要说明中已经提及;
<template>
<view
class="fw-page"
:style="{backgroundColor: `rgba(0,0,0,${maskOpacity})`}"
@touchend="onTouchEnd"
>
<!-- =========================
微信小程序:原生 2d canvas
========================= -->
<!-- #ifdef MP-WEIXIN -->
<canvas id="trails" type="2d" class="fw-canvas"></canvas>
<!-- @touchstart.stop.prevent="wx_onTouchStart"
@touchmove.stop.prevent="wx_onTouchMove"
@touchend.stop.prevent="wx_onTouchEnd"
@touchcancel.stop.prevent="wx_onTouchEnd" -->
<canvas id="main" type="2d" class="fw-canvas fw-canvas--top"></canvas>
<!-- #endif -->
<!-- =========================
App-vue / H5:renderjs 驱动
- renderjs 在视图层运行,减少跨层通信损耗[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt)[2](https://juejin.cn/post/7463077167405350966)[3](https://bbs.itying.com/topic/68f7a92adf49280042641327)
========================= -->
<!-- #ifdef APP-VUE || H5 -->
<view id="fwWrap" class="fw-wrap" :cmd="cmd" :change:cmd="fwRender.onCmd">
<!-- ✅ hidpi=false:避免 App-vue canvas 自动高清 + renderjs 手动 dpr 叠加导致“画布太大”[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt) -->
<canvas
id="trailsCanvas"
canvas-id="trailsCanvas"
class="fw-canvas"
:hidpi="false"
:disable-scroll="true"
></canvas>
<canvas
id="mainCanvas"
canvas-id="mainCanvas"
class="fw-canvas fw-canvas--top"
:hidpi="false"
:disable-scroll="true"
></canvas>
</view>
<!-- #endif -->
<!-- UI -->
<!--<view class="fw-ui">-->
<!-- <view class="fw-btn" @click="togglePause">{{ paused ? '播放' : '暂停' }}</view>-->
<!-- <view class="fw-btn" @click="toggleAuto">{{-->
<!-- autoLaunch ? '关闭自动' : '开启自动'-->
<!-- }}</view>-->
<!-- <view class="fw-btn" @click="toggleWords">{{-->
<!-- wordShell ? '关闭文字' : '开启文字'-->
<!-- }}</view>-->
<!-- <view class="fw-btn" @click="toggleFinale">{{-->
<!-- finale ? '关闭齐射' : '开启齐射'-->
<!-- }}</view>-->
<!-- <view class="fw-btn" @click="toggleRelease">{{-->
<!-- releaseEnabled ? '停止释放' : '继续释放'-->
<!-- }}</view>-->
<!-- <view class="fw-btn" @click="clearAll">清屏</view>-->
<!--</view>-->
</view>
</template>
<script>
/**
* 逻辑层(service)
* - 微信小程序:逻辑层驱动(setTimeout),不可用 renderjs。[2](https://juejin.cn/post/7463077167405350966)[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt)
* - App-vue/H5:renderjs 驱动(视图层),逻辑层只下发配置/指令,避免跨层高频通信。[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt)[2](https://juejin.cn/post/7463077167405350966)[3](https://bbs.itying.com/topic/68f7a92adf49280042641327)
*/
// ========= 公用:小程序文字点阵缓存(不要挂 data,避免序列化问题)=========
const WX_WORD_CACHE = new Map();
// ========= 微信端引擎(精简但完整:多壳体+文字烟花+拖影双层)=========
const WX_ENGINE = (() => {
const MyMath = {
halfPI: Math.PI / 2,
twoPI: Math.PI * 2,
pointDist(x1, y1, x2, y2) {
const dx = x2 - x1,
dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
},
pointAngle(x1, y1, x2, y2) {
return MyMath.halfPI + Math.atan2(y2 - y1, x2 - x1);
},
clamp(n, min, max) {
return Math.min(Math.max(n, min), max);
},
};
const COLOR = {
Red: '#fc6b93',
Red1: '#f42927',
Gold: '#ffe629',
Green: '#00c2ff',
Blue: '#04c2ff',
Purple: '#ff8749',
Purple1: '#fca824',
Purple2: '#f95d22',
White: '#c9c0fc',
};
const COLOR_CODES = Object.keys(COLOR).map((k) => COLOR[k]);
const INVISIBLE = '_INVISIBLE_';
const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
const GRAVITY = 0.9;
function createParticleCollection() {
const m = {};
COLOR_CODES_W_INVIS.forEach((c) => (m[c] = []));
return m;
}
let _lastColor = null;
function randomColor(opts = {}) {
const {notSame = false, notColor = null, limitWhite = false} = opts;
let c = COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0];
if (limitWhite && c === COLOR.White && Math.random() < 0.6) {
c = COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0];
}
if (notSame) {
while (c === _lastColor)
c = COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0];
} else if (notColor) {
while (c === notColor)
c = COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0];
}
_lastColor = c;
return c;
}
const whiteOrGold = () => (Math.random() < 0.5 ? COLOR.Gold : COLOR.White);
const makePistilColor = (shellColor) =>
shellColor === COLOR.White || shellColor === COLOR.Gold
? randomColor({notColor: shellColor})
: whiteOrGold();
const BurstFlash = {
active: [],
_pool: [],
_new() {
return {};
},
add(x, y, radius) {
const ins = this._pool.pop() || this._new();
ins.x = x;
ins.y = y;
ins.radius = radius;
this.active.push(ins);
return ins;
},
returnInstance(ins) {
this._pool.push(ins);
},
};
const Star = {
airDrag: 0.98,
airDragHeavy: 0.992,
active: createParticleCollection(),
_pool: [],
_new() {
return {};
},
add(x, y, color, angle, speed, life, speedOffX = 0, speedOffY = 0, size = 3) {
const ins = this._pool.pop() || this._new();
ins.visible = true;
ins.heavy = false;
ins.x = x;
ins.y = y;
ins.prevX = x;
ins.prevY = y;
ins.color = color;
ins.speedX = Math.sin(angle) * speed + speedOffX;
ins.speedY = Math.cos(angle) * speed + speedOffY;
ins.life = life;
ins.fullLife = life;
ins.size = size;
ins.spinAngle = Math.random() * MyMath.twoPI;
ins.spinSpeed = 0.8;
ins.spinRadius = 0;
ins.sparkFreq = 0;
ins.sparkSpeed = 1;
ins.sparkTimer = 0;
ins.sparkColor = color;
ins.sparkLife = 750;
ins.sparkLifeVariation = 0.25;
// 文字稳定期
ins.isWord = false;
ins.settle = 0;
ins.strobe = false;
ins.strobeFreq = 0;
ins.secondColor = null;
ins.transitionTime = 0;
ins.colorChanged = false;
ins.onDeath = null;
ins.updateFrame = 0;
this.active[color].push(ins);
return ins;
},
returnInstance(ins) {
ins.onDeath && ins.onDeath(ins);
ins.onDeath = null;
ins.secondColor = null;
ins.transitionTime = 0;
ins.colorChanged = false;
ins.isWord = false;
ins.settle = 0;
this._pool.push(ins);
},
};
const Spark = {
drawWidth: 1,
airDrag: 0.9,
active: createParticleCollection(),
_pool: [],
_new() {
return {};
},
add(x, y, color, angle, speed, life) {
const ins = this._pool.pop() || this._new();
ins.x = x;
ins.y = y;
ins.prevX = x;
ins.prevY = y;
ins.color = color;
ins.speedX = Math.sin(angle) * speed;
ins.speedY = Math.cos(angle) * speed;
ins.life = life;
this.active[color].push(ins);
return ins;
},
returnInstance(ins) {
this._pool.push(ins);
},
};
function createParticleArc(start, arcLength, count, randomness, particleFactory) {
const angleDelta = arcLength / count;
const end = start + arcLength - angleDelta * 0.5;
if (end > start) {
for (let a = start; a < end; a = a + angleDelta)
particleFactory(a + Math.random() * angleDelta * randomness);
} else {
for (let a = start; a > end; a = a + angleDelta)
particleFactory(a + Math.random() * angleDelta * randomness);
}
}
function createBurst(
count,
particleFactory,
startAngle = 0,
arcLength = MyMath.twoPI,
) {
const R = 0.5 * Math.sqrt(count / Math.PI);
const C = 2 * R * Math.PI;
const C_HALF = C / 2;
for (let i = 0; i <= C_HALF; i++) {
const ringAngle = (i / C_HALF) * (Math.PI * 0.5);
const ringSize = Math.cos(ringAngle);
const partsPerFullRing = C * ringSize;
const partsPerArc = partsPerFullRing * (arcLength / MyMath.twoPI);
const angleInc = MyMath.twoPI / partsPerFullRing;
const angleOffset = Math.random() * angleInc + startAngle;
const maxRandomAngleOffset = angleInc * 0.33;
for (let j = 0; j < partsPerArc; j++) {
const randomAngleOffset = Math.random() * maxRandomAngleOffset;
const angle = angleInc * j + angleOffset + randomAngleOffset;
particleFactory(angle, ringSize);
}
}
}
function crackleEffect(star, quality = 2) {
const count = quality >= 3 ? 32 : 18;
createParticleArc(0, MyMath.twoPI, count, 1.8, (angle) => {
Spark.add(
star.x,
star.y,
COLOR.Gold,
angle,
Math.pow(Math.random(), 0.45) * 2.4,
300 + Math.random() * 220,
);
});
}
// ===== shell types =====
function crysanthemumShell(size = 1, quality = 2) {
const glitter = Math.random() < 0.25;
const singleColor = Math.random() < 0.72;
const color = singleColor
? randomColor({limitWhite: true})
: [randomColor({}), randomColor({notSame: true})];
const pistil = singleColor && Math.random() < 0.42;
const pistilColor = pistil && makePistilColor(color);
const secondColor =
singleColor && (Math.random() < 0.2 || color === COLOR.White)
? pistilColor || randomColor({notColor: color, limitWhite: true})
: null;
const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;
let starDensity = glitter ? 1.1 : 1.25;
if (quality >= 3) starDensity = 1.2;
return {
name: 'Crysanthemum',
shellSize: size,
spreadSize: 300 + size * 100,
starLife: 900 + size * 200,
starDensity,
color,
secondColor,
glitter: glitter ? 'light' : '',
glitterColor: whiteOrGold(),
pistil,
pistilColor,
streamers,
};
}
function willowShell(size = 1) {
return {
name: 'Willow',
shellSize: size,
spreadSize: 300 + size * 100,
starDensity: 0.6,
starLife: 3000 + size * 300,
glitter: 'willow',
glitterColor: COLOR.Gold,
color: INVISIBLE,
};
}
function palmShell(size = 1) {
const color = randomColor({});
const thick = Math.random() < 0.5;
return {
name: 'Palm',
shellSize: size,
color,
spreadSize: 250 + size * 75,
starDensity: thick ? 0.18 : 0.35,
starLife: 1800 + size * 200,
glitter: thick ? 'heavy' : 'medium',
glitterColor: whiteOrGold(),
};
}
function ringShell(size = 1) {
const color = randomColor({});
const pistil = Math.random() < 0.75;
return {
name: 'Ring',
shellSize: size,
ring: true,
color,
spreadSize: 300 + size * 100,
starLife: 900 + size * 200,
starCount: 2.2 * MyMath.twoPI * (size + 1),
pistil,
pistilColor: makePistilColor(color),
glitter: !pistil ? 'light' : '',
glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White,
streamers: Math.random() < 0.3,
};
}
function crackleShell(size = 1, quality = 2) {
const color = Math.random() < 0.75 ? COLOR.Gold : randomColor({});
return {
name: 'Crackle',
shellSize: size,
spreadSize: 380 + size * 75,
starDensity: quality >= 3 ? 1 : 0.75,
starLife: 650 + size * 110,
starLifeVariation: 0.32,
glitter: 'light',
glitterColor: COLOR.Gold,
color,
crackle: true,
};
}
function ghostShell(size = 1) {
const base = crysanthemumShell(size, 2);
const ghostColor = randomColor({notColor: COLOR.White});
base.name = 'Ghost';
base.starLife *= 1.5;
base.streamers = true;
base.color = INVISIBLE;
base.secondColor = ghostColor;
base.glitter = '';
return base;
}
class Shell {
constructor(options, env) {
Object.assign(this, options);
this.env = env;
this.starLifeVariation = options.starLifeVariation || 0.125;
this.color = options.color || randomColor({});
this.glitterColor = options.glitterColor || this.color;
if (!this.starCount) {
const density = options.starDensity || 1;
const scaledSize = this.spreadSize / 54;
this.starCount = Math.max(6, scaledSize * scaledSize * density);
}
}
launch(position, launchHeight) {
const {stageW, stageH} = this.env;
const hpad = 60,
vpad = 50;
const minHeightPercent = 0.45;
const minHeight = stageH - stageH * minHeightPercent;
const launchX = position * (stageW - hpad * 2) + hpad;
const launchY = stageH;
const burstY = minHeight - launchHeight * (minHeight - vpad);
const launchDistance = launchY - burstY;
const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);
const cometColor =
typeof this.color === 'string' &&
this.color !== 'random' &&
this.color !== INVISIBLE
? this.color
: COLOR.White;
const comet = (this.comet = Star.add(
launchX,
launchY,
cometColor,
Math.PI,
launchVelocity,
launchVelocity * 400,
));
comet.heavy = true;
comet.spinRadius = 0.5;
comet.sparkFreq = this.name === 'Willow' ? 10 : 16;
comet.sparkLife = this.name === 'Willow' ? 520 : 320;
comet.sparkLifeVariation = 3;
comet.sparkSpeed = this.name === 'Willow' ? 0.65 : 0.6;
comet.sparkColor = this.color === INVISIBLE ? COLOR.Gold : whiteOrGold();
comet.onDeath = () => this.burst(comet.x, comet.y);
}
burst(x, y) {
const env = this.env;
const quality = env.quality || 2;
const speed = this.spreadSize / 96;
let color = null;
let onDeath = null;
let sparkFreq, sparkSpeed, sparkLife;
let sparkLifeVariation = 0.25;
if (this.crackle) onDeath = (star) => crackleEffect(star, quality);
if (this.glitter === 'light') {
sparkFreq = 400;
sparkSpeed = 0.3;
sparkLife = 300;
sparkLifeVariation = 2;
} else if (this.glitter === 'medium') {
sparkFreq = 200;
sparkSpeed = 0.44;
sparkLife = 700;
sparkLifeVariation = 2;
} else if (this.glitter === 'heavy') {
sparkFreq = 80;
sparkSpeed = 0.8;
sparkLife = 1400;
sparkLifeVariation = 2;
} else if (this.glitter === 'streamer') {
sparkFreq = 32;
sparkSpeed = 1.05;
sparkLife = 620;
sparkLifeVariation = 2;
} else if (this.glitter === 'willow') {
sparkFreq = 120;
sparkSpeed = 0.34;
sparkLife = 1400;
sparkLifeVariation = 3.8;
}
if (sparkFreq) sparkFreq = sparkFreq / Math.max(1, quality);
const starFactory = (angle, speedMult) => {
const standardInitialSpeed = this.spreadSize / 1800;
const star = Star.add(
x,
y,
color || randomColor({}),
angle,
speedMult * speed,
this.starLife + Math.random() * this.starLife * this.starLifeVariation,
0,
-standardInitialSpeed,
);
if (this.secondColor) {
star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32);
star.secondColor = this.secondColor;
}
if (this.glitter && sparkFreq) {
star.sparkFreq = sparkFreq;
star.sparkSpeed = sparkSpeed;
star.sparkLife = sparkLife;
star.sparkLifeVariation = sparkLifeVariation;
star.sparkColor = this.glitterColor;
star.sparkTimer = Math.random() * star.sparkFreq;
}
star.onDeath = onDeath;
if (star.color === INVISIBLE) star.visible = false;
};
if (typeof this.color === 'string') {
color = this.color === 'random' ? null : this.color;
if (this.ring) {
const ringStartAngle = Math.random() * Math.PI;
const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;
const count = Math.max(28, this.starCount | 0);
for (let i = 0; i < count; i++) {
const a = (i / count) * MyMath.twoPI;
const initSpeedX = Math.sin(a) * speed * ringSquash;
const initSpeedY = Math.cos(a) * speed;
const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
const newAngle =
MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
const s = Star.add(
x,
y,
color,
newAngle,
newSpeed,
this.starLife +
Math.random() * this.starLife * this.starLifeVariation,
);
if (this.glitter && sparkFreq) {
s.sparkFreq = sparkFreq;
s.sparkSpeed = sparkSpeed;
s.sparkLife = sparkLife;
s.sparkLifeVariation = sparkLifeVariation;
s.sparkColor = this.glitterColor;
s.sparkTimer = Math.random() * s.sparkFreq;
}
if (s.color === INVISIBLE) s.visible = false;
s.onDeath = onDeath;
}
} else {
createBurst(this.starCount, starFactory);
}
}
// 文字烟花(由 env 实现)
if (
env.wordShell &&
!this.disableWord &&
Math.random() < env.wordProbability
) {
env.wx_createWordBurstAt(x, y);
}
if (this.pistil) {
const inner = new Shell(
{
spreadSize: this.spreadSize * 0.5,
starLife: this.starLife * 0.6,
starLifeVariation: this.starLifeVariation,
starDensity: 1.4,
color: this.pistilColor,
glitter: 'light',
disableWord: true,
glitterColor:
this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White,
},
env,
);
inner.burst(x, y);
}
if (this.streamers) {
const inner = new Shell(
{
spreadSize: this.spreadSize * 0.9,
starLife: this.starLife * 0.8,
starLifeVariation: this.starLifeVariation,
starCount: Math.floor(Math.max(6, this.spreadSize / 45)),
color: COLOR.White,
disableWord: true,
glitter: 'streamer',
},
env,
);
inner.burst(x, y);
}
BurstFlash.add(x, y, this.spreadSize / 4);
}
}
return {
MyMath,
COLOR,
COLOR_CODES,
COLOR_CODES_W_INVIS,
INVISIBLE,
GRAVITY,
Star,
Spark,
BurstFlash,
Shell,
randomColor,
crysanthemumShell,
willowShell,
palmShell,
ringShell,
crackleShell,
ghostShell,
};
})();
function debounce(fn, delay = 80) {
let t = null;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), delay);
};
}
export default {
props: {
startScrollTop: {
type: Number,
default: 0,
},
isPause: {
type: Boolean,
default: false,
},
},
data() {
return {
// 画布遮罩层透明度(0~1)
maskOpacity: 0.35,
// 是否允许产生新的烟花(含自动/触摸)
releaseEnabled: true,
_prevAutoLaunch: true,
// 公共状态
paused: false,
autoLaunch: true,
wordShell: true,
releaseEnabled: true,
finale: false,
wordSettleMs: 900,
wordProbability: 0.18,
randomWords: ['前程似锦', '万事胜意', '日进斗金', '势不可挡'],
quality: 2,
// renderjs cmd
cmd: null,
// 微信端 canvas
wx_trailsCanvas: null,
wx_trailsCtx: null,
wx_mainCanvas: null,
wx_mainCtx: null,
stageW: 0,
stageH: 0,
dpr: 1,
// 微信端仿真
simSpeed: 1,
isUpdatingSpeed: false,
speedBarOpacity: 0,
_timer: null,
_lastTs: 0,
_frameId: 0,
// 自动序列
autoLaunchTime: 260,
finaleCount: 32,
currentFinaleCount: 0,
// touch launch
_pendingLaunch: null,
// offscreen (weixin)
_offscreen: null,
_offscreenCtx: null,
};
},
watch: {
isPause: {
handler(newVal, oldVal) {
if (newVal) {
this.stopRelease();
this.clearAll();
} else {
this.resumeRelease();
}
},
},
},
onReady() {
// #ifdef MP-WEIXIN
this.wx_init();
// #endif
},
onShow() {
// #ifdef MP-WEIXIN
if (this.wx_trailsCtx && this.wx_mainCtx && !this._timer) this.wx_startLoop();
// #endif
// #ifdef APP-VUE || H5
this.sendCmd('resume');
// #endif
},
onHide() {
// #ifdef MP-WEIXIN
this.wx_stopLoop();
// #endif
// #ifdef APP-VUE || H5
this.sendCmd('pauseRender');
// #endif
},
onUnload() {
// #ifdef MP-WEIXIN
this.wx_stopLoop();
this.wx_resetParticles();
// #endif
// #ifdef APP-VUE || H5
this.sendCmd('destroy');
// #endif
},
created() {
this.onTouchEnd = debounce(this._onTouchEnd, 50);
},
methods: {
async _onTouchEnd(e) {
this.$emit('onScroll', {scrollTop: this.startScrollTop});
},
// =========================
// App-vue/H5:renderjs 握手回调(renderjs ready -> init)[2](https://juejin.cn/post/7463077167405350966)[4](https://www.cnblogs.com/fqs123456/p/16623389.html)
// =========================
onRenderReady(info) {
this.sendCmd('init', {
w: info && info.w ? info.w : uni.getSystemInfoSync().windowWidth,
h: info && info.h ? info.h : uni.getSystemInfoSync().windowHeight,
dpr: info && info.dpr ? info.dpr : uni.getSystemInfoSync().pixelRatio || 1,
config: this.getConfig(),
});
},
getConfig() {
return {
paused: this.paused,
autoLaunch: this.autoLaunch,
wordShell: this.wordShell,
finale: this.finale,
releaseEnabled: this.releaseEnabled,
maskOpacity: this.maskOpacity,
wordSettleMs: this.wordSettleMs,
wordProbability: this.wordProbability,
randomWords: this.randomWords,
quality: this.quality,
};
},
sendCmd(op, payload = {}) {
this.cmd = {id: Date.now() + Math.random(), op, payload};
},
togglePause() {
this.paused = !this.paused;
// #ifdef MP-WEIXIN
// 微信端直接影响 loop
// #endif
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
toggleAuto() {
this.autoLaunch = !this.autoLaunch;
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
toggleWords() {
this.wordShell = !this.wordShell;
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
toggleFinale() {
this.finale = !this.finale;
this.currentFinaleCount = 0;
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
// 停止/继续产生新的烟花(含自动与触摸)
toggleRelease() {
if (this.releaseEnabled) this.stopRelease();
else this.resumeRelease();
},
stopRelease() {
this.releaseEnabled = false;
this._prevAutoLaunch = this.autoLaunch;
this.autoLaunch = false;
this.finale = false;
this.currentFinaleCount = 0;
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
resumeRelease() {
this.releaseEnabled = true;
this.autoLaunch =
this._prevAutoLaunch !== undefined ? this._prevAutoLaunch : true;
// #ifdef APP-VUE || H5
this.sendCmd('setConfig', {config: this.getConfig()});
// #endif
},
clearAll() {
// #ifdef MP-WEIXIN
this.wx_resetParticles();
this.wx_trailsCtx &&
this.wx_trailsCtx.clearRect(0, 0, this.stageW, this.stageH);
this.wx_mainCtx && this.wx_mainCtx.clearRect(0, 0, this.stageW, this.stageH);
// #endif
// #ifdef APP-VUE || H5
this.sendCmd('clear');
// #endif
},
// =========================
// 微信端初始化
// =========================
async wx_init() {
const sys = uni.getSystemInfoSync();
this.stageW = sys.windowWidth;
this.stageH = sys.windowHeight;
this.dpr = Math.max(1, sys.pixelRatio || 1);
const trails = await this.wx_getCanvasNode('#trails');
const main = await this.wx_getCanvasNode('#main');
this.wx_trailsCanvas = trails.node;
this.wx_trailsCtx = trails.ctx;
this.wx_mainCanvas = main.node;
this.wx_mainCtx = main.ctx;
this.wx_resizeCanvas(
this.wx_trailsCanvas,
this.wx_trailsCtx,
this.stageW,
this.stageH,
);
this.wx_resizeCanvas(
this.wx_mainCanvas,
this.wx_mainCtx,
this.stageW,
this.stageH,
);
this.wx_initOffscreen();
this.wx_startLoop();
},
wx_initOffscreen() {
try {
if (typeof uni !== 'undefined' && uni.createOffscreenCanvas) {
const off = uni.createOffscreenCanvas({
type: '2d',
width: 32,
height: 32,
});
this._offscreen = off;
this._offscreenCtx = off.getContext('2d');
}
} catch (e) {
this._offscreen = null;
this._offscreenCtx = null;
}
},
wx_getCanvasNode(selector) {
return new Promise((resolve) => {
uni.createSelectorQuery()
.in(this)
.select(selector)
.fields({node: true, size: true})
.exec((res) => {
const canvas = res && res[0] && res[0].node;
if (!canvas) return resolve({node: null, ctx: null});
resolve({node: canvas, ctx: canvas.getContext('2d')});
});
});
},
wx_resizeCanvas(canvas, ctx, w, h) {
if (!canvas || !ctx) return;
const dpr = this.dpr;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
},
// =========================
// 微信端触摸
// =========================
wx_touchPos(e) {
const t =
(e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0]);
if (!t) return null;
return {x: t.x, y: t.y};
},
wx_onTouchStart(e) {
const pos = this.wx_touchPos(e);
if (!pos) return;
if (pos.y >= this.stageH - 4) {
this.isUpdatingSpeed = true;
this.wx_updateSpeedFromX(pos.x);
return;
}
if (this.releaseEnabled) {
this._pendingLaunch = {x: pos.x, y: pos.y};
}
},
wx_onTouchMove(e) {
if (!this.isUpdatingSpeed) return;
const pos = this.wx_touchPos(e);
if (!pos) return;
this.wx_updateSpeedFromX(pos.x);
},
wx_onTouchEnd() {
this.isUpdatingSpeed = false;
console.log('结束', this.isShowFirwork);
},
wx_updateSpeedFromX(x) {
const edge = 16;
const newSpeed = (x - edge) / (this.stageW - edge * 2);
this.simSpeed = WX_ENGINE.MyMath.clamp(newSpeed, 0, 1);
this.speedBarOpacity = 1;
},
// =========================
// 微信端 loop(setTimeout)
// =========================
wx_startLoop() {
this.wx_stopLoop();
this._lastTs = Date.now();
const tick = () => {
const now = Date.now();
let frameTime = now - this._lastTs;
this._lastTs = now;
// cap
if (frameTime < 0) frameTime = 17;
if (frameTime > 68) frameTime = 68;
if (!this.paused) this.wx_update(frameTime);
this._timer = setTimeout(tick, 16);
};
tick();
},
wx_stopLoop() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
},
// =========================
// 微信端序列
// =========================
wx_startSequence() {
if (this.finale) {
this.wx_seqFinaleFast();
if (this.currentFinaleCount < this.finaleCount) {
this.currentFinaleCount++;
return 170;
} else {
this.currentFinaleCount = 0;
return 4200;
}
}
const r = Math.random();
if (r < 0.5) return this.wx_seqRandomShell();
if (r < 0.78) return this.wx_seqTwoShell();
return this.wx_seqTriple();
},
wx_seqRandomShell() {
const x = 0.18 + Math.random() * 0.64;
const h = 0.15 + Math.random() * 0.55;
const shell = new WX_ENGINE.Shell(this.wx_randomShellConfig(), this);
shell.launch(x, h);
return 780 + Math.random() * 640 + shell.starLife;
},
wx_seqTwoShell() {
const shell1 = new WX_ENGINE.Shell(this.wx_randomShellConfig(), this);
const shell2 = new WX_ENGINE.Shell(this.wx_randomShellConfig(), this);
const left = 0.3 + (Math.random() * 0.2 - 0.1);
const right = 0.7 + (Math.random() * 0.2 - 0.1);
const h1 = 0.2 + Math.random() * 0.5;
const h2 = 0.2 + Math.random() * 0.5;
shell1.launch(left, h1);
setTimeout(() => shell2.launch(right, h2), 100);
return 900 + Math.random() * 600 + Math.max(shell1.starLife, shell2.starLife);
},
wx_seqTriple() {
const shellType = this.wx_randomFastShellFactory();
const baseSize = 2;
const offset = Math.random() * 0.08 - 0.04;
new WX_ENGINE.Shell(shellType(baseSize), this).launch(0.5 + offset, 0.7);
const leftDelay = 900 + Math.random() * 420;
const rightDelay = 900 + Math.random() * 420;
setTimeout(
() =>
new WX_ENGINE.Shell(
shellType(Math.max(0, baseSize - 1.25)),
this,
).launch(0.2 + offset, 0.18),
leftDelay,
);
setTimeout(
() =>
new WX_ENGINE.Shell(
shellType(Math.max(0, baseSize - 1.25)),
this,
).launch(0.8 + offset, 0.18),
rightDelay,
);
return 3600;
},
wx_seqFinaleFast() {
const shellType = this.wx_randomFastShellFactory();
new WX_ENGINE.Shell(shellType(2), this).launch(
0.25 + Math.random() * 0.1,
0.3 + Math.random() * 0.35,
);
new WX_ENGINE.Shell(shellType(2), this).launch(
0.65 + Math.random() * 0.1,
0.3 + Math.random() * 0.35,
);
},
wx_randomShellConfig() {
const size = 2;
const r = Math.random();
if (r < 0.45) return WX_ENGINE.crysanthemumShell(size, this.quality);
if (r < 0.62) return WX_ENGINE.willowShell(size);
if (r < 0.78) return WX_ENGINE.palmShell(size);
if (r < 0.9) return WX_ENGINE.ringShell(size);
return WX_ENGINE.crackleShell(size, this.quality);
},
// ✅ 修复:随机快速壳体 factory(避免 listsize 这种拼写错误)
wx_randomFastShellFactory() {
const list = [
(s) => WX_ENGINE.crysanthemumShell(s, this.quality),
(s) => WX_ENGINE.palmShell(s),
(s) => WX_ENGINE.ringShell(s),
(s) => WX_ENGINE.crackleShell(s, this.quality),
(s) => WX_ENGINE.ghostShell(s),
];
return (size) => {
const fn = list[(Math.random() * list.length) | 0];
return fn(size);
};
},
// =========================
// 微信端文字烟花:离屏点阵
// =========================
wx_randomWord() {
const arr = this.randomWords || [];
if (!arr.length) return '';
return arr[(Math.random() * arr.length) | 0];
},
wx_literalLattice(
text,
density = 3,
fontFamily = 'sans-serif',
fontSizePx = 90,
) {
if (!this._offscreen || !this._offscreenCtx) return null;
const key = `${text}|${density}|${fontFamily}|${fontSizePx}`;
const cached = WX_WORD_CACHE.get(key);
if (cached) return cached;
const ctx = this._offscreenCtx;
const pad = 20;
ctx.font = `${fontSizePx}px ${fontFamily}`;
const w = Math.ceil(ctx.measureText(text).width) + pad;
const h = Math.ceil(fontSizePx) + pad;
this._offscreen.width = w;
this._offscreen.height = h;
ctx.clearRect(0, 0, w, h);
ctx.font = `${fontSizePx}px ${fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillStyle = '#fff';
ctx.fillText(text, pad / 2, pad / 2);
const img = ctx.getImageData(0, 0, w, h);
const points = [];
for (let yy = 0; yy < h; yy += density) {
for (let xx = 0; xx < w; xx += density) {
const i = (yy * w + xx) * 4;
if (img.data[i + 3] > 0) points.push({x: xx, y: yy});
}
}
const res = {width: w, height: h, points};
WX_WORD_CACHE.set(key, res);
return res;
},
wx_createWordBurstAt(cx, cy) {
if (!this.wordShell) return;
const word = this.wx_randomWord();
if (!word) return;
if (!this._offscreen || !this._offscreenCtx) {
this.wx_initOffscreen();
if (!this._offscreen || !this._offscreenCtx) return;
}
const fontSize = Math.floor(Math.random() * 40 + 76);
const density = 3;
const fontFamily = 'sans-serif';
const map = this.wx_literalLattice(word, density, fontFamily, fontSize);
if (!map || !map.points || !map.points.length) return;
const dcenterX = map.width / 2;
const dcenterY = map.height / 2;
const scale = Math.min(1.15, Math.max(0.65, this.stageW / 750));
const color = WX_ENGINE.randomColor({});
const settleMs = this.wordSettleMs || 900;
const baseLife = 2000;
const lifeJitter = 250;
const baseSpeed = 0.55;
const speedJitter = 0.35;
const tailStep = 6;
for (let i = 0; i < map.points.length; i++) {
const p = map.points[i];
const x = cx + (p.x - dcenterX) * scale;
const y = cy + (p.y - dcenterY) * scale;
const ang = Math.random() * Math.PI * 2;
const dx = p.x - dcenterX;
const dy = p.y - dcenterY;
const dist = Math.sqrt(dx * dx + dy * dy);
const distNorm = Math.min(1, dist / Math.max(map.width, map.height));
const spd =
(baseSpeed + Math.random() * speedJitter) * (0.35 + 0.85 * distNorm);
const life = baseLife + (Math.random() * 2 - 1) * lifeJitter;
const s = WX_ENGINE.Star.add(x, y, color, ang, spd, life, 0, 0, 2);
s.isWord = true;
s.settle = settleMs;
s.size = 2.2;
if (i % tailStep === 0) {
s.sparkFreq = 130;
s.sparkSpeed = 0.22;
s.sparkLife = 520;
s.sparkLifeVariation = 1.5;
s.sparkColor = color;
s.sparkTimer = Math.random() * s.sparkFreq;
}
}
},
// =========================
// 微信端 update/render/reset
// =========================
wx_update(frameTime) {
// touch launch
if (this._pendingLaunch && this.releaseEnabled) {
const {x, y} = this._pendingLaunch;
this._pendingLaunch = null;
const px = x / this.stageW;
const ph = 1 - y / this.stageH;
new WX_ENGINE.Shell(this.wx_randomShellConfig(), this).launch(px, ph);
}
// auto
if (this.autoLaunch) {
this.autoLaunchTime -= frameTime * this.simSpeed;
if (this.autoLaunchTime <= 0) {
this.autoLaunchTime = this.wx_startSequence() * 0.95;
}
}
this._frameId++;
const timeStep = frameTime * this.simSpeed;
const lag = frameTime / 16.6667;
const speed = this.simSpeed * lag;
// speed bar fade
if (!this.isUpdatingSpeed) {
this.speedBarOpacity -= speed / 30;
if (this.speedBarOpacity < 0) this.speedBarOpacity = 0;
}
const Star = WX_ENGINE.Star;
const Spark = WX_ENGINE.Spark;
const starDrag = 1 - (1 - Star.airDrag) * speed;
const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
const gAcc = (timeStep / 1000) * WX_ENGINE.GRAVITY;
WX_ENGINE.COLOR_CODES_W_INVIS.forEach((color) => {
// stars
const stars = Star.active[color];
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i];
if (s.updateFrame === this._frameId) continue;
s.updateFrame = this._frameId;
s.life -= timeStep;
if (s.life <= 0) {
stars.splice(i, 1);
Star.returnInstance(s);
continue;
}
const burnRate = Math.pow(s.life / s.fullLife, 0.5);
const burnRateInv = 1 - burnRate;
s.prevX = s.x;
s.prevY = s.y;
s.x += s.speedX * speed;
s.y += s.speedY * speed;
if (!s.heavy) {
s.speedX *= starDrag;
s.speedY *= starDrag;
} else {
s.speedX *= starDragHeavy;
s.speedY *= starDragHeavy;
}
// ✅ 文字 settle:前 N ms 更稳
if (s.isWord && s.settle > 0) {
s.settle -= timeStep;
s.speedX *= 0.82;
s.speedY *= 0.82;
s.speedY += gAcc * 0.35;
} else {
s.speedY += gAcc;
}
if (s.spinRadius) {
s.spinAngle += s.spinSpeed * speed;
s.x += Math.sin(s.spinAngle) * s.spinRadius * speed;
s.y += Math.cos(s.spinAngle) * s.spinRadius * speed;
}
if (s.sparkFreq) {
s.sparkTimer -= timeStep;
while (s.sparkTimer < 0) {
s.sparkTimer +=
s.sparkFreq * 0.75 + s.sparkFreq * burnRateInv * 4;
Spark.add(
s.x,
s.y,
s.sparkColor,
Math.random() * WX_ENGINE.MyMath.twoPI,
Math.random() * s.sparkSpeed * burnRate,
s.sparkLife * 0.8 +
Math.random() * s.sparkLifeVariation * s.sparkLife,
);
}
}
}
// sparks
const sparks = Spark.active[color];
for (let i = sparks.length - 1; i >= 0; i--) {
const sp = sparks[i];
sp.life -= timeStep;
if (sp.life <= 0) {
sparks.splice(i, 1);
Spark.returnInstance(sp);
continue;
}
sp.prevX = sp.x;
sp.prevY = sp.y;
sp.x += sp.speedX * speed;
sp.y += sp.speedY * speed;
sp.speedX *= sparkDrag;
sp.speedY *= sparkDrag;
sp.speedY += gAcc;
}
});
this.wx_render(speed);
},
wx_render(speed) {
if (!this.wx_trailsCtx || !this.wx_mainCtx) return;
const w = this.stageW;
const h = this.stageH;
const trailsCtx = this.wx_trailsCtx;
const mainCtx = this.wx_mainCtx;
// 使用 destination-out 擦除实现“透明背景的拖影淡出”,避免把画布刷成纯黑
trailsCtx.globalCompositeOperation = 'destination-out';
trailsCtx.fillStyle = `rgba(0,0,0,${0.22 * speed})`;
trailsCtx.fillRect(0, 0, w, h);
trailsCtx.globalCompositeOperation = 'source-over';
mainCtx.clearRect(0, 0, w, h);
while (WX_ENGINE.BurstFlash.active.length) {
const bf = WX_ENGINE.BurstFlash.active.pop();
const g = trailsCtx.createRadialGradient(
bf.x,
bf.y,
0,
bf.x,
bf.y,
bf.radius,
);
g.addColorStop(0.024, 'rgba(255,255,255,1)');
g.addColorStop(0.125, 'rgba(255,160,20,0.2)');
g.addColorStop(0.32, 'rgba(255,140,20,0.11)');
g.addColorStop(1, 'rgba(255,120,20,0)');
trailsCtx.fillStyle = g;
trailsCtx.fillRect(
bf.x - bf.radius,
bf.y - bf.radius,
bf.radius * 2,
bf.radius * 2,
);
WX_ENGINE.BurstFlash.returnInstance(bf);
}
trailsCtx.globalCompositeOperation = 'lighten';
trailsCtx.lineCap = 'round';
mainCtx.strokeStyle = '#fff';
mainCtx.lineWidth = 1;
mainCtx.beginPath();
WX_ENGINE.COLOR_CODES.forEach((color) => {
const stars = WX_ENGINE.Star.active[color];
if (!stars.length) return;
trailsCtx.strokeStyle = color;
trailsCtx.beginPath();
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
if (!s.visible) continue;
trailsCtx.lineWidth = s.size;
trailsCtx.moveTo(s.x, s.y);
trailsCtx.lineTo(s.prevX, s.prevY);
mainCtx.moveTo(s.x, s.y);
mainCtx.lineTo(s.x - s.speedX * 1.6, s.y - s.speedY * 1.6);
}
trailsCtx.stroke();
});
mainCtx.stroke();
trailsCtx.lineWidth = WX_ENGINE.Spark.drawWidth;
trailsCtx.lineCap = 'butt';
WX_ENGINE.COLOR_CODES.forEach((color) => {
const sparks = WX_ENGINE.Spark.active[color];
if (!sparks.length) return;
trailsCtx.strokeStyle = color;
trailsCtx.beginPath();
for (let i = 0; i < sparks.length; i++) {
const sp = sparks[i];
trailsCtx.moveTo(sp.x, sp.y);
trailsCtx.lineTo(sp.prevX, sp.prevY);
}
trailsCtx.stroke();
});
if (this.speedBarOpacity) {
const barH = 6;
mainCtx.globalAlpha = this.speedBarOpacity;
mainCtx.fillStyle = WX_ENGINE.COLOR.Blue;
mainCtx.fillRect(0, h - barH, w * this.simSpeed, barH);
mainCtx.globalAlpha = 1;
}
},
wx_resetParticles() {
WX_ENGINE.COLOR_CODES_W_INVIS.forEach((c) => {
WX_ENGINE.Star.active[c].length = 0;
WX_ENGINE.Spark.active[c].length = 0;
});
WX_ENGINE.BurstFlash.active.length = 0;
},
},
};
</script>
<!-- =========================
renderjs:App-vue/H5 端引擎(视图层)
- renderjs 仅支持 App-vue/H5[2](https://juejin.cn/post/7463077167405350966)[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt)
========================= -->
<!-- #ifdef APP-VUE || H5 -->
<script module="fwRender" lang="renderjs">
/* eslint-disable */
const DOC = document;
// ✅ App-vue 画布可能是包装节点,必须取内部真正的 canvas 才有 getContext[1](https://lightoffleet-my.sharepoint.com/personal/weikiyue_lightoffleet_onmicrosoft_com/Documents/Microsoft%20Copilot%20Chat%20%E6%96%87%E4%BB%B6/MyMath.txt)[2](https://juejin.cn/post/7463077167405350966)
function getRealCanvas(el) {
if (!el) return null;
if (typeof el.getContext === "function") return el;
const inner = el.querySelector && el.querySelector("canvas");
if (inner && typeof inner.getContext === "function") return inner;
return null;
}
function createOffscreen2D() {
if (typeof OffscreenCanvas !== "undefined") {
const c = new OffscreenCanvas(32, 32);
return { canvas: c, ctx: c.getContext("2d") };
}
const c = DOC.createElement("canvas");
c.width = 32; c.height = 32;
return { canvas: c, ctx: c.getContext("2d") };
}
const WORD_CACHE = new Map();
// ====== 视图层引擎(与小程序端结构一致,省略注释)======
const MyMath = { halfPI: Math.PI/2, twoPI: Math.PI*2,
pointDist(x1,y1,x2,y2){const dx=x2-x1,dy=y2-y1;return Math.sqrt(dx*dx+dy*dy)},
pointAngle(x1,y1,x2,y2){return MyMath.halfPI+Math.atan2(y2-y1,x2-x1)},
clamp(n,min,max){return Math.min(Math.max(n,min),max)}
};
const COLOR = {
Red: '#fc6b93',
Red1: '#f42927',
Gold: '#ffe629',
Green: '#00c2ff',
Blue: '#04c2ff',
Purple: '#ff8749',
Purple1: '#fca824',
Purple2: '#f95d22',
White: '#c9c0fc',
};
const COLOR_CODES = Object.keys(COLOR).map(k=>COLOR[k]);
const INVISIBLE = "_INVISIBLE_";
const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
const GRAVITY = 0.9;
function createParticleCollection(){const m={}; COLOR_CODES_W_INVIS.forEach(c=>m[c]=[]); return m;}
let _lastColor=null;
function randomColor(o={}) {
const {notSame=false,notColor=null,limitWhite=false}=o;
let c=COLOR_CODES[(Math.random()*COLOR_CODES.length)|0];
if(limitWhite&&c===COLOR.White&&Math.random()<0.6) c=COLOR_CODES[(Math.random()*COLOR_CODES.length)|0];
if(notSame){while(c===_lastColor) c=COLOR_CODES[(Math.random()*COLOR_CODES.length)|0];}
else if(notColor){while(c===notColor) c=COLOR_CODES[(Math.random()*COLOR_CODES.length)|0];}
_lastColor=c; return c;
}
const whiteOrGold=()=>Math.random()<0.5?COLOR.Gold:COLOR.White;
const makePistilColor=(c)=> (c===COLOR.White||c===COLOR.Gold)?randomColor({notColor:c}):whiteOrGold();
const BurstFlash={active:[],_pool:[],_new(){return{}},
add(x,y,r){const ins=this._pool.pop()||this._new(); ins.x=x;ins.y=y;ins.radius=r; this.active.push(ins); return ins;},
returnInstance(ins){this._pool.push(ins);}
};
const Star={airDrag:0.98,airDragHeavy:0.992,active:createParticleCollection(),_pool:[],_new(){return{}},
add(x,y,color,angle,speed,life,sx=0,sy=0,size=3){
const ins=this._pool.pop()||this._new();
ins.visible=true; ins.heavy=false;
ins.x=x; ins.y=y; ins.prevX=x; ins.prevY=y; ins.color=color;
ins.speedX=Math.sin(angle)*speed+sx; ins.speedY=Math.cos(angle)*speed+sy;
ins.life=life; ins.fullLife=life; ins.size=size;
ins.spinAngle=Math.random()*MyMath.twoPI; ins.spinSpeed=0.8; ins.spinRadius=0;
ins.sparkFreq=0; ins.sparkSpeed=1; ins.sparkTimer=0; ins.sparkColor=color; ins.sparkLife=750; ins.sparkLifeVariation=0.25;
ins.isWord=false; ins.settle=0;
ins.onDeath=null; ins.updateFrame=0;
this.active[color].push(ins); return ins;
},
returnInstance(ins){
ins.onDeath&&ins.onDeath(ins);
ins.onDeath=null; ins.isWord=false; ins.settle=0;
this._pool.push(ins);
}
};
const Spark={drawWidth:1,airDrag:0.9,active:createParticleCollection(),_pool:[],_new(){return{}},
add(x,y,color,angle,speed,life){
const ins=this._pool.pop()||this._new();
ins.x=x;ins.y=y;ins.prevX=x;ins.prevY=y;ins.color=color;
ins.speedX=Math.sin(angle)*speed; ins.speedY=Math.cos(angle)*speed;
ins.life=life; this.active[color].push(ins); return ins;
},
returnInstance(ins){this._pool.push(ins);}
};
function createParticleArc(start,arcLength,count,randomness,fn){
const d=arcLength/count, end=start+arcLength-d*0.5;
if(end>start){for(let a=start;a<end;a=a+d) fn(a+Math.random()*d*randomness);}
else{for(let a=start;a>end;a=a+d) fn(a+Math.random()*d*randomness);}
}
function createBurst(count, fn, startAngle=0, arcLength=MyMath.twoPI){
const R=0.5*Math.sqrt(count/Math.PI), C=2*R*Math.PI, C_HALF=C/2;
for(let i=0;i<=C_HALF;i++){
const ringAngle=(i/C_HALF)*(Math.PI*0.5);
const ringSize=Math.cos(ringAngle);
const partsPerFullRing=C*ringSize;
const partsPerArc=partsPerFullRing*(arcLength/MyMath.twoPI);
const angleInc=MyMath.twoPI/partsPerFullRing;
const angleOffset=Math.random()*angleInc+startAngle;
const maxRand=angleInc*0.33;
for(let j=0;j<partsPerArc;j++){
const angle=angleInc*j+angleOffset+Math.random()*maxRand;
fn(angle, ringSize);
}
}
}
function crackleEffect(star,quality=2){
const count=quality>=3?32:18;
createParticleArc(0,MyMath.twoPI,count,1.8,(a)=>{
Spark.add(star.x,star.y,COLOR.Gold,a,Math.pow(Math.random(),0.45)*2.4,300+Math.random()*220);
});
}
// shells
function crysanthemumShell(size=1,quality=2){
const glitter=Math.random()<0.25;
const single=Math.random()<0.72;
const color=single?randomColor({limitWhite:true}):[randomColor({}),randomColor({notSame:true})];
const pistil=single&&Math.random()<0.42;
const pistilColor=pistil&&makePistilColor(color);
const secondColor=single&&(Math.random()<0.2||color===COLOR.White)?(pistilColor||randomColor({notColor:color,limitWhite:true})):null;
const streamers=!pistil&&color!==COLOR.White&&Math.random()<0.42;
let starDensity=glitter?1.1:1.25; if(quality>=3) starDensity=1.2;
return {name:"Crysanthemum",shellSize:size,spreadSize:300+size*100,starLife:900+size*200,starDensity,color,secondColor,
glitter:glitter?"light":"",glitterColor:whiteOrGold(),pistil,pistilColor,streamers};
}
function willowShell(size=1){
return {name:"Willow",shellSize:size,spreadSize:300+size*100,starDensity:0.6,starLife:3000+size*300,glitter:"willow",glitterColor:COLOR.Gold,color:INVISIBLE};
}
function palmShell(size=1){
const color=randomColor({}); const thick=Math.random()<0.5;
return {name:"Palm",shellSize:size,color,spreadSize:250+size*75,starDensity:thick?0.18:0.35,starLife:1800+size*200,glitter:thick?"heavy":"medium",glitterColor:whiteOrGold()};
}
function ringShell(size=1){
const color=randomColor({}); const pistil=Math.random()<0.75;
return {name:"Ring",shellSize:size,ring:true,color,spreadSize:300+size*100,starLife:900+size*200,starCount:2.2*MyMath.twoPI*(size+1),
pistil,pistilColor:makePistilColor(color),glitter:!pistil?"light":"",glitterColor:color===COLOR.Gold?COLOR.Gold:COLOR.White,streamers:Math.random()<0.3};
}
function crackleShell(size=1,quality=2){
const color=Math.random()<0.75?COLOR.Gold:randomColor({});
return {name:"Crackle",shellSize:size,spreadSize:380+size*75,starDensity:quality>=3?1:0.75,starLife:650+size*110,starLifeVariation:0.32,
glitter:"light",glitterColor:COLOR.Gold,color,crackle:true};
}
function ghostShell(size=1){
const base=crysanthemumShell(size,2);
const ghostColor=randomColor({notColor:COLOR.White});
base.name="Ghost"; base.starLife*=1.5; base.streamers=true; base.color=INVISIBLE; base.secondColor=ghostColor; base.glitter="";
return base;
}
class Shell {
constructor(opt, env){
Object.assign(this,opt); this.env=env;
this.starLifeVariation=opt.starLifeVariation||0.125;
this.color=opt.color||randomColor({});
this.glitterColor=opt.glitterColor||this.color;
if(!this.starCount){
const density=opt.starDensity||1;
const scaled=this.spreadSize/54;
this.starCount=Math.max(6,scaled*scaled*density);
}
}
launch(position,launchHeight){
const {stageW,stageH}=this.env;
const hpad=60,vpad=50,minHeightPercent=0.45;
const minHeight=stageH-stageH*minHeightPercent;
const launchX=position*(stageW-hpad*2)+hpad;
const launchY=stageH;
const burstY=minHeight-launchHeight*(minHeight-vpad);
const launchDistance=launchY-burstY;
const launchVelocity=Math.pow(launchDistance*0.04,0.64);
const cometColor=(typeof this.color==="string" && this.color!=="random" && this.color!==INVISIBLE)?this.color:COLOR.White;
const comet=this.comet=Star.add(launchX,launchY,cometColor,Math.PI,launchVelocity,launchVelocity*400);
comet.heavy=true;
comet.spinRadius=0.5;
comet.sparkFreq=this.name==="Willow"?10:16;
comet.sparkLife=this.name==="Willow"?520:320;
comet.sparkLifeVariation=3;
comet.sparkSpeed=this.name==="Willow"?0.65:0.6;
comet.sparkColor=this.color===INVISIBLE?COLOR.Gold:whiteOrGold();
comet.onDeath=()=>this.burst(comet.x,comet.y);
}
burst(x,y){
const env=this.env, quality=env.quality||2;
const speed=this.spreadSize/96;
let color=null, onDeath=null;
let sparkFreq, sparkSpeed, sparkLife, sparkLifeVariation=0.25;
if(this.crackle) onDeath=(s)=>crackleEffect(s,quality);
if(this.glitter==="light"){sparkFreq=400;sparkSpeed=0.3;sparkLife=300;sparkLifeVariation=2;}
else if(this.glitter==="medium"){sparkFreq=200;sparkSpeed=0.44;sparkLife=700;sparkLifeVariation=2;}
else if(this.glitter==="heavy"){sparkFreq=80;sparkSpeed=0.8;sparkLife=1400;sparkLifeVariation=2;}
else if(this.glitter==="streamer"){sparkFreq=32;sparkSpeed=1.05;sparkLife=620;sparkLifeVariation=2;}
else if(this.glitter==="willow"){sparkFreq=120;sparkSpeed=0.34;sparkLife=1400;sparkLifeVariation=3.8;}
if(sparkFreq) sparkFreq=sparkFreq/Math.max(1,quality);
const starFactory=(angle,speedMult)=>{
const standardInitialSpeed=this.spreadSize/1800;
const star=Star.add(x,y,color||randomColor({}),angle,speedMult*speed,this.starLife+Math.random()*this.starLife*this.starLifeVariation,0,-standardInitialSpeed);
if(this.glitter&&sparkFreq){
star.sparkFreq=sparkFreq; star.sparkSpeed=sparkSpeed; star.sparkLife=sparkLife; star.sparkLifeVariation=sparkLifeVariation;
star.sparkColor=this.glitterColor; star.sparkTimer=Math.random()*star.sparkFreq;
}
star.onDeath=onDeath;
if(star.color===INVISIBLE) star.visible=false;
};
if(typeof this.color==="string"){
color=this.color==="random"?null:this.color;
if(this.ring){
const ringStart=Math.random()*Math.PI;
const squash=Math.pow(Math.random(),2)*0.85+0.15;
const count=Math.max(28,(this.starCount|0));
for(let i=0;i<count;i++){
const a=(i/count)*MyMath.twoPI;
const initX=Math.sin(a)*speed*squash;
const initY=Math.cos(a)*speed;
const newSpeed=MyMath.pointDist(0,0,initX,initY);
const newAngle=MyMath.pointAngle(0,0,initX,initY)+ringStart;
const s=Star.add(x,y,color,newAngle,newSpeed,this.starLife+Math.random()*this.starLife*this.starLifeVariation);
if(this.glitter&&sparkFreq){
s.sparkFreq=sparkFreq; s.sparkSpeed=sparkSpeed; s.sparkLife=sparkLife; s.sparkLifeVariation=sparkLifeVariation;
s.sparkColor=this.glitterColor; s.sparkTimer=Math.random()*s.sparkFreq;
}
if(s.color===INVISIBLE) s.visible=false;
s.onDeath=onDeath;
}
} else {
createBurst(this.starCount, starFactory);
}
}
if(env.wordShell && !this.disableWord && Math.random()<env.wordProbability){
env.createWordBurstAt(x,y);
}
BurstFlash.add(x,y,this.spreadSize/4);
}
}
// ===== renderjs 主模块 =====
export default {
data() {
return {
wrapEl: null,
trailsWrap: null,
mainWrap: null,
trails: null,
main: null,
trailsCtx: null,
mainCtx: null,
stageW: 0,
stageH: 0,
dpr: 1,
paused: false,
autoLaunch: true,
wordShell: true,
releaseEnabled: true,
finale: false,
wordSettleMs: 900,
wordProbability: 0.18,
randomWords: ['前程似锦', '万事胜意', '日进斗金', '势不可挡'],
quality: 2,
simSpeed: 1,
isUpdatingSpeed: false,
speedBarOpacity: 0,
running: false,
lastTs: 0,
frameId: 0,
autoLaunchTime: 260,
finaleCount: 32,
currentFinaleCount: 0,
off: null,
offCtx: null
};
},
mounted() {
const off = createOffscreen2D();
this.off = off.canvas;
this.offCtx = off.ctx;
this.bindCanvasAndEvents();
// 握手:通知逻辑层 renderjs ready -> 逻辑层再 init(避免 init 丢失黑屏)[2](https://juejin.cn/post/7463077167405350966)[4](https://www.cnblogs.com/fqs123456/p/16623389.html)
try {
this.$ownerInstance.callMethod("onRenderReady", {
w: this.stageW || window.innerWidth,
h: this.stageH || window.innerHeight,
dpr: window.devicePixelRatio || 1
});
} catch (e) {}
},
methods: {
onCmd(newVal, oldVal, ownerInstance) {
if (!newVal || !newVal.op) return;
const { op, payload } = newVal;
if (op === "init") {
this.dpr = payload.dpr || (window.devicePixelRatio || 1);
if (!this.stageW || !this.stageH) this.measureStage();
this.applyConfig((payload && payload.config) || {});
this.prepareCanvas();
this.start();
}
if (op === "setConfig") {
this.applyConfig(payload.config || {});
}
if (op === "clear") {
this.resetParticles();
this.trailsCtx && this.trailsCtx.clearRect(0, 0, this.stageW, this.stageH);
this.mainCtx && this.mainCtx.clearRect(0, 0, this.stageW, this.stageH);
}
if (op === "pauseRender") this.stop();
if (op === "resume") this.start();
if (op === "destroy") this.stop();
},
applyConfig(cfg) {
this.paused = !!cfg.paused;
this.autoLaunch = !!cfg.autoLaunch;
this.wordShell = !!cfg.wordShell;
this.releaseEnabled = cfg.releaseEnabled !== false;
this.finale = !!cfg.finale;
this.wordSettleMs = cfg.wordSettleMs || 900;
this.wordProbability = cfg.wordProbability != null ? cfg.wordProbability : 0.18;
this.randomWords = cfg.randomWords && cfg.randomWords.length ? cfg.randomWords : this.randomWords;
this.quality = cfg.quality || 2;
},
bindCanvasAndEvents() {
this.wrapEl = DOC.getElementById("fwWrap");
this.trailsWrap = DOC.getElementById("trailsCanvas");
this.mainWrap = DOC.getElementById("mainCanvas");
this.trails = getRealCanvas(this.trailsWrap);
this.main = getRealCanvas(this.mainWrap);
if (!this.trails || !this.main) {
setTimeout(() => this.bindCanvasAndEvents(), 50);
return;
}
this.trailsCtx = this.trails.getContext("2d");
this.mainCtx = this.main.getContext("2d");
this.measureStage();
const getPos = (evt) => {
const t = (evt.touches && evt.touches[0]) || (evt.changedTouches && evt.changedTouches[0]);
if (!t) return null;
const rect = this.main.getBoundingClientRect();
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
};
const onStart = (e) => {
e.preventDefault();
const pos = getPos(e);
if (!pos) return;
if (pos.y >= this.stageH - 44) {
this.isUpdatingSpeed = true;
this.updateSpeedFromX(pos.x);
return;
}
if (this.releaseEnabled) {
this.launchShellAt(pos.x, pos.y);
}
};
const onMove = (e) => {
e.preventDefault();
if (!this.isUpdatingSpeed) return;
const pos = getPos(e);
if (!pos) return;
this.updateSpeedFromX(pos.x);
};
const onEnd = (e) => {
e && e.preventDefault && e.preventDefault();
this.isUpdatingSpeed = false;
};
this.main.addEventListener("touchstart", onStart, { passive: false });
this.main.addEventListener("touchmove", onMove, { passive: false });
this.main.addEventListener("touchend", onEnd, { passive: false });
this.main.addEventListener("touchcancel", onEnd, { passive: false });
window.addEventListener("resize", () => {
this.measureStage();
this.prepareCanvas();
});
},
measureStage() {
const el = this.wrapEl || this.main || document.body;
this.stageW = el.clientWidth || window.innerWidth;
this.stageH = el.clientHeight || window.innerHeight;
},
prepareCanvas() {
if (!this.trails || !this.main) return;
const w = this.stageW, h = this.stageH;
const dpr = this.dpr || 1;
// hidpi=false,所以这里手动 dpr
this.trails.width = Math.floor(w * dpr);
this.trails.height = Math.floor(h * dpr);
this.main.width = Math.floor(w * dpr);
this.main.height = Math.floor(h * dpr);
this.trailsCtx.setTransform(1, 0, 0, 1, 0, 0);
this.mainCtx.setTransform(1, 0, 0, 1, 0, 0);
this.trailsCtx.scale(dpr, dpr);
this.mainCtx.scale(dpr, dpr);
this.trailsCtx.clearRect(0, 0, w, h);
this.mainCtx.clearRect(0, 0, w, h);
},
start() {
if (this.running) return;
this.running = true;
this.lastTs = performance.now();
const loop = (ts) => {
if (!this.running) return;
let ft = ts - this.lastTs;
this.lastTs = ts;
if (ft < 0) ft = 16;
if (ft > 68) ft = 68;
if (!this.paused) this.update(ft);
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
},
stop() { this.running = false; },
updateSpeedFromX(x) {
const edge = 16;
const newSpeed = (x - edge) / (this.stageW - edge * 2);
this.simSpeed = MyMath.clamp(newSpeed, 0, 1);
this.speedBarOpacity = 1;
},
// shell factory
randomShellConfig() {
const size = 2;
const r = Math.random();
if (r < 0.45) return crysanthemumShell(size, this.quality);
if (r < 0.62) return willowShell(size);
if (r < 0.78) return palmShell(size);
if (r < 0.9) return ringShell(size);
return crackleShell(size, this.quality);
},
randomFastShellFactory() {
const list = [
(s) => crysanthemumShell(s, this.quality),
(s) => palmShell(s),
(s) => ringShell(s),
(s) => crackleShell(s, this.quality),
(s) => ghostShell(s)
];
return (size) => list[(Math.random() * list.length) | 0](size);
},
// sequences
startSequence() {
if (this.finale) {
this.seqFinaleFast();
if (this.currentFinaleCount < this.finaleCount) {
this.currentFinaleCount++;
return 170;
} else {
this.currentFinaleCount = 0;
return 4200;
}
}
const r = Math.random();
if (r < 0.5) return this.seqRandomShell();
if (r < 0.78) return this.seqTwoShell();
return this.seqTriple();
},
seqRandomShell() {
const x = 0.18 + Math.random() * 0.64;
const h = 0.15 + Math.random() * 0.55;
new Shell(this.randomShellConfig(), this).launch(x, h);
return 780 + Math.random() * 640 + 1200;
},
seqTwoShell() {
const s1 = new Shell(this.randomShellConfig(), this);
const s2 = new Shell(this.randomShellConfig(), this);
const left = 0.3 + (Math.random() * 0.2 - 0.1);
const right = 0.7 + (Math.random() * 0.2 - 0.1);
s1.launch(left, 0.2 + Math.random() * 0.5);
setTimeout(() => s2.launch(right, 0.2 + Math.random() * 0.5), 100);
return 1800;
},
seqTriple() {
const shellType = this.randomFastShellFactory();
const baseSize = 2;
const offset = Math.random() * 0.08 - 0.04;
new Shell(shellType(baseSize), this).launch(0.5 + offset, 0.7);
setTimeout(() => new Shell(shellType(Math.max(0, baseSize - 1.25)), this).launch(0.2 + offset, 0.18), 900 + Math.random() * 420);
setTimeout(() => new Shell(shellType(Math.max(0, baseSize - 1.25)), this).launch(0.8 + offset, 0.18), 900 + Math.random() * 420);
return 3600;
},
seqFinaleFast() {
const shellType = this.randomFastShellFactory();
new Shell(shellType(2), this).launch(0.25 + Math.random() * 0.1, 0.3 + Math.random() * 0.35);
new Shell(shellType(2), this).launch(0.65 + Math.random() * 0.1, 0.3 + Math.random() * 0.35);
},
autoLaunchStep(timeStep) {
if (!this.autoLaunch || !this.releaseEnabled) return;
this.autoLaunchTime -= timeStep;
if (this.autoLaunchTime <= 0) this.autoLaunchTime = this.startSequence() * 0.95;
},
launchShellAt(x, y) {
const px = x / this.stageW;
const ph = 1 - y / this.stageH;
new Shell(this.randomShellConfig(), this).launch(px, ph);
},
// text lattice
randomWord() {
const arr = this.randomWords || [];
if (!arr.length) return "";
return arr[(Math.random() * arr.length) | 0];
},
literalLattice(text, density = 3, fontFamily = "sans-serif", fontSizePx = 90) {
const key = `${text}|${density}|${fontFamily}|${fontSizePx}`;
const cached = WORD_CACHE.get(key);
if (cached) return cached;
const ctx = this.offCtx;
const pad = 20;
ctx.font = `${fontSizePx}px ${fontFamily}`;
const w = Math.ceil(ctx.measureText(text).width) + pad;
const h = Math.ceil(fontSizePx) + pad;
this.off.width = w;
this.off.height = h;
ctx.clearRect(0, 0, w, h);
ctx.font = `${fontSizePx}px ${fontFamily}`;
ctx.textBaseline = "top";
ctx.fillStyle = "#fff";
ctx.fillText(text, pad / 2, pad / 2);
const img = ctx.getImageData(0, 0, w, h);
const points = [];
for (let yy = 0; yy < h; yy += density) {
for (let xx = 0; xx < w; xx += density) {
const i = (yy * w + xx) * 4;
if (img.data[i + 3] > 0) points.push({ x: xx, y: yy });
}
}
const res = { width: w, height: h, points };
WORD_CACHE.set(key, res);
return res;
},
createWordBurstAt(cx, cy) {
if (!this.wordShell) return;
const word = this.randomWord();
if (!word) return;
const fontSize = Math.floor(Math.random() * 40 + 76);
const density = 3;
const fontFamily = "sans-serif";
const map = this.literalLattice(word, density, fontFamily, fontSize);
if (!map || !map.points || !map.points.length) return;
const dcenterX = map.width / 2;
const dcenterY = map.height / 2;
const scale = Math.min(1.15, Math.max(0.65, this.stageW / 750));
const color = randomColor({});
const settleMs = this.wordSettleMs || 900;
const baseLife = 2000;
const lifeJitter = 250;
const baseSpeed = 0.55;
const speedJitter = 0.35;
const tailStep = 6;
for (let i = 0; i < map.points.length; i++) {
const p = map.points[i];
const x = cx + (p.x - dcenterX) * scale;
const y = cy + (p.y - dcenterY) * scale;
const ang = Math.random() * Math.PI * 2;
const dx = p.x - dcenterX;
const dy = p.y - dcenterY;
const dist = Math.sqrt(dx * dx + dy * dy);
const distNorm = Math.min(1, dist / Math.max(map.width, map.height));
const spd = (baseSpeed + Math.random() * speedJitter) * (0.35 + 0.85 * distNorm);
const life = baseLife + (Math.random() * 2 - 1) * lifeJitter;
const s = Star.add(x, y, color, ang, spd, life, 0, 0, 2);
s.isWord = true;
s.settle = settleMs;
s.size = 2.2;
if (i % tailStep === 0) {
s.sparkFreq = 130;
s.sparkSpeed = 0.22;
s.sparkLife = 520;
s.sparkLifeVariation = 1.5;
s.sparkColor = color;
s.sparkTimer = Math.random() * s.sparkFreq;
}
}
},
resetParticles() {
COLOR_CODES_W_INVIS.forEach(c => { Star.active[c].length = 0; Spark.active[c].length = 0; });
BurstFlash.active.length = 0;
},
update(frameTime) {
this.frameId++;
const timeStep = frameTime * this.simSpeed;
const lag = frameTime / 16.6667;
const speed = this.simSpeed * lag;
if (!this.isUpdatingSpeed) {
this.speedBarOpacity -= speed / 30;
if (this.speedBarOpacity < 0) this.speedBarOpacity = 0;
}
this.autoLaunchStep(timeStep);
const starDrag = 1 - (1 - Star.airDrag) * speed;
const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
const gAcc = (timeStep / 1000) * GRAVITY;
for (let ci = 0; ci < COLOR_CODES_W_INVIS.length; ci++) {
const color = COLOR_CODES_W_INVIS[ci];
const stars = Star.active[color];
for (let i = stars.length - 1; i >= 0; i--) {
const s = stars[i];
if (s.updateFrame === this.frameId) continue;
s.updateFrame = this.frameId;
s.life -= timeStep;
if (s.life <= 0) { stars.splice(i,1); Star.returnInstance(s); continue; }
const burnRate = Math.pow(s.life / s.fullLife, 0.5);
const burnRateInv = 1 - burnRate;
s.prevX = s.x; s.prevY = s.y;
s.x += s.speedX * speed;
s.y += s.speedY * speed;
if (!s.heavy) { s.speedX *= starDrag; s.speedY *= starDrag; }
else { s.speedX *= starDragHeavy; s.speedY *= starDragHeavy; }
// ✅ 文字 settle:前 N ms 更稳
if (s.isWord && s.settle > 0) {
s.settle -= timeStep;
s.speedX *= 0.82;
s.speedY *= 0.82;
s.speedY += gAcc * 0.35;
} else {
s.speedY += gAcc;
}
if (s.spinRadius) {
s.spinAngle += s.spinSpeed * speed;
s.x += Math.sin(s.spinAngle) * s.spinRadius * speed;
s.y += Math.cos(s.spinAngle) * s.spinRadius * speed;
}
if (s.sparkFreq) {
s.sparkTimer -= timeStep;
while (s.sparkTimer < 0) {
s.sparkTimer += s.sparkFreq * 0.75 + s.sparkFreq * burnRateInv * 4;
Spark.add(
s.x, s.y, s.sparkColor,
Math.random() * MyMath.twoPI,
Math.random() * s.sparkSpeed * burnRate,
s.sparkLife * 0.8 + Math.random() * s.sparkLifeVariation * s.sparkLife
);
}
}
}
const sparks = Spark.active[color];
for (let i = sparks.length - 1; i >= 0; i--) {
const sp = sparks[i];
sp.life -= timeStep;
if (sp.life <= 0) { sparks.splice(i,1); Spark.returnInstance(sp); continue; }
sp.prevX = sp.x; sp.prevY = sp.y;
sp.x += sp.speedX * speed;
sp.y += sp.speedY * speed;
sp.speedX *= sparkDrag;
sp.speedY *= sparkDrag;
sp.speedY += gAcc;
}
}
this.render(speed);
},
render(speed) {
const w = this.stageW, h = this.stageH;
const trailsCtx = this.trailsCtx;
const mainCtx = this.mainCtx;
// 使用 destination-out 擦除实现“透明背景的拖影淡出”,避免把画布刷成纯黑
trailsCtx.globalCompositeOperation = "destination-out";
trailsCtx.fillStyle = `rgba(0,0,0,${0.22 * speed})`;
trailsCtx.fillRect(0, 0, w, h);
trailsCtx.globalCompositeOperation = "source-over";
mainCtx.clearRect(0, 0, w, h);
while (BurstFlash.active.length) {
const bf = BurstFlash.active.pop();
const g = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
g.addColorStop(0.024, "rgba(255,255,255,1)");
g.addColorStop(0.125, "rgba(255,160,20,0.2)");
g.addColorStop(0.32, "rgba(255,140,20,0.11)");
g.addColorStop(1, "rgba(255,120,20,0)");
trailsCtx.fillStyle = g;
trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);
BurstFlash.returnInstance(bf);
}
trailsCtx.globalCompositeOperation = "lighten";
trailsCtx.lineCap = "round";
mainCtx.strokeStyle = "#fff";
mainCtx.lineWidth = 1;
mainCtx.beginPath();
for (let ci = 0; ci < COLOR_CODES.length; ci++) {
const color = COLOR_CODES[ci];
const stars = Star.active[color];
if (!stars.length) continue;
trailsCtx.strokeStyle = color;
trailsCtx.beginPath();
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
if (!s.visible) continue;
trailsCtx.lineWidth = s.size;
trailsCtx.moveTo(s.x, s.y);
trailsCtx.lineTo(s.prevX, s.prevY);
mainCtx.moveTo(s.x, s.y);
mainCtx.lineTo(s.x - s.speedX * 1.6, s.y - s.speedY * 1.6);
}
trailsCtx.stroke();
}
mainCtx.stroke();
trailsCtx.lineWidth = Spark.drawWidth;
trailsCtx.lineCap = "butt";
for (let ci = 0; ci < COLOR_CODES.length; ci++) {
const color = COLOR_CODES[ci];
const sparks = Spark.active[color];
if (!sparks.length) continue;
trailsCtx.strokeStyle = color;
trailsCtx.beginPath();
for (let i = 0; i < sparks.length; i++) {
const sp = sparks[i];
trailsCtx.moveTo(sp.x, sp.y);
trailsCtx.lineTo(sp.prevX, sp.prevY);
}
trailsCtx.stroke();
}
if (this.speedBarOpacity) {
const barH = 6;
mainCtx.globalAlpha = this.speedBarOpacity;
mainCtx.fillStyle = COLOR.Blue;
mainCtx.fillRect(0, h - barH, w * this.simSpeed, barH);
mainCtx.globalAlpha = 1;
}
}
}
};
</script>
<!-- #endif -->
<style scoped>
.fw-page,
.fw-wrap {
pointer-events: none;
}
.fw-page {
position: fixed;
width: 100vw;
height: 100vh;
overflow: auto;
z-index: 1000;
background: rgba(0, 0, 0, 0.35);
/* #ifdef H5 */
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
/* #endif */
}
.fw-wrap {
position: relative;
width: 100%;
height: 100%;
}
.fw-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.fw-canvas--top {
z-index: 2;
}
.fw-ui {
position: absolute;
z-index: 3;
left: 24rpx;
top: 24rpx;
display: flex;
flex-wrap: wrap;
gap: 16rpx;
width: calc(100% - 48rpx);
}
.fw-btn {
padding: 12rpx 18rpx;
border: 1px solid rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.9);
border-radius: 12rpx;
font-size: 26rpx;
background: rgba(0, 0, 0, 0.25);
}
</style>
细腻版本的:
效果图:
细腻版本烟花效果图
组件代码:
// 注:组件页面代码,其他工具代码打包上传了
<template>
<!-- 烟花绽放组件 - uniapp + vue2 + canvas type=2d 高性能版 -->
<!-- #ifdef APP-PLUS -->
<!-- APP 端:整个容器由 renderjs (canvasWorker) 接管渲染 -->
<view
class="firework-container"
:renderConfig="renderConfig"
:change:renderConfig="canvasWorker.onConfigChange"
:renderCmd="renderCmd"
:change:renderCmd="canvasWorker.onCmdChange"
@touchstart="canvasWorker.onTouchStart"
>
<view
id="canvasContainer"
class="firework-canvas"
:style="{
width: canvasW + 'px',
height: canvasH + 'px',
position: 'relative',
overflow: 'hidden',
}"
></view>
<!-- 操作按钮区域 -->
<view class="firework-controls">
<view class="firework-btn" @click.stop="togglePause">
<text class="firework-btn-text">{{ isPaused ? '继续' : '暂停' }}</text>
</view>
<view class="firework-btn" @click.stop="clearScreen">
<text class="firework-btn-text">清屏</text>
</view>
<view class="firework-btn firework-btn-danger" @click.stop="stop">
<text class="firework-btn-text">停止</text>
</view>
</view>
</view>
<!-- #endif -->
<!-- #ifndef APP-PLUS -->
<!-- 小程序端:保持原有逻辑层驱动 -->
<view class="firework-container" @touchstart="onTouchStart">
<canvas
type="2d"
id="fireCanvas"
class="firework-canvas"
:style="{width: canvasW + 'px', height: canvasH + 'px'}"
></canvas>
<!-- 操作按钮区域 -->
<view class="firework-controls">
<view class="firework-btn" @click.stop="togglePause">
<text class="firework-btn-text">{{ isPaused ? '继续' : '暂停' }}</text>
</view>
<view class="firework-btn" @click.stop="clearScreen">
<text class="firework-btn-text">清屏</text>
</view>
<view class="firework-btn firework-btn-danger" @click.stop="stop">
<text class="firework-btn-text">停止</text>
</view>
</view>
</view>
<!-- #endif -->
</template>
<script>
import MyMath from './utils/MyMath.js';
import P from './utils/particles.js';
import ShellModule from './utils/Shell.js';
var Star = P.Star;
var Spark = P.Spark;
var BurstFlash = P.BurstFlash;
var COLOR = P.COLOR;
var INVISIBLE = P.INVISIBLE;
var PI_2 = P.PI_2;
var COLOR_CODES = P.COLOR_CODES;
var COLOR_CODES_W_INVIS = P.COLOR_CODES_W_INVIS;
var COLOR_TUPLES = P.COLOR_TUPLES;
var Shell = ShellModule.Shell;
var randomShell = ShellModule.randomShell;
var GRAVITY = ShellModule.GRAVITY;
export default {
name: 'FireworkCanvas',
props: {
// 自动发射间隔范围(毫秒)
autoLaunchInterval: {
type: Array,
default: function () {
return [800, 2400];
},
},
// 同时最多自动发射数量
maxAutoShells: {
type: Number,
default: 3,
},
// 烟花大小 0~5
shellSize: {
type: Number,
default: 2,
},
// 是否自动发射
autoLaunch: {
type: Boolean,
default: true,
},
// 帧间隔 ms(16.67≈60fps,建议小程序用 25~33)
frameInterval: {
type: Number,
default: 25,
},
// 是否启用文字烟花
wordShell: {
type: Boolean,
default: true,
},
// 文字烟花内容列表
textContent: {
type: Array,
default: function () {
return ['新年快乐', '心想事成'];
},
},
// 文字烟花触发概率(每次爆炸时)
wordProbability: {
type: Number,
default: 0.18,
},
// 文字粒子稳定期 ms
wordSettleMs: {
type: Number,
default: 900,
},
},
data: function () {
return {
canvasW: 375,
canvasH: 667,
// #ifndef APP-PLUS
// 小程序端内部状态
_ctx: null,
_canvas: null,
_dpr: 1,
_timer: null,
_autoTimer: null,
_lastTime: 0,
_currentFrame: 0,
_running: false,
_paused: false, // 暂停发射标记(帧循环继续,禁止发射新烟花)
// 文字烟花:离屏 canvas 和缓存
_offscreen: null,
_offscreenCtx: null,
_wordCache: null,
// #endif
// APP 端 renderjs 通信(Vue2 响应式属性,不能用 _ 前缀)
// renderConfig:传递所有配置给 renderjs,仅在 mounted 时赋值一次
renderConfig: null,
// renderCmd:传递指令给 renderjs(如 stop)
renderCmd: null,
// 按钮状态(不带 _ 前缀,保持 Vue 2 响应式)
isPaused: false,
};
},
mounted: function () {
var sysInfo = uni.getSystemInfoSync();
this.canvasW = sysInfo.windowWidth;
this.canvasH = sysInfo.windowHeight;
// #ifdef APP-PLUS
// APP 端:将所有配置打包传给 renderjs,由 renderjs 接管 canvas 动画
this.$nextTick(function () {
this.renderConfig = {
id: Date.now(),
canvasW: this.canvasW,
canvasH: this.canvasH,
dpr: sysInfo.pixelRatio,
autoLaunchInterval: this.autoLaunchInterval,
maxAutoShells: this.maxAutoShells,
shellSize: this.shellSize,
autoLaunch: this.autoLaunch,
frameInterval: this.frameInterval,
wordShell: this.wordShell,
textContent: this.textContent,
wordProbability: this.wordProbability,
wordSettleMs: this.wordSettleMs,
};
});
return;
// #endif
// #ifndef APP-PLUS
this.initCanvas();
// #endif
},
beforeDestroy: function () {
// #ifdef APP-PLUS
// 通知 renderjs 停止动画
this.renderCmd = {cmd: 'stop', id: Date.now()};
return;
// #endif
// #ifndef APP-PLUS
this.stop();
// #endif
},
methods: {
/** 初始化画布 - type=2d 高性能版 */
initCanvas: function () {
var self = this;
// 获取系统信息设置画布尺寸
var sysInfo = uni.getSystemInfoSync();
self.canvasW = sysInfo.windowWidth;
self.canvasH = sysInfo.windowHeight;
self._dpr = sysInfo.pixelRatio;
// type=2d canvas 需要通过 SelectorQuery 获取 node
self.$nextTick(function () {
uni.createSelectorQuery()
.in(self)
.select('#fireCanvas')
.fields({node: true, size: true})
.exec(function (res) {
if (!res || !res[0] || !res[0].node) {
console.error('[firework] 获取 canvas node 失败');
return;
}
var canvas = res[0].node;
var ctx = canvas.getContext('2d');
var dpr = self._dpr;
// 设置 canvas 物理像素尺寸,匹配设备分辨率
canvas.width = self.canvasW * dpr;
canvas.height = self.canvasH * dpr;
ctx.scale(dpr, dpr);
self._canvas = canvas;
self._ctx = ctx;
// 首帧清除为透明
ctx.clearRect(0, 0, self.canvasW, self.canvasH);
// 初始化文字烟花离屏 canvas
self.initOffscreen();
self.start();
});
});
},
/** 启动动画 */
start: function () {
if (this._running) return;
this._running = true;
this._idleFrames = 0;
this._lastTime = Date.now();
this._currentFrame = 0;
this.scheduleFrame();
if (this.autoLaunch) {
this.scheduleAutoLaunch();
}
},
/** 停止动画 */
stop: function () {
this._paused = true;
this.isPaused = true;
this._running = false;
if (this._timer) {
var canvas = this._canvas;
if (canvas && canvas.cancelAnimationFrame) {
canvas.cancelAnimationFrame(this._timer);
} else {
clearTimeout(this._timer);
}
this._timer = null;
}
if (this._autoTimer) {
clearTimeout(this._autoTimer);
this._autoTimer = null;
}
},
/** 清屏:清除所有粒子和画布内容,不影响动画循环 */
clearScreen: function () {
// #ifdef APP-PLUS
this.renderCmd = {cmd: 'clear', id: Date.now()};
return;
// #endif
// #ifndef APP-PLUS
// 清除所有 Star 粒子
COLOR_CODES_W_INVIS.forEach(function (color) {
var stars = Star.active[color];
while (stars.length) {
Star.returnInstance(stars.pop());
}
});
// 清除所有 Spark 粒子
COLOR_CODES_W_INVIS.forEach(function (color) {
var sparks = Spark.active[color];
while (sparks.length) {
Spark.returnInstance(sparks.pop());
}
});
// 清除所有 BurstFlash
while (BurstFlash.active.length) {
BurstFlash.returnInstance(BurstFlash.active.pop());
}
// 清除画布
if (this._ctx) {
this._ctx.clearRect(0, 0, this.canvasW, this.canvasH);
}
this._idleFrames = 0;
// #endif
},
/** 切换暂停/继续发射烟花(帧循环不停,已有粒子自然消亡) */
togglePause: function () {
// #ifdef APP-PLUS
this.renderCmd = {cmd: 'togglePause', id: Date.now()};
this.isPaused = !this.isPaused;
return;
// #endif
// #ifndef APP-PLUS
this._paused = !this._paused;
if (this._paused) {
// 暂停:取消自动发射定时器
if (this._autoTimer) {
clearTimeout(this._autoTimer);
this._autoTimer = null;
}
// 清除画布残留粒子与画面
this.clearScreen();
} else {
// 继续:清屏后全新启动烟花(非恢复上一次)
this.clearScreen();
if (this._running) {
// 帧循环仍在运行,先停掉
if (this._timer) {
var canvas = this._canvas;
if (canvas && canvas.cancelAnimationFrame) {
canvas.cancelAnimationFrame(this._timer);
} else {
clearTimeout(this._timer);
}
this._timer = null;
}
if (this._autoTimer) {
clearTimeout(this._autoTimer);
this._autoTimer = null;
}
this._running = false;
}
this.start();
}
this.isPaused = this._paused;
return this._paused;
// #endif
},
/** 帧循环 - 优先 canvas.requestAnimationFrame,降级 setTimeout */
scheduleFrame: function () {
if (!this._running) return;
var self = this;
var canvas = self._canvas;
var tick = function () {
if (!self._running) return;
var now = Date.now();
var frameTime = now - self._lastTime;
self._lastTime = now;
// 限制帧时间范围
if (frameTime < 0) frameTime = 17;
else if (frameTime > 68) frameTime = 68;
var lag = frameTime / 16.6667;
self.update(frameTime, lag);
self.scheduleFrame();
};
if (canvas && canvas.requestAnimationFrame) {
self._timer = canvas.requestAnimationFrame(tick);
} else {
self._timer = setTimeout(tick, self.frameInterval);
}
},
/** 自动发射烟花调度 */
scheduleAutoLaunch: function () {
if (!this._running || !this.autoLaunch || this._paused) return;
var self = this;
var min = self.autoLaunchInterval[0];
var max = self.autoLaunchInterval[1];
var delay = Math.random() * (max - min) + min;
self._autoTimer = setTimeout(function () {
if (!self._running || self._paused) return;
self.launchRandomShell();
self.scheduleAutoLaunch();
}, delay);
},
/** 发射一颗随机烟花 */
launchRandomShell: function () {
var baseSize = this.shellSize;
var maxVariance = Math.min(2.5, baseSize);
var variance = Math.random() * maxVariance;
var size = baseSize - variance;
var height = maxVariance === 0 ? Math.random() : 1 - variance / maxVariance;
var centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
var x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
// 确保 x 在安全范围
var edge = 0.18;
x = (1 - edge * 2) * x + edge;
height = height * 0.75;
var opts = randomShell(size);
this._injectWordBurst(opts);
var shell = new Shell(opts);
shell.launch(x, height, this.canvasW, this.canvasH);
},
/** 点击屏幕发射烟花 */
onTouchStart: function (e) {
if (!this._running || this._paused) return;
var touch = e.touches[0];
if (!touch) return;
var x = touch.clientX / this.canvasW;
var y = 1 - touch.clientY / this.canvasH;
var opts = randomShell(this.shellSize);
this._injectWordBurst(opts);
var shell = new Shell(opts);
shell.launch(x, y, this.canvasW, this.canvasH);
},
/** 核心物理更新 */
update: function (frameTime, lag) {
var width = this.canvasW;
var height = this.canvasH;
var timeStep = frameTime;
var speed = lag;
this._currentFrame++;
var currentFrame = this._currentFrame;
var starDrag = 1 - (1 - Star.airDrag) * speed;
var starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
var sparkDrag = 1 - (1 - Spark.airDrag) * speed;
var gAcc = (timeStep / 1000) * GRAVITY;
// 更新所有颜色的粒子
COLOR_CODES_W_INVIS.forEach(function (color) {
// 更新星花
var stars = Star.active[color];
for (var i = stars.length - 1; i >= 0; i--) {
var star = stars[i];
if (star.updateFrame === currentFrame) continue;
star.updateFrame = currentFrame;
star.life -= timeStep;
if (star.life <= 0) {
stars.splice(i, 1);
Star.returnInstance(star);
} else {
var burnRate = Math.pow(star.life / star.fullLife, 0.5);
var burnRateInverse = 1 - burnRate;
star.prevX = star.x;
star.prevY = star.y;
star.x += star.speedX * speed;
star.y += star.speedY * speed;
if (!star.heavy) {
star.speedX *= starDrag;
star.speedY *= starDrag;
} else {
star.speedX *= starDragHeavy;
star.speedY *= starDragHeavy;
}
// 文字粒子大幅削弱重力,防止文字整体下沉导致下半截出画布
if (star.isWord) {
star.speedY += gAcc * 0.08;
} else {
star.speedY += gAcc;
}
if (star.spinRadius) {
star.spinAngle += star.spinSpeed * speed;
star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
}
if (star.sparkFreq) {
star.sparkTimer -= timeStep;
while (star.sparkTimer < 0) {
star.sparkTimer +=
star.sparkFreq * 0.75 +
star.sparkFreq * burnRateInverse * 4;
Spark.add(
star.x,
star.y,
star.sparkColor,
Math.random() * PI_2,
Math.random() * star.sparkSpeed * burnRate,
star.sparkLife * 0.8 +
Math.random() * star.sparkLifeVariation * star.sparkLife,
);
}
}
// 颜色过渡
if (star.life < star.transitionTime) {
if (star.secondColor && !star.colorChanged) {
star.colorChanged = true;
star.color = star.secondColor;
stars.splice(i, 1);
Star.active[star.secondColor].push(star);
if (star.secondColor === INVISIBLE) {
star.sparkFreq = 0;
}
}
if (star.strobe) {
star.visible =
Math.floor(star.life / star.strobeFreq) % 3 === 0;
}
}
}
}
// 更新火花
var sparks = Spark.active[color];
for (var j = sparks.length - 1; j >= 0; j--) {
var spark = sparks[j];
spark.life -= timeStep;
if (spark.life <= 0) {
sparks.splice(j, 1);
Spark.returnInstance(spark);
} else {
spark.prevX = spark.x;
spark.prevY = spark.y;
spark.x += spark.speedX * speed;
spark.y += spark.speedY * speed;
spark.speedX *= sparkDrag;
spark.speedY *= sparkDrag;
spark.speedY += gAcc;
}
}
});
this.render(speed);
},
/** 渲染到 canvas - type=2d 单层高性能版 */
render: function (speed) {
var width = this.canvasW;
var height = this.canvasH;
var ctx = this._ctx;
if (!ctx) return;
// 空闲清零机制:粒子池全部为空时,累计空闲帧,达到阈值后全量清黑消除残留
var hasParticles =
Star._totalCount > 0 ||
Spark._totalCount > 0 ||
BurstFlash.active.length > 0;
if (!hasParticles) {
this._idleFrames++;
if (this._idleFrames >= 20) {
ctx.clearRect(0, 0, width, height);
return;
}
} else {
this._idleFrames = 0;
}
// === (1) destination-in 透明度衰减 - 实现拖尾渐隐(透明背景) ===
// 原理:将已有像素的 alpha 乘以衰减系数,逐帧淡出至完全透明
ctx.globalCompositeOperation = 'destination-in';
ctx.globalAlpha = 1 - 0.22 * speed;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
// 周期性深度衰减:每 120 帧大幅降低残留像素的透明度
if (this._currentFrame % 120 === 0) {
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
// === (2) 绘制爆炸闪光 ===
while (BurstFlash.active.length) {
var bf = BurstFlash.active.pop();
// createRadialGradient 替代旧版 createCircularGradient
var grd = ctx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
grd.addColorStop(0, 'rgba(255,255,255,0.6)');
grd.addColorStop(0.2, 'rgba(255,160,20,0.15)');
grd.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = grd;
ctx.fillRect(
bf.x - bf.radius,
bf.y - bf.radius,
bf.radius * 2,
bf.radius * 2,
);
BurstFlash.returnInstance(bf);
}
// === (3) 绘制星花 ===
COLOR_CODES.forEach(function (color) {
var stars = Star.active[color];
if (stars.length === 0) return;
ctx.strokeStyle = color;
ctx.lineCap = 'round';
for (var i = 0, len = stars.length; i < len; i++) {
var star = stars[i];
if (star.visible) {
ctx.lineWidth = star.size;
ctx.beginPath();
ctx.moveTo(star.x, star.y);
ctx.lineTo(star.prevX, star.prevY);
ctx.stroke();
}
}
});
// === (4) 绘制火花 ===
var sparkW = Spark.drawWidth;
COLOR_CODES.forEach(function (color) {
var sparks = Spark.active[color];
if (sparks.length === 0) return;
ctx.strokeStyle = color;
ctx.lineWidth = sparkW;
ctx.lineCap = 'butt';
ctx.beginPath();
for (var j = 0, sLen = sparks.length; j < sLen; j++) {
ctx.moveTo(sparks[j].x, sparks[j].y);
ctx.lineTo(sparks[j].prevX, sparks[j].prevY);
}
ctx.stroke();
});
// === (5) 绘制白色中心线(合并原 mainCtx 层) ===
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.beginPath();
COLOR_CODES.forEach(function (color) {
var stars = Star.active[color];
for (var k = 0, kLen = stars.length; k < kLen; k++) {
var star = stars[k];
if (star.visible) {
ctx.moveTo(star.x, star.y);
ctx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
}
}
});
ctx.stroke();
},
// ============ 文字烟花方法 ============
/** 初始化离屏 Canvas(用于文字转点阵)- 仅小程序端使用 */
initOffscreen: function () {
this._wordCache = {};
try {
if (typeof uni !== 'undefined' && uni.createOffscreenCanvas) {
var off = uni.createOffscreenCanvas({
type: '2d',
width: 32,
height: 32,
});
this._offscreen = off;
this._offscreenCtx = off.getContext('2d');
}
} catch (e) {
this._offscreen = null;
this._offscreenCtx = null;
}
},
/** 文字转点阵:使用离屏 Canvas 将文字绘制后提取像素 - 仅小程序端使用 */
literalLattice: function (text, density, fontFamily, fontSizePx) {
if (!density) density = 3;
if (!fontFamily) fontFamily = 'sans-serif';
if (!fontSizePx) fontSizePx = 90;
var key = text + '|' + density + '|' + fontFamily + '|' + fontSizePx;
if (this._wordCache && this._wordCache[key]) {
return this._wordCache[key];
}
if (!this._offscreen || !this._offscreenCtx) return null;
var ctx = this._offscreenCtx;
// 动态 pad:基于字号的 40%,最小 24px,确保大字号不被裁切
var pad = Math.max(24, Math.ceil(fontSizePx * 0.4));
ctx.font = fontSizePx + 'px ' + fontFamily;
var metrics = ctx.measureText(text);
// 优先使用 TextMetrics 精确边界,降级到 advance width + pad
var w, h, offsetX, offsetY;
if (
metrics.actualBoundingBoxLeft !== undefined &&
metrics.actualBoundingBoxAscent !== undefined
) {
// 精确模式:使用实际渲染边界
var bboxW = Math.ceil(
metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight,
);
var bboxH = Math.ceil(
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
);
w = bboxW + pad;
h = bboxH + pad;
offsetX = pad / 2 + Math.ceil(metrics.actualBoundingBoxLeft);
offsetY = pad / 2 + Math.ceil(metrics.actualBoundingBoxAscent);
} else {
// 降级模式:advance width + 加大余量
w = Math.ceil(metrics.width) + pad;
h = Math.ceil(fontSizePx * 1.3) + pad;
offsetX = pad / 2;
offsetY = pad / 2;
}
this._offscreen.width = w;
this._offscreen.height = h;
ctx.clearRect(0, 0, w, h);
ctx.font = fontSizePx + 'px ' + fontFamily;
// 精确模式用 alphabetic 基线配合 offsetY;降级模式用 top 基线
if (
metrics.actualBoundingBoxLeft !== undefined &&
metrics.actualBoundingBoxAscent !== undefined
) {
ctx.textBaseline = 'alphabetic';
} else {
ctx.textBaseline = 'top';
}
ctx.fillStyle = '#fff';
ctx.fillText(text, offsetX, offsetY);
var img = ctx.getImageData(0, 0, w, h);
var points = [];
for (var yy = 0; yy < h; yy += density) {
for (var xx = 0; xx < w; xx += density) {
var idx = (yy * w + xx) * 4;
if (img.data[idx + 3] > 0) {
points.push({x: xx, y: yy});
}
}
}
var res = {width: w, height: h, points: points};
if (this._wordCache) {
this._wordCache[key] = res;
}
return res;
},
/** 随机选取一个文字 */
randomWord: function () {
var arr = this.textContent || [];
if (!arr.length) return '';
return arr[(Math.random() * arr.length) | 0];
},
/** 在爆炸位置创建文字粒子 - 统一 Star 粒子保持文字形状清晰 */
createWordBurstAt: function (cx, cy) {
if (!this.wordShell) return;
var word = this.randomWord();
if (!word) return;
// 确保点阵生成能力可用
if (!this._wordCache) {
this.initOffscreen();
if (!this._wordCache) return;
}
var fontSize = Math.floor(Math.random() * 24 + 64);
var density = 5;
var fontFamily = 'sans-serif';
// 动态 density 降级:粒子池剩余空间不足时降低采样密度
var available = Star.MAX_TOTAL - Star._totalCount;
if (available < 200) {
density = 3;
}
var map = this.literalLattice(word, density, fontFamily, fontSize);
if (!map || !map.points || !map.points.length) return;
var dcenterX = map.width / 2;
var dcenterY = map.height / 2;
// 动态缩放:当文字点阵超过画布可用区域时,等比缩小,防止长文字溢出
var margin = 10;
var availW = this.canvasW - margin * 2;
var availH = this.canvasH - margin * 2;
var scale = Math.min(1, availW / map.width, availH / map.height);
var scaledHalfW = dcenterX * scale;
var scaledHalfH = dcenterY * scale;
// 边界钳制:使用缩放后的半宽/半高确保文字粒子不超出 canvas 可视区域
var minY = scaledHalfH + margin;
var maxY = this.canvasH - scaledHalfH - margin;
if (minY > maxY) {
cy = this.canvasH / 2;
} else {
if (cy < minY) cy = minY;
if (cy > maxY) cy = maxY;
}
var minX = scaledHalfW + margin;
var maxX = this.canvasW - scaledHalfW - margin;
if (minX > maxX) {
cx = this.canvasW / 2;
} else {
if (cx < minX) cx = minX;
if (cx > maxX) cx = maxX;
}
var randomColor = ShellModule.randomColor;
var color = randomColor();
// 文字粒子生命参数 - 增加到 2200ms 让文字有足够展示时间
var starLife = 2200;
var starLifeVariation = 0.08;
// 50% 概率使用双色渐变(纯颜色过渡,不闪烁)
var hasTwoColors = Math.random() < 0.5;
var secondColor = hasTwoColors ? randomColor() : null;
// 统一使用 Star 池,预检查配额
var needCount = map.points.length;
var available = Star.MAX_TOTAL - Star._totalCount;
if (available < needCount) {
this._evictParticles(Star, needCount - available);
}
for (var i = 0; i < map.points.length; i++) {
var p = map.points[i];
// 坐标映射,支持动态缩放(scale=1 时等同于原版 1:1 映射)
var x = cx + (p.x - dcenterX) * scale;
var y = cy + (p.y - dcenterY) * scale;
// 极低速度保持文字形状,粒子几乎不动
var spd = Math.random() * 0.06 + 0.02;
var life =
starLife + Math.random() * starLife * starLifeVariation + spd * 800;
var s = Star.add(
x,
y,
color,
Math.random() * Math.PI * 2,
spd,
life,
0,
0,
// 粒子 size 统一为 2.5,比普通星花更粗,在拖尾覆盖下更持久
2.5,
);
if (s) {
// 标记为文字粒子,淘汰逻辑会跳过
s.isWord = true;
// 颜色过渡时间推迟到生命 75%~80% 处,确保文字长时间清晰
s.transitionTime = starLife * (Math.random() * 0.05 + 0.2);
// 禁用 strobe 闪烁 - 这是文字看不清的主要原因
s.strobe = false;
if (secondColor) {
s.secondColor = secondColor;
}
}
}
},
/**
* 为文字烟花腾出粒子配额:淘汰池中生命值最低的普通粒子
* @param {Object} pool Star 或 Spark 对象
* @param {Number} need 需要腾出的数量
*/
_evictParticles: function (pool, need) {
if (need <= 0) return;
// 第一阶段:淘汰普通粒子(非文字粒子),按 life 升序
var normalCandidates = [];
COLOR_CODES_W_INVIS.forEach(function (color) {
var arr = pool.active[color];
for (var i = 0; i < arr.length; i++) {
var p = arr[i];
if (p.isWord) continue;
normalCandidates.push({life: p.life, color: color, ref: p});
}
});
normalCandidates.sort(function (a, b) {
return a.life - b.life;
});
var evicted = 0;
for (var k = 0; k < normalCandidates.length && evicted < need; k++) {
var c = normalCandidates[k];
var arr = pool.active[c.color];
var idx = arr.indexOf(c.ref);
if (idx !== -1) {
arr.splice(idx, 1);
pool.returnInstance(c.ref);
evicted++;
}
}
// 第二阶段:如果普通粒子不够,淘汰已过半生命的老文字粒子
if (evicted < need) {
var wordCandidates = [];
COLOR_CODES_W_INVIS.forEach(function (color) {
var arr = pool.active[color];
for (var i = 0; i < arr.length; i++) {
var p = arr[i];
if (!p.isWord) continue;
// 只淘汰已消耗超过 50% 生命的文字粒子
if (p.life > p.fullLife * 0.5) continue;
wordCandidates.push({life: p.life, color: color, ref: p});
}
});
wordCandidates.sort(function (a, b) {
return a.life - b.life;
});
for (var j = 0; j < wordCandidates.length && evicted < need; j++) {
var wc = wordCandidates[j];
var warr = pool.active[wc.color];
var widx = warr.indexOf(wc.ref);
if (widx !== -1) {
warr.splice(widx, 1);
pool.returnInstance(wc.ref);
evicted++;
}
}
}
},
/** 注入文字烟花回调到 Shell options */
_injectWordBurst: function (opts) {
if (!this.wordShell) return;
var self = this;
opts.onWordBurst = function (x, y) {
self.createWordBurstAt(x, y);
};
opts.wordProbability = this.wordProbability;
},
},
};
</script>
<!-- #ifdef APP-PLUS -->
<script module="canvasWorker" lang="renderjs">
/**
* canvasWorker renderjs 模块
* APP 端视图层(webview)运行,直接操作 DOM canvas
* 内联全部依赖(MyMath + particles + Shell)+ 动画引擎 + 文字烟花
*/
// ============================================================
// 第一段:内联依赖 - MyMath
// ============================================================
var MyMath = {};
MyMath.toDeg = 180 / Math.PI;
MyMath.toRad = Math.PI / 180;
MyMath.halfPI = Math.PI / 2;
MyMath.twoPI = Math.PI * 2;
MyMath.dist = function (width, height) {
return Math.sqrt(width * width + height * height);
};
MyMath.pointDist = function (x1, y1, x2, y2) {
var distX = x2 - x1;
var distY = y2 - y1;
return Math.sqrt(distX * distX + distY * distY);
};
MyMath.angle = function (width, height) {
return MyMath.halfPI + Math.atan2(height, width);
};
MyMath.pointAngle = function (x1, y1, x2, y2) {
return MyMath.halfPI + Math.atan2(y2 - y1, x2 - x1);
};
MyMath.splitVector = function (speed, angle) {
return {
x: Math.sin(angle) * speed,
y: -Math.cos(angle) * speed,
};
};
MyMath.random = function (min, max) {
return Math.random() * (max - min) + min;
};
MyMath.randomInt = function (min, max) {
return ((Math.random() * (max - min + 1)) | 0) + min;
};
MyMath.randomChoice = function (choices) {
if (Array.isArray(choices)) {
return choices[(Math.random() * choices.length) | 0];
}
return choices;
};
MyMath.clamp = function (num, min, max) {
return Math.min(Math.max(num, min), max);
};
// ============================================================
// 第二段:内联依赖 - particles(Star / Spark / BurstFlash)
// ============================================================
var COLOR = {
Red: '#ff0043',
Green: '#14fc56',
Blue: '#1e7fff',
Purple: '#e60aff',
Gold: '#ffbf36',
White: '#ffffff',
};
var INVISIBLE = '_INVISIBLE_';
var PI_2 = Math.PI * 2;
var PI_HALF = Math.PI * 0.5;
var COLOR_NAMES = Object.keys(COLOR);
var COLOR_CODES = COLOR_NAMES.map(function (name) {
return COLOR[name];
});
var COLOR_CODES_W_INVIS = COLOR_CODES.concat([INVISIBLE]);
var COLOR_CODE_INDEXES = {};
COLOR_CODES_W_INVIS.forEach(function (code, i) {
COLOR_CODE_INDEXES[code] = i;
});
var COLOR_TUPLES = {};
COLOR_CODES.forEach(function (hex) {
COLOR_TUPLES[hex] = {
r: parseInt(hex.substr(1, 2), 16),
g: parseInt(hex.substr(3, 2), 16),
b: parseInt(hex.substr(5, 2), 16),
};
});
function createParticleCollection() {
var collection = {};
COLOR_CODES_W_INVIS.forEach(function (color) {
collection[color] = [];
});
return collection;
}
var Star = {
airDrag: 0.98,
airDragHeavy: 0.992,
active: createParticleCollection(),
_pool: [],
_totalCount: 0,
MAX_TOTAL: 700,
_new: function () {
return {};
},
add: function (x, y, color, angle, speed, life, speedOffX, speedOffY, size) {
if (this._totalCount >= this.MAX_TOTAL) return null;
if (size === undefined) size = 3;
var instance = this._pool.pop() || this._new();
instance.visible = true;
instance.heavy = false;
instance.x = x;
instance.y = y;
instance.prevX = x;
instance.prevY = y;
instance.color = color;
instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
instance.life = life;
instance.fullLife = life;
instance.size = size;
instance.spinAngle = Math.random() * PI_2;
instance.spinSpeed = 0.8;
instance.spinRadius = 0;
instance.sparkFreq = 0;
instance.sparkSpeed = 1;
instance.sparkTimer = 50;
instance.sparkColor = color;
instance.sparkLife = 750;
instance.sparkLifeVariation = 0.25;
instance.strobe = false;
instance.onDeath = null;
instance.secondColor = null;
instance.transitionTime = 0;
instance.colorChanged = false;
instance.strobeFreq = 0;
instance.updateFrame = 0;
instance.isWord = false;
instance.settle = 0;
this.active[color].push(instance);
this._totalCount++;
return instance;
},
returnInstance: function (instance) {
if (instance.onDeath) instance.onDeath(instance);
instance.onDeath = null;
instance.secondColor = null;
instance.transitionTime = 0;
instance.colorChanged = false;
instance.isWord = false;
instance.settle = 0;
this._pool.push(instance);
this._totalCount--;
},
};
var Spark = {
drawWidth: 0.75,
airDrag: 0.9,
active: createParticleCollection(),
_pool: [],
_totalCount: 0,
MAX_TOTAL: 500,
_new: function () {
return {};
},
add: function (x, y, color, angle, speed, life) {
if (this._totalCount >= this.MAX_TOTAL) return null;
var instance = this._pool.pop() || this._new();
instance.x = x;
instance.y = y;
instance.prevX = x;
instance.prevY = y;
instance.color = color;
instance.speedX = Math.sin(angle) * speed;
instance.speedY = Math.cos(angle) * speed;
instance.life = life;
this.active[color].push(instance);
this._totalCount++;
return instance;
},
returnInstance: function (instance) {
this._pool.push(instance);
this._totalCount--;
},
};
var BurstFlash = {
active: [],
_pool: [],
_new: function () {
return {};
},
add: function (x, y, radius) {
var instance = this._pool.pop() || this._new();
instance.x = x;
instance.y = y;
instance.radius = radius;
this.active.push(instance);
return instance;
},
returnInstance: function (instance) {
this._pool.push(instance);
},
};
// ============================================================
// 第三段:内联依赖 - Shell(颜色工具 + 粒子弧 + Shell 类)
// ============================================================
var GRAVITY = 0.9;
function randomColorSimple() {
return COLOR_CODES[(Math.random() * COLOR_CODES.length) | 0];
}
var lastColor = '';
function randomColor(options) {
var notSame = options && options.notSame;
var notColor = options && options.notColor;
var limitWhite = options && options.limitWhite;
var color = randomColorSimple();
if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
color = randomColorSimple();
}
if (notSame) {
while (color === lastColor) {
color = randomColorSimple();
}
} else if (notColor) {
while (color === notColor) {
color = randomColorSimple();
}
}
lastColor = color;
return color;
}
function whiteOrGold() {
return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
}
function makePistilColor(shellColor) {
return shellColor === COLOR.White || shellColor === COLOR.Gold
? randomColor({notColor: shellColor})
: whiteOrGold();
}
function createParticleArc(start, arcLength, count, randomness, particleFactory) {
var angleDelta = arcLength / count;
var end = start + arcLength - angleDelta * 0.5;
if (end > start) {
for (var angle = start; angle < end; angle = angle + angleDelta) {
particleFactory(angle + Math.random() * angleDelta * randomness);
}
} else {
for (var angle = start; angle > end; angle = angle + angleDelta) {
particleFactory(angle + Math.random() * angleDelta * randomness);
}
}
}
function createBurst(count, particleFactory, startAngle, arcLength) {
if (startAngle === undefined) startAngle = 0;
if (arcLength === undefined) arcLength = PI_2;
var R = 0.5 * Math.sqrt(count / Math.PI);
var C = 2 * R * Math.PI;
var C_HALF = C / 2;
for (var i = 0; i <= C_HALF; i++) {
var ringAngle = (i / C_HALF) * PI_HALF;
var ringSize = Math.cos(ringAngle);
var partsPerFullRing = C * ringSize;
var partsPerArc = partsPerFullRing * (arcLength / PI_2);
var angleInc = PI_2 / partsPerFullRing;
var angleOffset = Math.random() * angleInc + startAngle;
var maxRandomAngleOffset = angleInc * 0.33;
for (var j = 0; j < partsPerArc; j++) {
var randomAngleOffset = Math.random() * maxRandomAngleOffset;
var angle = angleInc * j + angleOffset + randomAngleOffset;
particleFactory(angle, ringSize);
}
}
}
function crossetteEffect(star) {
var startAngle = Math.random() * PI_HALF;
createParticleArc(startAngle, PI_2, 3, 0.5, function (angle) {
Star.add(star.x, star.y, star.color, angle, Math.random() * 0.6 + 0.75, 600);
});
}
function floralEffect(star) {
var count = 12;
createBurst(count, function (angle, speedMult) {
Star.add(star.x, star.y, star.color, angle, speedMult * 2.4, 1000, star.speedX, star.speedY);
});
BurstFlash.add(star.x, star.y, 46);
}
function fallingLeavesEffect(star) {
createBurst(4, function (angle, speedMult) {
var newStar = Star.add(star.x, star.y, INVISIBLE, angle, speedMult * 2.4, 2400, star.speedX, star.speedY);
if (newStar) {
newStar.sparkColor = COLOR.Gold;
newStar.sparkFreq = 200;
newStar.sparkSpeed = 0.28;
newStar.sparkLife = 750;
}
});
BurstFlash.add(star.x, star.y, 46);
}
function crackleEffect(star) {
var count = 8;
createParticleArc(0, PI_2, count, 1.8, function (angle) {
Spark.add(star.x, star.y, COLOR.Gold, angle, Math.pow(Math.random(), 0.45) * 2.4, 300);
});
}
var crysanthemumShell = function (size) {
if (!size) size = 1;
var glitter = Math.random() < 0.2;
var singleColor = Math.random() < 0.72;
var color = singleColor
? randomColor({limitWhite: true})
: [randomColor(), randomColor({notSame: true})];
var pistil = singleColor && Math.random() < 0.2;
var pistilColor = pistil && makePistilColor(color);
var secondColor =
singleColor && (Math.random() < 0.2 || color === COLOR.White)
? pistilColor || randomColor({notColor: color, limitWhite: true})
: null;
var streamers = !pistil && color !== COLOR.White && Math.random() < 0.2;
var starDensity = glitter ? 0.6 : 0.75;
return {
shellSize: size,
spreadSize: 300 + size * 100,
starLife: 900 + size * 200,
starDensity: starDensity,
color: color,
secondColor: secondColor,
glitter: glitter ? 'light' : '',
glitterColor: whiteOrGold(),
pistil: pistil,
pistilColor: pistilColor,
streamers: streamers,
};
};
var ghostShell = function (size) {
if (!size) size = 1;
var shell = crysanthemumShell(size);
shell.starLife *= 1.5;
var ghostColor = randomColor({notColor: COLOR.White});
shell.streamers = false;
shell.color = INVISIBLE;
shell.secondColor = ghostColor;
shell.glitter = '';
return shell;
};
var strobeShell = function (size) {
if (!size) size = 1;
var color = randomColor({limitWhite: true});
return {
shellSize: size,
spreadSize: 280 + size * 92,
starLife: 1100 + size * 200,
starLifeVariation: 0.4,
starDensity: 0.6,
color: color,
glitter: '',
glitterColor: COLOR.White,
strobe: true,
strobeColor: Math.random() < 0.5 ? COLOR.White : null,
pistil: false,
pistilColor: null,
};
};
var palmShell = function (size) {
if (!size) size = 1;
var color = randomColor();
return {
shellSize: size,
color: color,
spreadSize: 250 + size * 75,
starDensity: 0.15,
starLife: 1800 + size * 200,
glitter: 'medium',
};
};
var ringShell = function (size) {
if (!size) size = 1;
var color = randomColor();
return {
shellSize: size,
ring: true,
color: color,
spreadSize: 300 + size * 100,
starLife: 900 + size * 200,
starCount: 1.5 * PI_2 * (size + 1),
pistil: false,
glitter: '',
streamers: false,
};
};
var crossetteShell = function (size) {
if (!size) size = 1;
var color = randomColor({limitWhite: true});
return {
shellSize: size,
spreadSize: 300 + size * 100,
starLife: 750 + size * 160,
starLifeVariation: 0.4,
starDensity: 0.5,
color: color,
crossette: true,
pistil: false,
};
};
var floralShell = function (size) {
if (!size) size = 1;
return {
shellSize: size,
spreadSize: 300 + size * 120,
starDensity: 0.06,
starLife: 500 + size * 50,
starLifeVariation: 0.5,
color:
Math.random() < 0.65
? 'random'
: Math.random() < 0.15
? randomColor()
: [randomColor(), randomColor({notSame: true})],
floral: true,
};
};
var willowShell = function (size) {
if (!size) size = 1;
return {
shellSize: size,
spreadSize: 300 + size * 100,
starDensity: 0.3,
starLife: 3000 + size * 300,
glitter: 'willow',
glitterColor: COLOR.Gold,
color: INVISIBLE,
};
};
var crackleShell = function (size) {
if (!size) size = 1;
var color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
return {
shellSize: size,
spreadSize: 380 + size * 75,
starDensity: 0.5,
starLife: 600 + size * 100,
starLifeVariation: 0.32,
glitter: 'light',
glitterColor: COLOR.Gold,
color: color,
crackle: true,
pistil: false,
};
};
var horsetailShell = function (size) {
if (!size) size = 1;
var color = randomColor();
return {
shellSize: size,
horsetail: true,
color: color,
spreadSize: 250 + size * 38,
starDensity: 0.5,
starLife: 2500 + size * 300,
glitter: 'medium',
glitterColor: Math.random() < 0.5 ? whiteOrGold() : color,
strobe: color === COLOR.White,
};
};
var shellTypes = {
Crackle: crackleShell,
Crossette: crossetteShell,
Crysanthemum: crysanthemumShell,
Floral: floralShell,
Ghost: ghostShell,
'Horse Tail': horsetailShell,
Palm: palmShell,
Ring: ringShell,
Strobe: strobeShell,
Willow: willowShell,
};
var shellNames = Object.keys(shellTypes);
function randomShellName() {
return Math.random() < 0.5
? 'Crysanthemum'
: shellNames[(Math.random() * shellNames.length) | 0];
}
function randomShell(size) {
return shellTypes[randomShellName()](size);
}
function Shell(options) {
for (var key in options) {
if (options.hasOwnProperty(key)) {
this[key] = options[key];
}
}
this.starLifeVariation = options.starLifeVariation || 0.125;
this.color = options.color || randomColor();
this.glitterColor = options.glitterColor || this.color;
if (!this.starCount) {
var density = options.starDensity || 1;
var scaledSize = this.spreadSize / 54;
this.starCount = Math.min(80, Math.max(6, scaledSize * scaledSize * density));
}
}
Shell.prototype.launch = function (position, launchHeight, stageW, stageH) {
var width = stageW;
var height = stageH;
var hpad = 60;
var vpad = 50;
var minHeightPercent = 0.45;
var minHeight = height - height * minHeightPercent;
var launchX = position * (width - hpad * 2) + hpad;
var launchY = height;
var burstY = minHeight - launchHeight * (minHeight - vpad);
var launchDistance = launchY - burstY;
var launchVelocity = Math.pow(launchDistance * 0.04, 0.64);
var cometColor =
typeof this.color === 'string' && this.color !== 'random'
? this.color
: COLOR.White;
var comet = Star.add(
launchX,
launchY,
cometColor,
Math.PI,
launchVelocity * (this.horsetail ? 1.2 : 1),
launchVelocity * (this.horsetail ? 100 : 400),
);
if (!comet) return;
this.comet = comet;
comet.heavy = true;
comet.spinRadius = MyMath.random(0.32, 0.85);
comet.sparkFreq = 64;
comet.sparkLife = 200;
comet.sparkLifeVariation = 3;
if (this.glitter === 'willow' || this.fallingLeaves) {
comet.sparkFreq = 50;
comet.sparkSpeed = 0.5;
comet.sparkLife = 300;
}
if (this.color === INVISIBLE) {
comet.sparkColor = COLOR.Gold;
}
var self = this;
comet.onDeath = function (c) {
self.burst(c.x, c.y);
};
};
Shell.prototype.burst = function (x, y) {
var speed = this.spreadSize / 96;
var color, onDeath, sparkFreq, sparkSpeed, sparkLife;
var sparkLifeVariation = 0.25;
var self = this;
if (this.crossette) onDeath = crossetteEffect;
if (this.crackle) onDeath = crackleEffect;
if (this.floral) onDeath = floralEffect;
if (this.fallingLeaves) onDeath = fallingLeavesEffect;
if (this.glitter === 'light') {
sparkFreq = 1200;
sparkLife = 200;
} else if (this.glitter === 'medium') {
sparkFreq = 800;
sparkLife = 400;
} else if (this.glitter === 'heavy' || this.glitter === 'thick') {
sparkFreq = 400;
sparkLife = 600;
} else if (this.glitter === 'streamer') {
sparkFreq = 200;
sparkLife = 400;
} else if (this.glitter === 'willow') {
sparkFreq = 600;
sparkLife = 800;
}
var shellRef = this;
var starFactory = function (angle, speedMult) {
var star = Star.add(
x, y,
color || randomColor(),
angle,
speedMult * speed,
shellRef.starLife + Math.random() * shellRef.starLife * shellRef.starLifeVariation,
shellRef.horsetail ? shellRef.comet && shellRef.comet.speedX : 0,
shellRef.horsetail ? shellRef.comet && shellRef.comet.speedY : -shellRef.spreadSize / 1800,
);
if (!star) return;
if (shellRef.secondColor) {
star.transitionTime = shellRef.starLife * (Math.random() * 0.05 + 0.32);
star.secondColor = shellRef.secondColor;
}
if (shellRef.strobe) {
star.strobe = true;
star.strobeFreq = Math.random() * 20 + 40;
if (shellRef.strobeColor) star.secondColor = shellRef.strobeColor;
}
star.onDeath = onDeath;
if (shellRef.glitter) {
star.sparkFreq = sparkFreq;
star.sparkLife = sparkLife;
star.sparkColor = shellRef.glitterColor;
star.sparkTimer = Math.random() * sparkFreq;
}
};
if (typeof this.color === 'string') {
if (this.color === 'random') color = null;
else color = this.color;
if (this.ring) {
var ringStartAngle = Math.random() * Math.PI;
var ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;
createParticleArc(0, PI_2, this.starCount, 0, function (angle) {
var initSpeedX = Math.sin(angle) * speed * ringSquash;
var initSpeedY = Math.cos(angle) * speed;
var newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
var newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
starFactory(newAngle, newSpeed / speed);
});
} else {
createBurst(this.starCount, starFactory);
}
} else if (Array.isArray(this.color)) {
color = this.color[0];
createBurst(this.starCount, starFactory, 0, Math.PI);
color = this.color[1];
createBurst(this.starCount, starFactory, Math.PI, Math.PI);
}
if (this.pistil) {
var pistilCount = Math.floor(this.starCount * 0.4);
for (var k = 0; k < pistilCount; k++) {
var ang = Math.random() * PI_2;
var spd = Math.random() * speed * 0.5;
var pStar = Star.add(x, y, this.pistilColor, ang, spd, this.starLife * 0.6);
if (pStar && this.glitter) {
pStar.sparkFreq = 2000;
}
}
}
if (this.streamers) {
var streamCount = Math.floor(this.starCount * 0.3);
for (var m = 0; m < streamCount; m++) {
var sAng = Math.random() * PI_2;
var sSpd = Math.random() * speed * 0.9;
Star.add(x, y, COLOR.White, sAng, sSpd, this.starLife * 0.8);
}
}
if (this.onWordBurst && !this.disableWord) {
var wordProb = this.wordProbability || 0.05;
if (Math.random() < wordProb) {
this.onWordBurst(x, y);
}
}
BurstFlash.add(x, y, this.spreadSize / 4);
};
// ============================================================
// 第四段:模块级动画状态
// ============================================================
var _canvas = null;
var _ctx = null;
var _dpr = 1;
var _canvasW = 375;
var _canvasH = 667;
var _running = false;
var _rafId = null;
var _autoTimer = null;
var _lastTime = 0;
var _currentFrame = 0;
var _config = null;
var _wordCanvas = null;
var _wordCtx = null;
var _wordCache = {};
var _idleFrames = 0; // 空闲帧计数器:无粒子时递增,达阈值后清除拖尾残留
var _paused = false; // 暂停发射标记(帧循环继续,禁止发射新烟花)
// ============================================================
// 第五段:Vue renderjs 组件
// ============================================================
export default {
methods: {
// === 通信接口 ===
onConfigChange: function (newVal) {
if (!newVal) return;
_config = newVal;
_canvasW = newVal.canvasW || 375;
_canvasH = newVal.canvasH || 667;
_dpr = newVal.dpr || window.devicePixelRatio || 2;
// 初始化文字转点阵用的离屏 canvas
try {
_wordCanvas = document.createElement('canvas');
_wordCtx = _wordCanvas.getContext('2d');
_wordCache = {};
} catch (e) {
_wordCanvas = null;
_wordCtx = null;
}
this.initCanvas();
},
onCmdChange: function (newVal) {
if (!newVal) return;
if (newVal.cmd === 'stop') {
this.stop();
} else if (newVal.cmd === 'clear') {
this.clearScreen();
} else if (newVal.cmd === 'togglePause') {
this.togglePause();
}
},
onTouchStart: function (e) {
if (!_running || !_config || _paused) return;
var touch = e.touches ? e.touches[0] : (e.changedTouches ? e.changedTouches[0] : null);
if (!touch) return;
var x = touch.clientX / _canvasW;
var y = 1 - touch.clientY / _canvasH;
var opts = randomShell(_config.shellSize);
this._injectWordBurst(opts);
var shell = new Shell(opts);
shell.launch(x, y, _canvasW, _canvasH);
},
// === Canvas 初始化 ===
initCanvas: function () {
var self = this;
// 等一帧确保 DOM 已渲染
setTimeout(function () {
// uni-app APP 端 <canvas> 被编译为 <uni-canvas>,无 getContext 方法
// 因此使用 <view> 容器 + 动态创建原生 <canvas> 元素
var container = document.querySelector('#canvasContainer');
if (!container) {
// 降级:尝试 class 查找
container = document.querySelector('.firework-canvas');
}
if (!container) {
console.error('[canvasWorker] canvas container not found');
return;
}
// 动态创建原生 HTML5 canvas 元素
var el = document.createElement('canvas');
el.style.position = 'absolute';
el.style.top = '0';
el.style.left = '0';
el.style.width = _canvasW + 'px';
el.style.height = _canvasH + 'px';
container.appendChild(el);
_canvas = el;
_ctx = el.getContext('2d');
// 设置物理像素尺寸
el.width = _canvasW * _dpr;
el.height = _canvasH * _dpr;
_ctx.scale(_dpr, _dpr);
// 首帧清除为透明
_ctx.clearRect(0, 0, _canvasW, _canvasH);
self.start();
}, 50);
},
// === 动画控制 ===
start: function () {
if (_running) return;
_running = true;
_lastTime = Date.now();
_currentFrame = 0;
this.scheduleFrame();
if (_config && _config.autoLaunch) {
this.scheduleAutoLaunch();
}
},
stop: function () {
_paused = true;
_running = false;
if (_rafId) {
cancelAnimationFrame(_rafId);
_rafId = null;
}
if (_autoTimer) {
clearTimeout(_autoTimer);
_autoTimer = null;
}
},
/** 清屏:清除所有粒子和画布内容,不影响动画循环 */
clearScreen: function () {
// 清除所有 Star 粒子
COLOR_CODES_W_INVIS.forEach(function (color) {
var stars = Star.active[color];
while (stars.length) {
Star.returnInstance(stars.pop());
}
});
// 清除所有 Spark 粒子
COLOR_CODES_W_INVIS.forEach(function (color) {
var sparks = Spark.active[color];
while (sparks.length) {
Spark.returnInstance(sparks.pop());
}
});
// 清除所有 BurstFlash
while (BurstFlash.active.length) {
BurstFlash.returnInstance(BurstFlash.active.pop());
}
// 清除画布
if (_ctx) {
_ctx.clearRect(0, 0, _canvasW, _canvasH);
}
_idleFrames = 0;
},
/** 切换暂停/继续发射烟花(帧循环不停,已有粒子自然消亡) */
togglePause: function () {
_paused = !_paused;
if (_paused) {
// 暂停:取消自动发射定时器
if (_autoTimer) {
clearTimeout(_autoTimer);
_autoTimer = null;
}
// 清除画布残留粒子与画面
this.clearScreen();
} else {
// 继续:清屏后全新启动烟花(非恢复上一次)
this.clearScreen();
if (_running) {
// 帧循环仍在运行,先停掉
if (_rafId) {
cancelAnimationFrame(_rafId);
_rafId = null;
}
if (_autoTimer) {
clearTimeout(_autoTimer);
_autoTimer = null;
}
_running = false;
}
this.start();
}
},
scheduleFrame: function () {
if (!_running) return;
var self = this;
_rafId = requestAnimationFrame(function () {
if (!_running) return;
var now = Date.now();
var frameTime = now - _lastTime;
_lastTime = now;
if (frameTime < 0) frameTime = 17;
else if (frameTime > 68) frameTime = 68;
var lag = frameTime / 16.6667;
self.update(frameTime, lag);
self.scheduleFrame();
});
},
scheduleAutoLaunch: function () {
if (!_running || !_config || !_config.autoLaunch || _paused) return;
var self = this;
var min = _config.autoLaunchInterval[0];
var max = _config.autoLaunchInterval[1];
var delay = Math.random() * (max - min) + min;
_autoTimer = setTimeout(function () {
if (!_running || _paused) return;
self.launchRandomShell();
self.scheduleAutoLaunch();
}, delay);
},
launchRandomShell: function () {
if (!_config) return;
var baseSize = _config.shellSize;
var maxVariance = Math.min(2.5, baseSize);
var variance = Math.random() * maxVariance;
var size = baseSize - variance;
var height = maxVariance === 0 ? Math.random() : 1 - variance / maxVariance;
var centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
var x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
var edge = 0.18;
x = (1 - edge * 2) * x + edge;
height = height * 0.75;
var opts = randomShell(size);
this._injectWordBurst(opts);
var shell = new Shell(opts);
shell.launch(x, height, _canvasW, _canvasH);
},
// === 物理更新 ===
update: function (frameTime, lag) {
var width = _canvasW;
var height = _canvasH;
var timeStep = frameTime;
var speed = lag;
_currentFrame++;
var currentFrame = _currentFrame;
var starDrag = 1 - (1 - Star.airDrag) * speed;
var starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
var sparkDrag = 1 - (1 - Spark.airDrag) * speed;
var gAcc = (timeStep / 1000) * GRAVITY;
COLOR_CODES_W_INVIS.forEach(function (color) {
var stars = Star.active[color];
for (var i = stars.length - 1; i >= 0; i--) {
var star = stars[i];
if (star.updateFrame === currentFrame) continue;
star.updateFrame = currentFrame;
star.life -= timeStep;
if (star.life <= 0) {
stars.splice(i, 1);
Star.returnInstance(star);
} else {
var burnRate = Math.pow(star.life / star.fullLife, 0.5);
var burnRateInverse = 1 - burnRate;
star.prevX = star.x;
star.prevY = star.y;
star.x += star.speedX * speed;
star.y += star.speedY * speed;
if (!star.heavy) {
star.speedX *= starDrag;
star.speedY *= starDrag;
} else {
star.speedX *= starDragHeavy;
star.speedY *= starDragHeavy;
}
if (star.isWord) {
star.speedY += gAcc * 0.08;
} else {
star.speedY += gAcc;
}
if (star.spinRadius) {
star.spinAngle += star.spinSpeed * speed;
star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
}
if (star.sparkFreq) {
star.sparkTimer -= timeStep;
while (star.sparkTimer < 0) {
star.sparkTimer +=
star.sparkFreq * 0.75 +
star.sparkFreq * burnRateInverse * 4;
Spark.add(
star.x,
star.y,
star.sparkColor,
Math.random() * PI_2,
Math.random() * star.sparkSpeed * burnRate,
star.sparkLife * 0.8 +
Math.random() * star.sparkLifeVariation * star.sparkLife,
);
}
}
if (star.life < star.transitionTime) {
if (star.secondColor && !star.colorChanged) {
star.colorChanged = true;
star.color = star.secondColor;
stars.splice(i, 1);
Star.active[star.secondColor].push(star);
if (star.secondColor === INVISIBLE) {
star.sparkFreq = 0;
}
}
if (star.strobe) {
star.visible =
Math.floor(star.life / star.strobeFreq) % 3 === 0;
}
}
}
}
var sparks = Spark.active[color];
for (var j = sparks.length - 1; j >= 0; j--) {
var spark = sparks[j];
spark.life -= timeStep;
if (spark.life <= 0) {
sparks.splice(j, 1);
Spark.returnInstance(spark);
} else {
spark.prevX = spark.x;
spark.prevY = spark.y;
spark.x += spark.speedX * speed;
spark.y += spark.speedY * speed;
spark.speedX *= sparkDrag;
spark.speedY *= sparkDrag;
spark.speedY += gAcc;
}
}
});
this.render(speed);
},
// === 渲染 ===
render: function (speed) {
var width = _canvasW;
var height = _canvasH;
var ctx = _ctx;
if (!ctx) return;
// 空闲清零机制:粒子池全部为空时,累计空闲帧,达到阈值后全量清黑消除残留
var hasParticles = Star._totalCount > 0 || Spark._totalCount > 0 || BurstFlash.active.length > 0;
if (!hasParticles) {
_idleFrames++;
if (_idleFrames >= 20) {
ctx.clearRect(0, 0, width, height);
return;
}
} else {
_idleFrames = 0;
}
// destination-in 透明度衰减 - 实现拖尾渐隐(透明背景)
ctx.globalCompositeOperation = 'destination-in';
ctx.globalAlpha = 1 - 0.22 * speed;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, width, height);
// 周期性深度衰减:每 120 帧大幅降低残留像素的透明度
if (_currentFrame % 120 === 0) {
ctx.globalAlpha = 0.5;
ctx.fillRect(0, 0, width, height);
}
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
while (BurstFlash.active.length) {
var bf = BurstFlash.active.pop();
var grd = ctx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
grd.addColorStop(0, 'rgba(255,255,255,0.6)');
grd.addColorStop(0.2, 'rgba(255,160,20,0.15)');
grd.addColorStop(1, 'rgba(255,120,20,0)');
ctx.fillStyle = grd;
ctx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);
BurstFlash.returnInstance(bf);
}
COLOR_CODES.forEach(function (color) {
var stars = Star.active[color];
if (stars.length === 0) return;
ctx.strokeStyle = color;
ctx.lineCap = 'round';
for (var i = 0, len = stars.length; i < len; i++) {
var star = stars[i];
if (star.visible) {
ctx.lineWidth = star.size;
ctx.beginPath();
ctx.moveTo(star.x, star.y);
ctx.lineTo(star.prevX, star.prevY);
ctx.stroke();
}
}
});
var sparkW = Spark.drawWidth;
COLOR_CODES.forEach(function (color) {
var sparks = Spark.active[color];
if (sparks.length === 0) return;
ctx.strokeStyle = color;
ctx.lineWidth = sparkW;
ctx.lineCap = 'butt';
ctx.beginPath();
for (var j = 0, sLen = sparks.length; j < sLen; j++) {
ctx.moveTo(sparks[j].x, sparks[j].y);
ctx.lineTo(sparks[j].prevX, sparks[j].prevY);
}
ctx.stroke();
});
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.beginPath();
COLOR_CODES.forEach(function (color) {
var stars = Star.active[color];
for (var k = 0, kLen = stars.length; k < kLen; k++) {
var star = stars[k];
if (star.visible) {
ctx.moveTo(star.x, star.y);
ctx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
}
}
});
ctx.stroke();
},
// === 文字烟花 ===
literalLattice: function (text, density, fontFamily, fontSizePx) {
if (!density) density = 3;
if (!fontFamily) fontFamily = 'sans-serif';
if (!fontSizePx) fontSizePx = 90;
var key = text + '|' + density + '|' + fontFamily + '|' + fontSizePx;
if (_wordCache && _wordCache[key]) {
return _wordCache[key];
}
if (!_wordCanvas || !_wordCtx) return null;
var ctx = _wordCtx;
// 动态 pad:基于字号的 40%,最小 24px,确保大字号不被裁切
var pad = Math.max(24, Math.ceil(fontSizePx * 0.4));
ctx.font = fontSizePx + 'px ' + fontFamily;
var metrics = ctx.measureText(text);
// 优先使用 TextMetrics 精确边界,降级到 advance width + pad
var w, h, offsetX, offsetY;
if (metrics.actualBoundingBoxLeft !== undefined && metrics.actualBoundingBoxAscent !== undefined) {
// 精确模式:使用实际渲染边界
var bboxW = Math.ceil(metrics.actualBoundingBoxLeft + metrics.actualBoundingBoxRight);
var bboxH = Math.ceil(metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent);
w = bboxW + pad;
h = bboxH + pad;
offsetX = pad / 2 + Math.ceil(metrics.actualBoundingBoxLeft);
offsetY = pad / 2 + Math.ceil(metrics.actualBoundingBoxAscent);
} else {
// 降级模式:advance width + 加大余量
w = Math.ceil(metrics.width) + pad;
h = Math.ceil(fontSizePx * 1.3) + pad;
offsetX = pad / 2;
offsetY = pad / 2;
}
_wordCanvas.width = w;
_wordCanvas.height = h;
ctx.clearRect(0, 0, w, h);
ctx.font = fontSizePx + 'px ' + fontFamily;
// 精确模式用 alphabetic 基线配合 offsetY;降级模式用 top 基线
if (metrics.actualBoundingBoxLeft !== undefined && metrics.actualBoundingBoxAscent !== undefined) {
ctx.textBaseline = 'alphabetic';
} else {
ctx.textBaseline = 'top';
}
ctx.fillStyle = '#fff';
ctx.fillText(text, offsetX, offsetY);
var img = ctx.getImageData(0, 0, w, h);
var points = [];
for (var yy = 0; yy < h; yy += density) {
for (var xx = 0; xx < w; xx += density) {
var idx = (yy * w + xx) * 4;
if (img.data[idx + 3] > 0) {
points.push({x: xx, y: yy});
}
}
}
var res = {width: w, height: h, points: points};
if (_wordCache) {
_wordCache[key] = res;
}
return res;
},
randomWord: function () {
if (!_config) return '';
var arr = _config.textContent || [];
if (!arr.length) return '';
return arr[(Math.random() * arr.length) | 0];
},
createWordBurstAt: function (cx, cy) {
if (!_config || !_config.wordShell) return;
var word = this.randomWord();
if (!word) return;
if (!_wordCanvas || !_wordCtx) return;
var fontSize = Math.floor(Math.random() * 24 + 64);
var density = 5;
var fontFamily = 'sans-serif';
// 动态 density 降级:粒子池剩余空间不足时降低采样密度
var available = Star.MAX_TOTAL - Star._totalCount;
if (available < 200) {
density = 3;
}
var map = this.literalLattice(word, density, fontFamily, fontSize);
if (!map || !map.points || !map.points.length) return;
var dcenterX = map.width / 2;
var dcenterY = map.height / 2;
// 动态缩放:当文字点阵超过画布可用区域时,等比缩小,防止长文字溢出
var margin = 10;
var availW = _canvasW - margin * 2;
var availH = _canvasH - margin * 2;
var scale = Math.min(1, availW / map.width, availH / map.height);
var scaledHalfW = dcenterX * scale;
var scaledHalfH = dcenterY * scale;
// 边界钳制:使用缩放后的半宽/半高确保文字粒子不超出 canvas 可视区域
var minY = scaledHalfH + margin;
var maxY = _canvasH - scaledHalfH - margin;
if (minY > maxY) {
cy = _canvasH / 2;
} else {
if (cy < minY) cy = minY;
if (cy > maxY) cy = maxY;
}
var minX = scaledHalfW + margin;
var maxX = _canvasW - scaledHalfW - margin;
if (minX > maxX) {
cx = _canvasW / 2;
} else {
if (cx < minX) cx = minX;
if (cx > maxX) cx = maxX;
}
var color = randomColor();
var starLife = 2200;
var starLifeVariation = 0.08;
var hasTwoColors = Math.random() < 0.5;
var secondColor = hasTwoColors ? randomColor() : null;
var needCount = map.points.length;
var available = Star.MAX_TOTAL - Star._totalCount;
if (available < needCount) {
this._evictParticles(Star, needCount - available);
}
for (var i = 0; i < map.points.length; i++) {
var p = map.points[i];
var x = cx + (p.x - dcenterX) * scale;
var y = cy + (p.y - dcenterY) * scale;
var spd = Math.random() * 0.06 + 0.02;
var life = starLife + Math.random() * starLife * starLifeVariation + spd * 800;
var s = Star.add(x, y, color, Math.random() * Math.PI * 2, spd, life, 0, 0, 2.5);
if (s) {
s.isWord = true;
s.transitionTime = starLife * (Math.random() * 0.05 + 0.2);
s.strobe = false;
if (secondColor) {
s.secondColor = secondColor;
}
}
}
},
_evictParticles: function (pool, need) {
if (need <= 0) return;
// 第一阶段:淘汰普通粒子(非文字粒子),按 life 升序
var normalCandidates = [];
COLOR_CODES_W_INVIS.forEach(function (color) {
var arr = pool.active[color];
for (var i = 0; i < arr.length; i++) {
var p = arr[i];
if (p.isWord) continue;
normalCandidates.push({life: p.life, color: color, ref: p});
}
});
normalCandidates.sort(function (a, b) {
return a.life - b.life;
});
var evicted = 0;
for (var k = 0; k < normalCandidates.length && evicted < need; k++) {
var c = normalCandidates[k];
var arr = pool.active[c.color];
var idx = arr.indexOf(c.ref);
if (idx !== -1) {
arr.splice(idx, 1);
pool.returnInstance(c.ref);
evicted++;
}
}
// 第二阶段:如果普通粒子不够,淘汰已过半生命的老文字粒子
if (evicted < need) {
var wordCandidates = [];
COLOR_CODES_W_INVIS.forEach(function (color) {
var arr = pool.active[color];
for (var i = 0; i < arr.length; i++) {
var p = arr[i];
if (!p.isWord) continue;
// 只淘汰已消耗超过 50% 生命的文字粒子
if (p.life > p.fullLife * 0.5) continue;
wordCandidates.push({life: p.life, color: color, ref: p});
}
});
wordCandidates.sort(function (a, b) {
return a.life - b.life;
});
for (var j = 0; j < wordCandidates.length && evicted < need; j++) {
var wc = wordCandidates[j];
var warr = pool.active[wc.color];
var widx = warr.indexOf(wc.ref);
if (widx !== -1) {
warr.splice(widx, 1);
pool.returnInstance(wc.ref);
evicted++;
}
}
}
},
/** 注入文字烟花回调到 Shell options */
_injectWordBurst: function (opts) {
if (!_config || !_config.wordShell) return;
var self = this;
opts.onWordBurst = function (x, y) {
self.createWordBurstAt(x, y);
};
opts.wordProbability = _config.wordProbability;
},
}
};
</script>
<!-- #endif -->
<style scoped>
.firework-container {
position: relative;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
overflow: hidden;
z-index: 100;
}
.firework-canvas {
position: absolute;
top: 0;
left: 0;
}
/* 操作按钮区域:悬浮在画布底部中间,不影响画布触摸交互 */
.firework-controls {
position: absolute;
bottom: 60rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
z-index: 200;
pointer-events: auto;
}
.firework-btn {
min-width: 120rpx;
height: 68rpx;
padding: 0 28rpx;
border-radius: 34rpx;
background-color: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.35);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(8px);
}
.firework-btn:active {
background-color: rgba(255, 255, 255, 0.35);
}
.firework-btn-danger {
background-color: rgba(255, 77, 79, 0.3);
border-color: rgba(255, 77, 79, 0.5);
}
.firework-btn-danger:active {
background-color: rgba(255, 77, 79, 0.55);
}
.firework-btn-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
line-height: 68rpx;
}
</style>
更多推荐
所有评论(0)