ライブラリを使わず、HTML,CSS,JSのDOM操作でブロック崩しゲームを作る

まえがき

近年のWeb業界はたくさんのフレームワーク、ライブラリの上に成り立っています。それに伴い、作業環境もどんどん複雑になっていきました。

プログラミング初心者が容易に手が出せない状況だなあと危機感を感じております。「何から学んだらよいでしょう!」と言われても、「何から教えようかなあ」とついつい頭を抱えてしまう現状があります。

昔よりもはるかに制作環境が多様化したため、新規環境を整えるのにも一苦労してしまいます

そう、天高く昇るモジュールで積み上げられた塔はまさしくバベルの塔。

そして盛者必衰のコトワリのごとく、どんなに栄えたライブラリも数年も経てば神のいかづちを受けてバラバラに解体していくものなのです。

僕たちはいま一度!何のライブラリにも依存しない、プレーンな世界の草原を駆け回らなければならない!

ということで、今回はjQueryさえ使わず、HTMLのDOM操作のみでブロック崩しゲームを作ります。

画像も使いません。デザインはCSSでやります。CSSも頑張ったら球やブロックのデザインくらいはできてしまうものです。

初心者の方でもわかるように基本的な部分から説明を書きます。

どんなゲームを作るの?

今回は、以下デザインのブロック崩しを制作します。

画像やライブラリは一切利用しないで、あくまでHTML,CSS,JSのみを使用してこのブロック崩しを制作します。

バーやブロックなどのデザインはHTMLとCSSを用いて制作します。

球が動く処理やバーが動く処理、ゲーム進行処理はJSで書いていきます。

それでは、これからHTMLとCSSを書いてブロック崩しのデザインを作成していきたいのですが、ここで基礎知識としてHTMLの重要概念であるDOMについて説明します。

DOM操作

HTMLを見ると<div class=”caption”>うんち</div> というタグでくくられたコードを見るかと思います。

このように、タグを用いて文書構造を構築するデータ表現のことをDOM(Document Object Model)といいます。

上のコードの意味は、「うんち」という単語をひとまとまりにまとめたオブジェクトがあるよ、と説明しているのですね。このオブジェクトをDOMと呼びます。

この「うんち」DOMのデザインを決める言語のことをCSSといいます。

また、「うんち」DOMをJavaScriptを用いて操作する方法をDOM操作と呼びます。

まあ、難しいことは考えずにタグで囲まれた文章がDOMになるんだなあと思ってください。次からはHTMLとCSSを用いてブロック崩しのデザインを作っていきます。

 

HTMLとCSSを組んでみよう

HTMLを書く前に今回作るブロック崩しの部品に一つ一つ名前をつけていきましょう。

・ゲーム画面となる「display

・ディスプレイ内に表示される、「ball、block、bar、explain-label

・バーを左右に動かすための「left-button」「right-button

HTMLでこの構造を書くとこんな感じになります。

class名に上で付けた名前を当てはめていきましょう。

<div class="display">
        <div class="block"></div>
        <div class="bar"></div>
        <div class="ball"></div>
        <div class="explain-label">click to start</div>
        <div class="left-button">
            <div>←</div>
        </div>
        <div class="right-button">
            <div>→</div>
        </div>
    </div>

先ほどつけた名前をclass名として、タグを作っただけですね。

このHTMLを表示すると、こんな感じになります。

ただ文字が映っているだけで、これではブロック崩しのように見えません。

これからこのHTMLにCSSでデザインを加えていくことで、ブロック崩しっぽい画面を作ることができます。

例えば球を打ち返すbarのCSSはこんな感じで書きます。

.bar {
    width: 60px;
    height: 20px;
    background: #99ff99;
}

このCSSを適用すると、barのDOMはこんな感じで表示されます。

DOMは基本的に長方形で表されます。この長方形に横縦の大きさと背景色を指定してみます。すると、こんな感じでbarが表示されるようになりました。

blockもお化粧していきましょう。

.block {
    box-sizing: border-box;
    width: 40px;
    height: 20px;
    border-radius: 4px;
    border: 2px solid #ffffff;
    background: #339999;
}

borderで2pxの白色を指定するとおしゃれに見えますね。border-radiusで角に丸みを持たせるとさらにおしゃれ感がアップします。

なぜかはわかりませんが、枠線を引いて角を丸くするだけでおしゃれに見えてしまうものです。

もしかしたら人の脳裏には、小さいころに遊んだドラクエやFFのメニューウィンドウが常に焼き付いていて、その記憶と共鳴して親近感がわいてしまうのかもしれません。

こんな感じでCSSでお化粧していきます。

