自定义 UI 指南
界面代码是一个小小的 React(TSX)应用,负责画出玩家看到的画面。它可以给聊天换个样子,在聊天周围加面板,或者整个换成一个看起来根本不像聊天的东西。它在 面板 → 前端代码 里。
这里面很多事现在已经不用写代码了。玩家界面 编辑器可以用模板搭开局页、状态面板、背包、地图和按钮,按钮还能改变量、让行为生效、叫 AI。模板和部件做不到你想要的效果时,再用界面代码。即便如此,大多数创作者也不手写:把想要的画面讲给创作助手听,代码由它来写。
基础和设置见 入门:视觉与音频。
世界的三种样子
默认聊天(不需要自定义 UI)
消息以文字气泡出现,底部有输入框,一切自然地往下滚。每个新世界一开始都是这样,对很多世界来说这就够了。
适合:日常角色扮演、简单的冒险、和角色聊天,以及任何文字本身就是体验的世界。
玩家界面里的 消息样子(平台默认、默认气泡、小说排版、信纸、终端)和它的细调选项,不用写代码就能给这种聊天换样子。
自定义消息气泡
保留完整的聊天体验(滚动、流式输出、swipe、输入框),只改每条消息的样子。
大家用它做的东西:
- 和世界氛围相配的主题背景和字体
- 对话旁边的角色立绘
- 每条消息上都看得到的状态栏(HP、金币、好感)
- 暗色调加诡异字体的恐怖游戏风格
- 多角色场景里按说话人区分颜色
全应用模式
你的代码画整个屏幕。聊天只是你布局里的一个组件,或者干脆不要聊天,做一个完全不同的东西。
大家用它做的东西:
- 带场景背景和角色立绘的视觉小说画面
- 点一个地点就发一条消息的地图导航
- 回合制战斗画面
- 手机模拟器,不同的「app」触发不同的 AI 行为
- 可交互的仪表盘、背包画面、任务日志
Battle Royale 用全应用模式做战术总览。Still 用它做解谜界面。
先从默认聊天开始。世界需要视觉氛围的话,换个消息样子或者加自定义气泡。需要可交互的面板、游戏化的布局或者不是聊天的体验,就用玩家界面;玩家界面不够用时,再上全应用模式。
用创作助手来做
怎么用
点画布右上角的 创作助手,用大白话描述你想要的样子。它会把界面代码写进 面板 → 前端代码,画布上的玩家界面预览会显示效果。
好的提示
描述越具体,结果越好。讲清楚 布局、氛围,以及要显示哪些 变量。
含糊(AI 什么都得猜):
「弄得酷一点」
不错(布局和氛围清楚):
「我想要一个暗黑恐怖风的界面。顶部一条红色血条,消息做成泛黄纸张上的老式打字机文字,边缘有一圈暗雾。」
很好(布局 + 氛围 + 具体的变量 + 行为):
「做一个视觉小说布局。全屏背景图来自
scene_bg变量。左边是角色立绘,来自character_sprite。底部一个半透明的对话框,角色名来自speaker_name,用粉色。好感高于 75 时,加一点淡淡的爱心粒子效果。」
一轮一轮来
- 先说大框架:「我想要一个带场景背景和角色立绘的视觉小说布局」
- 看预览:布局感觉对吗?间距合适吗?
- 细调细节:「对话框再透明一点。角色立绘挪到右边。对话用衬线字体。」
- 加点打磨:「换场景时加一个淡入动画。好感涨的时候让好感条发光。」
几轮短的来回,比一次写一大段描述更快到位。
怎么跟创作助手说你的变量
创作助手读得到你的变量,但把重点讲清楚会更好:
「我的变量: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 在哪里
带类型签名和示例的逐个方法参考在两个地方:
- API 参考:每个方法和字段,带例子
- World Spec: Custom UI:机器可读的规范,写给 AI 读的
搭复杂界面时,把 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 必须默认导出一个函数组件。这是你界面的根:
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 语法:
import StatBar from "./stat-bar"
import DialogueBox from "./dialogue-box"4. 用 React.useState(),不要用 useState()
React 作为模块在作用域里,但各个 hook 没有单独解构出来。一律加上 React. 前缀:
var [count, setCount] = React.useState(0)5. TypeScript 类型会被去掉,不会被检查
代码编译时类型标注和 interface 会被删掉,所以它们不会弄坏什么,但也没有任何东西检查它们。创作助手写的是 JSX 里的普通 JavaScript。const、let、var 和箭头函数都能用。
常见做法
下面这些是大多数自定义界面会组合使用的积木。代码示例是折叠起来的,仅供参考,这类代码创作助手会帮你写。
自定义消息气泡
保留完整的聊天体验,只改消息的样子。用 <Chat renderBubble={...} /> 接管气泡的渲染,其它的(滚动、流式输出、swipe、输入)都交给平台。
什么时候用:你想要有主题的消息(暗黑恐怖、优雅浪漫、科幻终端),又不想重做整个聊天,而玩家界面的 消息样子 选项又不够用。
代码示例:带状态栏的主题气泡
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 栏
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 随着剧情推进来更新。完整的搭法见 配方:视觉小说模式。
什么时候用:恋爱、剧情、日常故事,视觉氛围比传统聊天界面更重要的时候。
代码示例:带场景背景的视觉小说框架
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、冒险游戏,以及任何玩家聊天时需要查看数值或背包的世界。
代码示例:聊天 + 侧边栏
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 这些步骤,开局模板有 选开局、填名字进故事 和 选难度。按钮要放在聊天气泡里面,或者依赖步骤表达不了的逻辑时,再用代码。
什么时候用:任何你想让玩家从选项里选(代替打字或者和打字并用)的世界。
代码示例:把开场白做成角色创建
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。
另见
- API 参考:
useYumina()、callAi()、ai.complete()、injectContext()以及其它所有 bridge 方法的完整逐个参考 - 玩家界面:模板和部件,不用代码
- 设计游戏状态:你的界面读取和显示的变量
- 做能给别人套用的界面:想分享到资源市场的界面,人物和物品放进变量,别人装上就换成自己卡里的角色
- 3D、全景与素材:沙盒能加载什么,以及在 3D 地点之间传送
- 音频设计:通过 bridge API 从自定义界面播放音频
- AI 指令与宏:AI 怎么更新你的界面渲染的状态
机器可读的规范 → World Spec: Custom UI
