JavaScriptでタイルマップを描く:2次元配列からステージへ
遊びをつくる執筆にClaudeを使用しています。

ブラウザゲームのステージを作るとき、背景をひとつの大きな画像で用意する方法はすぐに限界を迎えます。ステージを変えるたびに新しい画像を作り直す手間がかかり、当たり判定のデータとも連動させにくくなるからです。タイルマップはその問題を解決する定番の仕組みで、「どのマス目にどのタイルを置くか」を2次元配列として持ち、その配列をループで描画する設計です。 この記事では、HTMLのCanvas要素とJavaScriptだけを使って、2次元配列をゲームのステージとして画面に描き出す手順を説明します。画像のスプライトシートからタイルを切り出す方法、配列の数値と見た目を対応付ける設計、そしてカメラスクロールへの拡張まで、実際に動くコードの構造を中心に解説します。
この記事の目次
2次元配列をステージデータとして設計する
タイルマップの出発点は、整数だけを並べた2次元配列です。行が縦方向(Y軸)、列が横方向(X軸)に対応し、各要素の数値が「そのマスに置くタイルの種類」を示します。
const MAP = [
[1, 1, 1, 1, 1],
[1, 0, 0, 0, 1],
[1, 0, 2, 0, 1],
[1, 1, 1, 1, 1],
];この例では 0 が床、1 が壁、2 がアイテム配置場所といった意味を持たせています。重要なのは、配列の添字がそのままマス目の座標になる点です。MAP[row][col] の値を見れば、行 row・列 col のマスに何を描くかが即座に分かります。
ステージのサイズが大きくなってもこの構造は変わりません。配列を外部のJSONファイルに切り出して fetch で読み込むようにすれば、コードを書き換えずにステージデータだけを差し替えられます。最初は配列をJSファイルに直書きして動かしてみて、ステージ数が増えてきたらJSONへ移行する、という順番が混乱しにくいです。
スプライトシートからタイルを切り出す
タイルの画像を1枚ずつ用意する代わりに、すべてのタイルを1枚の画像(スプライトシート)にまとめておくと管理が楽です。16×16ピクセルのタイルが横4枚・縦4枚で並んでいる場合、タイルのインデックス番号から切り出し位置を計算する関数を用意します。
const TILE_SIZE = 16;
const SHEET_COLS = 4; // シートの横に並ぶタイル数
function getTileSourceXY(tileIndex) {
const col = tileIndex % SHEET_COLS;
const row = Math.floor(tileIndex / SHEET_COLS);
return { sx: col * TILE_SIZE, sy: row * TILE_SIZE };
}tileIndex が 0 なら左上の1枚目、1 なら右隣、4 なら2行目の左端、という具合です。マップ配列の数値をそのままタイルインデックスとして使うと、配列の値と画像の切り出し位置が直結するので余計な変換テーブルが不要になります。
ただし、「壁は描きたいが床(値 0)は何も描かない」という使い方もよくあります。その場合は tileIndex === 0 のときに drawImage をスキップするだけでよく、透明タイルを画像側に用意する必要はありません。
ループで配列を走査して描画する
スプライトシートの Image オブジェクトのロードが完了したら、マップ配列を2重ループで走査してCanvasに描きます。
const img = new Image();
img.src = 'tileset.png';
img.onload = () => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
for (let row = 0; row < MAP.length; row++) {
for (let col = 0; col < MAP[row].length; col++) {
const tileIndex = MAP[row][col];
if (tileIndex === 0) continue; // 空マスはスキップ
const { sx, sy } = getTileSourceXY(tileIndex);
const dx = col * TILE_SIZE;
const dy = row * TILE_SIZE;
ctx.drawImage(
img,
sx, sy, TILE_SIZE, TILE_SIZE, // 切り出し元
dx, dy, TILE_SIZE, TILE_SIZE // 描画先
);
}
}
};dx = col * TILE_SIZE、dy = row * TILE_SIZE という計算が配列の添字をピクセル座標に変換している核心部分です。タイルサイズを変えたければ TILE_SIZE の値を変えるだけで全体が追従します。
よくある失敗として、画像のロード完了前に描画処理を呼んでしまうケースがあります。img.onload のコールバック内に描画処理を置くか、Promiseでラップして await する形にしておくと、この順序の問題を確実に回避できます。
カメラオフセットでスクロールに対応する
ステージがCanvasより大きい場合、「どこを表示するか」を管理するカメラが必要です。カメラの位置を cameraX・cameraY(ピクセル単位)として持っておき、描画先の座標から引くだけでスクロールを実現できます。
const cameraX = 32; // カメラの左端がマップの何ピクセル目か
const cameraY = 0;
const dx = col * TILE_SIZE - cameraX;
const dy = row * TILE_SIZE - cameraY;ただし、Canvas外にはみ出る座標も計算の対象に入るため、画面外のタイルまですべて drawImage を呼ぶと無駄な処理が増えます。描画前に「このタイルはCanvas内に入るか」を判定して、範囲外のものをスキップすると動作が軽くなります。
if (dx + TILE_SIZE < 0 || dx > canvas.width) continue;
if (dy + TILE_SIZE < 0 || dy > canvas.height) continue;カメラの移動はプレイヤーキャラクターの座標に追従させるのが一般的です。プレイヤーのX座標を中心にカメラを置く場合、cameraX = playerX - canvas.width / 2 のように計算し、マップの端でクランプ(最小・最大値に制限)すると画面外が見えなくなります。
レイヤーを重ねて表現の幅を広げる
地面・装飾・当たり判定を分離したい場合は、マップ配列を複数用意して描画を重ねます。
const LAYER_GROUND = [ /* 地面タイル */ ];
const LAYER_DECO = [ /* 装飾タイル */ ];
const LAYER_COLLISION = [ /* 当たり判定フラグ */ ];描画は LAYER_GROUND → LAYER_DECO の順にCanvasへ描き、LAYER_COLLISION は描画せずにゲームロジック(壁判定・ダメージ判定など)にだけ使います。配列を関数の引数にして drawLayer(ctx, img, LAYER_GROUND, cameraX, cameraY) のように呼び出す形にしておくと、レイヤーが増えても同じ関数を再利用できます。
当たり判定レイヤーを整数で持っておくと、「値が 1 のマスは壁」「値が 2 のマスは水」といった複数種類の判定を配列の値だけで区別できます。プレイヤーの次の座標がどのマスに入るかを Math.floor(x / TILE_SIZE) で求め、そのマスの値を参照するだけで衝突チェックが完結します。
次の行動
まず10×8程度の小さいマップ配列を作り、タイルサイズを固定して描画ループだけを動かしてみてください。スクロールや当たり判定はその後に追加するほうが、どこで問題が起きているかを切り分けやすくなります。動くステージができたら、アソバースにZIPまたはHTMLファイルとして投稿して他のプレイヤーに試してもらうと、ステージ設計の改善点が見えてきます。
参考資料
- MDN Web Docs「CanvasRenderingContext2D: drawImage() method」
- MDN Web Docs「HTMLCanvasElement」

