Help us understand the problem. What is going on with this article?

ブラウザWebVRで遊ぶ「おにごっこ」をA-frameとp5jsで作ってみた

おにごっこしたい

最近はいろんな技術がすすみ、VR・AR・MRといった言葉も市民権を得てきましたよね。
昔ドラえもんで出てきた秘密道具が「あれこれスマホでできるじゃん」みたいなものだったり。
当時は「ドラえもん的」だと思われていたものがどんどん現実になってきていて、本当に技術の進歩ってすごいなあと思います。
どこでもドアホッシィィィイイイイ。

先日投稿した「光センサで遊ぶ「かくれんぼ」をobnizとp5jsで作ってみた」の中で、ブラウザで遊べるかくれんぼゲームを作りました。
A-frameを使えばサクッとVRが作れるということを知り、VR版おにごっこを作ってみました。
ただ逃げ回るだけも面白くないなあと思ったので、白黒世界でめちゃめちゃ見えにくい世界を作ってみました。

コードはGistに載せてあるので是非実装してみてくださいね。
アプリもNetlifyに載せたので是非遊びに行ってみてください。
難易度がいまいちわからなかったので、3つ作ってみました。

Gistはこちらから
Appは
やさしいモードはこちらから
ふつうモードはこちらから
くらやみモードはこちらから
※VRをお持ちの方、今回はブラウザで操作すること前提で実装してしまったため、VRで操作する仕様になっていない可能性があります(theクソ仕様)。それを踏まえたうえでお楽しみください。

完成デモ

基本ルール

おにごっこと同じく、おにから逃げ続けるゲームです。
おには暗闇に溶け込みそうな真っ黒な箱。
こいつに追いつかれるか、場外に出てしまうとゲームオーバーになります。

黒い箱は近くにプレイヤーを見つけると接近してきます
検知の範囲はそこまで広くないので正面にいる場合は大丈夫だと思いますが、木々からヌッと出てくることもあるので要注意です。
また黒い箱は一日ごとに足が速くなります
3日目ぐらいでプレイヤー同等ぐらいのスピードになるので、うまく先読みをしながら逃げ回ってください。

朝と夜

ゲーム内には時間の概念がありまして夜になるほど空が暗くなってきます
この真っ暗な間は黒い箱は背景に隠れてしまう上に、木々が邪魔で見通しが悪くなります。
この間も黒い箱に近づくと接近してくるので要注意です。

ゲームオーバー

タッチされる・場外に出ると記録が表示されます。

ちなみに(余談)

今回初めてのVR制作でして、VRのヘッドセットを使うことも初めて
今回はOculus Goを一からセットアップしてやってみました。
動画も取ったんですが、なぜか起動ができなくなってしまってデータが抜けず断念。。。
DSC_0096.JPG
テンションの上がるぼく

基本的な操作はブラウザと同じく、手元のコントローラーのクリックでどうにかなりそう。
クリックする必要が無く周りが見渡せるので、もしかしたらこちらの方が簡単かもしれません。
ただ今回「ゲームオーバーしたらRボタンを押してね」仕様にしているので、ゲームオーバーのたびにURLを叩く必要があるのが難点。

この辺りはヘッドセットの種類に応じて実装を変える必要があるんですかね?
今回はOculus Go自体初めてだったので、もう少し突き詰めてみたいところですね。

実装こまごま

基本アーキテクチャ

今回は
・index.html (画面表示用)
・sketch.js (常に処理し続ける内容の記述)
・components.js (基本オブジェクトのクラス・メソッドを記述)
の3つで構成されています。
index.htmlはほとんどライブラリの呼び出しの記述のみで、メインはsketch.jsになります。

A-frameはa-skyやa-entityのようなタグを記述することで3Dオブジェクトをラクラク生成を可能にしてくれる便利なライブラリです。
今回はcreateElementなどを駆使しながら、a-sceneタグの子要素としてこれらのオブジェクトを入れ込んでいます。
詳しくは公式ドキュメントA-frameまわりのQIita記事をご覧になりながら、是非ハンズオンしてみてくださいね。

<html>
    <head>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/0.7.1/p5.js"></script>
        <script src="https://aframe.io/releases/1.0.4/aframe.min.js"></script>
        <script src="components.js"></script>
    </head>

    <script src="sketch.js">
    </script>

    <body>
    <a-scene id="VRScene">
      ここにオブジェクトのタグが入りまくるイメージ
    </a-scene>
 </body>