各部品のデザインを設定した後は、部品の配置位置を指定していきます。これもCSSを用いて行うことができます。

例えば、blockの座標をディスプレイの左上から100px,50pxの位置に表示する時は以下のようにCSSを書きます。

.block {
    position:absolute;
    left:100px;
    top:50px;
}

今回、displayの左上を基準座標として、position:absoluteを用いて球やバーの位置を指定しています。そのためにHTMLのdisplayタグ内にballやbarなどのdom要素を書きました。

position:absoluteで指定したCSSにleftにx座標,topにy座標を入力する事で、DOMの表示座標をを指定することができます。

この座標系は親要素であるdisplayのpositionにfixed以外の値を指定すると有効になります。逆にpositionを指定しないとウィンドウの左上が基準座標になってしまうので注意してください。

これを他のDOM要素にも適用すると、HTMLがこんな感じでデザインされて表示されるようになりました。

見違えるようにブロック崩しらしくなりましたね。

実際に動いているコードをお見せします。以下リンクからご確認ください。

https://codepen.io/hothukurou/pen/xxELOYd

 

※ゲーム画面がウィンドウで途切れて見辛かったら以下のようにドラッグして引っ張り上げてください。

さて、いよいよ次からJavaScriptを用いてDOM操作部分を制作していきます。

JavaScriptでDOM操作を行う

いよいよ、HTMLで書いたオブジェクトを変更するDOM操作を行っていきます。

このDOM操作により、先ほど制作したballやbarのDOMを動かしていきます。

DOM動作では、HTML上に存在するDOM要素を取得して、そのDOM要素を編集していきます。

class名からHTML上のDOMを取得する

html上のDOM要素をclass名から取得します。こんな感じで書きます。

// 初期から存在するDOMの定義
const displayDom = document.getElementsByClassName("display")[0];
const barDom = document.getElementsByClassName("bar")[0];
const ballDom = document.getElementsByClassName("ball")[0];
const leftButtonDom = document.getElementsByClassName("left-button")[0];
const rightButtonDom = document.getElementsByClassName("right-button")[0];
const explainLabelDom = document.getElementsByClassName("explain-label")[0];

末尾に[0]と書いている理由は、getElementsByClassNameの返り値が「同じclass名のDOMを配列にしたもの」だからです。

今回、ブロックのような動的に追加するdom以外で同じ名前のclass名はhtmlに書かないと決めているので、配列の先頭[0]に目的のDOMが入っています。

ここでは、block以外のdom要素をあらかじめHTML上に配置して、DOMを取得することにしました。

blockのdom要素は大量生産して配置する予定なので、HTML内には書かずに、ゲーム開始時にJSで大量に新規生成してdisplay内に貼り付けていきます。

DOM操作系関数を作る

DOM操作を行う関数を作っていきます。以下機能を実現する関数を作っていきます。

・DOMを新規生成

・親DOMを指定して子DOMを表示、削除

ブロック崩しのblockのDOM要素を新規に生成してdisplayのDOM要素に貼り付けるために使用します。

また、ブロックが消えるときにはdisplayからblockのDOM要素を削除する処理を書きます。

/**
 * class名をつけてdomオブジェクトを生成する
 */
const createDom = (className) => {
        const dom = document.createElement('div');
        dom.classList.add(className);
        return dom;
    }
    /**
     * 指定した親domオブジェクト内に子domオブジェクトを表示する
     */
const addChildDom = (parentDom, childDom) => {
        parentDom.append(childDom);
    }
    /**
     * 指定した親domオブジェクト内にある、子domオブジェクトを削除する
     */
const removeChildDom = (parentDom, childDom) => {
    parentDom.removeChild(childDom);
}

・DOMの現在座標を取得、変更

ballやbarのDOM要素の表示位置を変更するために使用します。

CSSのleft,topの値を書き換える処理を書きます。

コードはこんな感じで書きます。dom要素のcssは dom.styleプロパティを操作することでアクセス可能です。

/**
 * domオブジェクトの現在位置を設定する
 */
const setDomPosition = (dom, pos) => {
        dom.style.left = `${pos.x}px`;
        dom.style.top = `${pos.y}px`;
    }
    /**
     * domオブジェクトの現在位置を取得する。
     */
const getDomPosition = (dom) => {
    const pos = {
        x: parseFloat(dom.style.left),
        y: parseFloat(dom.style.top)
    };
    return pos;
}

・DOMの中身を変更する。

click to start!テキストをゲーム開始後に変更するために使用します。

/**
 * domオブジェクト内のコンテンツを変更する
 */
const changeDomTextContent = (dom, textContent) => {
    dom.textContent = textContent;
}

さて次にマウスクリックの入力イベントハンドラを設定していきます。

イベントハンドラを設定する

イベントハンドラとは「外部入力により発動する関数」のことです。

今回は「左右ボタンクリックで移動」したり、「ディスプレイクリックでゲーム開始」したりします。

実際に書いてみました。マウスダウン・アップなどのクリックイベントで以下の関数を呼び出すようにしています。関数の中身は後で考えます。

・leftButtonPress

←ボタンをクリックしたら発動する関数です。

バーが左に動き出します。

・leftButtonRelease

←ボタンを離した時、またはボタンからマウスが離れたら発動します。

バーが停止します。

・rightButtonPress

→ボタンをクリックしたら発動する関数です。

バーが右に動き出します。

・rightButtonRelease

→ボタンを離した時、またはボタンからマウスが離れたら発動します。

バーが停止します。

・displayRelease (ディスプレイクリックしてゲーム開始する関数)

ディスプレイをクリックしたとき(離したとき)に発動します。

ゲームが開始してボールやバーが動き出します。

/**
 * 入力系
 */
// MEMO: PCとスマホでタッチにベントが異なるので複数作成する 
//       実はPC・スマホ両方使えるpointerdown/pointerupというイベントがあるのだが、古いsafariがこれで動作しないので不採用
//       あと数年たったら死滅すると思われるのでpointer系のイベントが使えるようになるはず
// PC用
leftButtonDom.addEventListener("mousedown", leftButtonPress, false);
leftButtonDom.addEventListener("mouseup", leftButtonRelease, false);
leftButtonDom.addEventListener("mouseout", leftButtonRelease, false);
rightButtonDom.addEventListener("mousedown", rightButtonPress, false);
rightButtonDom.addEventListener("mouseup", rightButtonRelease, false);
rightButtonDom.addEventListener("mouseout", rightButtonRelease, false);
displayDom.addEventListener("mouseup", displayRelease, false);


// スマホ用
leftButtonDom.addEventListener("touchstart", leftButtonPress, false);
leftButtonDom.addEventListener("touchend", leftButtonRelease, false);
rightButtonDom.addEventListener("touchstart", rightButtonPress, false);
rightButtonDom.addEventListener("touchend", rightButtonRelease, false);
displayDom.addEventListener("touchend", displayRelease, false);

面倒なことにスマホのタッチイベントとPCのクリックイベントは異なるイベントハンドラで取得しなければいけません。

一応、スマホとPCの両方で対応可能な「pointerdown/pointerup」というイベントがあるのですが、このイベントは「iOS13(Safari13)より前のSafariでは未対応」なので、たくさんの人に遊んでもらうためには使用することができません。

あと数年たてば、iOS13環境は全滅するかと思うので今は辛抱の時機です。Web業界はこのように「一部のブラウザが調子に乗って一部仕様が未実装」であることが日常茶飯事です。耐えましょう。

さて、上記イベントハンドラによりゲームの入力部分は完成しました。

次に初期設定関数を作成します。

初期設定関数を作る

ゲーム開始時の状態に整えるための関数init()を作成します。

初期設定時に必要なことは以下となります。

・ブロックの生成配置

・ボールやバーの初期位置設定。移動方向設定。

・その他変数を初期状態に戻す。

この初期化関数を作る際に、状態によって変化しうる変数を定義しましょう。

状態によって変化する変数は「少なければ少ないほどわかりやすくて、バグが減る」ので、なるべくならばつくりたくないですが、なんだかんだで必要になってしまうので一か所にまとめて定義しましょう。

(値が変動して、結果に影響を及ぼす変数のことを、副作用を持つ変数と呼びます。こういった変数はない方がバグが少なくなります。)

// 副作用を持つ状態変数の定義
let blockDoms = []; // ブロックDomを保存する配列
let barLeftSpeed = 0; // 毎フレーム移動する左移動量
let barRightSpeed = 0; // 毎フレーム移動する右移動量
let ballVec = { x: 5, y: 5 }; // 球の移動ベクトル
let gameMode = "startWait"; // "startWait"|"gamePlaying"|"clear"のどれかになるゲームの状態

そして、この変数と画面状態を初期化する関数init()配下のように定義します。

