【withコロナ】超リアルな対局サイトをビデオ通話付きで実現する

# はじめに

昨年に引き続きオセロ Advent Calendarに誘っていただいたので記事を書くことにしました。今年(2020年)のトピックといえば新型コロナ(COVID-19)でした。Withコロナ/Afterコロナ等の言葉もできたように、社会状況が一変してしまいました。オセロ界にとっても同様で、これまで会場に集まってやっていた大会が開催できなくなる事態に直面しました。ネット上のオンラインゲームはこれまで通りでしたが、リアルな大会特有の雰囲気は失われてしまいました。そこで、少しでもリアルな大会の雰囲気を取り戻すべく、オンライン上でも超リアルに対戦ができるようなサイトの作成を試みました。今回はその実現方法や工夫点について書いてみたいと思います。

コロナ禍で囲碁や将棋等の他の対局競技でも同じような状況だと思いますが、この記事が何かの参考になれば良いなと思っています。

# 実現してみた内容

オンラインでもリアルな対戦を実現するには以下の要素が必要だと考えました。

- 盤と石、対局時計が見た目にリアル
- 石を置いて挟んだ石を返し、対局時計を押すところまで自分でやる
- 相手の映像が映り、音声通話もできる

2点目についてはうまく伝わりますでしょうか？実際のリアルな対局だと打って返して時計を押すという一連の動作が、普通のオンライン対局だと打つマスをタップするだけで自動的に返るべき石が返って相手の時計が進み始めます。それはそれでとても便利なのですが、やはりちょっとリズムが違います。（せめて実際に大会に出たことがある人だけにでも何となく伝わってほしいです…）

3点目については、たとえ対局中はほとんどしゃべらなくても、対局相手が目に見えて、ちょっとしたため息やボヤキが伝わるというのはリアリティという意味では重要なのではないかと考えました。

そんな目標を持って作ってみたのが以下のような対局サイトです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/46ed8ce6-7bb1-dc75-3e2c-27de9d682730.png)

実際に以下のサイトで遊んでみることができます（対局相手と一緒のキーワードを設定して待ち合わせる必要があります。オセロクエストでいう友達対局と同じようなやり方です）
https://realoth-4e23b.web.app

# 技術説明
以下では、このサイトの構築にあたっての技術的なポイントについてかいつまんで説明したいと思います。

## 基本アーキテクチャ
いろいろな環境の方が気軽に楽しんでもらえるようにということからブラウザ上で動作するWebアプリとして開発することにしました。Vue.jsに加えて、今回初めて勉強したVuexを使うことにしました。最初はVue.jsのみでやっていたのですが、画面コンポーネント間のやり取りが複雑化してきてしまったため、Vuexで基本的な状態や操作を実装するようにして整理しました。

GUIにはVuetifyを使用しています。

## 画面をリアルにするために
盤・石・対局時計をリアルに立体的な感じにするにはどうしたら良いかというのが１つの大きなテーマでした。

### 盤について
いろいろ検討した結果、盤は実物の写真を使用することにしました。実際に対局している雰囲気を少しでも出したいので、斜め上から撮影した写真を使用しています。マスをクリック(スマホの場合はタップ)して操作することを考えると、あまり斜めすぎると操作しにくくなるので、やや斜め程度にしました。

撮影後、横方向の線が水平に、そしてほぼ左右対称になるようにOpenCVでwarpPerspectiveを使って加工しました。さらに、Gimpで周囲を真っ白に加工しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/59b5ff0c-0d90-9893-2c9f-5a0eae080af5.png)

### 石について
#### 石の画像
石が返るアニメーションをしたかったので、実物ではちょっと無理かなと思い、POV-Rayで画像を作成することにしました。これについては昨年のAdvent Calendarで記事にしたのでご参照ください。

- [POV-Rayでオセロの石を返すアニメーションを作ってみた](https://qiita.com/tanaka-a/items/a66dce605eafb2684ed1)

昨年の記事は真上から見たものでしたが、今回は盤の角度に合わせて視点を変更しました。出来上がりはこんな感じです。cssの`animation`でパラパラ漫画的にアニメーションするように各コマを連結して１枚の画像にしてあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/e6fa0150-3218-6b06-39a9-9563def59fb9.png)

今回斜め上方から見ているので、これを適宜拡大縮小させて置けば良いのかなと思っていたのですが、ここで問題が発生しました。実際に盤に石を敷き詰めて描画してみると何か違和感がありました。

