NPCよ自我を持て〜UnityとChatGPTを連携させ、接続を単純化〜

# はじめに
本記事では、ChatGPTへの接続・テキスト取得を簡素にする`GPTManager`の導入について書きます。
メソッド一つで、ChatGPTからメッセージを寄越してくるようにします。

インパクトのために大袈裟なタイトルにしていて、ただが1大学生がイキがってんじゃねーよという所ではありますが、個人的にはChatGPTのゲームへの導入は革命的であると考えています。
というのも、毎回プレイするたびに異なるメッセージを与えることで、バリエーション豊かなプレイ体験を生み、 **「繰り返しても面白い」** を実現する一手であるからです。

デモンストレーションとして、下記の動画を用意しました。
https://youtu.be/eXH9HPEe8Co
このFPSゲームはUnityのサンプルゲーム(Unity Hubからダウンロードできます)そのままですが、右上に「オペレーター」を加えました。
経過時間、プレイヤーと敵のHPを入力し、「なんかプレイヤーに言ってあげて！」というプロンプトで得たメッセージを表示しています。
正直地味ですが、未来のゲームって感じがしますね。メッセージ考えたり条件分岐作ったりする手間もいらないし。
最近流行ってるプロンプトエンジニアリングの知見を活かせばもっと豊かなメッセージができると思います。

:::note
動画のUnityプロジェクト(OpenAIのトークンは自分のものを入力してください)
https://github.com/konbraphat51/FPS-Operator
:::

それでは、やっていきましょう。

(参考)
ゲームの面白さについて(岡本吉起さんには大学の講義でお世話になりました)：https://games.app-liv.jp/archives/320453
ゲームへのChatGPT導入の可能性について(今回の動機です):https://qiita.com/mfuji3326/items/d705831d58a70b4e4a27

# 前準備！
まずは、ChatGPTのAPIを利用するために、OpenAIのトークンを発行する必要があります。
今回利用するのは「text-davinci-003」です。

**アカウント作成**
作ってください：https://openai.com
画面最下部の「Log in」からアカウント作成画面に移れます。

**トークン発行**
発行してください：https://beta.openai.com/account/api-keys
発行したらメモるように。

