← Blog
TypeScriptGame DevelopmentReactState Management

TypeScriptで型安全なゲームループを設計する — Snake・Tetrisの状態管理

8 分で読める

このトピックを深掘りする

ブラウザゲーム開発

ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集

全7本中 6 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

ゲームの状態を型で表現する

Snakeの状態は「プレイ中」「ゲームオーバー」「一時停止」に分かれる。TypeScriptの判別可能なユニオン型で表現する:

type GameState =
  | { phase: 'playing'; snake: Point[]; food: Point; direction: Direction; score: number }
  | { phase: 'gameover'; finalScore: number }
  | { phase: 'paused'; snapshot: GameState & { phase: 'playing' } }

type Direction = 'up' | 'down' | 'left' | 'right'
type Point = { x: number; y: number }

phase がディスクリミネーターとして機能し、state.phase === 'playing' の分岐内では state.snake に安全にアクセスできる。

useRef vs useState の使い分け

ゲームループでは再レンダリングを引き起こさないデータuseRef で管理する:

// useRef: 60fpsで更新されるが描画トリガーにならないもの
const gameStateRef = useRef<GameState>(initialState)
const inputQueueRef = useRef<Direction[]>([])
const lastTickRef = useRef(0)

// useState: UIに反映されるもの
const [score, setScore] = useState(0)
const [phase, setPhase] = useState<'playing' | 'gameover' | 'paused'>('playing')

60fpsのゲームループ内で setState を呼ぶと毎フレーム再レンダリングが走る。ゲームロジックは useRef で更新し、スコア変動やフェーズ遷移のタイミングだけ useState に反映する。

入力のバッファリング

キー入力をそのまま反映すると、1フレーム内に「上→右」と素早く押した場合に最初の入力が消える:

function handleKeyDown(e: KeyboardEvent) {
  const dirMap: Record<string, Direction> = {
    ArrowUp: 'up',
    ArrowDown: 'down',
    ArrowLeft: 'left',
    ArrowRight: 'right',
  }

  const dir = dirMap[e.key]
  if (!dir) return

  const queue = inputQueueRef.current
  const last = queue.length > 0 ? queue[queue.length - 1] : currentDirection

  // 逆方向入力を無視(自分に衝突するため)
  const opposites: Record<Direction, Direction> = {
    up: 'down', down: 'up', left: 'right', right: 'left',
  }
  if (opposites[dir] === last) return

  queue.push(dir)
}

ゲームティックのたびにキューの先頭を消費する。これで素早い入力も取りこぼさない。

固定タイムステップのゲームティック

Snakeは10fps(100ms間隔)、Tetrisは難易度に応じて可変:

const TICK_INTERVAL = 100 // ms

function gameLoop(timestamp: number) {
  if (gameStateRef.current.phase !== 'playing') return

  if (timestamp - lastTickRef.current >= TICK_INTERVAL) {
    lastTickRef.current = timestamp

    // 入力キューから方向を取得
    const dir = inputQueueRef.current.shift() ?? currentDirection

    // ゲーム状態を更新
    const nextState = tick(gameStateRef.current, dir)
    gameStateRef.current = nextState

    // UIに反映が必要な変更だけsetState
    if (nextState.phase === 'playing') {
      setScore(nextState.score)
    } else if (nextState.phase === 'gameover') {
      setPhase('gameover')
    }

    // Canvas描画
    render(nextState)
  }

  rafRef.current = requestAnimationFrame(gameLoop)
}

requestAnimationFrame は60fpsで呼ばれるが、ゲームティックは100msに1回だけ。描画と論理の周期を分離することで、滑らかなアニメーション(フェードなど)とゲームスピードを独立に制御できる。

Tetrisのピース型定義

テトリミノの形状を2D配列で定義する:

const TETROMINOS = {
  I: { shape: [[1, 1, 1, 1]], color: '#00f0f0' },
  O: { shape: [[1, 1], [1, 1]], color: '#f0f000' },
  T: { shape: [[0, 1, 0], [1, 1, 1]], color: '#a000f0' },
  S: { shape: [[0, 1, 1], [1, 1, 0]], color: '#00f000' },
  Z: { shape: [[1, 1, 0], [0, 1, 1]], color: '#f00000' },
  J: { shape: [[1, 0, 0], [1, 1, 1]], color: '#0000f0' },
  L: { shape: [[0, 0, 1], [1, 1, 1]], color: '#f0a000' },
} as const satisfies Record<string, { shape: number[][]; color: string }>

as const satisfies でリテラル型を保持しつつ型チェックをかける。TypeScript 4.9以降のパターン。

回転処理

ピースの90度回転は行列の転置+行反転:

function rotate(shape: number[][]): number[][] {
  const rows = shape.length
  const cols = shape[0].length
  const rotated: number[][] = Array.from({ length: cols }, () =>
    new Array(rows).fill(0)
  )

  for (let y = 0; y < rows; y++) {
    for (let x = 0; x < cols; x++) {
      rotated[x][rows - 1 - y] = shape[y][x]
    }
  }

  return rotated
}

壁際での回転にはSRS(Super Rotation System)のキック判定が必要だが、ここでは簡易版として衝突する場合は回転をキャンセルする方式を採用。

Breakoutの物理統合

BreakoutではCanvas API上でusePhysics hookを使わず、よりシンプルな独自の物理ループを使う。ボール1個 + パドル1個なので、汎用物理エンジンはオーバースペック:

function updateBall(ball: Ball, paddle: Paddle, bricks: Brick[], dt: number) {
  ball.x += ball.vx * dt
  ball.y += ball.vy * dt

  // 壁反射
  if (ball.x - ball.r < 0 || ball.x + ball.r > width) ball.vx *= -1

  // パドル衝突
  if (intersectsRect(ball, paddle)) {
    ball.vy = -Math.abs(ball.vy)
    // パドル上の当たり位置で反射角を変える
    const offset = (ball.x - paddle.x) / (paddle.w / 2)
    ball.vx = offset * 5
  }
}

「道具を選ぶ」のも設計判断。 汎用物理エンジンが常に正解ではない。

まとめ:TypeScriptゲームループで使った技術と道具

TypeScriptの型システムをゲーム開発に活用する方法は、道具棚の書籍が体系的にカバーしている。型安全な設計はバグの早期発見に直結する。