見出し画像

【簡単】JSで横スクロールゲーム

はじめに

みなさんこんにちは、ALH開発事業部のREIYAです。
今回はChomeでお馴染みこちらのdinoのような簡単な横スクロールゲームを作りましたので、紹介して見たいと思います。
5行でフラクタル図形のようにcanvasを用いたアニメーションとなっています。



canvasでアニメーション

方針はcanvasでアニメーションとしました。
キャラが横からスクロールしてくる障害物をジャンプで避けるゲームで、当たった場合はゲームオーバーとします。
必要な実装は

  • 自キャラの描画

  • ジャンプの動作

  • 障害物の描画

  • 障害物の横スクロール

  • 当たり判定

  • アニメーションの開始、中止

あたりです。

コード

早速ですが全量はこの様になっています。せっかくならコードゴルフしたいくらいの少なめなコード量です。
あまり綺麗じゃないのはあしからず...

<meta charset="utf-8">
<button id=S>Start</button>
<canvas id=C></canvas>
<script>
    // canvasの設定
    C.width = C.height = 500;
    C.style.border = "4px solid";
    const ctx = C.getContext`2d`;

    // アニメーションする関数
    let _stop = 1;
    const setAnimationFrame = (func, interval) => {
        let elapsed = quit = 0, time = Date.now();
        const update = () => {
            let delta = Date.now() - time;
            time = Date.now();
            elapsed += delta;
            if (elapsed >= interval) {
                elapsed -= ~~(elapsed);
                if (!_stop) func();
            }
            if (!quit) requestAnimationFrame(update);
        }
        try {
            update();
        } catch (e) {
            quit = 1;
            console.error(e);
            console.trace();
        }
        return {
            stop: () => _stop = 1,
            restart: () => _stop = 0
        }
    };

    // 操作キャラの設定
    let player = "😊", dead = "😇";
    const pLimUp = 100, pLimDown = 480;
    let px = 100, py = 480, dpy = -38;
    let isJump = 0;

    // ブロックの設定
    const oWidth = 50;
    const oLimUp = 200, oLimDown = 450;
    const rand = (min, max) => Math.random() * (max - min) + min;
    let oy1 = oy2 = 0; // 頂点y座標
    const ini_dx1 = 500, ini_dx2 = 500 - oWidth; // x座標初期値
    const dox = 20;
    let dx1 = ini_dx1, dx2 = ini_dx2; // x座標
    const createObj = () => {
        dx1 = ini_dx1, dx2 = ini_dx2;
        oy1 = rand(oLimUp, oLimDown), oy2 = rand(oLimUp, oLimDown);
        if (oy1 > oy2) [oy1, oy2] = [oy2, oy1]; // 右上がりにする
    };
    const dObj = () => {
        dx1 -= dox, dx2 -= dox;
    };
    const renderObj = () => {
        with (ctx) {
            beginPath();
            moveTo(dx1, oy1);
            lineTo(dx1, pLimDown);
            lineTo(dx2, pLimDown);
            lineTo(dx2, oy2);
            lineTo(dx1, oy1);
            stroke();
            fillStyle = "rgb(120, 120, 120, 0.8)"
            fill();
        }
    }

    let ini = 1, isClear = 1, isCreate = 0;

    const draw = () => {
        // 1. クリア
        ctx.clearRect(0, 0, C.width, C.height)
        // 2. 描画
        with (ctx) {
            font = `25px self`;
            fillText(player, px, py);
        }
        if (ini || !isCreate) {
            createObj();
            ini = 0;
            isCreate = 1;
        }
        renderObj();
        // 3. x座標が被るタイミングで当たり判定する
        if (dx2 <= px && px <= dx1) {
            const O = (oy1 - oy2) / (dx2 - dx1); // 障害物上辺の傾き
            const P = (oy2 - py) / (px - dx2); // 自分と障害物の左上頂点の傾き
            isClear = O <= P;
        }
        if (!isClear) {
            player = dead; // 当たった場合
            setTimeout(() => {
                stop();
                S.innerText = "Retry";
                // リトライ用に初期化する
                player = "😎"
                isClear = 1;
                ini = 1;
                py = pLimDown;
                isJump = 0;
            }, 150);
        }
        if (isClear && dx1 < 0) isCreate = 0; // 成功かつ障害物が枠外で次
        // 4. ずらす
        dObj();
        if (isJump) {
            py += dpy;
            if (py === pLimUp) dpy *= -1;
            if (py === pLimDown) dpy *= -1, isJump = 0;
        }
    };

    // スペースキーでジャンプ
    onkeydown = (e) => {
        if (e.key === " ") isJump = 1;
    };

    // アニメーション開始
    const {stop, restart} = setAnimationFrame(draw, 100);

    // 開始/停止ボタン
    S.onclick = () => {
        (_stop ? restart : stop)();
        S.innerText = _stop ? "Start" : "Stop";
        S.blur(); // スペースキーでボタンを押さない様にフォーカスを外す
    };
</script>

解説

canvasの作成

絵文字が文字化けるのでUTF-8必須なの注意です。
id=C、などとすることでgetElementByIdなどを省略できます。