それもそのはずで、盤の画像は斜め上方からではありますが左右方向としては正面からみた画像になっています。ある程度近い視点から盤を見ている設定ですから、本来であれば左端にある石は極端に言えば右側の側面が見えるはずですし、右端にある石は左側の側面が見えるはずです。にもかかわらず、それらも正面から見た画像を使ってしまっているので、何か気持ち悪い感じになってしまったのです。

仕方がないので、2*2の4マス毎の16パターンで少しずつ視点を変えた画像を作って対処しました。例として、一番左上用の画像と右下用の画像を貼っておきます。見える角度が微妙に違うのがお分かりいただけるでしょうか？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/4bb3c2bc-c379-bf1c-3f02-c66d2b083012.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/7e1d6c03-0120-d8f2-c490-7c814e802a62.png)

これらの画像の両端や真ん中のコマはちょっと斜めに置かれているように見えますが、見る角度によるもので、普通に水平面に置かれている状態の画像です。盤の左上側のマスや右下側のマスに置けば、それらしく見えます。(まだ若干の違和感はあるかもしれませんが…)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/bd9e26a5-bb8a-f904-55bd-b138ab281f81.png)

#### 石の座標
盤を斜めから見ているので、描画時の座標については少し面倒なことになります。細かい計算は省きますが、1〜8の行が決まればY座標と拡大・縮小の率が決まり、さらにa〜hの列を決めることでX座標も決まるという感じで計算しています(当然X座標は1〜8行にも依存しています)。
ここについては書き出すと分量が多くなりそうなので、[別記事](https://qiita.com/tanaka-a/items/5250b46f01900c462844)にしました。

あとは、手前側(8行目側)の方のcss的なZ座標を大きくしておくことで、石が返るときに石同士が重なっても前後関係がうまく表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/2a13ec93-718a-9da9-3d0d-ba65b7fb37da.png)

