角色创建表单
玩家打开会话后看到一个角色创建界面——输入名字、选择职业、写一段背景故事,点击「开始冒险」,聊天就跳转到真正的故事开场。从第一个 AI 回复开始,AI 就已经了解了玩家角色的一切。
你将构建什么
第一条消息不是故事——而是一个角色创建表单。表单由 Root Component 渲染,包含:
- 一个文本输入框——让玩家输入角色名字
- 三个职业选择按钮——战士 / 法师 / 盗贼
- 一个文本区域——让玩家写背景故事
- 一个「开始冒险」按钮——点击后将所有信息保存到变量中,然后跳转到真正的故事开场
跳转后,条目中的 {{player_name}}、{{player_class}} 和 {{player_backstory}} 宏会被引擎自动替换为玩家填写的内容。当 AI 写第一个回复时,它已经拥有了完整的角色资料。
前置知识
本教程直接建立在教程 #1 的两个核心技术之上:
| 技术 | 来源 | 本教程如何使用 |
|---|---|---|
switchGreeting(index) 在开场之间跳转 | 教程 #1 第一部分 | 玩家填完表单后,从「创建界面」跳转到「故事开场」 |
条目内容中的 {{variableId}} 宏替换 | 教程 #1 第二部分 | 条目中的 {{player_name}} 等宏在构建提示词时被替换为玩家的输入 |
如果你还没有阅读教程 #1,请先从那里开始:场景跳转与条目切换。
工作原理
完整流程:
1. 玩家开始新会话 → 看到问候语 #1(角色创建表单)
2. Root Component 的 <Chat renderBubble> 检测到 msg.messageIndex === 0,渲染表单 UI
3. 玩家输入名字、选择职业、写背景故事
4. 玩家点击「开始冒险」
→ 代码调用 api.setVariable("player_name", "Elara")
→ 代码调用 api.setVariable("player_class", "Mage")
→ 代码调用 api.setVariable("player_backstory", "Grew up in a wizard's tower...")
→ 代码调用 api.switchGreeting(1)
→ 第一条消息立即切换到问候语 #2(真正的故事开场)
5. 玩家发送第一条消息
→ 引擎构建提示词 → 扫描条目中的 {{...}} 宏
→ {{player_name}} 被替换为 "Elara"
→ {{player_class}} 被替换为 "Mage"
→ {{player_backstory}} 被替换为 "Grew up in a wizard's tower..."
→ AI 收到完整的角色资料 → 写出第一个回复关键点: setVariable 立即生效,但 AI 只有在下一次构建提示词时才能看到变化。所以顺序是:先调用 setVariable 存储值 → 然后 switchGreeting 跳转 → 玩家发送消息 → AI 就能在回复中使用角色信息了。
分步教程
第 1 步:创建变量
你需要三个字符串变量来存储玩家的角色信息。
编辑器 → 侧边栏 → 变量标签页 → 点击「添加变量」,创建以下三个:
变量 1:角色名字
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Character Name | 在编辑器中供你自己参考 |
| ID | player_name | 条目中的 {{player_name}} 宏通过这个 ID 查找 |
| 类型 | String | 因为名字是文本 |
| 默认值 | Traveler | 如果玩家没有填名字就开始,AI 会称呼他们为「Traveler」 |
| 分类 | Custom | 组织标签,纯粹用于管理 |
| 行为规则 | Do not modify this variable. It is set by the player via the character creation form. | 告诉 AI 不要自行更改角色名字 |
变量 2:角色职业
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Character Class | 供你自己参考 |
| ID | player_class | 条目中的 {{player_class}} 宏通过这个 ID 查找 |
| 类型 | String | 因为职业是文本(「Warrior」、「Mage」、「Rogue」) |
| 默认值 | 留空 | 空意味着尚未选择。Root Component 检查这个值来决定高亮哪个按钮 |
| 分类 | Custom | 组织标签 |
| 行为规则 | Do not modify this variable. It is set by the player via the character creation form. | 告诉 AI 不要自行更改职业 |
变量 3:角色背景故事
| 字段 | 值 | 原因 |
|---|---|---|
| 显示名称 | Character Backstory | 供你自己参考 |
| ID | player_backstory | 条目中的 {{player_backstory}} 宏通过这个 ID 查找 |
| 类型 | String | 因为背景故事是文本 |
| 默认值 | 留空 | 空 = 玩家没有写背景故事。条目中对应的位置会是空字符串 |
| 分类 | Custom | 组织标签 |
| 行为规则 | Do not modify this variable. It is set by the player via the character creation form. | 告诉 AI 不要自行更改背景故事 |
为什么
player_name有默认值而其他两个没有? 因为几乎所有场景都需要一个名字——AI 必须称呼角色某个名字。回退值「Traveler」可以防止 AI 在回复中写出尴尬的空白或「无名角色」。职业和背景故事可以为空——AI 可以合理地忽略或即兴发挥。
第 2 步:在「第一条消息」中创建两个问候语
打开编辑器,点击侧边栏的第一条消息标签页。
创建第一个问候语(角色创建界面):
点击「创建第一条消息」按钮。在文本框中写:
*A warm glow envelops you. You feel yourself taking shape — but your identity is not yet defined.*
*An ancient voice echoes through the void:*
"Welcome, traveler. Before you step into this world, tell me — who are you?"这段文字是氛围装饰——实际的表单 UI 由 Root Component 在这段文字下方渲染。玩家看到的是:上面是一段烘托气氛的文字,下面是交互式的角色创建表单。
创建第二个问候语(真正的故事开场):
点击底部的「添加问候语」按钮。切换到标签 2,写下真正的故事开场:
*{{player_name}} pushes open the gate of destiny.*
*You are a {{player_class}}, and this is your first time setting foot in the Elderlands. The silhouette of a distant city shimmers in the dawn light, and a cobblestone road stretches toward the unknown.*
*A breeze brushes your face, carrying the scent of grass and distant hearth-smoke. You take a deep breath — the adventure begins now.*
Three paths lie before you: a wide road leading to town, a narrow trail through the woods, and a slope descending to the river. Which way do you go?宏在问候语中也能用
注意第二个问候语中的 {{player_name}} 和 {{player_class}}。这些宏在显示时会被替换为变量的当前值。所以当玩家填完表单,变量通过 setVariable 更新后,switchGreeting(1) 切换到这个问候语时,玩家会在故事开场中看到自己的角色名字和职业。
问候语顺序 = index
标签 1 = index 0(角色创建界面,默认显示),标签 2 = index 1(故事开场)。Root Component 中的 switchGreeting(1) 调用会跳转到第二个。
第 3 步:创建使用宏的知识条目
现在创建一个将角色信息注入每次发送给 AI 的提示词中的条目。
编辑器 → 条目标签页 → 创建新条目
| 字段 | 值 | 原因 |
|---|---|---|
| 名称 | Player Character Profile | 供你自己参考 |
| 段落 | System Presets | 预设段落中的条目始终发送给 AI |
| 启用 | 是(开启开关) | 始终激活——角色信息是 AI 随时需要的 |
内容:
[Player Character Profile]
Name: {{player_name}}
Class: {{player_class}}
Backstory: {{player_backstory}}
Always address the player by their character's name. Adjust interactions, available skills, and encounters based on their class and backstory.发生了什么?
当引擎构建提示词时,会扫描这段文本:
{{player_name}}→ 被替换为变量player_name的当前值(例如「Elara」){{player_class}}→ 被替换为变量player_class的当前值(例如「Mage」){{player_backstory}}→ 被替换为变量player_backstory的当前值(例如「Grew up in a wizard's tower」)
如果变量是空字符串,对应位置就是空白。例如,如果玩家没写背景故事,AI 看到的是「Backstory:」后面什么都没有——AI 通常会忽略空字段或即兴发挥。
第 4 步:在 Root Component 中构建角色创建表单
这是核心步骤——在聊天中渲染交互式的角色创建表单。
编辑器 → Custom UI 部分 → 打开 index.tsx → 粘贴以下代码(替换默认的 return <Chat />):
export default function MyWorld() {
const api = useYumina();
// ---- 表单状态 ----
const [name, setName] = React.useState(
String(api.variables.player_name || "")
);
const [selectedClass, setSelectedClass] = React.useState(
String(api.variables.player_class || "")
);
const [backstory, setBackstory] = React.useState(
String(api.variables.player_backstory || "")
);
// 检查角色创建是否已完成(职业已设置 = 表单已提交)
const hasCreated = String(api.variables.player_class || "") !== "";
// 职业列表
const classes = [
{ id: "Warrior", label: "Warrior", icon: "⚔️", desc: "Melee specialist, high HP" },
{ id: "Mage", label: "Mage", icon: "🔮", desc: "Ranged magic, high MP" },
{ id: "Rogue", label: "Rogue", icon: "🗡️", desc: "Agile and stealthy, high crit" },
];
// 处理「开始冒险」
const handleStart = () => {
if (!selectedClass) return; // 必须先选择职业
api.setVariable("player_name", name.trim() || "Traveler");
api.setVariable("player_class", selectedClass);
api.setVariable("player_backstory", backstory.trim());
api.switchGreeting?.(1); // 跳转到问候语 #2(故事开场)
};
return (
<Chat renderBubble={(msg) => (
<div>
{/* 渲染消息文本(平台已经渲染了 HTML,直接使用 msg.contentHtml) */}
<div
style={{ color: "#e2e8f0", lineHeight: 1.7 }}
dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
/>
{/* 角色创建表单——仅在第一条消息且尚未创建时显示 */}
{msg.messageIndex === 0 && !hasCreated && (
<div
style={{
marginTop: "20px",
padding: "24px",
background: "linear-gradient(135deg, #1e1b4b 0%, #1a1a2e 100%)",
borderRadius: "16px",
border: "1px solid #312e81",
}}
>
{/* 标题 */}
<div
style={{
fontSize: "18px",
fontWeight: "bold",
color: "#c4b5fd",
marginBottom: "20px",
textAlign: "center",
}}
>
Create Your Character
</div>
{/* 名字输入 */}
<div style={{ marginBottom: "16px" }}>
<div
style={{
fontSize: "13px",
color: "#a5b4fc",
marginBottom: "6px",
fontWeight: "600",
}}
>
Character Name
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter your name (leave blank for 'Traveler')"
style={{
width: "100%",
padding: "10px 14px",
background: "#0f172a",
border: "1px solid #334155",
borderRadius: "8px",
color: "#e2e8f0",
fontSize: "14px",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* 职业选择 */}
<div style={{ marginBottom: "16px" }}>
<div
style={{
fontSize: "13px",
color: "#a5b4fc",
marginBottom: "8px",
fontWeight: "600",
}}
>
Choose a Class
</div>
<div style={{ display: "flex", gap: "10px" }}>
{classes.map((cls) => (
<button
key={cls.id}
onClick={() => setSelectedClass(cls.id)}
style={{
flex: 1,
padding: "14px 10px",
background:
selectedClass === cls.id
? "linear-gradient(135deg, #4338ca, #6366f1)"
: "#1e293b",
border:
selectedClass === cls.id
? "2px solid #818cf8"
: "1px solid #334155",
borderRadius: "10px",
color:
selectedClass === cls.id ? "#e0e7ff" : "#94a3b8",
cursor: "pointer",
textAlign: "center",
transition: "all 0.2s",
}}
>
<div style={{ fontSize: "24px", marginBottom: "4px" }}>
{cls.icon}
</div>
<div
style={{
fontSize: "14px",
fontWeight: "bold",
marginBottom: "2px",
}}
>
{cls.label}
</div>
<div style={{ fontSize: "11px", opacity: 0.7 }}>
{cls.desc}
</div>
</button>
))}
</div>
</div>
{/* 背景故事 */}
<div style={{ marginBottom: "20px" }}>
<div
style={{
fontSize: "13px",
color: "#a5b4fc",
marginBottom: "6px",
fontWeight: "600",
}}
>
Backstory (optional)
</div>
<textarea
value={backstory}
onChange={(e) => setBackstory(e.target.value)}
placeholder="A few sentences about your character's history..."
rows={3}
style={{
width: "100%",
padding: "10px 14px",
background: "#0f172a",
border: "1px solid #334155",
borderRadius: "8px",
color: "#e2e8f0",
fontSize: "14px",
outline: "none",
resize: "vertical",
boxSizing: "border-box",
fontFamily: "inherit",
}}
/>
</div>
{/* 开始冒险按钮 */}
<button
onClick={handleStart}
disabled={!selectedClass}
style={{
width: "100%",
padding: "14px",
background: selectedClass
? "linear-gradient(135deg, #7c3aed, #a855f7)"
: "#374151",
border: "none",
borderRadius: "10px",
color: selectedClass ? "#f5f3ff" : "#6b7280",
fontSize: "16px",
fontWeight: "bold",
cursor: selectedClass ? "pointer" : "not-allowed",
transition: "all 0.2s",
}}
>
{selectedClass ? "Start Adventure" : "Pick a class first"}
</button>
</div>
)}
</div>
)} />
);
}代码详解
状态管理:
const api = useYumina()— 获取 Yumina API,用于读写变量和切换问候语name/selectedClass/backstory— 三个 React 状态,分别追踪输入框、职业按钮和文本区域React.useState(String(api.variables.player_name || ""))— 初始值从变量读取。在新会话中,这些是默认值;在已有会话中,它们从保存的变量恢复hasCreated— 检查player_class是否为空字符串。空 = 角色尚未创建;非空 = 已创建,隐藏表单
表单 UI:
msg.messageIndex === 0 && !hasCreated— 只在第一条消息且角色未创建前显示表单(msg由<Chat renderBubble>传入)classes.map(...)— 遍历职业列表,为每个渲染一个按钮。选中的职业会有高亮边框和渐变背景selectedClass === cls.id— 检查这是否是当前选中的职业,用于高亮显示disabled={!selectedClass}— 未选择职业前按钮是灰色且不可点击的
提交逻辑(handleStart):
api.setVariable("player_name", name.trim() || "Traveler")— 存储名字。如果玩家留空,回退为「Traveler」api.setVariable("player_class", selectedClass)— 存储职业api.setVariable("player_backstory", backstory.trim())— 存储背景故事api.switchGreeting?.(1)— 跳转到问候语 #2。?.可选链防止 API 不可用时报错
为什么是这个调用顺序?
setVariable x 3 → switchGreeting(1)
↑ ↑
先存储数据 再跳转你必须在 switchGreeting 之前调用 setVariable。问候语中的 {{player_name}} 和 {{player_class}} 宏在显示时立即替换——如果先跳转再存储,宏还是旧值(空字符串或默认值)。
第 5 步:保存并测试
- 点击编辑器顶部的保存
- 点击开始游戏或返回主页开始新会话
- 你看到第一个问候语的氛围文字,下方是角色创建表单
- 在名字框中输入「Elara」
- 点击 Mage 按钮——它高亮显示,底部按钮变为「Start Adventure」
- 在背景故事框中输入「Grew up in a wizard's tower and stumbled upon a portal to another world」
- 点击 Start Adventure
- 第一条消息立即切换为:「Elara pushes open the gate of destiny. You are a Mage...」——表单消失
- 发送一条消息(例如「I head toward the town」)——AI 的回复以「Elara」称呼你,并根据法师职业编写互动内容
验证 AI 是否真的获得了角色信息:
发送消息后,检查 AI 的回复是否:
- 使用了你的角色名字(「Elara」而不是「you」或「Traveler」)
- 提到了与职业相关的细节(Mage = 魔法、法杖、咒语等)
- 如果你写了背景故事,AI 可能会引用它(「You recall your days in the wizard's tower...」)
如果 AI 没有使用这些信息,请查看下面的故障排除表。
故障排除
| 症状 | 可能原因 | 解决方法 |
|---|---|---|
| 看不到角色创建表单 | Root Component 代码未保存或有语法错误 | 检查 Custom UI 部分底部的编译状态——应该显示绿色的「OK」 |
| 点击「Start Adventure」没反应 | 没有选择职业 | 按钮在未选择职业时是灰色的(disabled)——先点击一个职业 |
| 点了按钮但问候语没切换 | 只有一个问候语 | 确认第一条消息标签页中有 2 个问候语(标签 1 和标签 2) |
问候语切换了但看到 {{player_name}} 原始文本 | 宏没有被替换 | 检查变量 ID 拼写是否正确(player_name,不是 playerName) |
| AI 回复没有使用角色名字 | 条目未激活 | 检查知识条目是否已启用,内容中是否包含 {{player_name}} |
| AI 回复使用了默认的「Traveler」 | setVariable 在 switchGreeting 之后被调用 | 确认代码先调用 setVariable 再调用 switchGreeting |
| 角色创建后表单仍然显示 | hasCreated 检查有误 | 确认 player_class 的默认值是空字符串(不是某个非空值) |
进一步:扩展角色创建
添加更多职业
只需向 classes 数组添加新元素:
const classes = [
{ id: "Warrior", label: "Warrior", icon: "⚔️", desc: "Melee specialist, high HP" },
{ id: "Mage", label: "Mage", icon: "🔮", desc: "Ranged magic, high MP" },
{ id: "Rogue", label: "Rogue", icon: "🗡️", desc: "Agile and stealthy, high crit" },
{ id: "Cleric", label: "Cleric", icon: "✨", desc: "Healing and blessings, great support" },
{ id: "Ranger", label: "Ranger", icon: "🏹", desc: "Ranged attacks, expert tracker" },
];不需要其他代码更改——按钮会自动出现,选择时 selectedClass 会是新职业的 id。
与行为规则结合
就像教程 #1 一样,你可以根据职业自动启用/禁用不同的知识条目。例如:
- 在知识库中创建「Warrior Lore」、「Mage Lore」和「Rogue Lore」条目,默认禁用
- 在行为标签页中创建三个行为,当
player_class匹配时启用对应条目 - 在
handleStart中添加类似api.executeAction("choose-class-warrior")的调用
这样每个职业不仅仅是一个不同的标签——它获得了完全不同的世界观和 AI 行为。
在后续消息中显示角色信息
你可以在 Root Component 的 <Chat renderBubble> 中添加一个「角色信息栏」,在每条消息顶部显示角色名字和职业:
{/* 在 return 中,消息内容上方 */}
{hasCreated && (
<div style={{
display: "flex",
gap: "8px",
marginBottom: "8px",
fontSize: "12px",
color: "#a5b4fc",
}}>
<span>{String(api.variables.player_name)}</span>
<span style={{ opacity: 0.5 }}>|</span>
<span>{String(api.variables.player_class)}</span>
</div>
)}快速参考
| 你想做什么 | 怎么做 |
|---|---|
| 存储玩家输入的文本 | 创建 String 变量 + api.setVariable("id", value) |
| 构建选择按钮 | 在 React 状态中追踪选择 + 点击时 setSelectedClass(id) |
| 表单提交后跳转到不同开场 | 先调用 setVariable 设置所有值,然后 switchGreeting(index) |
| 让 AI 知道角色信息 | 在条目内容中使用 {{variableId}} 宏——引擎在构建提示词时替换 |
| 表单只显示一次 | 检查变量判断 hasCreated——创建后表单消失 |
| 条件未满足时禁用按钮 | disabled={!condition} + 匹配的灰色样式 |
| 在问候语中也显示角色信息 | 直接在问候语文本中写 {{player_name}} 和其他宏 |
试试看——可导入的演示世界
下载这个 JSON 并作为新世界导入,查看完整效果:
如何导入:
- 前往 Yumina → 我的世界 → 创建新世界
- 在编辑器中,点击更多操作 → 导入包
- 选择下载的
.json文件 - 新世界会被创建,所有问候语、变量和 Root Component 都已预配置
- 开始新会话并试试看
包含内容:
- 2 个问候语(角色创建表单 + 故事开场)
- 3 个变量(
player_name姓名、player_class职业、player_backstory背景故事) - 1 个知识条目(角色资料,使用
{{player_name}}、{{player_class}}、{{player_backstory}}宏) - 一个完整的 Root Component(角色创建表单 UI)
这是教程 #4
教程 #1 教了基于按钮的问候语切换和宏替换。本教程将它们组合成一个完整的角色创建流程。未来的教程将继续在此基础上构建——属性点分配、装备选择、多步引导等等。
