Skip to content

ビジュアルノベルモード

チャットインターフェースを完全なビジュアルノベルに変身させましょう — シーン背景、キャラクタースプライト、ダイアログボックス、選択肢ボタン、すべてAIディレクティブで駆動。Root Component(index.tsx)が画面全体を引き継ぎます:<Chat />はもう不要、プレイヤーが話す必要のある場所に<MessageInput />を埋め込んだ独自の全画面レイアウトのみ。プレーンなTailwind + インラインスタイル — 既製のコンポーネントライブラリ不要です。


これから作るもの

全画面のビジュアルノベルインターフェース:

  • シーン背景 — AIがディレクティブで背景画像を切り替え(教室、街、夜空...)、Root Componentが全画面のbackground-imageとして表示
  • キャラクタースプライト — AIがディレクティブで現在の話し手と感情を設定、Root Componentが画面中央に対応する<img>スプライトをレンダリング
  • ダイアログボックス — 画面下部の半透明ボックスにキャラクター名とダイアログを表示。イタリック体テキストは自動的にナレーション/内なる独白として扱われ、プレーンテキストはキャラクターの台詞
  • 選択肢ボタン — AIが選択肢を提示すると、Root Componentが画面上にクリック可能なボタンをオーバーレイ
  • フルスクリーンモード — Root Componentは<Chat />をネストせずに独自の全画面レイアウトを返すため、通常のチャットバブルがまったくない

仕組み

AIが毎回の応答にディレクティブを入れて画面を制御します:

AIの応答:
[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]

*The classroom is bathed in morning light. Cherry blossom petals occasionally drift in through the window.*

Yuki turns to face you with a smile:

Good morning! You're here early today.

エンジンがこれらのディレクティブを解析した後:

  1. current_bg"classroom_morning.jpg" になる → Root Componentがbackground-imageを教室にスワップ
  2. current_speaker"Yuki" になる → ダイアログボックスに名前「Yuki」を表示
  3. speaker_emotion"happy" になる → Root Componentがspeaker + emotionからスプライトパスを組み立ててレンダリング
  4. Root Componentが最新メッセージのテキストを解析 — イタリックセクションはナレーションとして、プレーンテキストはキャラクターダイアログとしてレンダリング
エンジン処理フロー:
  AI応答 → エンジンがディレクティブを抽出 → 変数を更新 → Root Componentが変数を読んで再描画
    → 背景レイヤー:<div style={{ backgroundImage: ... }} />
    → スプライトレイヤー:<img src={`/sprites/${speaker}_${emotion}.png`} />
    → ダイアログボックスレイヤー:ナレーション(グレーのイタリック)とダイアログ(白の通常)を区別
    → 選択肢レイヤー:show_choices = true のときボタンが表示

ステップバイステップ

ステップ1:変数の作成

ビジュアルノベルの表示を制御するために4つの変数が必要です。

エディタ → サイドバー → Variables タブ → 各々「Add Variable」をクリック

変数1:Current Background

フィールド理由
NameCurrent Background自分用の識別名
IDcurrent_bgAIが[current_bg: set "xxx"]で背景を切り替え
TypeString値は画像URLまたはファイル名
Default Valuedefault_bg.jpg新規セッション開始時のデフォルト背景。独自の画像URLに置き換える
CategoryCustomVNシステム専用カテゴリ
Behavior RulesUse [current_bg: set "imageURL"] to switch the scene background. Update this variable whenever the scene changes.この変数をいつどう変更するかAIに伝える

変数2:Current Speaker

フィールド理由
NameCurrent Speaker自分用の識別名
IDcurrent_speakerAIが[current_speaker: set "name"]で話し手を切り替え
TypeString値はキャラクター名
Default ValueNarratorデフォルトはナレーションモード — 特定のキャラクターが話していない
CategoryCustomVNシステム専用カテゴリ
Behavior RulesUse [current_speaker: set "characterName"] to set the current speaker. Set to "Narrator" for narration or inner monologue.使用ルールをAIに伝える

変数3:Speaker Emotion

