4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptとCanvas APIでテトリス風パズルゲームを作ってみよう(初心者向けステップ解説)

Last updated at Posted at 2025-05-03

概要

今回は、ブラウザ上で動作するテトリス風落ち物パズルゲームを、TypeScriptとHTMLのCanvas APIを用いてゼロから実装する工程を、細かなステップに分けて解説していきます。ソースコードを参考に、どのようにゲームが作られていくのか、その過程を一緒に見ていきましょう。

本記事は、「ゲーム開発に興味があるけれど、何から始めれば良いか分からない」「TypeScriptやCanvas APIに触れてみたい」といった初心者の方を対象としています。基本的な描画から始まり、ブロックの操作、落下、当たり判定、ライン消去、スコア加算といったゲームの主要な要素を順を追って実装していきます。

完成版のソースコードはこちらにおいておきます。

実装イメージ

実装は簡易的に、18x9のマスを作り、ブロックが積み上がるゲームエリアとします。
1ブロックのサイズは30pxとして、ゲームエリアのCanvasのサイズは、縦方向は540px(18行x30px)、横方向は270px(9列x30px)とします。
game_area.jpeg

ブロックの種類は下記の8種類を使います。便宜上、それぞれのブロックの形にアルファベットを振っています。
block.jpg

実装

事前準備:HTMLとCSSでゲーム画面の土台を作る

ゲームのコードを書き始める前に、ゲームを表示するためのHTMLファイルと、見た目を整えるためのCSSファイルを用意します。

index.htmlファイルでは、ゲームの描画領域となる<canvas>要素を2つ(メイン画面用とNextブロック表示用)、そしてスコアを表示するための要素を配置します。scriptタグで、これから作成するmain.tsファイルを読み込むように設定しておきます。

<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Block Puzzle Game</title>
    <link rel="stylesheet" href="src/style.css" />
  </head>
  <body>
    <div class="game-container">
      <canvas id="gameCanvas" width="270" height="540"></canvas>
      <div class="side-panel">
        <div id="scoreBoard">Score: 00000</div>
        <canvas id="nextBlockCanvas" width="150" height="150"></canvas>
      </div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

style.cssファイルでは、これらのHTML要素に対して、画面中央に配置したり、Canvas要素に枠線をつけたり、スコア表示のフォントを設定したりといった基本的なスタイリングを行います。

/* style.css */
body {
  display: flex; /* 子要素(.game-container)をフレックスアイテムに */
  justify-content: center; /* 子要素を水平方向の中央に配置 */
  /* ... その他のスタイル */
}
/* ゲームエリアのスタイル */
.game-container {
  display: flex; /* 子要素(canvasとside-panel)をフレックスアイテムに */
  justify-content: center; /* 子要素を水平方向の中央に配置 */
  align-items: flex-start; /* 子要素を上揃えに */
  gap: 20px; /* 子要素間の隙間 */
}
/* ブロックを移動できるエリアのスタイル */
#gameCanvas {
  border: 1px solid #000; /* 枠線 */
  background-color: #eee; /* 背景色 */
}
/* サイドパネル(ネクストブロックやスコアボード)のスタイル */
.side-panel {
  display: flex; /* 子要素をフレックスアイテムに */
  flex-direction: column; /* 子要素を縦方向に並べる */
  align-items: center; /* 子要素を水平方向(主軸と垂直な方向)の中央に揃える */
}
/* 次に落ちてくるブロックを表示するキャンバスのスタイル */
#nextBlockCanvas {
  border: 1px solid #000; /* 1ピクセルの実線で黒い枠線 */
  background-color: #ddd; /* 薄い灰色の背景色 */
  margin-bottom: 10px; /* 下に10ピクセルの余白 */
}
/* スコア表示要素のスタイル */
#scoreBoard {
  margin-bottom: 24px; /* 下に24ピクセルの余白 */
  text-align: center; /* テキストを中央揃え */
  font-size: 24px; /* フォントサイズを24ピクセルに */
  font-weight: bold; /* フォントを太字に */
  font-family: "Courier New", monospace; /* フォントファミリーを指定(等幅フォント) */
  background-color: white; /* 白い背景色 */
  padding: 10px; /* 内側に10ピクセルの余白 */
  border: #000 1px solid; /* 1ピクセルの実線で黒い枠線 */
  min-width: 180px; /* 最小幅を180ピクセルに */
  max-width: 180px; /* 最大幅を180ピクセルに(結果的に幅が180pxに固定される) */
  white-space: nowrap; /* テキストを改行しない */
  overflow: hidden; /* 要素からはみ出したコンテンツを非表示 */
}

