Skip to content

アチーブメントシステム

完全なアチーブメントシステムを構築しましょう。プレイヤーが特定のマイルストーン(ゴールド100超、戦闘勝利5回以上、隠しエリアの発見...)に到達すると、画面に金色のアチーブメント通知がポップアップします。boolean型変数で各アチーブメントの解除状況を追跡し、Root Componentでアチーブメントパネルを表示します。


これから作るもの

チャット内に組み込まれたアチーブメントシステム:

  • 金色のポップアップ通知 — プレイヤーがマイルストーンに到達した瞬間、画面に金色のアチーブメントトースト(achievementスタイル)が表示されます。例:「Achievement Unlocked: Big Spender」
  • 自動検出 — エンジンが背景で変数の変化を監視し、条件を満たすと自動的にトリガーされます。プレイヤーの操作は不要です
  • 一度きりの発火保証 — 各アチーブメントは正確に1回だけ解除され、二度とポップアップしません。maxFireCountとboolean変数の二重のセーフティネットがあります
  • アチーブメントパネル — 最後のメッセージの下に小さなパネルが表示され、全アチーブメントと解除状況がリストされます(解除済み = 金色アイコン、未解除 = グレーの鍵)

仕組み

中心のループはこうです:変数が変化 → エンジンが閾値超過を検出 → ビヘイビアが発火 → 通知がポップアップ + boolean変数がtrueに設定

プレイヤーが冒険中にゴールドを101貯める
  → エンジンがgoldの100超えを検出
  → 「Big Spender」ビヘイビアが発火
  → アクション実行:achievement_richをtrueに、金色通知「Achievement Unlocked: Big Spender」
  → maxFireCount: 1 によりこのビヘイビアは二度と発火しない
  → Root Componentがachievement_rich = trueを読み取り、パネルに金色のトロフィーアイコンを表示

ここで重要な設計判断があります:なぜstate-changeではなくvariable-crossedを使うのか?

  • state-changeは「任意の変数が変わるたびにチェック」という意味で、非常に広範です。state-change + 条件gold gt 100を使うと、goldが101→102、102→103...と変化するたびに毎回条件が再評価されます。maxFireCount: 1で再発火は防げますが、エンジンは毎回無意味な評価を行います。
  • variable-crossedは「goldが100以下から100超に変わった瞬間だけ発火」という意味で、正確かつ効率的です。maxFireCount: 1と組み合わせると、二重のセーフティネットになります。

ステップバイステップ

ステップ1:変数の作成

5つの変数が必要です。進捗を追跡する2つの数値変数と、各アチーブメントの解除状況を追跡する3つのboolean変数です。

エディタ → 左サイドバー → Variables タブ → 各変数について「Add Variable」をクリック

変数1:Gold

フィールド理由
Display NameGold変数リスト内で自分が識別するため
IDgoldビヘイビアとRoot ComponentはこのIDで読み書きします
TypeNumberゴールドは数値で、算術演算が必要です
Default Value0新規セッションは0ゴールドで開始
CategoryStatsキャラクター属性とまとめる
Behavior RulesCurrent gold count. The AI can modify this via directives when the narrative calls for it.これが何で、どう使うかをAIに伝える

変数2:Combat wins

フィールド理由
Display NameCombat Wins識別しやすい名前
IDcombat_winsビヘイビアから参照される
TypeNumberカウンタです
Default Value00から開始
CategoryStatsキャラクター属性
Behavior RulesCumulative number of battles the player has won. The AI can +1 this via directive when the player wins a fight.いつ加算するかをAIに伝える

変数3:Achievement — Big Spender

フィールド理由
Display NameAchievement: Big Spender識別しやすい名前
IDachievement_rich全アチーブメント変数はachievement_プレフィックスを使用
TypeBoolean2状態のみ:解除済みまたは未解除
Default Valuefalse開始時は未解除
CategoryAchievements全アチーブメント変数を1つのカテゴリにまとめて管理しやすくする
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.アチーブメントはビヘイビア経由で発火させ通知を正しく表示する必要がある

変数4:Achievement — First Blood

フィールド理由
Display NameAchievement: First Blood識別しやすい名前
IDachievement_warrior同じプレフィックス規則
TypeBoolean同上
Default Valuefalse開始時は未解除
CategoryAchievements同上
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.同じ理由

