経緯
AIによる自然な会話をゲームに組み込みたい。
今回はゲーム側からの呼び出しを実装する。
AI側の準備は過去の記事を参照
実現方法
- AIインタフェース : Ollama
- モデル : gemma3:4b
- Ollama が起動するローカルサーバを利用
- ゲームエンジン : Unreal Engine5
- 実装方法 : ブループリント
最終的にはAIをゲームに直接組み込むなどするかもしれないが、この記事ではひとまず上記の方法で実施。
Unreal Engine 5 を選んだ理由は特になし (もともと環境を作っていたため使いまわし)。
やってみる
1. プロジェクトを作成
ここは作りたいゲーム次第なので特に解説せず。
最低限の設定のみ共有。
| オプション | |
|---|---|
| プロジェクト | ブランク |
| 実装方法 | ブループリント |
| ターゲット プラットフォーム | Desktop |
| 品質プリセット | Scalable |
レベルの設定などもお好みで。
2. 最低限の UI を追加
追加する手順を表示
土台
文字入力
- パレットから「テキストボックス (複数行)」を「キャンバスパネル」の直下に追加
レイアウトや色などはお好みで。
送信ボタン
- パレットから「ボタン」を「キャンバスパネル」の直下に追加
- ボタンの詳細の「Is Visible」のチェックボックスにチェック
- ボタンの詳細 > イベント にある「On Clicked」の横の「+」ボタンをクリック
- イベントグラフに移動するものの、後で実装するので何もせずに「デザイナー」表示に戻す
レイアウトや色などはお好みで。
「送信」というテキストを載せてもよい。
送信内容表示エリア
- パレットから「テキスト」を「キャンバスパネル」の直下に追加
応答表示エリア
- パレットから「スクロールボックス」を「キャンバスパネル」の直下に追加
- パレットから「テキスト」を「スクロールボックス」の直下に追加
- テキストの詳細の「Is Visible」のチェックボックスにチェック
レイアウトや色などはお好みで。
ゲームに UI を表示する
適当な場所で作成したウィジェットを Viewport に追加し、入力を制御する。
とりあえず今回はレベルブループリントの Event BeginPlay に直接以下を追加。
- Create Widget の Class には上記で作成したウィジェットブループリントを指定する
- 今回の場合は WBP_AI_Interface という名前にしたので、それを指定している
3. Ollama とやりとりする部分を作る
C++ 部分を実装する
こんな感じのコードを実装する。(適当なプラグインを使おうとしたら Experimental だったので、自前で実装)
追加する手順を表示
土台
- ツール > 新規 C++ クラス を選択
- 親クラスは「アクタコンポーネント」
- クラス名「AIChatComponent」
- Build.cs に必要なモジュールを追加し、Unreal Editor に戻ってビルド
プロジェクト名.Build.cs
PublicDependencyModuleNames.AddRange(new string[] { "HTTP", "Json", "JsonUtilities" }); - ModelType.h を追加
ModelType.h
#pragma once #include "CoreMinimal.h" #include "ModelType.generated.h" UENUM(BlueprintType) enum class EModelType : uint8 { Gemma3_4B UMETA(DisplayName="Gemma3 4B"), Custom UMETA(DisplayName="Custom") };
プロンプト調整
※このクラスはなくてもよい (著者の好みで追加)
- ツール > 新規 C++ クラス を選択
- 親クラスは「なし」
- クラス名「PromptBuilder」
- 自動生成されたヘッダに、以下を追加 (自動生成された部分を省略しています)
PromptBuilder.h
#include "ModelType.h" class YOURPROJECT_API PromptBuilder { public: FString BuildPrompt( EModelType ModelType, const FString& UserPrompt ) const; private: FString BuildForGemma(const FString& UserPrompt) const; }; - 自動生成された cpp に、以下を追加 (自動生成された部分を省略しています)
PromptBuilder.cpp
FString PromptBuilder::BuildPrompt( EModelType ModelType, const FString& UserPrompt ) const { switch (ModelType) { case EModelType::Gemma3_4B: return BuildForGemma(UserPrompt); case EModelType::Custom: default: return UserPrompt; } } FString PromptBuilder::BuildForGemma(const FString& UserPrompt) const { // 後で調整するかもしれないが、とりあえずそのまま使う return UserPrompt; }
HTTP 通信
- HttpClient.h を追加
HttpClient.h
#pragma once #include "CoreMinimal.h" #include "HttpModule.h" #include "Interfaces/IHttpRequest.h" #include "Interfaces/IHttpResponse.h" class HttpClient { public: void PostJson( const FString& Url, const FString& JsonBody, TFunction<void(const FString&)> OnSuccess, TFunction<void(const FString&)> OnError ) { TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest(); Request->SetURL(Url); Request->SetVerb(TEXT("POST")); Request->SetHeader(TEXT("Content-Type"), TEXT("application/json")); Request->SetContentAsString(JsonBody); Request->OnProcessRequestComplete().BindLambda( [OnSuccess, OnError](FHttpRequestPtr Req, FHttpResponsePtr Res, bool bSuccess) { if (!bSuccess || !Res.IsValid()) { OnError(TEXT("HTTP request failed")); return; } OnSuccess(Res->GetContentAsString()); } ); Request->ProcessRequest(); } };
Ollama 向けの処理
- AIContext.h を追加
AIContext.h
#pragma once #include "CoreMinimal.h" #include "AIContext.generated.h" USTRUCT(BlueprintType) struct FAIContext { GENERATED_BODY() UPROPERTY() TArray<int32> OllamaContext; }; - OllamaClient.h を追加
OllamaClient.h
#pragma once #include "CoreMinimal.h" #include "Json.h" #include "JsonUtilities.h" #include "HttpClient.h" #include "AIContext.h" #include "ModelType.h" class OllamaClient { public: void Generate( EModelType ModelType, const FString& Prompt, const FAIContext& InContext, TFunction<void(const FString&, const FAIContext& OutContext)> OnSuccess, TFunction<void(const FString&)> OnError ) { const FString ModelName = ResolveModelName(ModelType); // JSON Body TSharedRef<FJsonObject> JsonObject = MakeShared<FJsonObject>(); JsonObject->SetStringField("model", ModelName); JsonObject->SetStringField("prompt", Prompt); if (InContext.OllamaContext.Num() > 0) { TArray<TSharedPtr<FJsonValue>> ContextJson; for (int32 Val : InContext.OllamaContext) { ContextJson.Add(MakeShared<FJsonValueNumber>(Val)); } JsonObject->SetArrayField(TEXT("context"), ContextJson); } FString Body; TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Body); FJsonSerializer::Serialize(JsonObject, Writer); // HTTP POST Http.PostJson( TEXT("http://localhost:11434/api/generate"), Body, [InContext, OnSuccess, OnError](const FString& ResponseText) { // JSon parse FString AIText; FAIContext OutContext = InContext; TArray<FString> Lines; ResponseText.ParseIntoArrayLines(Lines); for (const FString& Line : Lines) { FString Trimmed = Line.TrimStartAndEnd(); if (Trimmed.IsEmpty()) continue; TSharedPtr<FJsonObject> JsonObj; TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Trimmed); if (!FJsonSerializer::Deserialize(Reader, JsonObj) || !JsonObj.IsValid()) continue; if (JsonObj->HasField(TEXT("error"))) { FString ErrorMessage = JsonObj->GetStringField(TEXT("error")); OnError(ErrorMessage); return; } if (JsonObj->HasField(TEXT("response"))) { AIText += JsonObj->GetStringField(TEXT("response")); } if (JsonObj->HasField(TEXT("done")) && JsonObj->GetBoolField(TEXT("done"))) { if (JsonObj->HasField(TEXT("context"))) { OutContext.OllamaContext.Empty(); const auto& Arr = JsonObj->GetArrayField(TEXT("context")); for (auto& Val : Arr) { OutContext.OllamaContext.Add(Val->AsNumber()); } } break; } } OnSuccess(AIText, OutContext); }, OnError ); } private: FString ResolveModelName(EModelType ModelType) const { switch (ModelType) { case EModelType::Gemma3_4B: return TEXT("gemma3:4b"); case EModelType::Custom: default: return TEXT("gemma3:4b"); } } HttpClient Http; };
ブループリント向けのインタフェース
- 自動生成された AIChatComponent.h に、以下を追加 (自動生成された部分を省略しています)
AIChatComponent.h
#include "AIContext.h" #include "ModelType.h" #include "PromptBuilder.h" #include "OllamaClient.h" DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FAIResponseEvent, const FString&, Response); class YOURPROJECT_API UAIChatComponent : public UActorComponent { public: UFUNCTION(BlueprintCallable) void SendChat(EModelType ModelType, const FString& UserPrompt); UPROPERTY(BlueprintAssignable) FAIResponseEvent OnAIResponse; UPROPERTY(VisibleAnywhere, BlueprintReadWrite) FAIContext Context; private: void HandleResponse(const FString& ResponseText); PromptBuilder Builder; OllamaClient Ollama; }; - 自動生成された AIChatComponent.cpp に、以下を追加 (自動生成された部分を省略しています)
AIChatComponent.cpp
UAIChatComponent::UAIChatComponent() { // 自動生成だと true になっているものの、使わないため false に変えておく PrimaryComponentTick.bCanEverTick = false; } void UAIChatComponent::SendChat(EModelType ModelType, const FString& UserPrompt) { FString FinalPrompt = Builder.BuildPrompt(ModelType, UserPrompt); Ollama.Generate( ModelType, FinalPrompt, Context, [this](const FString& ResponseText, const FAIContext& NewContext) { Context = NewContext; HandleResponse(ResponseText); }, [this](const FString& Error) { UE_LOG(LogTemp, Error, TEXT("Ollama Error: %s"), *Error); } ); } void UAIChatComponent::HandleResponse(const FString& ResponseText) { OnAIResponse.Broadcast(ResponseText); }
アクタを追加する
- コンテンツブラウザの中の適当なパスで右クリックし、以下を選択
- 親クラスは「アクタ」
- 作成したブループリントを開き、「イベントグラフ」を表示する
- コンポーネントパネルで「追加」から「AIChat」を追加する
- 作ったアクタをレベルに配置する
- UI として作っていたウィジェットブループリントを開く
できたもの
ちょっとレスポンスが遅い。。。(三つ目の問いかけへのレスポンスが無限に来なかったので録画を中断)

レスポンス自体の長さと返ってくるまでの時間に相関がありそうなので、プロンプトを調整してみる。
FString PromptBuilder::BuildForGemma(const FString& UserPrompt) const
{
return UserPrompt + TEXT("。短めにレスポンスしてください");
}
パッケージ化するともう少し早くなるかも?
感想
- HTTP通信のための既存プラグインは使いづらかった (結局使わなかったので記事からは割愛)
- 自前で用意したので、拡張はしやすくなった
- 手順を丁寧に書いていくと、想定より記事が長くなってしまった。。。
今後やるかもしれないこと
- 今回実装した機能だけをメインにちょっとしたゲームっぽいものを作る
- Ollama を経由せず、ゲームに直接 LLM を組み込む