// 初期化関数
const init = () => {
    // 状態の初期化
    gameMode = "startWait";

    changeDomTextContent(explainLabelDom, "click to start");

    // 静的な定義DOMの座標定義
    setDomPosition(barDom, { x: 175, y: 410 });
    setDomPosition(leftButtonDom, { x: 50, y: 440 });
    setDomPosition(rightButtonDom, { x: 250, y: 440 });
    setDomPosition(ballDom, { x: 190, y: 300 });
    setDomPosition(explainLabelDom, { x: 0, y: 350 });

    // ボール向き定義
    ballVec = { x: 3, y: -3 };


    // 画面にブロックが残っていれば、全部削除してblockDomsを空にする
    while (blockDoms.length > 0) {
        removeChildDom(displayDom, blockDoms.pop());
    }


    //ブロック生成 (w:20px h:10px)
    for (let xIndex = 0; xIndex < 10; xIndex++) {
        for (let yIndex = 0; yIndex < 10; yIndex++) {
            const blockDom = createDom("block");
            setDomPosition(blockDom, { x: xIndex * 40, y: yIndex * 20 });
            addChildDom(displayDom, blockDom);
            blockDoms.push(blockDom);
        }
    }
}

ブロック生成で早速前の項目で作成したdom操作系の関数を利用しています。

for文使って、ブロックを敷き詰めていますね。実際このプログラムでブロックは以下のように表示されるようになります。

また、ここで変数を一通り設定したことで、「イベントハンドラで発動する関数」を書くことができます。基本的にものすごくシンプルです。

/**
 * 入力イベント
 */
const leftButtonPress = () => {
    barLeftSpeed = 5; //毎フレームの移動量
}

const leftButtonRelease = () => {
    barLeftSpeed = 0; //毎フレームの移動量
}

const rightButtonPress = () => {
    barRightSpeed = 5; //毎フレームの移動量
}

const rightButtonRelease = () => {
    barRightSpeed = 0; //毎フレームの移動量
}

const displayRelease = () => {
    if (gameMode === "startWait") {
        changeDomTextContent(explainLabelDom, "break all blocks!");
        gameMode = "gamePlaying";
    }
}

次の項で説明する「メインループ関数」にて、バーの位置を「barRightSpeed-barLeftSpeed」だけ移動することで毎フレームのバー移動を実現しようと考えています。

では、ゲームのメインループ関数を作成していきましょう。

メインループ関数を作る

メインループ関数とは、毎フレーム実行される関数のことです。

ボールやバーは毎フレームDOMの位置をずらすことで、動いていることを表現します。

またこのメインループ内で球、ブロック、バーの当たり判定を書きます。

プレーンなJSでメインループを作成する時は以下の書き方が主流です。

/**
 * メインループイベント
 */
const mainLoop = () => {
    //ここにメインループ処理を書く
    window.requestAnimationFrame(mainLoop); // mainLoopを再度呼ぶ
};

window.requestAnimationFrame(mainLoop); //mainLoop開始指令

requestAnimationFrameは描画可能な状態なら、指定した関数を実行する命令です。この中に書かれた命令は毎フレーム実行される命令となります。

このループ関数はゲーム開始中のみ毎フレーム呼ばれるようにします。

具体的にはこんな感じで書いていきます。

/**
 * メインループイベント
 */
const mainLoop = () => {
    switch (gameMode) {
        case "gamePlaying":
            {
                // gameModeがgamePlaying中のみ実行する
                window.requestAnimationFrame(mainLoop); // mainLoopを再度呼ぶ
                break;
            }
    }
}

さて、このメインループに当たり判定や毎フレームの移動指令を書いていきましょう。

バーの移動処理

// バーの操作
const barPos = getDomPosition(barDom);
const barMovedPos = {
    x: barPos.x + barRightSpeed - barLeftSpeed,
    y: barPos.y
};
if (barMovedPos.x >= 0 && barMovedPos.x <= 340) {
    setDomPosition(barDom, barMovedPos);
}

毎フレームごとのバーの操作です。

バーの座標値にbarRightSpeed(右ボタン押している間5になる)とbarLeftSpeed(左ボタン押している間5になる) を加算しています。

これで、右キーを押すとx方向に+5移動するし左キーを押すとx方向に-5移動しますね。

ボールの移動処理

// ボール移動
const ballPos = getDomPosition(ballDom);
let ballMovedPos = {
    x: ballPos.x + ballVec.x,
    y: ballPos.y + ballVec.y
};
if (ballMovedPos.x > 390) {
    ballMovedPos.x = 390;
    ballVec.x = -ballVec.x;
}
if (ballMovedPos.x < 0) {
    ballMovedPos.x = 0;
    ballVec.x = -ballVec.x;
}
if (ballMovedPos.y < 0) {
    ballMovedPos.y = 0;
    ballVec.y = -ballVec.y;
}
if (ballMovedPos.y > 510) {
    init(); // ボールが画面下にいってしまうとゲームオーバーなので初期化する
} else {
    setDomPosition(ballDom, ballMovedPos);
}

