ブロックパズルのソースコード全文 — JavaScript・HTML・CSS

▶ ブロックパズルで遊ぶ 📖 作り方を1から学ぶ ✏️ このコードを書きかえて動かす

このページでは、ひなテックGamesで実際に動いている「ブロックパズル」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直した特別なコードではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。

ブロックパズルは7種類のブロック(テトロミノ)を回転・移動させてラインをそろえる、テトリス風の世界中で愛されているパズルゲームです。「マス目をデータでどう持つか」「ブロックを回転させるとはどういうことか」「ぶつかり判定をどうやるか」という、ゲーム作りの大事な考え方がぎっしり詰まっています。同じgame.jsで、40ラインを消すまでのタイムを競う「スプリント40」も動いています。

コードはコピーして自分のパソコンで自由に動かせます。「どうやってブロックが動いているんだろう?」と思ったら、解説を読みながらコードを追いかけてみてください。

ブロックパズルは3つのファイルでできている

ブロックパズルは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。

ファイル役割
index.htmlゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する
style.css見た目のデザイン。色・大きさ・レイアウト・アニメーション
game.jsゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など

この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。

index.html — ゲーム画面の骨組み

index.htmlは、ブラウザが最初に読み込むファイルです。ここにはゲームの「部品」をHTMLで並べているだけで、動きそのものは書かれていません。

大きく分けると、(1)スコア・ベスト・レベルを並べるscore-area、(2)ホールド表示・ゲーム盤・ネクスト表示を横に並べたtetris-area、(3)スマホ用の操作ボタンmobile-controls、(4)パソコンのときだけ出るキー操作の早見表key-guide、でできています。注目してほしいのはtetris-boardの中が空っぽなことです。盤面のマス(200個)はJavaScriptがあとから作って入れます。ゲームオーバー画面はgame-overlayとして盤の上に重ねてあります。スタート画面も同じ仕組みですが、ページを開くとJavaScriptがすぐ外して盤面を見せ、最初の操作まで落下を待ちます(そのあいだ出ているのがready-hintです)。

スプリント40で使う部品も同じHTMLに入っています。のこりのライン数を出すsprint-remain-boxと、ゴールしたときのタイムを出すsprint-clear-overlayです。ふだんはかくしておき、スプリントのときだけ表示します。スタート画面のmode-selectは、スプリント40のページ(../tetris-sprint/)へのリンクです。

ゲーム本体は<body>の中から始まります。

※ 下のコードでは、ゲームの動きに関係のないタグ(広告・アクセス解析・検索エンジン向けの記述)を省いています。

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <!-- title: 本命クエリ「テトリス 無料」(imp 57,142・順位7.5) との一致を早く見せるため
       「テトリス風」を先頭へ寄せた(2026-08-15)。実データで勝てている「ダウンロードなし」も維持する。
       ★商標配慮で「テトリス」単独表記は使わず、必ず「テトリス風」の形にすること -->
  <title>テトリス風ブロックパズル 無料|ダウンロードなしでwebブラウザですぐ遊べる|ひなテックGames</title>
  <meta name="description" content="テトリス風の無料ブロックパズル。ダウンロードなし・インストール不要・登録不要、webブラウザで開くだけですぐ遊べる無料サイト。PC・スマホ対応で安全。回転とホールドを駆使してラインを消し、ハイスコアランキングに挑戦!">
  <link rel="stylesheet" href="../../css/style.css?v=20260707">
  <link rel="stylesheet" href="style.css?v=20260916">
  <link rel="stylesheet" href="../../css/leaderboard.css">