これで、ゲーム画面を表示するための土台が完成しました。次に、いよいよTypeScriptのコードを書き始めます。

Step 1: ゲームキャンバスの描画

まずは、ゲームのメイン画面となるgameCanvasに描画するための準備をします。

main.tsファイルの冒頭で、HTMLで用意した<canvas>要素を取得し、その要素から2D描画のためのコンテキストを取得します。このコンテキストを使って、様々な図形を描画していきます。

// main.ts
const canvas = document.getElementById("gameCanvas") as HTMLCanvasElement;
const context = canvas.getContext("2d")!; // 2D描画コンテキストを取得

また、ゲームボードのサイズやブロック1マスあたりのサイズなどを定数として定義しておきます。

const grid = 30; // 1マスあたりのピクセルサイズ
const rows = 18; // ボードの縦マス数
const cols = 9;  // ボードの横マス数

このゲームでは、ゲームボード自体やブロックは小さな正方形の集まりで表現します。そこで、指定された座標とサイズ、色で正方形を一つ描画する基本的な関数drawSquareを作成します。

function drawSquare(
  x: number,
  y: number,
  color: string,
  ctx: CanvasRenderingContext2D,
  size: number
) {
  ctx.fillStyle = color; // 塗りつぶしの色を指定
  // 指定したマス目の位置 (x * size, y * size) から、サイズ (size, size) の四角形を塗りつぶす
  ctx.fillRect(x * size, y * size, size, size);
  ctx.strokeStyle = "white"; // 線の色を指定
  // 指定したマス目の位置から、サイズ (size, size) の四角形の境界線を描画する
  ctx.strokeRect(x * size, y * size, size, size);
}

drawSquare関数は、これからブロックやボードを描画する際に何度も利用する基本的な部品となります。

Step 2: ブロックの定義と描画

落ちてくるブロックの形状や色を定義し、それをgameCanvasに描画できるようにします。

まず、ブロックの色と形状を定義します。

const colors = ["red", "blue", "purple", "teal", "green", "orange", "brown", "gold"]; // ブロックの色

const shapes = [
  [
    [1, 1, 1, 1]
  ], // I型
  [
    [1, 0, 0],
    [1, 1, 1],
  ], // J型
  [
    [0, 0, 1],
    [1, 1, 1],
  ], // L型
  [
    [1, 1],
    [1, 1],
  ], // O型
  [
    [0, 1, 1],
    [1, 1, 0],
  ], // S型
  [
    [0, 1, 0],
    [1, 1, 1],
  ], // T型
  [
    [1, 1, 0],
    [0, 1, 1],
  ], // Z型
  [
    [1, 0, 1],
    [1, 1, 1],
  ], // U型
];

shapes配列は、それぞれのブロックの形状を2次元配列で表現しています。1がある場所がブロックの存在するセルを表します。

次に、ゲーム中に操作する「落ちてくるブロック」の状態を管理するための型(インターフェース)と変数を用意します。

interface Piece {
  shape: number[][]; // 形状 (shapesの要素)
  color: string; // 色 (colorsの要素)
  x: number; // ゲームボード上のX座標 (列)
  y: number; // ゲームボード上のY座標 (行)
}

let currentPiece: Piece; // 現在操作中のブロック
// let nextPiece: Piece; // 次のブロック (Step 7で追加)

currentPieceは、現在プレイヤーが操作しているブロックの形状、色、そしてゲームボード上の現在位置(左上のセルの座標)を保持します。

これらの定義を元に、特定のPieceオブジェクトをgameCanvasに描画する関数drawPieceを作成します。

function drawPiece(piece: Piece, ctx: CanvasRenderingContext2D, size: number) {
  // pieceオブジェクトのshape配列をループ処理
  piece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value) { // valueが1 (ブロックが存在するセル) なら描画
        // pieceの現在位置(piece.x, piece.y)に、shape内の相対位置(x, y)を加算して
        // ゲームボード上の絶対位置を計算し、drawSquareで描画
        drawSquare(piece.x + x, piece.y + y, piece.color, ctx, size);
      }
    });
  });
}

この関数は、Pieceオブジェクトの形状(shape)を調べ、1になっているセルに対応する位置に、piece.colorを使ってdrawSquareでブロックを描画します。描画位置は、piece.xpiece.y(ブロック全体の基準位置)に、形状内の相対位置(xy)を加えたゲームボード上の絶対位置になります。

最後に、ゲームボード自体の描画関数drawBoardも作成します。これは、Step 6以降で固定されたブロックを描画するために使用しますが、この時点ではまだボードは空です。

let board: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); // ゲームボードの状態を表す2次元配列 (最初は全て空きマス0)

function drawBoard() {
  context.clearRect(0, 0, canvas.width, canvas.height); // Canvas全体をクリア
  for (let row = 0; row < rows; row++) { // ボードの各行をループ
    for (let col = 0; col < cols; col++) { // ボードの各列をループ
      if (board[row][col]) { // そのセルにブロックが存在する場合 (値が0以外)
        // boardの値 (色のインデックス+1) を使って色を取得し、drawSquareで描画
        drawSquare(col, row, colors[board[row][col] - 1], context, grid);
      }
    }
  }
}

drawBoard関数は、board配列を走査し、0以外の値が入っているセル(固定されたブロックがある場所)に色付きのブロックを描画します。値が1以上なのは、colors配列のインデックスに+1したものを格納しているためです。

Step 3: キーボード操作で移動

プレイヤーが矢印キーを使ってブロックを左右に移動させたり、回転させたりできるように、キーボードイベントを捕捉して対応する処理を呼び出すようにします。

document.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft") movePiece(-1); // 左矢印キーでmovePiece(-1)を呼び出し
  if (e.key === "ArrowRight") movePiece(1); // 右矢印キーでmovePiece(1)を呼び出し
  if (e.key === "ArrowUp") rotatePiece(); // 上矢印キーでrotatePiece()を呼び出し
  if (e.key === "ArrowDown") hardDrop(); // 下矢印キーでhardDrop()を呼び出し (Step 4/5で実装)
});

function movePiece(dx: number) {
  currentPiece.x += dx; // ブロックのX座標をdxだけ増減
  // ※ 注意: この時点ではまだ壁や他のブロックとの衝突判定は考慮していません。
  //   次のステップ以降で衝突判定を実装し、移動可能かチェックします。
}

function rotatePiece() {
  // 形状を表す2次元配列を90度回転させるロジック
  const rotatedShape = currentPiece.shape[0].map((_, index) =>
    currentPiece.shape.map((row) => row[index]).reverse()
  );
  const backup = currentPiece.shape; // 回転前の形状をバックアップ
  currentPiece.shape = rotatedShape; // 回転後の形状をセット
  // ※ 注意: この時点ではまだ回転後の衝突判定は考慮していません。
  //   次のステップ以降で衝突判定を実装し、回転可能かチェックします。
}

movePiece(dx)関数は、currentPieceのX座標を引数dxの値(左移動なら-1、右移動なら1)だけ変更します。rotatePiece()関数は、現在のブロックの形状を90度回転させた新しい形状を計算し、currentPiece.shapeにセットします。

この時点では、まだ壁や他のブロックとの衝突判定は行っていません。そのため、ブロックが壁を突き抜けたり、他のブロックに重なったりしてしまいますが、これは次のステップで解決します。

Step 4: 時間が立つとブロックが落ちていく処理の実装

落ち物ゲームの主要な要素の一つである、時間経過によるブロックの自動落下を実装します。

ゲームの描画や状態更新を一定間隔で行うために、ブラウザのrequestAnimationFrameを使ったゲームループを構築します。

let dropStart = performance.now(); // 前回の落下処理または描画更新が行われた時刻
let dropInterval = 500; // ブロックが1マス落下するまでの時間(ミリ秒)

function update(timestamp: number) {
  // timestampはrequestAnimationFrameが提供する現在の時刻

  // 前回の処理からの経過時間を計算
  const deltaTime = timestamp - dropStart;

  // 経過時間が落下間隔を超えていたらブロックを落とす
  if (deltaTime > dropInterval) {
    dropPiece(); // ブロックを1マス落下させる関数を呼び出し
    dropStart = timestamp; // dropStartを現在の時刻に更新
  }

  // ゲーム画面を最新の状態に描画
  drawBoard(); // 固定されたブロックを描画
  drawPiece(currentPiece, context, grid); // 現在落下中のブロックを描画

  // 次のフレームでのupdate関数の実行をブラウザに依頼
  requestAnimationFrame(update);
}

// ゲーム開始時に最初に一度だけ呼び出す処理
function initializeGame() {
    // ... 初期化処理(ブロック生成など、Step 7で追加) ...
    requestAnimationFrame(update); // ゲームループを開始
}

// initializeGame(); // ゲーム開始 (initializeGameはStep 7で定義)

update(timestamp)関数が、ブラウザの描画更新タイミングに合わせて繰り返し呼び出されます。この関数の中で、performance.now()を使って前回の処理からの経過時間を計測し、dropIntervalで設定した時間(例: 500ms)ごとにdropPiece()関数を呼び出すことで、ブロックが自動で落下する仕組みを作ります。

dropPiece()関数は、シンプルにcurrentPiece.yを1つ増やすことでブロックを1マス下に移動させます。

function dropPiece() {
  currentPiece.y += 1; // ブロックを1マス下に移動
  // ※ 注意: この時点では落下後の衝突判定はまだ考慮していません。
  //   次のステップで衝突判定を実装し、落下可能かチェックします。
}

update関数の最後でrequestAnimationFrame(update)を呼び出すことで、再帰的にupdate関数が呼び出され、ゲームループが継続します。また、ゲーム開始時に一度だけrequestAnimationFrame(update)を呼び出すことで、ゲームループを開始します。

Step 5: 壁とブロックの衝突判定の実装

このゲームでは、ブロックがゲームボードの境界(壁や底)を突き抜けたり、既に固定されている他のブロックと重なったりすることはできないようにします。これらの「衝突」を検出する処理を実装し、操作や落下を制限します。

Step 4で作成したdropPiece関数や、Step 3で作成したmovePiece, rotatePiece関数の中に、衝突判定のロジックを追加します。衝突判定を行うためのcollision()関数を実装します。

function collision(): boolean {
  // currentPieceの形状を構成する各セルについてループ
  return currentPiece.shape.some((row, y) => // 形状の各行
    row.some(
      (value, x) =>
        value && // そのセルがブロックの一部であるか (valueが1か)
        (
          // ゲームボードの境界外に出ていないかチェック
          currentPiece.x + x < 0 || // 左端の境界判定
          currentPiece.x + x >= cols || // 右端の境界判定
          currentPiece.y + y >= rows || // 下端 (底) の境界判定
          // 既にボードに固定されているブロックと重なっていないかチェック
          // ※ ただし、currentPiece.y + y が負になる可能性 (ボードより上) も考慮が必要ですが、
          //    ここではシンプル化のため、ボード内の座標を前提としています。
          //    厳密には board[currentPiece.y + y] が undefined にならないかのチェックも必要ですが、
          //    形状のy座標は通常0以上から始まるため、y >= 0 と currentPiece.y >= 0 であれば問題ありません。
          //    ゲーム開始時のy=0で即衝突判定するケースなどを考慮します。
          (currentPiece.y + y >= 0 && board[currentPiece.y + y][currentPiece.x + x])
        )
    )
  );
}

collision()関数は、currentPieceの形状を構成する各セルについて、そのセルがゲームボード上のどの位置に来るかを計算し、以下のいずれかに当てはまる場合にtrue(衝突している)を返します。

  • ゲームボードの左端より左に出ている (currentPiece.x + x < 0)
  • ゲームボードの右端より右に出ている (currentPiece.x + x >= cols)
  • ゲームボードの底より下に出ている (currentPiece.y + y >= rows)
  • 計算された位置に、既にboard配列上でブロックが固定されている (board[currentPiece.y + y][currentPiece.x + x] が0以外)

このcollision()関数を、移動、回転、落下の各関数に組み込みます。

function movePiece(dx: number) {
  currentPiece.x += dx; // とりあえず移動させてみる
  if (collision()) { // 衝突したら
    currentPiece.x -= dx; // 移動をキャンセル (元に戻す)
  }
}

function rotatePiece() {
  const rotatedShape = currentPiece.shape[0].map((_, index) =>
    currentPiece.shape.map((row) => row[index]).reverse()
  );
  const backup = currentPiece.shape; // 回転前の形状をバックアップ
  currentPiece.shape = rotatedShape; // 回転後の形状を仮セット
  if (collision()) { // 回転後の形状で衝突したら
    currentPiece.shape = backup; // 回転をキャンセル (元の形状に戻す)
  }
}

function dropPiece() {
  currentPiece.y += 1; // とりあえず下に移動
  if (collision()) { // 下に移動して衝突したら
    currentPiece.y -= 1; // 移動をキャンセル (元のY座標に戻す)
    // ※ 注意: この後、ブロックを固定する処理に進みます (Step 6)
  }
  // ※ 注意: この時点ではまだ時間経過による自動落下で落下できなかった場合の固定処理は未実装です。
  //   次のステップで固定処理を実装します。
}

// 下矢印キーで呼び出される hardDrop() も、collision() を使って最下部を判定します (Step 5/6 で完成)
function hardDrop() {
  while (!collision()) { // 衝突しない間、下に移動し続ける
    currentPiece.y += 1;
  }
  currentPiece.y -= 1; // 衝突した位置から1マス戻る (最下部確定)
  // ※ 注意: この後、ブロックを固定する処理に進みます (Step 6)
  // lockStart = performance.now(); // ハードドロップ後の猶予 (Step 10で追加)
  // dropPiece(); // 通常の落下処理に任せる (Step 10で修正)
}

これで、ブロックが壁や他のブロックにめり込むことなく、正しく操作・落下するようになりました。

Step 6: ブロックを積み上げて固定する実装

ブロックがそれ以上下に移動できなくなった際に、そのブロックをゲームボードの一部として固定し、次のブロックが出現するようにします。

dropPiece()関数内で、落下後にcollision()trueになった場合(つまり、下に移動しようとしたが衝突した、落下できない状態になった場合)に、ブロックをボードに固定する処理を呼び出します。

function dropPiece() {
  currentPiece.y += 1;
  if (collision()) { // 下に移動して衝突した (落下できない)
    currentPiece.y -= 1; // 元のY座標に戻す

    // ブロックをゲームボードに固定する処理を呼び出し
    placePiece();

    // 次のブロックを生成し、現在のブロックと入れ替える (Step 7で完成)
    // currentPiece = nextPiece;
    // nextPiece = randomPiece();
    // drawNextPiece();
    // lockStart = null; // 固定猶予をリセット (Step 10で追加)

    // ライン消去とスコア加算のチェック (Step 8/9で追加)
    // clearLines();

    // 新しいブロックが出現した時点で即衝突したらゲームオーバー (Step 7/??で追加)
    // if (collision()) {
    //   gameOver();
    // }

  }
  // else { // 落下できた場合は猶予をリセット (Step 10で追加)
  //   lockStart = null;
  // }
}

function placePiece() {
  // currentPieceの形状を構成する各セルについてループ
  currentPiece.shape.forEach((row, y) => {
    row.forEach((value, x) => {
      if (value) { // そのセルがブロックの一部であれば (valueが1)
        // ゲームボード上の該当セルに、ブロックの色に対応する値を書き込む
        // colors配列のインデックス (0-7) に+1した値 (1-8) を格納することで、0 (空き) と区別し色も保持
        board[currentPiece.y + y][currentPiece.x + x] =
          colors.indexOf(currentPiece.color) + 1;
      }
    });
  });
}

// hardDrop() も、最下部に到達したらplacePiece() を呼び出すようにする
function hardDrop() {
  while (!collision()) {
    currentPiece.y += 1;
  }
  currentPiece.y -= 1;

  placePiece(); // 最下部に固定

  // ... (ライン消去、次のブロック生成などの後処理) ...
}

placePiece()関数は、currentPieceが現在位置に固定されたものとして、その形状をboard配列に書き込みます。board配列の該当セルに、ブロックの色を識別するための値(colors配列のインデックス + 1)をセットします。これにより、そのブロックはゲームボードの一部となり、drawBoard()関数で描画されるようになります。

また、hardDrop()関数も、最下部に到達した後にplacePiece()を呼び出すように修正します。

Step 7: Nextブロックのキャンバスとロジックの実装

次に落ちてくるブロックをプレイヤーに示すためのNextブロック表示エリアを実装し、ゲームの進行に合わせて表示を更新するようにします。

事前準備でHTMLに用意したnextBlockCanvas要素の描画コンテキストを取得します。

const nextBlockCanvas = document.getElementById(
  "nextBlockCanvas"
) as HTMLCanvasElement;
const nextContext = nextBlockCanvas.getContext("2d")!; // Nextブロック用コンテキスト
const nextBlockGrid = 30; // Nextブロックエリアの1マスサイズ (今回はゲームボードと同じ)
const nextBlockSize = 5; // Nextブロックエリアを何マス分として扱うか (ブロックは最大4マスなので5マスあれば収まる)

let nextPiece: Piece; // 次のブロックを保持する変数

ブロックをランダムに生成するrandomPiece()関数を作成します。

function randomPiece(): Piece {
  // shapes配列からランダムに形状を選ぶ
  const index = Math.floor(Math.random() * shapes.length);
  const shape = shapes[index];
  // ゲームボードの上部中央に出現するように初期X座標を計算
  const startX = Math.floor((cols - shape[0].length) / 2);
  // ランダムな形状、対応する色、計算した初期位置でPieceオブジェクトを作成して返す
  return { shape, color: colors[index], x: startX, y: 0 };
}

NextブロックエリアにnextPieceを描画する関数drawNextPiece()を作成します。Nextブロックエリアの中央に表示されるように位置を調整する計算を含めます。

function drawNextPiece() {
  // NextブロックCanvasをクリア
  nextContext.clearRect(0, 0, nextBlockCanvas.width, nextBlockCanvas.height);
  // Nextブロックエリアの中央に描画するためのオフセットを計算
  const offsetX = (nextBlockSize - nextPiece.shape[0].length) / 2;
  const offsetY = (nextBlockSize - nextPiece.shape.length) / 2;
  // オフセットを適用した新しいPieceオブジェクトを作成し、drawPieceで描画
  const nextPieceCentered: Piece = { ...nextPiece, x: offsetX, y: offsetY };
  drawPiece(nextPieceCentered, nextContext, nextBlockGrid);
}

ゲーム開始時や、ブロックが固定されて次のブロックが出現する際に、これらの関数を呼び出すようにします。ゲーム開始時の初期化関数initializeGame()を定義し、ゲームループの開始前に一度呼び出します。

// initializeGame 関数を定義
function initializeGame() {
  // ゲームボードを初期化(全て空きマスに)
  board = Array.from({ length: rows }, () => Array(cols).fill(0));
  // 最初のブロックと次のブロックを生成
  currentPiece = randomPiece();
  nextPiece = randomPiece();
  // Nextブロックを描画
  drawNextPiece();
  // ゲームループを開始
  requestAnimationFrame(update);
}

// ゲーム開始!
initializeGame();

そして、Step 6で実装したdropPiece()関数内で、ブロックを固定(placePiece())した後に、currentPiecenextPieceを入れ替え、新しいnextPieceを生成し、drawNextPiece()を呼び出す処理を追加します。

function dropPiece() {
  currentPiece.y += 1;
  if (collision()) {
    currentPiece.y -= 1;

    placePiece(); // ブロック固定

    // 次のブロックを現在のブロックにする
    currentPiece = nextPiece;
    // 新しい次のブロックを生成
    nextPiece = randomPiece();
    // Nextブロック表示を更新
    drawNextPiece();
    // lockStart = null; // 固定猶予をリセット (Step 10で追加)

    // ライン消去とスコア加算のチェック (Step 8/9で追加)
    // clearLines();

    // 新しいブロックが出現した時点で、即座に他のブロックと衝突している場合、ゲームオーバー
    if (collision()) {
      gameOver(); // ゲームオーバー処理 (別途定義)
    }
  }
  // else { // 落下できた場合は猶予をリセット (Step 10で追加)
  //   lockStart = null;
  // }
}

// ゲームオーバー処理の定義 (リセットなど)
function gameOver() {
  alert("Game Over!"); // 例としてアラート表示
  resetGame(); // ゲーム状態をリセットする関数 (別途定義)
}

function resetGame() {
  score = 0; // スコアをリセット (Step 8で追加)
  // updateScore(0); // スコア表示を更新 (Step 9で追加)
  board = Array.from({ length: rows }, () => Array(cols).fill(0)); // ボードをリセット
  initializePieces(); // ブロックを再生成 (initializePiecesはinitializeGameから名前変更または一部抽出)
}

//initializeGame から一部抽出・改変した initializePieces 関数(リセット時などに使用)
function initializePieces() {
  currentPiece = randomPiece();
  nextPiece = randomPiece();
  drawNextPiece();
}

// ゲーム開始は initializeGame() を使用
// initializeGame(); // main.tsの末尾で呼び出す

これで、ブロックが固定されるたびにNextブロックが表示され、次のブロックがゲームボードの上部に出現するようになりました。また、新しいブロックが出現した時点で既に衝突していた場合のゲームオーバー判定も加わりました。

Step 8: 1行そろうとブロックを消す実装

このゲームのゲーム性として、横一列にブロックが揃うとラインが消去され、上のブロックが下に落ちてくるようにします。このライン消去処理を実装します。

Step 6でブロックを固定(placePiece())した後に、clearLines()関数を呼び出すようにします。

function clearLines() {
  let linesCleared = 0; // 今回の操作で消去したライン数をカウント

  // ゲームボードの下から順番に各行をチェック
  for (let y = rows - 1; y >= 0; y--) {
    // その行の全てのセルにブロックがあるか? (値が0以外か)
    if (board[y].every((value) => value !== 0)) {
      // 全て埋まっていた場合、その行をボード配列から削除
      board.splice(y, 1);
      // ボードの一番上に新しい空の行 (全て0) を追加
      board.unshift(Array(cols).fill(0));
      // 行を削除したので、同じy座標をもう一度チェックする必要があるため、yをインクリメント
      y++;
      // 消去したライン数をカウント
      linesCleared++;
    }
  }
  // ※ スコア加算処理は次のステップで追加
  // if (linesCleared > 0) { ... updateScore(...) ... }
}

// dropPiece関数内で placePiece() の後に clearLines() を呼び出すように修正
function dropPiece() {
  currentPiece.y += 1;
  if (collision()) {
    currentPiece.y -= 1;
    placePiece();
    // ここでライン消去を呼び出す
    clearLines(); // <-- 追加
    currentPiece = nextPiece;
    nextPiece = randomPiece();
    drawNextPiece();
    // lockStart = null; // Step 10
    if (collision()) {
      gameOver();
    }
  }
  // else { lockStart = null; } // Step 10
}

// hardDrop関数内でも placePiece() の後に clearLines() を呼び出すように修正
function hardDrop() {
  while (!collision()) {
    currentPiece.y += 1;
  }
  currentPiece.y -= 1;
  placePiece();
  // ここでライン消去を呼び出す
  clearLines(); // <-- 追加
  // lockStart = performance.now(); // Step 10
  // dropPiece(); // Step 10 で修正
  currentPiece = nextPiece; // hardDrop後も次のブロックが登場
  nextPiece = randomPiece();
  drawNextPiece();
  // lockStart = null; // Step 10
  if (collision()) {
      gameOver();
  }
}

clearLines()関数は、ボードの最も下の行から順番に上に向かって、各行が全てブロックで埋まっているか(everyメソッドを使って判定)を確認します。もし全て埋まっていれば、その行をboard配列から削除し、配列の先頭(一番上)に新しい空の行を追加します。これにより、上の行が一段下に移動したように見えます。削除した行の位置をもう一度チェックする必要があるため、ループ変数のyをインクリメントしているのがポイントです。

これで、ラインが揃うと消去されるようになりました。

Step 9: 1行そろうとスコアが加算される実装

ラインを消去した際に、消去したライン数に応じてプレイヤーのスコアを加算し、画面に表示します。

事前準備でHTMLに用意したscoreBoard要素を取得します。スコアを保持する変数も用意します。

const scoreBoard = document.getElementById("scoreBoard") as HTMLElement;
let score = 0; // 現在のスコア

// スコア表示を更新する関数
function updateScore(points: number) {
  score += points; // スコアを加算
  // スコアボードのテキストを更新
  scoreBoard.innerText = `Score: ${score.toString().padStart(5, '0')}`; // 5桁表示の例
}

// ゲーム開始時やリセット時にスコアを0にリセットし、表示を更新
function resetGame() {
  score = 0;
  updateScore(0); // 初期スコア表示を更新
  board = Array.from({ length: rows }, () => Array(cols).fill(0));
  initializePieces();
}

// initializeGame 関数内でも初期スコア表示を呼び出す
function initializeGame() {
  board = Array.from({ length: rows }, () => Array(cols).fill(0));
  score = 0; // スコア初期化
  updateScore(0); // スコア表示を初期化
  currentPiece = randomPiece();
  nextPiece = randomPiece();
  drawNextPiece();
  requestAnimationFrame(update);
}

そして、Step 8で作成したclearLines()関数内に、消去したライン数(linesCleared)に応じてスコアを加算するロジックを追加します。

function clearLines() {
  let linesCleared = 0;
  // ... (ライン消去処理) ...

  // 消去したライン数が1以上の場合
  if (linesCleared > 0) {
    // 消去したライン数に応じたポイントを計算 (例: 1ライン100点, 2ライン300点, 3ライン500点, 4ライン800点)
    const points = [0, 100, 300, 500, 800][linesCleared];
    // スコア更新関数を呼び出し
    updateScore(points);
  }
}

updateScore(points)関数は、引数で受け取ったポイントをグローバル変数scoreに加算し、scoreBoard要素のテキストを更新することで、現在のスコアを画面に表示します。clearLines関数内で、消去したライン数に応じて[0, 100, 300, 500, 800]のような配列を使って得点を計算し、updateScoreを呼び出しています。

これで、ラインを揃えることで得点が入るようになりました。

Step 10: ブロックが落ちてもロックされるまでに少し時間を設ける実装

落ち物パズルゲームでは、ブロックが接地(他のブロックやボードの底に触れること)しても、すぐに固定されるわけではなく、少しの間操作の猶予がつけるとゲームの面白さが上がります。この「固定猶予(Lock Delay)」の仕組みを実装します。

これは、Step 6でブロックを固定する処理を呼び出していたdropPiece()関数を修正することで実現します。固定猶予の時間を設定する定数と、猶予時間の計測を開始した時刻を記録する変数を導入します。