フィールド理由
NameSpeaker Emotion自分用の識別名
IDspeaker_emotionAIが[speaker_emotion: set "happy"]で表情を切り替え
TypeString値は感情キーワード
Default Valueneutralデフォルトは中立的な表情
CategoryCustomVNシステム専用カテゴリ
Behavior RulesUse [speaker_emotion: set "emotion"] to change the character's expression. Available emotions: neutral, happy, sad, angry, surprised, shy. Update whenever the character's emotion changes.利用可能な感情をリストすることでAIが存在しない表情を作るのを防ぐ

変数4:Show Choices

フィールド理由
NameShow Choices自分用の識別名
IDshow_choicesAIが[show_choices: set true]で選択肢ボタンを表示
TypeBoolean表示/非表示の2状態のみ
Default Valuefalse選択肢ボタンはデフォルトで非表示
CategoryCustomVNシステム専用カテゴリ
Behavior RulesUse [show_choices: set true] when you want to offer the player a choice. Keep it false otherwise.プレイヤーの選択が必要なときだけ有効にするようAIに伝える

なぜAIにディレクティブで画面を制御させるのか?

これがYuminaのコア設計です — AIはコードを実行しません。代わりに、構造化されたディレクティブを使ってエンジンに何をするか伝えます。エンジンがディレクティブを解析、変数を更新、Root Componentが変数を読んで画面を更新します。完全なチェーンは:AIがディレクティブを書く → エンジンが解析 → 変数が更新 → Root Componentが再レンダリング。


ステップ2:ナレッジエントリを作成 — VNシステム指示

AIはビジュアルノベル環境にいることと、ディレクティブで画面を制御する方法を知る必要があります。

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

フィールド理由
NameVisual Novel System Instructions自分用の識別名
SectionPresetsPresetsセクションのエントリは毎回AIに送信される
EnabledYes(トグルオン)常時アクティブ

内容:

[Visual Novel Mode]
You are generating content for a visual novel engine. Every response must include directives to control the screen.

Format rules:
1. Set the scene with directives at the start of your response:
   [current_bg: set "backgroundImageURL"]
   [current_speaker: set "characterName"]
   [speaker_emotion: set "emotion"]

2. Text formatting:
   - *Italic text* = narration or inner monologue. Use for describing environments, character actions, inner thoughts.
   - Plain text (no formatting) = character dialogue/speech.
   - Do not wrap dialogue in quotation marks — just write plain text.

3. When you want to give the player a choice:
   - Use [show_choices: set true]
   - List choices at the end of the text in this format:
     A) Choice text
     B) Choice text
     C) Choice text

4. Each response should contain only one scene fragment (3-5 sentences). Keep the pacing tight, like a real visual novel.

5. Available emotions: neutral, happy, sad, angry, surprised, shy

6. Always update current_bg when switching scenes. Always update current_speaker and speaker_emotion when a character speaks.

なぜこれほど詳細に? AIはあなたのRoot Componentがどう動くか知らないからです。「イタリック = ナレーション、プレーンテキスト = ダイアログ」と明示的に伝える必要があります — そうしないとAIがランダムなフォーマットを使い、コンポーネントがナレーションとダイアログを正しく区別できません。


ステップ3:アセットを準備してアップロード

ビジュアルノベルには背景画像とキャラクタースプライトが必要です。それらを提供する2つの方法:

  • オプションA(推奨):Yuminaのアセットシステムにアップロードし、@asset:参照を取得 — 安定、期限切れなし
  • オプションB:外部画像URLを使用(imgur、自分のサーバー)— シンプルですが壊れる可能性