</head>
<body>
  <div id="header"></div>

  <div class="game-page">
    <h1 class="game-page-title">ブロックパズル</h1>
    <div class="game-page-tags">
      <a href="../../tags/puzzle/" class="tag">パズル</a>
      <a href="../../tags/classic/" class="tag">クラシック</a>
    </div>

    <p class="game-lead">落ちてくるブロックを回転・移動させて、横一列をそろえて消していく定番のブロックパズル。<strong>ダウンロード不要・登録不要</strong>で、PCでもスマホでもブラウザからすぐ無料で遊べます。</p>

    <div class="game-container">
      <!-- スコアエリア -->
      <div class="score-area">
        <div class="score-box">
          <span class="score-label">スコア</span>
          <span class="score-value" id="score">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-score">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">レベル</span>
          <span class="score-value" id="level">1</span>
        </div>
        <!-- スプリント40モード専用HUD(スプリントプレイ中だけ表示) -->
        <div class="score-box" id="sprint-remain-box" hidden>
          <span class="score-label">のこり</span>
          <span class="score-value" id="sprint-remain">40</span>
        </div>
        <button class="btn-new-game" id="btn-new-game">New Game</button>
      </div>

      <!-- ゲームエリア(ホールド + ボード + ネクスト表示) -->
      <div class="tetris-area">
        <!-- ホールドピース表示 -->
        <div class="hold-piece-panel">
          <div class="hold-piece-label">ホールド</div>
          <div class="hold-piece-box" id="hold-piece-box"></div>
        </div>

        <!-- ゲームボード -->
        <div class="board-wrapper">
          <div class="tetris-board" id="tetris-board"></div>

          <!-- 待機中のヒント。最初の操作で消える(盤面が見えていても始め方が分からないと止まるため) -->
          <div class="ready-hint" id="ready-hint">
            <span class="ready-hint-pc">キーをおすとスタート!<kbd>←</kbd><kbd>→</kbd><kbd>↑</kbd></span>
            <span class="ready-hint-sp">下のボタンをおすとスタート!</span>
          </div>

          <!-- ゲームオーバーオーバーレイ -->
          <div class="game-overlay" id="game-over-overlay">
            <div class="overlay-content">
              <h2>ゲームオーバー</h2>
              <p>スコア: <span id="final-score">0</span></p>
              <p class="best-result" id="best-result"></p>
              <button class="btn-primary" id="btn-retry">もう一度遊ぶ</button>
            </div>
          </div>

          <!-- スプリント40クリアオーバーレイ(スプリントモード専用) -->
          <div class="game-overlay" id="sprint-clear-overlay">
            <div class="overlay-content">
              <h2>クリア!</h2>
              <p>タイム: <span id="sprint-time">0.0</span>秒</p>
              <button class="btn-primary" id="btn-sprint-retry">もう一度挑戦</button>
            </div>
          </div>

          <!-- スタートオーバーレイ -->
          <div class="game-overlay active" id="game-start-overlay">
            <div class="overlay-content">
              <h2>ブロックパズル</h2>
              <p>ブロックを並べてラインを消そう!</p>
              <!-- 操作説明は1行に詰め込むと読み飛ばされる。キーごとに分けて視認性を上げる -->
              <div class="start-keys">
                <span><kbd>←</kbd><kbd>→</kbd> 動かす</span>
                <span><kbd>↑</kbd> まわす</span>
                <span><kbd>Space</kbd> 一気に落とす</span>
              </div>
              <p class="overlay-hint">スマホは画面下のボタンで操作できます</p>
              <button class="btn-primary" id="btn-start">ゲームスタート</button>
              <!-- モード選択(JSが表示する。スプリント40は専用ページに独立) -->
              <div class="mode-select" id="mode-select" hidden>
                <a class="btn-primary mode-select-btn" href="../tetris-sprint/">スプリント40(40ラインRTA)はこちら →</a>
              </div>
            </div>
          </div>
        </div>

        <!-- ネクストピース表示 -->
        <div class="next-piece-panel">
          <div class="next-piece-label">つぎ</div>
          <div class="next-piece-box" id="next-piece-box"></div>
        </div>
      </div>

      <!-- モバイル操作ボタン -->
      <div class="mobile-controls" id="mobile-controls">
        <div class="mobile-controls-row">
          <button class="mobile-btn" id="btn-hold" aria-label="ホールド">&#9744; ホールド</button>
          <button class="mobile-btn" id="btn-rotate" aria-label="回転">&#10227;</button>
        </div>
        <div class="mobile-controls-row">
          <button class="mobile-btn" id="btn-left" aria-label="左">&#9664;</button>
          <button class="mobile-btn" id="btn-down" aria-label="下">&#9660;</button>
          <button class="mobile-btn" id="btn-right" aria-label="右">&#9654;</button>
        </div>
        <div class="mobile-controls-row">
          <button class="mobile-btn mobile-btn-wide" id="btn-drop" aria-label="ハードドロップ">&#9660;&#9660; ドロップ</button>
        </div>
      </div>

      <!-- PC用のキーガイド(PCのみ表示)。
           スマホには常時見える操作ボタンがあるのに、PCはスタート画面の1行だけで
           ゲームが始まると手がかりが消えていた。実測でもPCの即離脱29.6%に対し
           スマホは20.0%と9.6ポイント差があったため、PCにも常設のガイドを置く -->
      <div class="key-guide" id="key-guide">
        <span class="key-guide-item"><kbd>←</kbd><kbd>→</kbd> 動かす</span>
        <span class="key-guide-item"><kbd>↑</kbd> まわす</span>
        <span class="key-guide-item"><kbd>↓</kbd> そっと落とす</span>
        <span class="key-guide-item"><kbd>Space</kbd> 一気に落とす</span>
        <span class="key-guide-item"><kbd>Shift</kbd> ホールド</span>
      </div>
    </div>

    <!-- ゲーム枠直下の広告(hinata-game-below) -->

    <div class="game-instructions">
      <h2>無料・ダウンロード不要!テトリス風パズルをブラウザですぐプレイ</h2>
      <p>「ブロックパズル」は、落ちてくるブロックを回転・移動させてラインをそろえて消す、テトリス風の無料パズルゲームです。ダウンロードやインストール、会員登録は一切不要。このサイトを開くだけで、学校や家のPCでもスマホでもすぐに遊べます。</p>

      <h3>web ブラウザで動く「普通のテトリス風」パズル</h3>
      <p>アプリストアからのダウンロードや面倒な会員登録なしで、web ブラウザだけで動く無料サイトです。ルールは昔ながらのテトリス風パズルそのままで、7種類のブロックを回して積み、横一列がそろうと消えていきます。奇をてらった特殊ルールは無いので、「普通のテトリスのようなゲームを無料で遊びたい」というときにそのまま使えます。</p>
      <p>ブロックの回転・ホールド・次に落ちてくるブロックの表示(ネクスト)・一気に落とすハードドロップなど、テトリス風パズルでおなじみの操作にひと通り対応しています。スマホやタブレットでは画面下のボタン、PCではキーボードで操作でき、どちらでも同じルールで遊べます。</p>
      <p>ブラウザを閉じてもベストスコアは端末に保存され、ランキングに登録すれば全国順位も確認できます。無料で何度でも遊べます。</p>

      <h3>遊び方</h3>
      <ul>
        <li><strong>PC</strong>: ← → で左右移動、↑ で回転、↓ でソフトドロップ、スペースでハードドロップ、Shift/Cでホールド</li>
        <li><strong>スマホ</strong>: 画面下のボタンで操作</li>
        <li>横一列をブロックで埋めるとラインが消えます</li>
        <li>一度に多くのラインを消すと高得点!(1列=100, 2列=300, 3列=500, 4列=800)</li>
        <li>10ライン消すごとにレベルアップ!スピードも上がります</li>
        <li>ブロックが一番上まで積み上がるとゲームオーバー!</li>
      </ul>
    </div>

    <div class="game-instructions">
      <h3>ブロックパズルで高得点を取るコツ・攻略</h3>
      <ul>
        <li><strong>表面を平らに積む</strong>:デコボコに積むと隙間が埋まらない。なるべく高さをそろえると消しやすい。</li>
        <li><strong>端の1列をあけて4ライン消しを狙う</strong>:縦長のブロックを差し込む列を確保すると、一度に4ライン消して大量得点できる。</li>
        <li><strong>回転で隙間に入れる</strong>:そのままでは入らない形も、回転させると窪みにぴったりはまることが多い。</li>
        <li><strong>ホールドを活用</strong>:今使いにくいブロックは一時保管し、必要なときに呼び出すと無駄が減る。</li>
        <li><strong>積み上げすぎない</strong>:高くなったら一気消しより確実に1〜2ラインずつ消して立て直す。</li>
      </ul>
    </div>

    <div class="game-instructions">
      <h3>よくある質問</h3>
      <ul>
        <li><strong>Q. 無料で遊べますか?</strong><br>A. はい、完全無料です。料金や課金は一切ありません。</li>
        <li><strong>Q. ダウンロードやインストールは必要?</strong><br>A. 不要です。ブラウザを開くだけですぐに遊べます。</li>
        <li><strong>Q. スマホでも遊べますか?</strong><br>A. 遊べます。スマホは画面下のボタン、PCは矢印キー・スペース・Shift/Cで操作します。</li>
        <li><strong>Q. 4ライン同時消しのコツは?</strong><br>A. 端の1列をあけて平らに積み、縦長ブロックでまとめて消すのが基本です。</li>
        <li><strong>Q. スコアは保存されますか?</strong><br>A. ベストスコアは自動で保存され、ランキングにスコアを登録することもできます。</li>
        <li><strong>Q. 似たパズルゲームはありますか?</strong><br>A. 数字を合体させる「<a href="../2048/">2048</a>」、ボールでブロックを壊す「<a href="../breakout/">ブロック崩し</a>」、マスにブロックを置いて消す「<a href="../pittari-block/">ぴったりブロック</a>」も無料で遊べます。</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        ブロックパズルはJavaScriptの配列操作と<br>
        タイマー処理で作られています。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/online/" class="btn-primary" data-class-link="game-promo">オンラインで習ってみる(全国OK)→</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
        <a href="scratch/" class="btn-secondary">🐱 Scratch版で中を見る →</a>
      </div>
    </div>

    <div class="other-games">
      <a href="../../">&larr; 他のゲームも遊ぶ</a>
    </div>
  </div>

  <div id="footer"></div>

  <script src="../../js/common.js?v=20260914" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="../../js/game-audio.js?v=20260902"></script>
  <script src="game.js?v=20260916"></script>
</body>
</html>

style.css — 見た目とアニメーション

style.cssは、ブロックパズルの見た目を作っているファイルです。7種類のブロックはそれぞれ色が違いますが、その色は.cell-I(むらさき)から.cell-L(赤)までのクラスで1つずつ指定しています。JavaScriptは「このマスはJ型の色」と決めるとき、マスにcell-Jというクラスを付けるだけで色がつきます。

.cell-ghostは「ゴーストピース」という、ブロックが落ちる先をうすく見せるための専用スタイルです。ライン消去のときの光る演出は.clearingクラスのアニメーションで作っています。

ゲーム盤はdisplay:gridで10列のマス目に並べています。ファイルの下のほうの@mediaはスマホ向けにマスを小さくする指定です。

.key-guideはパソコン用のキー操作の早見表です。ふだんはdisplay: noneでかくしておき、@media (min-width: 769px)で画面の広いときだけ表示します。スマホには画面に操作ボタンがあるので、早見表はいらないからです。キーの名前は<kbd>タグで書き、.key-guide kbdで枠と下の太い線をつけて、本物のキーボードのキーのように見せています。

style.css
/* ============================================
   ブロックパズル - 固有スタイル
   ============================================ */

/* リード文(H1直下の説明) */
.game-lead {
  font-size: 0.95rem;
  line-height: 1.7;
  color: #555;
  margin-bottom: 1.5rem;
}

/* スコアエリア */
.score-area {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 1rem;
  flex-wrap: wrap;
}

.score-box {
  background: #BBADA0;
  border-radius: 8px;
  padding: 0.5rem 1rem;
  text-align: center;
  min-width: 70px;
}

.score-label {
  display: block;
  font-size: 0.7rem;
  color: #EEE4DA;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.score-value {
  display: block;
  font-size: 1.25rem;
  font-weight: 700;
  color: #fff;
}

.btn-new-game {
  margin-left: auto;
  background: #8F7A66;
  color: #fff;
  border: none;
  border-radius: 8px;
  padding: 0.5rem 1.25rem;
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s;
}

.btn-new-game:hover {
  background: #7A6658;
}

/* ブロックエリア(ボード + ネクスト) */
.tetris-area {
  display: flex;
  gap: 1rem;
  justify-content: center;
  align-items: flex-start;
}

/* ゲームボード */
.board-wrapper {
  position: relative;
  width: 100%;
  max-width: 300px;
  aspect-ratio: 1 / 2;
  touch-action: none;
}

.tetris-board {
  position: absolute;
  inset: 0;
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  grid-template-rows: repeat(20, 1fr);
  gap: 1px;
  padding: 4px;
  background: #2C2C2E;
  border-radius: 8px;
  overflow: hidden;
}

.tetris-cell {
  background: #1C1C1E;
  border-radius: 2px;
}

