Skip to content

自定义 UI 指南 ​

界面代码是一个小小的 React(TSX)应用,负责画出玩家看到的画面。它可以给聊天换个样子,在聊天周围加面板,或者整个换成一个看起来根本不像聊天的东西。它在 面板 → 前端代码 里。

这里面很多事现在已经不用写代码了。玩家界面 编辑器可以用模板搭开局页、状态面板、背包、地图和按钮,按钮还能改变量、让行为生效、叫 AI。模板和部件做不到你想要的效果时,再用界面代码。即便如此,大多数创作者也不手写:把想要的画面讲给创作助手听,代码由它来写。

基础和设置见 入门:视觉与音频。


世界的三种样子 ​

默认聊天(不需要自定义 UI) ​

消息以文字气泡出现,底部有输入框,一切自然地往下滚。每个新世界一开始都是这样,对很多世界来说这就够了。

适合:日常角色扮演、简单的冒险、和角色聊天,以及任何文字本身就是体验的世界。

玩家界面里的 消息样子(平台默认、默认气泡、小说排版、信纸、终端)和它的细调选项,不用写代码就能给这种聊天换样子。

自定义消息气泡 ​

保留完整的聊天体验(滚动、流式输出、swipe、输入框),只改每条消息的样子。

大家用它做的东西:

  • 和世界氛围相配的主题背景和字体
  • 对话旁边的角色立绘
  • 每条消息上都看得到的状态栏(HP、金币、好感)
  • 暗色调加诡异字体的恐怖游戏风格
  • 多角色场景里按说话人区分颜色

全应用模式 ​

你的代码画整个屏幕。聊天只是你布局里的一个组件,或者干脆不要聊天,做一个完全不同的东西。

大家用它做的东西:

  • 带场景背景和角色立绘的视觉小说画面
  • 点一个地点就发一条消息的地图导航
  • 回合制战斗画面
  • 手机模拟器,不同的「app」触发不同的 AI 行为
  • 可交互的仪表盘、背包画面、任务日志

Battle Royale 用全应用模式做战术总览。Still 用它做解谜界面。

先从默认聊天开始。世界需要视觉氛围的话,换个消息样子或者加自定义气泡。需要可交互的面板、游戏化的布局或者不是聊天的体验,就用玩家界面;玩家界面不够用时,再上全应用模式。


用创作助手来做 ​

怎么用 ​

点画布右上角的 创作助手,用大白话描述你想要的样子。它会把界面代码写进 面板 → 前端代码,画布上的玩家界面预览会显示效果。

好的提示 ​

描述越具体,结果越好。讲清楚 布局、氛围,以及要显示哪些 变量。

含糊(AI 什么都得猜):

「弄得酷一点」

不错(布局和氛围清楚):

「我想要一个暗黑恐怖风的界面。顶部一条红色血条,消息做成泛黄纸张上的老式打字机文字,边缘有一圈暗雾。」

很好(布局 + 氛围 + 具体的变量 + 行为):

「做一个视觉小说布局。全屏背景图来自 scene_bg 变量。左边是角色立绘,来自 character_sprite。底部一个半透明的对话框,角色名来自 speaker_name,用粉色。好感高于 75 时,加一点淡淡的爱心粒子效果。」

一轮一轮来 ​

  1. 先说大框架:「我想要一个带场景背景和角色立绘的视觉小说布局」
  2. 看预览:布局感觉对吗?间距合适吗?
  3. 细调细节:「对话框再透明一点。角色立绘挪到右边。对话用衬线字体。」
  4. 加点打磨:「换场景时加一个淡入动画。好感涨的时候让好感条发光。」

几轮短的来回,比一次写一大段描述更快到位。

怎么跟创作助手说你的变量 ​

创作助手读得到你的变量,但把重点讲清楚会更好:

「我的变量:health(0-100,显示成红色血条)、gold(数字,显示成带金币图标的文字)、location(字符串,比如 'forest' 或 'cave',显示在右上角)、is_night(布尔值,为 true 时把背景调暗)」


界面代码能做什么 ​

你的代码通过 useYumina() 和游戏打交道。按能做出什么来说:

读取游戏状态 ​