<meta charset="utf-8">
<button id=S>Start</button>
<canvas id=C></canvas>
<script>
    // canvasの設定
    C.width = C.height = 500;
    C.style.border = "4px solid";
    const ctx = C.getContext`2d`;

アニメーションのための関数

canvasをクリア→基本的にはcanvasに描画→すこしズラす→クリア→描画→ ...
を適度な秒数だけ遅延しながら実行することで、パラパラ漫画のようにアニメーションさせます。
setIntervalでも問題なく動作しますが、途中停止や処理の負荷などの観点からrequestAnimationFrameの使用が好ましいです。
以下は描画をする関数とインターバル(ms)を引数にとってアニメーションさせる使い方の例です。

intervalのミリ秒ごとにfunc関数を実行します。戻り値のstop関数やrestart関数によってフラグを見て、アニメーションの停止や再開ができます。
ちなみに「~~(elapsed)」ですが
「 ~ 」を2個つけることで数値に変換しています。今回はなくても動きます。

    // アニメーションする関数
    let _stop = 1;
    const setAnimationFrame = (func, interval) => {
        let elapsed = quit = 0, time = Date.now();
        const update = () => {
            let delta = Date.now() - time;
            time = Date.now();
            elapsed += delta;
            if (elapsed >= interval) {
                elapsed -= ~~(elapsed);
                if (!_stop) func();
            }
            if (!quit) requestAnimationFrame(update);
        }
        try {
            update();
        } catch (e) {
            quit = 1;
            console.error(e);
            console.trace();
        }
        return {
            stop: () => _stop = 1,
            restart: () => _stop = 0
        }
    };

操作キャラの設定

キャラを絵文字で、ゲームオーバー時の絵文字も用意しておきます。
canvasは左上が原点なので、y軸について数字が大きいほど下であることに注意します。

pLimUpはplayer Limit Upのイメージで、ジャンプ時の高さ上限。同様にpLimDownは着地時の高さ = 通常時の高さになります。

x座標は固定でpx = 100のまま。py = 480について、差分のdpy = -38の分だけ徐々に移動させる設定としました。
これにより、y軸にそって480 -> 100に向けて38ずつ移動、とすることでジャンプを表現します。

また、ボタンを押したらジャンプとしたいので、ジャンプを開始すべきかどうかのフラグを用意しておきます。

    // 操作キャラの設定
    let player = "😊", dead = "😇";
    const pLimUp = 100, pLimDown = 480;
    let px = 100, py = 480, dpy = -38;
    let isJump = 0;

障害物の設定

ここが一番めんどうです。
まずはブロックの横幅を50固定にしちゃいます。この辺は簡易的なゲームなので...

また、高さをランダムで決めたいので、200 ~ 450までの間の疑似乱数を作るため
乱数幅を設定しておきました。

    // ブロックの設定
    const oWidth = 50;
    const oLimUp = 200, oLimDown = 450;

範囲内で擬似乱数を作る関数を用意

    const rand = (min, max) => Math.random() * (max - min) + min;

さて、サンプルのGifにもある通り障害物はこういった形をイメージしました。

このうち、底辺あたりは地面に接着していないとジャンプでかわす必要がでてこないので
底辺は固定となります。

上側の2頂点について、横移動、つまりx座標は
時間の経過に伴い変わっていくので、特に考える必要はなく

2つのy座標によって、どれくらい尖った図形になるかが変わります。

よって頂点y座標を2つ持ち、createObj関数で擬似乱数で決定します。

    let oy1 = oy2 = 0; // 頂点y座標
    const ini_dx1 = 500, ini_dx2 = 500 - oWidth; // x座標初期値
    const dox = 20;
    let dx1 = ini_dx1, dx2 = ini_dx2; // x座標
    const createObj = () => {
        dx1 = ini_dx1, dx2 = ini_dx2;
        oy1 = rand(oLimUp, oLimDown), oy2 = rand(oLimUp, oLimDown);
        if (oy1 > oy2) [oy1, oy2] = [oy2, oy1]; // 右上がりにする
    };

また、障害物の横移動は前述のとおり時間の経過によって一定数左向きに進めればいいので
dObj関数のようになり

    const dObj = () => {
        dx1 -= dox, dx2 -= dox;
    };

描画も簡単で、canvasのbeginPathから初めて
4頂点を順にめぐりながら線を引きます。
ついでに図形に色を塗ります。キャラが被ったときに見にくいのでopacityを0.8にしてみました。

    const renderObj = () => {
        with (ctx) {
            beginPath();
            moveTo(dx1, oy1);
            lineTo(dx1, pLimDown);
            lineTo(dx2, pLimDown);
            lineTo(dx2, oy2);
            lineTo(dx1, oy1);
            stroke();
            fillStyle = "rgb(120, 120, 120, 0.8)"
            fill();
        }
    }

描画

