tech-book-labs
連載(全 5 話を 1 ページに統合)

ブラウザで動く対戦テトリスを作る

落ちものパズルの基本 → CPU 対戦 AI → 2 人対戦 → おじゃまライン送信 → クリスタル + 必殺技ゲージ + キャラ別技 まで、5 回で 0 から完成形まで積み上げる連載。React + TypeScript の純粋関数 + useReducer だけで書きます。初心者でも順に読み進められる構成。

著者:TechBook.net編集部 · 最終検証 2026-05-16
ブラウザでテトリスをゼロから作るイメージ — ビルド成功 / コード行数 1200 / キーボードに乗るキャラと TETRO ブロックを抱えるキャラ
第 1 話 · 2026-05-11

ブラウザで動くテトリスをゼロから設計して作る

ブラウザで遊べるテトリスを、設計の段階から 1 つずつ組み上げる入門記事。盤面(Board)/ ピース(Tetromino)/ 衝突判定 / 自動落下のループ / キー入力 / ライン消去 / スコア計算 / ゲームオーバー判定までを、React と TypeScript の純粋関数 + useReducer だけで完成させる。動く demo を触りながら読み進められる。連載「ブラウザで動く対戦テトリスを作る」第 1 回(全 5 回)。

検証日: 2026-05-11

使用バージョン: React 19 + TypeScript 6

対象: React 入門(useState / useEffect)を一通り済ませた中級者手前の人。useReducer は触ったことがある or 名前を知っている程度で OK。discriminated union(タグ付き union 型)は本文中で軽く触れますが、深い説明は省きます。本物の初学者向けではないので、React チュートリアルを終えてから読むのがおすすめです

ブラウザで動くテトリスを ゼロから設計して、動くところまで 組み上げる記事です。Canvas は使わず React state + CSS Grid で組むので、各 cell の中身が状態として直接見えます。完成形は記事下の demo:

ブラウザでテトリスをゼロから作るイメージ — ビルド成功 / コード行数 1200 / キーボードに乗るキャラと TETRO ブロックを抱えるキャラ

本記事のゴール — ブラウザでテトリスをゼロから組み上げる(本連載 第 1 回)

実装中、特に手こずったのは useEffect の依存配列の扱いでした。setInterval を立てる effect の依存に dispatch を入れたら、毎レンダーで interval が再生成されて、ピースが秒速で底まで落ちるバグになります。dispatchRef 経由で参照する形で解決した話を §12 つまずいたポイント に書いてあります。

触って試す

← / → で移動、↑ または Z / X で回転、↓ で soft drop、Space で hard drop。P で一時停止、R でリセット。

全体像 — 4 つの責務に分ける

ゲームは複雑に見えますが、責務を 4 つに分解すると見通しが良くなります:

4 つの責務 — ロジックは純粋関数、I/O(loop / input / render)は周辺で吸収 (クリックで拡大)

ポイント:

  • 純粋関数で書ける部分は純粋に(collides, merge, clearLines)→ test しやすい、デバッグしやすい
  • 副作用(時間 / 入力 / 描画)は外側(useEffect, event listener, JSX)
  • state の遷移は 1 箇所(useReducer の reducer)→ どこから呼ばれても挙動が同じ

先に useReducer の流れを 30 秒で

本記事は React の useReducer を全面的に使います。中身は単純で、state(現在の状態)action(更新の指示)reducer(純粋関数) に渡されて 次の state が返ってくるだけです。

// 1. state の型と初期値を決める
type State = { count: number };
const initialState: State = { count: 0 };

// 2. action の型を決める(本記事の Action は { type: "tick" } | { type: "left" } などの union)
type Action = { type: "increment" } | { type: "reset" };

// 3. reducer は (state, action) → 新 state の純粋関数
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment": return { count: state.count + 1 };
    case "reset":     return { count: 0 };
  }
}

// 4. コンポーネント内で useReducer を呼ぶと、現 state と dispatch が返る
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <button onClick={() => dispatch({ type: "increment" })}>
      {state.count}
    </button>
  );
}

本記事のテトリスでは:

  • State = { board, active, score, lines, over, paused }(6 フィールド)
  • Action = "tick" | "left" | "right" | "down" | "drop" | "rotate" | "togglePause" | "reset" の 8 種
  • dispatch({ type: "left" }) を呼ぶと reducer が動いて、衝突判定して、新しい state が返る

「state が変わるたびに React が再描画する」 ので、reducer を書けば自動的に画面が更新されます。以降の章ではこの reducer の中身を 1 つずつ実装していきます。

1. 盤面を 2 次元配列で表現する

盤面は 20 行 × 10 列の grid。各 cell は「空 = 空文字」or「色を表す piece 名」を持つ:

type Cell = "" | "I" | "O" | "T" | "S" | "Z" | "L" | "J";
type Board = Cell[][];

const BOARD_W = 10;
const BOARD_H = 20;

const emptyBoard = (): Board =>
  Array.from({ length: BOARD_H }, () =>
    Array.from({ length: BOARD_W }, () => "" as Cell),
  );

なぜ「数値」ではなく「色名」?:

  • 確定 cell の色を引きやすい(COLORS[cell] で即取得)
  • type 安全:存在しない piece 名は TypeScript が拒否
  • 盤面を debug 出力しても何の piece か一目で分かる

2. テトロミノ(7 種)を 4×4 行列で定義

テトロミノ(tetromino、4 マスを組合せた piece)はテトリスの 7 種類のピース(I, O, T, S, Z, L, J)。それぞれ 4 回転分を 4×4 binary matrix(0/1 の 2 次元配列)で持ちます:

type Mat4 = ReadonlyArray<ReadonlyArray<0 | 1>>;
type Piece = "I" | "O" | "T" | "S" | "Z" | "L" | "J";

const SHAPES: Record<Piece, Mat4[]> = {
  I: [
    [[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]],   // 横
    [[0,0,1,0],[0,0,1,0],[0,0,1,0],[0,0,1,0]],   // 縦
    [[0,0,0,0],[0,0,0,0],[1,1,1,1],[0,0,0,0]],   // 横(下寄り)
    [[0,1,0,0],[0,1,0,0],[0,1,0,0],[0,1,0,0]],   // 縦(左寄り)
  ],
  T: [
    [[0,1,0,0],[1,1,1,0],[0,0,0,0],[0,0,0,0]],   // ⊤
    [[0,1,0,0],[0,1,1,0],[0,1,0,0],[0,0,0,0]],   // ⊢
    [[0,0,0,0],[1,1,1,0],[0,1,0,0],[0,0,0,0]],   // ⊥
    [[0,1,0,0],[1,1,0,0],[0,1,0,0],[0,0,0,0]],   // ⊣
  ],
  // ... 他 5 種類も同じ構造
};

const COLORS: Record<Piece, string> = {
  I: "#22d3ee", O: "#fde047", T: "#a78bfa",
  S: "#86efac", Z: "#fca5a5", L: "#fdba74", J: "#93c5fd",
};

なぜ 4×4?:

  • 全 piece が 4×4 で覆える(I が一番大きい)
  • 回転中心がブレない(matrix の中央付近で回せば見た目が安定)
  • 純粋なデータ:回転を計算で求めるより、4 回転を 手書きデータ で持つほうが SRS(現代テトリスの公式回転規則。後述)に合わせた微調整が入れやすい

ピース 1 つ分の状態は次の構造体:

type Active = {
  type: Piece;     // 種類
  rot: number;     // 0..3
  x: number;       // 4×4 ボックスの左上 x(board 座標)
  y: number;       // 同 y
};

3. cellsOf — 形状を board 座標に展開する

ピースは 抽象的な形 で持っているので、「いま board のどの cell を占めているか」を毎回計算します:

function cellsOf(p: Active): { bx: number; by: number }[] {
  const m = SHAPES[p.type][p.rot % 4];
  const out: { bx: number; by: number }[] = [];
  for (let r = 0; r < 4; r++)
    for (let c = 0; c < 4; c++) {
      if (m[r][c]) out.push({ bx: p.x + c, by: p.y + r });
    }
  return out;
}

これさえあれば、衝突判定もマージも線形に書けます。

4. 衝突判定 — 移動の前に試行

「移動 / 回転 / 落下」のすべては:新しい仮 piece を作って、衝突しなければ採用、というシンプルな構造で書けます。

function collides(board: Board, p: Active): boolean {
  for (const { bx, by } of cellsOf(p)) {
    // 壁・床
    if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true;
    // 既存ブロックと重なる(by が負 = 画面上部はみ出しは許容、まだ盤面に来ていないので)
    if (by >= 0 && board[by][bx] !== "") return true;
  }
  return false;
}

3 つのケースを 1 関数で:

  • 左壁を超えた(bx < 0)
  • 右壁 / 床を超えた(bx >= BOARD_W / by >= BOARD_H)
  • 既存 cell と重なった(board[by][bx] !== "")

by < 0 を許容しているのは、ピースが画面上部から少しずつ降りてくる初期状態を考慮するため。

5. ゲームループ — setInterval で 600ms ごとに 1 マス落とす

ループは React の useEffect で立てます:

useEffect(() => {
  if (state.over || state.paused) return;
  const id = setInterval(() => dispatch({ type: "tick" }), TICK_MS);
  return () => clearInterval(id);
}, [state.over, state.paused]);

ポイント:

  • state.over / state.paused が依存配列:変わったら old interval を捨てて新規(or 起動しない)
  • dispatch は ref で参照 する:effect の依存に dispatch を入れると毎 render で interval が再起動して挙動が崩れます。具体パターンは §12「つまずいたポイント」 で詳述

tick action が来た時の処理は reducer 内で:

case "tick": {
  const next = { ...state.active!, y: state.active!.y + 1 };
  if (!collides(state.board, next)) return { ...state, active: next };
  // 落ちられない = 着地 → 確定 + 次の piece を出す
  return lockAndSpawn(state);
}

落下できなければ 「ロック → ライン消去 → 新ピース生成」 のフェーズに移ります(後述)。

6. 入力処理 — keydown を 1 箇所で集める

キーボード入力は window レベルで listen + map で action 名に変換:

useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    const map: Record<string, Action["type"]> = {
      ArrowLeft: "left",
      ArrowRight: "right",
      ArrowDown: "down",
      ArrowUp: "rotate",
      z: "rotate", Z: "rotate",
      x: "rotate", X: "rotate",
      " ": "drop",     // Space = hard drop
      p: "togglePause", P: "togglePause",
      r: "reset", R: "reset",
    };
    const action = map[e.key];
    if (!action) return;
    e.preventDefault();
    dispatch({ type: action } as Action);
  };
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, []);

ポイント:

  • action 名 → reducer の case に 1 対 1 対応:キーマップを変えても reducer は変えなくて良い
  • e.preventDefault():Space で page スクロール、Arrow で page navigate、を防ぐ
  • 依存配列空 []:1 回だけ listener を bind、unmount で外す

7. 回転 — 簡易 wall kick

wall kick = 回転後の形が壁や既存ブロックと重なる時、ピースを少しずらして回転を成立させる仕組み。これがないと壁際で回せず操作感が悪い。

回転は「次の rot index を計算」+「衝突なら諦める」が基本ですが、壁際で 少しずらせば回せる ケース(wall kick)を簡易実装:

case "rotate": {
  const np = { ...state.active!, rot: (state.active!.rot + 1) % 4 };
  // 0 → -1 → 1 → -2 → 2 の順に offset を試す
  for (const dx of [0, -1, 1, -2, 2]) {
    const c = { ...np, x: np.x + dx };
    if (!collides(state.board, c)) return { ...state, active: c };
  }
  return state; // どれも衝突 → 回転キャンセル
}

公式 SRS(Super Rotation System)はもっと精緻ですが、まずはこれだけでも壁際の操作感が大きく改善します。

8. ハードドロップ — 衝突手前まで一気に落とす

Space キーで一気に底まで落とす機能:

case "drop": {
  let p = state.active!;
  while (!collides(state.board, { ...p, y: p.y + 1 })) {
    p = { ...p, y: p.y + 1 };
  }
  return lockAndSpawn({ ...state, active: p });
}

衝突するまで 1 マスずつ下げて、最後の安全地点で確定。

9. ロック + ライン消去 + スコア

「ピースが着地」した時の処理を 1 関数にまとめます:

const SCORE_TABLE = [0, 100, 300, 500, 800];  // 0 / 1 / 2 / 3 / 4 ライン同時消去

function lockAndSpawn(state: State): State {
  if (!state.active) return state;
  const merged = merge(state.board, state.active);          // 確定セルを書き込む
  const { board, cleared } = clearLines(merged);            // 揃った行を消す
  const next = newActive();                                  // 次の piece
  if (collides(board, next)) {
    return { ...state, board, active: null, over: true };   // game over
  }
  return {
    ...state,
    board,
    active: next,
    score: state.score + SCORE_TABLE[cleared],
    lines: state.lines + cleared,
  };
}

mergeclearLines は純粋関数:

function merge(board: Board, p: Active): Board {
  const next = board.map((row) => row.slice());
  for (const { bx, by } of cellsOf(p)) {
    if (by >= 0) next[by][bx] = p.type;   // 色情報を書き込む
  }
  return next;
}

function clearLines(board: Board) {
  const kept = board.filter((row) => row.some((c) => c === ""));  // 1 つでも空なら残す
  const cleared = BOARD_H - kept.length;
  while (kept.length < BOARD_H) {
    kept.unshift(Array.from({ length: BOARD_W }, () => "" as Cell));  // 上に空行を補充
  }
  return { board: kept, cleared };
}

filter で「すべて埋まった行」を捨て、残った行を上から空行で埋める = 自然と上から落ちてくる挙動。

10. ゲームオーバー判定

新しいピースを spawn した時点で 既に衝突していたら、もう積む場所がない = ゲームオーバー:

const next = newActive();
if (collides(board, next)) {
  return { ...state, board, active: null, over: true };
}

reducer の冒頭でも state.over をチェックして、reset 以外の action を全て無視します:

if (state.over && action.type !== "reset") return state;

11. 描画 — CSS Grid に流し込むだけ

確定 cell + 現在のピースを overlay した「描画用 board」を作って、CSS Grid に流すだけ:

const view: Cell[][] = state.board.map((row) => row.slice());
if (state.active) {
  for (const { bx, by } of cellsOf(state.active)) {
    if (by >= 0) view[by][bx] = state.active.type;
  }
}

<div style={{
  display: "grid",
  gridTemplateColumns: `repeat(${BOARD_W}, 22px)`,
  gridAutoRows: "22px",
  gap: 1,
  background: "var(--color-line)",
  padding: 4,
}}>
  {view.flat().map((c, i) => (
    <div key={i} style={{
      background: c ? COLORS[c as Piece] : "var(--color-paper)",
      borderRadius: 2,
    }} />
  ))}
</div>

ポイント:

  • 確定 cell + active piece を 1 つの 2 次元配列に重ねてから描画 → React の reconciliation が最小差分で済む
  • gap: 1 + 黒背景:cell 間に 1px の溝が見える表現を CSS だけで(border よりシンプル)
  • gridAutoRows: 22px:行高固定で leak 防止

12. つまずいたポイント

  • useEffect 内で dispatch を依存に入れて interval が無限に再起動:dispatch は React 保証で stable な参照だが、ESLint exhaustive-deps が警告する。useRef(dispatch) を経由するか、空 deps + 内部で ref を読む形に
  • キーが効かない:<input> フォーカス時にも window listener が動いて preventDefault が効く事故。e.target instanceof HTMLInputElement で early return
  • ピースが画面上部で詰まる:by < 0 を collision check で許容しないと、初期 spawn 直後に game over になる
  • 回転で形が変 :4×4 の中での重心がずれていると、回転の度に position が右にずれて見える。手書きデータの重心を一致させる
  • ライン消去でちらつく:merge 後に clearLines の return をすぐ render すると 1 frame だけ “埋まった盤面” が見える。本記事規模では気にしないが、本格実装はアニメで段階表示
  • TICK_MS を変えるとロック挙動が遅延:tick だけ速くしても入力 → 反映の遅延は別問題。入力は即時反映、tick は重力だけ に責務を分ける
  • Math.random() で同じ piece が連続する:本格テトリスは “7-bag” シャッフル(7 種を 1 セットにして順序を shuffle、消費したら次のバッグ)。本記事は単純 random で済ませた

拡張の余地

ここまでで「最小の動くテトリス」になりますが、本格運用には以下が定番拡張:

機能概要
SRS(Super Rotation System)回転時の wall kick を 5 試行 + 各 piece 別の offset table
next preview次に来る 1〜5 個の piece をサイドに表示
hold1 piece を保持して後で出せる(Shift キー)
ghost piece現在のピースが落ちる位置を半透明で先取り表示
7-bag7 種の piece を bag 単位でシャッフル
lock delay着地後 0.5 秒は左右移動 / 回転を許す(誤操作リカバリ)
レベル / 速度上昇ライン数で TICK_MS を短くしていく
タッチ操作swipe で移動、tap で回転(モバイル対応)
音 / 効果音Web Audio API でブロック着地音、ライン消去ファンファーレ

これらは本記事のシンプル設計の上に独立に追加できる構造になっています。

完全なコード(コピペ可)

ここまでの実装を 1 ファイルにまとめた完全版。React 19 + TypeScript + Tailwind の theme 変数(var(--color-*)) を使う前提。TetrisDemo.tsx として保存すれば、別途依存なしで動く:

import { useCallback, useEffect, useReducer, useRef } from "react";

// ───────── 定数 ─────────
const BOARD_W = 10;
const BOARD_H = 20;
const TICK_MS = 600; // 落下間隔(ms)

// 各 tetromino の 4 回転を 4×4 binary matrix で表現
type Mat4 = ReadonlyArray<ReadonlyArray<0 | 1>>;
type Piece = "I" | "O" | "T" | "S" | "Z" | "L" | "J";

const SHAPES: Record<Piece, Mat4[]> = {
  I: [
    [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0], [0, 0, 1, 0]],
    [[0, 0, 0, 0], [0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]],
  ],
  O: [
    [[0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
  ],
  T: [
    [[0, 1, 0, 0], [1, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [0, 1, 1, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
    [[0, 0, 0, 0], [1, 1, 1, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [1, 1, 0, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
  ],
  S: [
    [[0, 1, 1, 0], [1, 1, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [0, 1, 1, 0], [0, 0, 1, 0], [0, 0, 0, 0]],
    [[0, 0, 0, 0], [0, 1, 1, 0], [1, 1, 0, 0], [0, 0, 0, 0]],
    [[1, 0, 0, 0], [1, 1, 0, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
  ],
  Z: [
    [[1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 0, 1, 0], [0, 1, 1, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
    [[0, 0, 0, 0], [1, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [1, 1, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]],
  ],
  L: [
    [[0, 0, 1, 0], [1, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 1, 0], [0, 0, 0, 0]],
    [[0, 0, 0, 0], [1, 1, 1, 0], [1, 0, 0, 0], [0, 0, 0, 0]],
    [[1, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
  ],
  J: [
    [[1, 0, 0, 0], [1, 1, 1, 0], [0, 0, 0, 0], [0, 0, 0, 0]],
    [[0, 1, 1, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 0, 0, 0]],
    [[0, 0, 0, 0], [1, 1, 1, 0], [0, 0, 1, 0], [0, 0, 0, 0]],
    [[0, 1, 0, 0], [0, 1, 0, 0], [1, 1, 0, 0], [0, 0, 0, 0]],
  ],
};

const COLORS: Record<Piece, string> = {
  I: "#22d3ee", O: "#fde047", T: "#a78bfa", S: "#86efac",
  Z: "#fca5a5", L: "#fdba74", J: "#93c5fd",
};
const PIECES: Piece[] = ["I", "O", "T", "S", "Z", "L", "J"];

// ───────── 型 ─────────
type Cell = "" | Piece;
type Board = Cell[][];
type Active = { type: Piece; rot: number; x: number; y: number };

type State = {
  board: Board;
  active: Active | null;
  score: number;
  lines: number;
  over: boolean;
  paused: boolean;
};

type Action =
  | { type: "tick" } | { type: "left" } | { type: "right" } | { type: "down" }
  | { type: "drop" } | { type: "rotate" } | { type: "togglePause" } | { type: "reset" };

// ───────── 純粋関数 ─────────
const emptyBoard = (): Board =>
  Array.from({ length: BOARD_H }, () =>
    Array.from({ length: BOARD_W }, () => "" as Cell),
  );

const randPiece = (): Piece => PIECES[Math.floor(Math.random() * PIECES.length)];

const newActive = (type?: Piece): Active => ({
  type: type ?? randPiece(),
  rot: 0,
  x: 3,
  y: 0,
});

function cellsOf(p: Active): { bx: number; by: number }[] {
  const m = SHAPES[p.type][p.rot % 4];
  const out: { bx: number; by: number }[] = [];
  for (let r = 0; r < 4; r++) for (let c = 0; c < 4; c++) {
    if (m[r][c]) out.push({ bx: p.x + c, by: p.y + r });
  }
  return out;
}

function collides(board: Board, p: Active): boolean {
  for (const { bx, by } of cellsOf(p)) {
    if (bx < 0 || bx >= BOARD_W || by >= BOARD_H) return true;
    if (by >= 0 && board[by][bx] !== "") return true;
  }
  return false;
}

function merge(board: Board, p: Active): Board {
  const next = board.map((row) => row.slice());
  for (const { bx, by } of cellsOf(p)) {
    if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) next[by][bx] = p.type;
  }
  return next;
}

function clearLines(board: Board): { board: Board; cleared: number } {
  const kept = board.filter((row) => row.some((c) => c === ""));
  const cleared = BOARD_H - kept.length;
  while (kept.length < BOARD_H)
    kept.unshift(Array.from({ length: BOARD_W }, () => "" as Cell));
  return { board: kept, cleared };
}

const SCORE_TABLE = [0, 100, 300, 500, 800];

function lockAndSpawn(state: State): State {
  if (!state.active) return state;
  const merged = merge(state.board, state.active);
  const { board, cleared } = clearLines(merged);
  const next = newActive();
  if (collides(board, next)) return { ...state, board, active: null, over: true };
  return {
    ...state,
    board,
    active: next,
    score: state.score + SCORE_TABLE[cleared],
    lines: state.lines + cleared,
  };
}

// ───────── reducer ─────────
function reducer(state: State, action: Action): State {
  if (state.over && action.type !== "reset") return state;
  if (state.paused && action.type !== "togglePause" && action.type !== "reset") return state;
  if (!state.active && action.type !== "reset") return state;

  switch (action.type) {
    case "tick":
    case "down": {
      const nextP = { ...state.active!, y: state.active!.y + 1 };
      if (!collides(state.board, nextP)) return { ...state, active: nextP };
      return lockAndSpawn(state);
    }
    case "left": {
      const np = { ...state.active!, x: state.active!.x - 1 };
      return collides(state.board, np) ? state : { ...state, active: np };
    }
    case "right": {
      const np = { ...state.active!, x: state.active!.x + 1 };
      return collides(state.board, np) ? state : { ...state, active: np };
    }
    case "rotate": {
      const np = { ...state.active!, rot: (state.active!.rot + 1) % 4 };
      for (const dx of [0, -1, 1, -2, 2]) {
        const c = { ...np, x: np.x + dx };
        if (!collides(state.board, c)) return { ...state, active: c };
      }
      return state;
    }
    case "drop": {
      let p = state.active!;
      while (!collides(state.board, { ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 };
      return lockAndSpawn({ ...state, active: p });
    }
    case "togglePause":
      return { ...state, paused: !state.paused };
    case "reset":
      return initialState();
  }
}

function initialState(): State {
  return {
    board: emptyBoard(),
    active: newActive(),
    score: 0,
    lines: 0,
    over: false,
    paused: false,
  };
}

// ───────── component ─────────
export function TetrisDemo() {
  const [state, dispatch] = useReducer(reducer, undefined, initialState);
  const dispatchRef = useRef(dispatch);
  dispatchRef.current = dispatch;

  // 自動落下 (ゲームループ)
  useEffect(() => {
    if (state.over || state.paused) return;
    const id = setInterval(() => dispatchRef.current({ type: "tick" }), TICK_MS);
    return () => clearInterval(id);
  }, [state.over, state.paused]);

  // キーボード操作
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement) return;
      const map: Record<string, Action["type"]> = {
        ArrowLeft: "left", ArrowRight: "right",
        ArrowDown: "down", ArrowUp: "rotate",
        z: "rotate", Z: "rotate", x: "rotate", X: "rotate",
        " ": "drop", p: "togglePause", P: "togglePause",
        r: "reset", R: "reset",
      };
      const action = map[e.key];
      if (!action) return;
      e.preventDefault();
      dispatchRef.current({ type: action } as Action);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  // 描画用 board: 確定 cells + active piece overlay
  const view: Cell[][] = state.board.map((row) => row.slice());
  if (state.active) {
    for (const { bx, by } of cellsOf(state.active)) {
      if (by >= 0 && by < BOARD_H && bx >= 0 && bx < BOARD_W) view[by][bx] = state.active.type;
    }
  }

  const button = useCallback(
    (label: string, action: Action) => (
      <button type="button" onClick={() => dispatch(action)}
        style={{ padding: "4px 8px", border: "1px solid #ccc", borderRadius: 4, fontSize: 12 }}>
        {label}
      </button>
    ), [],
  );

  return (
    <div style={{ padding: 16, background: "#f4f1ea", borderRadius: 8 }}>
      <div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
        <div style={{
          display: "grid",
          gridTemplateColumns: `repeat(${BOARD_W}, 22px)`,
          gridAutoRows: "22px", gap: 1, padding: 4,
          background: "#ddd", borderRadius: 4,
        }}>
          {view.flat().map((c, i) => (
            <div key={i} style={{
              background: c ? COLORS[c as Piece] : "#fff",
              borderRadius: 2,
              boxShadow: c ? "inset 0 0 0 1px rgba(0,0,0,0.1)" : undefined,
            }} />
          ))}
        </div>
        <div style={{ fontSize: 12, minWidth: 160 }}>
          <div>SCORE: <b style={{ fontFamily: "monospace" }}>{state.score}</b></div>
          <div>LINES: <b style={{ fontFamily: "monospace" }}>{state.lines}</b></div>
          {state.over && <div style={{ color: "#dc2626", fontWeight: 700 }}>GAME OVER</div>}
          {state.paused && !state.over && <div style={{ color: "#6366f1", fontWeight: 700 }}>PAUSED</div>}
          <div style={{ display: "flex", gap: 4, marginTop: 8 }}>
            {button("←", { type: "left" })}
            {button("↓", { type: "down" })}
            {button("→", { type: "right" })}
          </div>
          <div style={{ display: "flex", gap: 4, marginTop: 4 }}>
            {button("↻ rot", { type: "rotate" })}
            {button("⇩ drop", { type: "drop" })}
          </div>
          <div style={{ display: "flex", gap: 4, marginTop: 4 }}>
            {button(state.paused ? "▶ resume" : "❚❚ pause", { type: "togglePause" })}
            {button("↺ reset", { type: "reset" })}
          </div>
        </div>
      </div>
    </div>
  );
}

export default TetrisDemo;

行数:約 250 行(SHAPES データを含む)。設計章で説明した 純粋関数 + reducer + useEffect がそのまま入っている。

関連 Topic — 体系的に学ぶための入口

tech-book.net /topics/react

React — おすすめ書籍 7 冊・関連用語 51 個・学習マップ

React とは、Meta(旧 Facebook)が 2013 年に公開した宣言的 UI 構築のための JavaScript ライブラリで、フロントエンド開発の事実上の標準である。

この Topic が役立つ理由 — 本記事のテトリス実装は React 19 のコンポーネント + state パターンの上に立っている。Topic ページの推薦書から順に押さえると、ゲームに限らずブラウザ UI 全般の設計判断ができるようになる。
学習マップを tech-book.net で見る
tech-book.net /topics/react-hooks

React Hooks — 定義・前提と次に学ぶ用語 32 個・学習マップ

関数コンポーネントから React の状態・ライフサイクル・コンテキストなどを利用するための API 群。`use` から始まる関数として提供され、コンポーネントのトップレベルでのみ呼び出せる。

この Topic が役立つ理由 — useState / useReducer / useEffect / useRef / useCallback を本記事は全部使う。Topic ページの hook 解説書を 1 冊やっておくと、本記事の reducer + ゲームループ設計が手で書けるようになる。
学習マップを tech-book.net で見る
tech-book.net /topics/javascript

JavaScript — おすすめ書籍 17 冊・関連用語 42 個・学習マップ

JavaScript とは、1995 年に Netscape で生まれたブラウザ向けスクリプト言語が起点で、現在は Web・サーバ・アプリ・組み込みまで幅広く使われる汎用言語である。

この Topic が役立つ理由 — 衝突判定、純粋関数による board 更新、行消去のフィルタなど、ロジック部分は React と独立した JS の問題。基礎を厚くしたい人向け。
学習マップを tech-book.net で見る

関連書籍

第 1 回(土台づくり)に効く 3 冊。

tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov/牧野 聡 · オライリー・ジャパン · 2017年 · ¥2,750

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

この本が役立つ理由 — 本記事の対象読者(React 入門は終えた中級者手前)にちょうど厚みが合う 1 冊。useState / useEffect / useReducer の使い分けを「いつどれを選ぶか」 で整理してあり、本記事の reducer の形と setInterval を useEffect に入れる理由を確認するのに最適。
詳細を tech-book.net で見る
tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks/Eve Porcello/宮崎 空 · オライリー・ジャパン · 2021年 · ¥3,740

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

この本が役立つ理由 — 第 1 回の reducer + 純粋関数の設計は、本書 Hooks 編の「state 更新は純粋関数で書く」 章を下敷きにしている。Tic-Tac-Toe を useReducer で書き直す章まで読むと、テトリス以外のゲームでも同じ骨格が使えると納得できる。
詳細を tech-book.net で見る
tech-book.net /books/9784297127473

プロを目指す人のためのTypeScript入門 安全なコードの書き方から高度な型の使い方まで

鈴木 僚太 · 技術評論社 · 2022年

TypeScriptは、JavaScriptに静的型付けの機能を加えたオープンソースのプログラミング言語です。本書では、根幹となるJavaScr…

この本が役立つ理由 — 本記事の `Cell = '' | 'I' | 'O' | ...` のような union 型は、本書の「型を厚く書く」 章で詳しく扱われている。第 4 回で `Cell` に `'G'`(garbage)を 1 つ足すだけで派生型が表現できる仕組みの土台はここ。
詳細を tech-book.net で見る
第 2 話 · 2026-05-12

テトリスに CPU 対戦を入れる — 評価関数で形を読む AI

前回作った 1 人用テトリスに、自分で考えて積む CPU を追加する回。盤面の良し悪しを 4 つの数値(全列の高さ合計 / 消えるライン数 / 穴の数 / 凸凹度)で評価し、すべての置き場所を 1 手だけ読んで最良の手を選ぶ仕組みを純粋関数で書く。動く demo で AI の選択理由が数字で見える。連載「ブラウザで動く対戦テトリスを作る」第 2 回。

検証日: 2026-05-12

使用バージョン: React 19 + TypeScript 6

対象: ゲーム AI の入口を「全候補列挙 + 評価関数」で体験したい人

前提: 前回 ブラウザで動くテトリスをゼロから設計して作る のコードを引き継ぎます

連載「ブラウザで動く対戦テトリスを作る」第 2 回

対戦テトリス連載のイメージ — クマ(P1)とウサギ(P2)が対戦している

今回作るのは「自分で考えて積む CPU」 — 連載最終話の対戦相手の「脳」

5 回かけて「1 人用テトリス → CPU AI → 2 人対戦 → おじゃまライン → 必殺技で殴り合い」 まで積み上げる連載の 2 回目。今回は前回作った 1 人用テトリスに、自分で考えて積む CPU を入れます。

「テトリス武闘(バトル)外伝」 というスーパーファミコンのゲームがありました。テトリスにキャラクターの必殺技などを持ち込んだ異色作として知られています。そんなゲームになんとなく近づけようと作成しました。

#タイトル主な内容
1ブラウザで動くテトリスをゼロから設計して作る7 種ピース / 衝突判定 / ライン消去
2本記事:テトリスに CPU 対戦を入れる — 評価関数で形を読む AI4 因子評価関数で 1 手読み
3テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有1 reducer + キーマップ振り分け
4テトリス対戦に「おじゃまライン送信」を入れる — 攻撃 / 相殺 / push up攻撃テーブル / 相殺 / 押し上げ
5クリスタル + 必殺技ゲージ + キャラ別技(連載 最終回)3 キャラ / 時限効果

今回は「対戦」の前提となる CPU 側の脳 を作ります。仕組みは 2 段階だけ:

  1. ヒューリスティック評価関数 — 盤面の良し悪しを数値化する関数(経験則を数式で表したもの)
  2. 1 手読み — 今のピースを置ける全パターンを試して、評価が一番高い手を選ぶ

この 2 つだけで、人間より速く正確に積めるレベルの AI になります。

最初は「先読みなしで強い AI なんて無理だろう」 と思って 2 手先まで読む実装を考えていたのですが、El-Tetris の重みのまま 1 手読みで動かしてみたら、私自身より明らかに上手く積んでいて笑いました。動く demo を眺めていると、AI が「凹凸を嫌って右に逃げる」 「穴ができそうなら回転して避ける」 のが見えて、評価関数 4 因子の効きが直感的にわかります。

触って試す

下の demo では CPU が自分で考えて落とし続けます。右側の表が AI の評価:aggregate height(全列の高さ合計) / cleared(消える行数) / holes(穴の数) / bumpiness(隣接列の高さ差合計) の 4 因子の重み付き和が total で、これが最大になる (rotation, x) を選んでいます。盤面の薄い点線が AI の着地予定地。

「速度」スライダーを動かすと AI の操作速度が変わります(数字が小さいほど高速)。100 ms/step あたりだと「1 手ずつ動かして置いている」 様子が観察できます。

なぜヒューリスティック AI で十分か

テトリスのような「短時間で 1 手を決める」ゲームでは、ゲーム木を深く読むよりも、盤面の良し悪しを数値化して全候補から最良を選ぶだけで十分強くなります。理由:

  • 手が短時間で多数くる(0.5 〜 1 秒に 1 piece)。深く読むほど piece の不確実性で精度が落ちる
  • 盤面が局所的(20 行 × 10 列)。評価関数を 1 回計算するのに数 μs しかかからない
  • 「悪い積み方」が明確(穴ができる、列がガタガタになる)。プロでも避けたい状態が定量化しやすい

この方針は Pierre Dellacherie 法(2003 年頃に提案された、6 因子の重み付き評価関数。テトリス AI のベンチマークとして長く参照されている)や、その派生の El-Tetris(Yiyuan Lee, 2010、4 因子に削った版)で実証されており、重みを調整するだけで人間トップクラスと張り合えます。本記事は El-Tetris の重みをほぼそのまま採用しています(別系統で bumpiness を使わない流派もあり、評価軸の選び方には幅があります)。

全体像 — 「考える → 動く」のサイクル

CPU は piece が出現したタイミングで一度だけ「最良の置き場」を計算し、その後の tick はその目的地に向かって 1 マスずつ動くだけです。

AI の制御フロー — plan は piece 生成時に 1 回だけ、tick は plan に近づける単純動作 (クリックで拡大)

「計画と実行を分ける」のがポイントです。1 tick = 1 評価にすると重く感じますし、表示上も瞬間移動になります。piece 出現時に 1 回考える だけで、tick は機械的に近づけるだけにすると、人間が観察しやすく実装も読みやすくなります。

評価関数の 4 因子

盤面の「良さ」を 4 つの数値の重み付き和で表します。値が大きいほど良い盤面と定義するため、ペナルティ項は負の重みを持ちます。

因子何を見る重み(El-Tetris)
aggregate height全列の高さ合計-0.510066
cleared lines配置で消えるライン数+0.760666
holes上に block がある空 cell-0.35663
bumpiness隣接列の高さ差絶対値の総和-0.184483

直感的には:

  • 高くしない(aggregate height):全体が低いほど次の piece の自由度が増す
  • 消せるなら消す(cleared):line clear は得点 + スペース確保
  • 穴を作らない(holes):上に block がある空 cell は将来掘り返せず詰みに近づく
  • 凸凹を作らない(bumpiness):凹凸が激しいと I 字以外で消しづらい

評価関数を計算する純粋関数:

columnHeights で各列のスタック上端を、countHoles で「上に block がある空 cell」を、bumpiness で隣接列の高さ差を集計します。

function columnHeights(board: Board): number[] {
  const h = Array(BOARD_W).fill(0);
  for (let x = 0; x < BOARD_W; x++) {
    for (let y = 0; y < BOARD_H; y++) {
      if (board[y][x] !== "") { h[x] = BOARD_H - y; break; }
    }
  }
  return h;
}

function countHoles(board: Board): number {
  let holes = 0;
  for (let x = 0; x < BOARD_W; x++) {
    let seenBlock = false;
    for (let y = 0; y < BOARD_H; y++) {
      if (board[y][x] !== "") seenBlock = true;
      else if (seenBlock) holes++;
    }
  }
  return holes;
}

function bumpiness(heights: number[]): number {
  let s = 0;
  for (let i = 0; i < heights.length - 1; i++) {
    s += Math.abs(heights[i] - heights[i + 1]);
  }
  return s;
}

const W = { height: -0.510066, lines: 0.760666, holes: -0.35663, bumpiness: -0.184483 };

function evaluate(board: Board, cleared: number): ScoreBreakdown {
  const h = columnHeights(board);
  const total =
    W.height * h.reduce((a, b) => a + b, 0) +
    W.lines * cleared +
    W.holes * countHoles(board) +
    W.bumpiness * bumpiness(h);
  return { aggregateHeight: h.reduce((a, b) => a + b, 0), completeLines: cleared, holes: countHoles(board), bumpiness: bumpiness(h), total };
}

注:重みは「現状の盤面」ではなく piece を置いて line clear した後の盤面 に対して計算します。配置の善し悪しを評価するため、当然です。

候補の全列挙

piece は 4 つの回転 × 約 10 個の x 位置 = 30 〜 40 通り しかないので、すべての配置を試して評価し、最良の (rotation, x) を選びます。

各候補で「その位置に hard drop した時の着地 y」を求めるため、衝突するまで y を 1 ずつ進める純粋関数 dropY を用意します。

function dropY(board: Board, p: Active): number {
  let y = p.y;
  while (!collides(board, { ...p, y: y + 1 })) y++;
  return y;
}

function findBestPlan(board: Board, type: Piece): Plan {
  let best: Plan = null;
  for (let rot = 0; rot < 4; rot++) {
    for (let x = -2; x < BOARD_W; x++) {
      const trial: Active = { type, rot, x, y: 0 };
      if (collides(board, trial)) continue;          // 初期位置で衝突 → スキップ
      const y = dropY(board, trial);                 // 着地点
      const placed: Active = { ...trial, y };
      const merged = merge(board, placed);
      const { board: cleared, cleared: lines } = clearLines(merged);
      const score = evaluate(cleared, lines);
      if (!best || score.total > best.scoreBreakdown.total) {
        best = { rot, x, y, scoreBreakdown: score };
      }
    }
  }
  return best;
}

x = -2 から開始しているのは、shape matrix の左 2 列が空のことがある(IJ)ため、piece の見かけ位置と x の整合をとるためです。collides で範囲外を弾いてくれるので、無効な x は自然と skip されます。

AI の reducer — plan に近づけるだけ

「計画は piece 出現時に決まっている」ため、tick での reducer は 4 ステップの単純判定になります。

function reducer(state: State, action: Action): State {
  if (action.type === "reset") return initialState();
  if (state.over) return state;
  if (!state.active || !state.plan) return state;

  const a = state.active;
  const target = state.plan;

  if (a.rot !== target.rot) {
    return { ...state, active: { ...a, rot: (a.rot + 1) % 4 } };
  }
  if (a.x < target.x) return { ...state, active: { ...a, x: a.x + 1 } };
  if (a.x > target.x) return { ...state, active: { ...a, x: a.x - 1 } };
  if (a.y < target.y) return { ...state, active: { ...a, y: a.y + 1 } };

  return lockAndSpawn(state);  // 一致したらロック → 次 piece の plan を計算
}

lockAndSpawn の中で findBestPlan(board, next.type) を呼んで次の plan を仕込んでおきます。プレイヤー操作版と異なるのはこの 1 点だけで、それ以外(衝突判定 / merge / clearLines)は共通の純粋関数です。

ゴースト(着地点 preview)を表示する

AI の「考え」を人間が確認できると一気に楽しくなります。state.plan から着地予定 cell を cellsOf で計算して、現在 piece と重ならない cell に薄い色で表示します。

const ghostCells = state.plan && state.active
  ? cellsOf({ type: state.active.type, rot: state.plan.rot, x: state.plan.x, y: state.plan.y })
  : [];

// 描画時:
const isGhost = ghostCells.some((g) => g.bx === bx && g.by === by) && !c;
<div style={{
  background: c ? COLORS[c] : isGhost ? "rgba(99, 102, 241, 0.25)" : "var(--color-paper)",
  outline: isGhost ? "1px dashed rgba(99, 102, 241, 0.6)" : undefined,
}} />

ゴースト表示は デバッグツールとしても便利 で、AI の選択が直感に反する時に「なぜここ?」を観察しやすくなります。

パフォーマンス測定

1 回の findBestPlan の計算量:

  • rotation 4 × x 約 12 = 約 50 候補
  • 候補ごとに dropY で最大 BOARD_H = 20 回の collides
  • evaluate 内の columnHeights / countHoles / bumpinessO(W × H) = 200 cell ずつ

合計 ~50 × (20 + 200 × 3) = ~31,000 cell オペレーション程度。手元の Chrome では 1 plan あたり 0.5 ms 未満 で計算でき、tick 速度の制約にはほぼなりません。

performance.now() で計測するとリアルな数字が見えます:

const t0 = performance.now();
const plan = findBestPlan(board, type);
console.log("plan ms:", performance.now() - t0);

弱点 — 1 手読みの限界

このヒューリスティック AI は強いですが、次 piece の情報を使っていない ため次のような場面で人間に劣ります:

  • I 字待ちの判断ができない: 1 列を空けて完全消し(テトリス)を狙う戦略は、現 piece だけ見ていると「凹凸が増える」と判定されて避ける方向に動く
  • 連鎖を組めない: T-spin / RYUKAGI / B2B のような特殊消しは 2 〜 3 手の先読みが必要
  • おじゃまライン受け の戦略がない:第 4 回 で導入する「相手から飛んでくる灰色行」 を加味した防御策は、今の評価関数では取れない

これらは「2 手読み」(現 piece + next piece の全組み合わせを評価)で大きく改善します。候補数は 50 × 50 = 2500 で、依然 5 ms 程度に収まるはずです。発展として:

  • next piece の固定枠(7-bag からの先読み)を State に持たせる
  • findBestPlan(board, current, next) で 2 重ループ
  • 評価関数の重みを 遺伝的アルゴリズム で最適化(El-Tetris の公開記事に調整プロセスが書かれている。学術論文というよりは個人プロジェクトの解説)

次回以降への伏線

CPU 単体ができたので、次回からは「対戦」を組み上げていきます。

  • 第 3 回: 同じゲームを 2 つの盤面で並走させ、キーボードを分割(WASD vs 矢印)。state を { p1: State, p2: State } に拡張して 1 reducer で動かす
  • 第 4 回: line clear した時にもう片方の盤面下から「おじゃまライン」(穴 1 つの灰色行)が湧く。1 ライン → 0、2 → 1、3 → 2、4(テトリス) → 4 が標準。今回作った AI を防御方向にも応用
  • 第 5 回(連載最終回): ライン消去で クリスタル が貯まり、満タンでキャラ固有の 必殺技(視界妨害 / 攻撃 ×2 / シールド 等)を発動できる仕組みを実装

評価関数 AI は CPU 対戦相手として再利用できます。重みを変えるだけで「ガード型」「攻撃型」のキャラ性格が作れる予定です。

まとめ

  • 1 手読み + 評価関数 4 因子だけで、人間と張り合える程度のテトリス AI が作れる
  • 重みは El-Tetris(Yiyuan Lee, 2010)の値をそのまま使える。自動チューニングは遺伝的アルゴリズムで
  • 「piece 出現時に 1 回計画する」 と「tick ごとに計画へ近づくだけ」 を分けると、観察も実装も単純になる
  • 全候補列挙でも約 50 通り / 1 ms 未満。2 手先読みに広げても 5 ms 程度
  • 弱点は「次の piece を見ていない」 点。連載 第 5 回の必殺技で、相手の盤面状態まで考慮する拡張へ繋げる

関連 Topic — 体系的に学ぶための入口

本記事の理解を深めるための tech-book.net 上の Topic ページ。Topic ページには その分野のおすすめ書籍(編集が選定)と 関連用語 / 学習マップ が並んでいるので、「次に何を読むか」の判断材料に。

tech-book.net /topics/react

React — おすすめ書籍 7 冊・関連用語 51 個・学習マップ

React とは、Meta(旧 Facebook)が 2013 年に公開した宣言的 UI 構築のための JavaScript ライブラリで、フロントエンド開発の事実上の標準である。

この Topic が役立つ理由 — 本記事は React 19 + useReducer の応用編。useState / useReducer / useEffect の基礎が前提なので、Topic ページの React 入門書から順に押さえると AI ロジックが手で書けるレベルに到達できる。
学習マップを tech-book.net で見る
tech-book.net /topics/react-hooks

React Hooks — 定義・前提と次に学ぶ用語 32 個・学習マップ

関数コンポーネントから React の状態・ライフサイクル・コンテキストなどを利用するための API 群。`use` から始まる関数として提供され、コンポーネントのトップレベルでのみ呼び出せる。

この Topic が役立つ理由 — reducer パターン(action → state)はゲームの状態管理によく使われる定番の書き方。Topic ページの hook 解説書を 1 冊読むと、本記事の reducer がなぜこの形になるのか、判断の根拠まで追える。
学習マップを tech-book.net で見る
tech-book.net /topics/javascript

JavaScript — おすすめ書籍 17 冊・関連用語 42 個・学習マップ

JavaScript とは、1995 年に Netscape で生まれたブラウザ向けスクリプト言語が起点で、現在は Web・サーバ・アプリ・組み込みまで幅広く使われる汎用言語である。

この Topic が役立つ理由 — 評価関数の純粋関数化、不変オブジェクトの扱い、配列メソッド連鎖など、AI 実装の各所で JavaScript の関数型寄りの書き方を要求する。基礎を厚くしたい人向け。
学習マップを tech-book.net で見る

関連書籍 — この記事の各節を補強する一冊

tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks/Eve Porcello/宮崎 空 · オライリー・ジャパン · 2021年 · ¥3,740

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

この本が役立つ理由 — 本記事の「piece 出現時に plan を計算して state に持たせ、tick で消費する」 パターンは、本書の Hooks 編の「state を 1 箇所に集める」 設計をそのままゲームに当てはめたもの。AI に限らず、似た計算結果のキャッシュ設計に応用が利きます。
詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov/牧野 聡 · オライリー・ジャパン · 2017年 · ¥2,750

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

この本が役立つ理由 — React の useState / useEffect / useReducer の差を「いつどれを使うか」で整理した薄い入門書。本記事の TetrisAIDemo を写経した後、設計の意図を 1 冊で確認するのにちょうどいい厚み。
詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也/吉田 健人/高林 佳稀 · 技術評論社 · 2022年

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

この本が役立つ理由 — `Active` / `Plan` / `ScoreBreakdown` の型は、本書の「ドメインを型で表現する」 章で扱われている API レスポンス型の作り方とほぼ同じパターン。将棋・オセロなど別ゲームの AI に転用するとき、型から書き始めるための足場になります。
詳細を tech-book.net で見る
tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

この本が役立つ理由 — ブラウザではなくモバイルで同じテトリスを動かすとしたら、という発展に。React Native + Expo なら本記事の component を最小修正で iPhone / Android に展開できる(キー入力をジェスチャに置き換える)。
詳細を tech-book.net で見る
第 3 話 · 2026-05-16

テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有

1 画面に 2 つの盤面を並べ、1 つのキーボードを 2 人で共有して対戦する『versus テトリス』を作る回。状態を `{ p1, p2 }` の形にまとめて 1 つの reducer で両方を進める設計、1 つの keydown 受け口から 2 人にキーを振り分ける仕組み、先に詰んだ方が負け / 詰みが同時ならライン数で勝敗を決める判定までを実装。動く demo 付き。連載「ブラウザで動く対戦テトリスを作る」第 3 回。

検証日: 2026-05-16

使用バージョン: React 19 + TypeScript 6

対象: 連載 第 1-2 回で CPU 単体は作れた、次は人間対人間の対戦盤面を作りたい人

前提: browser-tetris-from-scratch / tetris-cpu-ai-heuristic のコードを引き継ぎます

連載「ブラウザで動く対戦テトリスを作る」第 3 回

対戦テトリス連載のイメージ — クマ(P1)とウサギ(P2)が 1 つのキーボードを左右で共有して対戦している

本記事のゴール — 1 画面 2P 対戦テトリスを React の 1 reducer で組む

#タイトル主な内容
1ブラウザで動くテトリスをゼロから設計して作る7 種ピース / 衝突判定 / ライン消去
2テトリスに CPU 対戦を入れる — 評価関数で形を読む AI4 因子評価関数で 1 手読み
3本記事:テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有1 reducer + キーマップ振り分け
4テトリス対戦に「おじゃまライン送信」を入れる — 攻撃 / 相殺 / push up攻撃テーブル / 相殺 / 押し上げ
5クリスタル + 必殺技ゲージ + キャラ別技(連載 最終回)3 キャラ / 時限効果

今回作るのは「1 画面に 2 つの盤面を並べて、1 つのキーボードを 2 人で共有する対戦テトリス」。連載最終話の「必殺技で殴り合う対戦」へ向けて、対戦時の状態(state)の持ち方入力をプレイヤーに振り分ける設計 を先に固めておきます。

詰まったのは入力の振り分けでした。最初は player ごとに別 listener を作って dispatch を渡す形で書いていたのですが、player を増やすたびに reducer 内の分岐が複雑化していくのが見えて、第 4 回以降の拡張(おじゃまライン、必殺技)に耐えそうになかったので途中で書き直し。1 listener + KEY_MAP 辞書 で全キーを 1 ヶ所に集約する形にしたら、一気に書けるようになりました。今回の中で一番大きな設計変更です。

触って試す

  • Player 1: A D 移動 / W or Q 回転 / S 落下 / E ハードドロップ
  • Player 2: 移動 / or N 回転 / 落下 / M ハードドロップ
  • 共通: P 一時停止 / R リセット

先に top out(piece が出現位置で衝突 = 詰み)したほうが負け。両方 top out した場合は lines が多い側の勝ち(同数なら DRAW)。

なぜ「1 reducer で 2 プレイヤー」にするか

素直に思いつくのは「2 つの <TetrisDemo /> を並べる」ですが、これは対戦化には向きません:

構成対戦化の容易さ
2 つの独立 demo を並べる✗ 勝敗判定で 両方の state を同時に読めない(別 component 配下)
親で { p1, p2 } を 1 reducer 管理✓ 勝敗判定 / おじゃまライン送信 / 同時 tick が全部 1 ヶ所で書ける

次回(第 4 回)で実装する 「Player 1 が 2 ライン消した → Player 2 の盤面下に 1 行湧く」 のような state 間の副作用 は、1 reducer ならアトミックに書けます。今回はその下地作り。

全体像

入力 → KEY_MAP で振り分け → 1 reducer → 毎 tick で両 player 進行 → 勝敗判定 (クリックで拡大)

State 設計 — 2 player をネストする

各プレイヤーの state(board / active / score / lines / over)を PlayerState にまとめ、トップは { p1, p2, paused, winner }:

type PlayerState = {
  board: Board;
  active: Active | null;
  score: number;
  lines: number;
  over: boolean;
};

type State = {
  p1: PlayerState;
  p2: PlayerState;
  paused: boolean;
  winner: "p1" | "p2" | "draw" | null;
};

paused / winner をトップに置く のがポイント:両プレイヤー共通の状態は外側、プレイヤー固有は内側。次回のおじゃまラインのキュー(pendingGarbage)も PlayerState 側に入ります。

Action 設計 — player タグで振り分け

action の型に player: "p1" | "p2" を持たせれば、reducer 内で「どっちの state を更新するか」が一意に決まります。

type PlayerId = "p1" | "p2";
type Move = "left" | "right" | "down" | "rotate" | "drop";

type Action =
  | { type: "tick" }                              // 共通(両 player 同時進行)
  | { type: "move"; player: PlayerId; move: Move } // プレイヤー固有
  | { type: "togglePause" }
  | { type: "reset" };

tick だけ player タグを持たない理由 — 両プレイヤーが同じ周期で落ちる ためには 1 つの setInterval から両方を進めるのが自然です(2 つ別 interval にすると tick がズレて視覚的に不揃いになる)。

Reducer — applyMove(ps, move) で 1 プレイヤー分を差し替える

「1 プレイヤー分の純粋関数」を切り出しておけば、reducer 本体は どの player に適用するかを選ぶだけ になります。

function applyMove(ps: PlayerState, move: Move): PlayerState {
  if (ps.over || !ps.active) return ps;
  switch (move) {
    case "down": {
      const np = { ...ps.active, y: ps.active.y + 1 };
      if (!collides(ps.board, np)) return { ...ps, active: np };
      return lockAndSpawn(ps);
    }
    // ... left / right / rotate / drop も同じ形
  }
}

function tickPlayer(ps: PlayerState): PlayerState {
  if (ps.over || !ps.active) return ps;
  const np = { ...ps.active, y: ps.active.y + 1 };
  if (!collides(ps.board, np)) return { ...ps, active: np };
  return lockAndSpawn(ps);
}

function reducer(state: State, action: Action): State {
  if (action.type === "reset") return initialState();
  if (state.winner) return state;
  if (state.paused && action.type !== "togglePause") return state;

  switch (action.type) {
    case "togglePause":
      return { ...state, paused: !state.paused };
    case "tick": {
      const p1 = tickPlayer(state.p1);
      const p2 = tickPlayer(state.p2);
      return { ...state, p1, p2, winner: computeWinner(p1, p2) };
    }
    case "move": {
      const updated = applyMove(state[action.player], action.move);
      const next = { ...state, [action.player]: updated };
      return { ...next, winner: computeWinner(next.p1, next.p2) };
    }
  }
}

state[action.player] と書くと、action.player の値(“p1” or “p2”)を使って動的に state.p1state.p2 を選べます(state["p1"]state.p1 と同じ意味)。p1 / p2 を同じ shape の object に揃えてあるおかげで一行で書けます。配列(players[0] / players[1])で持つ手もありますが、object のほうが TypeScript の型補完が素直です。

キーマップ — 1 listener から 2 プレイヤーに振り分ける

1 つの window-level keydown listener で全キーを拾い、KEY_MAP で「どの player のどの move か」を引きます。

const KEY_MAP: Record<string, { player: PlayerId; move: Move } | "togglePause" | "reset"> = {
  // Player 1 (WASD + Q rotate + E drop)
  a: { player: "p1", move: "left" },
  d: { player: "p1", move: "right" },
  s: { player: "p1", move: "down" },
  w: { player: "p1", move: "rotate" },
  q: { player: "p1", move: "rotate" },  // 回転は 2 キー
  e: { player: "p1", move: "drop" },
  // Player 2 (Arrow + N rotate + M drop)
  ArrowLeft: { player: "p2", move: "left" },
  ArrowRight: { player: "p2", move: "right" },
  ArrowDown: { player: "p2", move: "down" },
  ArrowUp: { player: "p2", move: "rotate" },
  n: { player: "p2", move: "rotate" },
  m: { player: "p2", move: "drop" },
  // 共通
  p: "togglePause",
  r: "reset",
};

useEffect(() => {
  const onKey = (e: KeyboardEvent) => {
    if (e.target instanceof HTMLInputElement) return;
    const mapped = KEY_MAP[e.key];
    if (!mapped) return;
    e.preventDefault();
    if (mapped === "togglePause") dispatch({ type: "togglePause" });
    else if (mapped === "reset") dispatch({ type: "reset" });
    else dispatch({ type: "move", player: mapped.player, move: mapped.move });
  };
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, []);

キー配置の選び方

キー採用理由
Player 1 = WASD + Q/E片手で完結。左手用、キーボードの左半分
Player 2 = 矢印 + N/M片手で完結。右手用、キーボードの右半分
回転を WQ の 2 つにテンキー無しキーボードでも親指 / 人差し指のどちらでも届く
P / R 共通試合管理は片方が押せば両方に効く

両プレイヤーが同時に打鍵してもブラウザの keydown は順番に 1 つずつ発火するので、ロジック側で困ることはありません。物理的な制約として、安価なキーボードでは「複数キー同時押しが認識されない」 ことがありますが、本格対戦を目指すなら外部 USB ゲームパッド対応に切り替えるのが現実的です。

勝敗判定 — computeWinner を tick / move の毎度呼ぶ

prev state からも next state からも独立した 純粋関数 にしておけば、reducer の各 case の最後で呼ぶだけです。

function computeWinner(p1: PlayerState, p2: PlayerState): State["winner"] {
  if (p1.over && p2.over) {
    if (p1.lines === p2.lines) return "draw";
    return p1.lines > p2.lines ? "p1" : "p2";
  }
  if (p1.over) return "p2";
  if (p2.over) return "p1";
  return null;
}

「先に top out したほうが負け」 + 「ほぼ同時に top out した場合の DRAW 判定」 を 1 関数で。第 4 回のおじゃまラインが入ると 「ガベージで押し上げられて初手で top out」 という判定要件が増えるので、ここを 1 ヶ所に集約しておくと後で追加が楽です。

ゲームオーバー UI — 負け側を半透明 + ラベル色変え

勝敗を一目で伝えるために、視覚的に勝者と敗者を区別します:

const won = winner === self;
const lost = winner && winner !== "draw" && winner !== self;

<span style={{
  background: won ? "#16a34a" : lost ? "#dc2626" : "var(--color-paper)",
  color: won || lost ? "#fff" : "var(--color-ink)",
}}>
  {label}
</span>

<div style={{ /* board grid */ opacity: lost ? 0.6 : 1 }}>...</div>

ラベルが緑なら勝者、赤なら敗者、無色なら試合中。負け盤面を 60% 不透明にして「凍ったような演出」。

次回への伏線 — おじゃまライン

第 4 回で入れる予定:

  • Player 1 が 2 ライン消した → Player 2 の盤面下から 1 行湧く(灰色 + 1 マス穴)
  • 4 ライン消し(テトリス)→ 相手に 4 行送る
  • 受け手の active piece が押し上げで衝突 → top out

実装上の変更点(予告):

  • PlayerStatependingGarbage: number を追加
  • tickPlayerlockAndSpawn 時に 「自分が消した行数 - 1」 を相手の pendingGarbage にキュー
  • 次の tick で相手の盤面下から garbage を pushUp

今回作った { p1, p2 } の 1 reducer 構造があれば、この拡張は reducer の 1 関数追加で済みます。

まとめ

  • 1 画面 2 盤面の対戦は、独立した demo を 2 つ並べるより「1 つの reducer に { p1, p2 }」が後の拡張で楽
  • action に player: "p1" | "p2" タグを足すと、reducer 内の振り分けが state[action.player] で書ける
  • キーマップは 1 つの window-level listener + KEY_MAP 辞書に集約。プレイヤー追加にも強い
  • 勝敗判定や、次回入れるおじゃまラインのような state 間副作用も、純粋関数 1 つに集約しておけば追加が楽
  • 勝敗確定時に勝者(緑)/ 敗者(赤 + 半透明)で視覚的に区別すると、試合の決着が一目で分かる

次回は 第 4 回 おじゃまライン送信 で、state 間の副作用をどう純粋に書くかを扱います。


関連 Topic — 体系的に学ぶための入口

tech-book.net /topics/react

React — おすすめ書籍 7 冊・関連用語 51 個・学習マップ

React とは、Meta(旧 Facebook)が 2013 年に公開した宣言的 UI 構築のための JavaScript ライブラリで、フロントエンド開発の事実上の標準である。

この Topic が役立つ理由 — 本記事の `useReducer` + `{ p1, p2 }` ネスト state は React の state 設計の応用編。Topic ページの React 解説書を 1 冊やっておくと、2 player → 4 player などプレイヤー数拡張時の設計が手で書けるようになる。
学習マップを tech-book.net で見る
tech-book.net /topics/react-hooks

React Hooks — 定義・前提と次に学ぶ用語 32 個・学習マップ

関数コンポーネントから React の状態・ライフサイクル・コンテキストなどを利用するための API 群。`use` から始まる関数として提供され、コンポーネントのトップレベルでのみ呼び出せる。

この Topic が役立つ理由 — `useReducer` + `useRef` + `useEffect` で「ゲームループ + キー入力 + 自動 tick」を共存させるパターンは、ゲーム以外のリアルタイム UI(チャット / 共同編集)にも転用できる。
学習マップを tech-book.net で見る
tech-book.net /topics/javascript

JavaScript — おすすめ書籍 17 冊・関連用語 42 個・学習マップ

JavaScript とは、1995 年に Netscape で生まれたブラウザ向けスクリプト言語が起点で、現在は Web・サーバ・アプリ・組み込みまで幅広く使われる汎用言語である。

この Topic が役立つ理由 — `KEY_MAP` の object 辞書 / `state[action.player]` の動的アクセス / 純粋関数による state 遷移など、ロジック部は React と独立した JavaScript の問題。基礎を厚くしたい人向け。
学習マップを tech-book.net で見る

関連書籍 — この記事の各節を補強する一冊

tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks/Eve Porcello/宮崎 空 · オライリー・ジャパン · 2021年 · ¥3,740

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

この本が役立つ理由 — 本記事の `{ p1, p2 }` を 1 reducer で扱う設計は、本書 6 章の「state は共通の祖先に持たせる(state lift up)」の典型例。2 つの component を別々の useReducer で動かしてから、共通祖先で 1 reducer にする リファクタの章 を読むと、本記事の判断が手で再現できるようになります。
詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov/牧野 聡 · オライリー・ジャパン · 2017年 · ¥2,750

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

この本が役立つ理由 — `useReducer` の dispatch をプレイヤー単位で振り分ける書き方は、薄い入門書 1 冊で「reducer / action / state」の関係を順に追っておくと、本記事のコードがそのまま読めるようになる。
詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也/吉田 健人/高林 佳稀 · 技術評論社 · 2022年

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

この本が役立つ理由 — 本記事の `PlayerState` / `PlayerId` / `Move` / `Action` などの discriminated union 設計は、本書の「ドメインを型で表現する」章の典型例。型が厚いほど対戦ロジックのバグが減る。
詳細を tech-book.net で見る
tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

この本が役立つ理由 — 同じ versus テトリスをスマホで動かしたいなら React Native + Expo。本記事の component を最小修正で、左右タッチ領域 + ジェスチャに置き換えてモバイル対応できる。
詳細を tech-book.net で見る

連載前回:テトリスに CPU 対戦を入れる — 評価関数で形を読む AI

第 4 話 · 2026-05-16

テトリス対戦に「おじゃまライン送信」を入れる — 攻撃 / 相殺 / push up

前回作った 2 人対戦テトリスに『おじゃまライン送信』を加える回。ライン消しの数で攻撃力が決まり(1 ライン=0 行 / 2 ライン=1 行 / 3 ライン=2 行 / 4 ライン=4 行を相手に送信)、自分の攻撃は自分が受ける予定の攻撃を先に打ち消す『相殺』が起きる。受けた攻撃は次に自分がピースを置いた瞬間に、穴が 1 マスだけ空いた灰色の行として盤面下から押し上げられる。実装は純粋関数の組み合わせ + useReducer で行う。連載「ブラウザで動く対戦テトリスを作る」第 4 回。

検証日: 2026-05-16

使用バージョン: React 19 + TypeScript 6

対象: 前回の 2P 対戦盤面に攻撃メカニクスを足したい人

前提: 連載 第 3 回 2P 同一画面 + キーボード分割 のコードを引き継ぎます

連載「ブラウザで動く対戦テトリスを作る」第 4 回

対戦テトリス連載のイメージ — クマ(P1)が灰色のおじゃまラインの押し上げで詰まりかけ、ウサギ(P2)が攻撃を仕掛けている。攻撃テーブル 1→0/2→1/3→2/4→4 と受信キューが画面に出ている

本記事のゴール — 攻撃テーブル + 相殺 + push up メカニクスで「殴り合い」を成立させる

#タイトル主な内容
1ブラウザで動くテトリスをゼロから設計して作る7 種ピース / 衝突判定 / ライン消去
2テトリスに CPU 対戦を入れる — 評価関数で形を読む AI4 因子評価関数で 1 手読み
3テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有1 reducer + キーマップ振り分け
4本記事:テトリス対戦に「おじゃまライン送信」を入れる — 攻撃 / 相殺 / push up攻撃テーブル / 相殺 / 押し上げ
5クリスタル + 必殺技ゲージ + キャラ別技(連載 最終回)3 キャラ / 時限効果

今回作るのは「相手にライン消しの量だけ灰色行(garbage)を送りつけ、盤面下から押し上げる」 仕組み。最終話の必殺技で「特殊な garbage を送る」 ような拡張ができるよう、攻撃量の計算 / 相殺 / 押し上げを純粋関数に分けて実装します。

最初の試作で攻撃テーブルを [0, 1, 2, 3, 4](1 ライン消しでも 1 行送る)で書いて手元で動かしたら、対戦が一気にインフレして 10 秒で詰むゲームになりました。[0, 0, 1, 2, 4](single は攻撃にならない)に変えると、ピースを積んでまとめて消す駆け引きが急に生まれます。攻撃テーブルの数字をちょっと変えるだけでゲーム性が別物になるのを実装中に体感しました。

触って試す

  • Player 1: A D 移動 / W or Q 回転 / S 落下 / E ハードドロップ
  • Player 2: 移動 / or N 回転 / 落下 / M ハードドロップ
  • 共通: P 一時停止 / R リセット

2 ライン同時消しを試すと、相手ラベル隣に ⚠ +1 バッジが出る → 相手の次 piece 確定時に灰色行が下から push up される。4 ライン消し(テトリス) で一気に ⚠ +4、相手の盤面が押し上げで詰みやすくなります。

攻撃テーブル

同時消去通称相手へ送る garbage 行数
1single0
2double1
3triple2
4テトリス4

「1 ライン消しは攻撃にならない」 のがポイントです。まとめて消すほど攻撃効率が一段と上がる設計で、「ピースを積んでおいて 4 ラインまとめて消す(=テトリス)」 という対戦テトリスの駆け引きの根幹がここから生まれます。

// 攻撃量テーブル — index = 同時消去ライン数
// 同時消去ライン数 → 相手へ送る garbage 行数
//   index 0 = 0 ライン(消えなかった)→ 0 行
//   index 1 = 1 ライン(single)      → 0 行
//   index 2 = 2 ライン(double)      → 1 行
//   index 3 = 3 ライン(triple)      → 2 行
//   index 4 = 4 ライン(テトリス)    → 4 行
const GARBAGE_TABLE = [0, 0, 1, 2, 4];

全体像 — 攻撃 → 相殺 → 受け取りキュー → push up

lock の度に: 攻撃量計算 → 自分の incoming と相殺 → 残りを相手の pendingGarbage へ → 自分は次 piece の spawn 直前に push up (クリックで拡大)

「自分のターンに、自分の受信キューを処理する」 のが要点です。相手が攻撃してきても、自分がライン消しさえすれば打ち消せます — これが現代の対戦テトリスの中心的な駆け引きです。

Cell 型を拡張 — "G" を足すだけ

garbage 行は「灰色 + 1 マス穴」。色違いを表現するため Cell 型に "G" を足します。

type Cell = "" | Piece | "G";

const COLORS: Record<Piece | "G", string> = {
  I: "#22d3ee", O: "#fde047", T: "#a78bfa",
  S: "#86efac", Z: "#fca5a5", L: "#fdba74", J: "#93c5fd",
  G: "#6b7280",   // gray-500
};

collides / clearLines / merge は「空文字かそれ以外か」 だけで判定しているので、"G" を加えるだけでそのまま動きます。Cell を「色 + ピース種別」 で持つ設計だと、こうした派生型の追加が型 1 行で済みます。

State 拡張 — pendingGarbagetotalSent

PlayerState に 2 フィールド追加:

type PlayerState = {
  board: Board;
  active: Active | null;
  score: number;
  lines: number;
  over: boolean;
  pendingGarbage: number;   // 自分が受け取る待ち
  totalSent: number;        // 統計表示用(相手に送った累計)
};

pendingGarbage「相手から受け取って、自分の次 lock 時に押し上げられる garbage 行数」totalSent は UI 上の “SENT 7” 表示用で、ゲームロジック自体には影響しません。

pushGarbageUp — 上 N 行を削り、下に garbage 行を足す

「盤面を下から押し上げる」のは、配列操作 1 行で済みます。

function pushGarbageUp(board: Board, count: number): Board {
  const garbageRows: Board = [];
  for (let i = 0; i < count; i++) {
    const hole = Math.floor(Math.random() * BOARD_W);   // 穴位置 = ランダム 1 列
    const row: Cell[] = Array.from({ length: BOARD_W }, (_, x) =>
      x === hole ? "" : ("G" as Cell)
    );
    garbageRows.push(row);
  }
  return [...board.slice(count), ...garbageRows];  // 上 N 行を捨てて、下に garbage を追加
}

「上から N 行を 削る」 のが「押し上げ」と等価。盤面全体は常に 20 行を維持する設計なので、 slice(count) で頭を切る + 下に N 行追加で帳尻が合います。

穴は 1 列ランダム:Math.floor(Math.random() * BOARD_W) をループ内で毎行呼んでいるため、複数行送信時は 行ごとに穴の列がバラバラ になります(各行が独立にランダム)。

選択肢としては:

  • 行ごとにランダム(本実装):受け手は各行を別々に処理する必要があり、消すのに手数がかかる
  • 複数行で穴位置を一致:現代テトリスのガイドライン仕様。一気に消しやすいぶん、攻撃のキツさは下がる(対戦が長引きにくい)

今回はシンプルな前者で実装しています。一致にしたければ穴位置を 1 度だけ決めて全行に流用すれば済みます。

lockAndSpawn — 攻撃量計算 + 相殺 + push up を 1 関数に集約

連載第 3 回まで lockAndSpawn は piece を merge → ライン消去 → 次 piece を spawn するだけでしたが、第 4 回からはここに 攻撃ロジック が同居します。返り値も「次の state」だけでなく「相手に送る garbage 量」を返すように拡張:

function lockAndSpawn(ps: PlayerState): { ps: PlayerState; garbageSent: number } {
  if (!ps.active) return { ps, garbageSent: 0 };

  const merged = merge(ps.board, ps.active);
  const { board: cleared, cleared: linesCleared } = clearLines(merged);

  // 1) 自分の outgoing
  const garbageOut = GARBAGE_TABLE[Math.min(linesCleared, GARBAGE_TABLE.length - 1)] ?? 0;

  // 2) cancellation: 自分の incoming を outgoing で相殺
  let remainingIncoming = ps.pendingGarbage;
  let remainingOutgoing = garbageOut;
  const cancel = Math.min(remainingIncoming, remainingOutgoing);
  remainingIncoming -= cancel;
  remainingOutgoing -= cancel;

  // 3) 残った incoming を自分の盤面に適用(push up)
  const afterGarbage = remainingIncoming > 0
    ? pushGarbageUp(cleared, remainingIncoming)
    : cleared;

  // 4) 次 piece spawn(garbage 押し上げで top out するケースも考慮)
  const nextPiece = newActive();
  if (collides(afterGarbage, nextPiece)) {
    return {
      ps: { ...ps, board: afterGarbage, active: null, over: true, pendingGarbage: 0 },
      garbageSent: remainingOutgoing,
    };
  }

  return {
    ps: {
      ...ps,
      board: afterGarbage,
      active: nextPiece,
      score: ps.score + SCORE_TABLE[linesCleared],
      lines: ps.lines + linesCleared,
      pendingGarbage: 0,
      totalSent: ps.totalSent + remainingOutgoing,
    },
    garbageSent: remainingOutgoing,
  };
}

ステップを 4 段階に分けて 書くのが読みやすさのコツ:

  1. outgoing 計算(自分が何行送るか)
  2. cancellation(自分の incoming と相殺)
  3. incoming 適用(残った incoming を push up)
  4. 次 piece spawn(garbage で top out も検出)

Reducer — tickmove で garbage を routing

lockAndSpawngarbageSent を返してくるので、reducer 側で 相手の pendingGarbage にキュー します:

case "tick": {
  const r1 = tickPlayer(state.p1);
  const r2 = tickPlayer(state.p2);
  // 両プレイヤーが同じ tick で消した場合、お互いに送り合う
  const p1 = { ...r1.ps, pendingGarbage: r1.ps.pendingGarbage + r2.garbageSent };
  const p2 = { ...r2.ps, pendingGarbage: r2.ps.pendingGarbage + r1.garbageSent };
  return { ...state, p1, p2, winner: computeWinner(p1, p2) };
}

case "move": {
  const r = applyMove(state[action.player], action.move);
  const other: PlayerId = action.player === "p1" ? "p2" : "p1";
  const next: State = {
    ...state,
    [action.player]: r.ps,
    [other]: { ...state[other], pendingGarbage: state[other].pendingGarbage + r.garbageSent },
  };
  return { ...next, winner: computeWinner(next.p1, next.p2) };
}

同じ tick で両者がテトリス消ししたら 4 ずつ送り合う」のような同時イベントも、純粋関数として自然に書けます(r1.garbageSentr2.garbageSent を独立に計算して、それぞれ相手に追加)。

視覚化 — ⚠ +N バッジで脅威を可視化

pendingGarbage > 0 の player に 赤バッジ を出します:

{state.pendingGarbage > 0 && !state.over && (
  <span
    className="text-xs font-bold font-mono px-2 py-0.5 rounded"
    style={{ background: "#dc2626", color: "#fff" }}
    title="次の lock 時にこの行数の garbage が下から push up される"
  >
    ⚠ +{state.pendingGarbage}
  </span>
)}

これがあると、「相手にテトリスを決めた直後」 に相手のバッジが赤く +4 と表示され、次の piece 確定で 4 行がせり上がる瞬間まで一連の流れとして追えます。

統計表示も追加 — SCORE / LINES の隣に SENT(累計送信行数)を赤で:

<span>
  <span style={{ color: "var(--color-ink-soft)" }}>SENT</span>
  <strong style={{ color: "#dc2626" }}>{state.totalSent}</strong>
</span>

「相殺」 がなぜ駆け引きを生むか

このメカニクスのおかげで、プレイヤーは 2 つの戦略軸 を持つことになります:

戦略行動効果
攻撃(offense)できるだけ大きな同時消し(2+)で攻撃量を稼ぐ相手に garbage を送りつけ詰ませる
防御(defense)相手の攻撃が来たら、こちらもライン消しで相殺受け取り行数を 0 に近づける

⚠ +3 が立った時、

  • そのまま 1 ライン消しを 3 回 → 攻撃 0 ずつなので相殺できない、3 行 garbage を受ける
  • まとめて 3 ライン同時消し → outgoing 2 で 2 行相殺、1 行だけ受ける + 0 行送る
  • まとめて 4 ライン消し(テトリス)→ outgoing 4 で 3 行相殺、0 行受ける + 1 行を相手に 逆襲

「相殺できる量を計算しながら積み方を変える」が、対戦テトリスの読み合いの核です。

次回への伏線 — 必殺技ゲージ

第 5 回(連載最終話) で実装する キャラ別必殺技 は、本記事の「自分が送る攻撃量(outgoing garbage)」を拡張する形で実装できます。

  • ライン消しで クリスタル(crystal、画面上のキラキラ)を蓄積
  • クリスタルが満タンになると 必殺技ゲージ が発動可能
  • 必殺技を撃つと、通常 garbage とは違う特殊効果(視界妨害 / 操作反転 / piece 変形)を相手に送る

実装上のポイント(予告):

  • PlayerStatecrystals: number / specialReady: boolean を追加
  • pushGarbageUp を一般化 → applyAttack(board, attackType, params)
  • attackType が "GARBAGE" | "BLIND" | "REVERSE" | "MUTATE" の discriminated union に

本記事までで作った 「純粋関数で attack を表現 + reducer で routing」 の構造が、そのまま必殺技にスライドできます。

まとめ

  • Cell 型に "G" を 1 つ足すだけで garbage を表現できる(cell ベース設計の素直な拡張)
  • 攻撃テーブル [0, 0, 1, 2, 4] で「まとめて消すほど大きく送れる」 設計に
  • 自分の送信が自分の受信を打ち消す「相殺」 が、攻めと守りの読み合いを生む
  • lockAndSpawn を「送信量計算 → 相殺 → 受信を盤面に反映 → 次 piece」の 4 段階に分けると、後の拡張(必殺技)が 1 関数追加で済む
  • reducer は { ps, garbageSent } を返す関数を組み合わせて「相手にキューする」 を表現

次は 第 5 回 必殺技ゲージ + キャラ別技 で、連載のゴールへ。


関連 Topic — 体系的に学ぶための入口

tech-book.net /topics/react

React — おすすめ書籍 7 冊・関連用語 51 個・学習マップ

React とは、Meta(旧 Facebook)が 2013 年に公開した宣言的 UI 構築のための JavaScript ライブラリで、フロントエンド開発の事実上の標準である。

この Topic が役立つ理由 — 本記事の `PlayerState` 拡張(`pendingGarbage` / `totalSent` 追加)は React の段階的 state 拡張の典型例。Topic ページの React 解説書を 1 冊やっておくと、機能追加時の state 設計が手で書けるようになる。
学習マップを tech-book.net で見る
tech-book.net /topics/react-hooks

React Hooks — 定義・前提と次に学ぶ用語 32 個・学習マップ

関数コンポーネントから React の状態・ライフサイクル・コンテキストなどを利用するための API 群。`use` から始まる関数として提供され、コンポーネントのトップレベルでのみ呼び出せる。

この Topic が役立つ理由 — `useReducer` の return 値拡張(`{ ps, garbageSent }` で副作用情報を持って返す)パターンは、ゲーム以外のリアルタイム UI(チャット / 共同編集)で『誰に何が起きたか』を表現する設計に応用できる。
学習マップを tech-book.net で見る
tech-book.net /topics/javascript

JavaScript — おすすめ書籍 17 冊・関連用語 42 個・学習マップ

JavaScript とは、1995 年に Netscape で生まれたブラウザ向けスクリプト言語が起点で、現在は Web・サーバ・アプリ・組み込みまで幅広く使われる汎用言語である。

この Topic が役立つ理由 — `[...board.slice(count), ...garbageRows]` のような不変配列操作 / `Math.min` での相殺計算 / 純粋関数の合成は、ロジック部の中核。基礎を厚くしたい人向け。
学習マップを tech-book.net で見る

関連書籍 — この記事の各節を補強する一冊

tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks/Eve Porcello/宮崎 空 · オライリー・ジャパン · 2021年 · ¥3,740

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

この本が役立つ理由 — 本記事の `lockAndSpawn` が `{ ps, garbageSent }` を返す形(state 更新 + 次の指示を 1 セットで返す)は、本書の Hooks 編で「副作用を reducer の外側に切り出す」 章の応用。攻撃の routing を reducer から分離するときに参照しました。
詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov/牧野 聡 · オライリー・ジャパン · 2017年 · ¥2,750

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

この本が役立つ理由 — `useReducer` で 2 player の state を扱う発展編。pendingGarbage の追加 / 相殺ロジックを reducer 内で表現する書き方が、薄い入門書から手を動かすと一気に身につく。
詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也/吉田 健人/高林 佳稀 · 技術評論社 · 2022年

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

この本が役立つ理由 — 本記事の `Cell = '' | Piece | 'G'` のような union 型拡張は、本書の「型を厚く書いてバグを設計時に潰す」章の応用。次回の必殺技で attackType を discriminated union にする時に効く。
詳細を tech-book.net で見る
tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

この本が役立つ理由 — 同じ versus テトリスをスマホで動かしたいなら React Native + Expo。pendingGarbage の警告バッジ表示はそのまま、入力部だけタッチ領域に置き換えるイメージ。
詳細を tech-book.net で見る

連載前回:テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有

第 5 話 · 2026-05-16

クリスタル + 必殺技ゲージ + キャラ別技(連載 最終回)

連載のゴール。前回までの 2 人対戦テトリス + おじゃまライン送信に、ライン消しで貯まる『クリスタル』と、満タンで撃てるキャラ固有の『必殺技』を実装する。クマ(シールド:5 秒間 おじゃまラインを完全カット)/ ウサギ(速攻:5 秒間 攻撃力 ×2)/ フクロウ(視界妨害:相手の盤面を 5 秒間グレースケール)の 3 キャラ。時限効果は『種類 + 有効期限』の配列で管理し、毎 tick で期限切れを除外するだけの素朴な設計。最後におまけとして、ブラウザだけで(npm install 不要 / 自分の手で)この対戦テトリスを作れる Gemini / Claude 用プロンプトを掲載。連載「ブラウザで動く対戦テトリスを作る」第 5 回(最終回)。

検証日: 2026-05-16

使用バージョン: React 19 + TypeScript 6

対象: 連載 第 1〜4 回まで作ってきた対戦テトリスに、最後の 1 ピース(必殺技)を載せたい人

前提: 連載 第 4 回 おじゃまライン送信 のコードを引き継ぎます

連載「ブラウザで動く対戦テトリスを作る」第 5 回(最終回)

対戦テトリス連載 最終話のイメージ — クマ(シールド)と ウサギ(速攻 ×2 攻撃) と フクロウ(視界妨害)の 3 キャラの必殺技

本記事のゴール — クリスタル + 必殺技ゲージ + キャラ別技 で殴り合う対戦テトリス(おまけ:ブラウザだけで作れる Gemini / Claude 用プロンプト付き)

#タイトル主な内容
1ブラウザで動くテトリスをゼロから設計して作る7 種ピース / 衝突判定 / ライン消去
2テトリスに CPU 対戦を入れる — 評価関数で形を読む AI4 因子評価関数で 1 手読み
3テトリスを 2P 対戦化する — 同一画面分割 + キーボード共有1 reducer + キーマップ振り分け
4テトリス対戦に「おじゃまライン送信」を入れる — 攻撃 / 相殺 / push up攻撃テーブル / 相殺 / 押し上げ
5本記事:クリスタル + 必殺技ゲージ + キャラ別技(連載 最終回)3 キャラ / 時限効果

連載のゴール — 「ライン消しで貯まるエネルギーを、キャラ固有の必殺技に変換して殴り合う」 を実装します。

実装中、視界妨害(grayscale + blur)を入れた直後に「自分が必殺技を撃ったのに自分の盤面が見えなくなる」 というバグを 30 分追いかけました。原因は debuff 効果を「自分」 の effects 配列に push してしまっていた reducer の typo。effect を data として持っているので、devtool で state.p1.effects を覗いたら一発で気づけました。setTimeout でやっていたら、どこに残骸が残っているか追えなかったと思います。

触って試す

  • Player 1: A D 移動 / W or Q 回転 / S 落下 / E ハードドロップ / F 必殺技
  • Player 2: 移動 / or N 回転 / 落下 / M ハードドロップ / L 必殺技
  • 共通: P 一時停止 / R リセット
  • ライン消し +1 クリスタル / 6 個 溜まったら必殺技発動可(5 秒持続)
  • リセット前に各 player のキャラを切替可能

なぜ「必殺技」が連載のゴールなのか

連載 第 1〜4 回までで作ったのは 「同じルールで 2 人が対戦するテトリス」。これだけでも十分遊べますが、武闘外伝の面白さは キャラごとに違う戦い方を選べる 点にあります。

  • 相手の攻撃が来そう → 防御型(クマ)で耐える
  • 早めにリードを取りたい → 攻撃型(ウサギ)で押し切る
  • 相手のうまさを削ぎたい → 妨害型(フクロウ)で時間を奪う

これを成立させるには、「ライン消し」 → 「クリスタル」 → 「ゲージ満タン」 → 「キャラ固有の効果発動」 の流れを純粋関数で表現する必要がある。連載で積み上げてきた reducer + effect の設計が、ここで全部つながります。

全体像 — クリスタルから必殺技まで

ライン消去 → クリスタル蓄積 → 必殺技発動 → effects 配列に時限効果を push → 毎 tick で期限切れを purge (クリックで拡大)

3 キャラと必殺技

キャラタイプ必殺技効果
🐻 クマ防御型シールド5 秒間 incoming garbage を 0 化 + 現在の pending もクリア
🐰 ウサギ攻撃型速攻5 秒間 自分の outgoing 攻撃量を ×2
🦉 フクロウ妨害型視界妨害相手の盤面を 5 秒間 grayscale + blur

実装上は「自分に効く効果(self-buff)」と「相手に効く効果(debuff)」の 2 種に分かれます:

  • self-buff(クマ・ウサギ):自分の effects 配列に push
  • debuff(フクロウ):相手の effects 配列に "blinded" を push

State 拡張 — character / crystals / effects

PlayerState に 3 フィールドを追加。前回までの設計を壊さずそのまま積み増しできます。

type SpecialKind = "shield" | "rush" | "blind";
type CharacterId = "bear" | "rabbit" | "owl";
type Effect = { type: SpecialKind | "blinded"; expiresAt: number };

type PlayerState = {
  // 連載 1-4 回までと同じ
  board: Board;
  active: Active | null;
  score: number;
  lines: number;
  over: boolean;
  pendingGarbage: number;
  totalSent: number;
  // 第 5 回で追加
  character: CharacterId;
  crystals: number;
  effects: Effect[];
};

キャラクター定義は lookup table にまとめます。新キャラを足すときは table に 1 行追加するだけで全部繋がります。

type CharDef = {
  id: CharacterId;
  label: string;
  emoji: string;
  special: SpecialKind;
  specialName: string;
  badgeColor: string;
};

const CHARACTERS: Record<CharacterId, CharDef> = {
  bear:   { id: "bear",   label: "クマ",     emoji: "🐻", special: "shield", specialName: "シールド",   badgeColor: "#0ea5e9" },
  rabbit: { id: "rabbit", label: "ウサギ",   emoji: "🐰", special: "rush",   specialName: "速攻",       badgeColor: "#dc2626" },
  owl:    { id: "owl",    label: "フクロウ", emoji: "🦉", special: "blind",  specialName: "視界妨害",   badgeColor: "#a855f7" },
};

時限効果(Effect)の管理 — expiresAt + purgeExpired

「5 秒だけ有効」 を表現するとき、最初に思いつくのは setTimeout(消す処理, 5000) ですが、pause / resume で簡単に壊れます(pause 中もタイマーは進む)。

本記事では別のやり方を取ります — 「いつ無効になるか」 のタイムスタンプを effect オブジェクトに持たせて、毎 tick で『今もまだ有効か』 を判定する だけ。pause しても時間が進まなくなるだけで、effect 自体は壊れません。

type Effect = { type: SpecialKind | "blinded"; expiresAt: number };  // expiresAt = ミリ秒タイムスタンプ
const EFFECT_MS = 5000;

// 発動時
const newEffect: Effect = { type: def.special, expiresAt: Date.now() + EFFECT_MS };

// 効果が今アクティブか?
function hasEffect(ps: PlayerState, type: Effect["type"]): boolean {
  return ps.effects.some((e) => e.type === type);
}

// 期限切れ effect を除外(毎 tick で呼ぶ)
function purgeExpired(effects: Effect[], now: number): Effect[] {
  return effects.filter((e) => e.expiresAt > now);
}

reducer の tick ケースで毎回 purge することで、UI も自動的に「あと 3 秒」「あと 1 秒」と短くなり、5 秒経過で消えます。

case "tick": {
  const now = Date.now();
  const p1Purged = { ...state.p1, effects: purgeExpired(state.p1.effects, now) };
  const p2Purged = { ...state.p2, effects: purgeExpired(state.p2.effects, now) };
  // ... 以降は連載 第 4 回と同じ
}

lockAndSpawn を必殺技対応に拡張

第 4 回の 4 ステップ純粋関数(outgoing → cancellation → push up → spawn)に、RushShield の判定を 1 行ずつ挿入するだけで対応できます。

function lockAndSpawn(ps: PlayerState): { ps: PlayerState; garbageSent: number } {
  // ... merge + clearLines は同じ ...
  const linesCleared = ...;

  // 1) 攻撃量 — Rush 中なら ×2
  let garbageOut = GARBAGE_TABLE[Math.min(linesCleared, GARBAGE_TABLE.length - 1)] ?? 0;
  if (hasEffect(ps, "rush")) garbageOut *= 2;

  // 2) クリスタル獲得(ライン数分)
  const crystalsGained = linesCleared;

  // 3) Shield 中は incoming を完全無視
  let incoming = hasEffect(ps, "shield") ? 0 : ps.pendingGarbage;

  // 4) cancellation + push up + spawn は連載 第 4 回と同じ
  // ...

  return {
    ps: {
      ...ps,
      // ...
      crystals: Math.min(ps.crystals + crystalsGained, CRYSTAL_THRESHOLD),
    },
    garbageSent: remainingOutgoing,
  };
}

hasEffect 1 つで分岐が書けるのは、Effect を「種類 + 有効期限」 という data として持っているからです。

必殺技発動の reducer

special action を 3 ケースに分けて書きます。self-buff(shield, rush)は自分にdebuff(blind)は相手に effect を付与:

case "special": {
  const ps = state[action.player];
  if (ps.over || ps.crystals < CRYSTAL_THRESHOLD) return state;
  const def = CHARACTERS[ps.character];
  const now = Date.now();

  // shield: 自分に effect + pending クリア
  if (def.special === "shield") {
    return {
      ...state,
      [action.player]: {
        ...ps,
        crystals: 0,
        effects: [...purgeExpired(ps.effects, now), { type: "shield", expiresAt: now + EFFECT_MS }],
        pendingGarbage: 0,
      },
    };
  }
  // rush: 自分に effect
  if (def.special === "rush") {
    return {
      ...state,
      [action.player]: {
        ...ps,
        crystals: 0,
        effects: [...purgeExpired(ps.effects, now), { type: "rush", expiresAt: now + EFFECT_MS }],
      },
    };
  }
  // blind: 相手に "blinded" effect
  const other: PlayerId = action.player === "p1" ? "p2" : "p1";
  return {
    ...state,
    [action.player]: { ...ps, crystals: 0 },
    [other]: {
      ...state[other],
      effects: [...purgeExpired(state[other].effects, now), { type: "blinded", expiresAt: now + EFFECT_MS }],
    },
  };
}

UI — ゲージ + 必殺技ボタン + 視界妨害

3 つの可視化が必殺技を「楽しい」 に変えます:

クリスタルゲージバー(0/6 → 6/6)

const ready = state.crystals >= CRYSTAL_THRESHOLD;
<div className="h-3 rounded border border-line overflow-hidden">
  <div style={{
    width: `${(state.crystals / CRYSTAL_THRESHOLD) * 100}%`,
    background: ready ? def.badgeColor : "var(--color-accent)",
    transition: "width 200ms, background 200ms",
  }} />
</div>

満タンになると キャラ色に光る(ready 時に accent → def.badgeColor)。

必殺技ボタン

<button
  onClick={onSpecial}
  disabled={!ready}
  style={{
    background: ready ? def.badgeColor : "var(--color-paper)",
    color: ready ? "#fff" : "var(--color-ink-soft)",
  }}
>
  {def.emoji} {def.specialName}
</button>

disabled で押せないことを視覚的に伝え、ready で発色 + ホバー可。

視界妨害(blinded 時に grayscale + blur)

CSS フィルタ 1 行で表現できます:

<div style={{
  filter: blinded ? "grayscale(1) blur(0.5px)" : undefined,
  transition: "filter 200ms",
}}>
  {/* board grid */}
</div>

「相手の盤面を 5 秒間グレースケール」が、効果データ + CSS フィルタの 2 つだけで実装できる。

次に手を出せる方向

連載のゴールは到達しました。ここから広げるなら:

  • キャラ追加 — CHARACTERS テーブルに 1 行 + reducer の special に 1 分岐
  • コンボ攻撃量補正 — 連続消し(combo)カウンタを state に持たせて攻撃量に乗せる
  • T-spin 検出 — T ピースを回転だけで埋めた手を検出して攻撃量を増やす(現代テトリスでよく使われるテクニック)
  • オンライン対戦 — state を WebSocket で同期、p1p2 の片方をリモートに
  • AI vs AI 観戦モード — 第 4 回の AI 自動操作モードを両 player に適用、必殺技も自動発動

中心にある考え方は変わらず、「state は純粋関数で更新、効果は data として表現、tick で時間を進める」 のままで足せます。


ここまで読んだあなたへ

最後に、ブラウザだけでテトリスの土台を立ち上げられる LLM 向けプロンプト(Gemini Canvas / Claude Artifact 用)を置きます。このプロンプトをきっかけに、自分なりのアイデアを足して形にしてみてください!

Gemini の Canvas モード、または Claude.ai の Artifact 機能に貼り付けると、1 ファイルの HTML として動くものが返ってきます。プロンプトだけで動くものは作れます。本連載は「なぜそう書くか」を伝える方です。

Gemini Canvas 用プロンプト

gemini.google.com で「Canvas で作成」 を選択 → 下のボタンでコピーして貼り付け。モデルは Pro 推奨(Flash だと指示量に追いつかず、攻撃テーブルや必殺技ロジックが抜け落ちることがあります)。

GEMINI CANVAS 用
ブラウザで動く 2P 対戦テトリスを 1 ファイルの HTML として作って。
ダブルクリックで開いて即遊べる状態で出力。

【ゲーム概要】
- 1 画面に 2 盤面(10列×20行)を横並びで表示
- 1 つのキーボードを 2 人で共有して同時プレイ
- 先に top out した方が負け、両方落ちた場合は lines が多い方の勝ち
- 自動落下 700ms 間隔

【操作】
- Player 1: A/D 移動, W or Q 回転, S 落下, E ハードドロップ, F 必殺技
- Player 2: 矢印キー移動, ↑ or N 回転, ↓ 落下, M ハードドロップ, L 必殺技
- 共通: P 一時停止, R リセット

【ピース】
- 7 種類: I, O, T, S, Z, L, J(各 4 回転を 4×4 行列で持つ)
- 色: I=#22d3ee, O=#fde047, T=#a78bfa, S=#86efac, Z=#fca5a5, L=#fdba74, J=#93c5fd

【攻撃メカニクス(おじゃまライン)】
- 1 ライン消し → 0 行送信、2 ライン → 1、3 ライン → 2、4 ライン(テトリス)→ 4
- 自分が送る攻撃は自分が受ける予定の攻撃を相殺(双方マイナス)
- 相殺後の受信は灰色 garbage 行として下から push up(穴 1 マスはランダム位置)
- 相殺後の送信は相手の受信キューに追加

【クリスタル + 必殺技】
- ライン消し時にクリスタル +1 / line(上限 6)
- 6 個溜まったら必殺技発動可(発動でクリスタル 0 にリセット)
- 3 キャラ、各 1 つの必殺技:
- 🐻 クマ「シールド」: 5 秒間 受信 garbage を 0 化 + 現在の受信キューもクリア
- 🐰 ウサギ「速攻」: 5 秒間 自分の送信攻撃量 ×2
- 🦉 フクロウ「視界妨害」: 相手の盤面を 5 秒間 grayscale + blur
- ゲーム開始前に各 Player のキャラを HTML の select で選択可

【UI】
- 各 player 盤面の下に SCORE / LINES / SENT(累計送信、赤色)
- クリスタルゲージバー(0/6 → 6/6 で発動可、キャラ色で光る)
- 必殺技ボタン(disabled 時は薄く、active 時はキャラ色)
- 受信キュー > 0 で「⚠ +N」赤バッジ
- 発動中エフェクトに残り秒数バッジ
- 視界妨害を受けている side に CSS filter: grayscale(1) blur(0.5px)
- 勝敗確定で「PLAYER X WINS!」中央表示

【コード制約】
- 単一の .html ファイル、CDN だけで動く(React 使うなら CDN から、vanilla JS でも OK)
- セルは CSS Grid(Canvas 不使用)
- ダークモード対応(背景 #020617, 文字 #f1f5f9)
- フォントは monospace 系
- 純粋関数(衝突判定 / merge / clearLines / pushGarbageUp)を分離して読みやすく
- 簡易 wall-kick(回転時に左右 ±1, ±2 マスずらして衝突回避)あり

【実装・命名の厳密なルール(エラー防止)】
- プレイヤーの内部データ(JS 側)と HTML 要素の id(p1-score / p2-board 等)の連携で、インデックスのズレによる getElementById の null エラーを絶対に起こさないこと
- 対策のいずれかを採用:(a) JS のプレイヤーオブジェクトの id プロパティを最初から "p1" "p2" の文字列にする、(b) 配列インデックス(0, 1)→ HTML id(p1, p2)の変換を 1 関数 playerKey(idx) → "p" + (idx + 1) に集約する
- DOM 要素は初期化時に一度だけ取得して els.p1.score のような構造に保持。毎フレーム getElementById しない
- 矢印キーで盤面を操作する時は event.preventDefault() を呼んで、ページのスクロールが起きないようにすること
- タッチ操作のボタンには touch-action: manipulation を付け、300ms の遅延と double-tap zoom を抑止
- ピース生成は 7-bag 法を推奨(7 種類を 1 巡シャッフルして配り終わったら次の bag を作る)。Math.random を直接使うと同じピースが連続して詰まる
- 衝突判定はピースの実セル位置だけ調べ、ピースのバウンディングボックス全体を見ない(I ピースで誤判定する)
- ライン消去はループ中に splice せず、filter で「消えない行を残す」 → 「不足分を空行で頭に足す」の 2 ステップにする
- garbage の push up で盤面サイズが 20 行を超えないこと(上から N 行を捨ててから下に N 行追加)
- React を使う場合、useEffect の依存配列に dispatch を直接入れない(ref 経由で参照)。setInterval が再生成されてゲーム速度が崩れる
- 必殺技の「5 秒持続」は setTimeout ではなく、effect オブジェクトに expiresAt: Date.now() + 5000 を持たせて毎 tick で expiresAt > Date.now() で判定する。pause / resume で破綻しない
- 表示の DOM 更新は requestAnimationFrame ではなく state 更新後の再 render で十分(自動落下 700ms なので 60fps は不要)

完成形を 1 ファイルで吐いて。

Claude.ai Artifact 用プロンプト

claude.ai の入力欄に下のボタンでコピーして貼り付ければ、Artifact として右ペインに完成形 HTML が表示され、その場で動かせます。Gemini 用に冒頭 1 行を加えただけなので、内容は同じ。

CLAUDE.AI 用
HTML Artifact として、以下の仕様で 1 ファイルの対戦テトリスを作って。完成したら artifact 内で即座に動かせる形で出力して。

ブラウザで動く 2P 対戦テトリスを 1 ファイルの HTML として作って。
ダブルクリックで開いて即遊べる状態で出力。

【ゲーム概要】
- 1 画面に 2 盤面(10列×20行)を横並びで表示
- 1 つのキーボードを 2 人で共有して同時プレイ
- 先に top out した方が負け、両方落ちた場合は lines が多い方の勝ち
- 自動落下 700ms 間隔

【操作】
- Player 1: A/D 移動, W or Q 回転, S 落下, E ハードドロップ, F 必殺技
- Player 2: 矢印キー移動, ↑ or N 回転, ↓ 落下, M ハードドロップ, L 必殺技
- 共通: P 一時停止, R リセット

【ピース】
- 7 種類: I, O, T, S, Z, L, J(各 4 回転を 4×4 行列で持つ)
- 色: I=#22d3ee, O=#fde047, T=#a78bfa, S=#86efac, Z=#fca5a5, L=#fdba74, J=#93c5fd

【攻撃メカニクス(おじゃまライン)】
- 1 ライン消し → 0 行送信、2 ライン → 1、3 ライン → 2、4 ライン(テトリス)→ 4
- 自分が送る攻撃は自分が受ける予定の攻撃を相殺(双方マイナス)
- 相殺後の受信は灰色 garbage 行として下から push up(穴 1 マスはランダム位置)
- 相殺後の送信は相手の受信キューに追加

【クリスタル + 必殺技】
- ライン消し時にクリスタル +1 / line(上限 6)
- 6 個溜まったら必殺技発動可(発動でクリスタル 0 にリセット)
- 3 キャラ、各 1 つの必殺技:
- 🐻 クマ「シールド」: 5 秒間 受信 garbage を 0 化 + 現在の受信キューもクリア
- 🐰 ウサギ「速攻」: 5 秒間 自分の送信攻撃量 ×2
- 🦉 フクロウ「視界妨害」: 相手の盤面を 5 秒間 grayscale + blur
- ゲーム開始前に各 Player のキャラを HTML の select で選択可

【UI】
- 各 player 盤面の下に SCORE / LINES / SENT(累計送信、赤色)
- クリスタルゲージバー(0/6 → 6/6 で発動可、キャラ色で光る)
- 必殺技ボタン(disabled 時は薄く、active 時はキャラ色)
- 受信キュー > 0 で「⚠ +N」赤バッジ
- 発動中エフェクトに残り秒数バッジ
- 視界妨害を受けている side に CSS filter: grayscale(1) blur(0.5px)
- 勝敗確定で「PLAYER X WINS!」中央表示

【コード制約】
- 単一の .html ファイル、CDN だけで動く(React 使うなら CDN から、vanilla JS でも OK)
- セルは CSS Grid(Canvas 不使用)
- ダークモード対応(背景 #020617, 文字 #f1f5f9)
- フォントは monospace 系
- 純粋関数(衝突判定 / merge / clearLines / pushGarbageUp)を分離して読みやすく
- 簡易 wall-kick(回転時に左右 ±1, ±2 マスずらして衝突回避)あり

【実装・命名の厳密なルール(エラー防止)】
- プレイヤーの内部データ(JS 側)と HTML 要素の id(p1-score / p2-board 等)の連携で、インデックスのズレによる getElementById の null エラーを絶対に起こさないこと
- 対策のいずれかを採用:(a) JS のプレイヤーオブジェクトの id プロパティを最初から "p1" "p2" の文字列にする、(b) 配列インデックス(0, 1)→ HTML id(p1, p2)の変換を 1 関数 playerKey(idx) → "p" + (idx + 1) に集約する
- DOM 要素は初期化時に一度だけ取得して els.p1.score のような構造に保持。毎フレーム getElementById しない
- 矢印キーで盤面を操作する時は event.preventDefault() を呼んで、ページのスクロールが起きないようにすること
- タッチ操作のボタンには touch-action: manipulation を付け、300ms の遅延と double-tap zoom を抑止
- ピース生成は 7-bag 法を推奨(7 種類を 1 巡シャッフルして配り終わったら次の bag を作る)。Math.random を直接使うと同じピースが連続して詰まる
- 衝突判定はピースの実セル位置だけ調べ、ピースのバウンディングボックス全体を見ない(I ピースで誤判定する)
- ライン消去はループ中に splice せず、filter で「消えない行を残す」 → 「不足分を空行で頭に足す」の 2 ステップにする
- garbage の push up で盤面サイズが 20 行を超えないこと(上から N 行を捨ててから下に N 行追加)
- React を使う場合、useEffect の依存配列に dispatch を直接入れない(ref 経由で参照)。setInterval が再生成されてゲーム速度が崩れる
- 必殺技の「5 秒持続」は setTimeout ではなく、effect オブジェクトに expiresAt: Date.now() + 5000 を持たせて毎 tick で expiresAt > Date.now() で判定する。pause / resume で破綻しない
- 表示の DOM 更新は requestAnimationFrame ではなく state 更新後の再 render で十分(自動落下 700ms なので 60fps は不要)

完成形を 1 ファイルで吐いて。

両ツールとも、生成後にプロンプトを微調整して再生成 できます。例:「T-spin 検出を入れて」「キャラを 5 体に増やして」「攻撃テーブルを [0, 1, 2, 4, 6] に変更して」 等で実験できます。


まとめ — 連載のゴール

連載で組み上げた中身を回ごとに整理:

  1. board / piece / 衝突判定 / ゲームループ(第 1 回)
  2. 評価関数 + 1 手読み AI(第 2 回)
  3. { p1, p2 } を 1 reducer で扱う + キーマップ振り分け(第 3 回)
  4. pendingGarbage + 攻撃テーブル + 相殺 + 押し上げ(第 4 回)
  5. キャラクター + クリスタル + 必殺技 + 時限効果(本記事)

中心にあるのは最初から最後まで「state は純粋関数で更新、副作用は data として表現、tick で時間を進める」 の 3 点だけです。回を重ねるごとに機能は増えますが、各回で書き足したのは 1〜2 個の純粋関数と state フィールドにすぎません。

おまけのプロンプトは「動く demo を手早く欲しい」 人向けの近道で、内部設計の判断は本連載で見てきた内容そのもの。

最終話まで読んでくれてありがとうございました。


関連 Topic — 体系的に学ぶための入口

tech-book.net /topics/react

React — おすすめ書籍 7 冊・関連用語 51 個・学習マップ

React とは、Meta(旧 Facebook)が 2013 年に公開した宣言的 UI 構築のための JavaScript ライブラリで、フロントエンド開発の事実上の標準である。

この Topic が役立つ理由 — 本連載で積み上げた『state を関数で更新、副作用は data として表現』 の設計は React の基礎の応用編。Topic ページの React 入門書を 1 冊やっておくと、新しいゲームでも同じ設計に落とし込める判断軸ができる。
学習マップを tech-book.net で見る
tech-book.net /topics/react-hooks

React Hooks — 定義・前提と次に学ぶ用語 32 個・学習マップ

関数コンポーネントから React の状態・ライフサイクル・コンテキストなどを利用するための API 群。`use` から始まる関数として提供され、コンポーネントのトップレベルでのみ呼び出せる。

この Topic が役立つ理由 — useReducer + useEffect + useRef + useMemo の組み合わせで『ゲームループ + 入力 + 自動 tick + AI』を共存させるパターンは、リアルタイム UI(チャット / 共同編集 / IoT ダッシュボード)に転用可能。
学習マップを tech-book.net で見る
tech-book.net /topics/javascript

JavaScript — おすすめ書籍 17 冊・関連用語 42 個・学習マップ

JavaScript とは、1995 年に Netscape で生まれたブラウザ向けスクリプト言語が起点で、現在は Web・サーバ・アプリ・組み込みまで幅広く使われる汎用言語である。

この Topic が役立つ理由 — effects 配列の不変更新 / `Math.min` での相殺 / `Date.now()` ベースの時限管理など、ロジックの中核は JavaScript の問題。基礎を厚くしたい人向け。
学習マップを tech-book.net で見る

関連書籍 — この記事の各節を補強する一冊

tech-book.net /books/9784873119380

Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス

Alex Banks/Eve Porcello/宮崎 空 · オライリー・ジャパン · 2021年 · ¥3,740

Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…

この本が役立つ理由 — 本記事の `Effect = { type, expiresAt }` のような「副作用を data として表現する」 パターンは、本書 7 章の「useReducer の中で副作用情報を返す」 議論を発展させたもの。連載 5 回分の設計判断をひとつの軸でまとめ読みするときの参考書として置いていました。
詳細を tech-book.net で見る
tech-book.net /books/9784873117881

Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門

Stoyan Stefanov/牧野 聡 · オライリー・ジャパン · 2017年 · ¥2,750

FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…

この本が役立つ理由 — useReducer + useEffect で時間 / 入力 / 描画の 3 副作用を分離する書き方が、薄い入門書 1 冊で身につく。連載で書いた reducer がこの形になる根拠まで遡れる。
詳細を tech-book.net で見る
tech-book.net /books/9784297129163

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

手島 拓也/吉田 健人/高林 佳稀 · 技術評論社 · 2022年

新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…

この本が役立つ理由 — 本記事の `Effect = { type: SpecialKind | 'blinded'; expiresAt: number }` のような discriminated union 設計は、本書の『型でドメインを表現する』章の典型例。新キャラ / 新必殺技を増やす時に効く。
詳細を tech-book.net で見る
tech-book.net /books/9784839966645

React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際

松澤 太郎 · マイナビ出版 · 2018年

「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…

この本が役立つ理由 — この対戦テトリスをスマホで遊べるようにしたいなら React Native + Expo。本記事の component を最小修正でモバイル対応できる(キーボード → 左右タッチ + ジェスチャに置き換え)。
詳細を tech-book.net で見る

連載前回:テトリス対戦に「おじゃまライン送信」を入れる

全連載の一覧は 連載インデックス から。