変数5:Achievement — Trailblazer

フィールド理由
Display NameAchievement: Trailblazer識別しやすい名前
IDachievement_explorer同じプレフィックス規則
TypeBoolean同上
Default Valuefalse開始時は未解除
CategoryAchievements同上
Behavior RulesDo not modify this variable directly — achievements are unlocked automatically by behavior rules when conditions are met, which also triggers a notification. Modifying it manually bypasses the notification system.同じ理由

なぜアチーブメントごとに別々のboolean変数を使うのか?

Root Componentがパネル表示のために各アチーブメントの状態を読み取る必要があるからです。再発火防止にmaxFireCountだけに頼ると、コンポーネントは「このアチーブメントは解除済みか?」を知る手段がありません — ビヘイビアの発火回数は見えないのです。boolean変数は、Root Componentや他のビヘイビアが読める公開状態です。


ステップ2:ビヘイビアの作成

3つのビヘイビアが必要です。各アチーブメントに1つです。

エディタ → 左サイドバー → Behaviors タブ → 各ビヘイビアについて「Add Behavior」をクリック

ビヘイビア1:Big Spender(gold > 100)

基本情報:

フィールド理由
NameAchievement: Big Spender自分用の識別名
Max Fire Count1アチーブメントは一度だけ解除 — 発火後は二度と実行されない

トリガー(WHEN):

フィールド理由
Trigger TypeVariable Crossed Threshold (variable-crossed)goldが100を越える瞬間を検出したい
Variable IDgoldgoldを監視
DirectionRises Above (rises-above)goldが100以下から100超になったときに発火
Threshold100マイルストーン値

アクション(DO):

Action Type設定目的
Set Variableachievement_richtrueに設定Root Componentが読み取れるよう解除済みとマークする
Show NotificationメッセージAchievement Unlocked: Big Spender、スタイルachievement金色のアチーブメントトーストを表示

maxFireCount: 1について。 このフィールドはビヘイビア自体に設定します(トリガーではありません)。「このビヘイビアは合計で最大1回まで実行可能」という意味です。一度発火すると、その後goldがどう変化しても二度と実行されません。これがアチーブメントシステムの中心的なセーフガード — 同じアチーブメントが2回ポップアップするのは誰も望みません。

ビヘイビア2:First Blood(combat wins > 5)

基本情報:

フィールド理由
NameAchievement: First Blood自分用の識別名
Max Fire Count1同上

トリガー(WHEN):

フィールド理由
Trigger TypeVariable Crossed Threshold (variable-crossed)combat_winsが5を越える瞬間を検出
Variable IDcombat_wins戦闘勝利数を監視
DirectionRises Above (rises-above)combat_winsが5以下から5超になったときに発火
Threshold5マイルストーン値

アクション(DO):

Action Type設定目的
Set Variableachievement_warriortrueに設定解除済みとマーク
Show NotificationメッセージAchievement Unlocked: First Blood、スタイルachievement金色のアチーブメントトーストを表示

ビヘイビア3:Trailblazer(キーワードトリガー)

このアチーブメントは最初の2つと異なります — 数値閾値ではなくメッセージ内容を監視します。プレイヤーが「explore」と言ったり、AIが「discover」と言ったとき、アチーブメントがまだ解除されていなければ発火します。

基本情報:

フィールド理由
NameAchievement: Trailblazer自分用の識別名
Max Fire Count1同上

トリガー(WHEN):

このアチーブメントは2つの情報源を監視する必要があります — プレイヤーメッセージとAIメッセージです。Yuminaでは1つのビヘイビアに1つのトリガーしか持てないため、両方をカバーするために2つのビヘイビアを作成する必要があります。

最もシンプルなアプローチは、2つのビヘイビアを作成することです:

ビヘイビア3a:Trailblazer(プレイヤーキーワード)

フィールド理由
Trigger TypePlayer Said Keyword (keyword)プレイヤーメッセージを監視
Keywordexploreプレイヤーが「I want to explore」と言ったときに一致
Max Fire Count1一度だけ発火

条件(ONLY IF):

Variable ID演算子理由
achievement_explorerEquals (eq)falseアチーブメントがまだ解除されていない場合のみ発火

アクション(DO):