Yuminaへのアセットアップロード

  1. エディタを開く → サイドバー → Assets タブ
  2. アップロードエリアに画像ファイルをドラッグ&ドロップ(またはクリックして閲覧)
  3. アップロード後、各ファイルが@asset:参照を取得(例:@asset:a1b2c3d4-e5f6-7890
  4. アップロード済みアセットをクリックして参照をコピー

@asset:参照とは? これはYuminaの内部アセット識別子です。Root ComponentのTSXコード内で、<img src="@asset:xxx" />はレンダリング時に実際のCDN URLに自動的に解決されます。手動で変換する必要はありません — コンポーネントが処理します。変数も@asset:xxx値を保存でき、自動解決されます。

推奨アセット

背景(16:9比推奨、1920×1080以上):

シーン推奨ファイル名用途
教室(昼)classroom_morning.jpg授業、会話シーン
学校の廊下hallway.jpg遷移シーン
街(夕方)street_evening.jpg放課後シーン
寝室(夜)room_night.jpg夜のシーン

アップロード後、各背景の@asset:参照をメモ。これらをナレッジエントリに入れてAIがどの参照がどのシーンに対応するか知るようにします。

キャラクタースプライト(透明PNG推奨、1000px+の高さ):

キャラクターごとに複数の表情スプライトを準備。一貫した命名形式を使用:characterName_emotion.png

キャラクターファイル名例参照例
Yuki(happy)yuki_happy.png@asset:abc123...
Yuki(sad)yuki_sad.png@asset:def456...
Teacher(neutral)teacher_neutral.png@asset:ghi789...

AIに使用するアセットを伝える

アップロード後、ステップ2で作成したVNシステム指示エントリにアセット参照テーブルを追加。これがどの@asset:参照がどのシーンまたはキャラクターに対応するかをAIに伝えます:

[Asset Reference Table]
Backgrounds:
- Classroom daytime: @asset:your-classroom-reference
- School hallway: @asset:your-hallway-reference
- Street evening: @asset:your-street-reference

Character sprites (format: @asset:reference):
- Yuki happy: @asset:your-yuki-happy-reference
- Yuki sad: @asset:your-yuki-sad-reference
- Teacher neutral: @asset:your-teacher-reference

When using directives, use the @asset: references above as values. For example:
[current_bg: set "@asset:your-classroom-reference"]

AIがこのテーブルを読み、ディレクティブ内で正しい@asset:参照を使います。Root Componentが表示時に@asset:を実際の画像URLに自動変換します。

まだアセットがない?それでもテストできる

画像のロードに失敗するとRoot Componentが単色の背景を表示します。最初にロジックを動作させ — アセットは後で追加。クイックプロトタイピングには@asset:参照の代わりに無料のストック画像URLも使えます。


ステップ4:最初のメッセージを書く

最初のメッセージはビジュアルノベルのオープニングです。初期画面を設定するためにディレクティブが必要です。

エディタ → First Message タブ → 最初のメッセージを作成

[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Narrator"]
[speaker_emotion: set "neutral"]

*The first day of April. The tail end of cherry blossom season.*

*You push open the classroom door. The familiar smell of chalk dust and wood hits you. Most seats are still empty — ten minutes until class starts.*

*In the seat by the window, a girl you've never seen before is quietly gazing outside.*

[current_speaker: set "Narrator"]
*A transfer student? You don't remember anyone like her in your class.*

なぜ最初のメッセージにもディレクティブを置くのか? Root Componentは何を表示するか決めるのに変数に依存するからです。最初のメッセージのディレクティブはエンジンに解析され、初期背景とキャラクター状態を設定します。ディレクティブがなければデフォルトが起動(default_bg.jpg + Narrator + neutral)しますが、画面がオープニングシーンと一致しないかもしれません。


ステップ5:ビジュアルノベルのRoot Componentを書く

これがコアステップです。Root Componentが画面全体を引き継ぎます — <Chat />をネストする代わりに、独自の背景、スプライト、ダイアログボックス、選択肢ボタンを描画します。プレイヤー入力は画面下部に配置された<MessageInput />で処理されます。

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

tsx
export default function MyWorld() {
  const api = useYumina();
  const renderMarkdown = api.renderMarkdown;
  const msgs = api.messages || [];
  const lastMsg = msgs[msgs.length - 1];
  const content = lastMsg ? String(lastMsg.content || "") : "";

  // ---- Read variables ----
  const bgUrl = String(api.variables.current_bg || "default_bg.jpg");
  const speaker = String(api.variables.current_speaker || "Narrator");
  const emotion = String(api.variables.speaker_emotion || "neutral");
  const showChoices = Boolean(api.variables.show_choices);

  // ---- Clean content: strip directive lines, keep only narrative text ----
  const cleanContent = content
    .split("\n")
    .filter((line) => !line.trim().match(/^\[.+:\s*(set|add|subtract|multiply|toggle|append|merge|push|delete)\s+.+\]$/) && !line.trim().match(/^\[.+:\s*[+-]?\d+\]$/))
    .join("\n")
    .trim();

  // ---- Parse text: distinguish narration (italic) from dialogue (plain text) ----
  // Split text into paragraphs and classify each one
  const paragraphs = cleanContent
    .split("\n\n")
    .map((p) => p.trim())
    .filter((p) => p.length > 0);

  const parsed = paragraphs.map((p) => {
    // If the entire paragraph is wrapped in *, or every line starts with *, it's narration
    const isNarration = /^\*[^*].*[^*]\*$/.test(p.trim())
      || p.trim().startsWith("*");
    // Check if it's a choice line (A) B) C) format)
    const isChoice = /^[A-Z]\)\s/.test(p.trim());
    return { text: p, isNarration, isChoice };
  });

  // ---- Sprite URL (assembled from character name and emotion) ----
  const spriteUrl = speaker !== "Narrator"
    ? `/sprites/${speaker.toLowerCase()}_${emotion}.png`
    : null;

  // ---- Extract choices ----
  const choices = parsed
    .filter((p) => p.isChoice)
    .map((p) => p.text.replace(/^[A-Z]\)\s*/, ""));

  // ---- Render ----
  return (
    <div style={{
      position: "relative",
      width: "100%",
      minHeight: "500px",
      borderRadius: "12px",
      overflow: "hidden",
      background: "#000",
    }}>
      {/* ===== Background layer ===== */}
      <div style={{
        position: "absolute",
        inset: 0,
        backgroundImage: `url(${bgUrl})`,
        backgroundSize: "cover",
        backgroundPosition: "center",
        filter: "brightness(0.7)",
        transition: "background-image 0.8s ease",
      }} />

      {/* ===== Character sprite layer ===== */}
      {spriteUrl && (
        <div style={{
          position: "absolute",
          bottom: "120px",
          left: "50%",
          transform: "translateX(-50%)",
          zIndex: 2,
          transition: "opacity 0.5s ease",
        }}>
          <img
            src={spriteUrl}
            alt={`${speaker} - ${emotion}`}
            style={{
              maxHeight: "350px",
              objectFit: "contain",
              filter: "drop-shadow(0 4px 12px rgba(0,0,0,0.5))",
            }}
            onError={(e) => { e.target.style.display = "none"; }}
          />
        </div>
      )}

      {/* ===== Dialogue box layer ===== */}
      <div style={{
        position: "absolute",
        bottom: 0,
        left: 0,
        right: 0,
        zIndex: 3,
        background: "linear-gradient(transparent, rgba(0,0,0,0.85) 30%)",
        padding: "60px 24px 24px",
      }}>
        {/* Character name label */}
        {speaker !== "Narrator" && (
          <div style={{
            display: "inline-block",
            padding: "4px 16px",
            marginBottom: "8px",
            background: "rgba(99,102,241,0.8)",
            borderRadius: "6px 6px 0 0",
            color: "#e0e7ff",
            fontSize: "14px",
            fontWeight: "bold",
            letterSpacing: "0.05em",
          }}>
            {speaker}
          </div>
        )}

        {/* Text content */}
        <div style={{
          background: "rgba(15,23,42,0.9)",
          borderRadius: speaker !== "Narrator" ? "0 12px 12px 12px" : "12px",
          padding: "16px 20px",
          border: "1px solid rgba(148,163,184,0.2)",
          minHeight: "80px",
        }}>
          {parsed
            .filter((p) => !p.isChoice)
            .map((p, i) => (
              <p key={i} style={{
                margin: i > 0 ? "10px 0 0" : "0",
                color: p.isNarration ? "#94a3b8" : "#e2e8f0",
                fontStyle: p.isNarration ? "italic" : "normal",
                fontSize: "15px",
                lineHeight: 1.8,
              }}
              dangerouslySetInnerHTML={{
                __html: renderMarkdown(
                  p.isNarration
                    ? p.text.replace(/^\*|\*$/g, "")
                    : p.text
                ),
              }}
              />
            ))
          }
        </div>
      </div>

      {/* ===== Choice button layer ===== */}
      {showChoices && choices.length > 0 && (
        <div style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          transform: "translate(-50%, -50%)",
          zIndex: 4,
          display: "flex",
          flexDirection: "column",
          gap: "10px",
          width: "80%",
          maxWidth: "400px",
        }}>
          {choices.map((choice, i) => (
            <button
              key={i}
              onClick={() => {
                api.setVariable("show_choices", false);
                api.sendMessage(choice);
              }}
              style={{
                padding: "14px 20px",
                background: "rgba(30,27,75,0.9)",
                border: "1px solid rgba(99,102,241,0.6)",
                borderRadius: "10px",
                color: "#c7d2fe",
                fontSize: "15px",
                fontWeight: "600",
                cursor: "pointer",
                textAlign: "left",
                transition: "all 0.2s ease",
                backdropFilter: "blur(8px)",
              }}
              onMouseEnter={(e) => {
                e.target.style.background = "rgba(67,56,202,0.8)";
                e.target.style.borderColor = "#818cf8";
              }}
              onMouseLeave={(e) => {
                e.target.style.background = "rgba(30,27,75,0.9)";
                e.target.style.borderColor = "rgba(99,102,241,0.6)";
              }}
            >
              {choice}
            </button>
          ))}
        </div>
      )}

      {/* ===== Player input layer ===== */}
      {/* No <Chat /> wrapper — drop <MessageInput /> in directly so the player can still type */}
      <div style={{
        position: "absolute",
        bottom: 0,
        left: 0,
        right: 0,
        zIndex: 5,
      }}>
        <MessageInput />
      </div>
    </div>
  );
}

