VRChatのWebPanelでバッファ同期する変数を使う

2018/9/3追記: [WebPanelは2018年8月に無効化されました](https://medium.com/@vrchat/security-update-web-panels-4699fa0be103)。いまのところ復活の目処は立っていません。


## 概要

- VRChatのWebPanel上でLate-Joinersにもバッファで同期する変数の使い方を解説
- その発展形として、自前のリダイレクトサーバー等を使わずLate-JoinersにもURIを同期させ、任意のウェブページを閲覧できるようにする


## 前提知識

- VRC_WebPanelは現状バグだらけでまともに動く部分のほうが少ない
- 確実に信頼できるのはSynchronize URIオプションによるURI同期だけ（※個人の感想です）
- 4月のアップデートから発生している[入力フォームにフォーカスが移ると操作不能になるバグ](https://vrchat.canny.io/bug-reports/p/web-panel-input-broken)は完全に放置されており、WebPanel界隈では諦めムードが漂っている
- [SetWebPanelURI](https://docs.vrchat.com/docs/setwebpaneluri)は[BroadcastType](https://docs.vrchat.com/docs/trigger-broadcast-types)をAlwaysにしていても最後の1回しか実行されない（ログを見るとAlwaysBufferOneと同じく、いったんイベントが削除されてから再登録されているので、おそらくこれは不具合ではなく仕様）


## WebPanelに関する資料

- [VRChat公式ドキュメント](https://docs.vrchat.com/docs/vrc_webpanel)
- [VRChat技術メモ帳](http://vrcworld.wiki.fc2.com/wiki/VRC_WebPanel)
- [naqtnさんのブログ](http://vrcprog.hatenablog.jp/entry/component-WebPanel)


## ハッシュ同期

　[前回の記事](https://qiita.com/katanov/items/8a7aed9a92c77528602f)ではJavaScript疑似プロトコルを用いた同期方法を解説したが、今回はハッシュによる同期方法を紹介する。
　まずは以下のようなHTMLを作成し、Chromeで開いてみる。

```html
<!DOCTYPE html>
<html>
<head>
<title>Sync Var on WebPanel</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script>
$(function() {
  window.onhashchange = function() {
    $('#var').text(location.hash);
  };
  window.onhashchange();
});
</script>
</head>
<body>
<div id="var"></div>
</body>
</html>
```

　最初は何も表示されないが、URIの末尾に `#1` のようにハッシュを追加すると、その値がページに表示される。`#a` のようにしても動作するはずである。

![Chrome画面](https://qiita-image-store.s3.amazonaws.com/0/124790/d9803256-9ecc-c7c5-6732-c0c78786c1cf.png)

　ここではonhashchangeイベントハンドラに、ハッシュの値をページ上に書き込む処理をセットしているが、このイベントはハッシュが書き換えられたときにのみ発火するので、ハッシュ付きでページが開かれた場合に対処するため、それを即時実行している。
　次はこのHTMLをUnity上に持っていく。[前回の記事](https://qiita.com/katanov/items/8a7aed9a92c77528602f)に書いたように、VRChatのWebPanelはHTTP経由でHTMLを読み込む方が安定して動作するので、今回はローカルにサーバを立てて開発を進める。HTTPサーバは[Nginx](http://nginx.org/)をおすすめしたい。ワールド公開時のホスティング先は[GitHub Pages](https://pages.github.com/)がおすすめだ。

![Unity画面](https://qiita-image-store.s3.amazonaws.com/0/124790/beb1a19a-09fa-d231-a7e7-2107081aa3b1.png)

　まずはさきほどのHTMLをVRC_WebPanelのStart Pageにセットし、Synchronize URIにチェックを入れる。

![Unity画面](https://qiita-image-store.s3.amazonaws.com/0/124790/8a4e2e7c-68b7-0bee-1462-6e087c0dae09.png)

　ボタンとして使うオブジェクトのVRC_TriggerにAlwaysBufferOneのOnInteractを追加。WebPanelに対して `http://localhost/sync_var.html#1` のようにハッシュ付きのURI置換を行うようにする。
　動作をわかりやすくするため、もうひとつボタンを作成し `http://localhost/sync_var.html#2` としておく。

![VRChat画面](https://qiita-image-store.s3.amazonaws.com/0/124790/f3001655-4dbb-8cbb-5ec9-5d9120b256f2.png)

　VRChat上でローカルテストしてみると、ブラウザ上でURIを直接書き換えたときと同じように動作することが確認できる。マルチ環境でも、後から入室した人、いわゆるLate-Joinersにちゃんと同期する。
　これがハッシュによる同期の基本である。
　JavaScript疑似プロトコルによる同期も、AlwaysBufferOneで実行すれば最後の1回が実行されるので、現段階ではハッシュによる同期とたいした違いはないが、ページ遷移をともなう処理を行った場合に違いが明確となる。JavaScript疑似プロトコルは現在開いているページの状態をもとに、現在開いているページに対して処理を実行するが、今回紹介した方法では、VRChat上でボタンを押すたびに最初の状態に巻き戻され、その上で処理が実行される。だからページ遷移をともなう処理があっても、問題なく再実行が可能なのだ。


## 変数格納先の選定

　いくつかの固定ページを見たいだけなら、ワールド内にボタンをたくさん置いて、それぞれのSetWebPanelURIにURIを設定し、それをAlwaysBufferOneで実行するだけですむが、任意のウェブサイトを閲覧するとなると少しハードルが上がる。
　これの解決方法としては、リダイレクトサーバーを使う方法がある。WebPanelからアクセスするURIは固定で、そのURIが返すリダイレクト先を可変にする、というものだが、サーバーを用意したりAPIを用意したりといった作業はなかなかにハードルが高い。なので代替としてGoogle Spreadsheetを使う方法をおすすめしたい。
　Google Spreadsheetを「変数格納先」として使う利点は、以下のようなものが挙げられる。

- Googleアカウントがあれば誰でも無料で利用可能
- WebAPIが用意されており、シートの値をJSON形式で取得できる
- データの書き換えが容易・直感的
- 自分だけが編集可能、特定の人だけが編集可能、誰でも編集可能、といった権限設定が容易

　前節で紹介したハッシュを用いた同期でシート名や行番号を指定し、Google Spreadsheetから値を取得し、それをもとに何らかの処理を行う、という手法を次節から解説していく。


## Google Spreadsheet

　まずはスプレッドシートを新規作成し、[共有設定](https://support.google.com/drive/answer/2494822)をする。共有相手は「リンクを知っている全員」、権限はとりあえず「閲覧」にしておこう。他の人にもシート編集を許可する場合はここを「編集」にしておけばOKだ。

![GoogleDrive画面](https://qiita-image-store.s3.amazonaws.com/0/124790/61ab1ad4-549b-3155-187e-4516d195207f.png)

　共有設定が終わると `https://docs.google.com/spreadsheets/d/xxxx/edit?usp=sharing` のようなリンクを取得できるが、`xxxx` の部分がこのスプレッドシートのIDとなる。シートには動作確認用の文字列を適当に何か入力しておこう。シート名は `Default` に変更しておく。

![GoogleDrive画面](https://qiita-image-store.s3.amazonaws.com/0/124790/d90439cb-9ddb-327c-1176-7048b98971f9.png)

　この文字列をAPIから取得するにはアクセス用のAPIキーが必要となるが、これは[Google Cloud Platform](https://console.developers.google.com/apis/credentials)のコンソールから取得できる。プロジェクトがない場合は新規作成し、認証情報を作成→APIキーと選択することでAPIキーが新規作成される。その後、ライブラリからGoogle Sheets APIを有効化することで、このAPIキーを用いてスプレッドシートにアクセスできるようになる。

![Googleコンソール画面](https://qiita-image-store.s3.amazonaws.com/0/124790/c14bf796-eea2-13fc-0756-6bf272d36a28.png)

　これでスプレッドシートのIDとAPIキーが手に入ったので、以下のようなURIにHTTP GETリクエストを投げることで、シートの値をJSON形式で取得できる。

`https://sheets.googleapis.com/v4/spreadsheets/{Spreadsheet ID}/?key={API Key}&includeGridData=true&ranges=Default!A1:A1`

　とりあえずはブラウザでこのURIを開いてみる。

![API結果画面](https://qiita-image-store.s3.amazonaws.com/0/124790/9ad6c5cf-738b-25d3-d32d-f9ee351c2922.png)

　正常に値を取得できている。
　エラーコード404が返ってくる場合は、スプレッドシートのIDが間違っている。400の場合はAPIキーが間違っているか、セルの指定が間違っているので再確認しよう。


## JavaScriptでAPIにアクセスする

　さきほどブラウザで直接確認したJSONをJavaScriptから取得し、それをページ上に表示させてみる。
　まずはWebPanelにセットしているHTMLを以下のように書き換える。スプレッドシートIDとAPIキーを書き換えないと動かないので注意。

```html
<!DOCTYPE html>
<html>
<head>
<title>Sync Var on WebPanel</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script>
$(function() {
  var db = 'https://sheets.googleapis.com/v4/spreadsheets/{Spreadsheet ID}/?key={API Key}&includeGridData=true&ranges=';

  var getValue = function(row, sheet) {
    $.get([db, sheet, '!', 'A', row, ':', 'A', row].join(''))
      .done(function(data) {
        var value = '';
        try {
          value = data.sheets[0].data[0].rowData[0].values[0].formattedValue;
        } catch(e) {
          // Do nothing
        }
        if (!value) {
          // Write error handling code here
          return;
        }
        $('#var').text(value);
      })
      .fail(function() {
        // Write error handling code here
      });
  };

  window.onhashchange = function() {
    if (location.hash.length < 2) return;
    var hash = location.hash.slice(1);
    var options = hash.split(';');
    var row = options[0];
    if (!row.match(/^\d+$/)) return;
    row -= 0;
    var sheet = 'Default';
    if (options.length > 1) {
      sheet = options[1];
      if (!sheet.match(/^\w+$/)) return;
    }
    getValue(row, sheet);
  };
  window.onhashchange();
});
</script>
</head>
<body>
<div id="var"></div>
</body>
</html>
```

　これをブラウザで開いて、以前確認したときと同じく、URIの末尾に `#1` のようにハッシュを追加すると、シートのA1セルに入力した値が表示される。`#2` にするとA2セルの値が表示されるはずである。
　このプログラムは別シートにも対応している。指定がなければ `Default` シートを参照するが、ハッシュを `#1;Sheet2` のようにすると、`Sheet2` のA1セルを参照するようになっているので試してみてほしい。
　これをVRChatでローカルテストしてみると、押したボタンに対応した値が表示されるはずである。

![VRChat画面](https://qiita-image-store.s3.amazonaws.com/0/124790/c0d171b9-c2fd-6d19-5811-5571fabcf91a.png)

　別シートを参照したい場合はSetWebPanelURIを `http://localhost/sync_var.html#1;Sheet2` のようにするだけでいい。プログラムを書き換えることなく、ただUnity上でボタンを増やすだけで、「変数の格納先」をどんどん増やすことが可能だ。
　そしてこれが最も重要なのだが、最初に確認したように、これはバッファで同期する。Late-Joinersにも、この変数は伝わるのである。この性質を用いれば、かなり多様な表現が可能になる。その例として、任意のウェブページを閲覧する方法を最後に紹介する。


## 任意のウェブページを閲覧する

　もうすでにやり方は予想が付いていると思うので、特に詳しい解説はしない。取得した値で `location.href` を書き換えているだけである。

```html
<!DOCTYPE html>
<html>
<head>
<title>Sync Var on WebPanel</title>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script>
$(function() {
  var db = 'https://sheets.googleapis.com/v4/spreadsheets/{Spreadsheet ID}/?key={API Key}&includeGridData=true&ranges=';

  var getValue = function(row, sheet) {
    $.get([db, sheet, '!', 'A', row, ':', 'A', row].join(''))
      .done(function(data) {
        var value = '';
        try {
          value = data.sheets[0].data[0].rowData[0].values[0].formattedValue;
        } catch(e) {
          // Do nothing
        }
        if (!value) {
          // Write error handling code here
          return;
        }
        location.href = value;
      })
      .fail(function() {
        // Write error handling code here
      });
  };

  window.onhashchange = function() {
    if (location.hash.length < 2) return;
    var hash = location.hash.slice(1);
    var options = hash.split(';');
    var row = options[0];
    if (!row.match(/^\d+$/)) return;
    row -= 0;
    var sheet = 'Default';
    if (options.length > 1) {
      sheet = options[1];
      if (!sheet.match(/^\w+$/)) return;
    }
    getValue(row, sheet);
  };
  window.onhashchange();
});
</script>
</head>
<body>
</body>
</html>
```

　シートのA1セルに `http://example.com/` と入力しておき、ローカルテストしてみる。

![VRChat画面](https://qiita-image-store.s3.amazonaws.com/0/124790/e9113801-eda4-3afa-aeed-2d06efd11be6.png)

　ちゃんと動いているようだ。マルチ環境でもLate-Joinersにちゃんと同期されることを確認した。


## 注意点

　スプレッドシートを誰でも編集可にした場合は悪意あるコードが実行される可能性があるので、サニタイズをきっちりやる必要がある。


## 実例

　拙作のTwitchチャットワールドでは、実際にハッシュを用いた同期を使用している。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/VRChat?src=hash&amp;ref_src=twsrc%5Etfw">#VRChat</a> で仮想キーボードとTwitchからニコ動的なコメントを流せるワールド &quot;Tell Your Word Encore&quot; をリリースしました。<br><br>My new world &quot;Tell Your Word Encore&quot; that has flow chat systems with Twitch and VRChat is released. <a href="https://t.co/3KS3pOdyQt">pic.twitter.com/3KS3pOdyQt</a></p>&mdash; Kanata@VRC (@Kanata_VRC) <a href="https://twitter.com/Kanata_VRC/status/996695854848163840?ref_src=twsrc%5Etfw">2018年5月16日</a></blockquote>