Skip to content

キャラクター作成フォーム

プレイヤーがセッションを開くとキャラクター作成画面が表示されます — 名前を入力、クラスを選択、バックストーリーを書き、「Start Adventure」をクリックすると、チャットが本物のストーリーオープニングにジャンプします。最初のAI返信から、AIはプレイヤーキャラクターのすべてを知っています。


これから作るもの

最初のメッセージはストーリーではなく、キャラクター作成フォームです。フォームはRoot Componentによってレンダリングされ、以下を含みます:

  • テキスト入力 — プレイヤーがキャラクター名を入力
  • 3つのクラス選択ボタン — Warrior / Mage / Rogue
  • テキストエリア — プレイヤーがバックストーリーを書く
  • 「Start Adventure」ボタン — クリックすると全情報が変数に保存され、本物のストーリーオープニングにジャンプ

ジャンプ後、ロアエントリ内の{{player_name}}{{player_class}}{{player_backstory}}マクロは、プレイヤーが入力した内容でエンジンによって自動的に置き換えられます。AIが最初の返信を書く時点で、すでに完全なキャラクタープロフィールを持っています。

前提条件

このレシピはRecipe #1の2つのコアテクニックの上に直接構築されます:

テクニックソースこのレシピでの使い方
switchGreeting(index)でオープニング間をジャンプRecipe #1 Part 1プレイヤーがフォームに入力した後、「作成画面」から「ストーリーオープニング」にジャンプ
エントリ内容での{{variableId}}マクロ置換Recipe #1 Part 2エントリ内の{{player_name}}などのマクロは、プロンプトビルド時にプレイヤー入力で置換

Recipe #1をまだ読んでいない場合は、まずそこから始めてください:UIによるシーンジャンプとエントリ切り替え

仕組み

完全なシーケンス:

1. プレイヤーが新規セッションを開始 → グリーティング#1(キャラクター作成フォーム)を見る
2. Root Componentの`<Chat renderBubble>`が`msg.messageIndex === 0`を検出、フォームUIをレンダリング
3. プレイヤーが名前を入力、クラスを選択、バックストーリーを書く
4. プレイヤーが「Start Adventure」をクリック
   → コードが 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:変数の作成

プレイヤーのキャラクター情報を保存する3つの文字列変数が必要です。

エディタ → サイドバー → Variables タブ → 「Add Variable」をクリックし、これら3つを作成:

変数1:Character Name

フィールド理由
Display NameCharacter Nameエディタ内で自分用の識別名
IDplayer_nameエントリ内の{{player_name}}マクロがこのIDを参照
TypeString名前はテキスト
Default ValueTravelerプレイヤーが名前を入力せずに開始した場合、AIは「Traveler」と呼ぶ
CategoryCustom整理ラベル、純粋に管理用
Behavior RulesDo not modify this variable. It is set by the player via the character creation form.AIにキャラクターの名前を自分で変更しないよう伝える

変数2:Character Class

フィールド理由
Display NameCharacter Class自分用の識別名
IDplayer_classエントリ内の{{player_class}}マクロがこのIDを参照
TypeStringクラスはテキスト("Warrior"、"Mage"、"Rogue")
Default Value空のまま空は未選択を意味。Root Componentはこの値をチェックしてどのボタンをハイライトするか決める
CategoryCustom整理ラベル
Behavior RulesDo not modify this variable. It is set by the player via the character creation form.AIにクラスを自分で変更しないよう伝える

変数3:Character Backstory

フィールド理由
Display NameCharacter Backstory自分用の識別名
IDplayer_backstoryエントリ内の{{player_backstory}}マクロがこのIDを参照
TypeStringバックストーリーはテキスト
Default Value空のまま空 = プレイヤーがバックストーリーを書かなかった。エントリ内の対応箇所は空文字列になる
CategoryCustom整理ラベル
Behavior RulesDo not modify this variable. It is set by the player via the character creation form.AIにバックストーリーを自分で変更しないよう伝える

なぜplayer_nameにはデフォルト値があり、他の2つにはないのか? 名前はほぼすべてのシナリオで必要だからです — AIはキャラクターを何かと呼ぶ必要があります。「Traveler」のフォールバック値は、AIが返信で気まずい空白や「名前のないキャラクター」を書くのを防ぎます。クラスとバックストーリーは空でも問題ありません — AIは合理的に無視するか即興で対応できます。


ステップ2:「First Message」で2つのグリーティングを作成

エディタを開き、サイドバーのFirst Messageタブをクリック。

最初のグリーティング(キャラクター作成画面)を作成:

「Create First Message」ボタンをクリック。テキストボックスに次のように書きます:

*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番目のグリーティング(本物のストーリーオープニング)を作成:

下の「Add Greeting」ボタンをクリック。タブ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?

グリーティング内でもマクロは動作する

2番目のグリーティングの{{player_name}}{{player_class}}に注目。これらのマクロは表示時に変数の現在の値に置換されます。そのため、プレイヤーがフォームに入力し変数がsetVariableで更新された後、switchGreeting(1)がこのグリーティングに切り替わると、プレイヤーはストーリーオープニング内に自分のキャラクター名とクラスを見ます。

グリーティングの順序 = インデックス

タブ1 = インデックス0(キャラクター作成画面、デフォルト表示)、タブ2 = インデックス1(ストーリーオープニング)。Root Component内のswitchGreeting(1)呼び出しは2番目にジャンプします。


ステップ3:マクロを使用するロアエントリを作成

これで、AIに送られるすべてのプロンプトにキャラクター情報を注入するエントリを作成します。

エディタ → Entries タブ → 新規エントリを作成

フィールド理由
NamePlayer Character Profile自分用の識別名
SectionSystem Presetsプリセットセクションのエントリは常にAIに送信される
EnabledYes(トグルオン)常時アクティブ — キャラクター情報は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();

  // ---- Form state ----
  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 || "")
  );

  // Check whether character creation is already done (class is set = form was submitted)
  const hasCreated = String(api.variables.player_class || "") !== "";

  // Class list
  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" },
  ];

  // Handle "Start Adventure"
  const handleStart = () => {
    if (!selectedClass) return; // Must pick a class first
    api.setVariable("player_name", name.trim() || "Traveler");
    api.setVariable("player_class", selectedClass);
    api.setVariable("player_backstory", backstory.trim());
    api.switchGreeting?.(1); // Jump to greeting #2 (story opening)
  };

  return (
    <Chat renderBubble={(msg) => (
    <div>
      {/* Render message text (platform already rendered HTML, use msg.contentHtml directly) */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
      />

      {/* Character creation form — only on first message & not yet created */}
      {msg.messageIndex === 0 && !hasCreated && (
        <div
          style={{
            marginTop: "20px",
            padding: "24px",
            background: "linear-gradient(135deg, #1e1b4b 0%, #1a1a2e 100%)",
            borderRadius: "16px",
            border: "1px solid #312e81",
          }}
        >
          {/* Title */}
          <div
            style={{
              fontSize: "18px",
              fontWeight: "bold",
              color: "#c4b5fd",
              marginBottom: "20px",
              textAlign: "center",
            }}
          >
            Create Your Character
          </div>

          {/* Name input */}
          <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>

          {/* Class selection */}
          <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>

          {/* Backstory */}
          <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>

          {/* Start Adventure button */}
          <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 — 入力フィールド、クラスボタン、テキストエリアを追跡する3つのReact状態
  • React.useState(String(api.variables.player_name || "")) — 初期値は変数から読み取り。新規セッションではデフォルト、既存セッションでは保存された変数から復元
  • hasCreatedplayer_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. エディタ上部のSaveをクリック
  2. Start Gameをクリックするか、ホームページに戻って新規セッションを開始
  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」と呼びかけ、Mageクラスに基づくインタラクションを書く

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) — まずクラスをクリック
ボタンをクリックしたがグリーティングが切り替わらないグリーティングが1つしかないFirst Message タブに2つのグリーティング(タブ1とタブ2)があることを確認
グリーティングが切り替わったが{{player_name}}が生のテキストとして見えるマクロが置換されていない変数IDが正しく綴られていることを確認(player_nameplayerNameではない)
AI返信がキャラクター名を使わないエントリがアクティブでないロアエントリが有効で、内容に{{player_name}}が含まれていることを確認
AI返信がデフォルトの「Traveler」を使うsetVariableswitchGreetingの後に呼ばれたコードがswitchGreetingの前にsetVariableを呼ぶことを確認
キャラクター作成後もフォームが表示される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になります。

ビヘイビアルールとの組み合わせ

Recipe #1と同様に、クラスに基づいて異なるロアエントリを自動的に有効/無効にできます。例:

  1. ノレッジベースに「Warrior Lore」、「Mage Lore」、「Rogue Lore」エントリを作成、デフォルトで無効
  2. Behaviorsタブで、player_classが一致したとき対応するエントリを有効にする3つのビヘイビアを作成
  3. handleStart内にapi.executeAction("choose-class-warrior")のような呼び出しを追加

これで各クラスは異なるラベルだけでなく、まったく異なる世界観とAI動作を持ちます。

後続メッセージにキャラクター情報を表示

Root Componentの<Chat renderBubble>に「キャラクター情報バー」を追加して、各メッセージの上部にキャラクター名とクラスを表示できます:

tsx
{/* In the return, above the message content */}
{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>
)}

クイックリファレンス

やりたいこと方法
プレイヤー入力テキストを保存文字列変数を作成 + api.setVariable("id", value)
選択ボタンを構築React状態で選択を追跡 + クリックでsetSelectedClass(id)
フォーム送信後に異なるオープニングにジャンプまずすべての値をsetVariable、次にswitchGreeting(index)
AIにキャラクター情報を知らせるエントリ内容で{{variableId}}マクロを使用 — エンジンがプロンプトビルド時に置換
フォームを一度だけ表示hasCreatedの変数をチェック — 作成後にフォームが消える
条件が満たされるまでボタンを無効化disabled={!condition} + 対応するグレーアウトスタイル
グリーティング内でもキャラクター情報を表示グリーティングテキストに直接{{player_name}}などのマクロを書く

自分で試す — インポート可能なデモワールド

このJSONをダウンロードし、新規ワールドとしてインポートしてすべての動作を確認してください:

recipe-4-demo.json

インポート方法:

  1. Yumina → My WorldsCreate New World に移動
  2. エディタで More ActionsImport Package をクリック
  3. ダウンロードした.jsonファイルを選択
  4. 全グリーティング、変数、Root Componentが事前設定された新規ワールドが作成されます
  5. 新規セッションを開始して試す

含まれるもの:

  • 2つのグリーティング(キャラクター作成フォーム + ストーリーオープニング)
  • 3つの変数(名前用player_name、クラス用player_class、バックストーリー用player_backstory
  • 1つのロアエントリ({{player_name}}{{player_class}}{{player_backstory}}マクロを使用するキャラクタープロフィール)
  • 完全なRoot Component(キャラクター作成フォームUI)

これはRecipe #4です

Recipe #1ではボタンベースのグリーティング切り替えとマクロ置換を学びました。このレシピはそれらを完全なキャラクター作成フローに組み合わせます。今後のレシピはこの基盤の上にさらに構築していきます — 能力値割り当て、装備選択、マルチステップオンボーディングなど。