0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tetris をブラウザで実装 — SRS 回転と wall kick、7-bag ランダマイザ、純粋ロジック分離

0
Posted at

Tetris は「落ち物パズル」と聞くと素朴に見えるが、ちゃんと作ろうとすると SRS (Super Rotation System) の wall kick テーブル7-bag randomizer という公式準拠のルールが立ちはだかる。600 行 vanilla JS Canvas で実装した本ツールはどちらも実装し、ボードロジックを描画から分離して 23 件のユニットテストで保証している。

🌐 Demo: https://sen.ltd/portfolio/tetris/
📦 GitHub: https://github.com/sen-ltd/tetris

Screenshot

ボードロジックを Canvas から分離する

最初に決めたのは board.js には DOM も Canvas も登場させない」

pieces.js   ← 7 テトロミノの形状、SRS wall-kick テーブル、7-bag randomizer
board.js    ← ボードロジック(fits / move / rotate / hardDrop / clearLines / scoring)
render.js   ← Canvas レンダラ(pieces.js と board.js だけに依存)
app.js      ← UI グルー(入力 DAS、ゲームループ、HUD)

依存方向は app.js → render.js → board.js + pieces.js の一方向のみ。board.jspieces.js は副作用ゼロなので Node の組み込みテストランナーで完全に検証できる:

test("clearLines clears 4 rows for a tetris", () => {
  const b = emptyBoard();
  for (let y = TOTAL_ROWS - 4; y < TOTAL_ROWS; y++) {
    for (let x = 0; x < COLS; x++) b[y][x] = 1;
  }
  assert.equal(clearLines(b), 4);
});

requestAnimationFrame をモックする必要も、Canvas を JSDOM で立てる必要もない。

7-bag randomizer — Tetris Guideline の根幹

ナイーブな実装は Math.random() * 7 でピースを選ぶこと。これだと 同じピースが 5 連続で出る ことが平気で起きて、初心者は理不尽さに辞める。

Tetris Guideline 公式ルールは「7 個のテトロミノを 1 セットとして、各セット内で 7 種類が全部 1 回ずつ出る (= 順番だけランダム)」:

export function createBag(rng) {
  let bag = [];
  function refill() {
    bag = [...PIECE_NAMES];  // ["T", "J", "L", "S", "Z", "I", "O"]
    // Fisher–Yates シャッフル
    for (let i = bag.length - 1; i > 0; i--) {
      const j = Math.floor(rng() * (i + 1));
      [bag[i], bag[j]] = [bag[j], bag[i]];
    }
  }
  return {
    next() {
      if (bag.length === 0) refill();
      return bag.shift();
    },
  };
}
  • 長すぎる飢餓 (I ピースが 30 個出ない、等) が起こらない
  • 2 個連続 までは可能(前 bag の最後 + 次 bag の最初)
  • 3 個連続は不可能 — これがプレイ感覚を予測可能にする

テストで両方の不変条件を守る:

test("yields all 7 pieces before any repeat", () => {
  const bag = createBag(() => 0.5);
  const first7 = [];
  for (let i = 0; i < 7; i++) first7.push(bag.next());
  assert.deepEqual(first7.sort(), [...PIECE_NAMES].sort());
});

SRS rotation と wall kick

「右回転 = 形を 90°回転」と素朴に書くと、壁際で詰む。実機の Tetris は 壁に当たった時に位置を少しずらして再判定する という挙動で、これが SRS。

各ピースには「回転 0→1 → 2 → 3」の遷移ごとに kick offset が 5 通り定義されていて、順に試して最初に当たらなかったものを採用する:

// 簡略化した SRS (JLSTZ 用)
const KICKS_JLSTZ = {
  "0->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
  "1->0": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
  "1->2": [[0,0], [+1,0], [+1,-1], [0,+2], [+1,+2]],
  "2->1": [[0,0], [-1,0], [-1,+1], [0,-2], [-1,-2]],
  // ...
};

export function rotate(board, state, dir) {
  const toRot = (state.rot + dir + 4) % 4;
  const kicks = getKicks(state.piece, state.rot, toRot);
  for (const [kx, ky] of kicks) {
    const next = { ...state, rot: toRot, x: state.x + kx, y: state.y + ky };
    if (fits(board, next)) return next;  // 最初に通った kick で確定
  }
  return null;  // 全滅 = 回転不能
}

ポイント:

  1. [0,0] が必ず先頭 — 普通に回転できるなら何もしない
  2. I-piece と O-piece は別テーブル — I は 4×4 軸で挙動が違う、O はそもそも回転不要
  3. CW/CCW で順序が違う — 単純な反転ではなく独自テーブル

これを実装すると、L 字の窪みに T-spin が刺さるなど 「上級者が達成する技」が物理的に成立する

「フレーム独立な」 gravity

ナイーブ実装は「rAF 毎に 1 行下に落とす」 だが、それだと モニタの fps によって難易度が変わる (120Hz だと地獄)。

ms ベースで蓄積する:

function tick(ms) {
  game.dropTimer += ms;
  const interval = gravityMs(game.level);  // level 0 = 800ms, level 19+ = 30ms
  while (game.dropTimer >= interval) {
    game.dropTimer -= interval;
    const next = move(game.board, game.current, 0, 1);
    if (next === null) {
      lockAndSpawn();
      break;
    }
    game.current = next;
  }
}

while ループなのは、フレーム落ちで msinterval * 2 を超えたときに 複数行まとめて落とす ため。最後に Math.min(now - lastFrame, 100) で 100ms 上限のクランプを入れて、タブ復帰時の暴走を防いでいる。

gravityMs(level) は NES 風のテーブル:

export function gravityMs(level) {
  const table = [
    800, 720, 630, 550, 470, 380, 300, 220, 130, 100,
    80, 80, 80, 70, 70, 70, 50, 50, 50, 30,
  ];
  if (level >= table.length) return 30;
  return table[level];
}

DAS (Delayed Auto-Shift) — キーリピートを自前で管理

ブラウザの keydown は OS のキーリピート設定で出るので、Tetris プレイヤーが期待する 60Hz リピートにはならない。DAS = 「最初のキー押下から N ms 待って、その後 M ms 間隔で繰り返す」 を JS で実装する:

const DAS_DELAY = 170;     // 初回リピートまで
const DAS_INTERVAL = 50;   // 連続リピート間隔

function onKeyDown(e) {
  if (keysHeld[e.key]) return;  // OS リピートは無視
  keysHeld[e.key] = true;
  applyAction(action);
  if (action === "left" || action === "right" || action === "down") {
    dasTimers[e.key] = setTimeout(() => startRepeat(e.key, action), DAS_DELAY);
  }
}

function startRepeat(key, action) {
  dasTimers[key] = setInterval(() => {
    if (!keysHeld[key]) { clearInterval(dasTimers[key]); return; }
    applyAction(action);
  }, DAS_INTERVAL);
}

これで「左を押しっぱなしで壁まで滑らせる」「左を 1 回だけ叩いて 1 マス動く」 が両立する。Hard drop (Space) と回転 (Z/X) には DAS を適用しない — 連射されると意図しない動きが暴発する。

NES 風スコアリング

export function lineScore(lines, level) {
  const base = { 0: 0, 1: 40, 2: 100, 3: 300, 4: 1200 }[lines] ?? 0;
  return base * (level + 1);
}

4 lines (Tetris) = 1200 × (level + 1) というのが本家のテーブル。単純な x4 ではなく 4 段消し (Tetris) が単発の 30 倍 という非線形性が、わざと隙間を空けて待つプレイ戦略を作り出す。

ハードドロップは +2 per cell、ソフトドロップは +1 per cell も加算してプレイヤーの「能動的な決断」に小さく報酬する。

アーキテクチャの利点 — テスト容易性

23 件のユニットテストの内訳:

カテゴリ テスト
ボード形状 empty board の検証
spawn / fits 壁衝突、占有セル衝突
move 通常移動、壁での停止
rotate (SRS) T の 4 段階回転、O の no-op、壁際の kick
hardDrop / lock 床着地、永続化
clearLines 1/4 行消し、不完全行非消去
scoring 4-line ボーナス、level scaling
gravity level 別速度、上限
7-bag 重複なし、複数 bag 連続
helper 3×3 回転、SHAPES テーブル整合性

これらが全て pure 関数のテストなので、Canvas を立てずに npm test の 70ms 内で完全検証される。回転テーブル変更や SRS の追加バリアント (T-spin 判定、IRS 等) を入れるときも、テストを足してから実装すれば回帰しない。

まとめ

  • ボードロジックを DOM/Canvas から分離 すれば Node テストでルール全部を保証できる
  • 7-bag randomizer がプレイ感覚の根幹 — 飢餓と過剰を両方排除
  • SRS rotation は kick offset 5 通りを順に試すアルゴリズム。壁際の挙動が自然になる
  • ms ベース gravity + フレーム落ち対策 で fps 非依存
  • DAS を自前で書くと「押しっぱなしで滑らせる」感覚が手に入る
  • スコアリングが Tetris の戦略を作る — 4-line ボーナスの非線形性が「敢えて待つ」プレイを誘発する

リポジトリ: https://github.com/sen-ltd/tetris

このツールは弊社の OSS ポートフォリオ #243 として作成しました。SEN 合同会社(東京)では小さくて切れ味のあるツール群を継続的に公開しています: https://sen.ltd/portfolio/

0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?