</html>

オブジェクトの生成処理

今回はこちらのサイトをかなり参考にさせていただきました。
こちらのサイトにあるコードを読み解いて、実装に利用した形になります。
p5jsのお作法については先日の記事を見つつ実装しています。
今回は備忘録的にA-frameに関するお作法をメモしておきます。

p5jsではブラウザが読み込まれると同時にsetup()が実行されます。
setup()ではまずworldクラスという各オブジェクトを管理するためのクラスを生成します。
worldクラスで必要そうなメソッドはcomponents.jsに実装されています。

//sketch.js
function setup() {
    noCanvas();
    world = new World('VRScene');

        //
        //以下省略
        //
}

//components.js
function World(id) {
    console.log("A-FrameP5 v0.1 (Craig Kapp, 11/8/2016)");

    if (id == undefined) {
        id = "VRScene";
    }
    this.scene = document.getElementById(id);

    this.flying = false;
    this.setFlying = function(v) {
        this.flying = v;
        this.camera.setWASD(v);
    }
    this.getFlying = function() {
        return this.flying;
    }

    this.camera = new Camera();
    this.scene.appendChild(this.camera.holder);

    this.add = function(entity) {
        this.scene.appendChild(entity.tag);         
    }
    this.remove = function(entity) {
        this.scene.removeChild(entity.tag);         
    }

    this.removeall = function(){
        while(this.scene.firstChild){
            this.scene.removeChild(this.scene.firstChild);
        }
    }

       //
       //その他様々な処理を定義
       //
}

このworldクラスへオブジェクトをどんどん追加していきます。
今回の敵役である黒い箱に着目すると、

//sketch.js
//setup()の中
    enemy = new Box({x:x, y:0.5, z:z,
                     width:1, height:1, depth:1, 
                     red:0, green:0, blue:0,
                    });
    world.add(enemy);


//components.js
function Box(opts) {
    // store desired options
    setEntityOptions(opts, this);

    // store what kind of primitive shape this entity is
    this.prim = 'box';

    // setup geometry parameters
    if (!('width' in opts))  {
        opts.width = 1;
    }
    if (!('depth' in opts))  {
        opts.depth = 1;
    }
    if (!('height' in opts)) {
        opts.height = 1;
    }
    this.width  = opts.width;
    this.height = opts.height;
    this.depth  = opts.depth;

    // set geometry
    setGeometry(this);

    // set material
    processMaterial(this);
    setMaterial(this);

    // set scale
    setScale(this.opts, this);

    // set position
    setPosition(this.opts, this);

    // set rotation
    setRotation(this.opts, this);

    // set visibility   
    setVisibility(this.opts, this);

    // set click handler
    setClickHandler(this);

    // init common setters / getters
    initializerSettersAndGetters(this);

}

components.js中のsetEntityOptions()でタグを生成し、setGeometry()・setMaterial()・setScale()などによって属性情報の挿入処理が走るようです。
setEntityOptions()のcreateElement()でタグが生成します。

function setEntityOptions(opts, entity) {
    // store desired options
    if (opts == undefined) {
        opts = {};
    }
    entity.opts = opts;

    // create a tag for this box
    entity.tag = document.createElement('a-entity');

    // setup a "children" array
    entity.children = [];
}

その後の処理で属性情報が挿入されていきます。
setGeometryを代表例として示しますが、オブジェクトの形状に応じてタグの属性情報が変わるのでelse if(caseのほうが良いんでしょうか?)の嵐ですね。
ここでsetAttributeすることでタグへ属性情報を挿入することができます。

