本記事はHTML5 Japan Cup 2014で優秀賞を頂いたEnraged Fowlsというゲームに関する技術的な解説の前編です。
Enraged Fowlsとは
タイトルに使われているのは聞きなれない単語かもしれませんが、それぞれ類語辞典で調べたAngryとBirdの同義語です。「Angry Birds」については、おそらく皆さんご存知でしょう。全世界で累計15億ダウンロード(Wikipedia)されたアクションパズルゲームで、物理エンジンを使用してスリングショットで撃ち込まれた鳥と、それにより崩れ落ちるブロックのリアルな動きを実現していることが大きな特徴です。上のスクリーンショットからも分かる通り、「Enraged Fowls」はこの「Angry Birds」を3Dにすることを目指したものです。
加えて、Enraged Fowlsならではの機能としてスマホをセットして使用できる、スリングショット型のお手製コントローラーがあります。当コントローラーを使用すれば、モニターの中の世界に対して、実際にスリングショットで鳥を撃ちこんでいるような感覚を得ることができます。
技術要素
Enraged Fowlsの技術的な見どころとしては大きく
- スリングショットコントローラー
- 3Dの表示と動き
の2つがあると考えています。具体的な技術要素を挙げると以下の様になるでしょうか。
- WebRTC(スリングショットコントローラー)
- Web Workers(スリングショットコントローラー)
- WebGL(three.js)
- 3D物理エンジン(cannon.js)
この中でthree.jsについては、今回の「HTML5 Japan Cup 2014」の優秀・最優秀作4作品のうち、Enraged Fowlsを含む3作品が使用していることもあり、おそらく別の記事で解説されると判断して、本記事では説明を省略します。HTML5 Experts.jpにもthree.jsの解説記事がありますので、そちらを参考にしてもいいでしょう。
初心者でも絶対わかる、WebGLプログラミング<three.js最初の一歩>
また(three.jsを除いても)上記すべてを一度に解説することができなかったため、今回はスリングショット部について解説します。物理エンジンに興味のある方は次回までお待ちください。なお、Enraged Fowlsのソースコードは以下で全て公開されています。必要に応じて参照してください。
スリングショットコントローラー
スリングショットコントローラーは、本物のスリングショットのようにコントローラを移動して狙いをつけます。そして、ゴム紐を引いて手を離し弾を撃つという操作を、ウェブカメラとスマートフォンの加速度センサーを利用して実現したものです。
(もちろん上記のような工作をしなくてもスマートフォンさえあれば、ウェブカムの前にかざして画面をタップするか端末の裏面を指で弾けば動作を確認することができるので、よかったら試してみてください)
ウェブカムによるスマートフォンの位置の特定
ウェブカメラに写るスマートフォンの位置を取得する方法は、正直かなり手を抜いていて、スマートフォンの画面を真っ赤にし、ウェブカムの「赤い部分」の平均値をスマートフォンの位置と見なしています。スリングショットコントローラーは、締め切りの一週間ほど前に、ふと思いついて追加した機能です。まず簡単な方法で実装して動作や使用感を検証し、精度が足りないようなら後できちんとしたやり方にしようと考えて実装しましたが、ちょっと遊ぶ分には大して問題もなかったため、結局そのまま今に至っています。まぁよくあることです。
ソースコードの該当部分を以下に抜粋します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
Detector.prototype.startCamera = function() { // ウェブカムの映像取り込み navigator.getUserMedia({video: true}, // ... (1) function(localMediaStream) { // ウェブカムの映像をvideo要素で表示 this.video.src = window.URL.createObjectURL(localMediaStream); // ... (2) this.video.play(); }.bind(this), function(err) { ... }.bind(this) ); }; Detector.prototype.startWorker = function() { // ... (3) // 画像解析用WebWorker開始 this.worker = new Worker('js/detector_worker.js'); this.worker.addEventListener('message', function(event) { // WebWorkerから返された解析結果を処理 this.detectHandlers.forEach(function(handler) { handler(event.data, this.gc); }.bind(this)); if (this.isDetecting) setTimeout(this.detect.bind(this), 10); }.bind(this)); }; Detector.prototype.detect = function() { // ... (4) // video要素の内容を取り込んでWebWorkerで処理 this.gc.drawImage(this.video, 0, 0, this.video.width, this.video.height); this.worker.postMessage(this.gc.getImageData(0, 0, this.video.width, this.video.height)); }; |
スマートフォンの位置の特定は、Detectorオブジェクトで行います。Detectorオブジェクトはdetector.jsとdetector_worker.jsの2つからなっていて、detector.jsではnavigator.getUserMedia関数を使用してウェブカムの画像をvideo要素に取り込み(1)(2)、画像解析用のWebWorkerを開始し(3)、定期的にvideo要素の内容をWebWorkerに送ります(4)。現在のところdetector_worker.jsでは非常に単純な解析しか行っておらず、WebWorkerを使わなくてもそれほど問題にはならないかもしれませんが、将来的に複雑な解析を行うことを可能にするために、バックグラウンドで処理しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// 直近10回の解析結果を保持 var centers = []; addEventListener('message', function(event) { var imageData = event.data; var data = imageData.data; var redPoints = []; // 画像データを走査して赤い部分(isRed)の位置を記録 for (var x = 0; x < imageData.width; x+=5) { for (var y = 0; y < imageData.height; y+=5) { var base = x * 4 + y * imageData.width * 4; if (isRed(data[base], data[base+1], data[base+2], data[base+3])) { // ... (5) redPoints.push({x:x, y:y}); } } } // 直近10回の赤い部分の中心位置を記録 var center = getCenter(redPoints); while (10 <= centers.length) centers.shift(); centers.push(center); // 直近10回の平均位置を取得 var centerAverage = getCenterAverage(centers); // ... (6) // WebWorker呼び出し元に結果を返却 postMessage({ redPoints:{points:redPoints, center:center, centerAverage:centerAverage, size:redPoints.length} }); }); function isRed(r, b, g, a) { // なんとなく赤っぽい色を判定 return 250 < a && 100 < r && g < r/2.0 && b < r/2.0; } |
先に書いた通り、detector_woker.jsの中心的な処理はImageDataオブジェクト内の赤い部分を抜き出して、その中心位置を返すことです。基本的にはImageDataの画素の色をチェックしているだけですが(5)、特に厳密な解析も不要なので全画素をチェックするのではなくある程度間引いた値をチェックし、それにより発生するチラツキを直近10回の解析結果の平均を返すことで防いでいるのが(6)、工夫といえば工夫です。
ちなみに、「赤い部分の位置の平均を取るだけだと服や部屋が赤かったりすると問題が起きるんじゃないか?」と思う人もいるでしょうが、おっしゃる通り、割と大変なことになります。
解決策はいろいろあると思うので、まぁ読者の皆さんへの宿題というか、pullreqは随時受付中です。
Detectorオブジェクトの利用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
this.detector = new Detector(); this.detector.addDetectHandler(function(data, gc) { // ウェブカム画像内のスマートフォンの位置を正規化 var center = data.redPoints.centerAverage; var dx = center.x / Detector.DEFAULT_WIDTH - 0.5; var dy = center.y / Detector.DEFAULT_HEIGHT - 0.5; this.cameraDirection = new THREE.Vector3(0, 0, 5); // y軸方向の位置を射出の縦方向に変換 var yAxis = new THREE.Vector3(0, 1, 0); var yawAngle = -dx * Math.PI/2; var yawMatrix = new THREE.Matrix4().makeRotationAxis(yAxis, yawAngle); this.cameraDirection.applyMatrix4(yawMatrix); // x軸方向の位置を射出の横方向に変換 var xAxis = new THREE.Vector3(1, 0, 0); var pitchAngle = -dy * Math.PI/4 - Math.PI/8; var pitchMatrix = new THREE.Matrix4().makeRotationAxis(xAxis, pitchAngle); this.cameraDirection.applyMatrix4(pitchMatrix); // 視点とカメラの向きを射出方向に合わせて変更 this.world.threeCamera.position.copy(new THREE.Vector3().copy( this.bird.threeMesh.position).sub(this.cameraDirection)); this.world.threeCamera.lookAt(this.bird.threeMesh.position); }.bind(this)); this.detector.start(); |
Detectorを利用する側のコードは、上記のようになります。Detectorオブジェクト自体はなるべく汎用的にしたかったので、Enraged Fowls独自の処理はaddDetectHandler関数で、利用側がハンドラ登録するようにしています。
一見複雑な処理にみえるかもしれませんが、要は下の図のように、目の前に固定されたスリングショットがあるイメージで、ウェブカム映像内でスマートフォンが写っている位置に応じて、撃ち出す方向や視点を変更しているだけです。具体的な座標変換についてはコードが全てですし、説明を省略します。
加速度センサーによるゴム紐リリースの検知
スリングショットで狙いを付けることができるようになったので、次にゴム紐を伸ばして離すことで、狙いをつけた方向に鳥(弾)を撃てるようにします。正直なところ、これも非常に手を抜いたやり方で実装しています。
1 2 3 4 5 6 |
window.addEventListener('devicemotion', function(event) { var threshold = 5; if (threshold < Math.abs(event.acceleration.z)) { toggleMarkerColor(); } }); |
windowオブジェクトのdevicemotionイベントで、端末にかかる加速度を取得できます。スマートフォンの画面に対して垂直な方向はz軸方向なので、event.acceleration.zの値がある閾値を超えたときに、スマートフォンの裏側に何かしらの衝撃があったと判断できます。その衝撃を検知したことをアプリ本体に伝えられればいいわけですが、ここでも先ほどのDetectorオブジェクトを使用します。つまり衝撃を検知したときにスマートフォンの画面の色を赤から青に変更し、その色の変化を先のDetectorオブジェクトで検知してアプリに伝え、鳥(弾)を発射します。
勘のよい方はお気づきかと思いますが、z軸方向の加速度センサの値を見ているだけなので、例えば急にスマートフォンを裏返したり倒したりするだけで弾が暴発します。時間があればなんとかしようとも思っていたのですが、ちょっと遊ぶ分には大して問題もなかったため、結局そのまま今に至っています。まぁよくあることです。
加速度センサの値の履歴から特定のパターンの場合にだけ反応するなど解決策はいろいろあると思うので、読者の皆さんへの宿題というか、pullreq随時受付中です。
まとめ
スリングショットコントローラーに使用されている個々の技術は、非常に基本的で、特に難しいことも行っていません。ただそれらの組み合わせでスリングショットを実現したところは、ほんの少しだけ新しいのではないかと自負しています。
次回は、アプリ本体で使用している物理エンジンについて解説する予定です。