### 対局時計について
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/39551243-5d7a-3f32-b825-894ab3d32d07.png)
周囲の枠、ランプ、ボタンはスタイルシートとvuetifyのボタンを使って頑張って作りました。デジタル時計の部分についてはけしかん様のDSEG7 Classic Mini Bold Italicフォントを使用しています(https://www.keshikan.net/fonts.html)

なので、数字部分は画像ではなく文字として表示しています。
非表示となっているセグメントが薄くなっているのは、"88:88"を薄い色(透過度の大きい黒)で表示して、その上に濃い色(透過度の小さい黒)で数字を表示するようにしているためです。こうすることで液晶表示っぽく見えますね。

### 効果音について
石を打つ音、石を返す音、時計を押す音は実際の音を録音して使いました。Webアプリで音を鳴らす方法についてググってみるとJavaScriptの`Audio`オブジェクトの`play()`を使うのがよく出てきます。当初はこれで実装していたのですが、実際にやってみるとタイムラグが発生したり石が返るアニメーションがもっさりしたり、ちょっと重い感じになりました。さらにググって、以下のページで紹介されている`AudioContext`を使うことでサクサク動くようになりました。

- [Web Audio API の基礎](https://www.html5rocks.com/ja/tutorials/webaudio/intro/)

## ビデオ通話対局の実現
### ビデオ通話について
Webアプリでビデオ通話をするための技術としてWebRTCというのがありますが、今回実現方法を調べてみたところ[SkyWay](https://webrtc.ecl.ntt.com)という無料で始められるサービスがあるのを知りました。スマホでもPCでも行けそうだったので、これを使ってみることにしました。

#### APIキー認証
Webアプリなので、JavaScriptのSDKを使用することにしました。公式サイトに[APIドキュメント](https://webrtc.ecl.ntt.com/api-reference/javascript.html)や[サンプル](https://github.com/skyway/skyway-js-sdk/tree/master/examples)も用意されています。WebアプリでAPIキーは筒抜けになるので、シークレットを使用したAPIキー認証を使用することにしました。

APIキー認証の方法も[公式サイト](https://github.com/skyway/skyway-peer-authentication-samples)にやり方が書いてあります。今回は公式サイトのnodejsのサンプルに実装しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/26dec5bc-7f34-7c65-69de-ab9193048be0.png)

まずFirebaseのAuthenticationを使用して今回作成したアプリへのログイン処理を行います(①)。

アプリで対局のための接続開始処理をした際に(②)、ユーザーのSkyWay接続用のPeerIdをUUIDで生成します。これは予めユーザーが決めたユーザーIDでも良かったのですが、SkyWayの通話時のキー情報になるので安全のため毎回新たに振り出すことにしました。

このPeerId情報をキーにFunctionsに配備したnodejsアプリでクレデンシャル情報を生成します(③)。この実装は先ほどのSkyWay[公式サイト](https://github.com/skyway/skyway-peer-authentication-samples)のサンプルを参考にしています。コード内でログイン認証チェック(具体的には`context.auth`をチェック)をすることで、認証はFirebaseに任せられるので、SkyWayのサンプルにあるセッショントークンを使用する必要はなくなりました。
また、サンプル中ではシークレットがコードに埋め込まれていましたが、そこはFunctionsのconfig情報として設定しておくことにしました。

以上により取得したクレデンシャル情報と`PeerId`を元に、SkyWayの`Peer`を生成します(④)。以降はこのオブジェクトを使用してSkyWayの操作をすることになります。

#### 基本的な通信方式
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202945/35f7ad19-2964-4515-14dc-fbcd3e53e1d9.png)

SkyWayでは1対1の通話の他に、複数人が参加できるRoomという概念があります。今回、第三者が対局を観戦できるよう棋譜等のデータ情報はRoomを経由してやり取りすることにしました。映像や音声もRoomでやり取りすることは可能なのですが、そこまで第三者に開放するのはどうかと思ったので、映像・音声については1対1で行うことにしました。

RoomにはMeshRoomとSFURoomという2種類あるのですが、今回は`MeshRoom`を使用することにしました。これは文字通りRoomの参加者が相互にMeshを構成する方式です。小規模なサイトなので観戦者がそれほど多くなることはないだろうという前提の下でこれを使うことにしました。

#### 映像・音声の通話
SDKの`MediaConnection`というクラスを使って映像と音声の通話が可能となります。接続のためには接続相手の`peerId`が必要になります。相手の`peerId`が入手できれば(後述)、あとは`call`して呼び出された側が`answer`することで通話が確立します。
ローカル(自分)側は`navigator.mediaDevices.getUserMedia()`で取得したストリームをHTML上のvideoタグに設定するとともに`call`時に渡すことで共有でき、リモート(相手)側は`MediaConnection`の`stream`イベントで渡されるので、それをHTML上のvideoタグに設定することで表示されます。この辺りは[公式のサンプル](https://github.com/skyway/skyway-js-sdk/tree/master/examples/p2p-media)を見ると分かりやすいと思います。

#### 相手の`peerId`の入手
先ほど後回しにした、相手の`peerId`をどうやって入手するかという問題ですが、これはRoom経由で入手する方針にしました。

今回のアプリの対局の仕方は、対局者が共通の合言葉を入力して接続する方式です。なのでこの合言葉をキーに相手の`peerId`を引っ張ることができれば良いわけですが、合言葉をRoom名にすることで相手と同じRoomにつながることができます。Room内のメッセージのやり取りで`peerId`を渡して`MediaConnection`を確立することにしました。

#### 対局
対局は基本的にはRoomで棋譜に関するメッセージをやり取りして進めるわけですが、今回リアルさを追求して石を返したり時計を押したりするのも全て手動としたためちょっと面倒なことになりました。一般的な対局サイトでは、着手のたびにその手の情報と残り時間情報をメッセージで送れば良さそうですが、今回はメッセージを送信するタイミングが、

- 石を置く
- 石を返す
- 時計を押す

と3つ存在することになります。それぞれでメッセージを送信し、受け取った側はそれに応じた表示をすることにしました。逆にどこの石が挟まれたとかそういうロジックは一切いらなくなったのでそこは簡単になりました。

もう１つ機能として、対局に加えて対局後にはお互いに検討ができるようにしました。対局中については自分の手番（時計が動いている時）だけ操作可能にしたのであまり問題はなかったのですが、検討中はどちらかの手番という概念がないのでお互いに石を置いたり返したりする可能性があります。そうなると、お互い同じマスに石を置いたりする可能性が出てきます。タイミングによってはメッセージがすれ違って両者で違う盤面になってしまうかもしれません。

この問題に対しては、一方を「親」と決めて（最初にRoomに入った側を親とすることにしました）、メッセージがぶつかった場合は「親」の方を優先するようにしました。メッセージ番号を管理したりなど、発生しそうな頻度の割には結構面倒な実装になってしまいました。

#### 観戦機能
第三者が同じ合言葉でRoomに接続することで、試合を観戦する機能も実装しました。着手等の情報はRoomでやり取りしているので、それを拾うことで第三者側も試合の状況を画面に表示することができます。

# おわりに
以上、かなり概要レベルではありますが、超リアルな対局サイトをビデオ通話付きで実現してみた方法について説明してきました。細かい話をし始めるといろいろとあるのですが、キリがなくなりそうなのでここまでにしておきます。

コロナ禍においてボードゲーム界隈の活動が制限されている状況だと思いますが、ボードゲーム界が廃れてしまわないような一助になればと思います。