いよいよ描画です。
requestAnimationFrameに任せるための、パラパラ漫画の1枚分を関数で作ります。
コード内のコメントで説明していきます。

    // ini: ゲーム開始直後、isClearが1だと無限に1️⃣のif文を通ってしまうためフラグを設けました
    let ini = 1, isClear = 1, isCreate = 0;
    // isClear: 障害物を飛び越えたら1, 失敗で0 = ゲームオーバー
    // isCreate: 障害物を作ったら1にする。0になったら新しい障害物作る

    const draw = () => {
        // 1. クリア
        ctx.clearRect(0, 0, C.width, C.height)
        // 2. 描画
        with (ctx) {
            font = `25px self`; // これでプレイヤーキャラを描画
            fillText(player, px, py);
        }
        if (ini || !isCreate) { // 1️⃣
            createObj(); // 疑似乱数を用い障害物を作る
            ini = 0;
            isCreate = 1;
        }
        renderObj(); // 障害物を描画する、色とか塗ったりしてたやつです

        // 3. x座標が被るタイミングで当たり判定する
        // ⭐️ 障害物の幅50の中にプレイヤーがいた場合に
        // ⭐️ 障害物の上のナナメの辺と、プレイヤーを比較しています
        if (dx2 <= px && px <= dx1) {
            const O = (oy1 - oy2) / (dx2 - dx1); // 障害物上辺の傾き
            const P = (oy2 - py) / (px - dx2); // 自分と障害物の左上頂点の傾き
            isClear = O <= P;
        }
        if (!isClear) {
            player = dead; // 当たった場合
            setTimeout(() => {
                stop(); // requestAnimationFrameのstop

                S.innerText = "Retry"; // ボタンの文字変えてみた
                // リトライ用に初期化する
                player = "😎"

                isClear = 1; // ここと
                ini = 1;     // ここで、フラグを初期状態に。

                py = pLimDown; // ジャンプ中だったとしても地面からにする
                isJump = 0; // ジャンプ中だったとしても地面からにする
            }, 150);
        }
        if (isClear && dx1 < 0) isCreate = 0; // 成功かつ障害物が枠外で次
        // 4. ずらす
        dObj(); // 障害物をスライドさせる
        if (isJump) {
            py += dpy; // ジャンプ中であればキャラを縦にずらす
            // ジャンプの頂点まで行った場合、下向きに移動していく
            if (py === pLimUp) dpy *= -1;
            // 下向きに移動していて地面に着地した場合、ジャンプ終了
            if (py === pLimDown) dpy *= -1, isJump = 0;
        }
    };

操作系をつける

スペースキーを押した時のイベントと
ボタンのクリックイベントをつけます。

「(_stop ? restart : stop)();」は
_stopの結果で三項演算子したものが関数なので、そのまま実行しています。

    // スペースキーでジャンプ
    onkeydown = (e) => {
        if (e.key === " ") isJump = 1;
    };

    // アニメーション開始
    const {stop, restart} = setAnimationFrame(draw, 100);

    // 開始/停止ボタン
    S.onclick = () => {
        (_stop ? restart : stop)();
        S.innerText = _stop ? "Start" : "Stop";
        S.blur(); // スペースキーでボタンを押さない様にフォーカスを外す
    };

これらによって、

  • 横向きに障害物がスライドしてきて

  • 障害物の幅内にキャラがいる間

  • 障害物の上辺より下にいた場合にゲーム終了

  • 飛び越えてた場合、障害物がcanvas枠から出たら次の障害物を作成

というフレームを動かしながら描画できました。
また、ゲームオーバー時はボタンがRetryに変わり
リトライボタンでまたゲーム開始となります。

当たり判定について

当たり判定も本当にガッツリやるなら

  • 障害物を線でなく図形オブジェクトとして作成する

  • 自キャラの判定を点でなく四角にする

  • 2つの図形において重複する部分があるかどうか、すなわち

の間での判定(今回とほぼ同様)を

というような反映を実装すればよいです。
なおこれはキャラの動きが縦だけなのでこうなっていますが

キャラも障害物も縦横無尽に動く場合、前提とした「x軸方向に交わっている間」という判定期間が単調ではないため成り立たず
しっかりと2つの図形が重なっているかどうかを確認する必要があります。

円であれば中心からの距離などで簡単に判定できますが、多角形同士の判定となるとやや面倒になるので
今回はあくまでcanvas上で簡単に作成できる範囲として、障害物を四角形固定としました。

ジャンプ中の上下移動について

今回は時間の経過に伴い、常に一定の距離だけ移動するようにしましたね
すなわち等速直線運動のため、やや見た目に違和感がありませんか?
上下移動、すなわちジャンプでは通常、重力や抵抗による減速の結果、停止してから下向きに加速します。

時間の経過にともない、dy自体も変化させることによってこれを実現できます。
すなわち等速でなく、時間の経過によって加速度を変動させることで、上にいくほど緩やかな上昇になる様子を再現できます。
(つまり物理演算ということ。)
今回は割愛します。

さいごに

今回は移動と当たり判定を、簡単な方法で実装してみました。
スコアだったり背景の装飾など、もっと凝った作りにしてみるとより面白いと思いますので
ぜひお手元の環境であそんでみてください。