0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゲーム側からAPIサーバ経由でテキスト生成AIを呼び出す

0
Last updated at Posted at 2026-02-05

経緯

AIによる自然な会話をゲームに組み込みたい。
今回はゲーム側からの呼び出しを実装する。

AI側の準備は過去の記事を参照

実現方法

  • AIインタフェース : Ollama
    • モデル : gemma3:4b
    • Ollama が起動するローカルサーバを利用
  • ゲームエンジン : Unreal Engine5
    • 実装方法 : ブループリント

最終的にはAIをゲームに直接組み込むなどするかもしれないが、この記事ではひとまず上記の方法で実施。
Unreal Engine 5 を選んだ理由は特になし (もともと環境を作っていたため使いまわし)。

やってみる

1. プロジェクトを作成

ここは作りたいゲーム次第なので特に解説せず。
最低限の設定のみ共有。

オプション
プロジェクト ブランク
実装方法 ブループリント
ターゲット プラットフォーム Desktop
品質プリセット Scalable

レベルの設定などもお好みで。

2. 最低限の UI を追加

こんな感じの UI を追加する。
image.png

追加する手順を表示

土台

  1. コンテンツブラウザの中の適当なパスで右クリックし、以下を選択
    image.png
  2. 親クラスは「ユーザーウィジェット」
  3. 作成したウィジェットブループリントを開く
  4. パレットから「キャンバスパネル」を階層のトップに追加

文字入力

  • パレットから「テキストボックス (複数行)」を「キャンバスパネル」の直下に追加
    1. テキストボックスの詳細の「Is Visible」のチェックボックスにチェック
    2. テキストボックスの詳細 > イベント にある「OnTextCommited」の横の「+」ボタンをクリック
    3. イベントグラフに移動するので、Text型の変数を追加 (ここでは「入力」という名前にしておく)
    4. OnTextCommited の Text を作成した「入力」変数にセットimage.png
    5. 「グラフ」が表示されているので、「デザイナー」表示に戻す

レイアウトや色などはお好みで。

送信ボタン

  • パレットから「ボタン」を「キャンバスパネル」の直下に追加
    1. ボタンの詳細の「Is Visible」のチェックボックスにチェック
    2. ボタンの詳細 > イベント にある「On Clicked」の横の「+」ボタンをクリック
    3. イベントグラフに移動するものの、後で実装するので何もせずに「デザイナー」表示に戻す

レイアウトや色などはお好みで。
「送信」というテキストを載せてもよい。

送信内容表示エリア

  • パレットから「テキスト」を「キャンバスパネル」の直下に追加
    1. テキストの詳細の「Is Visible」のチェックボックスにチェック
    2. 「グラフ」表示にする
    3. 送信ボタンで追加した On Clicked から SetText
      • Target は送信内容表示エリア
      • In Text は「入力」変数
        image.png

応答表示エリア

  • パレットから「スクロールボックス」を「キャンバスパネル」の直下に追加
  • パレットから「テキスト」を「スクロールボックス」の直下に追加
    • テキストの詳細の「Is Visible」のチェックボックスにチェック

レイアウトや色などはお好みで。

ゲームに UI を表示する

適当な場所で作成したウィジェットを Viewport に追加し、入力を制御する。
とりあえず今回はレベルブループリントの Event BeginPlay に直接以下を追加。

image.png

  • Create Widget の Class には上記で作成したウィジェットブループリントを指定する
    • 今回の場合は WBP_AI_Interface という名前にしたので、それを指定している

3. Ollama とやりとりする部分を作る

C++ 部分を実装する

こんな感じのコードを実装する。(適当なプラグインを使おうとしたら Experimental だったので、自前で実装)

追加する手順を表示
土台
  1. ツール > 新規 C++ クラス を選択
  2. 親クラスは「アクタコンポーネント」
  3. クラス名「AIChatComponent」
  4. Build.cs に必要なモジュールを追加し、Unreal Editor に戻ってビルド
    プロジェクト名.Build.cs
    PublicDependencyModuleNames.AddRange(new string[] {
        "HTTP",
        "Json",
        "JsonUtilities"
    });
    
  5. 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")
    };
    
プロンプト調整

※このクラスはなくてもよい (著者の好みで追加)

  1. ツール > 新規 C++ クラス を選択
  2. 親クラスは「なし」
  3. クラス名「PromptBuilder」
  4. 自動生成されたヘッダに、以下を追加 (自動生成された部分を省略しています)
    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;
    };
    
  5. 自動生成された 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;
    };
    
ブループリント向けのインタフェース
  1. 自動生成された 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;
    };
    
  2. 自動生成された 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);
    }
    

アクタを追加する

  1. コンテンツブラウザの中の適当なパスで右クリックし、以下を選択image.png
  2. 親クラスは「アクタ」
  3. 作成したブループリントを開き、「イベントグラフ」を表示する
  4. コンポーネントパネルで「追加」から「AIChat」を追加する
  5. 作ったアクタをレベルに配置する
  6. UI として作っていたウィジェットブループリントを開く
    • 送信ボタンの On Clicked -> SetText の続きに、以下の通り AI とやりとりする部分を追加する
      image.png
      • Get Actor Of Class で指定するのは、先ほど追加したアクタクラス

できたもの

ちょっとレスポンスが遅い。。。(三つ目の問いかけへのレスポンスが無限に来なかったので録画を中断)
demo1.gif

レスポンス自体の長さと返ってくるまでの時間に相関がありそうなので、プロンプトを調整してみる。

PromptBuilder.cpp
FString PromptBuilder::BuildForGemma(const FString& UserPrompt) const
{
    return UserPrompt + TEXT("。短めにレスポンスしてください");
}

ちょっと改善された!
demo2.gif

パッケージ化するともう少し早くなるかも?

感想

  • HTTP通信のための既存プラグインは使いづらかった (結局使わなかったので記事からは割愛)
    • 自前で用意したので、拡張はしやすくなった
  • 手順を丁寧に書いていくと、想定より記事が長くなってしまった。。。

今後やるかもしれないこと

  • 今回実装した機能だけをメインにちょっとしたゲームっぽいものを作る
  • Ollama を経由せず、ゲームに直接 LLM を組み込む
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?