「2048ゲームを自分で作ってみたい」
「JavaScriptの練習になるミニゲーム教材を探している」
そんな方に向けて、本記事では HTML・CSS・JavaScript のみで作れる軽量2048ゲーム の作り方を、初心者でも迷わないように丁寧に解説します。
この記事では、
コピペすればそのまま動く完全版のソースを使いながら、
どの部分がどんな役割をしているのかを、わかりやすく噛み砕いて説明していきます。
「JavaScriptでゲームを作るのは難しそう…」
と思うかもしれませんが、大丈夫。
2048は “ルールがシンプル・ロジックが整理しやすい” ので、初心者でも確実にステップアップできます。
2048ゲームの完成イメージと仕様を確認しよう
まずは、この作り方記事で完成する 2048ゲームのイメージ をつかんでおきましょう。
今回あなたが作るのは、ブラウザでサクサク動く 軽量版2048ゲーム です。
完成版を体験してみよう
ゲームの仕様まとめ
2048のルールはとてもシンプルです。
- 4×4 のグリッドでタイルをスライドさせる
- 同じ数字がぶつかると合体して2倍になる
例:2→4→8→16→32… - 毎ターン移動のあとに、新しい「2」または「4」がランダムに出現
- 2048のタイルができればクリア!(ゲームは続行可能)
- 動かせなくなったらゲームオーバー
操作は
- PC:矢印キー(←↑→↓)
- スマホ:スワイプ
必要なファイルと準備
この2048ゲームは、
1つのHTMLファイルだけで完結するシンプルな構成になっています。
まずは「土台」となるファイルを用意して、
そこに完成版ソースコードをまるごと貼り付ける、という流れで進めていきます。
用意するもの
最低限、次の3つがあればOKです。
- パソコン(Windows / Mac どちらでも可)
- Webブラウザ
- Google Chrome 推奨(EdgeやFirefoxでも可)
- テキストエディタ
- Visual Studio Code / VSCode
- または メモ帳、メモ帳アプリ など
本格的な環境構築は不要で、
「ローカルにHTMLファイルを1枚置いて、ブラウザで開くだけ」で動きます。
htmlファイルを作成する
- デスクトップや任意のフォルダで
右クリック → 新規作成 → テキストドキュメント を選びます。 - ファイル名を
2048_game.html
に変更します(.txtが残って2048_game.html.txtにならないよう注意)。 - この
2048_game.htmlを、テキストエディタ(VSCode など)で開きます。
これで「ゲームを置く箱」の準備が完了です。
完成コードをまるごと貼り付ける
下の「完成コード」をすべてコピーして、2048_game.html に貼り付け、上書き保存します。
完成コード
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 |
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>2048風ゲーム</title> <style> :root { --grid-size: 4; --tile-gap: 12px; /* ボード背景: 濃いブルーグレー/スレート (コントラスト強調) */ --board-bg: #607d8b; /* 空きセル背景: 中くらいのブルーグレー */ --empty-cell-bg: #90a4ae; /* 濃いテキスト: ダークスレート */ --tile-font: #37474f; /* 明るいテキスト: 白 */ --light-text: #fcfcfc; --size: min(90vw, 450px); } /* Basic Reset and Layout */ body { margin: 0; font-family: 'Clear Sans', 'Helvetica Neue', Arial, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 80vh; /* ベース背景: 非常に薄いグレー */ background-color: #fafafa; padding: 10px; user-select: none; overflow: hidden; } .header { display: flex; justify-content: space-between; align-items: flex-start; width: var(--size); max-width: 450px; margin-bottom: 10px; } h1 { font-size: 50px; font-weight: bold; color: var(--tile-font); margin: 0; } .scores { display: flex; gap: 5px; } .score-container { /* スコアコンテナ: 濃いスレートブルー */ background: #455a64; color: var(--light-text); padding: 8px 15px; border-radius: 5px; text-align: center; line-height: 1.2; font-weight: bold; font-size: 13px; } .score-value { font-size: 25px; } .controls { display: flex; justify-content: space-between; align-items: center; width: var(--size); max-width: 450px; margin-bottom: 20px; } .message { color: var(--tile-font); font-weight: bold; min-height: 20px; text-align: left; flex-grow: 1; padding-right: 10px; } .btn-new-game { /* ボタン: コーラル(赤オレンジ)で強いアクセント */ background-color: #ff7043; color: var(--light-text); padding: 10px 15px; border-radius: 5px; font-weight: bold; cursor: pointer; border: none; transition: background 0.2s; flex-shrink: 0; } .btn-new-game:hover { background-color: #ff8a65; } /* Game Grid */ .game-container { position: relative; } .grid { width: var(--size); height: var(--size); max-width: 450px; max-height: 450px; background: var(--board-bg); border-radius: 6px; padding: var(--tile-gap); display: grid; grid-template-columns: repeat(var(--grid-size), 1fr); grid-template-rows: repeat(var(--grid-size), 1fr); gap: var(--tile-gap); box-sizing: border-box; } .grid-cell { width: 100%; height: 100%; border-radius: 3px; background: var(--empty-cell-bg); } /* Tile Layer and Tiles */ .tile-container { position: absolute; width: var(--size); height: var(--size); max-width: 450px; max-height: 450px; top: 0; left: 0; pointer-events: none; } .tile { position: absolute; display: flex; align-items: center; justify-content: center; border-radius: 3px; font-weight: 900; box-sizing: border-box; z-index: 2; } /* タイルが重なるときのために、z-indexを値に応じて調整 */ .tile[data-value="2"] { z-index: 3; } .tile[data-value="4"] { z-index: 4; } .tile[data-value="8"] { z-index: 5; } /* Tile Color Mapping (値と色のマッピング) */ /* 2 - 8: 明るいティール系 (フォント: 濃いテキスト) */ .tile[data-value="2"] { background: #e0f2f1; color: var(--tile-font); font-size: 35px; } .tile[data-value="4"] { background: #b2dfdb; color: var(--tile-font); font-size: 35px; } .tile[data-value="8"] { background: #80cbc4; color: var(--tile-font); font-size: 35px; } /* 16 - 128: 深いティール/エメラルド系 (フォント: 明るいテキスト) */ .tile[data-value="16"] { background: #4db6ac; color: var(--light-text); font-size: 35px; } .tile[data-value="32"] { background: #26a69a; color: var(--light-text); font-size: 35px; } .tile[data-value="64"] { background: #00897b; color: var(--light-text); font-size: 35px; } .tile[data-value="128"] { background: #00695c; color: var(--light-text); font-size: 30px; } /* 256 - 2048: インディゴ系 (フォント: 明るいテキスト) */ .tile[data-value="256"] { background: #45A1CF; color: var(--light-text); font-size: 30px; } .tile[data-value="512"] { background: #208DC3; color: var(--light-text); font-size: 30px; } .tile[data-value="1024"] { background: #007AB7; color: var(--light-text); font-size: 25px; } .tile[data-value="2048"] { background: #3261AB; color: var(--light-text); font-size: 25px; } /* 4桁以上のタイルはフォントサイズを小さく調整 */ .tile[data-value^="4"][data-value$="96"], .tile[data-value^="8"][data-value$="92"] { font-size: 20px; } /* Game Over Overlay */ .game-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(255, 255, 255, 0.7); border-radius: 6px; display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; opacity: 0; pointer-events: none; transition: opacity 300ms ease; } .game-overlay.active { opacity: 1; pointer-events: auto; } .overlay-message { font-size: 30px; font-weight: bold; color: var(--tile-font); margin-bottom: 20px; } /* Responsive Font Sizing */ @media (max-width: 450px) { h1 { font-size: 40px; } .score-value { font-size: 20px; } .score-container { padding: 5px 10px; font-size: 10px; } /* タイルフォントサイズを小型画面用に調整 */ .tile[data-value="2"], .tile[data-value="4"] { font-size: 30px; } .tile[data-value="8"], .tile[data-value="16"], .tile[data-value="32"], .tile[data-value="64"] { font-size: 30px; } .tile[data-value="128"], .tile[data-value="256"], .tile[data-value="512"] { font-size: 25px; } .tile[data-value="1024"], .tile[data-value="2048"] { font-size: 20px; } .overlay-message { font-size: 24px; } } </style> </head> <body> <div class="header"> <h1>2048</h1> <div class="scores"> <div class="score-container"> SCORE <div id="score" class="score-value">0</div> </div> <div class="score-container"> BEST <div id="highScore" class="score-value">0</div> </div> </div> </div> <div class="controls"> <div id="message" class="message">矢印キーまたはスワイプで操作します</div> <button id="newGameButton" class="btn-new-game">New Game</button> </div> <div class="game-container"> <div id="gameGrid" class="grid"> </div> <div id="tileContainer" class="tile-container"> </div> <div id="gameOverOverlay" class="game-overlay"> <div id="overlayMessage" class="overlay-message">Game Over!</div> <button id="tryAgainButton" class="btn-new-game">Try Again</button> </div> </div> <script> // =============================================== // 1. 定数と状態管理 (Constants and State) // =============================================== const SIZE = 4; const TILE_GAP = 12; const DEFAULT_MESSAGE = '矢印キーまたはスワイプで操作します'; let board = []; let score = 0; let highScore = 0; let gameEnded = false; // DOM Elements const gridEl = document.getElementById('gameGrid'); const tileContainerEl = document.getElementById('tileContainer'); const scoreEl = document.getElementById('score'); const highScoreEl = document.getElementById('highScore'); const newGameButton = document.getElementById('newGameButton'); const tryAgainButton = document.getElementById('tryAgainButton'); const messageEl = document.getElementById('message'); const gameOverOverlayEl = document.getElementById('gameOverOverlay'); const overlayMessageEl = document.getElementById('overlayMessage'); // =============================================== // 2. ユーティリティ (Utilities) // =============================================== /** ローカルストレージからハイスコアをロードする */ const loadHighScore = () => { const storedScore = localStorage.getItem('2048_high_score'); highScore = storedScore ? parseInt(storedScore) : 0; highScoreEl.textContent = highScore; }; /** ハイスコアを更新する */ const updateHighScore = () => { if (score > highScore) { highScore = score; localStorage.setItem('2048_high_score', highScore); highScoreEl.textContent = highScore; } }; /** タイルの位置とサイズをグリッドの実際の幅に基づいて正確に計算 */ const getTilePosition = (row, col) => { const boardWidth = gridEl.offsetWidth; const tileGap = TILE_GAP; const tileSize = (boardWidth - (5 * tileGap)) / SIZE; const x = tileGap + col * (tileSize + tileGap); const y = tileGap + row * (tileSize + tileGap); return { x, y, tileSize }; }; /** タイルDOM要素のスタイルを設定する */ const setTileStyle = (tileEl, row, col, value) => { const { x, y, tileSize } = getTilePosition(row, col); tileEl.style.width = `${tileSize}px`; tileEl.style.height = `${tileSize}px`; // translate transform を適用 tileEl.style.transform = `translate(${x}px, ${y}px)`; tileEl.dataset.value = value; tileEl.textContent = value; tileEl.dataset.r = row; tileEl.dataset.c = col; }; // =============================================== // 3. ゲームロジック (Core Logic) // =============================================== /** 新しいタイルDOM要素を作成する */ const createTileElement = (r, c, value) => { const tileEl = document.createElement('div'); tileEl.classList.add('tile'); setTileStyle(tileEl, r, c, value); return tileEl; }; /** 盤面全体を再描画する */ const redrawBoard = () => { tileContainerEl.innerHTML = ''; for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] !== 0) { const tileEl = createTileElement(r, c, board[r][c]); tileContainerEl.appendChild(tileEl); } } } }; /** 新しいタイルをランダムな空きマスに追加する */ const addNewTile = (count = 1) => { const emptyCells = []; for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] === 0) { emptyCells.push({ r, c }); } } } for (let i = 0; i < count && emptyCells.length > 0; i++) { const index = Math.floor(Math.random() * emptyCells.length); const { r, c } = emptyCells.splice(index, 1)[0]; const value = Math.random() < 0.9 ? 2 : 4; board[r][c] = value; } }; /** 1行(または列)のスライドと合体を処理する */ const slideAndMergeLine = (line) => { const originalLine = [...line]; const filtered = line.filter(v => v !== 0); const result = []; let i = 0; while (i < filtered.length) { if (i + 1 < filtered.length && filtered[i] === filtered[i + 1]) { const mergedValue = filtered[i] * 2; result.push(mergedValue); score += mergedValue; i += 2; } else { result.push(filtered[i]); i += 1; } } /** 残りを 0 で埋めて 4 マスの行に揃える */ while (result.length < SIZE) result.push(0); let lineMoved = false; for(let k = 0; k < SIZE; k++) { if (originalLine[k] !== result[k]) { lineMoved = true; break; } } return { newLine: result, moved: lineMoved }; }; /** 盤面を操作する (スライドと合体) */ const move = (dir) => { let moved = false; for (let i = 0; i < SIZE; i++) { let line = []; // 1. 行/列を抽出 for (let j = 0; j < SIZE; j++) { if (dir === 0) line.push(board[j][i]); else if (dir === 2) line.push(board[SIZE - 1 - j][i]); else if (dir === 3) line.push(board[i][j]); else if (dir === 1) line.push(board[i][SIZE - 1 - j]); } // 2. スライドと合体 const { newLine, moved: lineMoved } = slideAndMergeLine(line); if (lineMoved) moved = true; // 3. 盤面に戻す for (let j = 0; j < SIZE; j++) { const val = newLine[j]; if (dir === 0) board[j][i] = val; else if (dir === 2) board[SIZE - 1 - j][i] = val; else if (dir === 3) board[i][j] = val; else if (dir === 1) board[i][SIZE - 1 - j] = val; } } if (moved) { addNewTile(); redrawBoard(); updateGameStatus(); if (!hasLegalMoves()) { endGame(false); } } return moved; }; /** 合法手があるかチェックする (ゲームオーバー判定用) */ const hasLegalMoves = () => { for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] === 0) return true; const value = board[r][c]; const directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]; for (const [dr, dc] of directions) { const nr = r + dr; const nc = c + dc; if (nr >= 0 && nr < SIZE && nc >= 0 && nc < SIZE && board[nr][nc] === value) { return true; } } } } return false; }; /** ゲームの状態を更新する */ const updateGameStatus = () => { scoreEl.textContent = score; updateHighScore(); messageEl.textContent = DEFAULT_MESSAGE; let won = false; for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] === 2048) { won = true; break; } } } if (won && !gameEnded) { messageEl.textContent = '2048達成!続けて高スコアを目指せます'; } }; // =============================================== // 4. ゲーム制御 (Game Control) // =============================================== /** ゲームを初期化し、開始する */ const startGame = () => { board = new Array(SIZE).fill(0).map(() => new Array(SIZE).fill(0)); score = 0; gameEnded = false; gridEl.innerHTML = Array.from({ length: SIZE * SIZE }, () => `<div class="grid-cell"></div>`).join(''); gameOverOverlayEl.classList.remove('active'); addNewTile(2); redrawBoard(); updateGameStatus(); }; /** ゲームを終了する */ const endGame = (win = false) => { gameEnded = true; gameOverOverlayEl.classList.add('active'); overlayMessageEl.textContent = 'Game Over!'; updateHighScore(); }; // =============================================== // 5. 入力処理 (Input Handling) // =============================================== /** 共通のムーブ処理 */ const handleMove = (dir) => { if (gameEnded) return; const moved = move(dir); if (!moved && !hasLegalMoves()) { endGame(false); } }; // --- キーボード入力 --- document.addEventListener('keydown', (e) => { let direction = null; switch (e.key) { case 'ArrowUp': direction = 0; // 上 break; case 'ArrowRight': direction = 1; // 右 break; case 'ArrowDown': direction = 2; // 下 break; case 'ArrowLeft': direction = 3; // 左 break; } if (direction !== null) { e.preventDefault(); handleMove(direction); } }); // --- スワイプ入力 (モバイル対応) --- let touchstartX = 0; let touchstartY = 0; const SWIPE_THRESHOLD = 50; document.addEventListener('touchstart', (e) => { if (e.touches.length === 1) { touchstartX = e.touches[0].clientX; touchstartY = e.touches[0].clientY; } }); document.addEventListener('touchmove', (e) => { if (e.touches.length === 1 && (Math.abs(e.touches[0].clientX - touchstartX) > 10 || Math.abs(e.touches[0].clientY - touchstartY) > 10)) { e.preventDefault(); } }, { passive: false }); document.addEventListener('touchend', (e) => { if (e.changedTouches.length !== 1) return; const touchendX = e.changedTouches[0].clientX; const touchendY = e.changedTouches[0].clientY; const dx = touchendX - touchstartX; const dy = touchendY - touchstartY; let direction = null; if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SWIPE_THRESHOLD) { direction = dx > 0 ? 1 : 3; } else if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > SWIPE_THRESHOLD) { direction = dy > 0 ? 2 : 0; } if (direction !== null) { handleMove(direction); } }); // =============================================== // 6. 初期化とリサイズ対応 (Initialization & Resize) // =============================================== newGameButton.addEventListener('click', startGame); tryAgainButton.addEventListener('click', startGame); loadHighScore(); startGame(); // ウィンドウリサイズ時のタイル位置ズレを解消 window.addEventListener('resize', () => { redrawBoard(); }); </script> </body> </html> |
ブラウザで動作確認する
2048_game.htmlを保存します。- エクスプローラー/Finder で
2048_game.htmlをダブルクリックします。 - ブラウザが開き、2048ゲームの画面が表示されればOKです。
矢印キーやスワイプでタイルが動き、
スコアが加算されていれば、準備は完了です。
HTMLの構造解説(盤面やスコア表示など“見た目の骨組み”)
この章では、2048ゲームの「見た目の骨組み」をつくっている HTML構造 を整理して解説します。
ポイントは、
- スコア表示エリア
- メッセージと「New Game」ボタン
- ゲームボード本体(グリッド・タイル・ゲームオーバー表示)
この3つのブロックに分けて考えると、とても分かりやすくなります。
① ヘッダー部分:タイトルとスコア表示
まず最初のブロックが、ゲームタイトルとスコアを表示する ヘッダー部分 です。
ここでのポイントは次の3つです。
.header
→ タイトルとスコアをまとめる外枠コンテナです。.scoresと.score-container
→ 「SCORE」と「BEST(ハイスコア)」を横並びで表示するための囲いです。id="score"とid="highScore"
→ JavaScript側からスコアを更新するための要素です。
ゲーム中に、ここを書き換えることで数字がリアルタイムに変わります。
②コントロール部分:メッセージ+New Gameボタン
次に、ゲームの状態を表示するメッセージと、リセット用のボタンです。
.controls
→ メッセージとボタンをまとめているコンテナです。id="message"/.message
→ 「矢印キーまたはスワイプで操作します」など、
プレイヤーに状況を伝えるためのテキスト。
JavaScriptで、ゲーム開始・ゲームオーバーなどのタイミングに応じて文言を変えています。id="newGameButton"/.btn-new-game
→ 「New Game」ボタンです。
クリックすると盤面をリセットして、新しいゲームが始まります。
③ ゲーム本体:盤面・タイル・ゲームオーバー表示
ゲームの中心部分は、 .game-container の中に3つの要素が入っています。
gameGrid(背景グリッド)
id="gameGrid"/.grid
→ 4×4の背景マス(グレーっぽい空セル)を表示するエリア です。
実際の.tile(数字タイル)はここには入りません。
この中身は、JavaScript 側で「16個のセル」を自動生成して追加しています。
そのため、HTMLでは空の <div> のままにしておき、
「マスを置く場所」だけを用意している 形になっています。
tileContainer(数字タイルを配置する層)
id="tileContainer"/.tile-container
→ 実際の「2」「4」「8」…といった 数字タイルを絶対配置するためのコンテナ です。
ここが 2048 実装の重要ポイントで、
- 背景マス(
#gameGrid)と - 実際に動くタイル(
#tileContainer)
を「別レイヤー」に分けることで、
- タイルの位置計算(top・left)をやりやすく
- 見た目もきれいに重なる
というメリットがあります。
gameOverOverlay(ゲームオーバー時のオーバーレイ)
id="gameOverOverlay"/.game-overlay
→ ゲームオーバー時に表示される 半透明の黒い覆い(オーバーレイ) です。
通常時は非表示になっていて、ゲームオーバー時にだけ表示されます。id="overlayMessage"/.overlay-message
→ 「Game Over!」などのメッセージを表示する部分です。
JavaScript側で文言を書き換えて使います。id="tryAgainButton"/.btn-new-game
→ 「Try Again」ボタンです。New Gameボタンと同様、盤面をリセットして再スタートします。
CSSのポイント解説(配色・レイアウト・レスポンシブ対応)
この章では、2048ゲームの“見た目”を作っている CSS の重要ポイント をわかりやすく解説します。
全体の配色(背景色・タイル色・フォント色)
CSSでは、まず :root に配色がまとめられています。
|
1 2 3 4 5 6 7 8 9 |
:root { --grid-size: 4; --tile-gap: 12px; --board-bg: #607d8b; --empty-cell-bg: #90a4ae; --tile-font: #37474f; --light-text: #fcfcfc; --size: min(90vw, 450px); } |
- 配色変更が1箇所で完結
- タイル・盤面の統一感が出る
- レスポンシブ指定が簡単
- 開発時にも迷わない
盤面(グリッド)は .grid で構築
(※実際のHTML要素:<div id="gameGrid" class="grid"></div>)
|
1 2 3 4 5 6 7 8 9 10 |
.grid { width: var(--size); height: var(--size); background: var(--board-bg); padding: var(--tile-gap); display: grid; grid-template-columns: repeat(var(--grid-size), 1fr); grid-template-rows: repeat(var(--grid-size), 1fr); gap: var(--tile-gap); } |
主なポイント
- CSS Grid を使い 4×4マスを自動生成
- 背景は落ち着いたブルーグレー(
--board-bg) - マス同士の間隔は
--tile-gap: 12px
中の各マス(空セル)は .grid-cell で作られます。
これは タイルとは別レイヤー で、「盤面の枠」を作るためだけに存在します。
タイルは .tile-container の上に“絶対配置”
盤面の上に タイルだけを重ねるレイヤー が .tile-container です。
タイル1枚ずつは .tileです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
.tile-container { position: absolute; width: var(--size); height: var(--size); top: 0; left: 0; pointer-events: none; } .tile { position: absolute; display: flex; align-items: center; justify-content: center; border-radius: 3px; font-weight: 900; } |
JavaScriptで座標・サイズを制御
タイルは CSS Grid ではなく JS で transform: translate(x, y) を当てて動かします。
そのため .tile の位置指定はすべて 絶対配置(position:absolute) で管理されています。
タイルの色分け(値ごとにスタイル制御)
2048ゲーム特有の
数字が成長するごとに色が変わる演出
は、CSSの属性セレクタで管理しています。
|
1 2 3 4 |
.tile[data-value="2"] { background: #e0f2f1; color: var(--tile-font); } .tile[data-value="16"] { background: #4db6ac; color: var(--light-text); } .tile[data-value="256"] { background: #45A1CF; color: var(--light-text); } .tile[data-value="2048"] { background: #3261AB; color: var(--light-text); } |
レスポンシブ対応は @media でフォント調整
スマホ画面向けにタイルのフォントサイズを小さくする調整も含まれています。
|
1 2 3 4 |
@media (max-width: 450px) { .tile[data-value="2"], .tile[data-value="4"] { font-size: 30px; } .tile[data-value="1024"], .tile[data-value="2048"] { font-size: 20px; } } |
スマホでも数字が潰れないように最適化
フォントを段階的に縮小することで、
どの端末でも 見やすさをキープ できるようになっています。
JavaScriptロジック解説(タイル生成・合体・スライドなど)
この章では、2048ゲームの「中身の動き」を担当している
JavaScript部分のロジックを、なるべくやさしく分解して説明します。
ポイントになるのは、次の5つです。
- 定数とゲーム状態(
boardやscoreなど) - タイルの生成と描画(
tile-container内の.tile) - スライド&合体処理(
slideAndMergeLine()) - 盤面全体の移動処理(
move(dir)) - 入力処理(矢印キー・スワイプ)とゲーム開始・終了
順番に見ていきましょう。
定数とゲーム状態の管理
まず、ゲームの基本設定と現在の状態を表す変数が定義されています。
SIZE… グリッドの一辺の長さ(4×4なので 4)TILE_GAP… タイル間のすき間(CSSの--tile-gapと対応)board… 盤面を表す2次元配列(board[row][col]に数字が入る)score… 現在のスコアhighScore… ハイスコアgameEnded… ゲームが終了しているかどうか(入力無効にするフラグ)
そして、HTML側の各要素を getElementById() で取得しています。
タイルの位置計算と描画(見た目の座標ロジック)
タイル .tile は、#tileContainer の中で 絶対配置(position: absolute) されています。
「どのマスの上に置くか」を計算するのが getTilePosition() と setTileStyle() です。
|
1 2 3 4 5 6 7 8 9 10 11 |
const getTilePosition = (row, col) => { const boardWidth = gridEl.offsetWidth; const tileGap = TILE_GAP; const tileSize = (boardWidth - (5 * tileGap)) / SIZE; const x = tileGap + col * (tileSize + tileGap); const y = tileGap + row * (tileSize + tileGap); return { x, y, tileSize }; }; |
gridEl.offsetWidth… 実際のボード幅(レスポンシブ対応)tileSize… 1マスぶんの実サイズを計算x,y… 左上からの配置位置(タイルの座標)
この情報を使って、setTileStyle() で .tile の見た目を設定します。
ここで
.style.transform = translate(...)を使って、
CSSアニメーションなしで瞬時に位置を移動させています。
タイルDOMの生成と盤面の再描画
1枚のタイルを作るのが createTileElement() です。
|
1 2 3 4 5 6 |
const createTileElement = (r, c, value) => { const tileEl = document.createElement('div'); tileEl.classList.add('tile'); setTileStyle(tileEl, r, c, value); return tileEl; }; |
setTileStyle() を使って位置とサイズ・表示する数字を設定しています。
そして、盤面全体を描画し直すのが redrawBoard() です。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
const redrawBoard = () => { tileContainerEl.innerHTML = ''; for (let r = 0; r < SIZE; r++) { for (let c = 0; c < SIZE; c++) { if (board[r][c] !== 0) { const tileEl = createTileElement(r, c, board[r][c]); tileContainerEl.appendChild(tileEl); } } } }; |
board[r][c]に 0 以外の数字があれば.tileを生成- 一度
innerHTML = ''で空にしてから、全タイルを描き直す方式
→ アニメーションを使わない分、シンプルでバグが起きにくい実装です。
新しいタイルを追加する addNewTile()
タイルを動かした後に、ランダムな空きマスに
2 または 4 の新しいタイルを1枚(または複数)追加する関数です。
emptyCellsに空きマス(board[r][c] === 0)の座標をリストアップ- その中からランダムに1つ取り出し、
90%の確率で2、10%の確率で4をセット
1行ぶんのスライド&合体ロジック slideAndMergeLine()
2048の心臓部ともいえるのが、この関数です。
「1行(または1列)の数字」を受け取り、スライド+合体 を行います。
流れはこうです:
lineから 0 を取り除いて詰める(スライド)- 左から順に見て、隣が同じ数字なら合体
- 合体した値は
scoreに加算 - 1ペアごとに1回だけ合体(公式2048準拠)
- 合体した値は
- 足りない分は
0で埋めて、元の長さ(4マス)に戻す - 元の
lineと違いがあったかどうかをmovedで返す
→ 「この行は実際に変化したか?」を判定するため
盤面全体の移動処理 move(dir)
move(dir) は、上下左右いずれかの方向への移動を実現する関数です。
dirは 0=上、1=右、2=下、3=左- 各方向に応じて、
boardから 1行ぶんを取り出して並べ替え slideAndMergeLine()に渡してスライド&合体- 結果を
boardに書き戻す - どこか1行でも変化があったら、新しいタイルを1枚追加して
redrawBoard() - その後、
hasLegalMoves()で合法手が残っているかをチェック
合法手チェックとゲームオーバー判定 hasLegalMoves()
ゲームオーバーかどうかを調べるのが hasLegalMoves() です。
- 空きマスがあれば → まだ動かせる
- 隣(上下左右)に同じ値があれば → 合体できるのでまだ動かせる
- どちらもなければ → もう合法手なし → ゲームオーバー
ゲームオーバー時には endGame(false) が呼ばれ、#gameOverOverlay に .active クラスがついてオーバーレイが表示されます。
スコア更新と 2048 達成メッセージ updateGameStatus()
スコアやハイスコア、メッセージを更新するのが updateGameStatus() です。
scoreの表示を更新- ハイスコアは
updateHighScore()でローカルストレージにも保存 - 盤面に
2048があるかチェックし、達成していればメッセージを変更
→ 2048後もgameEndedはfalseのままなので、プレイ続行可能
入力処理(矢印キー&スワイプ)
共通のムーブ処理
共通のムーブ処理が handleMove() です。
キーボード(矢印キー)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
document.addEventListener('keydown', (e) => { let direction = null; switch (e.key) { case 'ArrowUp': direction = 0; break; case 'ArrowRight': direction = 1; break; case 'ArrowDown': direction = 2; break; case 'ArrowLeft': direction = 3; break; } if (direction !== null) { e.preventDefault(); handleMove(direction); } }); |
スワイプ(タッチ操作)
touchstart / touchend の座標差から
左右・上下どちらにスワイプしたかを判断し、handleMove(dir) を呼びます。
ゲーム開始・リセット startGame() と 初期化
ゲームのリセットは startGame() が担当します。
boardをすべて 0 で初期化#gameGridに 16個の.grid-cellを配置- オーバーレイを非表示に
- 最初のタイルを2枚追加(
addNewTile(2)) redrawBoard()でタイルを描画
おすすめの学習リソース|もっとスキルを伸ばしたいあなたへ
「2048ゲーム」を完成させたことで、あなたはすでに HTML・CSS・JavaScriptの基礎 に触れました。
ここからさらにステップアップするために役立つ学習リソースを紹介します。
おすすめ書籍
『ゲームで学ぶJavaScript入門 増補改訂版 ~ブラウザゲームづくりでHTML&CSSも身につく!』
- ゲームを作りながら学べるので、退屈せずにJavaScriptの基礎が身につく
- HTML・CSS・JavaScriptの基本を一冊で網羅、Web制作の土台が自然に理解できる
- 13本のサンプルゲームを実際に動かせるから「作れる喜び」が味わえる
- 初心者・中高生にもやさしい解説で、初めてのゲームプログラミングに最適
『スラスラわかるHTML&CSSのきほん』(SBクリエイティブ)
- Web制作初心者に最適な入門書。HTMLとCSSの仕組みをやさしく解説。
- これからWebページを作ってみたい方にぴったり。
- レイアウトの基本やスタイルの調整方法など、実践的に学べます。
オンライン講座編
Udemy|世界最大級のオンライン学習プラットフォーム
世界中で利用されるオンライン学習サイト。
HTML、CSS、JavaScriptの入門から応用まで、高評価の講座が数百種類揃っています。
初心者でも動画を見ながら手を動かせるので、挫折しにくいのが魅力です。
まとめ|シンプルな構造で“完成度の高い2048ゲーム”が作れる
2048ゲームは、一見シンプルに見えますが、
HTML・CSS・JavaScriptの基礎がしっかり詰まった良い教材です。
今回の実装では、
- HTMLで見た目の骨組みをわかりやすく定義
- CSSで配色・レイアウト・レスポンシブ対応を丁寧に実装
- JavaScriptでスライド、合体、タイル生成、ゲームオーバーまでのロジックを完全実装
という、Web制作の基本3要素がすべて揃っています。
HTML+CSS+JavaScriptだけで、
ここまでしっかり動くパズルゲームが作れるのは本当に魅力的です。
ぜひ、この2048をベースに、次の開発にも挑戦してみてください!
関連記事
【実際に遊べる2048ゲームはこちら。ベストスコア更新を目指して挑戦してみよう!】▼
2048ゲームづくりを楽しめた方は、以下の記事もおすすめです。
どれもブラウザだけで動く本格的なゲームを題材にしており、JavaScriptの理解がさらに深まります。
【作り方記事】スライドパズル(15パズル)の作り方|定番パズルゲームを作ろう▼
【作り方記事】五目並べAI対戦の作り方|本格的なAI構築の応用編!▼