ブロックごとの説明:

  • コンテンツのクリーン化cleanContent[current_bg: set "xxx"]のようなディレクティブ行をフィルタアウト(すべての操作タイプに一致:set/add/subtract/multiply/toggle/append/merge/push/delete、加えて[hp: -10]のような短縮ディレクティブ)。ディレクティブはすでにエンジンに解析されているため、コンポーネントは表示する必要がない
  • 段落を解析 — 空行でテキストを段落に分割し、各々をナレーション(*で始まる)、ダイアログ(プレーンテキスト)、または選択肢(A)形式で始まる)として分類
  • 背景レイヤーbackgroundImageで現在のシーン背景を表示。filter: brightness(0.7)がわずかに暗くして前景テキストを読みやすく保つ。transitionが背景切り替え時にクロスフェードアニメーション
  • スプライトレイヤーspeakeremotionからスプライトファイルパスを組み立てる。onErrorが画像欠落を処理(静かに隠す)。Narratorモードではスプライトは表示されない
  • ダイアログボックスレイヤー — 下部の半透明ボックス。speakerが「Narrator」でない場合、ダイアログボックスの上にキャラクター名ラベルが表示。ナレーションテキストはグレーでイタリック、ダイアログテキストは白で通常
  • 選択肢ボタンレイヤーshow_choicestrueでテキストにA) B) C)形式の選択肢が含まれる場合、画面中央にボタンが表示。ボタンクリックで選択肢が自動的に隠れ(show_choicesfalseに設定)、プレイヤーの選択を送信

