ℹ️
INFO
この記事では、WPF + MVVMで組んだInteractiveStoryTellerAppの制作過程を解説します。AIエピソード生成、背景プロンプト、アイテムガチャ、セーブ/ロード、BGM/SFXを全部詰め込んだ物語型アプリの実装ノウハウをまとめました。
浮遊都市ルーメの朝焼けを描くナレーションをもっと柔らかくしたかった。
Gemini(GoogleのAI)に頼むたびにJSONが砕け、WPF(WindowsアプリのUIフレームワーク)のチャット欄は真っ白になる。静かだった。
Choicesを押してもスクロールは跳ね、BGMは別世界のテンポで流れていた。
dotnetProjectForWindows_9で組んだInteractiveStoryTellerAppは、WPF + MVVM(画面とロジックを分ける設計)の定番構成に、AIエピソード生成、背景プロンプト、アイテムガチャ、セーブ/ロード、BGM/SFXを全部詰め込んだアプリです。
けれど体験を人肌の温度にするには、LLM(AI)のランダムさとWPFの几帳面さを同じ机に座らせる必要がありました。
⚠️
WARNING
失敗しました。429エラー(APIの利用制限)を食らってQuotaExceededExceptionを投げた瞬間、物語が止まる。これが問題でした。
例外処理とアニメーションを同じリズムで設計し直し、8本のデモと一緒に制作記をまとめたのがこの記事です。焦りと安堵をそのまま残しました。
対象読者
- WPF + MVVMで物語型アプリを作り、AI生成と演出を同居させたい.NETエンジニア
- GeminiのJSONレスポンスを安全に剥がしつつ、UIを止めずに扱いたい人
- ローカル保存や実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXE配布まで面倒をみたいクリエイター
記事に書いてあること
- 目標選択回数をステータスで揺らすStory Length LabとAIエピソード管理
- 1.5〜2.5秒のSmooth Scrollと0.3秒フェードを重ねたチャットUIの整え方
- Gemini APIのエラー/Quota/HTMLレスポンスを裁きつつJSONを抽出する方法
- アイテム生成、背景10分類、BGM/SFX、Save/Load/CompletedStory、実行時コンパイルの一部をビルド時に済ませるオプションを有効にした配布までの手順
前提知識
- .NET 10.0 + WPFの基本操作とMVVMパターン
- Google Gemini API(PHPプロキシ
https://yosuke4061.com/gemini-api.php で中継)
- JSON/HTTPエラーハンドリングとローカルファイルI/O
今回整えたもの
画面構成
画面は左右に分かれています。
左側にはステータスとアイテムを配置。勇気・共感・洞察のバーを10段階で可視化しました。アイテムリストにはレア度カラーと「🔮使用」「🗑削除」ボタンを並べています。
右側はLINE風のチャットボード。背景画像の上に物語が流れます。
保存/ロード/AI再生成/過去ストーリー表示ボタンは、ボタンと処理を直接結びました。最後にセーブした時刻を表示します。
タイトル横に「🤖AIエピソード生成」「⟳AIを再生成」「生成中インジケーター」を配置。フェードやHoverの質感も整えました。
シーン遷移とサービスの流れ
物語が進む流れはこんな感じです。
1. 物語の司令塔が次のシーンを取得
2. AIに頼んでナレーションと選択肢を生成
3. 背景カテゴリを返す
4. 背景画像を決める
5. 25%の確率で新アイテムを提案
6. BGM/SFXを鳴らす
これらをUIを更新する仕組み上で回しています。
保存の仕組み
保存は2種類あります。現在の状態をstory_save.jsonに保存します。旅の記録とイベントログ(最大80件)は別フォルダに保存します。
フロー図を眺めると、UI・AI・ストレージのどこで例外が起きてもチャットが止まらないように非同期(処理を待たずに進む仕組み)とログを散りばめた意図がわかります。
ぜひ全体像をdemo8で追ってみてください。詰まった工程に自分のノートを差し込めます。
目標選択回数を操る Story Length Lab
物語の長さを自動で調整する仕組みを作りました。
AIエピソードの長さは3回から50回の間でランダムに決まります。でも、それだけじゃつまらない。
勇気・共感・洞察の合計と偏差で±1ターン調整します。バランスが良い(差が2以下、合計が15以上)と+1。偏りが激しい(差が5以上)と-1。
さらに±1の揺らぎを加えて、自然な長さにします。プレイヤーには「続きの物語を始めます。目標選択回数: x回」と伝えます。
選択回数が目標に達したか、AI側が終了フラグを立てた瞬間に確実に終わらせます。だらだらしたループを防ぐためです。
実際のコードはこんな感じです。ステータスのバランスを見て、物語の長さを調整します。
// ViewModels/StoryViewModel.cs (2133-2164行目)
/// <summary>
/// 物語の目標長さを決定(選択内容やステータスに応じて調整)
/// </summary>
private int DetermineTargetStoryLength()
{
// ベースの長さをランダムに決定(最小値~最大値の範囲内)
var baseLength = _random.Next(_minStoryLength, _maxStoryLength + 1);
// 現在のステータスに応じて調整
var statSum = State.Courage + State.Empathy + State.Insight;
var statMax = Math.Max(State.Courage, Math.Max(State.Empathy, State.Insight));
var statMin = Math.Min(State.Courage, Math.Min(State.Empathy, State.Insight));
var statRange = statMax - statMin;
// ステータスのバランスが良い場合、少し長めに
if (statRange <= 2 && statSum >= 15)
{
baseLength = Math.Min(baseLength + 1, _maxStoryLength);
}
// ステータスが極端に偏っている場合、少し短めに
if (statRange >= 5)
{
baseLength = Math.Max(baseLength - 1, _minStoryLength);
}
// ランダム要素を追加(±1の変動)
var variation = _random.Next(-1, 2);
baseLength = Math.Max(_minStoryLength, Math.Min(_maxStoryLength, baseLength + variation));
return baseLength;
}
demo1-story-length-labでは勇気・共感・洞察をスライダーで動かせます。
実際に目標ターン数がどう変わるか、合計ポイントや偏差を即時フィードバックします。何度もロールさせて、自分のシナリオが短編向きか中編向きかを確認できます。
スクロールとチャットの滑走感
LINE風の滑らかなスクロールを実装しました。
チャットメッセージを管理します。データが変わると自動で画面が更新されます。1件追加されるたびに、自動でスクロールします。
距離が10px未満なら即座にスクロール。それ以上なら、距離に応じて1.5〜2.5秒かけて滑らかにスクロールします。8ms刻み(約120fps)でEaseOutQuint補間(最初は速く、最後はゆっくり)します。
テキストは1文字ごとに10msの遅延を入れて、タイピング風に表示。スキップボタンでキャンセルも可能です。
さらに0.3秒フェード+0.1秒のディレイを入れて、既読ラインが綺麗に積み上がるようにしました。
スクロールの実装コードです。距離に応じて滑らかにスクロールします。
// MainWindow.xaml.cs (50-116行目)
private void SmoothScrollToEnd()
{
if (ChatScrollViewer == null) return;
// 現在のスクロール位置を取得
var currentOffset = ChatScrollViewer.VerticalOffset;
// 最終的なスクロール位置を計算(ScrollViewerの最大スクロール位置)
// ScrollToEnd()を一度呼んで最終位置を取得
ChatScrollViewer.ScrollToEnd();
var targetOffset = ChatScrollViewer.VerticalOffset;
// 現在位置に戻す(アニメーションの開始位置)
ChatScrollViewer.ScrollToVerticalOffset(currentOffset);
// スクロール位置の差が小さい場合は即座にスクロール
var scrollDistance = Math.Abs(targetOffset - currentOffset);
if (scrollDistance < 10)
{
ChatScrollViewer.ScrollToEnd();
return;
}
// スクロール距離に応じて duration を調整(距離が大きいほど長く)
// 最小1.5秒、最大2.5秒、距離に比例(よりゆっくりと)
var baseDuration = 1.5;
var distanceFactor = Math.Min(scrollDistance / 500.0, 1.0); // 500pxを基準に
var duration = baseDuration + (distanceFactor * 1.0); // 1.5秒~2.5秒
// タイマーを使って段階的にスクロール
AnimateScrollToEnd(currentOffset, targetOffset, duration);
}
demo2-scroll-easerはスクロール距離をスライダーで動かせる可視化ツールです。
EaseOutQuintカーブと代表フレームを描画します。距離が伸びるほど淡く長い尾を引くグラフが、チャットの温度感をそのまま表しています。
Gemini Prompt Inspector とJSON救出術
AIに物語を生成してもらうのは、意外と大変です。
AIに物語を生成してもらう処理は、70秒でタイムアウトします。HTTP通信の仕組みを使います。創造性を高める設定で、より面白い物語を生成します。
リクエストに含める情報
リクエストには色々な情報を詰め込みます:
- チャット履歴(最初の2件+直近8件)
- イベントログ3件(50文字以内)
- 最近使ったアイテム
- 所持アイテム一覧
- ステータスから導いた注釈(例:「勇気が高いので大胆な行動が可能」)
レスポンス側は429(APIの利用制限)やHTMLエラーページを検知して例外を投げます。finishReason == "MAX_TOKENS"(トークン数が上限に達した)なら、ユーザーにプロンプトを短くするよう促します。
最後の砦がJSON抽出処理です。AIが返すJSONが、コードフェンスや余計な文章に囲まれていても、バランス判定しながら抜き出します。
実際のコードです。JSONを安全に抽出する処理です。
// Services/GeminiStoryService.cs (417-508行目)
private string? ExtractModelJson(string responseBody)
{
try
{
using var document = JsonDocument.Parse(responseBody);
// 通常のGemini API応答形式: { "candidates": [...] }
if (document.RootElement.TryGetProperty("candidates", out var candidates))
{
if (candidates.GetArrayLength() == 0)
{
return null;
}
var first = candidates[0];
if (!first.TryGetProperty("content", out var content)) return null;
// content.partsが存在しない場合(finishReasonがMAX_TOKENSなど)
if (!content.TryGetProperty("parts", out var parts) || parts.GetArrayLength() == 0)
{
// finishReasonをチェック
if (first.TryGetProperty("finishReason", out var finishReason))
{
var reason = finishReason.GetString();
if (reason == "MAX_TOKENS")
{
return null; // 呼び出し元でエラーメッセージを返す
}
}
return null;
}
var text = parts[0].GetProperty("text").GetString();
if (string.IsNullOrWhiteSpace(text)) return null;
text = StripCodeFences(text.Trim());
if (IsValidJson(text))
{
return text;
}
var candidate = ExtractBalancedJson(text);
if (candidate != null && IsValidJson(candidate))
{
return candidate;
}
}
// プロキシサーバーが直接JSONを返している場合(AiEpisode形式)
if (document.RootElement.TryGetProperty("title", out _) ||
document.RootElement.TryGetProperty("narration", out _))
{
// そのまま返す
return responseBody;
}
// テキストのみの場合(応答がJSONオブジェクト全体ではなくテキスト部分だけ)
var textOnly = responseBody.Trim();
if (textOnly.StartsWith("{", StringComparison.Ordinal) ||
textOnly.StartsWith("[", StringComparison.Ordinal))
{
textOnly = StripCodeFences(textOnly);
if (IsValidJson(textOnly))
{
return textOnly;
}
var extracted = ExtractBalancedJson(textOnly);
if (extracted != null && IsValidJson(extracted))
{
return extracted;
}
}
}
catch (JsonException)
{
// JSONとして解析できない場合は、テキストから抽出を試みる
var text = StripCodeFences(responseBody.Trim());
if (IsValidJson(text))
{
return text;
}
var extracted = ExtractBalancedJson(text);
if (extracted != null && IsValidJson(extracted))
{
return extracted;
}
}
return null;
}
demo3-prompt-inspectorではシーンを選ぶと、プロンプトがどう作られるか確認できます。
StoryFlow/Log/Status/Constraintsがどう連結されてプロンプト文字列になるか、そしてJSON制約がどう記述されるかをそのまま見られます。
Geminiがコードフェンスを付けてきても慌てないよう、プロンプトと抽出ロジックを並べて眺められます。
背景カテゴリを10種類で即着陸
背景画像を自動で切り替える仕組みです。
AIが返す短いシーンラベル(market/forest/.../temple)を受け取ります。背景画像フォルダの中からランダムに採用します。
背景画像の取得は3秒でタイムアウト。失敗したらデフォルト画像に戻します。
意外ですが、こんな単純な切り替えでも、背景が変わるだけでプレイヤーの集中度が持ち直します。
古い選択が割り込まないようにフラグで制御し、UIを更新する仕組みで確実に画面を更新します。
アイテムガチャと在庫の手触り
アイテム獲得の流れ
選択後は25%の確率でアイテムがもらえます。
Geminiにアイテム生成を依頼します。チャットに「✨ アイテム名を獲得しました!」を先に表示してから100ms待ちます。
UIに余裕を作ってからアイテムを追加し、効果を適用します。
SFXを鳴らし、レア度に応じた色で左ペインが光ります。
アイテム生成の実装コードです。25%の確率でアイテムがもらえます。
// ViewModels/StoryViewModel.cs (2426-2499行目)
/// <summary>
/// アイテムを生成(LLM使用)
/// </summary>
private async Task TryGenerateItemAsync()
{
// 25%(2.5割)の確率でアイテムを生成
if (_random.NextDouble() > 0.25)
{
return;
}
AddLog("アイテム生成を開始します...");
try
{
if (_currentNode == null)
{
AddLog("アイテム生成: 現在のノードが設定されていません");
return;
}
var chatHistory = new List<ChatMessage>(ChatMessages);
var currentItems = State.Items.ToList();
var item = await _itemService.GenerateItemAsync(
_currentNode,
State,
chatHistory,
currentItems,
CancellationToken.None).ConfigureAwait(false);
if (item != null)
{
// UIスレッドで確実に更新
// メッセージを先に表示してから、アイテムを追加する
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
// まずメッセージを表示
AddChatMessage($"✨ {item.Name} を獲得しました!", ChatMessageType.System);
AddLog($"{item.Name} を獲得: {item.Description}");
});
// メッセージ表示後に少し遅延してアイテムを追加(視覚的な順序を保つため)
await Task.Delay(100).ConfigureAwait(false);
// UIスレッドでアイテムを追加
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
State.AddItem(item);
// アイテムの効果を適用
if (item.Effect != null)
{
State.ApplyItemEffect(item);
}
});
}
else
{
// デバッグ用:アイテムがnullの場合
AddLog("アイテム生成: LLMがアイテムを返しませんでした");
}
}
catch (Exception ex)
{
// アイテム生成に失敗してもストーリーは続行
// デバッグ用:エラーをログに出力
var errorMsg = $"アイテム生成エラー: {ex.Message}";
if (ex.InnerException != null)
{
errorMsg += $" (詳細: {ex.InnerException.Message})";
}
AddLog(errorMsg);
}
}
demo5-item-drop-labは100回の抽選を一気に走らせます。
成功率や成功インデックスを可視化します。LLM(AI)を叩く回数をむやみに増やさないための感覚を、このミニラボで掴めます。
セーブ/ロードとアーカイブの整理
保存機能の種類
保存機能は2種類あります。
アプリ起動時に保存フォルダを作ります。現在の状態をstory_save.jsonに保存します。
完了した物語は別フォルダに整理します。継続中の冒険はin_progress.jsonとして分けて管理します。
ログは最大80件まで保存します。古いものから自動で削除されます。全ログを世界標準時に揃えて、時差の問題を防ぎます。
チャットメッセージは表示用に100件まで。古いものから消えていきます。でも、AIに渡す用には全部の履歴を保持しています。
ログが壊れても大丈夫です。ISO8601やJSON断片でも許容するようにしました。これで助かりました。
demo6-storage-mapではStorySave/Completed/Progressそれぞれのファイルパスと担当メソッドをタイムラインで示します。
ボタンひとつで動作ポイントを振り返られます。
Audio Service で呼吸を合わせる
BGMとSFXを管理する仕組みです。
音声を再生する仕組みを2基持ちます。BGM用プレイヤーは再生が終わったら即ループします。
音量を0.45⇔0.0に切り替えられます。SFXは各シーンで鳴らします。
BGMのON/OFF状態を画面に反映させます。左ペインのボタンからワンタッチで雰囲気を変えられます。
実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXEで配る
配布用のEXEファイルを作る手順です。
ビルドスクリプトはdistディレクトリをまっさらにしてから、dotnet publishコマンドを実行します。不要な Microsoft.DiaSymReader.Native.*.dll を削除して InteractiveStoryTellerApp-x64.exe を直下にコピーします。
70行分のREADMEも自動生成します。配布手順・必要ストレージ・インターネット接続要件・ウイルス対策の注意までまとめます。
実行時コンパイルの一部をビルド時に済ませるオプション(事前コンパイル)を入れたことで初回起動が約1秒短くなりました。自己解凍もストレスなく終わります。
最初は、実行時コンパイルの一部をビルド時に済ませるオプションを有効にしていませんでした。でも、起動速度が遅いことに気づき、有効にしてみました。結果、起動時間が約2秒から約1秒に短縮されました。ファイルサイズは約8MB増えましたが、起動速度の向上は明らかでした。
使ってみて
実際に試してみる手順です。
1. InteractiveStoryTellerApp-x64.exe をダウンロードして、実行時コンパイルの一部をビルド時に済ませるオプションを有効にした単一EXE版をそのまま起動します。
2. フローマップを開き、各ステップをハイライトさせながら自分の物語設計を重ねてみます。
3. Story Length Labで勇気/共感/洞察の値を変えます。目標ターン数の揺れ幅を把握してから実アプリのステータス配分を決めます。
4. dotnet publish -c Release -r win-x64 でアプリをビルドします。起動後にチャットを2〜3ターン進めたら📂ロード→📚過去の物語の順に押してセーブ/アーカイブ/ログが連動することを体感します。
BGMがうるさければ左ペインのトグルかAudio Consoleで感触を掴んでから本番に戻ります。
まとめ
主要な機能
今回実装した主な機能です。
- 勇気・共感・洞察の差分で目標ターン数を±1揺らし、3〜50ターンのエピソードを計画的に締めます
- 滑らかなスクロールと0.3秒フェード、10msテキストアニメーションでチャットの没入感を維持しました
- Geminiへの送信データをStoryFlow/EventLog/Status/Items/Constraintsで構造化し、壊れたレスポンスを救出します
- 背景10分類・アイテム25%ガチャ・BGM/SFX・EventLog80件・Chat100件などリソースを整理し、%LocalAppData%配下に保存します
- dotnet publish + 実行時コンパイルの一部をビルド時に済ませるオプション + README自動生成でInteractiveStoryTellerApp-x64.exeを即配布できる体制を整えました
さらに深く学ぶには
最後まで読んでくださりありがとうございます。浮遊都市で迷子になったプレイヤーが、あなたの手で調整されたスクロールやBGMに救われますように。また進捗ができたら追記します。
— 著者