const lockDelay = 300; // 固定猶予時間 (ミリ秒) - 例: 0.3秒
let lockStart: number | null = null; // 固定猶予の計測開始時刻。猶予中でないときはnull

function dropPiece() {
  currentPiece.y += 1; // まず下に移動させてみる
  if (collision()) { // 下に移動して衝突した (落下できない)
    currentPiece.y -= 1; // 元のY座標に戻す

    // 落下できない状態になったのが初めてなら、猶予時間の計測を開始
    if (lockStart === null) {
      lockStart = performance.now();
    } else {
      // 既に落下できない状態が継続していて、かつ猶予時間(lockDelay)が経過したかチェック
      if (performance.now() - lockStart >= lockDelay) {
        // 猶予時間経過!ブロックを固定する
        placePiece();
        clearLines(); // ライン消去
        currentPiece = nextPiece; // 次のブロックへ
        nextPiece = randomPiece(); // 新しいNextブロック生成
        drawNextPiece(); // Nextブロック描画更新
        lockStart = null; // 固定猶予をリセット

        // 新しいブロックが出現した時点で即衝突ならゲームオーバー
        if (collision()) {
          gameOver();
        }
      }
      // 猶予時間内であれば、ここではまだ固定しない
    }
  } else {
    // 下に移動できた場合(接地していない)、固定猶予は発生しないのでリセット
    lockStart = null;
  }
}

// hardDrop() 関数も、落下後にlockStartを設定するように修正します。
// ハードドロップ後も、接地したブロックに少し操作猶予を与えるためです。
function hardDrop() {
  while (!collision()) {
    currentPiece.y += 1;
  }
  currentPiece.y -= 1; // 最下部位置に決定

  lockStart = performance.now(); // ハードドロップ後、固定猶予の計測を開始

  // ハードドロップ後の固定処理は、dropPieceの猶予時間判定に任せる
  // ※ 元コードでは hardDrop内でも placePiece等を呼んでいますが、
  //    ロック猶予をdropPieceに集約するなら、ここでplacePieceは呼びません。
  //    ただし、元のコードはハードドロップ即固定+猶予という少し異なる挙動なので、
  //    元のコードの意図通りにする場合は以下のようになります。
  // placePiece(); // 最下部に固定 (猶予時間内でも操作はできるが即固定されている状態)
  // clearLines();
  // currentPiece = nextPiece;
  // nextPiece = randomPiece();
  // drawNextPiece();
  // if (collision()) { gameOver(); }
}

lockDelaylockStart変数を使って、ブロックが接地した(落下できなくなった)タイミングでlockStartに現在の時刻を記録します。その状態がlockDelayミリ秒以上続いた場合にのみ、ブロックの固定処理(placePiece()など)を実行します。猶予時間内にプレイヤーがブロックを左右に移動させたり回転させたりして、再び下に落下できるようになった場合は、collision()falseになるためlockStartnullにリセットされ、猶予期間は終了となります。

hardDrop()後もlockStartを設定することで、ハードドロップ後もわずかな時間だけ操作の猶予が生まれるようになります。

まとめ

今回の記事では、TypeScriptとHTMLのCanvas APIを使って、ブラウザ上で動作するテトリスのような落ち物パズルゲームゲームをゼロから構築する過程を、10のステップに分けて丁寧に解説しました。

  1. Canvasを使った描画方法
  2. ブロックのデータ構造と描画
  3. キーボード入力による操作
  4. ゲームループと時間経過による自動落下
  5. 壁や固定ブロックとの衝突判定
  6. ブロックの固定
  7. 次のブロックの描画と出現
  8. ライン消去
  9. スコア加算
  10. 操作猶予を生むロックディレイ

これらのステップを通じて、ゲーム開発における基本的な考え方や実装テクニックを学んでいただけたかと思います。特に、ゲームの状態をデータとして持ち、ゲームループの中でその状態を更新し、Canvasに再描画するというサイクルが、多くの2Dゲームの基本構造であることを理解していただけたのではないでしょうか。

今回作成したゲームはシンプルなものですが、ここからさらにホールド機能、異なる回転法則(SRS)、ゴーストピース表示、レベルによる落下速度の変化、サウンドエフェクト、スコアランキング機能など、様々な要素を追加してゲームをより面白く、完成度高くしていくことが可能です。

ぜひ、この記事で得た知識を元に、ご自身のアイデアを加えてオリジナルのゲーム開発に挑戦してみてください。皆様の今後の学習や制作のきっかけとなれば幸いです。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?