落ちてくる矢印をフリックで消すゲーム

JavaScriptでスマホ用の簡単な反射神経ゲームを作ってみました。
上から落ちてくる矢印を同じ方向に画面フリックして消すだけです。

![2020-08-23 01.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/466f9b8a-cca5-31cb-4d91-d8ab5a6ca29c.jpeg)

[実動サンプル](https://akebi.jp/fallarrows/) 
キー入力は未実装なので、今のところスマホ、タブレット専用です。
(ここに投稿したバージョンはPC未対応ですが、設置サンプルのほうは更に手を加えてPCでも操作可能になっています)

[動画サンプル](https://youtu.be/L4vux-XJZUE)

ルールはとりあえずこんな感じにしてあります。
・画面タップで開始
・時間経過とともに落下の間隔は短く、加速度はアップ
・点数は上で消すほど高く、時間が進むほど倍率もアップ
・下まで落ちるかフリック方向入力違いで1ミス
・5回ミスで終了

~~~html:index.html
<!doctype html>
<html lang='ja'>
    <head>
        <meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1' />
        <meta charset='utf-8'>
        <link rel='stylesheet' href='./style.css'>
    </head>
    <body>
        <div id='main'>
        </div>
        <div id='top'>
            <div id='score'>0</div>
            <div id='left'>■■■■■</div>
        </div>
    </body>
    <script src='./script.js'></script>
</html>
~~~

~~~js:script.js
"use strict";

let
    eId,
    intervalId = 0,
    frame,
    interval,
    fallAcceleration,
    score,
    left,
    idCount = 0,
    idEfObjCount = 0,
    arrowType = 0,
    touchFlg = false,
    sx, sy,
    mx, my,
    inputDirection = -1,
    loopNum = 1;

const
    // 落下オブジェクト
    objects = {},
    // 消滅エフェクトオブジェクト
    efObjects = {},
    // 落下物グループ(現在は矢印のみ使用)
    arrows = [
        ['↑','→','↓','←'],
        ['👆','👉','👇','👈'],
        ['上','右','下','左'],
    ],
    size = 8,
    handler = (function(){
        const events = {};
        let key = 0;
        return {
            add: function(target, type, listener, capture) {
                target.addEventListener(type, listener, capture);
                events[key] = {
                    target: target,
                    type: type,
                    listener: listener,
                    capture: capture
                };
                return key++;
            },
            remove: function(key) {
                if(key in events) {
                    const e = events[key];
                    e.target.removeEventListener(e.type, e.listener, e.capture);
                }
            }
        };
    }());

// ピンチインアウト引きずり抑制
eId = handler.add(window, 'touchmove', function(e){
    e.preventDefault();
}, { passive: false });

// タッチパネルイベント
window.addEventListener("touchstart", function(event) {
    touchFlg = true;
    sx = event.changedTouches[0].pageX;
    sy = event.changedTouches[0].pageY;

    // ループ開始
    if(!intervalId) {
        init();
        intervalId = requestAnimationFrame(mainLoop);
    }
}, false);

window.addEventListener("touchend", function(event) {
    touchFlg = false;
}, false);

// 移動時
window.addEventListener("touchmove", function(event) {
    if(!touchFlg) return;

    // 座標の取得
    mx = event.changedTouches[0].pageX;
    my = event.changedTouches[0].pageY;

    const dx = mx - sx,
          dy = my - sy;

    if(Math.abs(dx) > 32 || Math.abs(dy) > 32) {
        // 横スワイプ
        if(Math.abs(dx) > Math.abs(dy)) {
            // 右
            if(dx > 0) {
                inputDirection = 1;
            } else {
                inputDirection = 3;
            }
        } else {
            if(dy > 0) {
                inputDirection = 2;
            } else {
                inputDirection = 0;
            }
        }
        touchFlg = false;
    }
}, false);

//初期化
function init() {
    frame = 0;
    interval = 60; // 落下間隔
    fallAcceleration = 1.02; //加速度

    loopNum = 1;
    score = 0;
    left = 5;

    idCount = 0;
    idEfObjCount = 0;

    document.getElementById('left').textContent = '■'.repeat(left);
    document.getElementById('score').textContent = score;

    for(const i in objects) delete objects[i];
    for(const i in efObjects) delete efObjects[i];
    document.getElementById('main').innerHTML = '';
}

function mainLoop() {
    intervalId = requestAnimationFrame(mainLoop);

    // オブジェクト生成
    if(frame % Math.floor(interval) == 0) {
        for(let i = 0; i < loopNum; i++){
            const id = '_' + idCount++;
            objects[id] = {}; // 子オブジェクト作成
            objects[id].x = 10 + Math.random() * 80; // 横出現位置 (%)
            objects[id].y = 0; // 縦出現位置 (%)
            objects[id].s = 0.2; // 落下速度 (%)
            objects[id].t = Math.floor(Math.random() * 4); // 向き(0～3 4方向からランダム)

            // 作成した子オブジェクトに対応するエレメントを作成
            document.querySelector('#main').appendChild(document.createElement("div"));
            // オブジェクトと同名のID付与
            document.querySelector('#main>div:last-of-type').id = id;

            const el = document.getElementById(id);
            el.textContent = arrows[arrowType][objects[id].t];
            el.style.position = 'absolute';
            el.style.left = (objects[id].x - size / 2) + '%';
            el.style.top = (objects[id].y - size) + '%';
            el.style.fontSize = size + 'vh';
            objects[id].el = el;
        }
    }

    // オブジェクト移動
    for(const id in objects) {
        const obj = objects[id];
        // オブジェクトのY座標を更新
        obj.y += obj.s;
        obj.s *= fallAcceleration;

        // Y座標を更新
        obj.el.style.top = (obj.y - size / 2) + '%';

        // 画面下部まで到達
        if(obj.y > 100) {
            // 消滅エフェクトオブジェクト作成
            mkEfObj(obj.x, 95);

            // 要素削除
            obj.el.parentNode.removeChild(obj.el);
            // オブジェクト削除
            delete objects[id];

            miss();
            continue;
        }

        // フリック入力有り
        if(inputDirection >= 0) {
            // 入力と同じ方向のオブジェクト発見
            if(obj.t === inputDirection) {
                let sp;
                //score
                score += (sp = 1 + Math.floor(100 - obj.y) * (1 + Math.floor(frame / 400)));
                document.getElementById('score').textContent = score;

                // 消滅エフェクトオブジェクト作成
                mkEfObj(obj.x, obj.y, sp);
                // 要素削除
                obj.el.parentNode.removeChild(obj.el);
                // オブジェクト削除
                delete objects[id];
                // 入力値をニュートラルに
                inputDirection = -1;
            }
        }
    }

    // ループを抜けても入力が残ったままならミス
    if(inputDirection >= 0) {
        inputDirection = -1;
        miss();
    }

    // 消滅エフェクトオブジェクトループ
    for(const id in efObjects) {
        const obj = efObjects[id];
        obj.x += obj.sx;
        obj.y += obj.sy;

        obj.sy += 0.04;

        // lifeが切れたら削除
        if(obj.life-- <= 0) {
            // 要素削除
            obj.el.parentNode.removeChild(obj.el);
            // オブジェクト削除
            delete efObjects[id];
            continue;
        }

        // 座標を更新
        obj.el.style.top = (obj.y - size / 8) + '%';
        obj.el.style.left = (obj.x - size / 8) + '%';
        if(!obj.score) obj.el.textContent = arrows[0][Math.floor(Math.random() * 4)];
    }

    ++frame;

    // 400フレームごとに間隔を短く
    if(frame % 400 === 0) {
        interval /= 1.03;
        if(interval < 5) interval = 5;
        // 30フレーム間隔を切った後は加速度もアップ
        if(interval < 30) {
            fallAcceleration *= 1.0025;
        }
        if(interval < 15 && frame % 1600 === 0) {
            if(loopNum < 3) loopNum++;
        }
    }
}

function miss() {
    if(left < 1) return;
    flash();
    left--;
    document.getElementById('left').textContent = '■'.repeat(left) + '□'.repeat(5-left);
    if(left === 0) {
        gameover();
    }
}

function flash() {
    document.getElementById('left').style.backgroundColor = '#888';
    setTimeout(function(){
        document.getElementById('left').style.backgroundColor = '';
    }, 50);
}

function gameover() {
    setTimeout(function(){
        if(intervalId) cancelAnimationFrame(intervalId);
        intervalId = 0;
        alert("GAME OVER\nSCORE:" + score);
    }, 200);
}

function mkEfObj(x, y, sp) {
    for(let i = 0; i < 8; ++i) {
        const s = (i === 0 && sp !== undefined) ? sp : 0;
        const id = '_ef' + idEfObjCount++;
        efObjects[id] = {}; // 子オブジェクト作成
        efObjects[id].x = x;
        efObjects[id].y = y;
        efObjects[id].sx = s ? 0 : (Math.random() * 3 - 1.5) / 2;
        efObjects[id].sy = s ? -0.5 : (Math.random() * 3 - 1.5) / 2;
        efObjects[id].life = 120; // オブジェクト寿命
        efObjects[id].score = s;

        // 作成した子オブジェクトに対応するエレメントを作成
        document.querySelector('#main').appendChild(document.createElement("div"));
        // オブジェクトと同名のID付与
        document.querySelector('#main>div:last-of-type').id = id;

        const el = document.getElementById(id);
        efObjects[id].el = el;
        el.style.position = 'absolute';
        el.textContent = s ? s : arrows[0][Math.floor(Math.random() * 4)];
        el.style.left = (efObjects[id].x - size / 2) + '%';
        el.style.top = (efObjects[id].y - size) + '%';
        el.style.fontSize = (size / 3) + 'vh';
        el.style.color = s ? '#f53' : `rgb(${255 - Math.floor(Math.random() * 256)},${255 - Math.floor(Math.random() * 256)},${255 - Math.floor(Math.random() * 256)})`;
    }
}
~~~

~~~css:style.css
#main {
    position: fixed;
    background: #f0ffff;
    width: 100%;
    height: 100%;
    top: 0px;
    left: 0px;
}
#top {
    position: fixed;
    background: linear-gradient(to bottom, #fff 50%, #fff0 100%);
    width: 100%;
    height: 10vh;
    top: 0px;
    left: 0px;
}
#score {
    color: #888;
    position: absolute;
    top: 2%;
    right: 5%;
    font-size: 3vh;
}
#left {
    color: #888;
    position: absolute;
    top: 2%;
    left: 10%;
    font-size: 3vh;
}
~~~