弹幕留言墙

输入文字即可发射彩色弹幕,支持随机速度 / 颜色 / 位置,可接入直播间或留言墙。

弹幕区 · 发一条试试

弹幕使用 CSS transform 动画驱动,配合 requestAnimationFrame 移除越界元素,性能友好。

使用体验

  1. 在输入框输入内容(≤40 字),点击“发射”,弹幕从右向左飞过。
  2. 每条弹幕随机分配颜色(取自预设色板)、纵向位置与速度;点击预设词条可快速体验。
  3. “清屏”立即移除全部弹幕。演示仅本地展示,接入真实弹幕需 WebSocket 广播。

代码要点

js
const COLORS = ["#6fb3a8", "#7aa2f7", "#e0af68", "#f7768e", "#9ece6a"];

function fire(text) {
  const el = document.createElement("div");
  el.className = "dm-item";
  el.textContent = text;
  el.style.color = COLORS[Math.floor(Math.random() * COLORS.length)];
  el.style.top = Math.random() * 82 + "%";       // 纵向随机(避开顶部 18%)
  const speed = 90 + Math.random() * 120;         // px/s
  const startX = stage.clientWidth;
  const endX = -el.offsetWidth;
  el.style.left = startX + "px";
  stage.appendChild(el);

  let x = startX, last = performance.now();
  (function step(now) {
    const dt = (now - last) / 1000; last = now;
    x -= speed * dt;
    el.style.transform = "translateX(" + (x - startX) + "px)";
    if (x > endX) requestAnimationFrame(step);
    else el.remove();                             // 越界即移除,避免 DOM 膨胀
  })(last);
}

完整可运行工程

可直接运行纯前端 · 零依赖双击 index.html 即用

把演示区的「弹幕留言墙」抽成独立工程:样式随机(颜色 / 速度 / 高度)+ requestAnimationFrame 推进 + 出画回收,另附接直播间的改造点。

danmaku-wall/ ├─ index.html 输入区 + 弹幕舞台 ├─ app.js 样式随机 + 推进回收 ├─ styles.css 样式(跟随系统深浅色) └─ README.md 运行方式与扩展点

index.html

输入框 / 速度选择 / 弹幕舞台

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>弹幕留言墙 · 独立工程版</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main class="wrap">
    <h1>弹幕留言墙</h1>
    <p class="sub">输入文字即可发射彩色弹幕,随机速度 / 颜色 / 高度。</p>
    <section class="panel">
      <div class="row">
        <input id="text" type="text" maxlength="40" placeholder="说点什么…" style="flex:1;min-width:180px">
        <select id="speed">
          <option value="normal">速度:随机</option>
          <option value="slow">速度:慢</option>
          <option value="fast">速度:快</option>
        </select>
        <button id="send" type="button" class="btn">发射</button>
        <button id="clear" type="button" class="btn ghost">清空</button>
      </div>
      <p class="msg" id="status">已发射 0 条。</p>
    </section>
    <h2>弹幕舞台</h2>
    <div class="panel" style="position:relative;overflow:hidden;height:260px" id="stage"></div>
  </main>
  <script src="app.js"></script>
</body>
</html>

app.js

样式随机(pickStyle 可测)+ 推进与回收

javascript
/* app.js —— 弹幕留言墙:样式随机 + 舞台推进(node 中可 require 做逻辑自测) */
var COLORS = ["#6fb3a8", "#7aa2f7", "#e0af68", "#f7768e", "#9ece6a", "#bb9af7"];

/* pickStyle:纯函数(rand 可注入,便于测试),返回一条弹幕的随机外观 */
function pickStyle(rand, speedMode) {
  rand = rand || Math.random;
  var base = speedMode === "slow" ? 40 : speedMode === "fast" ? 220 : 60 + rand() * 140;
  return {
    color: COLORS[Math.floor(rand() * COLORS.length) % COLORS.length],
    speed: Math.round(base),
    top: Math.floor(rand() * 80) + 8
  };
}

function makeDanmaku(text, style, stageWidth) {
  return { text: text, color: style.color, speed: style.speed, top: style.top, x: stageWidth };
}

if (typeof module !== "undefined" && module.exports) {
  module.exports = { COLORS: COLORS, pickStyle: pickStyle, makeDanmaku: makeDanmaku };
}

/* ---- 浏览器交互层 ---- */
if (typeof document !== "undefined") {
  document.addEventListener("DOMContentLoaded", function () {
    var stage = document.getElementById("stage"), input = document.getElementById("text"),
        sendBtn = document.getElementById("send"), clearBtn = document.getElementById("clear"),
        speedSel = document.getElementById("speed"), status = document.getElementById("status");
    var items = [], sent = 0, raf = 0, last = 0;

    function spawn(text) {
      var style = pickStyle(Math.random, speedSel.value);
      var d = makeDanmaku(text || "弹幕 " + (sent + 1), style, stage.clientWidth);
      var el = document.createElement("span");
      el.textContent = d.text;
      el.style.cssText = "position:absolute;white-space:nowrap;font-size:15px;font-weight:600;" +
        "color:" + d.color + ";top:" + d.top + "%;left:" + d.x + "px;text-shadow:0 1px 2px rgba(0,0,0,.35)";
      stage.appendChild(el);
      items.push({ d: d, el: el });
      sent++;
      status.textContent = "已发射 " + sent + " 条。";
    }

    function step(now) {
      var dt = last ? (now - last) / 1000 : 0;
      last = now;
      for (var i = items.length - 1; i >= 0; i--) {
        var it = items[i];
        it.d.x -= it.d.speed * dt;
        it.el.style.left = it.d.x + "px";
        if (it.d.x < -it.el.offsetWidth) {
          it.el.remove();
          items.splice(i, 1);
        }
      }
      raf = requestAnimationFrame(step);
    }

    sendBtn.addEventListener("click", function () {
      var v = input.value.trim();
      if (!v) { input.focus(); return; }
      spawn(v);
      input.value = "";
      input.focus();
    });
    input.addEventListener("keydown", function (e) { if (e.key === "Enter") sendBtn.click(); });
    clearBtn.addEventListener("click", function () {
      items.forEach(function (it) { it.el.remove(); });
      items = [];
      status.textContent = "已清空(累计发射 " + sent + " 条)。";
    });
    raf = requestAnimationFrame(step);
  });
}

styles.css

样式:跟随系统深浅色

css
:root {
  --bg: #0e1014; --panel: #171a20; --ink: #e8ecf1; --muted: #9aa4b2; --line: #262b33; --brand: #6fb3a8;
}
@media (prefers-color-scheme: light) {
  :root { --bg: #f6f7f8; --panel: #ffffff; --ink: #1a1d21; --muted: #5b636e; --line: #e3e6ea; }
}
* { box-sizing: border-box; }
body {
  margin: 0; padding: 24px 16px 48px; background: var(--bg); color: var(--ink);
  font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
.wrap { max-width: 760px; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 6px; }
h2 { font-size: 1.05rem; margin: 22px 0 10px; }
.sub { color: var(--muted); margin: 0 0 18px; }
.panel, .log-wrap { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; }
.q { font-size: 1.06rem; margin: 0 0 12px; }
.btns, .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.btn {
  padding: 8px 16px; border-radius: 8px; border: 1px solid var(--brand);
  background: var(--brand); color: #0b0f12; cursor: pointer; font-size: .92rem;
}
.btn.ghost { background: transparent; color: var(--brand); }
.msg { color: var(--muted); margin: 12px 0 0; font-size: .92rem; }
.log { max-height: 200px; overflow: auto; font-size: .88rem; color: var(--muted); }
.log div { padding: 3px 0; border-bottom: 1px dashed var(--line); }
input, select {
  padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line);
  background: var(--bg); color: var(--ink); font: inherit;
}
canvas { width: 100%; display: block; border-radius: 10px; }

README.md

运行方式与接入 WebSocket 的改造点

markdown
# 弹幕留言墙(独立可运行工程)

作品站 fun 区「弹幕留言墙」演示的独立工程版:纯前端、零依赖。

## 文件
- `index.html`:输入框 + 速度选择 + 舞台
- `app.js`:样式随机(颜色 / 速度 / 高度)、弹幕推进与回收
- `styles.css`:样式

## 运行
1. 双击 `index.html`(或 `python -m http.server 8000`)
2. 逻辑自测:`node -e "console.log(require('./app.js').pickStyle(() => 0.5, 'normal'))"`
   → 期望 `{ color: ..., speed: 130, top: 48 }`

## 扩展点
- 接入直播间:把 `spawn()` 换成 WebSocket 消息回调即可(服务端推送 `{ text }`)
- 长弹幕防重叠:按 `top` 分轨道,记录每条弹幕的"尾部到达时间"再分配轨道

运行方式

  1. 新建一个目录,把上面全部文件按文件名分别保存(用「一键复制整份工程」可一次拿全)。
  2. 前端部分:直接双击 index.html 即可运行;若浏览器限制本地文件读取,执行 python -m http.server 8000 后访问 http://localhost:8000/。
  3. 逻辑自测:各文件 README.md 中给出了对应的 Node / Python 自测命令(均为真实可跑的最小用例)。

标注:可直接运行、无需构建;要改造只需动 app.js 顶部的常量与 styles.css 里的变量。

依赖清单:无第三方依赖 —— 纯前端(Canvas + DOM),无需安装任何包

可直接运行:可直接运行 双击 index.html 即可发弹幕;命令行自测 node -e "console.log(require('./app.js').pickStyle(() => 0.5, 'normal'))"

需先改的配置项:无配置项需要修改。要接直播间时把 spawn() 换成 WebSocket 消息回调

复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考

实现涉及的官方文档(可直接访问):