Action Type設定目的
Set Variableachievement_explorertrueに設定解除済みとマーク
Show NotificationメッセージAchievement Unlocked: Trailblazer、スタイルachievement金色のアチーブメントトーストを表示

ビヘイビア3b:Trailblazer(AIキーワード)

フィールド理由
Trigger TypeAI Said Keyword (ai-keyword)AIの返信を監視
KeyworddiscoverAIが「discover」に言及したときに一致
Max Fire Count1一度だけ発火

条件とアクションはビヘイビア3aと同じです。

なぜ条件achievement_explorer eq falseが必要なのか? 2つのビヘイビア(3aと3b)が両方とも同じアチーブメントを解除できるからです。ビヘイビア3aが先に発火すると仮定しましょう — achievement_explorertrueに設定し、自身のmaxFireCountを消費します。しかしビヘイビア3bのmaxFireCountはまだ未使用です!条件がなければ、次にキーワードが一致したときビヘイビア3bも発火し、プレイヤーは2つの通知を見ることになります。条件があれば、ビヘイビア3bはachievement_explorerがすでにtrueであることをチェックし、条件が満たされず発火しません。


ステップ3:Root Componentにアチーブメントパネルを追加

これがチャット内にアチーブメントパネルを表示させるための重要なステップです。パネルは最後のメッセージの下にのみ表示されます。

エディタ → Custom UI セクション → index.tsxを開く → 以下を貼り付け(デフォルトのreturn <Chat />を置き換え):

