A-Frameを用いてゾンビから逃げるWebVR迷路ゲームを作った

作成: 2020年04月12日

更新: 2021年03月29日

WebVR迷路ゲーム

A-Frameを用いたWebで動くVRゲームを作りました。VRなしでも3Dゲームとして遊べます。
ここから遊べます
vr_meiro.png

A-Frameとは

A-Frame: Hello WebVR
最近のWebブラウザに標準搭載されているWebGLというAPIのラッパーであるThree.jsのラッパーです。
CDNなら以下

<script src="https://aframe.io/releases/1.0.4/aframe.min.js"></script>

npmなら以下

npm install aframe

ラッパーのラッパーなだけありほとんどの実装がHTMLのみで簡潔するのがうりになっています。具体的には以下のようにa-sceneという要素を置き、その下にa-box、a-sphereなどの要素を記述するだけで3Dの箱や球を描画することができます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello, WebVR! • A-Frame</title>
    <meta name="description" content="Hello, WebVR! • A-Frame">
    <script src="https://aframe.io/releases/1.0.4/aframe.min.js"></script>
  </head>
  <body>
    <a-scene background="color: #FAFAFA">
      <a-box position="-1 0.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box>
      <a-sphere position="0 1.25 -5" radius="1.25" color="#EF2D5E" shadow></a-sphere>
      <a-cylinder position="1 0.75 -3" radius="0.5" height="1.5" color="#FFC65D" shadow></a-cylinder>
      <a-plane position="0 0 -4" rotation="-90 0 0" width="4" height="4" color="#7BC8A4" shadow></a-plane>
    </a-scene>
  </body>
</html>

aframe.png

ただゲームを作ろうとするとさすがにJavascriptを随所で書く必要があります。

作ったゲームの概要

15×15の迷路を自動生成し、ランダムに配置された赤い鍵と青い鍵を回収しゴールである門にたどり着くとゲームクリアとなります。またゾンビが徘徊しているためゾンビに触れるとゲームオーバーとなります。

迷路の作成

まずは0を道、1を壁として迷路を構成する2次元配列を作成します。迷路作成アルゴリズムは以下のサイトを参考に壁伸ばし方を用いました。
自動生成迷路 - BIGLOBE
迷路生成(壁伸ばし法) - アルゴリズム初心者向けの基礎と入門
迷路作成アルゴリズムのソースコードは以下です。