スプライトパスのカスタマイズ

コードは/sprites/${speaker.toLowerCase()}_${emotion}.pngを使ってスプライトパスを組み立てます。任意のURL形式に変更できます — CDNリンク、ローカルファイルパス、またはルックアップテーブル。キャラクター名に非ASCII文字が含まれる場合、URLエンコードするか英語IDを使うことを忘れないでください。


ステップ6:トグル不要 — Root Componentはすでに全画面

ビジュアルノベルは自然に画面を埋めます。index.tsxのルート要素がwidth: "100%" + minHeight: "500px"(または100vh)を使う限り — 上記のコードがすでにそうしている — あなたのTSXがキャンバス全体を引き継ぎます。切り替える「フルスクリーン」スイッチは別途ありません。Root ComponentがワールドUIエントリーポイントそのものだからです。

2つのパターンを並べると:

  • 通常のチャット + カスタムバブルreturn <Chat renderBubble={...} /> — プラットフォームのチャットシェルを維持、バブルだけリスタイル
  • 純粋なビジュアルノベル(このレシピ)return <div>...all VN elements...<MessageInput /></div><Chat />は一切なし;すべてのピクセルがあなたのもの

チャットとVNを切り替えたい? 「ほとんどの時間は通常のチャット、特別な瞬間にVN」の体験が欲しい場合、Root Component内で変数(例えばvn_mode)で分岐します。オフのとき<Chat />を返し、オンのときVN全画面レイアウトを返す。AIがディレクティブで変数を反転させ、セッション中にモード間を切り替えられます。


ステップ7:AIが画面を駆動する方法 — ディレクティブ例