# 実装！
実装にあたっては、[面白法人カヤックのkb-y-nakanoさんの記事](https://techblog.kayac.com/2022-group-calendar-unity-openapi)を大幅に参考にさせて頂いております。特にAPIとの連絡方法が簡潔で感激しました。

APIとの接続において、Cysharpさんの`UniTask`を利用します。
https://github.com/Cysharp/UniTask/releases
`UniTask.最新バージョン.unitypackage`
をインポート。

JSONのシリアライズ/デシリアライズに`Newtonsoft.Json`を利用します。
僕はデフォルトで入っていましたが、入っていない方はこちらをご参考に：https://qiita.com/sakano/items/6fa16af5ceab2617fc0f

`GPTManager`をシングルトンにします。
`SingletonMonoBehaviour.cs`を作成し、こちらの記事のスクリプトをコピペ(シングルトンを作る時は毎回お世話になっています。ありがとうございます…)：https://qiita.com/Teach/items/c146c7939db7acbd7eee

いよいよ`GPTManager.cs`を作成。下記をコピペしてください。
**そして、`API_KEY`の初期化のところに先程発行したトークンを記入**

スクリプトは名前空間`GPT`に閉じ込めています。

```C#:GPTManager.cs
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine.Events;
using UnityEngine.Networking;
using Newtonsoft.Json;
using Cysharp.Threading.Tasks;

namespace GPT
{
    public class GPTManager : SingletonMonoBehaviour<GPTManager>
    {
        //テキストを得たら、Invoke
        //インデックスはID
        Dictionary<int, UnityEvent<string>> onTextGots = new Dictionary<int, UnityEvent<string>>();

        //次に登録するID
        private int nextID = 0;

        //アクセス先
        const string API_END_POINT = "https://api.openai.com/v1/completions";
        const string API_KEY = "ここにトークンを代入";

        /// <summary>
        /// プロンプトをChatGPTに送り、得られたテキストをtextGetterに渡す
        /// </summary>
        /// <param name="prompt">ChatGPTに入力するプロンプト</param>
        /// <param name="textGetter">取得した際に呼ばれるメソッド。引数に得られたテキストを渡す</param>
        public void GetText(string prompt, UnityAction<string> textGetter)
        {
            //ゲッターを登録
            int id = AddQueue(textGetter);

            //非同期処理（API送信・受信）を開始
            GetAPIResponse(prompt, id);
        }

        /// <summary>
        /// 非同期処理部分
        /// APIを送信・受信
        /// ゲッターを呼ぶ
        /// </summary>
        private async void GetAPIResponse(string prompt, int id)
        {
            //リクエストのJSONオブジェクト
            APIRequestData requestData = new()
            {
                Prompt = prompt,
                MaxTokens = 300 //レスポンスのテキストが途切れる場合、こちらを変更する
            };

            //シリアライズ
            string requestJson = JsonConvert.SerializeObject(requestData, Formatting.Indented);

            // POSTするデータ
            byte[] data = System.Text.Encoding.UTF8.GetBytes(requestJson);

            string jsonString = null;
            // POSTリクエストを送信
            using (UnityWebRequest request = UnityWebRequest.Post(API_END_POINT, "POST"))
            {
                request.uploadHandler = new UploadHandlerRaw(data);
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.SetRequestHeader("Authorization", "Bearer " + API_KEY);
                await request.SendWebRequest();

                switch (request.result)
                {
                    case UnityWebRequest.Result.InProgress:
                        //リクエスト中
                        break;
                    case UnityWebRequest.Result.Success:
                        //リクエスト成功
                        jsonString = request.downloadHandler.text;
                        break;
                    default:
                        throw new ArgumentOutOfRangeException();

                }
            }

            // デシリアライズ
            APIResponseData jsonObject = JsonConvert.DeserializeObject<APIResponseData>(jsonString);

            //レスポンスからテキスト取得
            string outputText = jsonObject.Choices.FirstOrDefault().Text;
            string resultText = outputText.TrimStart('\n');

            //発動
            onTextGots[id].Invoke(resultText);

            //UnityEventが不要になったので、削除
            DeleteQueue(id);
        }

        /// <summary>
        /// 発動待ちを追加
        /// IDを返す
        /// </summary>
        private int AddQueue(UnityAction<string> unityAction)
        {
            //このUnityEventのID
            int currentID = nextID;

            //登録
            onTextGots[currentID] = new UnityEvent<string>();
            onTextGots[currentID].AddListener(unityAction);

            //ID進める
            nextID++;

            return currentID;
        }

        /// <summary>
        /// 不要なUnityEventを削除
        /// </summary>
        private void DeleteQueue(int id)
        {
            //Dictionaryから削除
            onTextGots.Remove(id);
        }

        //以下、JSONデータのクラス

        [JsonObject]
        private class APIRequestData
        {
            [JsonProperty("model")]
            public string Model { get; set; } = "text-davinci-003";
            [JsonProperty("prompt")]
            public string Prompt { get; set; } = "";
            [JsonProperty("temperature")]
            public int Temperature { get; set; } = 0;
            [JsonProperty("max_tokens")]
            public int MaxTokens { get; set; } = 100;
        }
   
        [JsonObject]
        private class APIResponseData
        {
            [JsonProperty("id")]
            public string Id { get; set; }
            [JsonProperty("object")]
            public string Object { get; set; }
            [JsonProperty("model")]
            public string Model { get; set; }
            [JsonProperty("created")]
            public int Created { get; set; }
            [JsonProperty("choices")]
            public ChoiceData[] Choices { get; set; }
            [JsonProperty("usage")]
            public UsageData Usage { get; set; }
        }
    
        [JsonObject]
        private class UsageData
        {
            [JsonProperty("prompt_tokens")]
            public int PromptTokens { get; set; }
            [JsonProperty("completion_tokens")]
            public int CompletionTokens { get; set; }
            [JsonProperty("total_tokens")]
            public int TotalTokens { get; set; }
        }
    
        private class ChoiceData
        {
            [JsonProperty("text")]
            public string Text { get; set; }
            [JsonProperty("index")]
            public int Index { get; set; }
            [JsonProperty("logprobs")]
            public string Logprobs { get; set; }
            [JsonProperty("finish_reason")]
            public string FinishReason { get; set; }
        }  
    }
}
```

この`GPTManager.cs`を空オブジェクトにアタッチ。

これで完了です。

# 使い方
使い方は簡単。
まずは **ChatGPTのメッセージを受け取るメソッド** `Receiver(string message)`を用意。
そして、プロンプトを送る際は、`GPTManager.Instance.GetText("好きなプロンプト", Receiver);`だけでOK

# 使用例
ちゃんと働いてるかテストするコード。
何か言ってもらって、デバッグログに表示する。
```C#:GPTManagerTester.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace GPT
{
    public class GPTManagerTester : MonoBehaviour
    {
        //なんか言わせる
        private void Start()
        {
            GPTManager.Instance.GetText("なんか言ってください", Log);
        }

        //Logに出力
        private void Log(string result)
        {
            Debug.Log(result);
        }
    }
}
```

冒頭のFPSサンプルゲームの例。
ChatGPTに経過時間、プレイヤー、敵のHPを与えている。

```C#:Operator.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unity.FPS.Game;
using GPT;


public class Operator : MonoBehaviour
{
    //UI
    [SerializeField] private TextMeshProUGUI messageUI;

    //HPコンポーネント
    [SerializeField] private Health playerHealth;
    [SerializeField] private Health enemyHealth;

    [Tooltip("メッセージ間隔（秒）")]
    [SerializeField] private float messageInterval = 30f;

    private float messageTimer = 0f;

    private float wholeTimer = 0f;

    //作戦開始
    void Start()
    {
        GPTManager.Instance.GetText(
            "You are an operator of a game player as a soldier. The game just started now. Say greetings to the player.",
            UpdateMessage);
    }

    private void Update()
    {
        //タイマー進める
        messageTimer += Time.deltaTime;
        wholeTimer += Time.deltaTime;

        //時間が来たら、メッセージ更新
        if(messageTimer >= messageInterval)
        {
            FetchNextMessage();

            //タイマーリセット
            messageTimer = 0f;
        }
    }

    /// <summary>
    /// 状況をインプットし、オペレーターを話させる
    /// </summary>
    private void FetchNextMessage()
    {
        //HPをパーセンテージで
        float playerHealthRatio = playerHealth.GetRatio() * 100f;
        float enemyHealthRatio = enemyHealth.GetRatio() * 100f;

        //経過時間
        int passedTime = (int)wholeTimer;

        //プロンプト
        string prompt = "You are an operator of a game player as a soldier. " +
            $"{passedTime} seconds has passed from the game started. " +
            $"Player's HP is now {playerHealthRatio}%. " +
            $"Enemy's HP is now {enemyHealthRatio}%. " +
            "Say something to the player within 30 words.";

        //ChatGPTに送信
        GPTManager.Instance.GetText(prompt, UpdateMessage);
    }

    /// <summary>
    /// テキストを取得したら、UIを更新
    /// </summary>
    /// <param name="text">GPTから得られたテキスト</param>
    private void UpdateMessage(string text)
    {
        messageUI.text = text;
    }
}
```

って感じです。

# 今後の展望
チラッと言いましたが、[【ChatGPT】人のように話すNPCが実現可能に！？　ChatGPTの創作への応用方法](https://qiita.com/mfuji3326/items/d705831d58a70b4e4a27)を読んで、これは今後のゲーム体験がとんでもないことになるぞと燃え、今回の記事に至りました。

今回の例ではプレイヤーを励ますオペレーターを実装しましたが、上手いことプロンプトを作ってChatGPTにはNPCになりきってもらい、いずれNPCが自我を持つようになることを期待しています。

例えば
```C#:NPC.cs
//職業
[SerializeField] private string occupation;
//性格
[SerializeField] private string nature;
```
みたいな感じでNPCの情報をstring型で編集できるよう用意し、あとは適宜入力しながら配置。すると自我を持って自分の役割になりきったNPCが完成。

というような感じでしょうか。プログラミングができなくとも自然言語で入力すればいいので、土台さえ作ればデザイナーさんとっても表現の幅が莫大に増えるでしょう。(というよりかは、自我を持っているので手に負えないでしょうが)

今後のゲーム界の展望が楽しみです。