你的界面能读任何变量、完整的消息记录、当前玩家是谁、选了哪个模型、AI 是不是正在生成,等等。状态栏、背包和任务追踪器就是这么做的:读变量,再把它们显示出来。

api.variables.hunger 会找 ID 为 hunger 的变量,找不到就找 显示名 为 hunger 的那个。两种写法都行,所以照着画布上看到的名字做的 HUD,即使 ID 是随机的 UUID,也能读到真实的值。写入也一样:api.setVariable("hunger", 50) 会写到那个变量上。完整规则:ID 和显示名。

控制聊天 ​

你的界面可以以玩家身份发消息、编辑或删除已有的消息、让 AI 重新生成、在流式输出中途停下,或者重新开始对话。比如一个「喝下药水」按钮,就是把这句话作为玩家的消息发出去,AI 接着回应。

让行为生效 ​

api.executeActionAndWait("buy", { price: 5 }) 会运行触发条件是 玩家点了某个按钮、按钮 名为 buy 的那些行为,并把值传进去。行为里用 {param.price} 读取。商店、合成、战斗掷骰都可以放在画布上的行为里,你的代码只需要说点的是哪件东西。见 行为 · 按钮传值。

叫卡里的 AI ​

画布上的 界面型 AI 会等着你的界面来叫它。api.callAi("Fortune Mushroom", input) 运行它,返回 { text, fields }:它说的话,以及它在 回答格式 里填的值。它的模型、看得到什么、重试、冷却和备用台词都在画布上设,答案在交给你的代码之前已经检查过。把 说在哪 设成 界面频道 的话,你的代码还可以用 api.onAiOutput(channel, cb) 监听它的输出。见 AI。

原始 AI 调用 ​

想自己写整段提示词的时候,ai.complete() 会单独跑一次 AI 调用,完全不碰主聊天。玩家看不到它是一条聊天消息,它也不影响主对话的历史和状态。用途:

  • NPC 手机聊天:某个角色在你的界面里有自己的聊天窗口。玩家给他发消息,AI 用这个角色的口吻回复,主线故事另外照常进行。每个配角都可以有自己的系统提示词和性格。
  • AI 生成的物品描述:玩家把鼠标停在背包里的某件物品上,AI 根据当前的剧情现写一段独一无二的描述。
  • 提示系统:一个「想一想」按钮,分析玩家现在的处境,给一点提示,主 AI 不用跳出角色。
  • 内心独白面板:一个侧边面板显示 NPC 在想什么,由另一套提示词生成,和推动对话的那个分开。
  • 翻译或摘要面板:在主聊天旁边显示 AI 写的对话摘要或翻译。

传 includeLorebook: "matched",旁支 AI 就能看到和主聊天一样的世界设定和角色资料。不需要世界背景的任务(翻译、分类、纯工具类的)就别传。

旁支调用有自己的频率限制池(每分钟 100 次,和主聊天互不影响),按 token 计费的方式和主聊天一样。完整的方法签名、限制和 includeLorebook 选项见 API 参考。

看不见的上下文注入 ​

你的界面可以发一条消息,主 AI 在下一回合能看到,玩家在聊天里永远看不到。调用 injectContext(),引擎会往下一次的提示词里塞一条一次性的 system(或 user)消息,用完自动丢掉。

用它让 AI 对主对话之外发生的事做出反应:

  • 幕后事件:「你走后,那个 NPC 自言自语:『绝不能让他们找到那封信。』」AI 会把这句话织进下一段回复。
  • 环境变化:「开始下雨了。洞口已经被淹了一半。」玩家看不到这条指令,但 AI 会描写这场雨。
  • 界面带来的后果:玩家在你的自定义界面里点了某个按钮(比如在商店里偷东西),注入一段上下文告诉 AI 发生了什么,让它做出反应。
  • 手机消息和通知:「你刚收到一条神秘短信:『今晚九点,老地方。』」AI 会把它写进叙述里,玩家不会看到一条系统消息。

ai.complete() 是单独跑一次 AI 调用;injectContext() 喂给的是 主 AI 的下一次回复。想要一个单独的 AI 声音时用 ai.complete(),想让主 AI 知道某件玩家没说出口的事时用 injectContext()。如果是一件跨回合一直成立的事,比如玩家在哪个房间、屏幕上显示什么,就用 setScene():在你改掉它之前,每一次 AI 调用都能拿到它。