毎フレームのボール移動です。ballVec.x,ballVec.yは毎フレーム移動方向です。

ボールDOMの座標値を毎フレーム取得して、ballVecを加算したballMovedPosを生成します。

この時、画面端ならば移動方向を反転します。

左右の壁にぶつかればballVec.xを反転させますし、上の壁にぶつかればballVec.yを反転させます。

画面下に行ってしまったらゲームオーバーなので初期化関数init()を呼びます。

ゲームオーバーにならなければballの座標値を移動します。

バーと球の衝突処理

// バーと球の衝突処理
if (checkCollision({ x: barPos.x, y: barPos.y, width: 60, height: 20 }, { x: ballMovedPos.x, y: ballMovedPos.y, width: 10, height: 10 })) {
    ballMovedPos.y = barDom.y;
    ballVec.y = -ballVec.y;
}

checkCollisionという「長方形同士の衝突判定を調べる」関数を作り、これを用いてbarとballの矩形が衝突したか判定して、衝突していたらballの向きを反転させます。

ちなみにcheckCollision関数はこんな感じで作りました。

長方形同士の衝突判定は今後ゲーム制作を続けていけば嫌というほど使うことがあるものです。印刷して神棚に飾っておきましょう。

const checkCollision = (rect1, rect2) => {
    if ((rect1.x + rect1.width) >= rect2.x && rect1.x <= (rect2.x + rect2.width) && (rect1.y + rect1.height) >= rect2.y && rect1.y <= (rect2.y + rect2.height)) {
        return true;
    }
    return false;
}

バーとブロックの衝突処理

// ブロックとの衝突処理 
for (const blockDom of blockDoms) {
    const blockPos = getDomPosition(blockDom);
    if (checkCollision({ x: blockPos.x, y: blockPos.y, width: 40, height: 20 }, { x: ballMovedPos.x, y: ballMovedPos.y, width: 10, height: 10 })) {
        removeChildDom(displayDom, blockDom);
        blockDoms = blockDoms.filter((dom => { return dom != blockDom; }));
        ballVec.y = -ballVec.y;
    }
} // ブロックが全てなくなったか調べる 
if (blockDoms.length === 0) {
    gameMode = "clear";
    changeDomTextContent(explainLabelDom, "CLEAR!");
}

blockのDOM生成時にblockDoms配列にblockを格納していたのですが、このblockDomsとballの衝突判定を毎フレーム行っています。

もし衝突していれば、衝突したblockDomをdisplayから削除して画面上から消すとともに、blockDoms配列から削除します。

blockDoms配列から削除する時のよくある書き方として、今回は以下のようにfilter関数を利用することにしました。

衝突したblockDomをfilterで外した後の残りのblockDoms配列を新しいblockDomsとして代入する処理を書いています。

blockDoms = blockDoms.filter((dom => {
    return dom != blockDom;
}));

この書き方は衝突判定では頻出する処理なので覚えておいて損はないです。これも印刷してトイレの壁に貼っておきましょう。

以上でメインループ処理の説明を終わります。

理解が追い付かない方も実際に動くコードを見れば、雰囲気から理解力も段違いに向上するものなので、さっそく実装結果を見てみましょう。

完成したプログラム

実際に以下のページから遊んでみましょう。

https://codepen.io/hothukurou/pen/wvzqWQb

DOM操作とは思えない程、意外とサクサク動いていますね。

こうやって見ると、ライブラリを使わなくても案外ゲーム制作はできそうだなと思えてくるのではないでしょうか。CSSだけで作ったシンプルなデザインは結構スタイリッシュに見えるもので、好印象なんじゃないかと思います。

・・・とはいえ、やっぱりライブラリを作らないと色々めんどくさいのも事実ではあります。

HTML,CSS,JSの基本的機能を今回の記事で触りだけでも学んで頂けたのなら、それらを利用したもっと発展的なライブラリを使いこなしやすくなるかと思います。

ちなみに、codepenではなく実際のブラウザで動かしたい方は以下のgithubからコードをcloneなりダウンロードなりしてください。

codepen上では省略していましたが、実際のファイルではhtmlからcss,jsファイルを呼び出している点と、window.onloadイベントによりjsコードを実行している点がミソです。よくある書き方なので参考にしてみてください。

実際のコード

https://github.com/hothukurou/breakout_with_dom

今回の記事を読んでわからないこと、不明な点、間違いなどありましたらコメントを頂けますと幸いです。

以上です。最後まで読んでいただきありがとうございました。