/* ブロックの色(固定ブロック) */
.tetris-cell.cell-I { background: #8B5CF6; }
.tetris-cell.cell-O { background: #14B8A6; }
.tetris-cell.cell-T { background: #F59E0B; }
.tetris-cell.cell-S { background: #EC4899; }
.tetris-cell.cell-Z { background: #38BDF8; }
.tetris-cell.cell-J { background: #84CC16; }
.tetris-cell.cell-L { background: #EF4444; }

/* ゴーストピース */
.tetris-cell.cell-ghost {
  background: rgba(255, 255, 255, 0.12);
  border: 1px solid rgba(255, 255, 255, 0.2);
}

/* ホールドピースパネル */
.hold-piece-panel {
  flex-shrink: 0;
  width: 90px;
}

.hold-piece-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: #EEE4DA;
  text-align: center;
  background: #BBADA0;
  border-radius: 8px 8px 0 0;
  padding: 0.3rem 0.5rem;
  letter-spacing: 0.05em;
}

.hold-piece-box {
  background: #2C2C2E;
  border-radius: 0 0 8px 8px;
  padding: 10px;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: 2px;
  aspect-ratio: 1 / 1;
}

.hold-piece-box.hold-locked {
  opacity: 0.4;
}

.hold-cell {
  background: #1C1C1E;
  border-radius: 2px;
}

.hold-cell.cell-I { background: #8B5CF6; }
.hold-cell.cell-O { background: #14B8A6; }
.hold-cell.cell-T { background: #F59E0B; }
.hold-cell.cell-S { background: #EC4899; }
.hold-cell.cell-Z { background: #38BDF8; }
.hold-cell.cell-J { background: #84CC16; }
.hold-cell.cell-L { background: #EF4444; }

/* ネクストピースパネル */
.next-piece-panel {
  flex-shrink: 0;
  width: 90px;
}

.next-piece-label {
  font-size: 0.75rem;
  font-weight: 600;
  color: #EEE4DA;
  text-align: center;
  background: #BBADA0;
  border-radius: 8px 8px 0 0;
  padding: 0.3rem 0.5rem;
  letter-spacing: 0.05em;
}

.next-piece-box {
  background: #2C2C2E;
  border-radius: 0 0 8px 8px;
  padding: 10px;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: 2px;
  aspect-ratio: 1 / 1;
}

.next-cell {
  background: #1C1C1E;
  border-radius: 2px;
}

.next-cell.cell-I { background: #8B5CF6; }
.next-cell.cell-O { background: #14B8A6; }
.next-cell.cell-T { background: #F59E0B; }
.next-cell.cell-S { background: #EC4899; }
.next-cell.cell-Z { background: #38BDF8; }
.next-cell.cell-J { background: #84CC16; }
.next-cell.cell-L { background: #EF4444; }

/* ゲームオーバーレイ */
.game-overlay {
  position: absolute;
  inset: 0;
  background: rgba(44, 44, 46, 0.9);
  border-radius: 8px;
  display: none;
  align-items: center;
  justify-content: center;
  z-index: 10;
  animation: overlay-appear 0.3s ease;
}

.game-overlay.active {
  display: flex;
}

@keyframes overlay-appear {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

.overlay-content {
  text-align: center;
  padding: 1.5rem;
}

.overlay-content h2 {
  font-size: 1.75rem;
  color: #fff;
  margin-bottom: 0.5rem;
}

.overlay-content p {
  font-size: 1.1rem;
  color: #ddd;
  margin-bottom: 0.75rem;
}

.overlay-content .overlay-hint {
  font-size: 0.85rem;
  color: #aaa;
  line-height: 1.5;
  margin-bottom: 1rem;
}

.overlay-content .best-result {
  font-size: 0.95rem;
  color: #FFEB3B;
  font-weight: 600;
  margin-bottom: 1rem;
}

.overlay-content .btn-primary {
  margin-bottom: 0.5rem;
}

/* モード選択(?sprint=1 の時だけ表示。hidden属性で通常は非表示) */
.mode-select {
  margin-top: 0.5rem;
}

.mode-select[hidden] {
  display: none;
}

.mode-select-btn {
  font-size: 0.9rem;
}

/* モバイル操作ボタン */
.mobile-controls {
  display: none;
  margin: 1.25rem auto 0;
  max-width: 280px;
}

/* PCでは非表示 */
@media (min-width: 769px) {
  .mobile-controls {
    display: none;
  }
}

/* モバイルでは表示 */
@media (max-width: 768px) {
  .mobile-controls {
    display: block;
  }
}

/* スタート画面の操作説明。長い1行だと読み飛ばされるのでキー単位で並べる */
.start-keys {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.4rem 0.8rem;
  margin: 0.75rem 0 1rem;
  font-size: 0.9rem;
  color: #e2e8f0;
}

.start-keys span {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  white-space: nowrap;
}

.start-keys kbd {
  display: inline-block;
  min-width: 1.5em;
  padding: 2px 6px;
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-bottom-width: 2px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.15);
  font-family: inherit;
  font-size: 0.85em;
  font-weight: 700;
  color: #fff;
  text-align: center;
}

/* PC用のキーガイド。モバイルボタンと同じ位置に置き、遊んでいる間ずっと見えるようにする。
   スマホ側にはボタンがあるので、こちらはPC幅のときだけ出す */
.key-guide {
  display: none;
  margin: 1rem auto 0;
  max-width: 460px;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.4rem 0.9rem;
  font-size: 0.82rem;
  color: #4a5568;
}

@media (min-width: 769px) {
  .key-guide { display: flex; }
}

.key-guide-item {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  white-space: nowrap;
}

.key-guide kbd {
  display: inline-block;
  min-width: 1.5em;
  padding: 2px 6px;
  border: 1px solid #cbd5e0;
  border-bottom-width: 2px;
  border-radius: 4px;
  background: #fff;
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.05);
  font-family: inherit;
  font-size: 0.85em;
  font-weight: 700;
  color: #2d3748;
  text-align: center;
}

.mobile-controls-row {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-bottom: 8px;
}

.mobile-btn {
  width: 72px;
  height: 52px;
  border: none;
  border-radius: 12px;
  background: #8F7A66;
  color: #fff;
  font-size: 1.25rem;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, transform 0.1s;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}

.mobile-btn:active {
  background: #7A6658;
  transform: scale(0.92);
}

.mobile-btn-wide {
  width: 100%;
  max-width: 232px;
  font-size: 0.95rem;
  font-weight: 600;
  gap: 0.25rem;
}

/* 待機中のヒント。開いた直後は盤面にブロックが乗っているが自動落下していないので、
   「自分が動かすと始まる」ことを1行で伝える。最初の操作で JS が .active を外す */
.ready-hint {
  position: absolute;
  left: 50%;
  bottom: 12px;
  transform: translateX(-50%);
  display: none;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 0.3rem;
  max-width: calc(100% - 16px);
  padding: 0.4rem 0.8rem;
  border-radius: 999px;
  background: rgba(28, 28, 30, 0.88);
  font-size: 0.8rem;
  font-weight: 600;
  color: #fff;
  text-align: center;
  z-index: 5;
  pointer-events: none;
  animation: ready-pulse 1.4s ease-in-out infinite;
}

.ready-hint.active {
  display: flex;
}

.ready-hint kbd {
  display: inline-block;
  min-width: 1.4em;
  padding: 1px 5px;
  border: 1px solid rgba(255, 255, 255, 0.5);
  border-bottom-width: 2px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.15);
  font-family: inherit;
  font-size: 0.85em;
  font-weight: 700;
  color: #fff;
}

/* PCはキー名、スマホは下のボタンを指す(モバイルボタンの有無に合わせる) */
.ready-hint-pc { display: none; }
.ready-hint-sp { display: inline-flex; align-items: center; gap: 0.3rem; }

@media (min-width: 769px) {
  .ready-hint-pc { display: inline-flex; align-items: center; gap: 0.3rem; }
  .ready-hint-sp { display: none; }
}

@keyframes ready-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.55; }
}

/* ライン消去アニメーション */
@keyframes line-clear {
  0% {
    background: #fff;
    transform: scaleY(1);
  }
  50% {
    background: #FFEB3B;
  }
  100% {
    background: #fff;
    transform: scaleY(0);
    opacity: 0;
  }
}

.tetris-cell.clearing {
  animation: line-clear 0.3s ease forwards;
}

/* レスポンシブ */
@media (max-width: 500px) {
  .tetris-area {
    gap: 0.5rem;
  }

  .board-wrapper {
    max-width: 250px;
  }

  .hold-piece-panel {
    width: 72px;
  }

  .hold-piece-box {
    padding: 6px;
  }

  .next-piece-panel {
    width: 72px;
  }

  .next-piece-box {
    padding: 6px;
  }

  .tetris-board {
    gap: 1px;
    padding: 3px;
  }

  .tetris-cell {
    border-radius: 1px;
  }
}

@media (max-width: 360px) {
  .board-wrapper {
    max-width: 210px;
  }

  .hold-piece-panel {
    width: 60px;
  }

  .next-piece-panel {
    width: 60px;
  }

  .tetris-board {
    gap: 0px;
    padding: 2px;
  }

  .score-box {
    padding: 0.4rem 0.75rem;
    min-width: 55px;
  }

  .score-value {
    font-size: 1rem;
  }

  .mobile-btn {
    width: 60px;
    height: 46px;
    border-radius: 10px;
    font-size: 1.1rem;
  }

  .mobile-btn-wide {
    max-width: 196px;
    font-size: 0.85rem;
  }
}

game.js — ゲームのルールを動かす頭脳

game.jsがこのゲームの心臓部です。少し長いですが、やっていることを順番に追えば必ず理解できます。全体は(function () { ... })();という「即時実行関数」で囲まれ、ゲームの変数が他のコードとぶつからないようになっています。

まず大事なのがboardという20行×10列の2次元配列です。board[y][x]が空文字ならそのマスは空き、色名('I'など)が入っていればブロックが固定されています。落下中のブロックはcurrentPieceという別のオブジェクトで、種類・回転状態・位置(x, y)を持っています。

このゲームでいちばん面白いのがTETROMINOSというブロックの設計図です。7種類それぞれに、回転した4つの形を「ブロックを構成する4マスの座標リスト」として書き並べています。だから「回転する」というのは難しい計算をしているわけではなく、rotationという番号を(rotation + 1) % 4で次に進めて、別の形のリストに切りかえているだけなのです。

ブロックが動けるかどうかはisValidPosition()が判定します。ブロックの4マスを1つずつ見て、盤の外に出ていないか、すでにあるブロックとぶつからないかを調べます。回転したいのに壁が邪魔なとき、左右に少しずらしてもう一度試すrotatePiece()の中の「ウォールキック」も、ブロックパズルならではの気持ちよさを生む工夫です。

横一列がそろったかはclearLines()が調べます。そろった行はboard.splice()で配列から抜き取り、かわりに空っぽの行をboard.unshift()で上に足します。これで上のブロックがストンと落ちます。消した列数に応じてLINE_SCORESから得点を決め、4列同時消しがいちばん高得点です。

自動で落下させているのはgameStep()です。setTimeoutでcurrentSpeedミリ秒ごとに自分をまた呼び出し、1マスずつ下げています。レベルが上がるとcalcSpeed()でこの間隔が短くなり、ブロックが速く落ちてきます。はじめの3レベルだけはEASE_MAXのぶん間隔を長くして、遊び方をつかむ時間を作っています(レベル4からは元の速さに戻ります)。接地してすぐ固定せず、LOCK_DELAYのあいだ動かせる「ロック猶予」も本格的な仕組みです。ベストスコアはlocalStorageに保存されます。

このgame.jsは、スプリント40のエンジンもかねています。変数modeが'marathon'(ふつうのモード)か'sprint'かを持っていて、スプリントでは消したライン数をsprintLinesに足していき、40ラインに届いたらfinishSprint()でゴールにします。タイムは最後のラインを消した瞬間の時刻で計り、sprintDeciseconds()で1/10秒単位の整数にしてから、Leaderboard.show('tetris-sprint', deci, { order: 'asc', format: 'time-deci' })で「タイムが小さいほど上位」のランキングに登録します。スプリント専用のページは、先にwindow.SPRINT_ONLYを決めてからこのgame.jsを読み込むので、ブロックを動かすコードを2つ書かずに済みます。parseSprintQuery()・sprintRemaining()・sprintDeciseconds()はDOMに触らない純粋関数にしてあるので、自動テストで確かめられます。

game.js
// ============================================
// ブロックパズル ロジック
// ============================================

(function () {
  'use strict';

  // 言語別テキスト(英語版ページが window.GAME_TEXT を定義して上書きする。docs/i18n.md 参照)
  var TEXT = window.GAME_TEXT || {};

  // ============================================
  // 定数
  // ============================================
  var COLS = 10;              // 横のマス数
  var ROWS = 20;              // 縦のマス数
  var INITIAL_SPEED = 800;    // 初期落下速度(ミリ秒)
  var MIN_SPEED = 80;         // 最速(ミリ秒)
  var SPEED_DECREASE = 60;    // レベルごとの速度減少量
  var LINES_PER_LEVEL = 10;  // レベルアップに必要なライン数
  var LOCK_DELAY = 500;       // 接地後のロック猶予(ミリ秒)

  // 序盤だけ落下をゆるめる(マラソンのみ)。レベル4=30ライン目からは従来のカーブに
  // 完全に合流するので、上位の記録が出る領域の難度は変わらない=これまでのランキングと
  // 並べて比べられる(ブロック崩しノーマルと同じ考え方)
  var EASE_LEVELS = 3;        // このレベルまでゆるめる
  var EASE_MAX = 0.30;        // レベル1で落下間隔を30%長くする(800→1040ms)

  // 落下を始めるきっかけになるキー(移動・回転・ドロップ・ホールド)
  var GAME_KEYS = /^(ArrowLeft|ArrowRight|ArrowUp|ArrowDown|a|A|d|D|w|W|s|S|c|C|Shift| )$/;

  // スコアテーブル(消去ライン数 → 得点)
  var LINE_SCORES = {
    1: 100,
    2: 300,
    3: 500,
    4: 800
  };

  // テトロミノの定義(4つの回転状態)
  var TETROMINOS = {
    I: {
      shapes: [
        [[0,0],[1,0],[2,0],[3,0]],
        [[0,0],[0,1],[0,2],[0,3]],
        [[0,0],[1,0],[2,0],[3,0]],
        [[0,0],[0,1],[0,2],[0,3]]
      ],
      color: 'I'
    },
    O: {
      shapes: [
        [[0,0],[1,0],[0,1],[1,1]],
        [[0,0],[1,0],[0,1],[1,1]],
        [[0,0],[1,0],[0,1],[1,1]],
        [[0,0],[1,0],[0,1],[1,1]]
      ],
      color: 'O'
    },
    T: {
      shapes: [
        [[0,0],[1,0],[2,0],[1,1]],
        [[0,0],[0,1],[0,2],[1,1]],
        [[1,0],[0,1],[1,1],[2,1]],
        [[1,0],[1,1],[1,2],[0,1]]
      ],
      color: 'T'
    },
    S: {
      shapes: [
        [[1,0],[2,0],[0,1],[1,1]],
        [[0,0],[0,1],[1,1],[1,2]],
        [[1,0],[2,0],[0,1],[1,1]],
        [[0,0],[0,1],[1,1],[1,2]]
      ],
      color: 'S'
    },
    Z: {
      shapes: [
        [[0,0],[1,0],[1,1],[2,1]],
        [[1,0],[0,1],[1,1],[0,2]],
        [[0,0],[1,0],[1,1],[2,1]],
        [[1,0],[0,1],[1,1],[0,2]]
      ],
      color: 'Z'
    },
    J: {
      shapes: [
        [[0,0],[0,1],[1,1],[2,1]],
        [[0,0],[1,0],[0,1],[0,2]],
        [[0,0],[1,0],[2,0],[2,1]],
        [[1,0],[1,1],[0,2],[1,2]]
      ],
      color: 'J'
    },
    L: {
      shapes: [
        [[2,0],[0,1],[1,1],[2,1]],
        [[0,0],[0,1],[0,2],[1,2]],
        [[0,0],[1,0],[2,0],[0,1]],
        [[0,0],[1,0],[1,1],[1,2]]
      ],
      color: 'L'
    }
  };

  var PIECE_NAMES = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];

  // ============================================
  // スプリント40モード 判定ロジック(純粋関数)
  // ============================================
  // スプリント40モードの目標ライン数をURLクエリから判定する
  // (2026-07-12 一般公開につき ?sprint=1 ゲートは撤去。gate は常に開)。
  // ?lines=N は検証用の短縮版。target が 40 以外のプレイはランキングに登録されない
  function parseSprintQuery(search) {
    var target = 40;
    var m = /[?&]lines=(\d+)/.exec(search);
    if (m) target = Math.max(1, parseInt(m[1], 10));
    return { gate: true, target: target };
  }

  // 残りライン数(HUD表示用)
  function sprintRemaining(target, cleared) {
    return Math.max(0, target - cleared);
  }

  // 経過ミリ秒 → 1/10秒単位の整数(time-deci形式でランキング登録)
  function sprintDeciseconds(startMs, nowMs) {
    return Math.round((nowMs - startMs) / 100);
  }

  // Node(vitest)からは純粋ロジックだけを読み込む
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = {
      parseSprintQuery: parseSprintQuery,
      sprintRemaining: sprintRemaining,
      sprintDeciseconds: sprintDeciseconds
    };
    return;
  }

  // ============================================
  // 状態変数
  // ============================================
  var board = [];               // ボード配列 [row][col] = '' or color名
  var currentPiece = null;      // 現在のピース { type, rotation, x, y }
  var nextPieceType = null;     // 次のピースの種類
  var holdPieceType = null;     // ホールド中のピースの種類
  var holdUsed = false;         // 現在のピースでホールド済みか
  var score = 0;
  var bestScore = parseInt(localStorage.getItem('bestTetris') || '0', 10);
  var level = 1;
  var totalLines = 0;
  var gameRunning = false;
  var gameOverFlag = false;
  var gameLoopTimer = null;
  var currentSpeed = INITIAL_SPEED;
  var lockTimer = null;
  var isLocking = false;

  // 開いた直後は盤面を見せたまま自動落下を止めておき、最初の操作で動かし始める。
  // スタート画面を押させると、押す前に帰った人と操作が分からず詰まった人を区別できない
  var awaitingFirstInput = false;
  var waitStartMs = 0;        // 待機を始めた時刻(最初の操作までの時間を計る)
  var piecesPlaced = 0;       // 置いたピース数(入口で詰まっているかの判定に使う)

  // スプリント40モードの状態
  var SPRINT = parseSprintQuery(location.search);
  // スプリント専用ページ(games/tetris-sprint/)は自前の game.js で window.SPRINT_ONLY を
  // 立ててから本エンジンを読み込む。以後どの開始経路(スタート/リトライ/New Game)でも
  // スプリント固定になる。エンジンを複製せず1本を共有するための仕掛け
  var SPRINT_ONLY = window.SPRINT_ONLY === true;
  var sprintFinished = false;   // finishSprint の二重発火防止(300ms内の連続ライン消去対策)
  var mode = SPRINT_ONLY ? 'sprint' : 'marathon';   // 'marathon'(従来・既定) or 'sprint'
  var sprintLines = 0;          // スプリントで消したライン数
  var sprintStart = 0;          // スプリント開始時刻(performance.now)

  // ---- サウンド(共通モジュール。読み込みに失敗しても無音のまま動く) ----
  var hasAudio = typeof window.GameAudio !== 'undefined';
  if (hasAudio) {
    GameAudio.init('tetris');
    // BGMは絶対パスで指定(この game.js は英語版・スプリント専用ページとも共用。相対だと英語版だけ404で無音になる)
    GameAudio.setBgm('/games/assets/bgm/denkigai.mp3', 0.09);
    // 既定の .game-container 右上は New Game ボタンと重なるため、盤面(.board-wrapper)右上に置く
    GameAudio.attachMuteButton({ parent: '.board-wrapper' });
  }
  function sfx(name, opts) { if (hasAudio) GameAudio.sfx(name, opts); }

  // 最初の操作で音を解禁する(ブラウザの自動再生制限。どの操作でも1回だけ)
  var audioUnlocked = false;
  function unlockAudio() {
    if (audioUnlocked || !hasAudio) return;
    audioUnlocked = true;
    GameAudio.unlock();
    GameAudio.startBgm();
  }
  document.addEventListener('pointerdown', unlockAudio, { once: true });
  document.addEventListener('keydown', unlockAudio, { once: true });

  // ============================================
  // DOM要素
  // ============================================
  var boardEl = document.getElementById('tetris-board');
  var scoreEl = document.getElementById('score');
  var bestScoreEl = document.getElementById('best-score');
  var levelEl = document.getElementById('level');
  var gameOverOverlay = document.getElementById('game-over-overlay');
  var gameStartOverlay = document.getElementById('game-start-overlay');
  var finalScoreEl = document.getElementById('final-score');
  var bestResultEl = document.getElementById('best-result');
  var nextPieceBox = document.getElementById('next-piece-box');
  var holdPieceBox = document.getElementById('hold-piece-box');
  // 待機中のヒント(スプリント専用ページには置いていないので null になりうる)
  var readyHintEl = document.getElementById('ready-hint');

  function setReadyHint(on) {
    if (readyHintEl) readyHintEl.classList.toggle('active', !!on);
  }

  // スプリント用DOM(?sprint=1 の時だけ使う)
  var modeSelectEl = document.getElementById('mode-select');
  var sprintRemainBox = document.getElementById('sprint-remain-box');
  var sprintRemainEl = document.getElementById('sprint-remain');
  var sprintClearOverlay = document.getElementById('sprint-clear-overlay');
  var sprintTimeEl = document.getElementById('sprint-time');

  // セルの2次元配列(高速アクセス用)
  var cells = [];
  var nextCells = [];
  var holdCells = [];

  // ============================================
  // ボードの初期化(DOMセルを生成)
  // ============================================
  function createBoard() {
    boardEl.innerHTML = '';
    cells = [];

    for (var y = 0; y < ROWS; y++) {
      cells[y] = [];
      for (var x = 0; x < COLS; x++) {
        var cell = document.createElement('div');
        cell.className = 'tetris-cell';
        boardEl.appendChild(cell);
        cells[y][x] = cell;
      }
    }
  }

  function createNextPieceDisplay() {
    nextPieceBox.innerHTML = '';
    nextCells = [];

    for (var y = 0; y < 4; y++) {
      nextCells[y] = [];
      for (var x = 0; x < 4; x++) {
        var cell = document.createElement('div');
        cell.className = 'next-cell';
        nextPieceBox.appendChild(cell);
        nextCells[y][x] = cell;
      }
    }
  }

  function createHoldPieceDisplay() {
    holdPieceBox.innerHTML = '';
    holdCells = [];

    for (var y = 0; y < 4; y++) {
      holdCells[y] = [];
      for (var x = 0; x < 4; x++) {
        var cell = document.createElement('div');
        cell.className = 'hold-cell';
        holdPieceBox.appendChild(cell);
        holdCells[y][x] = cell;
      }
    }
  }

  // ============================================
  // ボードデータ初期化
  // ============================================
  function initBoard() {
    board = [];
    for (var y = 0; y < ROWS; y++) {
      board[y] = [];
      for (var x = 0; x < COLS; x++) {
        board[y][x] = '';
      }
    }
  }

  // ============================================
  // ランダムなテトロミノを生成
  // ============================================
  function randomPieceType() {
    return PIECE_NAMES[Math.floor(Math.random() * PIECE_NAMES.length)];
  }

  // ============================================
  // 新しいピースを生成
  // ============================================
  function spawnPiece() {
    var type = nextPieceType || randomPieceType();
    nextPieceType = randomPieceType();
    holdUsed = false;

    var piece = {
      type: type,
      rotation: 0,
      x: Math.floor(COLS / 2) - 1,
      y: 0
    };

    // I型は少し左にずらす
    if (type === 'I') {
      piece.x = Math.floor(COLS / 2) - 2;
    }

    // 設置できるか確認
    if (!isValidPosition(piece)) {
      // ゲームオーバー
      return null;
    }

    return piece;
  }

  // ============================================
  // ピースの現在の形状を取得
  // ============================================
  function getShape(piece) {
    return TETROMINOS[piece.type].shapes[piece.rotation];
  }

  // ============================================
  // ピースの位置が有効か判定
  // ============================================
  function isValidPosition(piece) {
    var shape = getShape(piece);

    for (var i = 0; i < shape.length; i++) {
      var newX = piece.x + shape[i][0];
      var newY = piece.y + shape[i][1];

      // 範囲外チェック
      if (newX < 0 || newX >= COLS || newY < 0 || newY >= ROWS) {
        return false;
      }

      // 他のブロックとの衝突チェック
      if (board[newY][newX] !== '') {
        return false;
      }
    }

    return true;
  }

  // ============================================
  // ゴースト位置の計算(ハードドロップ先)
  // ============================================
  function getGhostY(piece) {
    var ghostY = piece.y;

    while (true) {
      var testPiece = { type: piece.type, rotation: piece.rotation, x: piece.x, y: ghostY + 1 };
      if (!isValidPosition(testPiece)) {
        break;
      }
      ghostY++;
    }

    return ghostY;
  }

  // ============================================
  // ボードの描画更新
  // ============================================
  function render() {
    // すべてのセルをリセット
    for (var y = 0; y < ROWS; y++) {
      for (var x = 0; x < COLS; x++) {
        if (board[y][x] !== '') {
          cells[y][x].className = 'tetris-cell cell-' + board[y][x];
        } else {
          cells[y][x].className = 'tetris-cell';
        }
      }
    }

    if (currentPiece) {
      // ゴーストピースを描画
      var ghostY = getGhostY(currentPiece);
      if (ghostY !== currentPiece.y) {
        var ghostShape = getShape(currentPiece);
        for (var i = 0; i < ghostShape.length; i++) {
          var gx = currentPiece.x + ghostShape[i][0];
          var gy = ghostY + ghostShape[i][1];
          if (gy >= 0 && gy < ROWS && gx >= 0 && gx < COLS && board[gy][gx] === '') {
            cells[gy][gx].className = 'tetris-cell cell-ghost';
          }
        }
      }

      // 現在のピースを描画
      var shape = getShape(currentPiece);
      var color = TETROMINOS[currentPiece.type].color;
      for (var i = 0; i < shape.length; i++) {
        var px = currentPiece.x + shape[i][0];
        var py = currentPiece.y + shape[i][1];
        if (py >= 0 && py < ROWS && px >= 0 && px < COLS) {
          cells[py][px].className = 'tetris-cell cell-' + color;
        }
      }
    }

    // ネクストピースを描画
    renderNextPiece();

    // ホールドピースを描画
    renderHoldPiece();
  }

  // ============================================
  // ネクストピースの描画
  // ============================================
  function renderNextPiece() {
    // リセット
    for (var y = 0; y < 4; y++) {
      for (var x = 0; x < 4; x++) {
        nextCells[y][x].className = 'next-cell';
      }
    }

    if (!nextPieceType) return;

    var shape = TETROMINOS[nextPieceType].shapes[0];
    var color = TETROMINOS[nextPieceType].color;

    // ピースを中央寄せするためのオフセットを計算
    var minX = 4, maxX = 0, minY = 4, maxY = 0;
    for (var i = 0; i < shape.length; i++) {
      if (shape[i][0] < minX) minX = shape[i][0];
      if (shape[i][0] > maxX) maxX = shape[i][0];
      if (shape[i][1] < minY) minY = shape[i][1];
      if (shape[i][1] > maxY) maxY = shape[i][1];
    }
    var pieceWidth = maxX - minX + 1;
    var pieceHeight = maxY - minY + 1;
    var offsetX = Math.floor((4 - pieceWidth) / 2) - minX;
    var offsetY = Math.floor((4 - pieceHeight) / 2) - minY;

    for (var i = 0; i < shape.length; i++) {
      var nx = shape[i][0] + offsetX;
      var ny = shape[i][1] + offsetY;
      if (nx >= 0 && nx < 4 && ny >= 0 && ny < 4) {
        nextCells[ny][nx].className = 'next-cell cell-' + color;
      }
    }
  }

  // ============================================
  // ホールドピースの描画
  // ============================================
  function renderHoldPiece() {
    // リセット
    for (var y = 0; y < 4; y++) {
      for (var x = 0; x < 4; x++) {
        holdCells[y][x].className = 'hold-cell';
      }
    }

    // ホールド使用済みならボックスを暗くする
    if (holdUsed) {
      holdPieceBox.classList.add('hold-locked');
    } else {
      holdPieceBox.classList.remove('hold-locked');
    }

    if (!holdPieceType) return;

    var shape = TETROMINOS[holdPieceType].shapes[0];
    var color = TETROMINOS[holdPieceType].color;

    // ピースを中央寄せするためのオフセットを計算
    var minX = 4, maxX = 0, minY = 4, maxY = 0;
    for (var i = 0; i < shape.length; i++) {
      if (shape[i][0] < minX) minX = shape[i][0];
      if (shape[i][0] > maxX) maxX = shape[i][0];
      if (shape[i][1] < minY) minY = shape[i][1];
      if (shape[i][1] > maxY) maxY = shape[i][1];
    }
    var pieceWidth = maxX - minX + 1;
    var pieceHeight = maxY - minY + 1;
    var offsetX = Math.floor((4 - pieceWidth) / 2) - minX;
    var offsetY = Math.floor((4 - pieceHeight) / 2) - minY;

    for (var i = 0; i < shape.length; i++) {
      var hx = shape[i][0] + offsetX;
      var hy = shape[i][1] + offsetY;
      if (hx >= 0 && hx < 4 && hy >= 0 && hy < 4) {
        holdCells[hy][hx].className = 'hold-cell cell-' + color;
      }
    }
  }

  // ============================================
  // ピースをボードに固定
  // ============================================
  function lockPiece() {
    // 設置音。ハードドロップ・ロック猶予切れのどちらの固定でもここを通る
    sfx('place');
    var shape = getShape(currentPiece);
    var color = TETROMINOS[currentPiece.type].color;

    for (var i = 0; i < shape.length; i++) {
      var px = currentPiece.x + shape[i][0];
      var py = currentPiece.y + shape[i][1];
      if (py >= 0 && py < ROWS && px >= 0 && px < COLS) {
        board[py][px] = color;
      }
    }

    // ロックタイマーをクリア
    clearLockTimer();
    isLocking = false;

    // 1個も置けずに帰ったのか、置いたうえで終わったのかを分けて数える
    piecesPlaced++;
    if (window.PlayTrack) window.PlayTrack.setExtra({ pieces: piecesPlaced });
  }

  // ============================================
  // ライン消去チェック
  // ============================================
  function clearLines() {
    var linesCleared = 0;
    var linesToClear = [];

    for (var y = ROWS - 1; y >= 0; y--) {
      var full = true;
      for (var x = 0; x < COLS; x++) {
        if (board[y][x] === '') {
          full = false;
          break;
        }
      }
      if (full) {
        linesToClear.push(y);
        linesCleared++;
      }
    }

    if (linesCleared > 0) {
      // スプリント用: ラインがそろった時刻を先に記録(アニメーション分を含めない)
      var clearedAt = performance.now();

      // 消えた手応えは操作直後に鳴らす(300msのアニメ完了後だと遅れて聞こえる)。
      // 4列同時消しは最大の得点イベントなので全消し級のファンファーレにする
      if (linesCleared >= 4) {
        sfx('allclear');
      } else {
        sfx('lineClear', { step: linesCleared - 1 });
      }

      // 消去アニメーション
      for (var i = 0; i < linesToClear.length; i++) {
        var row = linesToClear[i];
        for (var x = 0; x < COLS; x++) {
          cells[row][x].classList.add('clearing');
        }
      }

      // アニメーション後にラインを実際に消去
      setTimeout(function () {
        // ラインを上から順にソート(下から消すため)
        linesToClear.sort(function (a, b) { return a - b; });

        for (var i = linesToClear.length - 1; i >= 0; i--) {
          board.splice(linesToClear[i], 1);
        }

        // 上に空行を追加
        for (var i = 0; i < linesCleared; i++) {
          var emptyRow = [];
          for (var x = 0; x < COLS; x++) {
            emptyRow.push('');
          }
          board.unshift(emptyRow);
        }

        // スコア計算
        var lineScore = LINE_SCORES[linesCleared] || (linesCleared * 200);
        score += lineScore * level;
        totalLines += linesCleared;

        if (mode === 'sprint') {
          // スプリント: レベル上昇なし(落下速度固定)。残りライン数を更新し、目標到達でゴール
          sprintLines += linesCleared;
          updateSprintHud();
          if (sprintLines >= SPRINT.target) {
            finishSprint(clearedAt);
            return;
          }
        } else {
          // レベルアップ
          var newLevel = Math.floor(totalLines / LINES_PER_LEVEL) + 1;
          if (newLevel > level) {
            level = newLevel;
            currentSpeed = calcSpeed();
            levelEl.textContent = level;
            sfx('levelup');
          }
        }

        updateScore();
        render();
      }, 300);
    }

    return linesCleared;
  }

  // ============================================
  // スコア更新
  // ============================================
  function updateScore() {
    scoreEl.textContent = score;
    // スプリントはタイム勝負なので、マラソンのベストスコアを更新しない
    if (mode === 'sprint') return;
    if (score > bestScore) {
      bestScore = score;
      localStorage.setItem('bestTetris', bestScore.toString());
    }
    bestScoreEl.textContent = bestScore;
  }

  // ============================================
  // スプリント: 残りライン数のHUD更新
  // ============================================
  function updateSprintHud() {
    if (sprintRemainEl) {
      sprintRemainEl.textContent = sprintRemaining(SPRINT.target, sprintLines);
    }
  }

  // ============================================
  // スプリント: 目標ライン到達(ゴール)
  // ============================================
  function finishSprint(now) {
    if (sprintFinished) return;
    sprintFinished = true;
    // 盤面ロック
    gameRunning = false;
    gameOverFlag = true;
    clearGameLoop();
    clearLockTimer();
    currentPiece = null;
    render();

    // 完走ファンファーレ。タイム表示の余韻を邪魔しないようBGMは止める
    sfx('finish');
    if (hasAudio) GameAudio.stopBgm(0.8);

    var deci = sprintDeciseconds(sprintStart, now);
    if (sprintTimeEl) sprintTimeEl.textContent = (deci / 10).toFixed(1);

    // 少し遅延させてタイム表示→ランキング登録
    // ?lines= で短縮した検証用プレイは本物の40ラインランキングに載せない
    setTimeout(function () {
      if (sprintClearOverlay) sprintClearOverlay.classList.add('active');
      if (window.Leaderboard && SPRINT.target === 40) {
        Leaderboard.show('tetris-sprint', deci, { order: 'asc', format: 'time-deci' });
      }
    }, 300);
  }

  // ============================================
  // 速度の計算(レベルに応じて速くなる)
  // ============================================
  function calcSpeed() {
    var speed = INITIAL_SPEED - ((level - 1) * SPEED_DECREASE);
    speed = Math.max(speed, MIN_SPEED);
    // スプリントはレベルが上がらない(落下速度固定=タイム勝負)ので緩和しない。
    // マラソンも EASE_LEVELS を超えれば remain が消えて従来値そのものになる
    if (mode !== 'sprint' && level <= EASE_LEVELS) {
      var remain = EASE_LEVELS - level + 1;   // レベル1で3、レベル3で1
      speed = Math.round(speed * (1 + EASE_MAX * remain / EASE_LEVELS));
    }
    return speed;
  }

  // ============================================
  // ピースを左右に移動
  // ============================================
  function movePiece(dx) {
    if (!currentPiece || !gameRunning) return;

    var testPiece = {
      type: currentPiece.type,
      rotation: currentPiece.rotation,
      x: currentPiece.x + dx,
      y: currentPiece.y
    };

    if (isValidPosition(testPiece)) {
      currentPiece.x = testPiece.x;

      // ロック中に横移動した場合、ロックタイマーをリセット
      if (isLocking) {
        resetLockTimer();
      }

      render();
    }
  }

  // ============================================
  // ピースを回転
  // ============================================
  function rotatePiece() {
    if (!currentPiece || !gameRunning) return;

    var newRotation = (currentPiece.rotation + 1) % 4;
    var testPiece = {
      type: currentPiece.type,
      rotation: newRotation,
      x: currentPiece.x,
      y: currentPiece.y
    };

    // 通常の回転を試す
    if (isValidPosition(testPiece)) {
      currentPiece.rotation = newRotation;
      if (isLocking) {
        resetLockTimer();
      }
      render();
      return;
    }

    // ウォールキック: 左右にずらして試す
    var kicks = [1, -1, 2, -2];
    for (var i = 0; i < kicks.length; i++) {
      testPiece.x = currentPiece.x + kicks[i];
      if (isValidPosition(testPiece)) {
        currentPiece.rotation = newRotation;
        currentPiece.x = testPiece.x;
        if (isLocking) {
          resetLockTimer();
        }
        render();
        return;
      }
    }
  }

  // ============================================
  // ソフトドロップ(1マス下に移動)
  // ============================================
  function softDrop() {
    if (!currentPiece || !gameRunning) return false;

    var testPiece = {
      type: currentPiece.type,
      rotation: currentPiece.rotation,
      x: currentPiece.x,
      y: currentPiece.y + 1
    };

    if (isValidPosition(testPiece)) {
      currentPiece.y = testPiece.y;
      score += 1;
      updateScore();
      render();
      return true;
    }

    return false;
  }

  // ============================================
  // ハードドロップ(一番下まで落とす)
  // ============================================
  function hardDrop() {
    if (!currentPiece || !gameRunning) return;

    var ghostY = getGhostY(currentPiece);
    var dropDistance = ghostY - currentPiece.y;
    currentPiece.y = ghostY;
    score += dropDistance * 2;
    updateScore();

    // 即座にロック
    clearLockTimer();
    isLocking = false;
    lockPiece();

    var linesCleared = clearLines();

    // 次のピースを出す
    currentPiece = spawnPiece();
    if (!currentPiece) {
      gameOver();
      return;
    }

    render();

    // ゲームループをリスタート
    clearGameLoop();
    if (linesCleared > 0) {
      // ライン消去アニメーション後にゲームループ再開
      gameLoopTimer = setTimeout(gameStep, 350);
    } else {
      gameLoopTimer = setTimeout(gameStep, currentSpeed);
    }
  }

  // ============================================
  // ホールド(ピースを保存・交換)
  // ============================================
  function holdPiece() {
    if (!currentPiece || !gameRunning || holdUsed) return;

    var currentType = currentPiece.type;

    // ロック状態をリセット
    clearLockTimer();
    isLocking = false;

    if (holdPieceType) {
      // ホールド中のピースと交換
      var swapType = holdPieceType;
      holdPieceType = currentType;

      var piece = {
        type: swapType,
        rotation: 0,
        x: Math.floor(COLS / 2) - 1,
        y: 0
      };
      if (swapType === 'I') {
        piece.x = Math.floor(COLS / 2) - 2;
      }

      if (!isValidPosition(piece)) {
        gameOver();
        return;
      }

      currentPiece = piece;
    } else {
      // 初回ホールド: 現在のピースを保存して次のピースを出す
      holdPieceType = currentType;
      currentPiece = spawnPiece();
      if (!currentPiece) {
        gameOver();
        return;
      }
    }

    // 同じピースで2回ホールドできないようにする
    holdUsed = true;

    render();

    // ゲームループをリスタート
    clearGameLoop();
    gameLoopTimer = setTimeout(gameStep, currentSpeed);
  }

  // ============================================
  // ロックタイマー管理
  // ============================================
  function clearLockTimer() {
    if (lockTimer) {
      clearTimeout(lockTimer);
      lockTimer = null;
    }
  }

  function resetLockTimer() {
    clearLockTimer();
    lockTimer = setTimeout(function () {
      if (!gameRunning || !currentPiece) return;

      // まだ接地しているか確認
      var testPiece = {
        type: currentPiece.type,
        rotation: currentPiece.rotation,
        x: currentPiece.x,
        y: currentPiece.y + 1
      };

      if (!isValidPosition(testPiece)) {
        // ロック実行
        lockPiece();
        isLocking = false;

        var linesCleared = clearLines();

        currentPiece = spawnPiece();
        if (!currentPiece) {
          gameOver();
          return;
        }

        render();

        clearGameLoop();
        if (linesCleared > 0) {
          gameLoopTimer = setTimeout(gameStep, 350);
        } else {
          gameLoopTimer = setTimeout(gameStep, currentSpeed);
        }
      } else {
        isLocking = false;
      }
    }, LOCK_DELAY);
  }

  // ============================================
  // ゲームループのクリア
  // ============================================
  function clearGameLoop() {
    if (gameLoopTimer) {
      clearTimeout(gameLoopTimer);
      gameLoopTimer = null;
    }
  }

  // ============================================
  // ゲームの1ステップ(自動落下)
  // ============================================
  function gameStep() {
    if (!gameRunning || gameOverFlag || !currentPiece) return;

    var testPiece = {
      type: currentPiece.type,
      rotation: currentPiece.rotation,
      x: currentPiece.x,
      y: currentPiece.y + 1
    };

    if (isValidPosition(testPiece)) {
      currentPiece.y = testPiece.y;
      isLocking = false;
      clearLockTimer();
      render();
      gameLoopTimer = setTimeout(gameStep, currentSpeed);
    } else {
      // 接地した。ロック猶予を開始
      if (!isLocking) {
        isLocking = true;
        resetLockTimer();
      }
      render();
      // 自動落下ループは止まる。ロックタイマーが次のステップを引き受ける
      gameLoopTimer = setTimeout(gameStep, currentSpeed);
    }
  }

  // ============================================
  // ゲームオーバー処理
  // ============================================
  function gameOver() {
    gameRunning = false;
    gameOverFlag = true;

    clearGameLoop();
    clearLockTimer();

    sfx('lost');
    if (hasAudio) GameAudio.stopBgm(0.8);

    finalScoreEl.textContent = score;

    if (mode === 'sprint') {
      // スプリント失敗: 記録なし。残りライン数だけ表示(リトライボタンは共通)
      bestResultEl.textContent = (TEXT.sprintLinesLeft || 'のこり{n}ライン').replace('{n}', sprintRemaining(SPRINT.target, sprintLines));
    } else if (score >= bestScore && score > 0) {
      // ベストスコア更新チェック
      bestResultEl.textContent = TEXT.newBest || 'ハイスコア更新!';
    } else {
      bestResultEl.textContent = (TEXT.best || 'ベスト: ') + bestScore;
    }

    // 少し遅延させてオーバーレイを表示(スプリントは記録なし)
    setTimeout(function () {
      gameOverOverlay.classList.add('active');
      // 到達レベルとライン数も残す。序盤で詰まっているのか、続いた末に終わったのかが分かる
      if (window.PlayTrack) {
        window.PlayTrack.setExtra({ started: 1, ver: 2, level: level, lines: totalLines, pieces: piecesPlaced });
      }
      if (window.Leaderboard && mode !== 'sprint') Leaderboard.show('tetris', score);
    }, 300);
  }

  // ============================================
  // ゲーム開始・リスタート
  // ============================================
  function startGame(newMode, waitForInput) {
    // モード確定(引数なし=従来どおりマラソン。スプリント専用ページでは常にスプリント)
    mode = (SPRINT_ONLY || newMode === 'sprint') ? 'sprint' : 'marathon';

    // プレイ履歴に「スタートを押した」ことを残す。
    // PC操作ガイドの効果測定に使う。離脱がスタート前(説明を見て帰った)なのか
    // 開始後(操作が分からず詰まった)なのかを区別できないと打ち手が決められない
    // 待機起動(ページを開いた直後)ではまだ「始めた」ことにしない。最初の操作が
    // 入った時点(armDrop)で started=1 にする=盤面を見ただけで帰った人と、
    // 操作して遊んだ人を分けて数えられる
    if (window.PlayTrack && waitForInput !== true) {
      window.PlayTrack.setExtra({ started: 1, ver: 2 });
    }

    // リトライ時はゲームオーバーで止めたBGMを鳴らし直す(初回開始は unlockAudio 側)
    if (audioUnlocked && hasAudio) GameAudio.startBgm();

    // タイマーをクリア
    clearGameLoop();
    clearLockTimer();

    // 状態をリセット
    score = 0;
    level = 1;
    totalLines = 0;
    gameOverFlag = false;
    isLocking = false;
    holdPieceType = null;
    holdUsed = false;
    piecesPlaced = 0;
    currentSpeed = calcSpeed();   // レベル1。マラソンは序盤の緩和が乗る

    // スプリント初期化(落下速度は INITIAL_SPEED 固定・レベル上昇なし)
    sprintLines = 0;
    sprintFinished = false;
    if (mode === 'sprint') {
      sprintStart = performance.now();
      updateSprintHud();
    }
    if (sprintRemainBox) sprintRemainBox.hidden = (mode !== 'sprint');
    if (sprintClearOverlay) sprintClearOverlay.classList.remove('active');

    updateScore();
    levelEl.textContent = level;

    // オーバーレイを非表示
    gameOverOverlay.classList.remove('active');
    gameStartOverlay.classList.remove('active');

    // ボードを初期化
    initBoard();

    // 最初のピースを生成
    nextPieceType = randomPieceType();
    currentPiece = spawnPiece();
    render();

    // ゲームループ開始。waitForInput のときは盤面だけ見せて、
    // 最初の操作(armDrop)まで自動落下を始めない
    gameRunning = true;
    awaitingFirstInput = waitForInput === true;
    setReadyHint(awaitingFirstInput);
    if (awaitingFirstInput) {
      waitStartMs = performance.now();
    } else {
      gameLoopTimer = setTimeout(gameStep, currentSpeed);
    }
  }

  // 最初の操作で自動落下を始める。盤面を読む前から時間が進むのを避ける
  function armDrop() {
    if (!awaitingFirstInput) return;
    awaitingFirstInput = false;
    setReadyHint(false);
    // 待機起動をスプリントへ広げたときにタイムが不当に長くならないための保険
    if (mode === 'sprint') sprintStart = performance.now();
    if (window.PlayTrack) {
      window.PlayTrack.setExtra({
        started: 1,
        ver: 2,
        first_ms: Math.round(performance.now() - waitStartMs)
      });
    }
    clearGameLoop();
    gameLoopTimer = setTimeout(gameStep, currentSpeed);
  }

  // ============================================
  // キーボード操作
  // ============================================
  document.addEventListener('keydown', function (e) {
    if (e.target && /INPUT|TEXTAREA/.test(e.target.tagName)) return;

    if (!gameRunning || gameOverFlag) {
      // 中断からの復帰はゲーム操作のキーでも始められるようにする
      // (盤面が見えているのに、キーを押しても何も起きない状態を作らない)
      if (e.key === 'Enter' || e.key === ' ' || GAME_KEYS.test(e.key)) {
        e.preventDefault();
        if (!gameRunning && !gameOverFlag) {
          startGame();
        }
      }
      return;
    }

    // 最初のゲーム操作で落下を始める(開いた瞬間から時間が進むのを避ける)
    if (awaitingFirstInput && GAME_KEYS.test(e.key)) armDrop();

    switch (e.key) {
      case 'ArrowLeft':
      case 'a':
      case 'A':
        e.preventDefault();
        movePiece(-1);
        break;
      case 'ArrowRight':
      case 'd':
      case 'D':
        e.preventDefault();
        movePiece(1);
        break;
      case 'ArrowUp':
      case 'w':
      case 'W':
        e.preventDefault();
        rotatePiece();
        break;
      case 'ArrowDown':
      case 's':
      case 'S':
        e.preventDefault();
        if (softDrop()) {
          // ソフトドロップ成功時、ゲームループをリセット
          clearGameLoop();
          gameLoopTimer = setTimeout(gameStep, currentSpeed);
        }
        break;
      case ' ':
        e.preventDefault();
        hardDrop();
        break;
      case 'Shift':
      case 'c':
      case 'C':
        e.preventDefault();
        holdPiece();
        break;
    }
  });

  // ============================================
  // モバイル操作ボタン
  // ============================================
  function setupMobileButton(btnId, action) {
    var btn = document.getElementById(btnId);
    if (!btn) return;

    btn.addEventListener('click', function (e) {
      e.preventDefault();
      if (!gameRunning && !gameOverFlag) {
        startGame();
        return;
      }
      if (gameRunning) {
        armDrop();
        action();
      }
    });

    btn.addEventListener('touchstart', function (e) {
      e.preventDefault();
      if (!gameRunning && !gameOverFlag) {
        startGame();
        return;
      }
      if (gameRunning) {
        armDrop();
        action();
      }
    }, { passive: false });
  }

  setupMobileButton('btn-hold', function () { holdPiece(); });
  setupMobileButton('btn-left', function () { movePiece(-1); });
  setupMobileButton('btn-right', function () { movePiece(1); });
  setupMobileButton('btn-rotate', function () { rotatePiece(); });
  setupMobileButton('btn-down', function () {
    if (softDrop()) {
      clearGameLoop();
      gameLoopTimer = setTimeout(gameStep, currentSpeed);
    }
  });
  setupMobileButton('btn-drop', function () { hardDrop(); });

  // ============================================
  // ボタンイベント
  // ============================================
  document.getElementById('btn-new-game').addEventListener('click', function () {
    startGame(mode); // 直前のモードでやり直し(通常は従来どおりマラソン)
  });

  document.getElementById('btn-retry').addEventListener('click', function () {
    startGame(mode); // スプリント中のゲームオーバーは同モードでリトライ
  });

  document.getElementById('btn-start').addEventListener('click', function () {
    startGame();
  });

  // スプリント用ボタン(?sprint=1 の時だけUIに出る)
  var btnStartSprint = document.getElementById('btn-start-sprint');
  if (btnStartSprint) {
    btnStartSprint.addEventListener('click', function () {
      startGame('sprint');
    });
  }
  var btnSprintRetry = document.getElementById('btn-sprint-retry');
  if (btnSprintRetry) {
    btnSprintRetry.addEventListener('click', function () {
      startGame('sprint');
    });
  }

  // ============================================
  // ページ離脱時にゲームループを停止
  // ============================================
  document.addEventListener('visibilitychange', function () {
    if (document.hidden && gameRunning) {
      clearGameLoop();
      clearLockTimer();
      gameRunning = false;
    }
  });

  // ============================================
  // 初期化
  // ============================================
  bestScoreEl.textContent = bestScore;
  createBoard();
  createNextPieceDisplay();
  createHoldPieceDisplay();
  initBoard();

  // スプリントモードの導線は ?sprint=1 の時だけ表示(通常アクセスは従来と同一表示)
  if (SPRINT.gate && modeSelectEl) {
    modeSelectEl.hidden = false;
  }

  // ネクストピースのプレビュー用に初期化
  nextPieceType = randomPieceType();
  renderNextPiece();

  // スタート画面を押させず、開いた瞬間に盤面と1個目のブロックを見せる。自動落下は
  // 最初の操作(armDrop)まで待つので、操作が分からないまま積み上がることはない。
  // スプリント40はタイム計測の公正さを優先して従来どおりスタート画面から始める
  if (!SPRINT_ONLY) {
    startGame(undefined, true);
  }
})();

このコードで学べるポイント

自分のパソコンで動かすには

上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。

このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。

慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。

このコードは使っていいの?

このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。

ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。

もっと学びたい人へ

コードを読んで「自分でも作ってみたい!」と思ったら、ブロックパズルの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。

ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。2026年9月からはオンライン授業も始まり、全国どこからでも受講できます(土曜12:00と、月・水・金の20:00)。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、のぞいてみてください。