アチーブメントシステム
完全なアチーブメントシステムを構築しましょう。プレイヤーが特定のマイルストーン(ゴールド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 Name | Gold | 変数リスト内で自分が識別するため |
| ID | gold | ビヘイビアとRoot ComponentはこのIDで読み書きします |
| Type | Number | ゴールドは数値で、算術演算が必要です |
| Default Value | 0 | 新規セッションは0ゴールドで開始 |
| Category | Stats | キャラクター属性とまとめる |
| Behavior Rules | Current gold count. The AI can modify this via directives when the narrative calls for it. | これが何で、どう使うかをAIに伝える |
変数2:Combat wins
| フィールド | 値 | 理由 |
|---|---|---|
| Display Name | Combat Wins | 識別しやすい名前 |
| ID | combat_wins | ビヘイビアから参照される |
| Type | Number | カウンタです |
| Default Value | 0 | 0から開始 |
| Category | Stats | キャラクター属性 |
| Behavior Rules | Cumulative 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 Name | Achievement: Big Spender | 識別しやすい名前 |
| ID | achievement_rich | 全アチーブメント変数はachievement_プレフィックスを使用 |
| Type | Boolean | 2状態のみ:解除済みまたは未解除 |
| Default Value | false | 開始時は未解除 |
| Category | Achievements | 全アチーブメント変数を1つのカテゴリにまとめて管理しやすくする |
| Behavior Rules | Do 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 Name | Achievement: First Blood | 識別しやすい名前 |
| ID | achievement_warrior | 同じプレフィックス規則 |
| Type | Boolean | 同上 |
| Default Value | false | 開始時は未解除 |
| Category | Achievements | 同上 |
| Behavior Rules | Do 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 Name | Achievement: Trailblazer | 識別しやすい名前 |
| ID | achievement_explorer | 同じプレフィックス規則 |
| Type | Boolean | 同上 |
| Default Value | false | 開始時は未解除 |
| Category | Achievements | 同上 |
| Behavior Rules | Do 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)
基本情報:
| フィールド | 値 | 理由 |
|---|---|---|
| Name | Achievement: Big Spender | 自分用の識別名 |
| Max Fire Count | 1 | アチーブメントは一度だけ解除 — 発火後は二度と実行されない |
トリガー(WHEN):
| フィールド | 値 | 理由 |
|---|---|---|
| Trigger Type | Variable Crossed Threshold (variable-crossed) | goldが100を越える瞬間を検出したい |
| Variable ID | gold | goldを監視 |
| Direction | Rises Above (rises-above) | goldが100以下から100超になったときに発火 |
| Threshold | 100 | マイルストーン値 |
アクション(DO):
| Action Type | 設定 | 目的 |
|---|---|---|
| Set Variable | achievement_richをtrueに設定 | Root Componentが読み取れるよう解除済みとマークする |
| Show Notification | メッセージAchievement Unlocked: Big Spender、スタイルachievement | 金色のアチーブメントトーストを表示 |
maxFireCount: 1について。 このフィールドはビヘイビア自体に設定します(トリガーではありません)。「このビヘイビアは合計で最大1回まで実行可能」という意味です。一度発火すると、その後goldがどう変化しても二度と実行されません。これがアチーブメントシステムの中心的なセーフガード — 同じアチーブメントが2回ポップアップするのは誰も望みません。
ビヘイビア2:First Blood(combat wins > 5)
基本情報:
| フィールド | 値 | 理由 |
|---|---|---|
| Name | Achievement: First Blood | 自分用の識別名 |
| Max Fire Count | 1 | 同上 |
トリガー(WHEN):
| フィールド | 値 | 理由 |
|---|---|---|
| Trigger Type | Variable Crossed Threshold (variable-crossed) | combat_winsが5を越える瞬間を検出 |
| Variable ID | combat_wins | 戦闘勝利数を監視 |
| Direction | Rises Above (rises-above) | combat_winsが5以下から5超になったときに発火 |
| Threshold | 5 | マイルストーン値 |
アクション(DO):
| Action Type | 設定 | 目的 |
|---|---|---|
| Set Variable | achievement_warriorをtrueに設定 | 解除済みとマーク |
| Show Notification | メッセージAchievement Unlocked: First Blood、スタイルachievement | 金色のアチーブメントトーストを表示 |
ビヘイビア3:Trailblazer(キーワードトリガー)
このアチーブメントは最初の2つと異なります — 数値閾値ではなくメッセージ内容を監視します。プレイヤーが「explore」と言ったり、AIが「discover」と言ったとき、アチーブメントがまだ解除されていなければ発火します。
基本情報:
| フィールド | 値 | 理由 |
|---|---|---|
| Name | Achievement: Trailblazer | 自分用の識別名 |
| Max Fire Count | 1 | 同上 |
トリガー(WHEN):
このアチーブメントは2つの情報源を監視する必要があります — プレイヤーメッセージとAIメッセージです。Yuminaでは1つのビヘイビアに1つのトリガーしか持てないため、両方をカバーするために2つのビヘイビアを作成する必要があります。
最もシンプルなアプローチは、2つのビヘイビアを作成することです:
ビヘイビア3a:Trailblazer(プレイヤーキーワード)
| フィールド | 値 | 理由 |
|---|---|---|
| Trigger Type | Player Said Keyword (keyword) | プレイヤーメッセージを監視 |
| Keyword | explore | プレイヤーが「I want to explore」と言ったときに一致 |
| Max Fire Count | 1 | 一度だけ発火 |
条件(ONLY IF):
| Variable ID | 演算子 | 値 | 理由 |
|---|---|---|---|
achievement_explorer | Equals (eq) | false | アチーブメントがまだ解除されていない場合のみ発火 |
アクション(DO):
| Action Type | 設定 | 目的 |
|---|---|---|
| Set Variable | achievement_explorerをtrueに設定 | 解除済みとマーク |
| Show Notification | メッセージAchievement Unlocked: Trailblazer、スタイルachievement | 金色のアチーブメントトーストを表示 |
ビヘイビア3b:Trailblazer(AIキーワード)
| フィールド | 値 | 理由 |
|---|---|---|
| Trigger Type | AI Said Keyword (ai-keyword) | AIの返信を監視 |
| Keyword | discover | AIが「discover」に言及したときに一致 |
| Max Fire Count | 1 | 一度だけ発火 |
条件とアクションはビヘイビア3aと同じです。
なぜ条件
achievement_explorer eq falseが必要なのか? 2つのビヘイビア(3aと3b)が両方とも同じアチーブメントを解除できるからです。ビヘイビア3aが先に発火すると仮定しましょう —achievement_explorerをtrueに設定し、自身のmaxFireCountを消費します。しかしビヘイビア3bのmaxFireCountはまだ未使用です!条件がなければ、次にキーワードが一致したときビヘイビア3bも発火し、プレイヤーは2つの通知を見ることになります。条件があれば、ビヘイビア3bはachievement_explorerがすでにtrueであることをチェックし、条件が満たされず発火しません。
ステップ3:Root Componentにアチーブメントパネルを追加
これがチャット内にアチーブメントパネルを表示させるための重要なステップです。パネルは最後のメッセージの下にのみ表示されます。
エディタ → Custom UI セクション → index.tsxを開く → 以下を貼り付け(デフォルトのreturn <Chat />を置き換え):
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:保存してテスト
- エディタ上部のSaveをクリック
- Start Gameをクリックするか、ホームページに戻って新規セッションを開始
- 最後のメッセージの下にアチーブメントパネルが見えるはず — 3つすべてがグレーアウトされ鍵アイコン付き
- ゴールドアチーブメントのテスト:AIとチャットしてキャラクターに100超のゴールドを獲得させます。
goldが100以下から100超になったとき、金色通知「Achievement Unlocked: Big Spender」がポップアップし、パネルの最初のアチーブメントが金色になります - 戦闘アチーブメントのテスト:キャラクターに6回戦闘で勝たせます。
combat_winsが5から6になったとき、通知「Achievement Unlocked: First Blood」が表示されます - 探索アチーブメントのテスト:「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を使うと:
- goldが95から101へ → トリガー → 条件満足 → 実行(正解)
- goldが101から102へ → トリガー → 条件満足 → 再び実行を試みる(間違い!
maxFireCountがブロックするが、エンジンはまだ無意味な評価を行った) - goldが102から103へ → 再びトリガー → 再び条件をチェック...
variable-crossedでは:
- goldが95から101へ → 100を上回るクロスを検出 → 発火 → 実行(正解)
- goldが101から102へ → クロスイベントなし → まったく発火しない(効率的)
要点:正確なトリガー = 無駄な評価が減る = パフォーマンスとロジックの両方が良くなる。
拡張アイデア
基本の3アチーブメントを構築したら、同じパターンでさらに拡張できます:
| アチーブメント名 | 変数ID | トリガー方法 | 条件 |
|---|---|---|---|
| Chatterbox | achievement_talkative | message_count変数を作成、毎ターン+1、50を超えたら発火 | variable-crossed、message_count rises above 50 |
| Hoarder | achievement_hoarder | goldが500を超えたら発火 | variable-crossed、gold rises above 500 |
| Socialite | achievement_social | AIがキーワード「become friends」または「trusts you」と言う | ai-keyword、条件achievement_social eq false |
| Back from the Dead | achievement_survivor | HPが10未満(瀕死)になり、後にHPが50を超える(回復)にクロス | 2つの連動ビヘイビア |
新しいアチーブメントを追加するには:
- boolean変数を追加(
achievement_xxx、デフォルトfalse) - ビヘイビアを追加(トリガー + アクション +
maxFireCount: 1) - 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をダウンロードしてインポートし、すべての動作を確認してください:
インポート方法:
- Yumina → My Worlds → Create New World に移動
- エディタで More Actions → Import Package をクリック
- ダウンロードした
.jsonファイルを選択 - 全変数、ビヘイビア、Root Componentが事前設定された新規ワールドが作成されます
- 新規セッションを開始して試す
含まれるもの:
- 5つの変数(
gold、combat_wins、achievement_rich、achievement_warrior、achievement_explorer) - 4つのビヘイビア(Big Spender、First Blood、Trailblazer x2)
- アチーブメントパネル付きのRoot Component