方法签名见 API 参考。

播放音频 ​

你的界面可以放背景音乐、音效,在音轨之间渐变切换,调音量。配合变量,可以让音乐跟着地点或心情变化。

存取数据 ​

有两种存储。api.storage 在这个浏览器里按世界存字符串:偏好设置、记住的标签页、这台设备上的最高分。api.sessionStorage 把小的 JSON 记录跟着这一局存在服务器上,所以它会跟着玩家去别的设备,也会进存档点。

导航和提示 ​

切换沉浸模式、弹出提示、复制文字到剪贴板、在开场白之间切换、打开人设管理器或者模型选择器。

完整 API 在哪里

带类型签名和示例的逐个方法参考在两个地方:

搭复杂界面时,把 World Spec 交给你自己的 AI(Claude、ChatGPT、Cursor)。


三种自定义的路子 ​

路子一:让创作助手来做 ​

你说想要什么,创作助手写代码,你通过对话来调。

优点:不需要懂代码。改得快。创作助手了解 API,会处理流式输出、空状态、手机布局这些细节。

什么时候用:先从这里开始。只有碰到创作助手做不到的事,才换别的路子。

路子二:用你自己的 AI(Claude、ChatGPT、Cursor) ​

右上角 ⋮ 菜单里的 连接你的 AI 能让 Claude、ChatGPT / Codex、Cursor、Claude Code 或 Codex 终端版登录进来,直接改你的卡,包括界面代码。见 创作助手和你自己的 AI。

如果你更想自己把代码粘进来,就告诉那个 AI:

  • 你的代码是 TSX(React),跑在一个沙盒 iframe 里
  • 下面这些都是全局变量:React、useYumina、Icons、Chat、MessageList、MessageInput、Tailwind CSS
  • 入口文件是 index.tsx,写 export default function MyWorld() { ... }
  • 游戏状态来自 useYumina():变量、消息、流式状态,全都在里面
  • 用 React.useState 这类写法,不要 import hook

什么时候用:复杂的多文件界面、想要更长的对话,或者你本来就在用带 AI 的代码编辑器。

路子三:自己手写(给有经验的开发者) ​

打开 面板 → 前端代码,直接写 TSX。边写,编译标记会显示 正常 或 错误。

什么时候用:你是习惯用 React 思考的开发者,或者想精确控制每个细节。


自定义 UI 代码的基本规则 ​

不管走哪条路子都适用。创作助手本来就遵守这些;这一节是让你明白底下在发生什么,或者出问题时用来排查。

五条规则

1. 入口文件的格式

index.tsx 必须默认导出一个函数组件。这是你界面的根:

tsx
export default function MyWorld() {
  return <Chat />;
}

2. 全局变量:不要 import

这些在任何地方都已经能用,不用 import:React、useYumina、Icons、Chat、MessageList、MessageInput、useAssetFont、设定控件(LoreSlot、LoreButton……)、模型选择器组件,以及所有 Tailwind CSS 类。完整列表见 API 参考。

代码编译时 import 语句会被去掉,所以 import React from "react" 什么都不做,而 import { useState } from "react" 之后 useState 是 undefined。

3. 你自己的文件可以 import

多文件的界面代码用 ES module 语法:

tsx
import StatBar from "./stat-bar"
import DialogueBox from "./dialogue-box"

4. 用 React.useState(),不要用 useState()

React 作为模块在作用域里,但各个 hook 没有单独解构出来。一律加上 React. 前缀:

tsx
var [count, setCount] = React.useState(0)

5. TypeScript 类型会被去掉,不会被检查

代码编译时类型标注和 interface 会被删掉,所以它们不会弄坏什么,但也没有任何东西检查它们。创作助手写的是 JSX 里的普通 JavaScript。const、let、var 和箭头函数都能用。


常见做法 ​

下面这些是大多数自定义界面会组合使用的积木。代码示例是折叠起来的,仅供参考,这类代码创作助手会帮你写。

自定义消息气泡 ​

保留完整的聊天体验,只改消息的样子。用 <Chat renderBubble={...} /> 接管气泡的渲染,其它的(滚动、流式输出、swipe、输入)都交给平台。

什么时候用:你想要有主题的消息(暗黑恐怖、优雅浪漫、科幻终端),又不想重做整个聊天,而玩家界面的 消息样子 选项又不够用。

代码示例:带状态栏的主题气泡
tsx
export default function MyWorld() {
  var api = useYumina()

  return (
    <Chat renderBubble={function(msg) {
      if (msg.role === "user") {
        return (
          <div className="ml-auto max-w-[80%] rounded-xl bg-blue-500/20 px-4 py-3 text-blue-100">
            {msg.rawContent}
          </div>
        )
      }

      return (
        <div className="mr-auto max-w-[85%] rounded-xl border border-zinc-700 bg-zinc-900 p-4">
          <div dangerouslySetInnerHTML={{ __html: msg.contentHtml }} />
          <div className="mt-3 flex gap-4 text-xs text-zinc-400">
            <span>HP {api.variables.health}/100</span>
            <span>Gold {api.variables.gold}</span>
          </div>
        </div>
      )
    }} />
  )
}

状态显示和 HUD ​

一个固定的面板,显示生命、金币、好感、地点或者别的变量。通常放在聊天上方(用 <Chat> 的 children prop),或者旁边(flex 布局)。玩家界面的 养成面板 和 冒险状态 模板不用写代码就能做出来。

什么时候用:你的世界记着玩家需要随时看到的数值,比如 RPG、生存游戏、带好感条的恋爱模拟。

代码示例:顶部 HUD 栏
tsx
export default function MyWorld() {
  var api = useYumina()

  return (
    <Chat>
      <div className="shrink-0 px-4 py-2 bg-black/60 backdrop-blur flex gap-4 text-xs text-zinc-300">
        <div className="flex items-center gap-1">
          <Icons.Heart className="w-3 h-3 text-red-400" />
          <span>{api.variables.health || 100}/100</span>
        </div>
        <div className="flex items-center gap-1">
          <Icons.Coins className="w-3 h-3 text-amber-400" />
          <span>{api.variables.gold || 0}</span>
        </div>
        <div className="ml-auto text-zinc-500">
          {api.variables.location || "Unknown"}
        </div>
      </div>
    </Chat>
  )
}

视觉小说布局 ​

全屏的场景背景、角色立绘、底部半透明的对话框。通常从变量里读场景和角色的数据,这些变量由 AI 随着剧情推进来更新。完整的搭法见 配方:视觉小说模式。

什么时候用:恋爱、剧情、日常故事,视觉氛围比传统聊天界面更重要的时候。

代码示例:带场景背景的视觉小说框架
tsx
export default function MyWorld() {
  var api = useYumina()
  var bg = api.variables.scene_bg
  var sprite = api.variables.character_sprite
  var speaker = api.variables.speaker_name
  var lastMsg = (api.messages || []).slice(-1)[0]

  return (
    <div
      className="relative w-full h-full bg-cover bg-center"
      style={{
        backgroundImage: bg
          ? "url(" + bg + ")"
          : "linear-gradient(135deg, #1e293b, #0f172a)"
      }}
    >
      {sprite && (
        <img
          src={sprite}
          className="absolute bottom-0 left-1/2 -translate-x-1/2 max-h-[80%] pointer-events-none"
        />
      )}

      <div className="absolute inset-x-4 bottom-4">
        <div className="rounded-xl border border-white/10 bg-black/70 p-4 backdrop-blur-sm">
          {speaker && (
            <div className="mb-1 text-sm font-bold text-pink-300">{speaker}</div>
          )}
          <div className="leading-relaxed text-zinc-100">
            {lastMsg ? lastMsg.content : ""}
          </div>
        </div>

        <div className="mt-2">
          <MessageInput />
        </div>
      </div>
    </div>
  )
}

侧边栏游戏面板 ​

左边是聊天,右边一个固定面板,显示角色信息、数值、背包或者地图。玩家保留完整的聊天,聊天时也能随时看游戏信息。

什么时候用:RPG、冒险游戏,以及任何玩家聊天时需要查看数值或背包的世界。

