简要说明:

分为两版,一版比较粗糙的,临时接到需求,加工改造的;另一版,比较细腻一点;

核心方法思想,参考的项目:

https://github.com/liujie2345/-

需要注意的点:

  • ...

粗糙版的:

这个文件我是作为组件来使用的,如果影响到主包的体积,可以尝试跨分包加载;

需求是:作为一个蒙版盖在首页上的,这就要求有合适的透明度、不能妨碍首页的点击事件的触发——上面的简要说明中已经提及;

<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>

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