function createMaze(size) {  //sizeは迷路の1辺の長さ
    const BINARY_ARRAY = Array.from(new Array(size), () => new Array(size).fill(0));
    for (let i = 0; i < size; i++) {
        BINARY_ARRAY[0][i] = 1;
        BINARY_ARRAY[size - 1][i] = 1;
        BINARY_ARRAY[i][0] = 1;
        BINARY_ARRAY[i][size - 1] = 1;
    }
    let startCreateWallPoints = [];
    for (let i = 2; i < size - 1; i += 2) {
        for (let j = 2; j < size - 1; j += 2) {
            startCreateWallPoints.push([i, j]);
        }
    }
    while (startCreateWallPoints.length != 0) {
        let randIndex = Math.floor(Math.random() * startCreateWallPoints.length); //0 ~ startCreateWallPoints.length - 1
        let startCreateWallPoint = startCreateWallPoints.pop(randIndex);
        if (BINARY_ARRAY[startCreateWallPoint[0]][startCreateWallPoint[1]] == 0) {
            let currentCreatingWallEvenPoints = [];
            BINARY_ARRAY[startCreateWallPoint[0]][startCreateWallPoint[1]] = 1;
            currentCreatingWallEvenPoints.push(startCreateWallPoint);
            let canExtendWall = true;
            let x = startCreateWallPoint[0];
            let y = startCreateWallPoint[1];
            while (canExtendWall) {
                let extendDirection = [];
                if (BINARY_ARRAY[x - 1][y] == 0 && currentCreatingWallEvenPoints.filter(arr => (arr[0] == x - 2 && arr[1] == y)).length == 0) {
                    extendDirection.push("left");
                }
                if (BINARY_ARRAY[x + 1][y] == 0 && currentCreatingWallEvenPoints.filter(arr => (arr[0] == x + 2 && arr[1] == y)).length == 0) {
                    extendDirection.push("right");
                }
                if (BINARY_ARRAY[x][y - 1] == 0 && currentCreatingWallEvenPoints.filter(arr => (arr[0] == x && arr[1] == y - 2)).length == 0) {
                    extendDirection.push("down");
                }
                if (BINARY_ARRAY[x][y + 1] == 0 && currentCreatingWallEvenPoints.filter(arr => (arr[0] == x && arr[1] == y + 2)).length == 0) {
                    extendDirection.push("up");
                }
                if (extendDirection.length != 0) {
                    let randomDirectionIndex = Math.floor(Math.random() * extendDirection.length);
                    let randomDirection = extendDirection[randomDirectionIndex];
                    if (randomDirection == "left") {
                        canExtendWall = (BINARY_ARRAY[x - 2][y] == 0);
                        BINARY_ARRAY[x - 1][y] = 1;
                        BINARY_ARRAY[x - 2][y] = 1;
                        currentCreatingWallEvenPoints.push([x - 2, y]);
                        if (canExtendWall) {
                            x = x - 2;
                        }
                    } else if (randomDirection == "right") {
                        canExtendWall = (BINARY_ARRAY[x + 2][y] == 0);
                        BINARY_ARRAY[x + 1][y] = 1;
                        BINARY_ARRAY[x + 2][y] = 1;
                        currentCreatingWallEvenPoints.push([x + 2, y]);
                        if (canExtendWall) {
                            x = x + 2;
                        }
                    } else if (randomDirection == "down") {
                        canExtendWall = (BINARY_ARRAY[x][y - 2] == 0);
                        BINARY_ARRAY[x][y - 1] = 1;
                        BINARY_ARRAY[x][y - 2] = 1;
                        currentCreatingWallEvenPoints.push([x, y - 2]);
                        if (canExtendWall) {
                            y = y - 2;
                        }
                    } else { //randomDirection == "up"
                        canExtendWall = (BINARY_ARRAY[x][y + 2] == 0);
                        BINARY_ARRAY[x][y + 1] = 1;
                        BINARY_ARRAY[x][y + 2] = 1;
                        currentCreatingWallEvenPoints.push([x, y + 2]);
                        if (canExtendWall) {
                            y = y + 2;
                        }
                    }
                } else {
                    let previousPoint = currentCreatingWallEvenPoints.pop();
                    x = previousPoint[0];
                    y = previousPoint[1];
                }
            }
        }
    }
    return BINARY_ARRAY;
}

その後生成された配列を用いて壁の場所にブロックを配置します。

function showMaze(mazeElement, mazeArray, size) { // mazeArrayは生成された0,1の2次元配列
    for (let i = 0; i < size; i++) {
        for (let j = 0; j < size; j++) {
            if (mazeArray[i][j] == 1) {
                let wallElement = document.createElement("a-box");
                wallElement.id = `wall-${i}-${j}`;
                wallElement.setAttribute("position", `${i} 0 ${j}`);
                wallElement.setAttribute("color", "#4CC3D9");
                wallElement.setAttribute("height", "5");
                let width = 1;
                let depth = 1;

                if ((i == 0 || mazeArray[i - 1][j] == 1) && (i == size - 1 || mazeArray[i + 1][j] == 1)
                    && (j == 0 || mazeArray[i][j - 1] != 1) && (j == size - 1 || mazeArray[i][j + 1] != 1)) {
                    width = 1.1;
                    depth = 0.9;
                } else if ((j == 0 || mazeArray[i][j - 1] == 1) && (j == size - 1 || mazeArray[i][j + 1] == 1)
                    && (i == 0 || mazeArray[i - 1][j] != 1) && (i == size - 1 || mazeArray[i + 1][j] != 1)) {
                    width = 0.9;
                    depth = 1.1;
                } else {
                    width = 0.9;
                    depth = 0.9;
                }
                wallElement.setAttribute("width", width);
                wallElement.setAttribute("depth", depth);
                mazeElement.appendChild(wallElement);
            }
        }
    }
}

これによってmazeArrayElement要素の下にa-boxという箱の形をした要素を配置します。箱の大きさが正方形でないのは迷路内の道と壁の間に隙間を作りプレイヤーが壁と密着し、壁の向こう側が見えるのを防ぐためです。この箱は当たり判定がなくすりぬけてしまうためプレイヤーが移動できる範囲を指定する必要があります。

カメラの移動