代码示例:聊天 + 侧边栏
tsx
export default function MyWorld() {
  var api = useYumina()

  return (
    <div className="flex h-full">
      <div className="flex-1 min-w-0">
        <Chat />
      </div>
      <aside className="w-72 shrink-0 border-l border-border bg-card p-4 overflow-y-auto">
        <div className="text-sm font-bold mb-3">{api.variables.player_name || "Adventurer"}</div>

        <div className="space-y-2 text-xs">
          <div className="flex justify-between">
            <span className="text-muted-foreground">HP</span>
            <span>{api.variables.health || 100}/{api.variables.max_health || 100}</span>
          </div>
          <div className="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
            <div
              className="h-full bg-red-500 transition-all duration-300"
              style={{ width: ((api.variables.health || 100) / (api.variables.max_health || 100) * 100) + "%" }}
            />
          </div>
        </div>

        <div className="mt-4 text-xs text-muted-foreground">
          <div className="font-medium mb-2">Inventory</div>
          <div className="grid grid-cols-3 gap-1">
            {(api.variables.inventory || []).map(function(item, i) {
              return (
                <div key={i} className="aspect-square rounded border border-border bg-muted flex items-center justify-center text-[10px]">
                  {item.name || "?"}
                </div>
              )
            })}
          </div>
        </div>
      </aside>
    </div>
  )
}

可交互的按钮和选项 ​

点了以后发消息、设变量或者让行为生效的按钮。常见的用法是开场:把它做成角色创建画面、难度选择,或者分支剧情的开头。

玩家界面不用代码就能做大部分这类东西:按钮可以带 替玩家说一句、改变量、让一条行为生效 和 叫一个 AI 这些步骤,开局模板有 选开局、填名字进故事 和 选难度。按钮要放在聊天气泡里面,或者依赖步骤表达不了的逻辑时,再用代码。

什么时候用:任何你想让玩家从选项里选(代替打字或者和打字并用)的世界。

代码示例:把开场白做成角色创建
tsx
export default function MyWorld() {
  var api = useYumina()

  return (
    <Chat renderBubble={function(msg) {
      if (msg.messageIndex === 0 && msg.role === "assistant") {
        return (
          <div className="space-y-4">
            <div dangerouslySetInnerHTML={{ __html: msg.contentHtml }} />
            <div className="flex gap-3">
              <button
                onClick={function() {
                  api.setVariable("class", "Warrior")
                  api.sendMessage("I choose Warrior")
                }}
                className="px-4 py-3 rounded-lg border border-zinc-600 hover:bg-zinc-800 transition"
              >
                Warrior
              </button>
              <button
                onClick={function() {
                  api.setVariable("class", "Mage")
                  api.sendMessage("I choose Mage")
                }}
                className="px-4 py-3 rounded-lg border border-zinc-600 hover:bg-zinc-800 transition"
              >
                Mage
              </button>
            </div>
          </div>
        )
      }

      return <div dangerouslySetInnerHTML={{ __html: msg.contentHtml }} />
    }} />
  )
}

常见错误 ​

不需要却加了自定义 UI。 默认聊天干净、快,还有平台维护。如果你的世界强在文字,默认聊天能让玩家专注在文字上。

忘了流式输出。 AI 生成时,msg.isStreaming 为 true,内容还不完整。你的气泡要能好好处理不完整的文字,别假设内容已经完整再去解析。

不在手机上测。 很多玩家用手机。如果你的侧边栏有 320px 宽,手机屏幕放不下。用响应式的 Tailwind 类(hidden md:block 在小屏上藏起面板),或者在窄宽度下测一下布局。

把输入堵死了。 如果你用了全应用模式,却忘了放 <MessageInput />(或者你自己调用 api.sendMessage() 的输入框),玩家就没法跟 AI 说话。一定要留一个发消息的办法。

先设变量再切开场白。 api.switchGreeting() 会把变量重置成那个开场白的初始值。先 await 它,再设你自己的变量。

import 全局变量。 import 语句会被去掉,所以 import { useState } from "react" 之后 useState 是 undefined。React、useYumina、Icons、Chat、MessageList、MessageInput 都是全局变量,不要 import。


另见 ​

机器可读的规范 → World Spec: Custom UI