anime.jsを使ったブラウザパズルゲーム

作成: 2019年03月03日

更新: 2021年02月07日

やりたいこと

1ページで完結するブラウザゲームを作りたくなったので作った。
https://banatech.net/kurukuru

どんなゲームか

テトリスやぷよぷよはすでに多く作られていて面白くないので避けた。そこで昔やったパワプロクンポケット7のあるミニゲームからヒントを得て(というか要素を減らして)パズルゲームを作った。
実際のゲーム画面は以下。
kurukuru.png
くわしくは上記のリンクにも書いてあるので省くが、簡単に書くと盤上のブロックを入れ替えて同じ色のブロックで正方形を作ることを目標とするゲームだ。
基本はキーボード操作を想定しているが、スマートフォンでもできるようにボタンでも操作可能にしている。ただし操作性は非常に悪い。

ブロックとカーソルの設置

まずはゲームを行うために6×6のブロックを設置しなければならない。このブロックはHTML5のcanvasという要素によって作られている。canvasはブラウザ上で四角形や円などの図形を描画できる要素である。ブロックに画像を使わないことで描画速度を上げている。ブロック部分は以下のようになっている。

<div class="blockBox d-flex justify-content-center" style="position: relative;">
    <canvas id="controller" width="96px" height="96px" style="position: absolute; top:0px; left: 0px;"></canvas>
    {% for block in ""|rjust:"36" %}
    {% if forloop.counter0|divisibleby:"6" %}
        <div class="blockColumn d-flex flex-column">
        {% endif %}
            <canvas class="m-1" id="b-{{ forloop.counter0 }}" width="40px" height="40px"></canvas>
            {% if forloop.counter|divisibleby:"6" %}
        </div>
    {% endif %}
    {% endfor %}
    <canvas id="screen" width="290px" height="290px" style="position: absolute;" onClick="gameStart();"></canvas>
</div>

{% %}で囲われた部分はWebフレームワークDjangoの機能でありhtml内でifやforを用いてることができる。
id=b-*の要素がすべてブロックである。レイアウトにはBootstarapのflexboxを用いた。canvasのfillRectメソッドを以下のように用いることでcanvas内に色のついた四角形を描くことができる。

var block = document.getElementById("b-*");
function fillRed(block) {
    if (!block || !block.getContext) {
        return false;
    }
    var ctx = block.getContext('2d');
    ctx.beginPath();
    ctx.fillStyle = 'red';
    ctx.fillRect(0, 0, size, size);
}

カーソルも同様にcanvasを用い、少し工夫することでカーソル型の図形を描画することができた。

position(x, y) {
    this.ctx.beginPath();
    this.ctx.lineWidth = 2;
    this.ctx.strokeRect(1, 1, controllerSize - 2, controllerSize - 2);
    this.ctx.clearRect(controllerSize / 2 - spanSize / 2, 0, spanSize, controllerSize);
    this.ctx.clearRect(0, controllerSize / 2 - spanSize / 2, controllerSize, spanSize);
}

x、yはカーソルの位置、controllerSizeはカーソルの大きさを表している。thisはカーソルそのものである。
StrokeRectで四角形の枠線だけを描き、clearRectでカーソル全体を十字型に消している。こうしてカーソル型の図形だけが描画されている。
カーソルの位置ははこのようにJavaScript内で数字として扱い、それを画面上にcanvasによって反映させている。このときカーソルのスタイルを以下のように直接操作することで画面上への反映を実現させている。

this.controller.style.left = this.x * (size + margin * 2) + "px";
this.controller.style.top = this.y * (size + margin * 2) + "px";

anime.jsを用いたアニメーションの付与

カーソル内のブロックの回転操作も基本はcanvasの描画を利用して実現してる。しかしこれだけでは味気ないのでanime.jsを用いてカーソルを回転させることで操作している感を出している。
anime.jsは以下のサイトからダウンロードできる。開発時は.js版、本番時は.min.js版を使えばいい。
Releases · juliangarnier/anime
anime.jsを用いて以下のコードでカーソルを時計回りに90度回転させることができる。

controller = document.getElementById('controller');
anime({
    targets: controller,
    rotate: [-90, 0],
    duration: 500,
});

targetsはアニメーションを付与する対象、rotate: [-90, 0]は-90度から0度への回転、durationはアニメーションの実行時間(単位はミリ秒)を指定する。このほかにもさまざまな引数があるので公式サイトをチェックしたほうがいい。callbackを指定して、アニメーションの直前や直後に関数を実行させることもできる。
ブロックをそろえて消した後、ブロックを再生成するときも以下のコードによってスケールを0から1までアニメーションつきで変化させることでブロックがにゅるっとでてくるように見える。

