JavaScript+Canvas实现一个烟花文字效果

564 阅读6分钟

最近又捣鼓了一个好玩的效果,这里赶紧分享给大家。
简单来说就是可以把输入的文字,像放烟花一样在空中绽放。
其实这个想法早就有过,之前也搞过一次;效果不是很好;最近请了AI小助理,和小助理一起努努力,又搞了现在这版;感觉效果挺好的。
下面介绍下。

一、3 秒玩转:输入→发射→看烟花绽放

  • 输入框敲入想说的话(如「某同学我爱你」),点击「确定」;
  • 文字秒变彩色粒子,升空→爆炸→飘散,背景还有随机彩蛋烟花;
  • 点击「分享」生成专属链接,朋友打开直接看你的定制烟花!

动画6.gif

二、技术亮点

  1. 像素级文字拆解:每个笔画化作独立粒子,爆炸时拼成清晰字符轮廓;
  2. 三阶段动画:上升→爆炸→飘散,粒子带物理模拟效果(减速、旋转);
  3. 多设备适配:手机 / 电脑都可以看;
  4. encode处理:分享的url上做了encode对文字处理;从url上看不出啥文字;
  5. 随机元素: 烟花颜色随机、位置随机。

三、怎么玩?

  • 表白:输入情话,让 TA 见证文字炸成星空;
  • 节日祝福:春节发「恭喜发财」,朋友生日发「xxx生日快乐」,特效随文字变色;

四、下面老规矩放源码;直接复制去玩。

<!DOCTYPE html>
<html lang="zh-cn">
<head>
  <meta charset="UTF-8">
  <title>文字烟花</title>
  <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
  <style>
    html, body {
      margin: 0;
      padding: 0;
      width: 100vw;
      height: 100vh;
      overflow: hidden;
      background: #000;
      font-size: 16px;
      -webkit-tap-highlight-color: transparent;
    }
    #ui {
      position: absolute;
      bottom: 0;
      left: 50%;
      transform: translateX(-50%);
      z-index: 10;
      background: rgba(20, 24, 40, 0.85);
      padding: 10px 8vw 18px 8vw;
      border-radius: 24px 24px 0 0;
      box-shadow: 0 -2px 24px #000a;
      color: #fff;
      font-size: 1rem;
      display: flex;
      flex-direction: column;
      gap: 8px;
      align-items: center;
      border: 1.5px solid rgba(80,120,255,0.18);
      backdrop-filter: blur(2px);
      width: 100vw;
      max-width: 280px;
      min-width: 0;
    }
    #textInput {
      font-size: 1.1rem;
      padding: 10px 16px;
      border-radius: 18px;
      border: 1.5px solid #3a4a7a;
      outline: none;
      background: rgba(30,40,70,0.85);
      color: #fff;
      transition: border 0.2s, box-shadow 0.2s;
      box-shadow: 0 2px 8px #0004 inset;
      width: 64vw;
      max-width: 340px;
      min-width: 0;
    }
    #launchBtn, #shareBtn {
      font-size: 1.1rem;
      padding: 10px 0;
      border-radius: 18px;
      border: none;
      background: linear-gradient(90deg,#3a8dde 0%,#7ecfff 100%);
      color: #fff;
      cursor: pointer;
      font-weight: bold;
      letter-spacing: 2px;
      box-shadow: 0 2px 12px #3a8dde44;
      transition: background 0.2s, box-shadow 0.2s;
      width: 38vw;
      max-width: 150px;
      min-width: 80px;
      margin: 0 2vw;
    }
    #launchBtn:hover, #shareBtn:hover {
      background: linear-gradient(90deg,#7ecfff 0%,#3a8dde 100%);
      box-shadow: 0 4px 18px #7ecfff66;
    }
    #canvas {
      display: block;
      width: 100vw;
      height: 100vh;
      touch-action: none;
    }
    @media (max-width: 600px) {
      #ui {
        padding: 8px 2vw 12px 2vw;
        max-width: 100vw;
        border-radius: 18px 18px 0 0;
      }
      #textInput {
        font-size: 1rem;
        padding: 8px 8px;
        width: 80vw;
        max-width: 98vw;
      }
      #launchBtn, #shareBtn {
        font-size: 1rem;
        padding: 8px 0;
        width: 38vw;
        max-width: 120px;
        min-width: 60px;
      }
    }
  </style>