function setGeometry(entity) {
    if (entity.prim == 'sphere') {
        entity.tag.setAttribute('geometry', 'primitive: sphere; radius: ' + entity.radius + '; segmentsWidth: ' + entity.segmentsWidth + '; segmentsHeight: ' + entity.segmentsHeight + '; phiStart: ' + entity.phiStart + '; phiLength: ' + entity.phiLength + '; thetaStart: ' + entity.thetaStart + '; thetaLength: ' + entity.thetaLength);             
    }
    else if (entity.prim == 'circle') {
        entity.tag.setAttribute('geometry', 'primitive: circle; radius: ' + entity.radius + '; segments: ' + entity.segments + '; thetaStart: ' + entity.thetaStart + '; thetaLength: ' + entity.thetaLength);              
    }
    else if (entity.prim == 'ring') {
        entity.tag.setAttribute('geometry', 'primitive: ring; radiusInner: ' + entity.radiusInner + '; radiusOuter: ' + entity.radiusOuter + '; segmentsTheta: ' + entity.segmentsTheta + '; segmentsPhi: ' + entity.segmentsPhi + '; thetaStart: ' + entity.thetaStart + '; thetaLength: ' + entity.thetaLength);              
    }
    else if (entity.prim == 'cone') {
        entity.tag.setAttribute('geometry', 'primitive: cone; height: ' + entity.height + '; openEnded: ' + entity.openEnded + '; radiusBottom: ' + entity.radiusBottom + '; radiusTop: ' + entity.radiusTop + '; segmentsRadial: ' + entity.segmentsRadial + '; segmentsHeight: ' + entity.segmentsHeight + '; thetaStart: ' + entity.thetaStart + '; thetaLength: ' + entity.thetaLength);            }
    else if (entity.prim == 'torus') {
        entity.tag.setAttribute('geometry', 'primitive: torus; radius: ' + entity.radius + '; radiusTubular: ' + entity.radiusTubular + '; segmentsRadial: ' + entity.segmentsRadial + '; segmentsTubular: ' + entity.segmentsTubular + '; arc: ' + entity.arc);            
    }
    else if (entity.prim == 'torusKnot') {
        entity.tag.setAttribute('geometry', 'primitive: torusKnot; radius: ' + entity.radius + '; radiusTubular: ' + entity.radiusTubular + '; segmentsRadial: ' + entity.segmentsRadial + '; segmentsTubular: ' + entity.segmentsTubular + '; p: ' + entity.p + '; q: ' + entity.q);           
    }
    else if (entity.prim == 'cylinder') {
        entity.tag.setAttribute('geometry', 'primitive: cylinder; radius: ' + entity.radius + '; height: ' + entity.height + '; openEnded: ' + entity.openEnded + '; segmentsRadial: ' + entity.segmentsRadial + '; segmentsHeight: ' + entity.segmentsHeight + '; thetaStart: ' + entity.thetaStart + '; thetaLength: ' + entity.thetaLength);         }
    else if (entity.prim == 'box') {
        entity.tag.setAttribute('geometry', 'primitive: box; depth: ' + entity.depth + '; height: ' + entity.height + '; width: ' + entity.width);  
    }
    else if (entity.prim == 'plane') {
        entity.tag.setAttribute('geometry', 'primitive: plane; height: ' + entity.height + '; width: ' + entity.width);
    }
    else if (entity.prim == 'octahedron' || entity.prim == 'tetrahedron' || entity.prim == 'dodecahedron') {
        entity.tag.setAttribute('geometry', 'primitive: ' + entity.prim + '; radius: ' + entity.radius);
    }
}

setGeometryだとかsetMaterialだとか、それぞれがどんな役割をしているのかについてはまだまだ理解できていません。。。
A-frameのEntity-Component-Systemあたりが関係していそうなんですがいまいちわからず。。。
ちょっとずつ勉強していこうと思います。

おにごっこの処理

一通りオブジェクトの生成がすんだら、残りはオブジェクトをどう動かすかの処理になります。
p5jsのお作法ではdraw()が常に実行され続けるので、この部分におにごっこのロジックを書いていきます。
細かいロジックについては先日の記事を参考にしてみてください。

コード全体はこちらからご覧いただけますので、是非是非実装してみてください!
参考にしたサイトの方もこちらからご覧ください。

今後やりたいこと

A-frame面白い。。。楽しい。。。
みんなが作った3Dモデルの共有サイトもあるらしく、そこから良いモデルをインポートすることもできる様子ユメガヒロガルゥゥゥウウ
VR周りはもっと勉強していきたい。

最後までご覧いただきありがとうございました!!
LGTMつけていただけると励みになります、よろしくお願いします!

canonno
ピアノとマラソンと読書がいきがい
protoout-studio
ProtoOut Studioは日本初のプロトタイピング専門スクールです。プログラミングとプランニング(企画)の両方のスキルを兼ね備えた人材輩出を行います。作って発信して、がんがんプロトアウトしていきましょう。
https://protoout.studio
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした