【JavaScript】テトリスを作ってみよう!基礎から実装手順まで初心者向けに解説
はじめに
皆さんは、JavaScriptを使ってゲームを作ったことはありますか。
普段はWebサイトやアプリのフロントエンドを構築するときにJavaScriptを使うことが多いかもしれませんが、実はシンプルなゲームを作るうえでも活躍します。
今回は、テトリス という有名なパズルゲームをJavaScriptで実装する手順を、初心者の方にも理解しやすいように解説していきます。
テトリスは、ブロック(いわゆるテトリミノ)を積み上げてラインを揃えるゲームです。
ゲームループや衝突判定、ライン消去といった要素を扱うので、プログラミングの基礎をバランスよく身につける ことができます。
また、2次元配列の操作やイベントリスナーの使い方など、実務でも応用できる技術がたくさん詰まっています。
本記事では、テトリスの仕組みを分解し、JavaScriptを用いた実装の流れを具体的に説明していきます。
初心者向けに解説しているので、複雑な専門用語は極力かみ砕いて説明します。
「テトリスのルールは知っているけど、コードは初めて書く」という方でも読み進められるよう構成しています。
この記事を読むとわかること
- JavaScriptでテトリスを作る全体的な流れ
- テトリミノの動きや衝突判定のしくみ
- ブロックを消去するロジックや得点計算の方法
- 実務で役立つ2次元配列の扱い方やイベントリスナーの使い方
JavaScriptでテトリスを作る全体像
テトリスは単純に見えて、いくつかの要素が組み合わさっています。
ブロック(テトリミノ)を定義して、ランダムに登場させる。
プレイヤーの入力によってブロックを左右に動かしたり回転させたりして、積み上げる場所を選ぶ。
ラインが揃ったらブロックを消し、得点を追加する。
画面がブロックで埋まったらゲームオーバー。
実際にはこういった流れを一連のループ(ゲームループ)で管理し、リアルタイムに画面を更新していきます。
テトリスの基本ルールを整理する
テトリスでは、7種類のテトリミノ があります。
それぞれ形が違い、例えばI字型やT字型、正方形(O字型)などがあります。
これらをランダム順に登場させ、フィールド(盤面)に落とします。
プレイヤーが操作できるのは、左右移動、回転、ソフトドロップ(ゆっくり落下)やハードドロップ(即時落下)のようなアクションです。
ブロックが下まで落ちると、盤面に固定されます。
固定されたブロックによって横一列がすべて埋まった場合はラインが消え、上に積んでいたブロックが下へ降りてきます。
ラインを消すと同時に得点を加算することで、スコアを競う仕組みです。
また、ブロックが盤面の一番上を超えてしまうとゲームオーバー。
この一連の流れがテトリスの根幹となる部分です。
ゲームループとは何か
ゲームループは、定期的に画面を更新してゲーム内の状態を管理するための仕組み です。
一般的に、JavaScriptでゲームループを実装するときは requestAnimationFrame()
や setInterval()
を使う方法があります。
requestAnimationFrame()
はブラウザ側でフレーム単位のタイミング調整をしてくれるので、アニメーションが比較的滑らかに動くというメリットがあります。
一方で setInterval()
は一定時間ごとに処理を走らせるシンプルな方法ですが、フレームレートの制御などを柔軟にしたい場合は工夫が必要です。
テトリスの場合、非常に高いフレームレートで動かす必要はありません。
落下スピードはレベルによって異なるものの、1秒間に何十回も更新が必要というほどではないため、シンプルに setInterval()
を使っても問題ないケースが多いです。
ただし後ほど「操作感を重視したい」「一定以上の滑らかさを確保したい」といった要望が出てきたら、 requestAnimationFrame()
に切り替えることも検討できます。
実務に活かせる視点
テトリスを通じて学べるのは、画面を動的に更新するスキル や イベントのハンドリング、そして 配列を用いた情報管理 です。
たとえば、ウェブアプリのダッシュボードでチャートや数値をリアルタイムに更新する場面を想像してください。
テトリスのゲームループで定期的に状態を更新するロジックは、そういったUI更新処理にも応用できます。
また、ユーザーがキー入力したときに瞬時に画面を変えるという部分は、フォーム入力やボタン操作など、ユーザーインタラクションへの即時反応 が求められる開発でも大いに役立ちます。
さらに、ブロックの位置や状態を2次元配列で管理する方法は、複雑なデータ構造をシンプルに扱う ノウハウとして役立つでしょう。
テトリスの盤面とブロックを管理する
テトリスの実装には、 盤面 (フィールド) をどう表現するか と 落下するブロック (テトリミノ) の定義 が欠かせません。 ここでは盤面を2次元配列で持ち、テトリミノは形状ごとに配列で定義する方法を紹介します。
盤面(フィールド)の作り方
フィールドは横10マス、縦20マス程度が一般的です。
ここでは縦横いずれも固定サイズにし、幅を10、高さを20 と仮定しましょう。
// フィールドサイズ const COLS = 10; const ROWS = 20; // フィールドを2次元配列として初期化 let field = []; for (let r = 0; r < ROWS; r++) { field[r] = []; for (let c = 0; c < COLS; c++) { field[r][c] = 0; // 空のマスを0とする } }
このように、行(row)と列(col)を使ってフィールドを初期化します。
field[r][c]
に 0 が入っている場合は、まだブロックが置かれていないという意味です。
一方、ブロックが置かれたマスには 1 や 2 など、異なる数字を入れて区別することがよくあります。
テトリミノ(ブロック)の定義
テトリミノは形によってマスの配置が異なるので、形状を表す配列 をあらかじめ用意しておきます。
たとえば I 字型(4マスが横一列に並ぶ形)なら、以下のような2次元配列で表現できます。
// I字型テトリミノの一例 const I_SHAPE = [ [1, 1, 1, 1] ];
さらに回転させた形状も含めて定義する場合は、回転後の形を同じ配列の別要素として持つ方法があります。
ただし、回転は後で関数を使って行列を入れ替える方法でも実装できるので、まずは単純な表現にしておいて、後で回転ロジックを加えていく形でも問題ありません。
複数のテトリミノをまとめて管理したいときは、次のように7種類をまとめた配列を作ります。
const TETROMINOS = [ // I [ [1, 1, 1, 1] ], // O [ [2, 2], [2, 2] ], // T [ [0, 3, 0], [3, 3, 3] ], // S [ [0, 4, 4], [4, 4, 0] ], // Z [ [5, 5, 0], [0, 5, 5] ], // J [ [6, 0, 0], [6, 6, 6] ], // L [ [0, 0, 7], [7, 7, 7] ] ];
ここでは数字を変えているだけですが、3
や 4
といった値は「どのテトリミノに属するか」を示すためのものと考えるとわかりやすいでしょう。
ブロックを落下させるロジック
テトリミノをフィールドの上から落としていくには、位置情報を管理する 必要があります。
テトリミノがどの行・列にいるのか、という座標(x, y)を管理しつつ、一定間隔で y
座標を増やしてブロックを下げていくのが基本的なアプローチです。
現在のブロック情報を保持する
ランダムにテトリミノを選んだら、現在動かしているブロック の情報をオブジェクトで持ちます。
例として、次のような構造を考えてみましょう。
let currentBlock = { shape: null, // 例えば TETROMINOS のどれか row: 0, // 上部から落としていくので最初は0 col: 3 // 横の中心にくるように適当に設定 }; // 新しいブロックを生成する関数 function spawnBlock() { const randomIndex = Math.floor(Math.random() * TETROMINOS.length); currentBlock.shape = TETROMINOS[randomIndex]; currentBlock.row = 0; currentBlock.col = 3; }
shape
に実際のテトリミノ配列を入れ、row
と col
で位置を記録します。
テトリスでは、ブロックの配置起点 を盤面の上のほう(row=0)かそれより少し上に設定するのが一般的です。
横方向は中央に配置するとプレイヤーが操作しやすくなります。
一定時間ごとにブロックを下げる
次のように setInterval()
を使い、update()
関数を繰り返し呼び出す形でブロックを落とす処理を行います。
function update() { // ブロックを1つ下に移動させる currentBlock.row++; // 衝突するかどうかチェック if (hasCollision()) { // 衝突していた場合は1つ上に戻す(固定) currentBlock.row--; fixBlockToField(); clearLines(); // ライン消去のチェック&処理 spawnBlock(); // 次のブロックを生成 } draw(); // 画面の描画更新 } setInterval(update, 1000); // 1秒ごとに更新
ブロックが着地または衝突したら、盤面に固定化し、新しいブロックを生成して再び落とす。
この繰り返しがテトリスの基本サイクルになります。
一方で衝突判定を誤ると、ブロックが空中で止まったり、めり込んだりするので、丁寧に実装することが大事です。
衝突判定の仕組み
ブロックがどこまで落ちられるか、あるいは左右に移動した際に他のブロックとぶつかっていないかを調べるのが衝突判定です。
衝突判定は、テトリミノの形状とフィールドの中身を照らし合わせて実施します。
衝突判定の具体的な考え方
現在のテトリミノの各マスと、フィールドの対応するマスを比べて、以下のどれかに当てはまったら衝突(またははみ出し)とみなします。
- テトリミノのマスがフィールドの上・下・左右の範囲外に出る
- テトリミノのマスがフィールド内の既に埋まっているマスと重なる
具体的には、こんな関数をイメージするとわかりやすいです。
function hasCollision() { const shape = currentBlock.shape; for (let r = 0; r < shape.length; r++) { for (let c = 0; c < shape[r].length; c++) { if (shape[r][c] !== 0) { let newRow = currentBlock.row + r; let newCol = currentBlock.col + c; // フィールド外のチェック if (newRow < 0 || newRow >= ROWS || newCol < 0 || newCol >= COLS) { return true; } // フィールド内にブロックがあるかチェック if (field[newRow][newCol] !== 0) { return true; } } } } return false; }
このように、テトリミノ配列のどこかにブロック(非0)がある場合のみ、実際にフィールドの座標と照らし合わせます。
もしフィールド外に出ていたり、すでに埋まっているマスに重なっているなら衝突とみなし、true
を返すわけです。
ブロックを固定して盤面に反映する
ブロックが衝突したら、その場所にブロックを「固定」して盤面を更新します。
固定処理はシンプルで、テトリミノの位置に応じて field
に書き込むだけです。
固定処理のイメージ
以下の関数 fixBlockToField()
は、衝突が起きた直後などに呼び出します。
function fixBlockToField() { const shape = currentBlock.shape; for (let r = 0; r < shape.length; r++) { for (let c = 0; c < shape[r].length; c++) { if (shape[r][c] !== 0) { // テトリミノの位置をフィールドへ書き込む field[currentBlock.row + r][currentBlock.col + c] = shape[r][c]; } } } }
テトリミノ配列上で非0になっている部分を、そのままフィールドへ反映しています。
これでブロックがフィールド上に「着地」した状態が表現できます。
ラインを消去して得点を加算する
テトリスの醍醐味は、ブロックをうまく積み重ねてラインをそろえると、そのラインが消える という部分にあります。
ラインが消えると同時にスコアが加算されるので、プレイヤーは一度に多くのラインを消すほど高得点を得られます。
ラインチェックのロジック
ラインが消える条件は、1行に含まれる全マスが埋まっていることです。
つまり、field[r]
のすべての要素が 0 以外であれば、その行は消去対象となります。
function clearLines() { let clearedLines = 0; for (let r = 0; r < ROWS; r++) { // ある行が全て埋まっているかチェック let isFull = true; for (let c = 0; c < COLS; c++) { if (field[r][c] === 0) { isFull = false; break; } } // 一行が全て埋まっていたら上の行を下にずらす if (isFull) { // 指定行を削除 field.splice(r, 1); // 新たに一番上に空行を追加 const emptyRow = new Array(COLS).fill(0); field.unshift(emptyRow); clearedLines++; } } // スコア計算は自由に if (clearedLines > 0) { score += getScore(clearedLines); } } function getScore(clearedLines) { // 1ライン消し -> 100点、2ライン -> 300点、3ライン -> 500点…のように自由に設定 if (clearedLines === 1) return 100; if (clearedLines === 2) return 300; if (clearedLines === 3) return 500; if (clearedLines === 4) return 800; // テトリス return 0; }
field.splice(r, 1)
で該当の行を削除し、field.unshift(emptyRow)
で上に新しい空行を挿入しています。
これにより、削除した行より上に積まれていたブロックが自然に下に降りてくるわけです。
実務における応用例
ラインチェックのロジックでやっていることは、配列の中身を条件付きで置き換えたり、並び替えたりしている 処理です。
これは、リアルタイムにデータを操作するシステムでも非常に役立ちます。
たとえば、Webアプリの中で「特定の条件を満たすレコードを抽出して削除し、新たにエントリを挿入する」といった操作は、テトリスのライン消去に近い発想で実装できる場面があります。
単純に配列操作の練習をするという視点でも、このライン消去ロジックは良い題材です。
ブロックの回転と左右移動
テトリスならではの要素として、ブロックの回転 や 左右への移動 をどう実装するかが挙げられます。
キーボード入力(またはボタン入力)があったタイミングで処理を呼び出す仕組みが必要です。
キーボード入力をハンドリングする
JavaScriptなら、document.addEventListener('keydown', callback)
などでキー押下を検知することができます。
例として左右移動と回転を実装するなら、次のような形をイメージするとわかりやすいでしょう。
document.addEventListener('keydown', (e) => { switch (e.key) { case 'ArrowLeft': moveBlock(-1); break; case 'ArrowRight': moveBlock(1); break; case 'ArrowUp': rotateBlock(); break; case 'ArrowDown': // ソフトドロップの例: ブロックを一気に下げる dropBlock(); break; } }); function moveBlock(direction) { currentBlock.col += direction; if (hasCollision()) { // 衝突するなら元に戻す currentBlock.col -= direction; } }
矢印キーの左右で moveBlock()
を呼び出し、衝突判定を行いながら位置を調整します。
もし移動先がすでにブロックで埋まっていたり、盤面からはみ出していたら元に戻すという実装です。
回転の実装
テトリミノの回転は、2次元配列の行列を入れ替える のが定石です。
行列を90度回転させるには、行と列を反転させた上で順番を変えるやり方がよく使われます。
function rotateBlock() { const shape = currentBlock.shape; // shapeを90度回転させる新しい配列を作る let rotated = []; for (let c = 0; c < shape[0].length; c++) { rotated[c] = []; for (let r = shape.length - 1; r >= 0; r--) { rotated[c].push(shape[r][c]); } } // 回転してみて衝突しないなら反映 const originalShape = currentBlock.shape; currentBlock.shape = rotated; if (hasCollision()) { // 衝突するなら回転を元に戻す currentBlock.shape = originalShape; } }
回転後も衝突判定を行い、衝突するなら回転をキャンセルする仕組みにすることで、壁や他のブロックにめり込む事態を防ぎます。
このように回転ロジックは一見ややこしいですが、本質的には配列の転置と並び替えをしているだけです。
画面描画とUI
テトリスをプレイするときには、フィールドやブロックを見える形 で表示してあげる必要があります。
HTMLの <canvas>
を使って描画する方法と、<table>
や <div>
要素を組み合わせて実装する方法があります。
どちらも一長一短ですが、コード例としてはキャンバス描画を取り入れる人が多い印象です。
キャンバスを使った描画
<canvas>
要素を用意し、JavaScriptで2次元グラフィックスを描画していきます。
<canvas id="gameCanvas" width="200" height="400"></canvas>
たとえば上記のように幅200px、高さ400pxと決めておき、それぞれ横10マス、縦20マスに相当すると考えると、1マスの大きさを 20 x 20 ピクセル にすればよいわけです。
function draw() { const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d'); // キャンバスをクリア ctx.clearRect(0, 0, canvas.width, canvas.height); // フィールドを描画 for (let r = 0; r < ROWS; r++) { for (let c = 0; c < COLS; c++) { if (field[r][c] !== 0) { drawBlock(ctx, c, r, field[r][c]); } } } // 現在のブロックを描画 const shape = currentBlock.shape; for (let r = 0; r < shape.length; r++) { for (let c = 0; c < shape[r].length; c++) { if (shape[r][c] !== 0) { drawBlock(ctx, currentBlock.col + c, currentBlock.row + r, shape[r][c]); } } } } function drawBlock(ctx, col, row, value) { ctx.fillStyle = getColor(value); ctx.fillRect(col * 20, row * 20, 20, 20); // 枠線を描くなどの処理を追加してもよい } function getColor(value) { // 数字に応じて色を返す例 switch(value) { case 1: return 'cyan'; // I case 2: return 'yellow'; // O case 3: return 'purple'; // T case 4: return 'green'; // S case 5: return 'red'; // Z case 6: return 'blue'; // J case 7: return 'orange'; // L default: return 'gray'; } }
このように draw()
関数を呼ぶたびに、まず画面をクリア し、それから フィールド上の固定ブロック と 現在動いているブロック を描画します。
fillRect
を使うだけでも最低限のビジュアルが実現できますが、もっとリアルな表現にしたいときは、ブロックにグラデーションをつけたり画像を貼ったりすることも可能です。
実務におけるUIへの応用
キャンバス描画そのものは、ダッシュボードのグラフ描画やシミュレーションツールなどにも応用可能です。
また、操作性を良くするためにイベントリスナー を組み合わせる点などは、Webアプリ全般でのUI設計にも役立つでしょう。
たとえば、矢印キー入力のようなキーボード操作はもちろん、マウスクリックやタッチ操作を使ってインタラクティブに要素を動かす場面でも同様の考え方が使われます。
レベルアップ・スピード調整
テトリスをよりゲームらしくするには、ラインを消すたびにレベル を上げて落下スピードを早くするなどの工夫があります。
シンプルには、以下のような仕組みを導入することが考えられます。
- 一定のライン数を消すごとにレベルを上げる
- レベルが上がるたびに
setInterval()
の時間を短くする
スピードを変える際の注意
setInterval()
の間隔を動的に変えるのは、すでにタイマーが走っている場合に再設定する手間があります。
そのため、レベルアップのタイミングで clearInterval
を呼び出し、新しい間隔で再度 setInterval
を設定する必要が出てきます。
let level = 1; let linesClearedTotal = 0; let dropInterval = 1000; // 初期は1秒 function clearLines() { let clearedLines = 0; for (let r = 0; r < ROWS; r++) { let isFull = true; for (let c = 0; c < COLS; c++) { if (field[r][c] === 0) { isFull = false; break; } } if (isFull) { field.splice(r, 1); field.unshift(new Array(COLS).fill(0)); clearedLines++; } } if (clearedLines > 0) { // スコア計算など score += getScore(clearedLines); linesClearedTotal += clearedLines; // レベルアップ判定 if (linesClearedTotal >= level * 10) { levelUp(); } } } function levelUp() { level++; dropInterval = Math.max(100, dropInterval - 100); // 落下速度を少し速める restartInterval(); } let gameTimer = null; function startGame() { gameTimer = setInterval(update, dropInterval); } function restartInterval() { clearInterval(gameTimer); gameTimer = setInterval(update, dropInterval); }
こうすることで、ラインをいくつか消すたびにスピードが上がり、ゲームがだんだん難しくなる仕組みが作れます。
スピードを変えるために dropInterval
を変え、restartInterval()
で実際のタイマーを再設定している点がポイントです。
ゲームオーバー判定
テトリスはブロックが積み上がってフィールド上部まで到達したらゲームオーバー です。
具体的には、新しいブロックを生成した際にすでに衝突が発生している場合や、固定後のブロックが一番上の行を埋め尽くしている場合にゲーム終了処理を呼び出します。
シンプルな判定方法
spawnBlock()
直後に衝突チェックをする方法がわかりやすいでしょう。
もし新規に出したブロックがフィールドに収まらず衝突していたら、それ以上プレイ不可能 と判断します。
function spawnBlock() { const randomIndex = Math.floor(Math.random() * TETROMINOS.length); currentBlock.shape = TETROMINOS[randomIndex]; currentBlock.row = 0; currentBlock.col = 3; if (hasCollision()) { // ブロック生成直後に衝突している -> ゲームオーバー gameOver(); } } function gameOver() { clearInterval(gameTimer); alert("ゲームオーバー!"); }
実際のゲームオーバーの演出は、アラート表示に限らず、モーダルウィンドウを表示したり、スコアをランキングサーバに送ったりとさまざまな応用ができます。
さらに発展させるためのアイデア
JavaScriptで作るテトリスは、ここまでに紹介した基礎に追加要素 を加えることで、さらに面白く発展させられます。
ゴーストピース機能
多くのテトリスでは、ブロックがどこに落ちるかを事前に示すためにゴーストピース と呼ばれる半透明のシルエットが表示されます。
これは、いま操作中のテトリミノをそのまま下方向に移動させた場合、どこに着地するか を視覚的に示すための機能です。
実装としては、衝突判定を使いつつ下に移動し、着地地点を計算してから半透明のピースを描画するだけなので、コードをそれほど大きく変えずに追加できます。
ネクストピースやホールド機能
次に出てくるテトリミノをあらかじめ表示する ネクストピース 機能もよくある要素です。
また、現在のブロックをホールドして後で使いまわす ホールド機能 などを追加すれば、より本格的なテトリスに近づきます。
スキンやアニメーション
ビジュアル面を充実させるなら、ブロックのスキンを変更したり、ライン消去時にアニメーションをつけたりして見た目の演出 を強化できます。
単純な長方形だけでなく、ブロック画像を読み込んだり、キャンバスAPIでグラデーションを描いたりするだけで印象はガラリと変わるでしょう。
実務での活用シーン
テトリスを作る経験は、単なるゲーム制作にとどまらず、業務システム開発 や UI/UX の知識にもつながります。
-
定期更新の技術
- ゲームループを通じて、一定時間ごとにUIを更新する感覚は、リアルタイムチャートや自動リフレッシュが必要なダッシュボード開発でも応用できます。
-
衝突判定やイベント処理
- ユーザー入力に即座に反応して、ブロックが動くのを制御するロジックは、ドラッグ&ドロップ機能やその他のインタラクションを実装する際の基礎になります。
-
2次元配列を使ったデータ管理
- 盤面を表す2次元配列の操作は、座標データやマトリックス演算など、さまざまなシステムで出てくる概念です。
- たとえば画像処理やタイルマップ、地図情報の管理などでも2次元配列を使うことがあります。
-
ステートマシン管理
- テトリスではブロックの生成から移動、固定、消去チェック、スコア加算、ゲームオーバー判定など、多彩なステートが存在します。
- これらを明確に区切って実装する経験は、複雑な画面遷移やフロー管理が必要なWebアプリケーションでも活きてきます。
JavaScriptでのゲーム制作経験は、動的UIが重視されるWebアプリの開発スキル強化に直結します。
デプロイや公開のポイント
完成したテトリスは、ローカル環境でHTMLファイルを開くだけで動作させることができます。
もし社内向けのアプリとして使いたい場合や、何かのデモとしてWeb上で公開したい場合は、そのまま静的ホスティングサービスなどにデプロイしてしまえばOKです。
JavaScriptのエントリポイントとして index.html
があれば、GitHub PagesやVercelなどでも簡単に公開できます。
まとめ
JavaScriptで作るテトリスについて、基本的な仕組みから実装手順までを紹介してきました。
テトリスのコードを追いかける中で、以下のようなポイントを学んだのではないでしょうか。
- ゲームループ :
setInterval()
やrequestAnimationFrame()
を使った定期的な状態更新 - 衝突判定 : 2次元配列を照合して、ブロックがフィールド外や既存のブロックと重なるかをチェック
- ライン消去 : 1行が全部埋まっているかどうかを判定し、埋まっていれば配列操作で行を削除・再挿入
- イベントハンドリング : キーボード入力でブロックの移動や回転を行う
- 実務への応用 : 配列操作や画面更新のロジック、ユーザー操作への即時反応などは幅広い領域で活かせる
テトリスは、初心者が初めて作るゲームとしてちょうどよい難易度と学びのバランスを持っています。
しかもゲームとしてわかりやすく、実装過程を通じて自然にJavaScriptの実用的な書き方 が身につくところが魅力です。
もし今後さらに機能を追加していく場合は、ネクストピースやホールド機能、アニメーションやスキン変更などを試してみてください。
一連の制作プロセスをこなすだけで、Webアプリ開発に通じる多くの経験を積むことができるはずです。
以上を踏まえて、ぜひ JavaScriptでのテトリス開発 を入り口に、幅広い応用へとつなげてみてはいかがでしょうか。
ゲーム制作は、コードを書きながら目で見て楽しさを実感できるところが大きなメリットです。
実務レベルでも、このような動的なUI更新や配列操作のロジックは広く使われるので、ぜひ活用してみてください。