clusterの外部通信機能とGoogle Geminiを使って話しかけてくるNPCをつくってみる

この記事は[クラスター Adovent Calender 2024](https://qiita.com/advent-calendar/2024/cluster)の18日目の記事です！
テクニカルアーティストのMSA.iです。早いもので今年もこの時期がやってまいりました。

前回は「すぎしー」さんの「Visual Studio CodeでVRMを改変する」でした。
意外と簡単に編集できるですね。なかなかなためになる記事でした。
UnityのHold Onが長すぎて付き合い切れんわというときにも便利そうです。

今回この記事では、外部通信機能とGoogle Geminiを使ってワールド内のNPCが来場者に話しかけるような仕組みを作ってみようと思います。

ベースのものはt-furuさんの記事を参考にさせていただいております🙏
https://zenn.dev/t_furu/articles/81ebe7eb57a218

# GeminiのAPIトークンを取得しよう
https://aistudio.google.com/apikey
とにもかくにもAPIトークンが無いとテストもできないのでとりあえず取得してきます。
一応Gemini自体は従量課金制プランも設けられていますが、今回は無料枠の範囲で動く程度の使い方しかしませんのでそんなに警戒しなくても大丈夫です。

# 中継用のGASプロジェクトを作成しよう
外部通信機能を使うにはcluster側のAPIに認証用の文字列を返答しないといけないことになっています。（それをしないとErrorとなり何も起きません）
ので今回はGoogle App Scriptで中継サーバーを作ります。
以下で新規プロジェクトを作成
https://script.google.com/home

プロジェクトを作成したら先ほどのGeminiのTokenをスクリプトプロパティに保存します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/abbf3053-e960-ea9e-8df0-579136858495.png)

その後、以下のようなコードをメインの.gsファイルに貼り付けます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/e3243a8c-884a-f4f0-a83d-f05090e3f2cd.png)

```
const scriptProperties = PropertiesService.getScriptProperties();

// ClusterのVerifyトークン
const VERIFY_TOKEN = scriptProperties.getProperty('VERIFY_TOKEN');

// Google GeminiのAPIのエンドポイント
const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=';

// APIキー（または認証トークン）
const API_KEY = scriptProperties.getProperty('API_KEY');

// Google Apps ScriptでAPIリクエストを送信
function callGeminiAPI(message) {
  // APIリクエストのペイロード（リクエストのボディ）
  const requestBody = {
    contents:[{
      parts: [
        {text: message}
      ] 
    }]
      
  };
  // リクエストオプションの設定
  const options = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify(requestBody) // リクエストボディをJSON形式に変換して送信
  };

  try {
    // APIにリクエストを送信
    const response = UrlFetchApp.fetch(API_URL + API_KEY, options);
    const responseData = JSON.parse(response.getContentText());
    // 返答メッセージを返却
    return responseData.candidates[0].content.parts[0].text;
  } 
  catch (error) {
    // エラーハンドリング
    return 'エラーが発生しました: ' + error.message;
  }
}

function doPost(e) {
  // リクエスト内容 を ログ出力
  const dataAsString = e.postData.getDataAsString();
  const params = JSON.parse(dataAsString);
  const request = JSON.parse(params.request);

  // Geminiに問い合わせ
  const responseMessage = callGeminiAPI(request)

  // レスポンス内容を作成する
  const status = {"message": responseMessage};
  const response = JSON.stringify(status);
  const output = ContentService.createTextOutput();
  output.setMimeType(ContentService.MimeType.JSON);  
  output.setContent(JSON.stringify({ verify: VERIFY_TOKEN, response: response }));  
  return output;
}
```
# 中継用のGASプロジェクトをデプロイしよう
ではさっそくこの中継サーバーをデプロイ（リリース）しましょう！
（ClusterのVerifyトークンがないので現時点では機能しません）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/4c1a4c63-5f68-1a32-8deb-844046dca2cd.png)
「次のユーザーとして実行」は自分
「アクセスできるユーザー」は全員
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/10ae4ee9-cb9e-4f6b-a5a9-cff38296d7e2.png)

そうするとウェブアプリのURLが発行されると思うのでこれを控えておきます！

# ClusterでVerifyトークンを発行しよう
Unity上のClusterタブから外部通信接続先を登録します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/4a6276bf-86f6-da0b-b7ac-aea9313c1d74.png)

その後、発行されたVerifyトークンをGASへ登録します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/57c445ee-90b6-e27c-65ca-cefe0dae5e08.png)

# Cluster Scriptを用意しよう
今回はこんな感じのScriptを用意してみました。
特にcallExternalのDataに関しては、好きに変えていろいろな返答をしてもらうと面白いと思います。
```
$.onStart(() => {
  $.state.targetPlayer = null;
});

$.onUpdate(deltaTime => {
  // Playerが取得できているとき
  if ($.state.targetPlayer) {
    // 前回と異なるPlayerなら
    if ($.state.oldPlayerIdfc == null || $.state.oldPlayerIdfc != $.state.targetPlayer.idfc){
      callGemini($.state.targetPlayer);
      $.state.oldPlayerIdfc = $.state.targetPlayer.idfc;
    }
  }
});

function callGemini(player) {
  const data = {"message": player.userDisplayName + "さんが近くにいるのでこの方のユーザー名にちなんだ挨拶をしてあげてください"};
  $.callExternal(JSON.stringify(data), "greeting");
}

$.onCollide(collision => {
  if (collision.object.playerHandle) {
    // プレイヤーと衝突した場合、そのプレイヤーを追跡
    $.state.targetPlayer = collision.object.playerHandle;
  }
});

$.onExternalCallEnd((response, meta, errorReason) => {
  // レスポンスをログに表示する
  if (errorReason != null) {
    $.log(`errorReason ${errorReason}`);
  }
  const status = JSON.parse(response);
  const message = status.message;
  const textView = $.subNode("TextView");
  if (textView) {
    textView.setText(insertLineBreaks(message));
  }
});

function insertLineBreaks(inputStr) {
  // 入力された文字列を行ごとに分割
  let lines = inputStr.split('\n');
  
  // 各行に対して25文字ごとに改行を挿入
  lines = lines.map(line => {
    let result = '';
    while (line.length > 25) {
      result += line.slice(0, 25) + '\n'; // 25文字を切り取って改行を追加
      line = line.slice(25); // 残りの文字列を取り出す
    }
    result += line; // 残った部分を追加
    return result;
  });

  // 改行された行を再度結合して最終的な文字列を返す
  return lines.join('\n');
}
```
最後はScriptableItemにScriptをアサインしてアップロードします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174972/4298136f-6767-cb77-fb31-fbcf9a5b70f2.png)

# あとがき
デプロイするたびにVerifyトークンを登録しなおさなきゃいけないのがネックですが、そこを越えればClusterScriptをちまちま変えてあれやこれやといろいろ楽しめると思います！
みなさんもぜひやってみてください！！