var block = document.getElementById("b-*");
anime({
    targets: block,
    scale: [0, 1],
    duration: 1000,
});

実際にブロックの要素を消しているわけではなくcanvasの色を変えているだけなのだがこうやってアニメーションをつけることでブロックを消しているように感じさせることができる。

ゲーム開始と終了

ゲーム開始と終了を定義するために以下のようなコードを書いた。

<!--音楽再生用の要素-->
<audio src="" id="startSoundEffect"></audio>
<audio src="" id="overSoundEffect"></audio>
<audio src="" id="countDownSoundEffect"></audio>
<audio src="" id="moveSoundEffect"></audio>
<audio src="" id="rotateSoundEffect"></audio>
<audio src="" id="scoreSoundEffect"></audio>
<audio src="" id="BGM"></audio>
var count = 3;
var mode = 0;
function gameStart() {
    if (mode == 0) {
        var countDownSoundEffect = document.getElementById("countDownSoundEffect");
        var screen = document.getElementById("screen");
        var ctx = screen.getContext("2d");
        var img = new Image();
        var BGM = document.getElementById("BGM");
        BGM.src = "{% static 'kurukuru/music/mozegaku_09_idance.mp3' %}";
        BGM.load();
        var startSoundEffect = document.getElementById("startSoundEffect");
        startSoundEffect.src = "{% static 'kurukuru/music/gamestart.mp3' %}";
        startSoundEffect.load();
        BGM.currentTime = 0;
        if (count == 3) {
            countDownSoundEffect.play();
            img.src = "{% static 'kurukuru/images/three.png' %}";
            img.onload = function () {
                ctx.drawImage(img, 0, 0, backgroundSize, backgroundSize);
                count--;
                setTimeout("gameStart()", 1000);
            }
        } else if (count == 2) {
            countDownSoundEffect.play();
            img.src = "{% static 'kurukuru/images/two.png' %}";
            img.onload = function () {
                ctx.drawImage(img, 0, 0, backgroundSize, backgroundSize);
                count--;
                setTimeout("gameStart()", 1000);
            }
        } else if (count == 1) {
            countDownSoundEffect.play();
            img.src = "{% static 'kurukuru/images/one.png' %}";
            img.onload = function () {
                ctx.drawImage(img, 0, 0, backgroundSize, backgroundSize);
                count--;
                setTimeout("gameStart()", 1000);
            }
        } else if (count == 0) {
            if (BGM.readyState === 4) {
                BGM.play();
                mode = 1;
                ctx.clearRect(0, 0, backgroundSize, backgroundSize);
                countDown();
                startSoundEffect.play();
            } else {
                BGM.addEventListener('canplaythrough', function (e) {
                    BGM.removeEventListener('canplaythrough', arguments.callee);
                    BGM.play();
                    mode = 1;
                    ctx.clearRect(0, 0, backgroundSize, backgroundSize);
                    countDown();
                    startSoundEffect.play();
                });
            }
        }
    } else if (mode == -1) { //ゲームオーバー画面から初期画面への移動
        countDownSoundEffect.play();
        mode = 0;
        reset();
        initScreen();
    }
}

{% static '~' %}の部分はDjangoの機能でサーバーから画像や音楽を取得している。
modeという変数でゲームの状態を管理し、mode=0のときは初期状態、mode=1のときはゲーム中、mode=-1のときはゲームオーバーとしている。modeが1でないときは操作を受け付けないようにしている。
ゲーム画面にかぶせるようにcanvasを置き、そのcanvasに初期画面やゲームオーバー画面用の画像を描画し、ゲーム開始時には何も描画させないようにしている。setTimeoutを利用してcountを減らしつつ、この関数を4回呼ぶことでゲーム開始時カウントダウンを行うようにしている。
音楽はHTML上にaudio要素を置きそこにmp3ファイルを入れることで流している。すべてのaudioはページ読み込み時にloadメソッドで読み込ませているがBGMはファイルサイズが大きくゲーム開始までにロードしきれないことがおおかったので、ゲーム開始時にBGM.readyStateを確認しロードできているならゲームを開始し、そうでないならaddEventListenerでBGMがロードし終わったらゲームを開始するように待たせている。

まとめ

JavaScriptライブラリanime.jsを用いることでゲームにアニメーションを持たせ操作感を持たせることができた。
反省点として音楽や画像などの静的ファイルのに読み込みがうまくいかず、スマートフォンなどでは正常に動作しないことがあるので今度作るときはそのあたりを改善したい。