</head>
<body>
  <div id="ui">
    <input id="textInput" type="text" placeholder="输入你想要的文字" maxlength="100" style="font-size:18px;padding:4px 8px;" />
    <div style="display:flex;gap:12px;justify-content:center;margin-top:6px;">
      <button id="launchBtn" style="font-size:18px;">确定</button>
      <button id="shareBtn" style="font-size:18px;background:linear-gradient(90deg,#3a8dde 0%,#7ecfff 100%);color:#fff;cursor:pointer;font-weight:bold;letter-spacing:2px;box-shadow:0 2px 12px #3a8dde44;border:none;padding:8px 28px;border-radius:18px;transition:background 0.2s,box-shadow 0.2s;">分享</button>
    </div>
  </div>
  <canvas id="canvas"></canvas>
  <script>
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');
    let W = window.innerWidth, H = window.innerHeight;
    function resize() {
      W = window.innerWidth;
      H = window.innerHeight;
      canvas.width = W;
      canvas.height = H;
    }
    window.addEventListener('resize', resize);
    resize();

    // 粒子类
    class Particle {
      constructor(x, y, color, vx, vy, size, alpha, fade, gravity = 0.02) {
        this.x = x;
        this.y = y;
        this.color = color;
        this.vx = vx;
        this.vy = vy;
        this.size = size;
        this.alpha = alpha;
        this.fade = fade;
        this.gravity = gravity;
      }
      update() {
        this.x += this.vx;
        this.y += this.vy;
        this.vy += this.gravity;
        this.alpha -= this.fade;
      }
      draw(ctx) {
        ctx.save();
        ctx.globalAlpha = Math.max(this.alpha, 0);
        ctx.beginPath();
        ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
        ctx.fillStyle = this.color;
        ctx.fill();
        ctx.restore();
      }
      isAlive() {
        return this.alpha > 0;
      }
    }

    // 烟花上升类
    class AscendFirework {
      constructor(sx, sy, tx, ty, color, textChar) {
        this.x = sx;
        this.y = sy;
        this.tx = tx;
        this.ty = ty;
        this.color = color;
        this.textChar = textChar;
        this.vx = (tx - sx) / 36;
        this.vy = (ty - sy) / 36;
        this.age = 0;
        this.exploded = false;
      }
      update() {
        if (!this.exploded) {
          this.x += this.vx;
          this.y += this.vy;
          this.age++;
          if ((Math.abs(this.x - this.tx) < 2 && Math.abs(this.y - this.ty) < 2) || this.age > 40) {
            this.exploded = true;
            return true;
          }
        }
        return false;
      }
      draw(ctx) {
        if (!this.exploded) {
          ctx.save();
          ctx.beginPath();
          ctx.arc(this.x, this.y, 3, 0, Math.PI * 2);
          ctx.fillStyle = this.color;
          ctx.shadowColor = this.color;
          ctx.shadowBlur = 12;
          ctx.fill();
          ctx.restore();
        }
      }
    }

    // 文字烟花粒子
    function createTextParticles(text, x, y, color) {
      const offCanvas = document.createElement('canvas');
      const offCtx = offCanvas.getContext('2d');
      const fontSize = 144;
      offCanvas.width = fontSize * text.length;
      offCanvas.height = fontSize * 1.2;
      offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
      offCtx.font = `bold ${fontSize}px cursive`;
      offCtx.textAlign = 'center';
      offCtx.textBaseline = 'middle';
      offCtx.fillStyle = '#fff';
      offCtx.fillText(text, offCanvas.width / 2, offCanvas.height / 2);
      const imageData = offCtx.getImageData(0, 0, offCanvas.width, offCanvas.height);
      const particles = [];
      for (let i = 0; i < imageData.width; i += 6) { // 步长由4改为6,减少粒子
        for (let j = 0; j < imageData.height; j += 6) {
          const idx = (j * imageData.width + i) * 4;
          if (imageData.data[idx + 3] > 128) {
            const tx = x - offCanvas.width / 2 + i;
            const ty = y - offCanvas.height / 2 + j;
            const sx = x;
            const sy = y;
            const duration = 32;
            const vx = (tx - sx) / duration;
            const vy = (ty - sy) / duration;
            const p = new Particle(sx, sy, color, vx, vy, 2.2, 1, 0.012 + Math.random() * 0.01, 0);
            p.tx = tx;
            p.ty = ty;
            p.explodeFrame = 0;
            p.state = 'explode';
            p.holdFrame = 0;
            p.disperseFrame = 0;
            particles.push(p);
          }
        }
      }
      return particles;
    }

    // 烟花爆炸粒子
    function createFirework(x, y, color, count = 40, speed = 4, size = 2) {
      const particles = [];
      for (let i = 0; i < count; i++) {
        const angle = (Math.PI * 2) * (i / count);
        const vx = Math.cos(angle) * (Math.random() * speed * 0.7 + speed * 0.3);
        const vy = Math.sin(angle) * (Math.random() * speed * 0.7 + speed * 0.3);
        particles.push(new Particle(x, y, color, vx, vy, size, 1, 0.015 + Math.random() * 0.01));
      }
      return particles;
    }

    // 随机颜色
    function randomColor() {
      const colors = ['#ff5252', '#ffd740', '#40c4ff', '#69f0ae', '#fff176', '#b388ff', '#ff80ab', '#fff'];
      return colors[Math.floor(Math.random() * colors.length)];
    }

    // 背景烟花
    let bgFireworks = [];
    let bgAscendFireworks = [];
    function launchBgFirework() {
      const tx = Math.random() * W * 0.9 + W * 0.05;
      const ty = Math.random() * H * 0.4 + H * 0.1;
      const sx = tx + (Math.random() - 0.5) * 80;
      const sy = H;
      const color = randomColor();
      bgAscendFireworks.push(new AscendFirework(sx, sy, tx, ty, color, null));
    }
    setInterval(() => {
      if (bgAscendFireworks.length < 8) launchBgFirework();
    }, 700);

    // 文字烟花流程
    let textQueue = [];
    let textParticles = [];
    let isTextFirework = false;
    let textCurrentChar = '';
    let textCurrentColor = '';
    let textLoopStr = '';
    let textAnimating = false;
    let textAscendFireworks = [];

    function launchTextFirework(str) {
      textLoopStr = str;
      textQueue = str.split('');
      textParticles = [];
      isTextFirework = true;
      textCurrentChar = '';
      textCurrentColor = '';
      textAnimating = false;
      textAscendFireworks = [];
      nextTextFirework();
    }

    function nextTextFirework() {
      if (textQueue.length === 0) {
        textAnimating = true;
        return;
      }
      textCurrentChar = textQueue.shift();
      // 目标位置在上下70%-80%和左右33%-66%区域内随机
      const x = W * (0.33 + 0.33 * Math.random());
      const y = H * (0.1 + 0.1 * Math.random());
      textCurrentColor = randomColor();
      const sx = x + (Math.random() - 0.5) * 80;
      const sy = H;
      textAscendFireworks.push(new AscendFirework(sx, sy, x, y, textCurrentColor, textCurrentChar));
    }

    // 在动画主循环中增加粒子拖影效果
    function animate() {
      // 拖影效果:用半透明黑色覆盖
      ctx.globalAlpha = 0.22;
      ctx.fillStyle = '#000';
      ctx.fillRect(0, 0, W, H);
      ctx.globalAlpha = 1;
      // 背景烟花上升
      for (let i = bgAscendFireworks.length - 1; i >= 0; i--) {
        const fw = bgAscendFireworks[i];
        fw.draw(ctx);
        if (fw.update()) {
          bgFireworks.push(...createFirework(fw.x, fw.y, fw.color, 30 + Math.random() * 20, 2.5 + Math.random() * 1.5, 1.5 + Math.random()));
          bgAscendFireworks.splice(i, 1);
        }
      }
      // 绘制背景烟花爆炸
      bgFireworks = bgFireworks.filter(p => p.isAlive());
      for (const p of bgFireworks) {
        p.update();
        p.draw(ctx);
      }
      // 文字烟花上升
      for (let i = textAscendFireworks.length - 1; i >= 0; i--) {
        const fw = textAscendFireworks[i];
        fw.draw(ctx);
        if (fw.update()) {
          textParticles.push(...createTextParticles(fw.textChar, fw.x, fw.y, fw.color));
          textAscendFireworks.splice(i, 1);
          setTimeout(nextTextFirework, 300);
        }
      }
      // 文字烟花动画
      textParticles = textParticles.filter(p => p.isAlive());
      let allDisappear = true;
      for (const p of textParticles) {
        if (p.tx !== undefined && p.ty !== undefined) {
          if (p.state === 'explode') {
            // 爆炸阶段粒子带有更高透明度拖影
            ctx.save();
            ctx.globalAlpha = 0.7;
            p.x += p.vx;
            p.y += p.vy;
            p.explodeFrame++;
            p.draw(ctx);
            ctx.restore();
            if (p.explodeFrame >= 32) {
              p.x = p.tx;
              p.y = p.ty;
              p.state = 'hold';
            } else {
              allDisappear = false;
            }
          } else if (p.state === 'hold') {
            p.holdFrame++;
            p.alpha = 1;
            p.draw(ctx);
            if (p.holdFrame > 38) {
              const angle = Math.random() * Math.PI * 2;
              const speed = 3 + Math.random() * 2.5;
              p.vx = Math.cos(angle) * speed;
              p.vy = Math.sin(angle) * speed;
              p.state = 'disperse';
            } else {
              allDisappear = false;
            }
          } else if (p.state === 'disperse') {
            p.x += p.vx;
            p.y += p.vy;
            p.vx *= 0.96;
            p.vy *= 0.96;
            p.alpha *= 0.94;
            p.disperseFrame++;
            p.draw(ctx);
            if (p.alpha > 0.05) allDisappear = false;
          }
        } else {
          p.update();
          p.draw(ctx);
          allDisappear = false;
        }
      }
      // 所有字动画完全消失后再自动循环
      if (isTextFirework && textAnimating && allDisappear) {
        setTimeout(() => {
          if (textLoopStr) {
            textQueue = textLoopStr.split('');
            textParticles = [];
            isTextFirework = true;
            textCurrentChar = '';
            textCurrentColor = '';
            textAnimating = false;
            textAscendFireworks = [];
            nextTextFirework();
          }
        }, 1200);
        isTextFirework = false;
        textAnimating = false;
      }
      requestAnimationFrame(animate);
    }
    animate();

    // 简单base64加密
    function encodeText(str) {
      return btoa(unescape(encodeURIComponent(str)));
    }
    function decodeText(str) {
      try {
        return decodeURIComponent(escape(atob(str)));
      } catch(e) { return ''; }
    }
    // 交互
    const input = document.getElementById('textInput');
    const btn = document.getElementById('launchBtn');
    const shareBtn = document.getElementById('shareBtn');
    btn.onclick = () => {
      const val = input.value.trim();
      if (!val) return;
      launchTextFirework(val);
      // 更新URL参数(加密)
      const url = new URL(window.location.href);
      url.searchParams.set('text', encodeText(val));
      window.history.replaceState(null, '', url.toString());
    };
    input.addEventListener('keydown', e => {
      if (e.key === 'Enter') btn.click();
    });
    shareBtn.onclick = () => {
      const val = input.value.trim();
      const url = new URL(window.location.href);
      if (val) {
        url.searchParams.set('text', encodeText(val));
      } else {
        url.searchParams.delete('text');
      }
      navigator.clipboard.writeText(url.toString()).then(() => {
        shareBtn.textContent = '已复制!';
        setTimeout(()=>{shareBtn.textContent='分享';},1200);
      });
    };
    // 页面加载时自动播放默认或URL参数文字(解密)
    const urlTextRaw = new URLSearchParams(window.location.search).get('text');
    let urlText = '';
    if (urlTextRaw) {
      urlText = decodeText(urlTextRaw);
    }
    if (urlText) {
      input.value = urlText;
      launchTextFirework(urlText);
    } else {
      launchTextFirework('邢同学我爱你');
    }
    
  </script>
</body>
</html>

最后,我有把网页部署到vercel

点击看看大家能不能打开