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

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

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

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

Gistは[こちらから](https://gist.github.com/canonno/71ba35a59bcceaa81ea4b83f950c01e8)
Appは
やさしいモードは[こちらから](https://focused-nightingale-fcc7a6.netlify.app/)
ふつうモードは[こちらから](https://naughty-beaver-64b32a.netlify.app/)
くらやみモードは[こちらから](https://sleepy-wing-36aa7e.netlify.app/)
※VRをお持ちの方、__今回はブラウザで操作すること前提で実装してしまったため、VRで操作する仕様になっていない可能性があります（theクソ仕様）__。それを踏まえたうえでお楽しみください。

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">a-frameとp5jsで鬼ごっこを作りました。真っ暗闇を黒い箱から逃げ回ってください。操作はクリックで進み続けるだけ。箱は日に日に速く動くようになります。5日目を超えられたら教えてください。<a href="https://t.co/ABG5CHaYY7">https://t.co/ABG5CHaYY7</a><a href="https://twitter.com/hashtag/p5js?src=hash&amp;ref_src=twsrc%5Etfw">#p5js</a> <a href="https://twitter.com/hashtag/javascript?src=hash&amp;ref_src=twsrc%5Etfw">#javascript</a> <a href="https://twitter.com/hashtag/html?src=hash&amp;ref_src=twsrc%5Etfw">#html</a> <a href="https://twitter.com/hashtag/protoout?src=hash&amp;ref_src=twsrc%5Etfw">#protoout</a> <a href="https://t.co/MVLLFiC9XX">pic.twitter.com/MVLLFiC9XX</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1306113657839919104?ref_src=twsrc%5Etfw">September 16, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

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

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">真夜中はこんな感じ。<br>なあんにも見えません。<a href="https://twitter.com/hashtag/protoout?src=hash&amp;ref_src=twsrc%5Etfw">#protoout</a> <a href="https://t.co/iy1ooZdkfF">pic.twitter.com/iy1ooZdkfF</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1306114230249111553?ref_src=twsrc%5Etfw">September 16, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

##ゲームオーバー
タッチされる・場外に出ると記録が表示されます。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">タッチされたとき・場外に出たときに記録が出ます。<br>5日目まで逃げ切れたら教えてください。<a href="https://twitter.com/hashtag/protoout?src=hash&amp;ref_src=twsrc%5Etfw">#protoout</a> <a href="https://t.co/2gY0QVnHLv">pic.twitter.com/2gY0QVnHLv</a></p>&mdash; canonno (@canonno_blog) <a href="https://twitter.com/canonno_blog/status/1306115066589097984?ref_src=twsrc%5Etfw">September 16, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

##ちなみに(余談)
今回__初めてのVR制作__でして、__VRのヘッドセットを使うことも初めて__。
今回は__Oculus Go__を一からセットアップしてやってみました。
動画も取ったんですが、なぜか起動ができなくなってしまってデータが抜けず断念。。。
<img width="300" alt="DSC_0096.JPG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/666990/ffc2e1e9-e78b-eb0a-ef53-ba251659c410.jpeg">
テンションの上がるぼく

基本的な操作はブラウザと同じく、手元のコントローラーのクリックでどうにかなりそう。
クリックする必要が無く周りが見渡せるので、もしかしたらこちらの方が簡単かもしれません。
ただ今回「__ゲームオーバーしたら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タグの子要素としてこれらのオブジェクトを入れ込んでいます。
詳しくは[公式ドキュメント](https://aframe.io/docs/1.0.0/introduction/)や[A-frameまわりのQIita記事](https://qiita.com/name_yy/items/9144f612a8e15d14f9ff)をご覧になりながら、是非ハンズオンしてみてくださいね。

```html
<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>

```


##オブジェクトの生成処理
今回は[こちらのサイト](https://leoouyang.com/let-it-snow)をかなり参考にさせていただきました。
__こちらのサイトにあるコードを読み解いて、実装に利用した形になります。__
p5jsのお作法については[先日の記事](https://qiita.com/canonno/items/00738c7d928c3ec655d5)を見つつ実装しています。
今回は備忘録的にA-frameに関するお作法をメモしておきます。

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

```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クラスへオブジェクトをどんどん追加していきます。
今回の敵役である黒い箱に着目すると、

```js
//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()でタグが生成します。

```js
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することでタグへ属性情報を挿入することができます。

```js

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()が常に実行され続けるので、この部分におにごっこのロジックを書いていきます。
細かいロジックについては[先日の記事](https://qiita.com/canonno/items/00738c7d928c3ec655d5)を参考にしてみてください。

コード全体は[こちらから](https://gist.github.com/canonno/71ba35a59bcceaa81ea4b83f950c01e8#file-components-js-L1798)ご覧いただけますので、是非是非実装してみてください！
参考にしたサイトの方も[こちらから](https://leoouyang.com/let-it-snow)ご覧ください。


#今後やりたいこと
A-frame面白い。。。楽しい。。。
みんなが作った3Dモデルの共有サイトもあるらしく、そこから良いモデルをインポートすることもできる様子ﾕﾒｶﾞﾋﾛｶﾞﾙｩｩｩｳｳ
VR周りはもっと勉強していきたい。

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