Skip to content

完整教程:做一个礼物滚动榜

本教程基于 快速开始 的最小闭环,从零做出一个完整可用、可发布的挂件——「礼物滚动榜」:

  • 实时监听礼物事件,按时间倒序展示;
  • 可配置标题、最多显示条数、是否显示礼物图标;
  • 支持自定义强调色与背景色,挂件内用 CSS 变量直接消费;
  • 累计金额持久化,关掉再开还在;
  • 最后打包成 .zip 供他人安装。

预计耗时 30 分钟。完成后的产物可直接放进 示例集 的同位置作为参考。

1. 建立项目骨架

gift-roll/
├── manifest.json
├── index.html
├── thumbnail.png       ← 200×200 的预览图,挂件库列表展示
└── assets/
    └── ding.mp3        ← (可选)礼物到达提示音

完全可以不用任何构建工具,纯 HTML + CSS + JS 就够了。如果你喜欢用 Vite/TS,看 §7 用 Vite 做开发

2. 写 manifest.json

json
{
  "manifestVersion": 1,
  "id": "gift-roll",
  "name": "礼物滚动榜",
  "version": "1.0.0",
  "author": "你的名字",
  "description": "实时显示最新礼物记录,可配置最多条数与样式",
  "sdkVersion": 1,
  "thumbnail": "thumbnail.png",
  "defaultSize": { "width": 380, "height": 560 },
  "minSize": { "width": 220, "height": 240 },
  "category": "custom",
  "permissions": ["gifts"],

  "config": [
    { "key": "title", "type": "text", "label": "标题",
      "default": "礼物榜", "group": "基础" },
    { "key": "max",   "type": "slider", "label": "最多显示条数",
      "default": 20, "min": 5, "max": 50, "step": 1, "group": "基础" },
    { "key": "showIcon", "type": "switch", "label": "显示礼物图标",
      "default": true, "group": "基础" },
    { "key": "showTotal", "type": "switch", "label": "显示累计金额",
      "default": true, "group": "基础" }
  ],

  "theme": [
    { "key": "accent", "type": "color", "label": "强调色",
      "default": "#63e2b7", "alpha": true },
    { "key": "bg",     "type": "color", "label": "背景色",
      "default": "rgba(0,0,0,0.35)", "alpha": true },
    { "key": "text",   "type": "color", "label": "文字颜色",
      "default": "#ffffff" }
  ]
}

要点:

  • config vs theme:两者结构完全一样,区别只是宿主在配置面板上把它们放在「内容」与「样式」两个 Tab 下。不强求,但分开后用户更好找。
  • permissions: ["gifts"]:声明本挂件会用到礼物事件 / 礼物 RPC。当前版本不强制校验,但建议从一开始就声明,未来切到强制模式时挂件无需改动。
  • thumbnail:相对 manifest.json 所在目录的路径,挂件库列表里展示。建议正方形、≤ 50 KB。
  • category: "custom":决定挂件归属在哪一类元素菜单下。本教程做的是「礼物滚动榜」,与内置「礼物菜单」(GiftMenu)侧重点不同(一个是滚动记录,一个是横排展示当前可送礼物),归 custom 即可。如果做的是真正能替代内置加班机的挂件,则选 OvertimeMachine,用户在「素材 → 加班机」入口就能直接找到。取值直接复用 ElementType 字符串,完整列表见 manifest 规范 §2.1

完整字段表见 manifest 规范

3. 写 index.html:结构与样式

