VRCから外部に情報を送信する方法（API編）

# 初めに
今はまだ、どうあがいても動的にURLを生成(*1)したりHTTP POSTをすることは叶わない（2023/09/22時点）。しかし、```VRCStringDownloader```等でリクエストを飛ばすことは可能。そこで、今回はリクエストを飛ばすURLのパスやクエリパラメータを介することで情報を送る方法を紹介する。

脳筋な事この上ないが、送りたい情報の**全パターンを網羅してリクエストを送れば解決**という事である。

https://creators.vrchat.com/worlds/udon/string-loading/

(*1) プレイヤーに手動でURLを入力させる場合に限り、動的にURLを設定することができる

# 今回作るもの
今回はシンプルなアンケートを作ることにする。雑だが、こんな感じ。
![Untitled Diagram.drawio (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/956485/e2281e9d-94a3-682d-ce1a-2c11f7ae7d5b.png)

アンケートの仕様としてはざっと
- アンケートは全6問
- 各問選択肢は5つ
- 単一回答
- リクエストは2回に分けて送信する(*2)
- 全選択肢の初期値は-1
- 選択肢を選ぶと色が変わる
- 未回答の選択肢があると送信できない

(*2) 後述の技術的制約から15625通り(5^6)のVRCUrlは現実的に作ることはできない為

# 実装
## Unity
まずはアンケートの基礎部分。選択肢の情報を保存して送信する機能を作る。
```cs
using System;
using System.Reflection;
using UdonSharp;
using UnityEngine;
using VRC.SDK3.StringLoading;
using VRC.SDK3.Video.Components;
using VRC.SDKBase;
using VRC.Udon;
using System.Collections.Generic;

public class QuestionnaireController : UdonSharpBehaviour
{
    public Material selectedMaterial;
    public Material defaultMaterial;
    public GameObject[] question1Buttons;
    public GameObject[] question2Buttons;
    public GameObject[] question3Buttons;
    public GameObject[] question4Buttons;
    public GameObject[] question5Buttons;
    public GameObject[] question6Buttons;

    public int[] answers = new int[5];
    public int[] currentSelections;

    public VRCUrl[] q1_3_urls = {
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/000"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/001"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/002"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/003"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/004"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/010"),
        ......
        ...
    }

    public VRCUrl[] q4_6_urls = {
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/000"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/001"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/002"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/003"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/004"),
        new VRCUrl("IP_ADDRESS_HERE:3000/body/q4-6/010"),
        ......
        ...
    }


    private void Start()
    {
        // Initialize current selections to -1 (no selection)
        currentSelections = new int[6];
        for (int i = 0; i < currentSelections.Length; i++) {
            currentSelections[i] = -1;
        }
    }

    public void SelectAnswer(int questionIndex, int answerIndex)
    {
        GameObject[] targetQuestionButtons = null;

        switch (questionIndex)
        {
            case 0:
                targetQuestionButtons = question1Buttons;
                break;
            case 1:
                targetQuestionButtons = question2Buttons;
                break;
            case 2:
                targetQuestionButtons = question3Buttons;
                break;
            case 3:
                targetQuestionButtons = question4Buttons;
                break;
            case 4:
                targetQuestionButtons = question5Buttons;
                break;
            case 5:
                targetQuestionButtons = question6Buttons;
                break;
        }

        if (targetQuestionButtons == null) return;

        // Reset the material for the previous selection for this question, if any
        if (currentSelections[questionIndex] != -1)
        {
            targetQuestionButtons[currentSelections[questionIndex]].GetComponent<Renderer>().material = defaultMaterial;
        }

        // Set the material for the new selection
        targetQuestionButtons[answerIndex].GetComponent<Renderer>().material = selectedMaterial;

        // Record the new selection
        currentSelections[questionIndex] = answerIndex;
        answers[questionIndex] = answerIndex;
    }

    public void SubmitAnswers()
    {
        string answers_1_3 = string.Concat(answers[0], answers[1], answers[2]);
        int index_1_3 = ConvertAnswerKeyToIndex(answers_1_3);
        VRCUrl url_1_3 = q1_3_urls[index_1_3];

        string answers_4_6 = string.Concat(answers[3], answers[4], answers[5]);
        int index_4_6 = ConvertAnswerKeyToIndex(answers_4_6);
        VRCUrl url_4_6 = q4_6_urls[index_4_6];

        Debug.Log("Request URL 1-3: " + url_1_3.Get());
        Debug.Log("Request URL 4-6: " + url_4_6.Get());

        // Request is sent here
        VRCStringDownloader.LoadUrl(url_1_3, this.GetComponent<UdonBehaviour>());
        VRCStringDownloader.LoadUrl(url_4_6, this.GetComponent<UdonBehaviour>());
    }

    private int ConvertAnswerKeyToIndex(string answerKey)
    {
        if (answerKey.Length != 3)
        {
            Debug.LogError("Invalid answerKey: " + answerKey);
            return -1;
        }

        int firstDigit = int.Parse(answerKey[0].ToString());
        int secondDigit = int.Parse(answerKey[1].ToString());
        int thirdDigit = int.Parse(answerKey[2].ToString());

        int index = firstDigit * 25 + secondDigit * 5 + thirdDigit;

        return index;
    }
```
ゲームオブジェクト周りの話は今回重要ではない為省略するが、要はあらかじめ3問毎に全ての選択肢の選び方（今回は 5 * 5 * 5 = 125通り）をあらかじめ用意しておき、選択パターンから配列の番号を計算して該当する```new VRCUrl("IP_ADDRESS_HERE:3000/body/q1-3/hogehoge")```を引っ張ってくる。

ここで、「なんで3問毎にやってるの？」と考える方もいると思うが、これは単純にUnity側が15625(5^6)個のVRCUrlを作ると落ちる問題があるためである。重くなりそうなので、試してないが1000個ぐらいまでは何とかなるかもしれない。

VRC SDKの制約によって、リクエストは5秒に一回しか送れない為、リクエストを2つ同時にVRCから送ろうとすると、ランダムな順番でキューに入って順に処理される。パスで```body```の後に質問番号を書いてあるのはどの問題に対する回答であるかを明確にするため。初めから5秒に一回送るようにすればこの問題は回避できる。

## サーバー
手っ取り早くNode.jsでリクエストのパスをパラメータとして抽出するスクリプトを作る。websocket程リッチなものを使う必要は無いが、ChatGPT君がわざわざ作ってくれたのそのまま採用する
```js
// server.js
const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

// 全体で共有する回答データ
let combinedAnswers = {};

// 別のHTTP GETリクエストを待ち受ける
app.get('/body/:questions/:answer', (req, res) => {
  const questions = req.params.questions;
  const answer = req.params.answer;
  const parsedAnswer = parseAnswer(questions, answer);

  if (!parsedAnswer) {
    res.status(400).send({ error: 'Invalid questions parameter' });
    return;
  }

  const ipAddress = req.connection.remoteAddress;

  // 回答を統合
  combinedAnswers = { ...combinedAnswers, ...parsedAnswer };

  // WebSocketで接続しているすべてのクライアントに送信
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(combinedAnswers));
    }
  });

  // HTTPレスポンス
  res.json(combinedAnswers);
  console.log(combinedAnswers);
  console.log(ipAddress);
});

// パースする
function parseAnswer(questions, answer) {
  let parsedAnswer = {};
  if (questions === 'q1-3') {
    parsedAnswer.q1 = Math.floor(answer / 100);
    parsedAnswer.q2 = Math.floor((answer % 100) / 10);
    parsedAnswer.q3 = answer % 10;
  } else if (questions === 'q4-6') {
    parsedAnswer.q4 = Math.floor(answer / 100);
    parsedAnswer.q5 = Math.floor((answer % 100) / 10);
    parsedAnswer.q6 = answer % 10;
  } else {
    return null;
  }
  return parsedAnswer;
}

server.listen(3000, () => {
  console.log('Server started on http://localhost:3000');
});
```

## ブラウザ
```html
<!-- index.html -->
<!DOCTYPE html>
<html>

<head>
    <title>Answer Display</title>
</head>

<body>
    <h1>Answer Display</h1>
    <div id="result"></div>

    <script>
        const ws = new WebSocket('ws://localhost:3000');

        ws.onopen = () => {
            console.log('Connected to the WebSocket');
        };

        ws.onmessage = (event) => {
            const data = JSON.parse(event.data);
            let html = '';
            for (const [question, ans] of Object.entries(data)) {
                html += `<h1>質問${question.substring(1)}の答え：${ans}</h1>`;
            }
            document.getElementById('result').innerHTML = html;
        };

        ws.onclose = () => {
            console.log('Disconnected from the WebSocket');
        };

        ws.onerror = (error) => {
            console.log(`WebSocket Error: ${error}`);
        };
    </script>

</body>

</html>
```
リクエストのキュー次第で問題4~6が先に表示されてしまうことがあるが、些事なので無視する。

## 実際に動かすとこんな感じ
https://twitter.com/3dy3day/status/1700121654205317161

よくあるブラウザに誘導するタイプのアンケートよりは大分ユーザビリティが高いんじゃないだろうか。リアルタイムに情報を送ることは難しいが、こういったユースケースにおいては不足なく機能すると言える。

# 余談
今回はVRCStringDownloaderを使って外部リクエストを送ったが、現在VRCで外部から情報を取ってくる方法は3つある
- VRCStringDownloader
- VRCImageDownloader
- Video Players

公式ではリクエストは5秒おきにしか送ることできず、実際そういう挙動をするが、上記3つのリクエストはそれぞれ独立したキューがあるため、3回までのリクエストを1度に飛ばすことができる。仮に1024個のVRCUrlインスタンスを作れるとして、5秒に3回リクエストを飛ばせるとしたら

2^10 * 2^10 * 2^10 = 2^30 
2^30 / 5 = 6bps

通信にかかる時間を度外視して、毎秒6bitの情報を送れることになる。ちなみにFAXの通信速度を調べてみると
> 通信速度は最大 14.4kbps です。
> (リコー よくあるご質問 -FAQ- より)

FAXより2400倍遅い！! ~~すごい！死ぬほどしょぼいや！！~~

あらかじめ辞書を用意して送信情報を圧縮したり、運用でカバーすることはできるけど汎用的な通信プロトコルとしてはまだまだなのが現状だと思う。