実際の会話中にAIが自然にビジュアルノベル画面を制御する様子を見てみましょう。

シーン1:オープニング(Narratorモード)

AIの応答:

[current_bg: set "classroom_morning.jpg"]
[current_speaker: set "Narrator"]
[speaker_emotion: set "neutral"]

*An April morning. The air carries the sweet scent of cherry blossoms.*

*You walk into the classroom and find a girl you don't recognize sitting by the window. She's resting her chin on her hand, staring outside, lost in thought.*

レンダリング結果:教室の背景 + スプライトなし + グレーのイタリックナレーションテキスト。

シーン2:キャラクターダイアログ

AIの応答:

[current_speaker: set "Yuki"]
[speaker_emotion: set "surprised"]

*She seems to notice you looking and turns her head.*

Oh, hello. Are you in this class too?

[speaker_emotion: set "shy"]

Sorry, I just transferred here today... I don't really know anyone yet.

レンダリング結果:背景は変わらず(current_bgディレクティブがない = 前の値を保持)+ Yukiのスプライトが驚いた表情からシャイな表情に切り替わる + ダイアログボックスに名前「Yuki」を表示 + イタリックのナレーションと通常のダイアログが交互。

シーン3:プレイヤーに選択肢を与える

AIの応答:

[current_speaker: set "Narrator"]
[show_choices: set true]

*Yuki looks at you, a hint of expectation in her eyes.*

*What do you do?*

A) Introduce yourself and start a conversation
B) Nod briefly and head back to your seat
C) Offer to show her around the classroom and school

レンダリング結果:ナレーションテキスト + 画面中央に3つのクリック可能なボタンが表示。プレイヤーが1つをクリックすると、ボタンが消え、選択されたテキストがAIへのプレイヤーの返答として送信されます。

シーン4:シーン遷移

AIの応答:

[current_bg: set "hallway.jpg"]
[current_speaker: set "Narrator"]

*The bell rings. The hallway instantly comes alive as students stream out in pairs and small groups.*

[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]

Want to have lunch on the rooftop together? I found a really nice spot.

レンダリング結果:背景が廊下に遷移(クロスフェードアニメーション付き)+ ナレーション + Yukiの嬉しそうなスプライト + ダイアログ。


ステップ8:イタリックナレーション vs プレーンダイアログ — 解析ルール

Root Componentはシンプルなルールで2種類のテキストを区別します:

形式認識される表示スタイル用途
*This is italic text*ナレーショングレー (#94a3b8)、イタリック環境の記述、キャラクターの動作、内なる独白
This is plain textダイアログ白 (#e2e8f0)、通常キャラクターの台詞
A) Choice text選択肢ボタンクリック可能なプレイヤー選択

AIはナレッジエントリでこれらのルールをすでに伝えられています。しかしAIが時々フォーマットを間違えても(例:ダイアログにイタリックを使用)、フォールバックロジックは不確実なテキストをダイアログとして扱う — そのため少なくとも何も壊れません。

なぜMarkdownの>引用または**bold**で区別しないのか? *italic*が最も自然なマークアップだからです — ロールプレイシナリオのほとんどのAIモデルはすでに追加のトレーニングなしでナレーションと動作記述にイタリックをデフォルトで使用します。AIが最も一貫して従いやすい形式を選び、モデルと戦う頭痛から自分を救いましょう。


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

  1. エディタ上部のSaveをクリック
  2. Start Gameをクリックするか、ホームページに戻って新規セッションを開始
  3. 全画面VN表示が見えるはず — 背景 + ダイアログボックス + オープニングナレーション
  4. 入力ボックスにメッセージを入力(例:「Say hello to her」)
  5. AIの応答にディレクティブが含まれるはず — 背景が変わり、キャラクターが現れ、ダイアログがボックスに表示
  6. AIが選択肢を提示する場合、画面中央にボタンが表示。クリックして試す
  7. 会話を続け、AIがシーン切り替え時にcurrent_bgを、キャラクターが話すときにcurrent_speakerspeaker_emotionを自然に更新するか観察

うまく動かない場合:

症状想定される原因対処
背景が黒画像URLが正しくない、または画像が存在しないcurrent_bg値が有効な画像URLか確認。ブラウザでURLを直接開いて画像がロードするか確認
スプライトが見えないスプライトファイルパスが一致しない/sprites/characterName_emotion.pngパスが正しいか確認。onErrorが読み込みに失敗した画像を静かに隠す
ディレクティブ行が画面に表示されるディレクティブ形式が非標準で正規表現が一致しない形式が[variableName: set "value"]であることを確認 — コロンの後のスペースに注意
すべてのテキストがナレーション/すべてのテキストがダイアログAIがフォーマットルールに従っていないナレッジエントリのフォーマット指示が明確か確認。ビヘイビアルールで強化することもできる
選択肢ボタンが表示されないshow_choicestrueに設定されていない、またはA)形式の選択肢がないAIの応答に[show_choices: set true]A)形式の選択肢が含まれているか確認
画面が全画面でないルート要素が可視領域を埋めていないRoot Componentの最外側のdivにminHeight: "100vh"またはheight: "100%"を追加、親コンテナに高さがあることを確認