html
<!doctype html>
<html lang="zh">
<head>
  <meta charset="utf-8" />
  <style>
    html, body {
      margin: 0; height: 100%;
      background: transparent; overflow: hidden;
      font: 14px/1.5 system-ui, "Microsoft YaHei", sans-serif;
      color: var(--zbj-text);
    }
    .wrap {
      height: 100%; box-sizing: border-box;
      padding: 10px 14px;
      background: var(--zbj-bg);
      border-radius: 12px;
      backdrop-filter: blur(8px);
      display: flex; flex-direction: column;
    }
    .head {
      display: flex; align-items: baseline; justify-content: space-between;
      margin-bottom: 6px;
    }
    .title { font-size: 18px; font-weight: 700; color: var(--zbj-accent); }
    .total { font-size: 12px; opacity: .8; }

    ul.list { list-style: none; margin: 0; padding: 0; flex: 1; overflow: hidden; }
    li.item {
      display: flex; align-items: center; gap: 8px;
      padding: 6px 0;
      animation: enter .35s ease;
    }
    li.item img { width: 28px; height: 28px; border-radius: 6px; object-fit: cover; }
    li.item .nick { font-weight: 600; }
    li.item .gift { opacity: .85; }
    li.item .accent { color: var(--zbj-accent); margin: 0 2px; }
    @keyframes enter { from { opacity: 0; transform: translateX(-12px); } }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="head">
      <span class="title" id="title">礼物榜</span>
      <span class="total" id="total" hidden>累计 ¥0</span>
    </div>
    <ul class="list" id="list"></ul>
  </div>

  <script src="http://127.0.0.1:9855/widget-sdk/zbj-widget-sdk.js"></script>
  <script type="module" src="./app.js"></script>
</body>
</html>

注意所有的颜色、文字色都直接用 var(--zbj-accent) / var(--zbj-bg) / var(--zbj-text)。这就是 CSS 变量桥theme.accent 字段的值会被 SDK 写到 :root --zbj-accent主题面板调色 → 挂件 UI 实时变化,不需要写一行 JS。

4. 写 app.js:拿配置、订事件

js
ZBJ.ready(() => {
  const titleEl = document.getElementById("title");
  const totalEl = document.getElementById("total");
  const listEl  = document.getElementById("list");

  /* ── 1. 状态与渲染函数(必须先于 applyConfig 声明)─────────
   * applyConfig 内部会调用 renderTotal / pruneList,
   * 这两个函数读取 total 与 ZBJ.config。
   * `let total` 必须在 applyConfig() 之前完成初始化,否则会触发
   * "Cannot access 'total' before initialization" 的 TDZ 错误。 */
  let total = ZBJ.storage.get("total") ?? 0;

  function renderTotal() {
    if (!ZBJ.config.showTotal) return;
    totalEl.textContent = `累计 ¥${(total / 100).toFixed(0)}`;
  }

  function pruneList() {
    const max = Number(ZBJ.config.max) || 20;
    while (listEl.children.length > max) listEl.lastChild.remove();
  }

  /* ── 2. 配置同步 ─────────────────────────────────────── */
  function applyConfig() {
    titleEl.textContent = ZBJ.config.title;
    totalEl.hidden = !ZBJ.config.showTotal;
    pruneList();
    renderTotal();
  }
  applyConfig();
  ZBJ.onConfigChange(applyConfig);

  /* ── 3. 礼物事件 ─────────────────────────────────────── */
  ZBJ.on("gift", e => {
    const li = document.createElement("li");
    li.className = "item";

    if (ZBJ.config.showIcon && e.giftIcon) {
      const img = document.createElement("img");
      img.src = e.giftIcon;
      img.alt = "";
      li.appendChild(img);
    }
    const nick = document.createElement("span");
    nick.className = "nick";
    nick.textContent = e.nickname;
    const gift = document.createElement("span");
    gift.className = "gift";
    gift.innerHTML = `送出 <span class="accent">${e.giftName}</span> ×${e.giftCount}`;
    li.append(nick, gift);

    listEl.prepend(li);
    pruneList();

    // 累计(diamondCount 单位:分;可能为 0 表示免费礼物)
    if (e.diamondCount) {
      total += e.diamondCount * e.giftCount;
      ZBJ.storage.set("total", total);   // 仅在 edit 模式下落盘
      renderTotal();
    }
  });
});

四件事讲一下:

行为解释
ZBJ.config.title 在初次渲染就能读到因为 ZBJ.ready 一定在 init 之后才触发,此时配置已下发
applyConfigready 内调用一次、再注册 onConfigChange这是「初次 + 增量」的标准模式;如果只注册 onConfigChange 会漏掉首屏
storage.set 只在 edit 模式下真正落盘在预览窗 / 虚拟摄像头窗里 set 仅在会话内有效。原因见 开发约定 §8
没有手动管理 WebSocketSDK 内部已经连了 /ws,自动归一化平台事件并分发;挂件作者只需要 ZBJ.on("gift", …)

5. 在主播酱里挂上

第一次:

  1. 在主播酱「创意工坊」→「开发者」Tab 注册:
    • manifest 路径.../gift-roll/manifest.json
    • 挂件源 URL:留空(主播酱内嵌服务会从 dev-widgets/<id>/ 直接托管同目录文件)。
  2. 「我的挂件」中拖到画布。
  3. 选中该挂件,右侧出现「内容」与「样式」两个 Tab,对应 config / theme 数组。

后续:改 index.html / app.js 保存即可,开发者模式下 iframe 会自动刷新。

manifest.json 时客户端会自动重新读取并刷新配置面板,不需要重新注册

6. 用真实事件验证

两种方式:

方式怎么做
连真实直播间在主播酱主界面绑定一个开播中的直播账号(任意支持平台),礼物到达即可看到挂件更新
注入测试事件在配置面板点「发送测试事件」按钮,选事件类型即可(详见 调试与排错

更丰富的事件录制 / 回放工具在规划中(见 版本策略 §3)。

7. 用 Vite 做开发体验

如果你想要 HMR、TypeScript 或者 npm 包,可以让挂件在自己的 dev server 上跑:

gift-roll/
├── manifest.json
├── public/
│   ├── thumbnail.png
│   └── assets/ding.mp3
├── src/
│   ├── index.html
│   ├── app.ts
│   └── style.css
├── vite.config.ts
└── package.json

vite.config.ts 关键配置:

ts
export default {
  root: "src",
  base: "./",
  server: { port: 5173 },
  build: { outDir: "../dist", emptyOutDir: true }
};

在主播酱「开发者」Tab:

  • manifest 路径:选项目根的 manifest.json
  • 挂件源 URL:填 http://localhost:5173

主播酱会优先用此 URL 装载 iframe,HMR、TS、PostCSS 全部按 Vite 默认行为工作。SDK 仍然从 http://127.0.0.1:9855/widget-sdk/zbj-widget-sdk.js 引入。

构建并打包时执行 pnpm build,将 dist/ 改名为 gift-roll/ 后按 §8 打包 走流程。

8. 打包发布

完整流程见 打包与发布。这里给出 90 秒版本:

  1. 确保目录结构manifest.json 必须在根):
    gift-roll/
    ├── manifest.json
    ├── index.html
    ├── app.js
    ├── thumbnail.png
    └── assets/
  2. 打成 zip(zip 内不要多一层文件夹,根直接是 manifest.json):
    pwsh
    Compress-Archive -Path gift-roll\* -DestinationPath gift-roll-1.0.0.zip
  3. gift-roll-1.0.0.zip 发给用户,对方在主播酱「创意工坊」→「我的挂件」→「安装」选择该 zip,校验通过即解压到 userData/widgets/gift-roll/
  4. 改了代码要发新版?升 manifest.version,重新打 zip 即可。

9. 下一步

想做的事看这里
给挂件加一个「输入礼物 ID 显示发出该礼物的最新用户」类的下拉,让用户从平台礼物里选示例集 §4 RPC
在没有动画/JS、纯 CSS 的场景做更轻量的挂件示例集 §2 弹幕墙
排查 iframe 白屏 / SDK 没加载 / 事件没收到调试与排错
了解所有可用事件字段(不同平台差异)平台事件
设计多挂件协同的玩法暂在版本策略的规划中,见 版本策略 §3