プレイヤー(カメラ)の移動にはA-Frame Extrasの使用がおすすめです。これはA-Frameの主要な拡張ライブラリをまとめたライブラリでカメラ移動に関する拡張も含まれています。
CDNなら以下

<script src="https://cdn.jsdelivr.net/gh/donmccurdy/aframe-extras@v6.1.0/dist/aframe-extras.min.js"></script>

npmなら以下

npm install aframe-extras

以下のようにcamera属性を含むa-entity要素とそれをラップするa-entity要素(rig)を用いてカメラを操作します。cameraの方にlook-controls属性をrigの方にmovement-controls属性を持たせることによって基本的なカメラの移動、視点移動がサポートされます。具体的にはキーボードの十字キー、wasdキー、ゲームパッドを用いたカメラ移動およびマウス、HMDの傾きによる視点移動が行えるようになります。

<body>
    <a-scene id="scene">
        <a-entity id="rig" position="1.3 0 1.3" movement-controls="constrainToNavMesh: true; speed:0.1">
            <a-entity id="camera" position="0 1.6 0" camera look-controls="pointerLockEnabled: true"></a-entity>
        </a-entity>
    </a-scene>
</body>

rigのmovement-contrrols属性でconstrainToNavMesh: trueを指定することでrigがnav-meshという属性を持った要素内しか移動できないようになります。これを活かして迷路内の道にnav-mesh属性を持たせることで迷路の壁抜けを行えないようにすることができます。
ただこのnav-mesh属性は1ページ中で1つしか認識されないため先のa-box配置と同様の手段で道を配置することはできません。つまり迷路のジグザグした道を1つの図形としてa-frame内に設置し、それにnav-mesh属性を持たせる必要があります。
そこで以下のようにして迷路内の道の形をした1枚の板を生成しました。A-FrameではAFRAME.registerComponent関数を用いることで特定の属性にスクリプトを仕込むことができます。init :function で定義された関数はその属性を持った要素が生成された時に実行されます。今回では迷路の道のすべての頂点を指定してTHREE.jsのAPIにより板を生成しました。
壁伸ばし法(棒倒し法、道伸ばし法でも同様)ではループした道もたどり着けない場所も存在しないため、右側の壁をたどるとすべての壁を通って元の場所にたどり着くためそれを利用しました。

function createPath(mazeArray, sceneElement) { // mazeArrayは生成された0,1の2次元配列, sceneElementはa-scene要素
    AFRAME.registerComponent('maze-path', {
        init: function () {
            let points = [];
            const meshSize = 1;
            let path = [1, 1];
            let isGoArround = false;
            let direction = "up";
            while (!isGoArround) {
                points.push(new THREE.Vector2(path[0] * meshSize - 0.5, path[1] * meshSize * -1 + 0.5));
                if (direction == "up") {
                    if (mazeArray[path[0]][path[1]] == 0) {
                        direction = "right";
                        path[0] = path[0] + 1;
                    } else if (mazeArray[path[0] - 1][path[1]] == 0) {
                        path[1] = path[1] + 1;
                    } else {
                        direction = "left";
                        path[0] = path[0] - 1;
                    }
                } else if (direction == "right") {
                    if (mazeArray[path[0]][path[1] - 1] == 0) {
                        direction = "down";
                        path[1] = path[1] - 1;
                    } else if (mazeArray[path[0]][path[1]] == 0) {
                        path[0] = path[0] + 1;
                    } else {
                        direction = "up";
                        path[1] = path[1] + 1;
                    }
                } else if (direction == "down") {
                    if (mazeArray[path[0] - 1][path[1] - 1] == 0) {
                        direction = "left";
                        path[0] = path[0] - 1;
                    } else if (mazeArray[path[0]][path[1] - 1] == 0) {
                        path[1] = path[1] - 1;
                    } else {
                        direction = "right";
                        path[0] = path[0] + 1;
                    }
                } else { // directon == "left"
                    if (mazeArray[path[0] - 1][path[1]] == 0) {
                        direction = "up";
                        path[1] = path[1] + 1;
                    } else if (mazeArray[path[0] - 1][path[1] - 1] == 0) {
                        path[0] = path[0] - 1;
                    } else {
                        direction = "down";
                        path[1] = path[1] - 1;
                    }
                }
                if (path[0] == 1 && path[1] == 1) {
                    isGoArround = true;
                }
            }
            let heartShape = new THREE.Shape(points);

            let geometry = new THREE.ShapeGeometry(heartShape);
            let material = new THREE.MeshBasicMaterial({
                color: 0x00ff00
            });
            let mesh = new THREE.Mesh(geometry, material);
            this.el.setObject3D('mesh', mesh);
        }
    });
    let pathElement = document.createElement("a-entity");
    pathElement.setAttribute("maze-path", "");
    pathElement.setAttribute("position", "0 0.01 0");
    pathElement.setAttribute("rotation", "-90 0 0"); // 板を視線と平行にする
    pathElement.setAttribute("color", "#00FF00");
    pathElement.setAttribute("nav-mesh", "");
    sceneElement.appendChild(pathElement);
}