tsx
export default function MyWorld() {
  const api = useYumina();
  const msgs = api.messages || [];

  // Achievement list definition
  const achievements = [
    {
      id: "achievement_rich",
      name: "Big Spender",
      desc: "Accumulate over 100 gold",
      icon: "💰",
    },
    {
      id: "achievement_warrior",
      name: "First Blood",
      desc: "Win more than 5 battles",
      icon: "⚔️",
    },
    {
      id: "achievement_explorer",
      name: "Trailblazer",
      desc: "Discover a hidden area or secret",
      icon: "🗺️",
    },
  ];

  // Count unlocked achievements
  const unlockedCount = achievements.filter(
    (a) => api.variables[a.id] === true
  ).length;

  return (
    <Chat renderBubble={(msg) => {
      const isLastMsg = msg.messageIndex === msgs.length - 1;
      return (
    <div>
      {/* Render message text normally (platform already produced HTML — just use contentHtml) */}
      <div
        style={{ color: "#e2e8f0", lineHeight: 1.7 }}
        dangerouslySetInnerHTML={{ __html: msg.contentHtml }}
      />

      {/* Achievement panel — only below the last message */}
      {isLastMsg && (
        <div
          style={{
            marginTop: "16px",
            padding: "12px 16px",
            background: "linear-gradient(135deg, #1c1917, #292524)",
            border: "1px solid #44403c",
            borderRadius: "10px",
          }}
        >
          {/* Panel header */}
          <div
            style={{
              display: "flex",
              justifyContent: "space-between",
              alignItems: "center",
              marginBottom: "10px",
            }}
          >
            <span
              style={{
                fontSize: "13px",
                fontWeight: "bold",
                color: "#fbbf24",
                letterSpacing: "0.05em",
              }}
            >
              🏆 Achievements
            </span>
            <span style={{ fontSize: "12px", color: "#a8a29e" }}>
              {unlockedCount} / {achievements.length}
            </span>
          </div>

          {/* Achievement list */}
          <div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
            {achievements.map((a) => {
              const unlocked = api.variables[a.id] === true;
              return (
                <div
                  key={a.id}
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: "10px",
                    padding: "6px 8px",
                    borderRadius: "6px",
                    background: unlocked
                      ? "rgba(251, 191, 36, 0.08)"
                      : "rgba(120, 113, 108, 0.08)",
                  }}
                >
                  {/* Icon */}
                  <span style={{ fontSize: "18px", opacity: unlocked ? 1 : 0.3 }}>
                    {unlocked ? a.icon : "🔒"}
                  </span>

                  {/* Text */}
                  <div style={{ flex: 1, minWidth: 0 }}>
                    <div
                      style={{
                        fontSize: "13px",
                        fontWeight: "600",
                        color: unlocked ? "#fbbf24" : "#78716c",
                      }}
                    >
                      {a.name}
                    </div>
                    <div
                      style={{
                        fontSize: "11px",
                        color: unlocked ? "#a8a29e" : "#57534e",
                        marginTop: "1px",
                      }}
                    >
                      {a.desc}
                    </div>
                  </div>

                  {/* Status badge */}
                  {unlocked && (
                    <span style={{ fontSize: "11px", color: "#fbbf24" }}>
                      ✓ Unlocked
                    </span>
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
      );
    }} />
  );
}

行ごとの解説:

  • MyWorld()はRoot Component — ワールドのUIエントリーポイントです。<Chat renderBubble={...} />はメッセージリスト、入力ボックス、スクロールをプラットフォームに任せ、バブルごとのレイアウトのみカスタマイズします
  • const api = useYumina() — Yumina APIを取得して変数の状態を読み取る
  • msg.messageIndex === msgs.length - 1 — パネルが各メッセージで繰り返されないよう、最後のメッセージにのみ表示
  • msg.contentHtml — プラットフォームがすでにMarkdownをHTMLにレンダリング済み。そのままdangerouslySetInnerHTMLに投入
  • achievements配列 — 全アチーブメントメタデータ(ID、名前、説明、アイコン)をRoot Component内で定義。新しいアチーブメントを追加したい?この配列にエントリを追加するだけ
  • api.variables[a.id] === true — boolean変数の値を読み取りアチーブメントの解除を確認
  • unlockedCount — 解除済みの数を集計し、ヘッダーに表示(例:「2 / 3」)
  • 未解除のアチーブメントはグレーの鍵アイコン、解除済みは金色アイコンと「Unlocked」バッジを表示

自分でコードを書きたくない?Studio AIを使おう

エディタ上部 → 「Enter Studio」をクリック → AI Assistantパネル → 欲しいものを平易な言葉で説明すると、AIがコードを生成します。


ステップ4:保存してテスト

  1. エディタ上部のSaveをクリック
  2. Start Gameをクリックするか、ホームページに戻って新規セッションを開始
  3. 最後のメッセージの下にアチーブメントパネルが見えるはず — 3つすべてがグレーアウトされ鍵アイコン付き
  4. ゴールドアチーブメントのテスト:AIとチャットしてキャラクターに100超のゴールドを獲得させます。goldが100以下から100超になったとき、金色通知「Achievement Unlocked: Big Spender」がポップアップし、パネルの最初のアチーブメントが金色になります
  5. 戦闘アチーブメントのテスト:キャラクターに6回戦闘で勝たせます。combat_winsが5から6になったとき、通知「Achievement Unlocked: First Blood」が表示されます
  6. 探索アチーブメントのテスト:「explore」を含むメッセージを送信(例:「I want to explore this cave」)。キーワードが一致すれば、通知「Achievement Unlocked: Trailblazer」が表示されます

うまく動かない場合:

症状想定される原因対処
アチーブメントパネルが見えないRoot Componentのコードが保存されていない、または構文エラーCustom UIパネル下部のコンパイル状態を確認 — 緑の「OK」が表示されるべき
ゴールドが100を超えたが通知なし変数が100以下から100超に「クロス」していない — 200に直接設定されたゴールドが段階的に変化することを確認(AIがディレクティブで加減算)。大きな数値への一括ジャンプは避ける
アチーブメントが2回ポップアップビヘイビアのmaxFireCountが1に設定されていないエディタに戻ってビヘイビア設定を確認
探索アチーブメントが2回ポップアップビヘイビア3aと3bの両方が発火し、条件チェックが欠落両方のビヘイビアに条件achievement_explorer eq falseがあることを確認
パネル状態が更新されないRoot Componentコード内の変数IDのスペルミスapi.variables[a.id]a.idが変数IDと正確に一致するか確認

深掘り:variable-crossed vs state-change

これはアチーブメントシステムにおける最も重要な概念的区別です — 詳しく見ていく価値があります。

variable-crossed(変数閾値クロス)

瞬間的なイベントを検出します:「変数が閾値の片側からもう一方の側にクロスした」。

gold: 80 → 95 → 101   ← 95→101ステップで発火(100を上回ってクロス)
gold: 101 → 150 → 200  ← 発火しない(既に閾値を超えている)
gold: 200 → 50 → 120   ← 50→120ステップで発火(再び100を上回ってクロス)

主な特徴:

  • クロスする瞬間にのみ発火、「閾値を超えている間ずっと発火し続ける」のではない
  • 値が閾値以下に戻って再び上昇すると再度発火する(maxFireCountが防がない限り)
  • 用途:アチーブメント解除、マイルストーン通知、HP=0の死亡チェック

state-change(変数変化)

継続的なイベントを検出します:「任意の変数が何でも変化した」。

gold: 80 → 95   ← 発火(goldが変化)
gold: 95 → 101  ← 発火(goldが再び変化)
gold: 101 → 150 ← 発火(goldがまだ変化中)
hp: 100 → 90    ← これも発火(hpが変化)

主な特徴:

  • 任意の変数の任意の変化でトリガー
  • フィルタリングに条件(ONLY IF)が必要
  • 用途:一般的な状態監視、現在の状態に基づくワールドコンテキストの切り替え

なぜvariable-crossedがアチーブメントに適しているか

アチーブメントはマイルストーンだからです — 線をクロスする瞬間だけを気にします。state-change + 条件gold gt 100を使うと:

  1. goldが95から101へ → トリガー → 条件満足 → 実行(正解)
  2. goldが101から102へ → トリガー → 条件満足 → 再び実行を試みる(間違い!maxFireCountがブロックするが、エンジンはまだ無意味な評価を行った)
  3. goldが102から103へ → 再びトリガー → 再び条件をチェック...

variable-crossedでは:

  1. goldが95から101へ → 100を上回るクロスを検出 → 発火 → 実行(正解)
  2. goldが101から102へ → クロスイベントなし → まったく発火しない(効率的)

要点:正確なトリガー = 無駄な評価が減る = パフォーマンスとロジックの両方が良くなる


拡張アイデア

基本の3アチーブメントを構築したら、同じパターンでさらに拡張できます:

アチーブメント名変数IDトリガー方法条件
Chatterboxachievement_talkativemessage_count変数を作成、毎ターン+1、50を超えたら発火variable-crossedmessage_count rises above 50
Hoarderachievement_hoardergoldが500を超えたら発火variable-crossedgold rises above 500
Socialiteachievement_socialAIがキーワード「become friends」または「trusts you」と言うai-keyword、条件achievement_social eq false
Back from the Deadachievement_survivorHPが10未満(瀕死)になり、後にHPが50を超える(回復)にクロス2つの連動ビヘイビア

新しいアチーブメントを追加するには:

  1. boolean変数を追加(achievement_xxx、デフォルトfalse
  2. ビヘイビアを追加(トリガー + アクション + maxFireCount: 1
  3. Root Componentのachievements配列にエントリを追加

クイックリファレンス

やりたいこと方法
数値が目標に達したらアチーブメント解除ビヘイビアトリガー:「Variable Crossed Threshold」(variable-crossed)、方向:rises above、閾値を設定
キーワードでアチーブメントをトリガービヘイビアトリガー:「Player Said Keyword」(keyword) または「AI Said Keyword」(ai-keyword)
アチーブメントが1回だけ発火することを保証ビヘイビアにmaxFireCount: 1を設定。キーワードトリガーの場合は条件achievement_xxx eq falseも追加
金色のアチーブメント通知をポップアップビヘイビアアクション:Show Notification、スタイルachievement
チャットにアチーブメントパネルを表示Root Componentがboolean変数を読み取って解除/未解除の状態をレンダリング
新しいアチーブメントを追加boolean変数を追加 + ビヘイビアを追加 + Root Componentのachievements配列にエントリを追加

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

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

recipe-13-demo.json

インポート方法:

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

含まれるもの:

  • 5つの変数(goldcombat_winsachievement_richachievement_warriorachievement_explorer
  • 4つのビヘイビア(Big Spender、First Blood、Trailblazer x2)
  • アチーブメントパネル付きのRoot Component

これはRecipe #13です

アチーブメントシステムは他のレシピと自由に組み合わせられます — 戦闘システムとペアリングして戦闘勝利を追跡、ショップシステムとペアリングしてゴールド蓄積を追跡、クエストトラッカーとペアリングして完了クエストを追跡できます。変数は普遍的で、ビヘイビアは互いに干渉しません。