VOYAGE GROUP VR室ブログ

VOYAGE GROUP VR室のブログです。コンテンツの紹介や制作方法、イベントレポートなどについて書きます。原則毎週水曜日更新。

A-FrameとSocket.IOを使ってリアルタイム通信ができるWebVRアプリを作ってみる

こんばんは。古くて重いMacBook Proを背負って腰が崩壊した@daybysay です。

腰に課題を感じるエンジニアの皆様におかれましてはぜひ軽めのMacBookを背負ってご活動をお勧めいたします。

さて、今回はは複数人で同期しながら遊べるタイプのVRコンテンツ開発を試してみました。

内容としては、A-FrameとWebSocketを使ったリアルタイムマルバツゲームの実装です!

出来上がりはこんな感じです。

https://media.giphy.com/media/l0MYtJaZ7wJfXGwQE/giphy.gif

リポジトリはこちら

github.com

そして今回出来上がったマルバツゲームへのリンクがこちらです。

目次

アプリの構想

まずは今回作るアプリの全体像を作っていきましょう。

最初の画像でほぼネタバレしていましたが、いわゆるマルバツゲームを作ります

f:id:DayBySay:20161224005350p:plain

  • 想定するプレイヤーは2人
  • プレイヤーごとにカメラを用意する
  • マス目をクリックすることで、プレイヤー1はバツ、プレイヤー2はマルをつけられる
  • マス目のクリックは視線を利用する
  • お互いのマルバツが相手側に反映される

くらいまでを作ろうかと思っています。

今回はWebSocketとA-Frameの連携を試したかっただけなので、ゲーム性の部分は作り込んでいません。

環境構築

まずは開発環境を作ります。

今回はA-Frameのアプリケーション開発なので、A-Frame-Boilerplateを使って開発環境を作りましょう。

A-Frameに関しては弊社ブログで何度か登場しているので説明は省きますが、WebVRアプリケーション開発をサポートするフレームワークですね。

おもむろに下記コマンドを叩きます

git clone https://github.com/aframevr/aframe-boilerplate.git
cd aframe-boilerplate && rm -rf .git && npm install && npm start

するとこのような画面がブラウザに表示されます。

f:id:DayBySay:20161224001207p:plain

こちらがA-Frame-BoilerplateのHello Worldですね。

アプリケーション実装

それではアプリケーションの実装に入っていきます。

クライアントサイドの実装

ではアプリで利用する下記コンポーネントを用意していきます。

今回用意すべきなものは

  • マス目にあたるプレーン
  • プレイヤー1,2用のカメラ
  • プレイヤーのクリックを可能にするカーソル
  • プレイヤーの名前を表示するテキスト

になります。

マス目を置く

まずはマス目になるプレーンの用意です。

index.htmlを次のように修正します。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <title>websocket vr</title>
        <script src="https://aframe.io/releases/0.4.0/aframe.min.js"></script>
    </head>
    <body>
        <a-scene>
            <a-assets>
                <img src="http://i.imgur.com/7YnjGfI.png" id="square" crossorigin="anonymous">
                <img src="http://i.imgur.com/vxesSQm.png" id="circle" crossorigin="anonymous">
                <img src="http://i.imgur.com/EdymqIg.png" id="cross" crossorigin="anonymous">
            </a-assets>

            <a-sky color="#ECECEC"></a-sky>
            <a-entity id="square">
                <a-plane id="square0" src="#square" position="-1 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square1" src="#square" position="-1 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square2" src="#square" position="-1 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square3" src="#square" position="0 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square4" src="#square" position="0 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square5" src="#square" position="0 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square6" src="#square" position="1 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square7" src="#square" position="1 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
                <a-plane id="square8" src="#square" position="1 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
            </a-entity>
        </a-scene>
    </body>
</html>

こういう感じのマス目が見えると思います。

f:id:DayBySay:20161224010920p:plain

マス目は基本<a-plane>を利用しています。

空白、マル、バツの3つのマス画像を用意しており、クリックされたときに画像を変えてマルバツを表現していきます。

ちなみに利用している画像はSketchで適当に用意し、Imgurにアップしています。

プレイヤー(カメラ)を用意

次にプレイヤーに当たるカメラを用意します。

今回は2プレイヤー予定ですが、とりあえず1プレイヤー分用意します。

プレイヤーを表すオブジェクトはとりあえず<a-box>をおいています。

<a-camera position="0 1.6 -4" rotation="0 180 0" id="player1">
    <a-cursor></a-cursor>
    <a-box color="#998877"></a-box>
</a-camera>

上記を<a-scene>直下に下記、保存して画面を更新してみましょう。

f:id:DayBySay:20161224012008p:plain

こんなかんじになりました。

真ん中の丸っこいのが<a-cursor>です。

カーソルをカメラに付けることで、視線の先にあるコンポーネントのクリックを可能にしています。

クリックできるようにする

ここからはJSの実装に入ります。

まずは、マス目がクリックされるとマルがつくようにしましょう。

下記JSを実装してください。

<script type="text/javascript">
    function click_square(square_id) {
        var image = "#circle";
        document.querySelector("#" + square_id).setAttribute("src", image);
    }

    AFRAME.registerComponent('cursor-listener', {
        init: function () {
            this.el.addEventListener("click", function (evt) {
                click_square(this.id);
            });
        }
    });
</script>

click_squareはそれぞれのマスを表すsquare_idを受け取り、エレメントの属性を書き換え画像をマルにしています。

また、AFRAME.registerComponenを使いcursor-listenerコンポーネントを新規で作成しました。

このコンポーネントは、カーソルでクリックされたときにclick_squareメソッドを呼び出し、自身のidを渡す実装になっています。

次に、マス目にcursor-listenerコンポーネントを追加します。

<a-entity id="square">
    <a-plane id="square0" cursor-listener src="#square" position="-1 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square1" cursor-listener src="#square" position="-1 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square2" cursor-listener src="#square" position="-1 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square3" cursor-listener src="#square" position="0 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square4" cursor-listener src="#square" position="0 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square5" cursor-listener src="#square" position="0 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square6" cursor-listener src="#square" position="1 0 -1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square7" cursor-listener src="#square" position="1 0 0" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
    <a-plane id="square8" cursor-listener src="#square" position="1 0 1" rotation="-90 0 0" width="1" height="1" color="#EEEEEE"></a-plane>
</a-entity>

これでそれぞれのマス目がクリックされたときにマル画像に変更されるようになりました。

f:id:DayBySay:20161224013221p:plain

ついでにPlayer2も用意しましょう。

先ほどと同じ要領でカメラとカーソルを用意します。位置はPlayer1の反対側に起き、カメラを反転させておきます。

<a-camera position="0 1.6 4" id="player2">
    <a-cursor></a-cursor>
    <a-cylinder color="#114499" ></a-cylinder>
</a-camera>

プレイヤー名表示

次にプレイヤー名を表示するテキストを追加しましょう。

テキスト表示には、A-Frame公式で推奨されているaframe-bmfont-text-componentを利用していきます。

まずは<head>タグに下記を追加します。

<script src="https://rawgit.com/bryik/aframe-bmfont-text-component/master/dist/aframe-bmfont-text-component.min.js">

次に、カメラのコードを下記のように変更します。

<a-camera position="0 1.6 -4" rotation="0 180 0" id="player1">
    <a-cursor></a-cursor>
    <a-box color="#998877"></a-box>
    <a-entity position="0 1 0" scale="3 3 0" rotation="0 180 0" bmfont-text="text: Player1; color: black;">
</a-camera>
<a-camera position="0 1.6 4" id="player2">
    <a-cursor></a-cursor>
    <a-cylinder color="#114499" ></a-cylinder>
    <a-entity position="0 1 0" scale="3 3 0" rotation="0 180 0" bmfont-text="text: Player2; color: black;">
</a-camera>

テキスト表示用に<a-entity>をカメラの下に追加し、bmfont-textプロパティを追加しました。

これでプレイヤー名が表示出来るようになったので確認してみましょう。

f:id:DayBySay:20161224014249p:plain

Player1が見えているので、どうやらPlayer2のカメラが有効になっているようですね。

この状態で⌘ + ⌥ + Iでインスペクタを開いてみると、下記のように見えています。

f:id:DayBySay:20161224014439p:plain

これでゲームの挙動は大体実装できました。

次にSocket.IOを利用して、リアルタイム同期を実装していきます。

サーバサイドの実装

Socket.IOを使う準備をする

それではSocket.IOを利用してリアルタイム同期を可能にしていきたいと思います。

リポジトリ内でおもむろに下記コマンドを叩きます。

npm install --save socket.io

これでSocket.IOが使えるようになりました。ラクティンだぜ!

別々のプレイヤーとして接続できるようにする

ここでは、ブラウザ毎に別々のプレイヤーとして接続できるように実装していきます。

まずはapp.jsという名前で下記サーバ用スクリプトを実装します。

var fs = require('fs');
var server = http.createServer(function(req, res) {
    res.writeHead(200, {'Content-Type' : 'text/html'});
    res.end(fs.readFileSync(__dirname + '/index.html', 'utf-8'));
}).listen(3000);

var io = socketio.listen(server);
var player1, player2;

io.sockets.on('connection', function(socket) {
    socket.on("connected", function () {
        if (player1 === undefined) {
            player1 = socket.id;
            io.to(socket.id).emit("set_player", "player1")
            return;
        }

        if (player2 === undefined) {
            player2 = socket.id;
            io.to(socket.id).emit("set_player", "player2")
            return;
        }
    });

    socket.on("disconnect", function() {
        if (socket.id === player1) {
            player1 = undefined;
            return;
        }

        if (socket.id == player2) {
            player2 = undefined;
            return;
        }
    });
});

次にindex.htmlのスクリプトを修正します。

