Skip to content

角色创建表单

玩家打开会话后看到一个角色创建界面——输入名字、选择职业、写一段背景故事,点击「开始冒险」,聊天就跳转到真正的故事开场。从第一个 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在编辑器中供你自己参考
IDplayer_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供你自己参考
IDplayer_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供你自己参考
IDplayer_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 />):

tsx
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 步:保存并测试

  1. 点击编辑器顶部的保存
  2. 点击开始游戏或返回主页开始新会话
  3. 你看到第一个问候语的氛围文字,下方是角色创建表单
  4. 在名字框中输入「Elara」
  5. 点击 Mage 按钮——它高亮显示,底部按钮变为「Start Adventure」
  6. 在背景故事框中输入「Grew up in a wizard's tower and stumbled upon a portal to another world」
  7. 点击 Start Adventure
  8. 第一条消息立即切换为:「Elara pushes open the gate of destiny. You are a Mage...」——表单消失
  9. 发送一条消息(例如「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」setVariableswitchGreeting 之后被调用确认代码先调用 setVariable 再调用 switchGreeting
角色创建后表单仍然显示hasCreated 检查有误确认 player_class 的默认值是空字符串(不是某个非空值)

进一步:扩展角色创建

添加更多职业

只需向 classes 数组添加新元素:

tsx
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 一样,你可以根据职业自动启用/禁用不同的知识条目。例如:

  1. 在知识库中创建「Warrior Lore」、「Mage Lore」和「Rogue Lore」条目,默认禁用
  2. 在行为标签页中创建三个行为,当 player_class 匹配时启用对应条目
  3. handleStart 中添加类似 api.executeAction("choose-class-warrior") 的调用

这样每个职业不仅仅是一个不同的标签——它获得了完全不同的世界观和 AI 行为。

在后续消息中显示角色信息

你可以在 Root Component 的 <Chat renderBubble> 中添加一个「角色信息栏」,在每条消息顶部显示角色名字和职业:

tsx
{/* 在 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 并作为新世界导入,查看完整效果:

recipe-4-demo.json

如何导入:

  1. 前往 Yumina → 我的世界创建新世界
  2. 在编辑器中,点击更多操作导入包
  3. 选择下载的 .json 文件
  4. 新世界会被创建,所有问候语、变量和 Root Component 都已预配置
  5. 开始新会话并试试看

包含内容:

  • 2 个问候语(角色创建表单 + 故事开场)
  • 3 个变量(player_name 姓名、player_class 职业、player_backstory 背景故事)
  • 1 个知识条目(角色资料,使用 {{player_name}}{{player_class}}{{player_backstory}} 宏)
  • 一个完整的 Root Component(角色创建表单 UI)

这是教程 #4

教程 #1 教了基于按钮的问候语切换和宏替换。本教程将它们组合成一个完整的角色创建流程。未来的教程将继续在此基础上构建——属性点分配、装备选择、多步引导等等。