8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】genuiを使ってギャルゲーを作ってみた

8
Last updated at Posted at 2025-12-23

はじめに

genuiは、AIがリアルタイムでUIを生成できるFlutterパッケージです。通常のチャットボットがテキストのみを返すのに対し、genuiを使うとAIが「UIそのもの」を返せるようになります。

本記事では、genuiを使って「ギャルゲー」を実装を試しに作ってみましたので、紹介します。

※サンプルアプリ内で利用している画像はWaifu Diffusionを用いて作成しています。
※本サンプルアプリはリリースを目的として作成していないため、商用利用はいたしません。

なぜギャルゲー?

ギャルゲーには以下の要素が含まれていており、生成UIの魅力を提示できるサンプルになると思ったからです。

  1. テキスト主体のコンテンツ - LLMが最も得意とする領域
  2. 対話形式 - ユーザーとAIのやり取りが自然にゲーム体験になる
  3. 選択肢による分岐 - ユーザー入力 → AI応答のループがゲームシステムそのもの
  4. 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をつなげるにしても、構造体を返せるような形にしないといけない

リポジトリ

ソースコードはこちら:

参考文献

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?