今回は自動生成された迷路の道が対象のためこのような手段を取りましたがより複雑な移動範囲を指定する場合はblenderをもちいてnav-meshを作成する手法が推奨されています。
Allow A-Frame primitive geometries to act as `nav-mesh` #226

力学的な当たり判定を利用して移動範囲を制限するAPIはkinematic-bodyとして提供されていますがDepricatedになっていてnav-meshを用いることが推奨されています。
aframe-extras/src/misc at master · donmccurdy/aframe-extras

ゾンビの3Dモデルの描画(glTFファイルの使用)

ゲーム性をあげるためゾンビを迷路内に配置し、プレイヤーを追いかけるようにしました。
zombi.png
3DモデルはSketchfabにあるモデル(glTFファイル)をお借りいたしました。モデルをお借りする場合はライセンスに注意しましょう。
今回使用したゾンビのモデルはCreative Commons Attributionのためクレジットを記載すれば商用、非商用問わず利用が自由です。
glTFやFBXといった3Dモデルファイルを使用するにはまず以下のようにa-assets要素をhtml内に置きます。

<body>
    <a-scene id="scene">
        <a-assets>
            <a-asset-item id="zombi-asset" src="/path/to/gltf_file"> </a-asset-item>
        </a-assets>
    </a-scene>
</body>

そしてa-entity要素にgltf-model属性を持たせます。今回はJavaScript上で生成しました。

const ZOMBI_ELEMENT = document.createElement("a-entity");
ZOMBI_ELEMENT.id = "zombi"
ZOMBI_ELEMENT.setAttribute("gltf-model", "#zombi-asset"); // gltfファイルへのパスを持つ要素のidを指定
ZOMBI_ELEMENT.setAttribute("position", `${(size - 1) / 2} 0.2 ${(size - 1) / 2}`);
ZOMBI_ELEMENT.setAttribute("scale", "0.01 0.01 0.01");
sceneElement.appendChild(ZOMBI_ELEMENT);

また今回お借りしたgltfファイルにはゾンビが歩くアニメーションも含まれているため、animation-mixer属性を持たせることでゾンビを歩かせることができます。

ZOMBI_ELEMENT.setAttribute("animation-mixer", "timeScale: 6"); // timeScaleでアニメーション1ループ当たりの時間を制御

これだけではその場で足踏みするだけになってしまうのでanimation-loopのEventListenerを設定することでアニメーション1ループごとに実行する関数を定義し、アニメーション1ループごとにゾンビのPositionを変化させました。

ZOMBI_ELEMENT.addEventListener("animation-loop", function () {
    /**
    ゾンビのPosition変更
    */
}

詰まった所

VRモードにした瞬間視点の高さが変わる

VRモードにするとやけに視点が高くなります。どうやらcameraのデフォルトの高さは1.6に設定されているらしく高さを0に設定していたとしてもVRモードにしたときに1.6にリセットされる見たいです。
Set camera position to (0, 0, 0) after entering VR Mode in Aframe - Stack Overflow
対策としてはあらかじめcameraの高さは1.6として実装を進めればVRモードにしても高さは変わりません。

Oculus Touchなどの両手コントローラで左ジョイスティックで移動、右ジョイスティックで視点移動をしたい

movement-controls属性でゲームパッドも含めたカメラの移動が実装できますがこれはOculus Touchのような2つのコントローラで操作することが想定されていません。よって片方(たいてい左)のジョイスティックしか認識されず視点移動がコントローラでできません。
対策としてはrotation-controlsという新しいコンポーネントを作りそちら側に右コントローラを認識させて視点の回転を制御させます。
A-Frame-Extrasライブラリのgamepad-controls.jsを参考にして作成しました。
両手ゲームパッドはパソコンに認識されるとIDが割り振られ通常は左に0右に1が割り振られるのでcontroller schemaのデフォルトを1(gamepad-controls.jsでは0になっている)にして右コントローラーを認識させました。

// run with look-controls component(camera) and movement-controls component(rig)

const JOYSTICK_EPS = 0.2;

module.exports.Component = AFRAME.registerComponent("rotation-controls", {
  schema: {
    // Controller 0-3
    controller: { default: 1, oneOf: [0, 1, 2, 3] },

    // Enable/disable features
    enabled: { default: true },

    // Debugging
    debug: { default: false },

    // Rotation sensitivity
    rotationSensitivity: { default: 2.0 },
  },
  init: function () {
    const sceneEl = this.el.sceneEl;
    this.system = sceneEl.systems['tracked-controls-webxr'] || { controllers: [] };
    this._lookVector = new THREE.Vector2();
  },
  tick: function (t, dt) {
    this.updateRotation(dt);
  },

  /*******************************************************************
   * Rotation
   */

  isRotationActive: function () {
    if (!this.data.enabled || !this.isConnected()) return false;

    const joystick = this._lookVector;

    this.getJoystick(joystick);

    return Math.abs(joystick.x) > JOYSTICK_EPS || Math.abs(joystick.y) > JOYSTICK_EPS;
  },

  updateRotation: function (dt) {
    //console.log(dt);
    if (!this.isRotationActive()) return;
    //this.el.object3D.rotation.y += 1

    const lookVector = this._lookVector;
    this.getJoystick(lookVector);
    if (Math.abs(lookVector.x) <= JOYSTICK_EPS) lookVector.x = 0;
    if (Math.abs(lookVector.y) <= JOYSTICK_EPS) lookVector.y = 0;
    lookVector.multiplyScalar(this.data.rotationSensitivity * dt / 1000);
    this.el.object3D.rotation.y -= lookVector.x;
  },

  /**
   * Returns the Gamepad instance attached to the component. If connected,
   * a proxy-controls component may provide access to Gamepad input from a
   * remote device.
   *
   * @return {Gamepad}
   */
  getGamepad: function () {
    const stdGamepad = navigator.getGamepads
      && navigator.getGamepads()[this.data.controller],
      xrController = this.system.controllers[this.data.controller],
      xrGamepad = xrController && xrController.gamepad,
      proxyControls = this.el.sceneEl.components['proxy-controls'],
      proxyGamepad = proxyControls && proxyControls.isConnected()
        && proxyControls.getGamepad(this.data.controller);
    return proxyGamepad || xrGamepad || stdGamepad;
  },

  /**
   * Returns the state of the specified joystick as a THREE.Vector2.
   * @param  {Joystick} role
   * @param  {THREE.Vector2} target
   * @return {THREE.Vector2}
   */
  getJoystick: function (target) {
    const gamepad = this.getGamepad();
    if (gamepad.mapping === 'xr-standard') {
      // See: https://github.com/donmccurdy/aframe-extras/issues/307
      return target.set(gamepad.axes[2], gamepad.axes[3]);
    } else {
      return target.set(gamepad.axes[0], gamepad.axes[1]);
    }
  },

  /**
   * Returns true if the gamepad is currently connected to the system.
   * @return {boolean}
   */
  isConnected: function () {
    const gamepad = this.getGamepad();
    return !!(gamepad && gamepad.connected);
  },

  /**
   * Returns a string containing some information about the controller. Result
   * may vary across browsers, for a given controller.
   * @return {string}
   */
  getID: function () {
    return this.getGamepad().id;
  }
});

これをcameraをラップするrig要素に持たせることでカメラの視点を右コントローラーで操作できます。

<body>
    <a-scene id="scene">
        <a-entity id="rig" position="1.3 0 1.3" rotation-controls movement-controls="constrainToNavMesh: true; speed:0.1">
            <a-entity id="camera" position="0 1.6 0" camera look-controls="pointerLockEnabled: true" position="0 0.3 0"></a-entity>
        </a-entity>
    </a-scene>
</body>

まとめ

A-FrameおよびWebVRはまだ発展途上ので仕様もガンガン変わっているようでGoogleで検索しても全然情報が違っていて実装は大変でしたがWeb上でVR対応の3Dコンテンツが容易に作成できるのは面白いです。