上級者向けTips

複数キャラクターのダイアログ

同じ応答内で複数のキャラクター間を切り替えられます:

[current_speaker: set "Yuki"]
[speaker_emotion: set "happy"]
The weather is so nice today!

[current_speaker: set "Teacher"]
[speaker_emotion: set "neutral"]
Alright everyone, class is starting. Please take your seats.

[current_speaker: set "Narrator"]
*The classroom falls silent in an instant.*

Root Componentがこれらを順番に処理し、最終画面は最後のcurrent_speakerのスプライトを表示します。各ダイアログセグメントが対応するキャラクターのスプライトを表示したい場合、各段落の直前の[current_speaker: set ...]ディレクティブを解析するようコンポーネントを変更できます。

遷移エフェクト

背景レイヤーのCSSにtransition: background-image 0.8s easeが含まれており、背景切り替えにクロスフェード効果を与えます。異なるシーンタイプに異なる遷移を使うこともできます:

  • 通常切り替え:クロスフェード(すでに実装済み)
  • フラッシュバック/記憶:白いフラッシュオーバーレイを追加
  • 緊張シーン:画面振動アニメーションを追加

サウンドとBGMとのペアリング

Recipe #9(昼夜サイクル)のオーディオシステムと組み合わせると、異なるシーンにBGMを割り当てられます。ビヘイビアルールに追加:current_bgが変わるとき、対応するシーンのBGMを再生。


クイックリファレンス

やりたいこと方法
背景を切り替えAIが[current_bg: set "imageURL"]を送信
話し手を切り替えAIが[current_speaker: set "characterName"]を送信
表情を切り替えAIが[speaker_emotion: set "emotion"]を送信
選択肢ボタンを表示AIが[show_choices: set true] + A) B) C)形式の選択肢を送信
ナレーションとダイアログを区別*italic* = ナレーション、プレーンテキスト = ダイアログ
フルスクリーンVN体験Root Componentのindex.tsxがVN全画面レイアウトを直接返す(<Chat />なし)、プレイヤー入力用に下部に<MessageInput />
キャラクタースプライト/sprites/ディレクトリにcharacterName_emotion.pngファイルを準備
プレイヤーが選択肢をクリックしたときメッセージを送信ボタンonClickapi.sendMessage(choiceText)を呼ぶ

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

このJSONファイルをダウンロードしてインポートし、完全な効果を体験してください:

recipe-10-demo.json

インポート方法:

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

含まれるもの:

  • 4つの変数(current_bg 背景、current_speaker 話し手、speaker_emotion 感情、show_choices 選択肢トグル)
  • 1つのナレッジエントリ(ディレクティブとテキストフォーマットの使い方をAIに伝えるビジュアルノベルシステム指示)
  • 1つの最初のメッセージ(初期ディレクティブ付きVNオープニング)
  • Root Component(完全なVNインターフェース:背景 + スプライト + ダイアログボックス + 選択肢ボタン + <MessageInput />

これはRecipe #10です

ビジュアルノベルモードは最もパワフルなYuminaを示しています — AIは単なるチャットパートナーではなく、ナラティブエンジンです。ディレクティブで画面を駆動し、フォーマット規則でテキストタイプを区別し、Root Componentにインターフェース全体を再形成させることで、普通のチャットボックスを想像できるあらゆるインタラクティブ体験に変えられます。同じアプローチがアドベンチャーゲーム、インタラクティブコミック、または経営シミュレーションにも動作します。