<script type="text/javascript">
    var socket = io.connect();
    var player_id;

    socket.on("set_player", function(player) {
        player_id = player;
        var playersCamera = document.querySelector("#" + player_id);
        playersCamera.setAttribute("camera", "active", true);
    });

    function login() {
        socket.emit("connected");
    }

    login();

    function click_square(square_id) {
        var image = "#circle";
        document.querySelector("#" + square_id).setAttribute("src", image);
    }

    AFRAME.registerComponent('cursor-listener', {
        init: function () {
            this.el.addEventListener("click", function (evt) {
                click_square(this.id);
            });
        }
    });
</script>

一番最初の接続をPlayer1、次の接続をPlayer2とする用に実装しました。

サーバ接続時にconnectedを呼び出す前提とし、そのタイミングでPlayerが存在していなければその接続をプレイヤーとするためにset_playerを呼び出しています。

set_playerが呼び出された際に、Player1か2かによって利用するカメラを変えています。

それでは早速サーバを立てて試してみましょう。

node app.js

この状態で2つのブラウザを開きます。

f:id:DayBySay:20161224022243p:plain

プレイヤー毎に別々のカメラになっていますね!

これだけではマス目のクリックが動悸されていないので、次はそちらを実装していきます。

マス目のクリックを同期する

次はマス目のクリックを同期するためのスクリプトを実装します。

index.htmlに下記のスクリプトを追加します。

socket.on("click-square", function(square_id) {
    click_square(square_id);
})

cursor-listener登録時の実装を下記のように修正します。

AFRAME.registerComponent('cursor-listener', {
    init: function () {
        this.el.addEventListener("click", function (evt) {
            click_square(this.id, player_id);
            socket.emit("click-square", this.id);
        });
    }
});

次に、app.js側を下記のように修正します。

socket.on("click-square", function(square_id) {
    socket.broadcast.emit("click-square",square_id);
});

サーバを再起動し、ブラウザを更新して試してみましょう。

f:id:DayBySay:20161224023930p:plain

おぉ、ついにマス目へのクリックが同期されるようになりました!

プレイヤーごとにマルバツを変える

クライアント側にPlayerのIDを持っているので、それによってマルとバツを変えられるように修正しましょう。

index.html

socket.on("click-square", function(square_id, player_id) {
    click_square(square_id, player_id);
});

function login() {
    socket.emit("connected");
}

function click_square(square_id, player_id) {
    var image = player_id === "player1" ? "#cross" : "#circle";
    document.querySelector("#" + square_id).setAttribute("src", image);
}

AFRAME.registerComponent('cursor-listener', {
    init: function () {
        this.el.addEventListener("click", function (evt) {
            click_square(this.id, player_id);
            socket.emit("click-square", this.id, player_id);
        });
    }
});

app.js

socket.on("click-square", function(square_id, player_id) {
    socket.broadcast.emit("click-square",square_id, player_id);
});

またサーバを再起動し、ブラウザのウィンドウを2つ立ち上げてlocalhost:3000にアクセスします。

この状態でマス目をクリックしてみましょう。

https://media.giphy.com/media/l0MYtJaZ7wJfXGwQE/giphy.gif

プレイヤーごとにマルバツが変わり、あそべるようになりました!

画面をタップしなくてもクリックできるようにする

今まではPCで動かしていたのであまり気にならなかったけれど、スマホのハコスコなどで動かすと画面をタップするのが結構たいへんです。

その場合は、一定時間見つめるだけでクリックイベントを送るような実装をすると良いです。

index.htmlの内のカーソル実装を下記の様に変更します。

<a-cursor fuse=true fuse-timeout=1000></a-cursor>

fusetrueにすると、fuse-timeoutで設定した秒数(ミリ秒)分見つめたオブジェクトにクリックイベントを送信するようになります。

今回は1000なので1秒見つめるとクリックイベントが送られます。

これで画面をタップしなくてもクリックできるようになりました!

インターネット上に公開する

今回のアプリはSocial性がウリなので、ローカルで使えるだけだとめっちゃ微妙です。

そんなときはみんな大好きheroku先生に助けてもらいましょう。

HerokuでSocket.ioアプリを動かす - Qiita を参考にさせていただきましたので、詳細はそちらへ。

  • Procfileの作成
  • Heroku上でのアプリケーション構築
  • Herokuへのデプロイ

をすればWeb上で動作の確認が出来ます。

まとめ

  • A-Frame-Boilerplateで簡単にA-Frameのアプリ開発を始められる
  • Socket.IOを使うと簡単にリアルタイム同期が出来る
  • 視線を使ったクリックは、fuseを使うと良い

以上になります!

今回はマルバツをつけた結果のみ共有しましたが、他者を表すアバターの動きを共有出来るとより楽しくなるんだろうなあとは思っているので、今後はそのあたりを試してみたいと思います。

また、この記事を持って今年の更新は最後となります。

10月にVR室を立ち上げ、ちょこちょこと勉強してきましたが、Unityを使ったネイティブのVR開発からWebVRまで広く学ぶことが出来た2ヶ月でした。来年はここから更に掘り下げて学んでいこうと思いますので、引き続きよろしくお願いします。

それでは良いお年を!