はじめに
genuiは、AIがリアルタイムでUIを生成できるFlutterパッケージです。通常のチャットボットがテキストのみを返すのに対し、genuiを使うとAIが「UIそのもの」を返せるようになります。
本記事では、genuiを使って「ギャルゲー」を実装を試しに作ってみましたので、紹介します。
※サンプルアプリ内で利用している画像はWaifu Diffusionを用いて作成しています。
※本サンプルアプリはリリースを目的として作成していないため、商用利用はいたしません。
なぜギャルゲー?
ギャルゲーには以下の要素が含まれていており、生成UIの魅力を提示できるサンプルになると思ったからです。
- テキスト主体のコンテンツ - LLMが最も得意とする領域
- 対話形式 - ユーザーとAIのやり取りが自然にゲーム体験になる
- 選択肢による分岐 - ユーザー入力 → AI応答のループがゲームシステムそのもの
- UIとしての表現 - 単なるテキストでの表現ではなく、AIがUIを表現するというサンプルとして丁度よい題材になる
genuiの概要
genuiとは
genuiは、Google製のFlutterパッケージで、LLMがUIコンポーネントを動的に生成・配置できる仕組みを提供します。
従来のアプローチ:
ユーザー入力 → LLM → テキスト応答 → 固定UIで表示
genuiのアプローチ:
ユーザー入力 → LLM → UI構造(JSON) → 動的にWidget生成
動作環境
- Flutter 3.38
主要な概念
| 概念 | 説明 |
|---|---|
| Catalog | AIが使用可能なUIコンポーネントの定義集 |
| CatalogItem | 個々のUIコンポーネント定義(スキーマ + ビルダー) |
| GenUiConversation | AIとの会話セッション管理 |
| GenUiSurface | 生成されたUIの描画領域 |
Catalog
AIが使用可能なUIコンポーネントの定義集です。AIが利用できるUIのメニュー表のようなものです。
// 標準コンポーネント(Row, Column, Textなど)に自作を追加
final catalog = CoreCatalogItems.asCatalog().copyWith([
myCustomItem1,
myCustomItem2,
]);
CatalogItem
個々のUIコンポーネントの定義です。以下の2つで構成されます:
- dataSchema: AIに「このコンポーネントはどんなデータを受け取るか」を教えるスキーマ
- widgetBuilder: 受け取ったJSONから実際のWidgetを構築する関数
final myCatalogItem = CatalogItem(
name: 'MyComponent',
dataSchema: S.object(...), // AIへの説明
widgetBuilder: (context) => ..., // Widget構築ロジック
);
GenUiConversation
AIとの会話セッションを管理するクラスです。リクエスト送信、レスポンス受信、UI更新の通知を行います。
_genUiConversation = GenUiConversation(
genUiManager: _genUiManager,
contentGenerator: contentGenerator,
onSurfaceAdded: (update) { /* 新しいUIが追加された */ },
onSurfaceDeleted: (update) { /* UIが削除された */ },
onTextResponse: (text) { /* テキストレスポンスを受信 */ },
onError: (error) { /* エラー処理 */ },
);
// リクエスト送信
_genUiConversation.sendRequest(UserMessage.text('ゲームを開始'));
GenUiSurface
AIが生成したUIを実際に描画するWidgetです。surfaceIdで識別される描画領域を提供します。
GenUiSurface(
host: _genUiConversation.host,
surfaceId: surfaceId,
)
AIは1回のレスポンスで複数のSurfaceを生成することもありますが、本アプリでは1つのSurfaceでシーン全体を表現しています。
アーキテクチャ
全体構成
カタログ構成
本アプリで定義したCatalogItemは以下の3つです。
Catalog getGalgeCatalog() {
return CoreCatalogItems.asCatalog().copyWith([
sceneDisplayItem, // シーン全体のレイアウト
characterDisplayItem, // キャラクター立ち絵
dialogueBoxItem, // セリフ・選択肢表示
]);
}
dataSchemaの書き方
genuiでは、json_schema_builderパッケージを使ってAIに渡すスキーマを定義し、それを各カタログに設定することでAIにUIを作成させます。
import 'package:json_schema_builder/json_schema_builder.dart';
dataSchema: S.object(
description: 'コンポーネントの説明(AIへの指示になる)',
properties: {
'propName': S.string(description: 'プロパティの説明'),
},
required: ['propName'], // 必須プロパティ
),
主な型定義
| メソッド | 用途 | 例 |
|---|---|---|
S.object() |
オブジェクト型 | コンポーネント全体の定義 |
S.string() |
文字列型 | キャラクター名、セリフなど |
S.boolean() |
真偽値型 |
isActiveなど |
S.integer() |
整数型 | 好感度、カウントなど |
S.list() |
配列型 | セリフ配列、選択肢配列など |
descriptionの重要性
descriptionに記述した内容は、そのままAIへのプロンプトに含まれます。
// NG: 曖昧な説明
'backgroundType': S.string(description: '背景の種類'),
// OK: 具体的な選択肢を列挙
'backgroundType': S.string(
description: '背景: classroom, hallway, rooftop, park, cafe, room, sunset, night, library, gym',
),
選択肢を具体的に列挙することで、AIが適切な値を選択しやすくなります。
子要素の指定
genuiでは、親コンポーネントが子コンポーネントのIDを受け取り、context.buildChild()で実際のWidgetを構築します。
// スキーマ側
'characterAreaChildId': S.string(description: 'キャラクター表示エリアの子要素ID'),
// widgetBuilder側
widgetBuilder: (context) {
final json = context.data as Map<String, Object?>;
final childId = json['characterAreaChildId'] as String?;
return MyWidget(
child: childId != null ? context.buildChild(childId) : null,
);
},
これにより、AIが「この場所にはキャラクター、この場所にはセリフ」といった構造を自由に組み立てられます。
実装詳細
1. SceneDisplay - シーンコンテナ
ビジュアルノベルの基本レイアウト(背景・キャラクター・テキスト)を提供するコンポーネントです。
final sceneDisplayItem = CatalogItem(
name: 'SceneDisplay',
dataSchema: S.object(
description: 'ビジュアルノベルのシーン。固定レイアウトで表示。',
properties: {
'backgroundType': S.string(
description: '背景: classroom, hallway, rooftop, park, cafe, room, sunset, night, library, gym',
),
'mood': S.string(
description: '雰囲気: normal, romantic, tense, happy, sad, mysterious',
),
'timeOfDay': S.string(
description: '時間帯: morning, afternoon, evening, night',
),
'location': S.string(description: '場所名'),
'time': S.string(description: '時間表示'),
'characterAreaChildId': S.string(description: 'キャラクター表示エリアの子要素ID'),
'textAreaChildId': S.string(description: 'テキストエリアの子要素ID'),
},
required: ['backgroundType'],
),
widgetBuilder: (context) {
final json = context.data as Map<String, Object?>;
// ... Widgetを構築
},
);
ポイント: descriptionに記述した内容がそのままAIへの指示になります。選択肢を具体的に列挙することで、AIが適切な値を選択できるようになります。
2. CharacterDisplay - キャラクター表示
キャラクターの立ち絵を表示するコンポーネントです。
final characterDisplayItem = CatalogItem(
name: 'CharacterDisplay',
dataSchema: S.object(
description: 'キャラクターを表示。複数配置時はRowで横並びに。',
properties: {
'name': S.string(description: 'キャラクター名'),
'isActive': S.boolean(
description: '話し中ならtrue(ハイライト表示)'
),
},
required: ['name'],
),
widgetBuilder: (context) {
// isActiveに応じて透明度・スケールを変更
},
);
isActiveプロパティにより、現在話しているキャラクターをハイライト表示します。
3. DialogueBox - セリフ・選択肢表示
セリフのタイピングアニメーションと選択肢表示を担当するコンポーネントです。
final dialogueBoxItem = CatalogItem(
name: 'DialogueBox',
dataSchema: S.object(
description: 'セリフを表示。textsに配列でセリフを渡すと内部で順番に表示。'
'タップで次へ進む。選択肢がある場合はchoicesに配列で渡す。',
properties: {
'speaker': S.string(description: '話者名'),
'texts': S.list(
description: 'セリフの配列。同じキャラの連続セリフをまとめて渡す',
items: S.string(description: 'セリフ'),
),
'choices': S.list(
description: '選択肢の配列。全テキスト表示後に表示される',
items: S.string(description: '選択肢テキスト'),
),
},
),
// ...
);
設計意図: textsを配列にすることで、同じキャラクターの連続セリフを1回のAIリクエストでまとめて取得できます。これにより、API呼び出し回数を削減しつつ、自然な会話の流れを実現しています。
以下のようなイメージになります。
4. システムプロンプト設計
AIの出力を安定させるため、システムプロンプトで詳細な指示を行います。
String _getSystemInstruction(String playerName) => '''
あなたは日本の学園ラブコメディ風ビジュアルノベル(ギャルゲー)のゲームマスターです。
## 設定
- 舞台: 私立桜花学園(高校)
- 季節: 春(桜の季節)
- 主人公: $playerName(転校してきたばかりの男子高校生)
- ヒロイン:
- 桜井美咲 - 明るく元気なクラスメイト。「〜だよね!」「すごーい!」
- 雪村凛 - クールで知的な図書委員。「〜ですね」「そうかもしれません」
- 天野ひまり - おっとり系の幼なじみ。「〜かな」「えへへ」
## 必須のUI構造(厳守)
各シーンは必ず以下の構造で生成してください:
SceneDisplay(ルート)
├── characterAreaChildId → Row → CharacterDisplay(1〜3人)
└── textAreaChildId → DialogueBox(選択肢はchoicesプロパティで指定)
## CharacterDisplayのルール
- isActive: DialogueBoxのspeakerと同じキャラクターのみtrue、他は必ずfalse
## プレイヤーの選択・入力の扱い(重要)
- プレイヤーが選択した選択肢のテキストを、そのままヒロインのセリフとして表示してはいけません
- プレイヤーの選択肢は「プレイヤーがその行動を取った」という意味であり、セリフではありません
''';
重要なポイント:
- UI構造をツリー形式で明示する
- 「厳守」「必ず」などの強い表現を使用する
- 具体例(OK/NG例)を含める
作ってみた感想
1. AI + UIの組み合わせが非常に簡単に作れる
- 従来文字のみだった表現をUIにすることが従来より簡易的に実装ができること
- 今回のアプリもカタログを3つ定義しただけでした
2. シナリオを考えなくていい
- AIが物語を構成してくれるのでシナリオを考えなくていい
一方でランニングコストがかかるので、おそらく普通にシナリオを作ったほうがコストいい
3. 開発は若干しにくい
- LLMにつなげないといけないので、ローカルLLMとかを立てないと無限にデバッグみたいなことが難しい
- ローカルLLMをつなげるにしても、構造体を返せるような形にしないといけない
リポジトリ
ソースコードはこちら: