1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ぷよぷよから始めるテスト駆動開発入門

Posted at

ぷよぷよから始めるテスト駆動開発入門

はじめに

みなさん、こんにちは!今日は私と一緒にテスト駆動開発(TDD)を使って、ぷよぷよゲームを作っていきましょう。さて、プログラミングの旅に出る前に、皆さんは「テスト駆動開発」について聞いたことがありますか?もしかしたら「テストって、コードを書いた後にするものじゃないの?」と思われるかもしれませんね。

テストを書きながら開発することによって、設計が良い方向に変わり、コードが改善され続け、それによって自分自身が開発に前向きになること、それがテスト駆動開発の目指すゴールです。

— Kent Beck 『テスト駆動開発』 付録C 訳者解説:テスト駆動開発の現在

この記事では、私たちが一緒にぷよぷよゲームを実装しながら、テスト駆動開発の基本的な流れと考え方を学んでいきます。まるでモブプログラミングのセッションのように、あなたと私が一緒に考え、コードを書き、改善していく過程を体験しましょう。「でも、ぷよぷよって結構複雑なゲームじゃないの?」と思われるかもしれませんが、心配いりません。各章では、ユーザーストーリーに基づいた機能を、テスト、実装、解説の順に少しずつ進めていきますよ。一歩一歩、着実に進んでいきましょう!

テスト駆動開発のサイクル

さて、テスト駆動開発では、どのように進めていけばいいのでしょうか?「テストを書いてから実装する」というのは分かりましたが、具体的にはどんな手順で進めるのでしょうか?

私がいつも実践しているのは、以下の3つのステップを繰り返すサイクルです。皆さんも一緒にやってみましょう:

  1. Red(赤): まず失敗するテストを書きます。「え?わざと失敗するテストを?」と思われるかもしれませんが、これには重要な意味があるんです。これから実装する機能が何をすべきかを明確にするためなんですよ。
  2. Green(緑): 次に、テストが通るように、最小限のコードを実装します。この段階では、きれいなコードよりも「とにかく動くこと」を優先します。「最小限」というのがポイントです。必要以上のことはしないようにしましょう。
  3. Refactor(リファクタリング): 最後に、コードの品質を改善します。テストが通ることを確認しながら、重複を取り除いたり、わかりやすい名前をつけたりします。「動くけど汚いコード」から「動いてきれいなコード」へと進化させるんです。

レッド・グリーン・リファクタリング。それがTDDのマントラだ。

— Kent Beck 『テスト駆動開発』

このサイクルを「Red-Green-Refactor」サイクルと呼びます。「赤・緑・リファクタリング」のリズムを刻むように、このサイクルを繰り返していくんです。これによって、少しずつ機能を追加し、コードの品質を高めていきましょう。皆さんも一緒にこのリズムを体感してみてください!

開発環境

さて、実際にコードを書く前に、私たちが使用する開発環境について少しお話ししておきましょう。皆さんは「道具選びは仕事の半分」という言葉を聞いたことがありますか?プログラミングでも同じことが言えるんです。

道具はあなたの能力を増幅します。道具のできが優れており、簡単に使いこなせるようになっていれば、より生産的になれるのです。

— 達人プログラマー 熟達に向けたあなたの旅(第2版)

「どんなツールを使えばいいの?」と思われるかもしれませんね。今回のプロジェクトでは、以下のツールを使用していきます:

  • 言語: TypeScript — 「JavaScriptだけじゃダメなの?」と思われるかもしれませんが、TypeScriptは型を追加することで、大規模な開発でもバグを減らしやすくなるんです。
  • ビルドツール: Vite — 開発中のコード変更をリアルタイムで反映してくれる高速な開発サーバーです。「待ち時間が少ないと開発が楽しくなりますよね!」
  • テストフレームワーク: Vitest — Viteと統合された高速なテストランナーです。テスト駆動開発には欠かせないツールですね。
  • タスクランナー: Gulp — 「同じ作業の繰り返しって退屈じゃないですか?」そんな反復的なタスクを自動化してくれます。
  • バージョン管理: Git — コードの変更履歴を追跡し、「あれ?昨日までちゃんと動いてたのに...」というときに過去の状態に戻れる魔法のツールです。

これらのツールを使って、テスト駆動開発の流れに沿ってぷよぷよゲームを実装していきましょう。「環境構築って難しそう...」と心配される方もいるかもしれませんが手順に従って進めればそんなに難しいことではありません。詳細はイテレーション0: 環境の構築で解説します。

要件

ユーザーストーリー

さて、実際にコードを書き始める前に、少し立ち止まって考えてみましょう。「何を作るのか?」という基本的な問いかけです。私たちが作るぷよぷよゲームは、どのような機能を持つべきでしょうか?

アジャイル開発では、この「何を作るのか?」という問いに対して、「ユーザーストーリー」という形で答えを出します。皆さんは「ユーザーストーリー」という言葉を聞いたことがありますか?

ユーザーストーリーは、ソフトウェア要求を表現するための軽量な手法である。ユーザーストーリーは、システムについてユーザーまたは顧客の視点からフィーチャの概要を記述したものだ。
ユーザーストーリーには形式が定められておらず、標準的な記法もない。とはいえ、次のような形式でストーリーを考えてみると便利である。「<ユーザーの種類>として、<機能や性能>がほしい。それは<ビジネス価値>のためだ」という形のテンプレートに従うと、
たとえば次のようなストーリーを書ける。「本の購入者として、ISBNで本を検索したい。それは探している本をすばやく見つけるためだ」

— Mike Cohn 『アジャイルな見積と計画づくり』

つまり、「プレイヤーとして、〇〇ができる(〇〇したいから)」という形式で機能を表現するんです。これによって、「誰のため」の「どんな機能」を「なぜ」作るのかが明確になります。素晴らしいですよね!

では、私たちのぷよぷよゲームでは、どんなユーザーストーリーが考えられるでしょうか?一緒に考えてみましょう:

  • プレイヤーとして、新しいゲームを開始できる(ゲームの基本機能として必要ですよね!)
  • プレイヤーとして、落ちてくるぷよを左右に移動できる(ぷよを適切な位置に配置したいですよね)
  • プレイヤーとして、落ちてくるぷよを回転できる(戦略的にぷよを配置するために必要です)
  • プレイヤーとして、ぷよを素早く落下させることができる(「早く次のぷよを落としたい!」というときのために)
  • プレイヤーとして、同じ色のぷよを4つ以上つなげると消去できる(これがぷよぷよの醍醐味ですよね!)
  • プレイヤーとして、連鎖反応を起こしてより高いスコアを獲得できる(「れ〜んさ〜ん!」と叫びたくなりますよね)
  • プレイヤーとして、全消し(ぜんけし)ボーナスを獲得できる(「やった!全部消えた!」という達成感を味わいたいですよね)
  • プレイヤーとして、ゲームオーバーになるとゲーム終了の演出を見ることができる(終わりが明確でないとモヤモヤしますよね)
  • プレイヤーとして、現在のスコアを確認できる(「今どれくらい点数取れてるかな?」と気になりますよね)
  • プレイヤーとして、キーボードでぷよを操作できる(PCでプレイするなら必須ですよね)
  • プレイヤーとして、タッチ操作でぷよを操作できる(スマホでもプレイしたいですよね)

「うわ、結構たくさんあるな...」と思われるかもしれませんが、心配いりません!これらのユーザーストーリーを一つずつ実装していくことで、徐々にゲームを完成させていきましょう。テスト駆動開発の素晴らしいところは、各ストーリーを小さなタスクに分解し、テスト→実装→リファクタリングのサイクルで少しずつ進められることなんです。一歩一歩、着実に進んでいきましょう!

ユースケース図

ユーザーストーリーを整理したところで、「これらの機能がどのように関連しているのか、全体像が見えるといいな」と思いませんか?そんなときに役立つのが「ユースケース図」です。
「ユースケース図って何?」と思われるかもしれませんね。ユースケース図は、システムと外部アクター(ここではプレイヤーとシステム自体)の相互作用を視覚的に表現するための図です。「絵に描いて整理すると分かりやすい」というやつですね。

ユースケースは、システムの振る舞いに関する利害関係者の契約を表現するものです。

— アリスター・コーバーン 『ユースケース実践ガイド』

「百聞は一見にしかず」というように、実際に見てみるのが一番分かりやすいですよね。では、私たちのぷよぷよゲームのユースケース図を見てみましょう:

この図を見ると、プレイヤーとシステムの役割分担がよくわかりますね。プレイヤーはゲームの開始や操作を担当し、システムはぷよの消去判定やスコア計算などの内部処理を担当しています。また、キーボード操作とタッチ操作は「拡張(extend)」関係にあり、ぷよの移動や回転などの基本操作を異なる入力方法で実現していることがわかります。

このようにユースケース図を作成することで、システムの全体像を把握し、実装すべき機能の関連性を明確にすることができます。それでは、実際のコード実装に進んでいきましょう!

誤解しないでもらいたいのですが本来ユースケースとはテキストで記述するものでありユースケース図は概要を把握するための手段に過ぎないということです。

楕円、矢印、人型おアイコンから構成されているUMLのユースケース図は、ユースケースを把握するための表記法ではありません。
楕円や矢印は、ユースケースをのパッケージや分解を表すもので、内容を表すものではありません。

— アリスター・コーバーン 『ユースケース実践ガイド』

リリース計画

要件もわかった、プログラミング開始だ!ちょっと待ってください、何事も計画を立てる事は大事なことです。ユースケース図を見てください、結構いろんなことがありますよね。何から取り組みますか?
「スコアの表示」ですか?「ゲームオーバー判定」ですか?でもまずは「新しいゲームを開始」しないとつながりとして難しいですよね。もちろん実際にプログラミングしながら順番を考えてもいいですけど間違った順番で進めると直すのが大変ですよね。
それにこれからどんなものを作るのかは事前にある程度イメージを固めておきたいものです(いきなり「ゲームオーバー」になるゲームはやりたくないですよね)。

計画づくりとは「なにをいつまでに作ればいいのか?」という質問に答える作業だと私は考えている

— Mike Cohn 『アジャイルな見積と計画づくり』

今回の目的はぷよぷよゲームを遊べるための最小限の機能の実装です。目的を実現するためにやるべきことをイテレーションという単位でまとめましょう。「全部やること洗い出すの?そんな先のことはわからないよ!」と思いますよね。安心してください今決めることは大まかな作業の流れと前後関係の整理だけです。
細かい部分は各イテレーションでおいおい明確になってきます。その手助けをしてくれるのがテスト駆動開発なのです。

正しい設計を、正しいタイミングで行う。動かしてから、正しくする。

— Kent Beck 『テスト駆動開発』

今回はユーザーストーリーとユースケース図から以下のイテレーション計画に従ってぷよぷよゲームをリリースします。

  • イテレーション1: ゲーム開始の実装
  • イテレーション2: ぷよの移動の実装
  • イテレーション3: ぷよの回転の実装
  • イテレーション4: ぷよの自由落下の実装
  • イテレーション5: ぷよの高速落下の実装
  • イテレーション6: ぷよの消去の実装
  • イテレーション7: 連鎖反応の実装
  • イテレーション8: 全消しボーナスの実装
  • イテレーション9: ゲームオーバーの実装

では、ぷよぷよゲーム開発スタートです!

イテレーション0: 環境の構築

...と言いたいところですがまずは環境の構築をしなければなりません。「プログラミングなんてどの言語でやるか決めるぐらいでしょ?」と思うかもしれませんが家を建てるときにしっかりとした基礎工事が必要なように開発環境もしっかりとした準備が必要です。
家を建てた後に基礎がダメだと困ったことになりますからね。

ソフトウェア開発の三種の神器

良いコードを書き続けるためには何が必要になるでしょうか?それはソフトウェア開発の三種の神器と呼ばれるものです。

今日のソフトウェア開発の世界において絶対になければならない3つの技術的な柱があります。
三本柱と言ったり、三種の神器と言ったりしていますが、それらは

  • バージョン管理
  • テスティング
  • 自動化

の3つです。

https://t-wada.hatenablog.jp/entry/clean-code-that-works

本章では開発環境のセットアップとして、これら三種の神器を準備していきます。環境構築は退屈に感じるかもしれませんが、これらのツールがあることで、安心してコードを書くことができるようになります。一緒に進めていきましょう!

バージョン管理: Gitとコミットメッセージ

バージョン管理システムとして Git を使います。Git については既に使用していると仮定しますが、コミットメッセージについて1つだけ重要なルールを確認しておきましょう。

コミットメッセージの書き方

私たちのプロジェクトでは、Conventional Commitsの書式に従ってコミットメッセージを書きます。具体的には、それぞれのコミットメッセージはヘッダ、ボディ、フッタで構成されます。

<タイプ>(<スコープ>): <タイトル>
<空行>
<ボディ>
<空行>
<フッタ>

ヘッダは必須で、スコープは任意です。コミットメッセージのタイトルは50文字までにしましょう(GitHub上で読みやすくなります)。

コミットのタイプは次を用います:

  • feat: 新しい機能
  • fix: バグ修正
  • docs: ドキュメント変更のみ
  • style: コードに影響を与えない変更(空白、フォーマットなど)
  • refactor: 機能追加でもバグ修正でもないコード変更
  • perf: パフォーマンスを改善するコード変更
  • test: テストの追加や修正
  • chore: ビルドプロセスや補助ツールの変更

例えば:

git commit -m 'feat: ゲーム初期化機能を追加'
git commit -m 'refactor: メソッドの抽出'
git commit -m 'test: ぷよ消去のテストケースを追加'

テスティング: パッケージマネージャとテスト環境

良いコードを書くためには、コードが正しく動作することを確認するテストが欠かせません。そのためのツールをセットアップしていきましょう。

パッケージマネージャ: npm

外部ライブラリやツールを管理するために npm(Node Package Manager)を使います。

npmとは、Node.jsで記述されたサードパーティ製のライブラリを管理するためのツールで、npmで扱うライブラリをパッケージと呼びます。

— Node.js公式ドキュメント

まず、package.json を作成してプロジェクトの依存関係を管理できるようにします:

npm init -y

これで package.json が作成されます。このファイルの "scripts" セクションを以下のように設定します:

{
  "name": "puyo-puyo-game",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "eslint . --ext .ts,.tsx --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "gulp": "gulp",
    "watch": "gulp watch",
    "guard": "gulp guard",
    "check": "gulp checkAndFix",
    "commit": "git add . && git commit",
    "setup": "npm install && npm run check"
  }
}

設定を追加したら、必要なパッケージをインストールします:

npm install

これで、テストフレームワークやその他の開発ツールがインストールされます。

自動化: コード品質の自動管理

良いコードを書き続けるためには、コードの品質を自動的にチェックし、維持していく仕組みが必要です。ここでは、静的コード解析、コードフォーマット、コードカバレッジ、そしてタスクランナーを設定します。

静的コード解析: ESLint

静的コード解析ツールとして ESLint を使います。ESLintは、コードを実行せずに潜在的な問題を検出するツールです。

npm run lint

このコマンドを実行すると、コードスタイルやベストプラクティスに違反している箇所が指摘されます。自動修正可能な問題は --fix オプションで修正できます:

npm run lint:fix

プロジェクトでは、循環的複雑度を7以下に保つ設定も追加しています。これにより、メソッドが複雑になりすぎることを防ぎます。

循環的複雑度(サイクロマティック複雑度)とは、ソフトウェア測定法の一つであり、コードがどれぐらい複雑であるかをメソッド単位で数値にして表す指標。

コードフォーマッタ: Prettier

コードのフォーマットを統一するために Prettier を使います。

優れたソースコードは「目に優しい」ものでなければいけない。

— リーダブルコード

フォーマットのチェックと自動修正は以下のコマンドで実行できます:

npm run format:check  # チェックのみ
npm run format        # 自動修正

コードカバレッジ: Vitest Coverage

テストがコードのどれだけをカバーしているかを確認するために、Vitest のカバレッジ機能を使います。

コード網羅率(コードもうらりつ、英: Code coverage)は、ソフトウェアテストで用いられる尺度の1つである。プログラムのソースコードがテストされた割合を意味する。

必要なパッケージをインストールします:

npm install --save-dev vitest @vitest/coverage-v8

カバレッジレポートを生成するには:

npm run test:coverage

実行後、coverage フォルダ内の index.html を開くと、視覚的にカバレッジ状況を確認できます。

タスクランナー: Gulp

複数のコマンドを覚えるのは大変です。タスクランナーの Gulp を使って、よく使うコマンドをタスクとして登録し、簡単に実行できるようにします。

GulpはJavaScript/TypeScriptにおけるタスクランナーです。gulpコマンドと起点となるgulpfile.jsというタスクを記述するファイルを用意することで、タスクの実行や登録されたタスクの一覧表示を行えます。

gulpfile.js を作成し、以下のように設定します:

import { watch, series } from 'gulp'
import shell from 'gulp-shell'

// テストタスク
export const test = shell.task(['npm run test'])

// テストカバレッジタスク
export const coverage = shell.task(['npm run test:coverage'])

// 静的コード解析タスク
export const lint = shell.task(['npm run lint'])

// 自動修正付き静的コード解析タスク
export const lintFix = shell.task(['npm run lint:fix'])

// フォーマットタスク
export const format = shell.task(['npm run format'])

// フォーマットチェックタスク
export const formatCheck = shell.task(['npm run format:check'])

// ビルドタスク
export const build = shell.task(['npm run build'])

// 開発サーバータスク
export const dev = shell.task(['npm run dev'])

// 全体チェックタスク(自動修正付き)
export const checkAndFix = series(lintFix, format, test)

// ファイル監視タスク(自動テスト実行)
export function guard() {
    console.log('🔍 Guard is watching for file changes...')
    console.log('Files will be automatically linted, formatted, and tested on change.')
    watch('src/**/*.ts', series(lintFix, format, test))
    watch('**/*.test.ts', series(test))
}

// ファイル監視タスク
export function watchFiles() {
    watch('src/**/*.ts', series(formatCheck, lint, test))
    watch('**/*.test.ts', series(test))
}

// デフォルトタスク
export default series(checkAndFix, guard)

// ウォッチタスクのエイリアス
export { watchFiles as watch }

登録されたタスクを確認するには:

npx gulp --tasks

特定のタスクを実行するには:

npx gulp test      # テスト実行
npx gulp lint      # 静的解析
npx gulp format    # フォーマット
npx gulp check     # 全体チェック(自動修正付き)

タスクの自動実行: Guard

ファイルを編集するたびに手動でコマンドを実行するのは面倒です。Guard 機能を使って、ファイルの変更を検知して自動的にテストやフォーマットを実行できるようにします。

npm run guard

このコマンドを実行すると、ファイルを保存するたびに自動的に以下が実行されます:

  1. ESLintによる静的解析(自動修正付き)
  2. Prettierによるフォーマット
  3. テストの実行

これにより、常にコードの品質を保ちながら開発を進めることができます。開発を始める際は、まず npm run guard を実行して、後はコードを書くことに集中しましょう!

環境構築の完了

お疲れさまでした!これで開発環境のセットアップが完了しました。以下のツールが使えるようになりました:

  • バージョン管理: Git(Conventional Commits形式)
  • テスティング: Vitest(カバレッジレポート付き)
  • 静的コード解析: ESLint(循環的複雑度チェック付き)
  • コードフォーマット: Prettier
  • タスクランナー: Gulp
  • 自動化: Guard(ファイル監視と自動実行)

これらのツールにより、ソフトウェア開発の三種の神器が揃いました。これから安心してテスト駆動開発に取り組むことができます!

環境構成

実際に構築した開発環境の構成は以下の通りです:

プロジェクト構造

app/typescript-3/
├── src/                    # ソースコード
│   └── main.ts            # エントリーポイント
├── tests/                 # テストコード
│   └── example.test.ts    # テストサンプル
├── package.json           # 依存関係管理
├── tsconfig.json          # TypeScript設定
├── tsconfig.node.json     # Node.js用TypeScript設定
├── vite.config.ts         # Vite設定
├── vitest.config.ts       # Vitest設定
├── gulpfile.js            # Gulpタスク定義
├── .eslintrc.cjs          # ESLint設定
├── .prettierrc            # Prettier設定
├── .gitignore             # Git除外設定
└── index.html             # HTMLエントリーポイント

package.json の主要な設定

{
  "name": "puyo-puyo-game",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "lint": "eslint . --ext .ts,.tsx",
    "lint:fix": "eslint . --ext .ts,.tsx --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "gulp": "gulp",
    "watch": "gulp watch",
    "guard": "gulp guard",
    "check": "gulp checkAndFix",
    "commit": "git add . && git commit",
    "setup": "npm install && npm run check"
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^7.0.0",
    "@typescript-eslint/parser": "^7.0.0",
    "@vitest/coverage-v8": "^1.3.1",
    "eslint": "^8.56.0",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-complexity": "^1.0.2",
    "gulp": "^5.0.0",
    "gulp-shell": "^0.8.0",
    "jsdom": "^27.0.0",
    "prettier": "^3.2.5",
    "typescript": "^5.3.3",
    "vite": "^5.1.0",
    "vitest": "^1.3.1"
  }
}

TypeScript設定(tsconfig.json)

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "types": ["vitest/globals"]
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

ESLint設定(.eslintrc.cjs)

module.exports = {
    root: true,
    env: { browser: true, es2020: true },
    extends: [
        'eslint:recommended',
        'plugin:@typescript-eslint/recommended',
        'prettier',
    ],
    ignorePatterns: ['dist', '.eslintrc.cjs'],
    parser: '@typescript-eslint/parser',
    plugins: ['complexity'],
    rules: {
        complexity: ['error', 7],
        '@typescript-eslint/no-explicit-any': 'warn',
    },
}

Prettier設定(.prettierrc)

{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "es5",
  "printWidth": 80,
  "tabWidth": 2
}

Vitest設定(vitest.config.ts)

import { defineConfig } from 'vitest/config'

export default defineConfig({
    test: {
        globals: true,
        environment: 'jsdom',
        coverage: {
            provider: 'v8',
            reporter: ['text', 'html', 'lcov'],
            exclude: [
                'node_modules/',
                'dist/',
                '**/*.test.ts',
                '**/*.spec.ts',
                'gulpfile.js',
                'vite.config.ts',
                'vitest.config.ts',
            ],
        },
    },
})

環境構築の確認

環境構築が正しく完了したことを確認するには、以下のコマンドを実行します:

cd app/typescript-3
npm run check

このコマンドは以下の処理を順次実行します:

  1. ESLint による静的解析(自動修正付き)
  2. Prettier によるコードフォーマット
  3. Vitest によるテスト実行

すべてのチェックが成功すれば、環境構築は完了です。

では、実際のゲーム開発に進みましょう!

イテレーション1: ゲーム開始の実装

さあ、いよいよコードを書き始めましょう!テスト駆動開発では、小さなイテレーション(反復)で機能を少しずつ追加していきます。最初のイテレーションでは、最も基本的な機能である「ゲームの開始」を実装します。

システム構築はどこから始めるべきだろうか。システム構築が終わったらこうなる、というストーリーを語るところからだ。

— Kent Beck 『テスト駆動開発』

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、新しいゲームを開始できる

このシンプルなストーリーから始めることで、ゲームの基本的な構造を作り、後続の機能追加の土台を築くことができます。では、テスト駆動開発のサイクルに従って、まずはテストから書いていきましょう!

TODOリスト

さて、ユーザーストーリーを実装するために、まずはTODOリストを作成しましょう。TODOリストは、大きな機能を小さなタスクに分解するのに役立ちます。

何をテストすべきだろうか - 着手する前に、必要になりそうなテストをリストに書き出しておこう。

— Kent Beck 『テスト駆動開発』

私たちの「新しいゲームを開始できる」というユーザーストーリーを実現するためには、どのようなタスクが必要でしょうか?考えてみましょう:

  • ゲームの初期化処理を実装する(ゲームの状態や必要なコンポーネントを設定する)
  • ゲーム画面を表示する(プレイヤーが視覚的にゲームを認識できるようにする)
  • 新しいぷよを生成する(ゲーム開始時に最初のぷよを作成する)
  • ゲームループを開始する(ゲームの継続的な更新と描画を行う)

これらのタスクを一つずつ実装していきましょう。テスト駆動開発では、各タスクに対してテスト→実装→リファクタリングのサイクルを回します。まずは「ゲームの初期化処理」から始めましょう!

テスト: ゲームの初期化

さて、TODOリストの最初のタスク「ゲームの初期化処理を実装する」に取り掛かりましょう。テスト駆動開発では、まずテストを書くことから始めます。

テストファースト

いつテストを書くべきだろうか——それはテスト対象のコードを書く前だ。

— Kent Beck 『テスト駆動開発』

では、ゲームの初期化処理をテストするコードを書いてみましょう。何をテストすべきでしょうか?ゲームが初期化されたとき、必要なコンポーネントが正しく作成され、ゲームの状態が適切に設定されていることを確認する必要がありますね。

// src/tests/game.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Game } from '../game';
import { Config } from '../config';
import { Stage } from '../stage';
import { PuyoImage } from '../puyoimage';
import { Player } from '../player';
import { Score } from '../score';

describe('ゲーム', () => {
    let game: Game;

    beforeEach(() => {
        // DOMの準備
        document.body.innerHTML = `
            <div id="stage"></div>
            <div id="score"></div>
            <div id="next"></div>
            <div id="next2"></div>
        `;
        game = new Game();
    });

    describe('ゲームの初期化', () => {
        it('ゲームを初期化すると、必要なコンポーネントが作成される', () => {
            game.initialize();

            expect(game['config']).toBeInstanceOf(Config);
            expect(game['puyoImage']).toBeInstanceOf(PuyoImage);
            expect(game['stage']).toBeInstanceOf(Stage);
            expect(game['player']).toBeInstanceOf(Player);
            expect(game['score']).toBeInstanceOf(Score);
        });

        it('ゲームを初期化すると、ゲームモードがstartになる', () => {
            game.initialize();

            expect(game['mode']).toEqual('start');
        });
    });
});

このテストでは、Gameクラスのinitializeメソッドが正しく動作することを確認しています。具体的には、必要なコンポーネント(Config, PuyoImage, Stage, Player, Score)が作成され、ゲームモードが'start'に設定されることを検証しています。

実装: ゲームの初期化

テストを書いたら、次に実行してみましょう。どうなるでしょうか?

Error: Cannot find module '../game'

おっと!まだGameクラスを実装していないので、当然エラーになりますね。これがテスト駆動開発の「Red(赤)」の状態です。テストが失敗することを確認できました。

アサートファースト

ではテストはどこから書き始めるべきだろうか。それはテストの終わりにパスすべきアサーションを書くところからだ。

— Kent Beck 『テスト駆動開発』

では、テストが通るように最小限のコードを実装していきましょう。「最小限」というのがポイントです。この段階では、テストが通ることだけを目指して、必要最低限のコードを書きます。

// src/game.ts
import { Config } from './config';
import { PuyoImage } from './puyoimage';
import { Stage } from './stage';
import { Player } from './player';
import { Score } from './score';

export type GameMode = 'start' | 'checkFall' | 'fall' | 'checkErase' | 'erasing' | 'newPuyo' | 'playing' | 'gameOver';

export class Game {
    private mode: GameMode = 'start';
    private frame: number = 0;
    private combinationCount: number = 0;
    private config: Config;
    private puyoImage: PuyoImage;
    private stage: Stage;
    private player: Player;
    private score: Score;

    constructor() {
        // コンストラクタでは何もしない
    }

    initialize(): void {
        // 各コンポーネントの初期化
        this.config = new Config();
        this.puyoImage = new PuyoImage(this.config);
        this.stage = new Stage(this.config, this.puyoImage);
        this.player = new Player(this.config, this.stage, this.puyoImage);
        this.score = new Score();

        // ゲームモードを設定
        this.mode = 'start';
    }
}

解説: ゲームの初期化

テストが通りましたね!おめでとうございます。これがテスト駆動開発の「Green(緑)」の状態です。

実装したゲームの初期化処理について、少し解説しておきましょう。この処理では、主に以下のことを行っています:

  1. 各コンポーネント(Config, PuyoImage, Stage, Player, Score)のインスタンスを作成
  2. ゲームモードを'start'に設定

これにより、ゲームを開始するための準備が整います。各コンポーネントの役割を理解しておくと、今後の実装がスムーズになりますよ:

  • Config: ゲームの設定値を管理します(画面サイズ、ぷよの大きさなど)
  • PuyoImage: ぷよの画像を管理します(各色のぷよの画像を読み込み、描画する)
  • Stage: ゲームのステージ(盤面)を管理します(ぷよの配置状態、消去判定など)
  • Player: プレイヤーの入力と操作を管理します(キーボード入力の処理、ぷよの移動など)
  • Score: スコアの計算と表示を管理します(連鎖数に応じたスコア計算など)

このように、責任を明確に分けることで、コードの保守性が高まります。これはオブジェクト指向設計の基本原則の一つ、「単一責任の原則」に従っています。

単一責任の原則(SRP):クラスを変更する理由は1つだけであるべき。

— Robert C. Martin 『Clean Architecture』

テスト: ゲームループの開始

次に、ゲームループを開始するテストを書きます。

// src/tests/game.test.ts(続き)
describe('ゲームループ', () => {
    it('ゲームループを開始すると、requestAnimationFrameが呼ばれる', () => {
        // requestAnimationFrameのモック
        const originalRequestAnimationFrame = window.requestAnimationFrame;
        const mockRequestAnimationFrame = vi.fn();
        window.requestAnimationFrame = mockRequestAnimationFrame;

        try {
            game.loop();

            expect(mockRequestAnimationFrame).toHaveBeenCalledTimes(1);
            expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function));
        } finally {
            // モックを元に戻す
            window.requestAnimationFrame = originalRequestAnimationFrame;
        }
    });
});

このテストでは、GameクラスのloopメソッドがrequestAnimationFrameを呼び出すことを確認しています。

実装: ゲームループの開始

テストが失敗することを確認したら、テストが通るように最小限のコードを実装します。

// src/game.ts(続き)
loop(): void {
    // ゲームループの処理
    requestAnimationFrame(this.loop.bind(this));
}

解説: ゲームループの開始

さて、今回実装した「ゲームループ」について少し詳しく解説しましょう。「ゲームループって何?」と思われるかもしれませんね。

ゲームループは、その名の通り、ゲームの状態を更新し、画面を描画するための繰り返し処理なんです。心臓がずっと鼓動を続けるように、このループが継続的に実行されることで、ゲームが生き生きと動き続けるんですよ。

ここで使っているrequestAnimationFrameというメソッド、これがとても賢いんです!「どう賢いの?」というと、ブラウザの描画タイミングに合わせて処理を実行してくれるんです。これによって、スムーズなアニメーションが可能になるんですよ。

コードを見てみると、loopメソッド内でrequestAnimationFrameを呼び出し、自分自身(this.loop)をコールバックとして渡していますね。「これってどういうこと?」というと、「次の描画タイミングでも、また私を呼んでね」とブラウザにお願いしているようなものなんです。これによって、ループ処理が実現されるんですよ。

また、bind(this)という少し難しそうな記述がありますね。これは「コールバック内でもthisが正しく機能するように」という指示なんです。JavaScriptのthisは少し扱いが難しいんですが、このbind(this)によって、コールバック内でも正しくthisが機能するようになるんです。

このゲームループが基盤となって、これから様々な機能を追加していきますよ!

実装: 画面

index.html の実装

まず、アプリケーションのエントリーポイントとなる HTML ファイルを作成します。

<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ぷよぷよゲーム</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

この HTML ファイルでは、以下のことを行っています:

  1. 基本的な HTML 構造の定義

    • lang="ja" で日本語ページであることを指定
    • ビューポート設定でレスポンシブ対応
    • ページタイトルの設定
  2. アプリケーションのマウントポイント

    • <div id="app"></div> がゲーム画面を表示する場所になります
    • 後続のイテレーションで、この中に Canvas 要素が追加されます
  3. TypeScript モジュールの読み込み

    • type="module" により ES Modules として読み込み
    • Vite が TypeScript をトランスパイルしてブラウザで実行可能にします

main.ts の実装

次に、ゲームを起動するエントリーポイント main.ts を実装します。

// src/main.ts
import { Game } from './game'

// ゲームのインスタンスを作成
const game = new Game()

// ゲームを初期化
game.initialize()

// ゲームループを開始
game.loop()

console.log('Puyo Puyo Game Started!')

このコードでは、以下の 3 つのステップでゲームを開始します:

  1. Game クラスのインスタンスを作成
  2. initialize() メソッドで各コンポーネントを初期化
  3. loop() メソッドでゲームループを開始

依存クラスの実装

必要な依存クラス(Config, PuyoImage, Stage, Player, Score)も最小限の実装を作成します。

// src/config.ts
export class Config {
  // 最小限の実装
}

// src/puyoimage.ts
import { Config } from './config'

export class PuyoImage {
  constructor(_config: Config) {
    // 最小限の実装
  }
}

// src/stage.ts
import { Config } from './config'
import { PuyoImage } from './puyoimage'

export class Stage {
  constructor(_config: Config, _puyoImage: PuyoImage) {
    // 最小限の実装
  }
}

// src/player.ts
import { Config } from './config'
import { Stage } from './stage'
import { PuyoImage } from './puyoimage'

export class Player {
  constructor(_config: Config, _stage: Stage, _puyoImage: PuyoImage) {
    // 最小限の実装
  }
}

// src/score.ts
export class Score {
  // 最小限の実装
}

これらのクラスは現時点では空の実装ですが、後続のイテレーションで徐々に機能を追加していきます。

ESLint 設定の調整

未使用の引数に関する ESLint エラーを回避するため、.eslintrc.cjs に以下の設定を追加します:

rules: {
  complexity: ['error', 7],
  '@typescript-eslint/no-explicit-any': 'warn',
  '@typescript-eslint/no-unused-vars': [
    'error',
    {
      argsIgnorePattern: '^_',
      varsIgnorePattern: '^_',
    },
  ],
},

この設定により、_ で始まる引数名は未使用でもエラーにならなくなります。

テストの確認

すべての実装が完了したら、テストを実行して確認しましょう:

npm run check

以下の結果が表示されれば成功です:

✓ tests/game.test.ts (3 tests) 9ms

Test Files  1 passed (1)
     Tests  3 passed (3)

画面の確認

ではここで以下のコマンドを実行して実際に動作する画面を確認しましょう。

npm run dev

ブラウザで http://localhost:3000 を開くと、ぷよぷよゲームの画面が表示されます。コンソールには「Puyo Puyo Game Started!」というメッセージが表示されているはずです。

おめでとうございます!リリースに向けて最初の第一歩を踏み出すことができました。これから機能を追加するごとにどんどん実際のゲームの完成に近づく事が確認できます、楽しみですね。

「機能は別々に作りこんで最後に画面と統合するんじゃないの?」と思うもしれません。そういうアプローチもありますが画面イメージが最後まで確認できないともし間違っていたら手戻りが大変です。それに動作するプログラムがどんどん成長するのを見るのは楽しいですからね。

トップダウンでもボトムアップでもなく、エンドツーエンドで構築していく

エンドツーエンドで小さな機能を構築し、そこから作業を進めながら問題について学習していく。

— 達人プログラマー 熟達に向けたあなたの旅(第2版)

イテレーション 1 のまとめ

このイテレーションで実装した内容:

  1. Game クラスの初期化

    • 必要なコンポーネント(Config, PuyoImage, Stage, Player, Score)の作成
    • ゲームモードの設定
  2. ゲームループの実装

    • requestAnimationFrame を使用した継続的なループ処理
    • bind(this) による this コンテキストの保持
  3. エントリーポイントの実装

    • index.html でアプリケーションの基本構造を定義
    • <div id="app"> をゲーム画面のマウントポイントとして設定
    • main.ts でゲームの初期化とループ開始
    • ブラウザでの動作確認が可能に
  4. テストの作成

    • ゲーム初期化のテスト(2 テスト)
    • ゲームループのテスト(1 テスト)
    • すべてのテストが成功

次のイテレーションでは、ぷよの移動機能を実装していきます。

イテレーション2: ぷよの移動の実装

さて、前回のイテレーションでゲームの基本的な構造ができましたね。「ゲームが始まったけど、ぷよが動かないと面白くないよね?」と思いませんか?そこで次は、ぷよを左右に移動できるようにしていきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、落ちてくるぷよを左右に移動できる

「ぷよぷよって、落ちてくるぷよを左右に動かして、うまく積み上げるゲームですよね?」そうです!今回はその基本操作である「左右の移動」を実装していきます。

TODOリスト

さて、このユーザーストーリーを実現するために、どんなタスクが必要でしょうか?一緒に考えてみましょう。
「ぷよを左右に移動する」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • プレイヤーの入力を検出する(キーボードの左右キーが押されたことを検知する)
  • ぷよを左右に移動する処理を実装する(実際にぷよの位置を変更する)
  • 移動可能かどうかのチェックを実装する(画面の端や他のぷよにぶつかる場合は移動できないようにする)
  • 移動後の表示を更新する(画面上でぷよの位置が変わったことを表示する)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: プレイヤーの入力検出

「最初に何をテストすればいいんでしょうか?」まずは、プレイヤーの入力を検出する部分からテストしていきましょう。キーボードの左右キーが押されたときに、それを正しく検知できるかどうかをテストします。

テストファースト

いつテストを書くべきだろうか——それはテスト対象のコードを書く前だ。

— Kent Beck 『テスト駆動開発』

// src/tests/player.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Player } from '../player';
import { Config } from '../config';
import { Stage } from '../stage';
import { PuyoImage } from '../puyoimage';

describe('プレイヤー', () => {
    let config: Config;
    let puyoImage: PuyoImage;
    let stage: Stage;
    let player: Player;

    beforeEach(() => {
        // DOMの準備
        document.body.innerHTML = `
            <div id="stage"></div>
        `;
        config = new Config();
        puyoImage = new PuyoImage(config);
        stage = new Stage(config, puyoImage);
        player = new Player(config, stage, puyoImage);
    });

    describe('キー入力', () => {
        it('左キーが押されると、左向きの移動フラグが立つ', () => {
            // キーダウンイベントをシミュレート(左キー)
            const event = new KeyboardEvent('keydown', { key: 'ArrowLeft' });
            document.dispatchEvent(event);

            expect(player['inputKeyLeft']).toBe(true);
        });

        it('右キーが押されると、右向きの移動フラグが立つ', () => {
            // キーダウンイベントをシミュレート(右キー)
            const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
            document.dispatchEvent(event);

            expect(player['inputKeyRight']).toBe(true);
        });

        it('キーが離されると、対応する移動フラグが下がる', () => {
            // まず左キーを押す
            document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
            expect(player['inputKeyLeft']).toBe(true);

            // 次に左キーを離す
            document.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' }));
            expect(player['inputKeyLeft']).toBe(false);
        });
    });
});

「このテストは何をしているんですか?」このテストでは、キーボードの左右キーが押されたときと離されたときに、Playerクラスの中の対応するフラグが正しく設定されるかどうかを確認しています。例えば、左キーが押されたらinputKeyLeftというフラグがtrueになり、離されたらfalseになることを期待していますね。

「テストを実行するとどうなるんでしょう?」まだ実装していないので、当然テストは失敗するはずです。これがテスト駆動開発の「Red(赤)」の状態です。では、テストが通るように実装していきましょう!

実装: プレイヤーの入力検出

「失敗するテストができたので、次は実装ですね!」そうです!テストが通るように、最小限のコードを実装していきましょう。

仮実装を経て本実装へ

失敗するテストを書いてから、最初に行う実装はどのようなものだろうか - ベタ書きの値を返そう。
それでテストが通るようになったら、ベタ書きの値をだんだん本物の式や変数に置き換えていく。

— Kent Beck 『テスト駆動開発』

// src/player.ts
import { Config } from './config';
import { Stage } from './stage';
import { PuyoImage } from './puyoimage';

export class Player {
    private inputKeyLeft: boolean = false;
    private inputKeyRight: boolean = false;
    private inputKeyUp: boolean = false;
    private inputKeyDown: boolean = false;

    constructor(
        private config: Config,
        private stage: Stage,
        private puyoImage: PuyoImage
    ) {
        // キーボードイベントの登録
        document.addEventListener('keydown', this.onKeyDown.bind(this));
        document.addEventListener('keyup', this.onKeyUp.bind(this));
    }

    private onKeyDown(e: KeyboardEvent): void {
        switch (e.key) {
            case 'ArrowLeft':
                this.inputKeyLeft = true;
                break;
            case 'ArrowRight':
                this.inputKeyRight = true;
                break;
            case 'ArrowUp':
                this.inputKeyUp = true;
                break;
            case 'ArrowDown':
                this.inputKeyDown = true;
                break;
        }
    }

    private onKeyUp(e: KeyboardEvent): void {
        switch (e.key) {
            case 'ArrowLeft':
                this.inputKeyLeft = false;
                break;
            case 'ArrowRight':
                this.inputKeyRight = false;
                break;
            case 'ArrowUp':
                this.inputKeyUp = false;
                break;
            case 'ArrowDown':
                this.inputKeyDown = false;
                break;
        }
    }
}

「なるほど!キーが押されたり離されたりしたときのイベントを検知して、フラグを設定しているんですね。」そうです!ここでは、document.addEventListenerを使って、キーボードのイベントをリッスンしています。キーが押されたらonKeyDownメソッドが呼ばれ、離されたらonKeyUpメソッドが呼ばれます。

bind(this)って何ですか?」良い質問ですね!JavaScriptでは、イベントハンドラの中のthisは、イベントが発生した要素(ここではdocument)を指してしまいます。でも、私たちはPlayerクラスのメソッドの中でthisPlayerインスタンスを指すようにしたいんです。そこでbind(this)を使って、thisの参照先を固定しているんですよ。

bind() は Function インスタンスのメソッドで、新しい関数を生成し、呼び出し時に、 this キーワードを指定された値に設定し、指定された引数の並びを、新しい関数が呼び出された際に指定されたものより前にして呼び出します。

— Mozilla Developer Network 『Function.prototype.bind()』

「テストは通りましたか?」はい、これでテストは通るはずです!これがテスト駆動開発の「Green(緑)」の状態です。次は、ぷよを実際に移動させる機能をテストしていきましょう。

テスト: ぷよの移動

「次は何をテストしますか?」次は、ぷよを左右に移動する機能をテストしましょう。ぷよが左右に移動できるか、そして画面の端に到達したときに移動が制限されるかをテストします。

// src/tests/player.test.ts(続き)
describe('ぷよの移動', () => {
    beforeEach(() => {
        // 新しいぷよを作成
        player.createNewPuyo();
    });

    it('左に移動できる場合、左に移動する', () => {
        // 初期位置を記録
        const initialX = player['puyoX'];

        // 左に移動
        player.moveLeft();

        // 位置が1つ左に移動していることを確認
        expect(player['puyoX']).toBe(initialX - 1);
    });

    it('右に移動できる場合、右に移動する', () => {
        // 初期位置を記録
        const initialX = player['puyoX'];

        // 右に移動
        player.moveRight();

        // 位置が1つ右に移動していることを確認
        expect(player['puyoX']).toBe(initialX + 1);
    });

    it('左端にいる場合、左に移動できない', () => {
        // 左端に移動
        player['puyoX'] = 0;

        // 左に移動を試みる
        player.moveLeft();

        // 位置が変わっていないことを確認
        expect(player['puyoX']).toBe(0);
    });

    it('右端にいる場合、右に移動できない', () => {
        // 右端に移動(ステージの幅 - 1)
        player['puyoX'] = config.stageCols - 1;

        // 右に移動を試みる
        player.moveRight();

        // 位置が変わっていないことを確認
        expect(player['puyoX']).toBe(config.stageCols - 1);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下の4つのケースを確認しています:

  1. 通常の状態で左に移動できるか
  2. 通常の状態で右に移動できるか
  3. 左端にいるときに左に移動しようとしても位置が変わらないか
  4. 右端にいるときに右に移動しようとしても位置が変わらないか

「なるほど、画面の端を超えて移動できないようにするんですね!」そうです!ゲームの画面外にぷよが出てしまうと困りますからね。では、このテストが通るように実装していきましょう。

実装: ぷよの移動

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、ぷよを移動させる機能を実装していきましょう。

// src/player.ts(続き)
private puyoX: number = 2; // ぷよのX座標(中央に配置)
private puyoY: number = 0; // ぷよのY座標(一番上)
private puyoType: number = 0; // 現在のぷよの種類
private nextPuyoType: number = 0; // 次のぷよの種類
private rotation: number = 0; // 現在の回転状態

createNewPuyo(): void {
    // 新しいぷよを作成(ここでは簡略化)
    this.puyoX = 2;
    this.puyoY = 0;
    this.puyoType = Math.floor(Math.random() * 4) + 1; // 1~4のランダムな値
    this.nextPuyoType = Math.floor(Math.random() * 4) + 1;
    this.rotation = 0;
}

moveLeft(): void {
    // 左端でなければ左に移動
    if (this.puyoX > 0) {
        this.puyoX--;
    }
}

moveRight(): void {
    // 右端でなければ右に移動
    if (this.puyoX < this.config.stageCols - 1) {
        this.puyoX++;
    }
}

「ぷよの位置や種類を管理するプロパティがたくさんありますね!」そうですね。ぷよの状態を管理するために、いくつかのプロパティを定義しています:

  • puyoXpuyoY:ぷよの位置(X座標とY座標)
  • puyoTypenextPuyoType:現在のぷよと次のぷよの種類
  • rotation:ぷよの回転状態

「移動の処理はシンプルですね!」そうですね。moveLeftメソッドでは左端(X座標が0)でなければX座標を1減らし、moveRightメソッドでは右端(X座標がステージの幅-1)でなければX座標を1増やしています。これで、ぷよが画面の端を超えて移動することはなくなりました。

「これでテストは通りましたか?」はい、これでテストは通るはずです!これでぷよを左右に移動させる基本的な機能が実装できました。プレイヤーがキーボードの左右キーを押すと、ぷよが対応する方向に移動し、画面の端に到達すると移動が制限されます。

「でも、まだ実際にキー入力に応じて移動する処理が実装されていませんよね?」鋭い指摘ですね!確かに、キーが押されたことを検知するフラグと、ぷよを移動させるメソッドはできましたが、それらを連携させる部分はまだ実装していません。これは次のイテレーションで、ゲームループの中で処理していきますね。

「なるほど、少しずつ機能を追加していくんですね!」そうです!テスト駆動開発では、小さな機能を一つずつ確実に実装していくことで、複雑なシステムを構築していきます。今回は「ぷよを左右に移動させる」という基本機能を実装しました。次のイテレーションでは、「ぷよを回転させる」機能を実装していきましょう!

実装:ぷよの画面表示

「テストは通ったけど、実際にぷよが動いているところを見たいですね!」そうですね!それでは、ぷよを画面に表示して、実際にキーボードで操作できるようにしましょう。

index.html の更新

まず、ゲーム画面を表示するための要素を index.html に追加します:

<!-- index.html -->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>ぷよぷよゲーム</title>
  </head>
  <body>
    <div id="app">
      <div id="stage"></div>
    </div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

「何が変わったんですか?」<div id="app"> の中に <div id="stage"></div> を追加しました。この要素に、後で Canvas 要素が追加されて、ゲーム画面が表示されるようになります。

Config クラスの拡張

次に、画面表示に必要な設定を Config クラスに追加します:

// src/config.ts
export class Config {
  stageCols: number = 6 // ステージの列数
  stageRows: number = 12 // ステージの行数
  puyoSize: number = 32 // ぷよのサイズ(ピクセル)
  stageBackgroundColor: string = '#2a2a2a' // ステージの背景色
  stageBorderColor: string = '#444' // ステージの枠線色
}

PuyoImage クラスの実装

次に、ぷよを描画するための PuyoImage クラスを実装します:

// src/puyoimage.ts
import { Config } from './config'

export class PuyoImage {
  private readonly colors: string[] = [
    '#888', // 0: 空
    '#ff0000', // 1: 赤
    '#00ff00', // 2: 緑
    '#0000ff', // 3: 青
    '#ffff00', // 4: 黄色
  ]

  constructor(private config: Config) {}

  draw(
    ctx: CanvasRenderingContext2D,
    type: number,
    x: number,
    y: number
  ): void {
    const size = this.config.puyoSize
    const color = this.colors[type] || this.colors[0]

    // 円の中心座標と半径を計算
    const centerX = x * size + size / 2
    const centerY = y * size + size / 2
    const radius = size / 2 - 2 // 少し小さめにして余白を作る

    // ぷよを円形で描画
    ctx.fillStyle = color
    ctx.beginPath()
    ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
    ctx.fill()

    // 枠線を描画
    ctx.strokeStyle = '#000'
    ctx.lineWidth = 2
    ctx.beginPath()
    ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
    ctx.stroke()
  }
}

「ぷよを円形で描画しているんですね!」そうです!ctx.arc() メソッドを使って、ぷよを円形で描画しています。

Stage クラスの実装

続いて、ゲームのステージを管理する Stage クラスを実装します:

// src/stage.ts
import { Config } from './config'
import { PuyoImage } from './puyoimage'

export class Stage {
  private canvas!: HTMLCanvasElement
  private ctx!: CanvasRenderingContext2D
  private field: number[][] = []

  constructor(
    private config: Config,
    private puyoImage: PuyoImage
  ) {
    this.initializeCanvas()
    this.initializeField()
  }

  private initializeCanvas(): void {
    // canvas要素を作成
    this.canvas = document.createElement('canvas')
    this.canvas.width = this.config.stageCols * this.config.puyoSize
    this.canvas.height = this.config.stageRows * this.config.puyoSize
    this.canvas.style.border = `2px solid ${this.config.stageBorderColor}`
    this.canvas.style.backgroundColor = this.config.stageBackgroundColor

    // ステージ要素に追加
    const stageElement = document.getElementById('stage')
    if (stageElement) {
      stageElement.appendChild(this.canvas)
    }

    // 描画コンテキストを取得(テスト環境では取得できない可能性がある)
    const ctx = this.canvas.getContext('2d')
    if (ctx) {
      this.ctx = ctx
    }
  }

  private initializeField(): void {
    // フィールドを初期化(全て0=空)
    this.field = []
    for (let y = 0; y < this.config.stageRows; y++) {
      this.field[y] = []
      for (let x = 0; x < this.config.stageCols; x++) {
        this.field[y][x] = 0
      }
    }
  }

  draw(): void {
    if (!this.ctx) return // テスト環境対応

    // キャンバスをクリア
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)

    // フィールドのぷよを描画
    for (let y = 0; y < this.config.stageRows; y++) {
      for (let x = 0; x < this.config.stageCols; x++) {
        const puyoType = this.field[y][x]
        if (puyoType > 0) {
          this.puyoImage.draw(this.ctx, puyoType, x, y)
        }
      }
    }
  }

  drawPuyo(x: number, y: number, type: number): void {
    if (!this.ctx) return // テスト環境対応

    // 指定位置にぷよを描画
    this.puyoImage.draw(this.ctx, type, x, y)
  }

  setPuyo(x: number, y: number, type: number): void {
    // フィールドにぷよを配置
    if (
      y >= 0 &&
      y < this.config.stageRows &&
      x >= 0 &&
      x < this.config.stageCols
    ) {
      this.field[y][x] = type
    }
  }

  getPuyo(x: number, y: number): number {
    // フィールドからぷよの種類を取得
    if (
      y < 0 ||
      y >= this.config.stageRows ||
      x < 0 ||
      x >= this.config.stageCols
    ) {
      return -1 // 範囲外
    }
    return this.field[y][x]
  }
}

「Canvas を使って描画しているんですね!」そうです。HTML5 の Canvas API を使って、ゲーム画面を描画しています。

Player クラスの拡張

Player クラスに描画と更新のメソッドを追加します:

// src/player.ts(追加部分)
draw(): void {
  // 現在のぷよを描画
  this.stage.drawPuyo(this.puyoX, this.puyoY, this.puyoType)
}

update(): void {
  // キー入力に応じて移動
  if (this.inputKeyLeft) {
    this.moveLeft()
    this.inputKeyLeft = false // 移動後フラグをクリア
  }
  if (this.inputKeyRight) {
    this.moveRight()
    this.inputKeyRight = false // 移動後フラグをクリア
  }
}

Game クラスの更新

最後に、Game クラスのゲームループで描画と更新を行うようにします:

// src/game.ts
initialize(): void {
  // 各コンポーネントの初期化
  this.config = new Config()
  this.puyoImage = new PuyoImage(this.config)
  this.stage = new Stage(this.config, this.puyoImage)
  this.player = new Player(this.config, this.stage, this.puyoImage)
  this.score = new Score()

  // ゲームモードを設定
  this.mode = 'newPuyo'
}

loop(): void {
  // ゲームループの処理
  this.update()
  this.draw()
  requestAnimationFrame(this.loop.bind(this))
}

private update(): void {
  this.frame++

  // モードに応じた処理
  switch (this.mode) {
    case 'newPuyo':
      // 新しいぷよを作成
      this.player.createNewPuyo()
      this.mode = 'playing'
      break

    case 'playing':
      // プレイ中の処理(キー入力に応じた移動)
      this.player.update()
      break
  }
}

private draw(): void {
  // ステージを描画
  this.stage.draw()

  // プレイヤーのぷよを描画
  if (this.mode === 'playing') {
    this.player.draw()
  }
}

動作確認

「これで実際に動かせますね!」はい!開発サーバーを起動して、ブラウザで確認してみましょう:

npm run dev

ブラウザで http://localhost:3000/ にアクセスすると、ステージが表示され、円形のぷよが表示されます。左右の矢印キーを押すと、ぷよが左右に移動します!

「動きました!」素晴らしい!これで、テストだけでなく実際の動作も確認できるようになりましたね。

テスト環境への対応

「でも、Canvas を使うとテストが動かなくなりませんか?」良い質問ですね。jsdom 環境では Canvas の getContext() がサポートされていないので、以下のように対応しました:

  1. Stage クラスの initializeCanvas() で、ctx が取得できない場合でもエラーにしない
  2. draw()drawPuyo() メソッドで、ctx が存在しない場合は早期リターン

これにより、テスト環境でも問題なくテストが実行できます。

イテレーション2のまとめ

このイテレーションで実装した内容:

  1. index.html の更新

    • <div id="stage"></div> 要素を追加
    • Canvas 要素のマウントポイントとして機能
  2. Player クラスのキー入力検出機能

    • 4方向のキー入力フラグ(inputKeyLeft, inputKeyRight, inputKeyUp, inputKeyDown)の実装
    • keydown/keyup イベントリスナーの登録
    • setKeyState メソッドによるキー状態の一元管理(リファクタリング)
  3. Player クラスのぷよ移動機能

    • ぷよの状態管理(puyoX, puyoY, puyoType, nextPuyoType, rotation)
    • createNewPuyo メソッド:新しいぷよを生成
    • moveLeft/moveRight メソッド:ぷよを左右に移動(境界チェック付き)
    • getRandomPuyoType メソッド:ランダムなぷよの種類を生成(リファクタリング)
    • マジックナンバーの定数化(INITIAL_PUYO_X, INITIAL_PUYO_Y, MIN_PUYO_TYPE, MAX_PUYO_TYPE)
  4. Config クラスの拡張

    • stageCols: ステージの列数(6)
    • stageRows: ステージの行数(12)
    • puyoSize: ぷよのサイズ(32ピクセル)
    • stageBackgroundColor: ステージの背景色
    • stageBorderColor: ステージの枠線色
  5. PuyoImage クラスの実装

    • colors 配列:ぷよの種類ごとの色定義(赤、緑、青、黄色)
    • draw メソッド:Canvas API を使用した円形ぷよの描画
    • ctx.arc() による円の描画、枠線の追加
  6. Stage クラスの実装

    • Canvas 要素の生成と DOM への追加
    • field 配列:ステージ上のぷよ配置情報を管理
    • initializeCanvas/initializeField:初期化処理
    • draw/drawPuyo:ステージとぷよの描画
    • setPuyo/getPuyo:フィールドへのぷよの配置と取得
    • テスト環境対応:ctx が null の場合の早期リターン
  7. Player クラスの拡張

    • draw メソッド:プレイヤーが操作中のぷよを描画
    • update メソッド:キー入力に応じてぷよを移動
  8. Game クラスの拡張

    • update メソッド:ゲーム状態の更新(newPuyo → playing の状態遷移)
    • draw メソッド:ステージとプレイヤーのぷよを描画
    • loop メソッド:update と draw を呼び出すゲームループ
  9. テストの作成

    • キー入力検出のテスト(3 テスト)
      • 左キー押下時のフラグ設定
      • 右キー押下時のフラグ設定
      • キー解放時のフラグクリア
    • ぷよの移動テスト(4 テスト)
      • 左に移動できる場合の移動
      • 右に移動できる場合の移動
      • 左端での移動制限
      • 右端での移動制限
    • 合計10テストすべて成功
  10. TDD サイクルの実践

    • Red: 失敗するテストを先に作成
    • Green: テストを通す最小限の実装
    • Refactor: マジックナンバーの定数化、ランダム生成ロジックの抽出
  11. 学んだ重要な概念

    • bind(this) によるイベントハンドラのコンテキスト固定
    • TypeScript の private フィールドアクセス(テストでの player['inputKeyLeft'] 記法)
    • jsdom 環境での KeyboardEvent のシミュレーション
    • 境界チェックによる移動制限の実装
    • static readonly 定数による設定値の管理
    • Canvas API による 2D グラフィックス描画(arc, fillRect, clearRect)
    • requestAnimationFrame によるゲームループの実装
    • テスト環境と実行環境の違いへの対応(Canvas の getContext の存在チェック)

次のイテレーションでは、ぷよの回転機能を実装していきます。

イテレーション3: ぷよの回転の実装

「左右に移動できるようになったけど、ぷよぷよって回転もできますよね?」そうですね!ぷよぷよの醍醐味の一つは、ぷよを回転させて思い通りの場所に配置することです。今回は、ぷよを回転させる機能を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、落ちてくるぷよを回転できる

「回転って具体的にどういう動きですか?」良い質問ですね!ぷよぷよでは、2つのぷよが連なった状態で落ちてきます。回転とは、この2つのぷよの相対的な位置関係を変えることです。例えば、縦に並んでいるぷよを横に並ぶように変えたりできるんですよ。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「ぷよを回転させる」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • ぷよの回転処理を実装する(時計回り・反時計回りの回転)
  • 回転可能かどうかのチェックを実装する(他のぷよや壁にぶつかる場合は回転できないようにする)
  • 壁キック処理を実装する(壁際での回転を可能にする特殊処理)
  • 回転後の表示を更新する(画面上でぷよの位置が変わったことを表示する)

「壁キックって何ですか?」壁キックとは、ぷよが壁際にあるときに回転すると壁にめり込んでしまうので、自動的に少し位置をずらして回転を可能にする処理のことです。プレイヤーの操作性を向上させるための工夫なんですよ。

テスト: ぷよの回転

「まずは何からテストしますか?」テスト駆動開発の流れに沿って、まずは基本的な回転機能のテストから書いていきましょう。

// src/tests/player.test.ts(続き)
describe('ぷよの回転', () => {
    beforeEach(() => {
        // 新しいぷよを作成
        player.createNewPuyo();
    });

    it('時計回りに回転すると、回転状態が1増える', () => {
        // 初期回転状態を記録
        const initialRotation = player['rotation'];

        // 時計回りに回転
        player.rotateRight();

        // 回転状態が1増えていることを確認
        expect(player['rotation']).toBe((initialRotation + 1) % 4);
    });

    it('反時計回りに回転すると、回転状態が1減る', () => {
        // 初期回転状態を記録
        const initialRotation = player['rotation'];

        // 反時計回りに回転
        player.rotateLeft();

        // 回転状態が1減っていることを確認(負の値にならないように調整)
        expect(player['rotation']).toBe((initialRotation + 3) % 4);
    });

    it('回転状態が4になると0に戻る', () => {
        // 回転状態を3に設定
        player['rotation'] = 3;

        // 時計回りに回転
        player.rotateRight();

        // 回転状態が0になっていることを確認
        expect(player['rotation']).toBe(0);
    });
});

「このテストは何を確認しているんですか?」このテストでは、以下の3つのケースを確認しています:

  1. 時計回りに回転すると、回転状態が1増えるか
  2. 反時計回りに回転すると、回転状態が1減るか(ただし、負の値にならないように調整)
  3. 回転状態が最大値(3)から時計回りに回転すると、0に戻るか(循環するか)

「回転状態って何ですか?」回転状態は、ぷよの向きを表す値です。0から3までの値を取り、それぞれ以下の状態を表します:

  • 0: 2つ目のぷよが上にある状態
  • 1: 2つ目のぷよが右にある状態
  • 2: 2つ目のぷよが下にある状態
  • 3: 2つ目のぷよが左にある状態

「なるほど、4方向の回転を表現するんですね!」そうです!では、このテストが通るように実装していきましょう。

実装: ぷよの回転

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、ぷよを回転させる機能を実装していきましょう。

// src/player.ts(続き)
rotateRight(): void {
    // 時計回りに回転(0→1→2→3→0)
    this.rotation = (this.rotation + 1) % 4;
}

rotateLeft(): void {
    // 反時計回りに回転(0→3→2→1→0)
    this.rotation = (this.rotation + 3) % 4;
}

「シンプルですね!」そうですね。回転処理自体はとてもシンプルです。rotateRightメソッドでは回転状態を1増やし、rotateLeftメソッドでは回転状態を1減らしています(ただし、負の値にならないように3を足して4で割った余りを取っています)。

「なぜ反時計回りの場合は単純に1減らすのではなく、3を足して4で割るんですか?」鋭い質問ですね!JavaScriptでは、負の数の剰余演算(%演算子)の結果が他の言語と異なる場合があります。例えば、-1 % 4は-1になることがあります。しかし、私たちは常に0から3の範囲の値が欲しいので、3を足して(これは1を引くのと同じ効果があります)から4で割ることで、確実に正の値の範囲内に収めているんです。

剰余演算子 (%) は、1 つ目のオペランドが 2 つ目のオペランドで除算されたときの余りである剰余を返します。これは常に被除数の符号を取ります。

— Mozilla Developer Network 『剰余演算子』

「テストは通りましたか?」はい、これでテストは通るはずです!これで基本的な回転機能が実装できました。しかし、まだ壁際での回転(壁キック)処理が実装されていませんね。次はそれをテストしていきましょう。

テスト: 壁キック処理

「壁キック処理のテストはどうやって書くんですか?」壁キック処理は、ぷよが壁際にあるときに回転すると自動的に位置を調整する機能です。これをテストするには、ぷよを壁際に配置し、回転させたときに適切に位置が調整されるかを確認します。

// src/tests/player.test.ts(続き)
describe('壁キック処理', () => {
    beforeEach(() => {
        // 新しいぷよを作成
        player.createNewPuyo();
    });

    it('右端で右回転すると、左に移動して回転する(壁キック)', () => {
        // 右端に移動
        player['puyoX'] = config.stageCols - 1;
        player['rotation'] = 0; // 上向き

        // 右回転(2つ目のぷよが右にくる)
        player.rotateRight();

        // 壁キックにより左に移動していることを確認
        expect(player['puyoX']).toBe(config.stageCols - 2);
        expect(player['rotation']).toBe(1);
    });

    it('左端で左回転すると、右に移動して回転する(壁キック)', () => {
        // 左端に移動
        player['puyoX'] = 0;
        player['rotation'] = 0; // 上向き

        // 左回転(2つ目のぷよが左にくる)
        player.rotateLeft();

        // 壁キックにより右に移動していることを確認
        expect(player['puyoX']).toBe(1);
        expect(player['rotation']).toBe(3);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下の2つのケースを確認しています:

  1. 右端にいるときに時計回りに回転すると、左に1マス移動して回転するか
  2. 左端にいるときに反時計回りに回転すると、右に1マス移動して回転するか

「なるほど、壁にめり込まないように自動的に位置を調整するんですね!」そうです!これがいわゆる「壁キック」と呼ばれる処理です。プレイヤーの操作性を向上させるための工夫なんですよ。では、このテストが通るように実装していきましょう。

実装: 壁キック処理

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、壁キック処理を実装していきましょう。

// src/player.ts(続き)
rotateRight(): void {
    // 回転前の状態を保存
    const oldRotation = this.rotation;

    // 時計回りに回転
    this.rotation = (this.rotation + 1) % 4;

    // 右端で右回転した場合(2つ目のぷよが右にくる場合)
    if (this.rotation === 1 && this.puyoX === this.config.stageCols - 1) {
    // 左に移動(壁キック)
    this.puyoX--;
}

// 左端で左回転した場合(2つ目のぷよが左にくる場合)
if (this.rotation === 3 && this.puyoX === 0) {
    // 右に移動(壁キック)
    this.puyoX++;
}
}

rotateLeft(): void {
    // 回転前の状態を保存
    const oldRotation = this.rotation;

    // 反時計回りに回転
    this.rotation = (this.rotation + 3) % 4;

    // 右端で右回転した場合(2つ目のぷよが右にくる場合)
    if (this.rotation === 1 && this.puyoX === this.config.stageCols - 1) {
    // 左に移動(壁キック)
    this.puyoX--;
}

// 左端で左回転した場合(2つ目のぷよが左にくる場合)
if (this.rotation === 3 && this.puyoX === 0) {
    // 右に移動(壁キック)
    this.puyoX++;
}
}

「なるほど、回転後に壁にめり込む場合は位置を調整するんですね!」そうです!この実装では、以下のことを行っています:

  1. まず通常の回転処理を行う
  2. 回転後、ぷよが壁にめり込む状況になっていないかチェックする
  3. めり込む場合は、ぷよの位置を調整する(壁キック)

「でも、rotateRightrotateLeftで同じ壁キック処理が重複していますね?」鋭い指摘です!確かに重複していますね。これはリファクタリングの良い候補です。共通の壁キック処理を抽出して、コードの重複を減らすことができるでしょう。しかし、今回はテストが通ることを優先して、リファクタリングは次のステップで行うことにしましょう。

「テストは通りましたか?」はい、これでテストは通るはずです!これでぷよを回転させる機能と、壁際での特殊処理(壁キック)が実装できました。プレイヤーがキーボードの上キーを押すと、ぷよが回転し、壁際でも適切に位置が調整されるようになりました。

イテレーション3のまとめ

このイテレーションでは、ぷよの回転機能と壁際での特殊処理を実装しました。以下がイテレーション3で実施した内容のまとめです:

  1. 回転状態の管理

    • rotation プロパティ:0(上)、1(右)、2(下)、3(左) の 4 状態で管理
    • 回転状態に応じた 2 つ目のぷよの位置計算(offsetX, offsetY 配列使用)
  2. 回転メソッドの実装

    • rotateRight メソッド:時計回りに回転(rotation を +1)
    • rotateLeft メソッド:反時計回りに回転(rotation を +3、つまり -1 と同等)
    • performRotation メソッド:回転処理と壁キック処理を統合
  3. 壁キック処理

    • 回転後に 2 つ目のぷよが壁外に出る場合、軸ぷよの位置を自動調整
    • 左壁キック:nextX < 0 のとき puyoX を +1
    • 右壁キック:nextX >= stageCols のとき puyoX を -1
  4. 2 つ目のぷよを考慮した移動制限

    • moveLeft/moveRight の改善:回転状態に応じて 2 つ目のぷよの位置も計算
    • 軸ぷよと 2 つ目のぷよの両方が範囲内にある場合のみ移動可能
  5. 描画の更新

    • draw メソッドで回転状態に応じた 2 つ目のぷよの描画
    • 軸ぷよと 2 つ目のぷよを両方描画
  6. キー入力の統合

    • update メソッドで上キー(inputKeyUp)による回転処理
    • 回転後フラグをクリア
  7. テストの作成

    • 回転機能のテスト(3 テスト)
      • 時計回りに回転すると回転状態が 1 増える
      • 反時計回りに回転すると回転状態が 1 減る
      • 回転状態が 4 になると 0 に戻る
    • 壁キック処理のテスト(3 テスト)
      • 左壁際で回転すると右にキックする
      • 右壁際で右向き回転時に左にキックする
      • 左壁際で左向き状態から回転すると右にキックする
    • 横向き移動制限のテスト(2 テスト)
      • 横向き(右)の状態で右端にいる場合、右に移動できない
      • 横向き(左)の状態で左端にいる場合、左に移動できない
    • 合計 18 テストすべて成功
  8. TDD サイクルの実践

    • Red: 回転・壁キック・移動制限のテストを先に作成し失敗を確認
    • Green: 各機能を実装してテストを通過
    • Refactor: performRotation メソッドへの共通処理抽出
  9. 学んだ重要な概念

    • 4 方向状態管理:配列インデックスによる効率的な座標オフセット計算
    • 壁キック:ユーザビリティ向上のための位置自動調整
    • 複合的な境界チェック:軸ぷよと 2 つ目のぷよ両方の範囲チェック
    • DRY 原則:重複コードの共通メソッド化(performRotation)

注意: このイテレーションでは壁との境界チェックのみを実装しており、既存のぷよとの衝突判定はまだ実装されていません。そのため、回転や移動時に着地済みのぷよを上書きしてしまう問題があります。この問題は後のイテレーションで修正します。

このイテレーションにより、ぷよを自由に回転させながら左右に移動できるようになり、ぷよぷよの基本的な操作性が実現できました。

イテレーション4: ぷよの自由落下の実装

「回転ができるようになったけど、ぷよぷよって自動で落ちていくよね?」そうですね!ぷよぷよでは、ぷよが一定間隔で自動的に下に落ちていきます。今回は、その「自由落下」機能を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

システムとしてぷよを自由落下させることができる

「ぷよが自動的に落ちていく」という機能は、ぷよぷよの基本中の基本ですね。プレイヤーが何も操作しなくても、時間とともにぷよが下に落ちていく仕組みを作りましょう。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODO リストを作成してみましょう。

「ぷよを自由落下させる」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • 落下タイマーの実装(一定時間ごとに落下処理を実行する仕組み)
  • 自動落下処理の実装(タイマーが発火したときにぷよを1マス下に移動する)
  • 落下可能判定の実装(下に移動できるかどうかをチェックする)
  • 着地処理の実装(ぷよが着地したときの処理)
  • ゲームループとの統合(ゲームの更新処理に自由落下を組み込む)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: 落下タイマー

「最初に何をテストすればいいんでしょうか?」まずは、一定時間ごとに落下処理が実行される仕組みをテストしましょう。

// src/tests/player.test.ts(続き)
describe('自由落下', () => {
    beforeEach(() => {
        // 新しいぷよを作成
        player.createNewPuyo()
    })

    it('指定時間が経過すると、ぷよが1マス下に落ちる', () => {
        // 初期位置を記録
        const initialY = player['puyoY']

        // 落下間隔を取得(例: 1000ms = 1秒)
        const dropInterval = 1000

        // ゲームの更新処理を実行(落下間隔分)
        player.update(dropInterval)

        // 位置が1つ下に移動していることを確認
        expect(player['puyoY']).toBe(initialY + 1)
    })

    it('指定時間未満では、ぷよは落ちない', () => {
        // 初期位置を記録
        const initialY = player['puyoY']

        // 落下間隔を取得
        const dropInterval = 1000

        // タイマーを半分だけ進める
        player.update(dropInterval / 2)

        // 位置が変わっていないことを確認
        expect(player['puyoY']).toBe(initialY)
    })

    it('下端に達した場合、それ以上落ちない', () => {
        // 下端の1つ上に配置
        player['puyoY'] = config.stageRows - 1

        // 落下処理を実行
        player.update(1000)

        // 位置が変わっていないことを確認(下端を超えない)
        expect(player['puyoY']).toBe(config.stageRows - 1)
    })
})

「テストを書いたら、次は実装ですね!」その通りです。Red(失敗)→ Green(成功)→ Refactor(改善)のサイクルで進めていきましょう。

実装: 落下タイマー

// src/player.ts(続き)
class Player {
    private dropTimer: number = 0;
    private dropInterval: number = 1000; // 1秒ごとに落下

    // タイマーをリセットする
    resetDropTimer(): void {
        this.dropTimer = 0;
    }

    // 落下間隔を取得する
    getDropInterval(): number {
        return this.dropInterval;
    }

    // ゲームの更新処理
    update(deltaTime: number): void {
        // タイマーを進める
        this.dropTimer += deltaTime;

        // 落下間隔を超えたら落下処理を実行
        if (this.dropTimer >= this.dropInterval) {
            this.applyGravity();
            this.dropTimer = 0; // タイマーをリセット
        }
    }

    // 重力を適用する(ぷよを1マス下に落とす)
    private applyGravity(): void {
        // 下に移動できるかチェック
        if (this.canMoveDown()) {
            this.puyoY += 1;
        } else {
            // 着地した場合の処理(後で実装)
            this.onLanded();
        }
    }

    // 下に移動できるかチェックする
    private canMoveDown(): boolean {
        // 子ぷよの位置も考慮してチェック
        const nextY = this.puyoY + 1;
        const childNextY = this.puyoY + this.childOffsetY + 1;

        // 親ぷよと子ぷよが範囲内かチェック
        if (nextY >= config.stageRows || childNextY >= config.stageRows) {
            return false;
        }

        // フィールドに衝突するかチェック(後で実装)
        // if (this.checkCollision(this.puyoX, nextY) ||
        //     this.checkCollision(this.puyoX + this.childOffsetX, childNextY)) {
        //     return false;
        // }

        return true;
    }

    // 着地したときの処理
    private onLanded(): void {
        // 後で実装
        console.log('ぷよが着地しました');
    }
}

「タイマーを使って一定間隔で落下させるんですね!」そうです!この実装では、以下のことを行っています:

  1. dropTimer でタイマーを管理
  2. update() メソッドで経過時間を加算
  3. 一定間隔(dropInterval)を超えたら applyGravity() を実行
  4. applyGravity() で下に移動できるかチェックし、移動またはは着地処理を実行

「でも、canMoveDown() の中でフィールドとの衝突チェックがコメントアウトされていますね?」鋭い指摘です!確かに、フィールドに配置されたぷよとの衝突チェックはまだ実装していません。今は基本的な落下の仕組みだけを実装して、フィールドとの衝突チェックは次のステップで追加していきましょう。

テスト: 着地判定

「ぷよが着地したときの処理もテストしたいです!」いいですね!次は、ぷよが着地したときの振る舞いをテストしましょう。

// src/tests/player.test.ts(続き)
describe('着地判定', () => {
    beforeEach(() => {
        player.createNewPuyo();
    });

    it('ぷよが一番下まで落ちたら着地する', () => {
        // ぷよを一番下の1つ上の位置に配置
        player['puyoY'] = config.stageRows - 2;
        player['childOffsetY'] = 1; // 子ぷよは下

        // スパイを設定して着地処理が呼ばれるか確認
        const landedSpy = jest.spyOn(player as any, 'onLanded');

        // 重力を適用
        player['applyGravity']();

        // 着地処理が呼ばれたことを確認
        expect(landedSpy).toHaveBeenCalled();
    });

    it('着地したぷよはフィールドに固定される', () => {
        // ぷよを一番下まで落とす
        player['puyoY'] = config.stageRows - 1;

        // 着地処理を実行
        player['onLanded']();

        // フィールドにぷよが配置されているか確認(後で実装)
        // const field = player.getField();
        // expect(field[player['puyoY']][player['puyoX']]).not.toBe(0);
    });
});

実装: 着地処理

// src/player.ts(続き)
class Player {
    // フィールドの状態を保持する2次元配列
    private field: number[][];

    constructor() {
        // フィールドを初期化(0で埋める)
        this.field = Array.from(
            { length: config.stageRows },
            () => Array(config.stageCols).fill(0)
        );
    }

    // フィールドを取得する
    getField(): number[][] {
        return this.field;
    }

    // 着地したときの処理
    private onLanded(): void {
        // 親ぷよをフィールドに配置
        this.field[this.puyoY][this.puyoX] = this.puyoColor;

        // 子ぷよをフィールドに配置
        const childX = this.puyoX + this.childOffsetX;
        const childY = this.puyoY + this.childOffsetY;
        this.field[childY][childX] = this.childColor;

        // 次のぷよを生成
        this.createNewPuyo();
    }

    // 下に移動できるかチェックする(更新版)
    private canMoveDown(): boolean {
        const nextY = this.puyoY + 1;
        const childNextY = this.puyoY + this.childOffsetY + 1;

        // 範囲外チェック
        if (nextY >= config.stageRows || childNextY >= config.stageRows) {
            return false;
        }

        // フィールドとの衝突チェック
        if (this.field[nextY][this.puyoX] !== 0) {
            return false;
        }

        const childX = this.puyoX + this.childOffsetX;
        if (this.field[childNextY][childX] !== 0) {
            return false;
        }

        return true;
    }
}

「着地したらフィールドに配置して、次のぷよを生成するんですね!」そうです!この実装では、以下のことを行っています:

  1. field でフィールドの状態を管理(0 は空、それ以外はぷよの色)
  2. onLanded() で親ぷよと子ぷよをフィールドに配置
  3. 着地後、createNewPuyo() で次のぷよを生成
  4. canMoveDown() でフィールドとの衝突もチェック

「テストは通りましたか?」はい、これでテストは通るはずです!これでぷよが自動的に落下し、着地したらフィールドに固定される機能が実装できました。

TODO リストの更新

実装が完了したので、TODO リストを更新しましょう:

  • 落下タイマーの実装
  • 自動落下処理の実装
  • 落下可能判定の実装
  • 着地処理の実装
  • ゲームループとの統合

「あとはゲームループとの統合だけですね!」そうです!実際のゲームでは、update() メソッドをゲームループから定期的に呼び出す必要があります。実装のまえに現在の動作状況を確認しておきましょう。

画面の確認

では、現在の進行状況を確認してみましょう。ぷよは落下しながら回転し左右に動きます。おや?着地したはずなのに次のぷよが出てきませんね?
それに、着地したぷよが回転できて左右に動きますね追加の作業が必要です TODOリストを追加しましょう。

  • ぷよが着地したら固定する
  • ぷよが着地して固定したら次のぷよを出す
  • 着地したぷよの上にぷよが重なる

ぷよの着地、衝突判定は機能しているようですが以下のケースがうまく機能していません。ぷよは個別に重力が働く必要があるようです。

   0  1  2  3  4  5
┌──┬──┬──┬──┬──┬──┐ 0
│  │  │  │  │  │  │
├──┼──┼──┼──┼──┼──┤ 1
│  │  │  │  │  │  │
├──┼──┼──┼──┼──┼──┤ 2
│  │  │  │ Y│ B│  │
├──┼──┼──┼──┼──┼──┤ 3
│  │  │  │ Y│  │  │
├──┼──┼──┼──┼──┼──┤ 4
│  │  │  │ Y│  │  │
└──┴──┴──┴──┴──┴──┘

凡例: Y=黄色 B=青色
  • 個別のぷよに重力が作用する

テスト: 重力判定

it('個別のぷよに重力が作用する', () => {
    // Y=黄色 B=青色
    // 位置 (3, 9), (3, 10), (3, 11) に黄色(下端から3段積み)
    stage.setPuyo(3, 9, 1)
    stage.setPuyo(3, 10, 1)
    stage.setPuyo(3, 11, 1)
    // 位置 (4, 2) に青色 (浮いている)
    stage.setPuyo(4, 2, 2)

    // 重力を適用
    stage.applyGravity()

    // 青ぷよが1マス落ちていることを確認
    expect(stage.getPuyo(4, 2)).toBe(0)
    expect(stage.getPuyo(4, 3)).toBe(2)
    // 黄色ぷよは変わらない(下端に積み重なっているので動かない)
    expect(stage.getPuyo(3, 9)).toBe(1)
    expect(stage.getPuyo(3, 10)).toBe(1)
    expect(stage.getPuyo(3, 11)).toBe(1)
})

実装: 重力判定

// ステージ上のぷよに重力を適用(1マスずつ落とす)
// 戻り値: 落下したぷよがあれば true
applyGravity(): boolean {
    // フィールドのコピーを作成(移動前の状態を保存)
    const originalField: number[][] = this.field.map((row) => [...row])

    let hasFallen = false

    // 下から上に向かって各列をスキャン(列ごとに処理)
    for (let x = 0; x < this.config.stageCols; x++) {
        for (let y = this.config.stageRows - 2; y >= 0; y--) {
            const color = originalField[y][x]
            if (color > 0) {
                // 元のフィールドで下に空きがあるかチェック
                if (originalField[y + 1][x] === 0) {
                    // 1マス下に移動
                    this.field[y + 1][x] = color
                    this.field[y][x] = 0
                    hasFallen = true
                }
            }
        }
    }

    return hasFallen
}

実装: ゲームループとの統合

ゲームループで deltaTime を計算し、Player の updateWithDelta を呼び出すようにします。

Game クラスの拡張

// src/game.ts
export class Game {
    private lastTime: number = 0

    loop(currentTime: number = 0): void {
        // 経過時間を計算(ミリ秒)
        const deltaTime = currentTime - this.lastTime
        this.lastTime = currentTime

        // ゲームループの処理
        this.update(deltaTime)
        this.draw()
        requestAnimationFrame(this.loop.bind(this))
    }

    private update(deltaTime: number): void {
        this.frame++

        // モードに応じた処理
        switch (this.mode) {
            case 'newPuyo':
                // 新しいぷよを作成
                this.player.createNewPuyo()
                this.mode = 'playing'
                break

            case 'playing':
                // プレイ中の処理(キー入力と自由落下)
                this.player.updateWithDelta(deltaTime)

                // 着地したら重力チェックに移行
                if (this.player.hasLanded()) {
                    this.mode = 'checkFall'
                }
                break

            case 'checkFall':
                // 重力を適用
                const hasFallen = this.stage.applyGravity()
                if (hasFallen) {
                    // ぷよが落下した場合、falling モードへ
                    this.mode = 'falling'
                } else {
                    // 落下するぷよがない場合、次のぷよを出す
                    this.mode = 'newPuyo'
                }
                break

            case 'falling':
                // 落下アニメーション用(一定フレーム待機)
                // 簡略化のため、すぐに checkFall に戻る
                this.mode = 'checkFall'
                break
        }
    }
}

requestAnimationFrame の引数 currentTime を使うんですね!」そうです!ブラウザが提供する高精度なタイムスタンプを使って、フレーム間の経過時間を計算します。

Player クラスの着地判定

Player クラスに着地判定と固定処理を追加します:

// src/player.ts
export class Player {
    private landed: boolean = false

    updateWithDelta(deltaTime: number): void {
        // タイマーを進める
        this.dropTimer += deltaTime

        // 落下間隔に達したら落下処理
        if (this.dropTimer >= this.dropInterval) {
            this.drop()
            this.dropTimer = 0
        }

        // 既存の update 処理も実行
        this.update()
    }

    private drop(): void {
        // 着地判定: 下に移動できるかを確認
        if (this.canMoveDown()) {
            this.puyoY++
        } else {
            // 着地したらステージに固定
            this.fixToStage()
        }
    }

    private canMoveDown(): boolean {
        // 下端チェック
        if (this.puyoY >= this.config.stageRows - 1) {
            return false
        }

        // 2つ目のぷよの位置を計算
        const offsetX = [0, 1, 0, -1][this.rotation]
        const offsetY = [-1, 0, 1, 0][this.rotation]
        const nextX = this.puyoX + offsetX
        const nextY = this.puyoY + offsetY

        // 軸ぷよの下にぷよがあるかチェック
        if (this.stage.getPuyo(this.puyoX, this.puyoY + 1) > 0) {
            return false
        }

        // 2つ目のぷよの下にぷよがあるかチェック
        if (offsetY !== 1) {
            if (nextY >= this.config.stageRows - 1) {
                return false
            }
            if (this.stage.getPuyo(nextX, nextY + 1) > 0) {
                return false
            }
        }

        return true
    }

    private fixToStage(): void {
        // 軸ぷよをステージに固定
        this.stage.setPuyo(this.puyoX, this.puyoY, this.puyoType)

        // 2つ目のぷよの位置を計算
        const offsetX = [0, 1, 0, -1][this.rotation]
        const offsetY = [-1, 0, 1, 0][this.rotation]
        const nextX = this.puyoX + offsetX
        const nextY = this.puyoY + offsetY

        // 2つ目のぷよをステージに固定
        this.stage.setPuyo(nextX, nextY, this.nextPuyoType)

        // 着地フラグを立てる
        this.landed = true
    }

    hasLanded(): boolean {
        return this.landed
    }

    createNewPuyo(): void {
        // 新しいぷよを作成
        this.puyoX = Player.INITIAL_PUYO_X
        this.puyoY = Player.INITIAL_PUYO_Y
        this.puyoType = this.getRandomPuyoType()
        this.nextPuyoType = this.getRandomPuyoType()
        this.rotation = 0
        this.landed = false // 着地フラグをリセット
    }
}

「着地したらステージに固定して、フラグを立てるんですね!」その通りです。これで Game クラスが着地を検知して、次のぷよを出せるようになります。

イテレーション4のまとめ

このイテレーションでは、ぷよの自由落下と着地処理、重力判定を実装しました。以下がイテレーション 4 で実施した内容のまとめです:

  1. 自由落下機能

    • dropTimer と dropInterval でタイミング管理
    • updateWithDelta メソッドで時間経過に応じた落下処理
    • 1 秒間隔(1000ms)で 1 マスずつ落下
  2. 着地判定

    • canMoveDown メソッド:下に移動できるかチェック
    • 下端チェック:puyoY >= stageRows - 1
    • 軸ぷよの下にぷよがあるかチェック
    • 2 つ目のぷよの下にぷよがあるかチェック
  3. ステージへの固定

    • fixToStage メソッド:着地したぷよをステージに配置
    • 軸ぷよと 2 つ目のぷよを両方固定
    • 着地フラグ(landed)を立てる
  4. 着地フラグの管理

    • landed プロパティで着地状態を管理
    • hasLanded メソッドで外部から着地状態を確認
    • createNewPuyo で着地フラグをリセット
  5. 次のぷよ生成

    • Game クラスで着地を検知
    • checkFall モードで重力を適用
    • 重力適用後、次のぷよを出現
  6. 重力処理(Stage クラス)

    • applyGravity メソッド:ステージ上のぷよに重力を適用
    • フィールドのコピーを作成して移動前の状態を保存
    • 下から上に向かって各列をスキャン
    • 1 マスずつ落下(一度の呼び出しで 1 マス)
    • 落下したぷよがあれば true を返す
  7. ゲームフロー

    • newPuyo → playing → (着地) → checkFall → falling → checkFall → ... → newPuyo
    • checkFall: 重力を適用して落下するぷよがあるかチェック
    • falling: 落下アニメーション(簡略化のため即座に checkFall に戻る)
  8. テストの作成

    • 自由落下のテスト(3 テスト)
      • 指定時間が経過すると、ぷよが 1 マス下に落ちる
      • 指定時間未満では、ぷよは落ちない
      • 下端に達した場合、それ以上落ちない
    • 着地のテスト(3 テスト)
      • ぷよが下端に着地したら固定される
      • ぷよが他のぷよの上に着地したら固定される
      • ぷよが着地したら着地フラグが立つ
    • 重力判定のテスト(3 テスト)
      • 個別のぷよに重力が作用する
      • 浮いているぷよがない場合、何も変化しない
      • 複数の浮いているぷよに重力が作用する
    • Game クラスのテスト(1 テスト)
      • ぷよが着地したら次のぷよが出る
    • 合計 28 テストすべて成功
  9. 学んだ重要な概念

    • deltaTime による時間ベースの更新
    • requestAnimationFrame の currentTime 引数の活用
    • 着地判定の複合的なチェック(下端、軸ぷよ、2 つ目のぷよ)
    • ゲームモードによる状態遷移
    • フィールドコピーによる安全な重力適用

このイテレーションにより、ぷよが自動的に落下し、着地後に次のぷよが出現するようになり、ぷよぷよの基本的なゲームループが完成しました。

イテレーション5: ぷよの高速落下の実装

「回転ができるようになったけど、ぷよぷよってもっと早く落とせたよね?」そうですね!ぷよぷよでは、プレイヤーが下キーを押すことで、ぷよを素早く落下させることができます。今回は、その「高速落下」機能を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、ぷよを素早く落下させることができる

「早く次のぷよを落としたい!」というときに、下キーを押して素早く落下させる機能は、ゲームのテンポを良くするために重要ですね。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「ぷよを素早く落下させる」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • 下キー入力の検出を実装する(キーボードの下キーが押されたことを検知する)
  • 高速落下処理を実装する(下キーが押されているときは落下速度を上げる)
  • 落下可能かどうかのチェックを実装する(下に障害物がある場合は落下できないようにする)
  • 着地判定を実装する(ぷよが着地したことを検知する)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: 高速落下

「最初に何をテストすればいいんでしょうか?」まずは、下キーが押されたときに落下速度が上がることと、ぷよが下に移動できるかどうかをテストしましょう。

// src/tests/player.test.ts(続き)
describe('高速落下', () => {
    beforeEach(() => {
        // 新しいぷよを作成
        player.createNewPuyo();
    });

    it('下キーが押されていると、落下速度が上がる', () => {
        // 下キーを押す
        document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }));

        // 通常の落下処理
        const normalDropSpeed = 1;
        const fastDropSpeed = player.getDropSpeed();

        // 高速落下の速度が通常より速いことを確認
        expect(fastDropSpeed).toBeGreaterThan(normalDropSpeed);
    });

    it('下に移動できる場合、下に移動する', () => {
        // 初期位置を記録
        const initialY = player['puyoY'];

        // 下に移動
        player.moveDown();

        // 位置が1つ下に移動していることを確認
        expect(player['puyoY']).toBe(initialY + 1);
    });

    it('下に障害物がある場合、下に移動できない', () => {
        // ステージの一番下に移動
        player['puyoY'] = config.stageRows - 1;

        // 下に移動を試みる
        const canMove = player.moveDown();

        // 移動できないことを確認
        expect(canMove).toBe(false);
        expect(player['puyoY']).toBe(config.stageRows - 1);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下の3つのケースを確認しています:

  1. 下キーが押されていると、落下速度が通常より速くなるか
  2. 通常の状態で下に移動できるか
  3. ステージの一番下にいるときに下に移動しようとしても移動できないか

「なるほど、ゲームの端を超えて移動できないようにするんですね!」そうです!ゲームの画面外にぷよが出てしまうと困りますからね。では、このテストが通るように実装していきましょう。

実装: 高速落下

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、高速落下の機能を実装していきましょう。

// src/player.ts(続き)
getDropSpeed(): number {
    // 下キーが押されていれば高速落下
    return this.inputKeyDown ? 10 : 1;
}

moveDown(): boolean {
    // 下に移動できるかチェック
    if (this.puyoY < this.config.stageRows - 1) {
        this.puyoY++;
        return true;
    }
    return false;
}

「シンプルですね!」そうですね。高速落下の処理自体はとてもシンプルです。getDropSpeedメソッドでは、下キーが押されているかどうかを確認し、押されていれば通常の10倍の速度で落下するようにしています。また、moveDownメソッドでは、ぷよがステージの下端に到達していなければ下に移動できるようにしています。

「なぜmoveDownメソッドはbooleanを返すんですか?」良い質問ですね!moveDownメソッドは、ぷよが実際に下に移動できたかどうかを返します。これは、ぷよが着地したかどうかを判定するために使われます。ぷよが下に移動できなかった場合(falseが返された場合)、それはぷよが着地したことを意味します。

「これでテストは通りましたか?」はい、これでテストは通るはずです!これでぷよを素早く落下させる基本的な機能が実装できました。プレイヤーがキーボードの下キーを押すと、ぷよが素早く落下し、ステージの下端や他のぷよに衝突すると停止するようになりました。

「でも、まだ実際にキー入力に応じて落下速度が変わる処理が実装されていませんよね?」鋭い指摘ですね!確かに、キーが押されたことを検知するフラグと、落下速度を変更するメソッドはできましたが、それらを連携させる部分はまだ実装していません。

実装: ゲームループとの統合

ゲームループで高速落下の速度を反映するように updateWithDelta メソッドを修正します。

// src/player.ts(続き)
updateWithDelta(deltaTime: number): void {
    // タイマーを進める(高速落下の速度を反映)
    this.dropTimer += deltaTime * this.getDropSpeed()

    // 落下間隔に達したら落下処理
    if (this.dropTimer >= this.dropInterval) {
    this.drop()
    this.dropTimer = 0 // タイマーをリセット
}

// 既存の update 処理も実行
this.update()
}

「なるほど!deltaTimegetDropSpeed() を掛けることで、下キーが押されているときはタイマーが10倍速く進むんですね!」その通りです!これで下キーを押している間は通常の10倍の速さでぷよが落下するようになりました。

イテレーション5のまとめ

このイテレーションでは、ぷよの高速落下機能を実装しました。以下がイテレーション 5 で実施した内容のまとめです:

  1. 高速落下機能

    • getDropSpeed メソッド:下キーが押されているかチェック
    • 押されていれば通常の10倍の速度(10)を返す
    • 押されていなければ通常速度(1)を返す
  2. moveDown メソッド

    • 下に移動できるかチェック
    • 移動できれば puyoY を1増やして true を返す
    • 移動できなければ false を返す
    • canMoveDown を使って安全に移動判定
  3. ゲームループとの統合

    • updateWithDelta メソッドを修正
    • dropTimer に deltaTime * getDropSpeed() を加算
    • 高速落下時はタイマーが10倍速く進む
  4. テストの作成

    • 高速落下のテスト(3テスト)
      • 下キーが押されていると、落下速度が上がる
      • 下に移動できる場合、下に移動する
      • 下に障害物がある場合、下に移動できない
    • 合計31テストすべて成功
  5. 学んだ重要な概念

    • 速度倍率による時間制御
    • boolean 返り値による移動成功判定
    • 既存の canMoveDown メソッドの再利用

このイテレーションにより、プレイヤーが下キーを押すことでぷよを素早く落下させることができるようになり、ゲームのテンポが向上しました。
今回は「ぷよを素早く落下させる」という基本機能を実装しました。次のイテレーションでは、「ぷよを消去する」機能を実装していきましょう!

イテレーション6: ぷよの消去の実装

「ぷよが落ちてくるようになったけど、ぷよぷよの醍醐味はぷよを消すことですよね?」そうですね!ぷよぷよの最も重要な要素の一つは、同じ色のぷよを4つ以上つなげると消去できる機能です。今回は、その「ぷよの消去」機能を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、同じ色のぷよを4つ以上つなげると消去できる

「これがぷよぷよの基本ルールですね!」そうです!同じ色のぷよを4つ以上つなげると消去できるというのが、ぷよぷよの基本的なルールです。これを実装することで、ゲームとしての面白さが大きく向上しますね。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「ぷよを消去する」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • ぷよの接続判定を実装する(隣接する同じ色のぷよを検出する)
  • 4つ以上つながったぷよの検出を実装する(消去対象となるぷよのグループを特定する)
  • ぷよの消去処理を実装する(消去対象のぷよを実際に消す)
  • 消去後の落下処理を実装する(消去された後の空きスペースにぷよが落ちてくる)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: ぷよの接続判定

「最初に何をテストすればいいんでしょうか?」まずは、ぷよの接続判定をテストしましょう。同じ色のぷよが4つ以上つながっているかどうかを判定する機能が必要です。

// src/tests/stage.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { Stage } from '../stage';
import { Config } from '../config';
import { PuyoImage } from '../puyoimage';

describe('ステージ', () => {
    let config: Config;
    let puyoImage: PuyoImage;
    let stage: Stage;

    beforeEach(() => {
        // DOMの準備
        document.body.innerHTML = `
            <div id="stage"></div>
        `;
        config = new Config();
        puyoImage = new PuyoImage(config);
        stage = new Stage(config, puyoImage);
    });

    describe('ぷよの接続判定', () => {
        it('同じ色のぷよが4つつながっていると、消去対象になる', () => {
            // ステージにぷよを配置(1は赤ぷよ)
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 1 1 0 0 0
            // 0 1 1 0 0 0
            stage.setPuyo(1, 10, 1);
            stage.setPuyo(2, 10, 1);
            stage.setPuyo(1, 11, 1);
            stage.setPuyo(2, 11, 1);

            // 消去判定
            const eraseInfo = stage.checkErase();

            // 4つのぷよが消去対象になっていることを確認
            expect(eraseInfo.erasePuyoCount).toBe(4);
            expect(eraseInfo.eraseInfo.length).toBeGreaterThan(0);
        });

        it('異なる色のぷよは消去対象にならない', () => {
            // ステージにぷよを配置(1は赤ぷよ、2は青ぷよ)
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 0 0 0 0 0
            // 0 1 2 0 0 0
            // 0 2 1 0 0 0
            stage.setPuyo(1, 10, 1);
            stage.setPuyo(2, 10, 2);
            stage.setPuyo(1, 11, 2);
            stage.setPuyo(2, 11, 1);

            // 消去判定
            const eraseInfo = stage.checkErase();

            // 消去対象がないことを確認
            expect(eraseInfo.erasePuyoCount).toBe(0);
            expect(eraseInfo.eraseInfo.length).toBe(0);
        });
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下の2つのケースを確認しています:

  1. 同じ色のぷよが4つつながっている場合、それらが消去対象になるか
  2. 異なる色のぷよが隣接している場合、それらは消去対象にならないか

「ステージにぷよを配置しているのはわかりますが、その図はどういう意味ですか?」良い質問ですね!コメントの図は、ステージ上のぷよの配置を視覚的に表現しています。0は空きマス、1は赤ぷよ、2は青ぷよを表しています。最初のテストでは2×2の正方形に赤ぷよを配置し、2つ目のテストでは市松模様に赤と青のぷよを配置しています。

「なるほど、視覚的に確認できるのは便利ですね!」そうですね。では、このテストが通るように実装していきましょう。

実装: ぷよの接続判定

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、ぷよの接続判定を実装していきましょう。

// src/stage.ts
import { Config } from './config';
import { PuyoImage } from './puyoimage';

export interface EraseInfo {
    erasePuyoCount: number;
    eraseInfo: {
        x: number;
        y: number;
        type: number;
    }[];
}

export class Stage {
    private board: number[][] = [];
    private stageElement: HTMLElement;

    constructor(
        private config: Config,
        private puyoImage: PuyoImage
    ) {
        // ステージ要素の取得
        this.stageElement = document.getElementById('stage')!;

        // ステージの初期化
        this.initialize();
    }

    initialize(): void {
        // ボードの初期化
        this.board = [];
        for (let y = 0; y < this.config.stageRows; y++) {
            this.board[y] = [];
            for (let x = 0; x < this.config.stageCols; x++) {
                this.board[y][x] = 0;
            }
        }
    }

    setPuyo(x: number, y: number, type: number): void {
        // ぷよをボードに設定
        this.board[y][x] = type;
    }

    getPuyo(x: number, y: number): number {
        // ボード外の場合は0(空)を返す
        if (x < 0 || x >= this.config.stageCols || y < 0 || y >= this.config.stageRows) {
            return 0;
        }
        return this.board[y][x];
    }

    checkErase(): EraseInfo {
        // 消去情報
        const eraseInfo: EraseInfo = {
            erasePuyoCount: 0,
            eraseInfo: []
        };

        // 一時的なチェック用ボード
        const checked: boolean[][] = [];
        for (let y = 0; y < this.config.stageRows; y++) {
            checked[y] = [];
            for (let x = 0; x < this.config.stageCols; x++) {
                checked[y][x] = false;
            }
        }

        // 全マスをチェック
        for (let y = 0; y < this.config.stageRows; y++) {
            for (let x = 0; x < this.config.stageCols; x++) {
                // ぷよがあり、まだチェックしていない場合
                if (this.board[y][x] !== 0 && !checked[y][x]) {
                    // 接続しているぷよを探索
                    const puyoType = this.board[y][x];
                    const connected: {x: number, y: number}[] = [];
                    this.searchConnectedPuyo(x, y, puyoType, checked, connected);

                    // 4つ以上つながっている場合は消去対象
                    if (connected.length >= 4) {
                        for (const puyo of connected) {
                            eraseInfo.eraseInfo.push({
                                x: puyo.x,
                                y: puyo.y,
                                type: puyoType
                            });
                        }
                        eraseInfo.erasePuyoCount += connected.length;
                    }
                }
            }
        }

        return eraseInfo;
    }

    private searchConnectedPuyo(
        startX: number,
        startY: number,
        puyoType: number,
        checked: boolean[][],
        connected: {x: number, y: number}[]
    ): void {
        // 探索済みにする
        checked[startY][startX] = true;
        connected.push({x: startX, y: startY});

        // 4方向を探索
        const directions = [
            {dx: 1, dy: 0},  // 右
            {dx: -1, dy: 0}, // 左
            {dx: 0, dy: 1},  // 下
            {dx: 0, dy: -1}  // 上
        ];

        for (const direction of directions) {
            const nextX = startX + direction.dx;
            const nextY = startY + direction.dy;

            // ボード内かつ同じ色のぷよがあり、まだチェックしていない場合
            if (
                nextX >= 0 && nextX < this.config.stageCols &&
                nextY >= 0 && nextY < this.config.stageRows &&
                this.board[nextY][nextX] === puyoType &&
                !checked[nextY][nextX]
            ) {
                // 再帰的に探索
                this.searchConnectedPuyo(nextX, nextY, puyoType, checked, connected);
            }
        }
    }
}

解説: ぷよの接続判定

ぷよの接続判定では、以下のことを行っています。

  1. ボード上の全マスを順番にチェック
  2. まだチェックしていないぷよがある場合、そのぷよと同じ色で接続しているぷよを探索
  3. 接続しているぷよが4つ以上ある場合、それらを消去対象として記録

接続しているぷよの探索には深さ優先探索(DFS)アルゴリズムを使用しています。このアルゴリズムでは、あるぷよから始めて、上下左右に隣接する同じ色のぷよを再帰的に探索していきます。探索済みのぷよはchecked配列でマークし、重複してカウントしないようにしています。

テスト: ぷよの消去と落下

次に、ぷよの消去と落下処理をテストします。

// src/tests/stage.test.ts(続き)
describe('ぷよの消去と落下', () => {
    it('消去対象のぷよを消去する', () => {
        // ステージにぷよを配置
        stage.setPuyo(1, 10, 1);
        stage.setPuyo(2, 10, 1);
        stage.setPuyo(1, 11, 1);
        stage.setPuyo(2, 11, 1);

        // 消去判定
        const eraseInfo = stage.checkErase();

        // 消去実行
        stage.eraseBoards(eraseInfo.eraseInfo);

        // ぷよが消去されていることを確認
        expect(stage.getPuyo(1, 10)).toBe(0);
        expect(stage.getPuyo(2, 10)).toBe(0);
        expect(stage.getPuyo(1, 11)).toBe(0);
        expect(stage.getPuyo(2, 11)).toBe(0);
    });

    it('消去後、上にあるぷよが落下する', () => {
        // ステージにぷよを配置
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 2 0 0 0
        // 0 0 2 0 0 0
        // 0 1 1 0 0 0
        // 0 1 1 0 0 0
        stage.setPuyo(1, 10, 1);
        stage.setPuyo(2, 10, 1);
        stage.setPuyo(1, 11, 1);
        stage.setPuyo(2, 11, 1);
        stage.setPuyo(2, 8, 2);
        stage.setPuyo(2, 9, 2);

        // 消去判定と実行
        const eraseInfo = stage.checkErase();
        stage.eraseBoards(eraseInfo.eraseInfo);

        // 落下処理
        stage.fall();

        // 上にあったぷよが落下していることを確認
        expect(stage.getPuyo(2, 10)).toBe(2);
        expect(stage.getPuyo(2, 11)).toBe(2);
    });
});

このテストでは、消去対象のぷよが正しく消去されることと、消去後に上にあるぷよが落下することをテストしています。

実装: ぷよの消去と落下

テストが失敗することを確認したら、テストが通るように最小限のコードを実装します。

// src/stage.ts(続き)
eraseBoards(eraseInfo: {x: number, y: number, type: number}[]): void {
    // 消去対象のぷよを消去
    for (const info of eraseInfo) {
    this.board[info.y][info.x] = 0;
}
}

fall(): void {
    // 下から上に向かって処理
    for (let y = this.config.stageRows - 2; y >= 0; y--) {
    for (let x = 0; x < this.config.stageCols; x++) {
        if (this.board[y][x] !== 0) {
            // 現在のぷよの下が空いている場合、落下させる
            let fallY = y;
            while (fallY + 1 < this.config.stageRows && this.board[fallY + 1][x] === 0) {
                this.board[fallY + 1][x] = this.board[fallY][x];
                this.board[fallY][x] = 0;
                fallY++;
            }
        }
    }
}
}

解説: ぷよの消去と落下

ぷよの消去と落下処理では、以下のことを行っています。

  1. 消去対象のぷよをボード上から消去するeraseBoardsメソッドを実装
  2. 消去後に上にあるぷよを落下させるfallメソッドを実装

落下処理では、下から上に向かって各列を処理し、ぷよがある場合はその下が空いていれば落下させます。これを繰り返すことで、すべてのぷよが適切な位置に落下します。

実装: ゲームループとの統合

ゲームループに消去処理を統合します。Game クラスの update メソッドを修正して、消去判定と消去処理を組み込みます。

// src/game.ts(update メソッドの一部)
private update(deltaTime: number): void {
    this.frame++

    // モードに応じた処理
    switch (this.mode) {
    // ... 他のケース ...

case 'checkFall':
    // 重力を適用
    const hasFallen = this.stage.applyGravity()
    if (hasFallen) {
        // ぷよが落下した場合、falling モードへ
        this.mode = 'falling'
    } else {
        // 落下するぷよがない場合、消去チェックへ
        this.mode = 'checkErase'
    }
    break

case 'falling':
    // 落下アニメーション用(一定フレーム待機)
    // 簡略化のため、すぐに checkFall に戻る
    this.mode = 'checkFall'
    break

case 'checkErase':
    // 消去判定
    const eraseInfo = this.stage.checkErase()
    if (eraseInfo.erasePuyoCount > 0) {
        // 消去対象がある場合、消去処理へ
        this.stage.eraseBoards(eraseInfo.eraseInfo)
        this.mode = 'erasing'
    } else {
        // 消去対象がない場合、次のぷよを出す
        this.mode = 'newPuyo'
    }
    break

case 'erasing':
    // 消去アニメーション用(一定フレーム待機)
    // 簡略化のため、すぐに checkFall に戻る(消去後の重力適用)
    this.mode = 'checkFall'
    break
}
}

「ゲームの流れがどう変わったんですか?」良い質問ですね!ゲームフローは以下のように拡張されました:

新しいゲームフロー:

newPuyo → playing → (着地) → checkFall → (重力適用) →
  ├─ 落下した → falling → checkFall
  └─ 落下なし → checkErase →
      ├─ 消去あり → erasing → checkFall(消去後の重力適用)
      └─ 消去なし → newPuyo

このフローにより、以下が実現されます:

  1. 着地後の重力適用: ぷよが着地したら、まず重力を適用して浮いているぷよを落とす
  2. 消去判定: 重力適用後、落下するぷよがなくなったら消去判定
  3. 消去処理: 4つ以上つながったぷよがあれば消去
  4. 消去後の重力適用: 消去後、再び重力を適用(これが連鎖の基礎になる)

イテレーション6のまとめ

このイテレーションでは、ぷよの消去機能を実装しました。以下がイテレーション 6 で実施した内容のまとめです:

  1. 接続判定機能

    • EraseInfo インターフェース:消去対象の情報を表現
    • checkErase メソッド:4つ以上つながったぷよを検出
    • searchConnectedPuyo メソッド:深さ優先探索で接続ぷよを探索
  2. 消去処理機能

    • eraseBoards メソッド:消去対象のぷよを削除
  3. ゲームループとの統合

    • checkErase モード:消去判定を実行
    • erasing モード:消去アニメーション後、重力チェックへ
    • ゲームフローの拡張:着地 → 重力 → 消去 → 重力 → 次のぷよ
  4. テストの作成

    • ぷよの接続判定(3テスト)
      • 同じ色のぷよが4つつながっていると、消去対象になる
      • 異なる色のぷよは消去対象にならない
      • 3つ以下のつながりは消去対象にならない
    • ぷよの消去処理(2テスト)
      • 消去対象のぷよを消去する
      • 消去後、上にあるぷよが落下する
    • ゲームループ統合(1テスト)
      • 4つ以上つながったぷよは消去される
    • 合計39テストすべて成功
  5. 学んだ重要な概念

    • 深さ優先探索(DFS)アルゴリズム
    • 再帰的な探索処理
    • ゲームモードによる状態管理
    • 消去と重力の連携処理

このイテレーションにより、同じ色のぷよを4つ以上つなげると消去できるようになり、ぷよぷよの基本的なゲームルールが実装できました。次のイテレーションでは、連鎖反応を実装していきます!

イテレーション7: 連鎖反応の実装

「ぷよを消せるようになったけど、ぷよぷよの醍醐味は連鎖じゃないですか?」そうですね!ぷよぷよの最も魅力的な要素の一つは、連鎖反応です。ぷよが消えて落下した結果、新たな消去パターンが生まれ、連続して消去が発生する「連鎖」を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、連鎖反応を起こしてより高いスコアを獲得できる

「れ〜んさ〜ん!」と叫びたくなるような連鎖反応を実装して、プレイヤーがより高いスコアを目指せるようにしましょう。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「連鎖反応を実装する」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • 連鎖判定を実装する(ぷよが消えた後に新たな消去パターンがあるかを判定する)
  • 連鎖カウントを実装する(何連鎖目かをカウントする)
  • 連鎖ボーナスの計算を実装する(連鎖数に応じたボーナス点を計算する)
  • スコア表示を実装する(プレイヤーに現在のスコアを表示する)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: 連鎖判定

「最初に何をテストすればいいんでしょうか?」まずは、連鎖判定をテストしましょう。ぷよが消えて落下した後に、新たな消去パターンが発生するかどうかを判定する機能が必要です。

// src/tests/game.test.ts(続き)
describe('連鎖反応', () => {
    beforeEach(() => {
        game.initialize();
    });

    it('ぷよの消去と落下後、新たな消去パターンがあれば連鎖が発生する', () => {
        // ゲームのステージにぷよを配置
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 0 0 0 0
        // 0 0 2 0 0 0
        // 0 0 2 0 0 0
        // 0 0 2 0 0 0
        // 0 1 1 2 0 0
        // 0 1 1 0 0 0
        const stage = game['stage'];
        stage.setPuyo(1, 10, 1);
        stage.setPuyo(2, 10, 1);
        stage.setPuyo(1, 11, 1);
        stage.setPuyo(2, 11, 1);
        stage.setPuyo(3, 10, 2);
        stage.setPuyo(2, 7, 2);
        stage.setPuyo(2, 8, 2);
        stage.setPuyo(2, 9, 2);

        // 消去判定
        const eraseInfo = stage.checkErase();

        // 消去実行
        stage.eraseBoards(eraseInfo.eraseInfo);

        // 落下処理
        stage.fall();

        // 連鎖判定
        const chainEraseInfo = stage.checkErase();

        // 連鎖が発生していることを確認
        expect(chainEraseInfo.erasePuyoCount).toBeGreaterThan(0);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下のシナリオを確認しています:

  1. まず、特定のパターンでぷよを配置します(赤ぷよの2×2の正方形と、その上に青ぷよが縦に3つ並んでいる状態)
  2. 最初の消去判定で赤ぷよの正方形が消えます
  3. 消去後に落下処理を行うと、上にあった青ぷよが落下します
  4. 落下した結果、新たに青ぷよが4つつながり、連鎖が発生することを確認します

「なるほど、連鎖の仕組みがテストで表現されているんですね!」そうです!このテストは、ぷよぷよの連鎖の基本的な仕組みを表現しています。では、このテストが通るように実装していきましょう。

実装: 連鎖判定

「既存のゲームループを見てみると、実は連鎖反応は既に実装されています!」そうなんです。イテレーション6で実装したゲームループの仕組みが、そのまま連鎖反応を実現しているんです。

「え?本当ですか?」はい。ゲームループの実装を見てみましょう:

// app/typescript-3/src/game.ts の update メソッド
private update(deltaTime: number): void {
    this.frame++

    // モードに応じた処理
    switch (this.mode) {
case 'newPuyo':
    // 新しいぷよを作成
    this.player.createNewPuyo()
    this.mode = 'playing'
    break

case 'playing':
    // プレイ中の処理(キー入力と自由落下)
    this.player.updateWithDelta(deltaTime)

    // 着地したら重力チェックに移行
    if (this.player.hasLanded()) {
        this.mode = 'checkFall'
    }
    break

case 'checkFall':
    // 重力を適用
    const hasFallen = this.stage.applyGravity()
    if (hasFallen) {
        // ぷよが落下した場合、falling モードへ
        this.mode = 'falling'
    } else {
        // 落下するぷよがない場合、消去チェックへ
        this.mode = 'checkErase'
    }
    break

case 'falling':
    // 落下アニメーション用(一定フレーム待機)
    // 簡略化のため、すぐに checkFall に戻る
    this.mode = 'checkFall'
    break

case 'checkErase':
    // 消去判定
    const eraseInfo = this.stage.checkErase()
    if (eraseInfo.erasePuyoCount > 0) {
        // 消去対象がある場合、消去処理へ
        this.stage.eraseBoards(eraseInfo.eraseInfo)
        this.mode = 'erasing'
    } else {
        // 消去対象がない場合、次のぷよを出す
        this.mode = 'newPuyo'
    }
    break

case 'erasing':
    // 消去アニメーション用(一定フレーム待機)
    // 簡略化のため、すぐに checkFall に戻る(消去後の重力適用)
    this.mode = 'checkFall'
    break
}
}

「このゲームループの何が連鎖反応を実現しているんですか?」重要なのは、erasing モード後に checkFall に戻るという点です。連鎖が発生する流れを見てみましょう:

連鎖の流れ

  1. 1回目の消去

    checkErase → ぷよが消去される → erasing → checkFall
    
  2. 重力適用

    checkFall → 上のぷよが落下 → falling → checkFall → 落下完了 → checkErase
    
  3. 2回目の消去(連鎖)

    checkErase → 落下後に新しい消去パターン発見 → erasing → checkFall
    
  4. 連鎖終了

    checkFall → 落下なし → checkErase → 消去なし → newPuyo
    

「つまり、erasing → checkFall → checkErase のサイクルが連鎖を作っているんですね!」そのとおりです!このサイクルが、消去対象がなくなるまで繰り返されることで、連鎖反応が実現されています。

テスト: 連鎖反応の確認

では、テストを見てみましょう:

// app/typescript-3/tests/game.test.ts
describe('連鎖反応', () => {
    it('ぷよの消去と落下後、新たな消去パターンがあれば連鎖が発生する', () => {
        // 初期化
        game.initialize()

        // ゲームのステージにぷよを配置
        // 赤ぷよの2×2と、その上に青ぷよが縦に3つ、さらに青ぷよが1つ横に
        const stage = game['stage']
        stage.setPuyo(1, 10, 1) // 赤
        stage.setPuyo(2, 10, 1) // 赤
        stage.setPuyo(1, 11, 1) // 赤
        stage.setPuyo(2, 11, 1) // 赤
        stage.setPuyo(3, 10, 2) // 青(横)
        stage.setPuyo(2, 7, 2) // 青(上)
        stage.setPuyo(2, 8, 2) // 青(上)
        stage.setPuyo(2, 9, 2) // 青(上)

        // checkErase モードに設定
        game['mode'] = 'checkErase'

        // 1回目の消去判定と消去実行
        game['update'](0) // checkErase → erasing
        expect(game['mode']).toBe('erasing')

        // 消去後の重力チェック
        game['update'](0) // erasing → checkFall
        expect(game['mode']).toBe('checkFall')

        // 重力適用(青ぷよが落下)
        game['update'](0) // checkFall → falling(落下あり)
        expect(game['mode']).toBe('falling')

        // 落下アニメーション
        game['update'](0) // falling → checkFall
        expect(game['mode']).toBe('checkFall')

        // 落下完了まで繰り返し
        let iterations = 0
        while (game['mode'] !== 'checkErase' && iterations < 20) {
            game['update'](0)
            iterations++
        }

        // checkErase モードに到達している
        expect(game['mode']).toBe('checkErase')

        // 2回目の消去判定(連鎖)
        const chainEraseInfo = stage.checkErase()

        // 連鎖が発生していることを確認(青ぷよが4つつながっている)
        expect(chainEraseInfo.erasePuyoCount).toBeGreaterThan(0)
    })
})

「このテストは何を確認しているんですか?」このテストでは以下を確認しています:

  1. 初期配置:赤ぷよ4つ(2×2)と青ぷよ4つ(縦3 + 横1)を配置
  2. 1回目の消去:赤ぷよが消える
  3. 重力適用:青ぷよが落下
  4. 2回目の消去:落下した青ぷよが4つつながり、連鎖が発生

「テストは通ったんですか?」はい!既存の実装で40テスト全てパスしました。イテレーション6で実装したゲームループが、自然に連鎖反応を実現していたんです。

イテレーション7のまとめ

このイテレーションでは、以下を学びました:

  1. 連鎖反応はゲームループの構造から生まれる

    • erasing → checkFall → checkErase のサイクルが連鎖を実現
    • 消去対象がなくなるまで自動的に繰り返される
  2. テストファースト開発の利点

    • テストを先に書くことで、既存実装の動作を確認できた
    • 新しいコードを追加せずに、テストだけで機能の動作を検証
  3. シンプルな設計の力

    • 複雑な連鎖ロジックを追加せずに、既存の状態遷移だけで実現
    • 各モードが単一責任を持つことで、組み合わせが自然に連鎖を生む

次のイテレーションでは、連鎖カウント機能やスコア計算を実装して、連鎖の面白さをさらに高めていきます!

イテレーション8: 全消しボーナスの実装

「連鎖ができるようになったけど、ぷよぷよには全消しボーナスもありますよね?」そうですね!ぷよぷよには、盤面上のぷよをすべて消すと得られる「全消し(ぜんけし)ボーナス」という特別な報酬があります。今回は、その全消しボーナスを実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、盤面上のぷよをすべて消したときに全消しボーナスを獲得できる

「やった!全部消えた!」という達成感と共に、特別なボーナスポイントを獲得できる機能を実装します。これにより、プレイヤーは全消しを狙った戦略を考えるようになりますね。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「全消しボーナスを実装する」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • 全消し判定を実装する(盤面上のぷよがすべて消えたかどうかを判定する)
  • 全消しボーナスの計算を実装する(全消し時に加算するボーナス点を計算する)
  • 全消し演出を実装する(全消し時に特別な演出を表示する)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: 全消し判定

「最初に何をテストすればいいんでしょうか?」まずは、全消し判定をテストしましょう。盤面上のぷよがすべて消えたかどうかを判定する機能が必要です。

// src/tests/stage.test.ts(続き)
describe('全消し判定', () => {
    it('盤面上のぷよがすべて消えると全消しになる', () => {
        // ステージにぷよを配置
        stage.setPuyo(1, 10, 1);
        stage.setPuyo(2, 10, 1);
        stage.setPuyo(1, 11, 1);
        stage.setPuyo(2, 11, 1);

        // 消去判定と実行
        const eraseInfo = stage.checkErase();
        stage.eraseBoards(eraseInfo.eraseInfo);

        // 全消し判定
        const isZenkeshi = stage.checkZenkeshi();

        // 全消しになっていることを確認
        expect(isZenkeshi).toBe(true);
    });

    it('盤面上にぷよが残っていると全消しにならない', () => {
        // ステージにぷよを配置
        stage.setPuyo(1, 10, 1);
        stage.setPuyo(2, 10, 1);
        stage.setPuyo(1, 11, 1);
        stage.setPuyo(2, 11, 1);
        stage.setPuyo(3, 11, 2); // 消えないぷよ

        // 消去判定と実行
        const eraseInfo = stage.checkErase();
        stage.eraseBoards(eraseInfo.eraseInfo);

        // 全消し判定
        const isZenkeshi = stage.checkZenkeshi();

        // 全消しになっていないことを確認
        expect(isZenkeshi).toBe(false);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、以下の2つのケースを確認しています:

  1. 盤面上のぷよがすべて消えた場合、全消しと判定されるか
  2. 盤面上にぷよが残っている場合、全消しと判定されないか

「最初のテストでは、2×2の正方形に赤ぷよを配置して、それらが消えた後に全消しになるんですね?」そうです!最初のテストでは、2×2の正方形に赤ぷよを配置し、それらが消去された後に盤面が空になるので、全消しと判定されるはずです。

「2つ目のテストでは、消えないぷよが残るようにしているんですね?」その通りです!2つ目のテストでは、2×2の正方形に赤ぷよを配置した上で、別の場所に青ぷよを1つ配置しています。赤ぷよは消えますが、青ぷよは消えないので、全消しにはならないはずです。

「なるほど、全消し判定の条件がよく分かりますね!」では、このテストが通るように実装していきましょう。

実装: 全消し判定

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、全消し判定を実装していきましょう。

// src/stage.ts(続き)
checkZenkeshi(): boolean {
    // 盤面上にぷよがあるかチェック
    for (let y = 0; y < this.config.stageRows; y++) {
        for (let x = 0; x < this.config.stageCols; x++) {
            if (this.board[y][x] !== 0) {
                return false;
            }
        }
    }
    return true;
}

「シンプルですね!」そうですね。全消し判定の実装自体はとてもシンプルです。盤面上のすべてのマスを順番にチェックし、ぷよがある(値が0でない)マスが見つかった時点でfalseを返します。すべてのマスをチェックして、ぷよが見つからなければtrueを返します。

「二重ループを使って、すべてのマスをチェックしているんですね!」その通りです!外側のループで行(y座標)を、内側のループで列(x座標)を順番にチェックしています。これにより、盤面上のすべてのマスを効率的にチェックできます。

解説: 全消し判定

全消し判定では、以下のことを行っています:

  1. 盤面上のすべてのマスをチェック
  2. ぷよがある(値が0でない)マスがあれば全消しではない
  3. すべてのマスが空(値が0)であれば全消し

「全消し判定はいつ行われるんですか?」良い質問ですね!全消し判定は、ぷよの消去処理後に行われます。ぷよが消えた後、盤面上にぷよが残っていないかをチェックするんです。

「テストは通りましたか?」はい、これでテストは通るはずです!次は、全消しボーナスの計算を実装していきましょう。

テスト: 全消しボーナス

「全消しボーナスはどのようにテストすればいいですか?」全消しボーナスは、全消し時に特別なボーナス点が加算されることをテストする必要があります。

// src/tests/score.test.ts(続き)
describe('全消しボーナス', () => {
    it('全消しするとボーナスが加算される', () => {
        // 通常のスコア加算
        score.addScore(4, 1);
        const normalScore = score.getScore();

        // 全消しボーナス加算
        score.addZenkeshiBonus();

        // 全消しボーナスが加算されていることを確認
        expect(score.getScore()).toBeGreaterThan(normalScore);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、全消しボーナスが加算されると、スコアが増加することを確認しています。具体的には:

  1. まず通常のスコア加算を行い、現在のスコアを記録します
  2. 次に全消しボーナスを加算します
  3. 最後に、スコアが増加していることを確認します

このテストでは、全消しボーナスが正しく加算されることをテストしています。

実装: 全消しボーナス

テストが失敗することを確認したら、テストが通るように最小限のコードを実装します。

// src/score.ts(続き)
addZenkeshiBonus(): void {
    // 全消しボーナス(固定値)
    const zenkeshiBonus = 3600;
    this.score += zenkeshiBonus;

    // スコア表示の更新
    this.draw();
}

解説: 全消しボーナス

全消しボーナスでは、以下のことを行っています。

  1. 全消しボーナスの計算(固定値3600点)
  2. スコアへの加算
  3. スコア表示の更新

全消しボーナスは、盤面上のぷよをすべて消去した場合に加算される特別なボーナスです。これにより、プレイヤーは全消しを狙った戦略を考えるようになります。

実装: ゲームループとの統合

「全消しボーナスはいつ判定されるんですか?」良い質問ですね!全消しボーナスは、ゲームループのcheckEraseモードで消去対象がない場合に判定されます。

// app/typescript-3/src/game.ts の update メソッド(抜粋)
case 'checkErase':
// 消去判定
const eraseInfo = this.stage.checkErase()
if (eraseInfo.erasePuyoCount > 0) {
    // 消去対象がある場合、消去処理へ
    this.stage.eraseBoards(eraseInfo.eraseInfo)
    this.mode = 'erasing'
} else {
    // 消去対象がない場合、全消し判定
    if (this.stage.checkZenkeshi()) {
        // 全消しボーナスを加算
        this.score.addZenkeshiBonus()
    }
    // 次のぷよを出す
    this.mode = 'newPuyo'
}
break

「なるほど!消去するぷよがなくなったタイミングで全消しチェックをするんですね。」そのとおりです。ゲームの流れは以下のようになります:

  1. ぷよが着地 → checkFall
  2. 重力適用 → fallingcheckFall(繰り返し)
  3. 落下なし → checkErase
  4. 消去判定:
    • 消去あり → erasingcheckFall(重力再適用)
    • 消去なし → 全消し判定 → newPuyo

「全消しボーナスの統合テストも追加したんですか?」はい、ゲームループ全体が正しく動作することを確認するテストを追加しました。

// app/typescript-3/tests/game.test.ts
describe('全消しボーナス', () => {
    it('盤面上のぷよをすべて消すと全消しボーナスが加算される', () => {
        // 初期化
        game.initialize()

        const stage = game['stage']
        const score = game['score']

        // 初期スコア確認
        const initialScore = score.getScore()

        // 盤面に4つのぷよを配置(すべて消去される)
        stage.setPuyo(1, 10, 1)
        stage.setPuyo(2, 10, 1)
        stage.setPuyo(1, 11, 1)
        stage.setPuyo(2, 11, 1)

        // checkErase モードに設定
        game['mode'] = 'checkErase'

        // 消去判定と処理
        game['update'](0) // checkErase → erasing(消去実行)

        // 消去後の重力チェック
        game['update'](0) // erasing → checkFall

        // 重力適用(落下なし)
        game['update'](0) // checkFall → checkErase

        // 2回目の消去判定(全消し判定が実行される)
        game['update'](0) // checkErase → newPuyo(全消しボーナス加算)

        // スコアが増加していることを確認
        expect(score.getScore()).toBeGreaterThan(initialScore)
        expect(score.getScore()).toBe(3600) // 全消しボーナスのみ
    })
})

「このテストでは、4つのぷよを消去して全消しになるシナリオを確認しているんですね!」そうです。全消しボーナスが正しく加算されることと、スコアが3600点になることを検証しています。

イテレーション8のまとめ

このイテレーションでは、以下を学びました:

  1. 全消し判定の実装

    • 二重ループでフィールド全体をスキャン
    • ぷよが1つでも残っていれば全消しではない
    • シンプルなロジックで確実な判定を実現
  2. 全消しボーナスの設計

    • 固定値(3600点)のボーナスを付与
    • プレイヤーに特別な達成感を与える仕組み
    • 戦略的な深みを追加
  3. ゲームループとの統合

    • checkEraseモードで消去対象がない場合に全消し判定
    • 既存の状態遷移に自然に組み込む
    • 統合テストでエンドツーエンドの動作を確認
  4. テスト駆動開発の継続

    • 全消しになるケースとならないケースの両方をテスト
    • 境界条件(空の盤面、1つだけ残る)を確認
    • 実装前にテストで仕様を明確化
    • 統合テストで全体の動作を保証

このイテレーションで、全消しボーナスという特別な報酬システムが実装できました。次のイテレーションでは、ゲームの終了条件となるゲームオーバー判定を実装していきます!

イテレーション9: ゲームオーバーの実装

「ゲームが終わる条件も必要ですよね?」そうですね!どんなゲームにも終わりがあります。ぷよぷよでは、新しいぷよを配置できなくなったときにゲームオーバーとなります。今回は、そのゲームオーバー判定と演出を実装していきましょう!

ユーザーストーリー

まずは、このイテレーションで実装するユーザーストーリーを確認しましょう:

プレイヤーとして、ゲームオーバーになるとゲーム終了の演出を見ることができる

「ゲームが終わったことが明確に分かるといいですね!」そうですね。ゲームの終わりが明確でないと、プレイヤーはモヤモヤした気持ちになってしまいます。ゲームオーバーになったことを明確に伝え、適切な演出を行うことで、プレイヤーに達成感や次回への意欲を持ってもらうことができます。

TODOリスト

「どんな作業が必要になりますか?」このユーザーストーリーを実現するために、TODOリストを作成してみましょう。

「ゲームオーバーを実装する」という機能を実現するためには、以下のようなタスクが必要そうですね:

  • ゲームオーバー判定を実装する(新しいぷよを配置できない状態を検出する)
  • ゲームオーバー演出を実装する(ゲームオーバー時に特別な表示や効果を追加する)
  • リスタート機能を実装する(ゲームオーバー後に新しいゲームを始められるようにする)

「なるほど、順番に実装していけばいいんですね!」そうです、一つずつ進めていきましょう。テスト駆動開発の流れに沿って、まずはテストから書いていきますよ。

テスト: ゲームオーバー判定

「最初に何をテストすればいいんでしょうか?」まずは、ゲームオーバー判定をテストしましょう。新しいぷよを配置できない状態を検出する機能が必要です。

// src/tests/game.test.ts(続き)
describe('ゲームオーバー', () => {
    beforeEach(() => {
        game.initialize();
    });

    it('新しいぷよを配置できない場合、ゲームオーバーになる', () => {
        // ステージの上部にぷよを配置
        const stage = game['stage'];
        stage.setPuyo(2, 0, 1);
        stage.setPuyo(2, 1, 1);

        // 新しいぷよの生成(通常は中央上部に配置される)
        game['player'].createNewPuyo();

        // ゲームオーバー判定
        const isGameOver = game['player'].checkGameOver();

        // ゲームオーバーになっていることを確認
        expect(isGameOver).toBe(true);
    });
});

「このテストでは何を確認しているんですか?」このテストでは、新しいぷよを配置できない状態がゲームオーバーと判定されるかを確認しています。具体的には:

  1. ステージの上部(新しいぷよが配置される位置)にぷよを配置します
  2. 新しいぷよを生成します
  3. ゲームオーバー判定を行い、ゲームオーバーになっていることを確認します

「なるほど、新しいぷよの配置位置にすでにぷよがあると、ゲームオーバーになるんですね!」そうです!ぷよぷよでは、新しいぷよを配置する位置(通常はステージの中央上部)にすでにぷよがある場合、これ以上ゲームを続行できないため、ゲームオーバーとなります。では、このテストが通るように実装していきましょう。

実装: ゲームオーバー判定

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、ゲームオーバー判定を実装していきましょう。

// src/player.ts(続き)
checkGameOver(): boolean {
    if (!this.stage) return false;

    // 新しいぷよの配置位置にすでにぷよがあるかチェック
    const nextPos = this.getNextPuyoPosition();

    // 軸ぷよまたは2つ目のぷよの位置にぷよがあればゲームオーバー
    // getPuyo() は範囲外の場合 -1 を返すので、> 0 でチェック
    return (
      this.stage.getPuyo(this.puyoX, this.puyoY) > 0 ||
      this.stage.getPuyo(nextPos.x, nextPos.y) > 0
    );
}

「シンプルですね!」そうですね。ゲームオーバー判定の実装自体はとてもシンプルです。createNewPuyo() で設定された軸ぷよの位置(puyoX, puyoY)と、回転状態に基づいて計算された2つ目のぷよの位置(nextPos)にすでにぷよがあるかどうかをチェックしています。

「なぜ > 0 でチェックしているんですか?」重要な質問ですね!stage.getPuyo() は範囲外の座標を渡されると -1 を返します。初期状態では、軸ぷよは y = 0、2つ目のぷよは上向き(rotation = 0)なので y = -1(画面外)になります。もし !== 0 でチェックすると、範囲外(-1)もゲームオーバーと誤判定してしまうため、> 0(実際にぷよがある)でチェックする必要があります。

解説: ゲームオーバー判定

ゲームオーバー判定では、以下のことを行っています:

  1. 新しいぷよの配置位置(中央上部)を確認
  2. その位置にすでにぷよがある場合、ゲームオーバーと判定

「ゲームオーバー判定はいつ行われるんですか?」良い質問ですね!ゲームオーバー判定は、新しいぷよを生成するタイミングで行われます。新しいぷよを生成しようとしたときに、配置位置にすでにぷよがあれば、ゲームオーバーとなります。

「テストは通りましたか?」はい、これでテストは通るはずです!次は、ゲームオーバー演出を実装していきましょう。

テスト: ゲームオーバー演出

「ゲームオーバー演出はどのようにテストすればいいですか?」ゲームオーバー演出は、ゲームの状態(モード)が変わることをテストするといいでしょう。

// src/tests/game.test.ts(続き)
it('ゲームオーバーになると、ゲームモードがgameOverに変わる', () => {
    // ステージの上部にぷよを配置
    const stage = game['stage'];
    stage.setPuyo(2, 0, 1);
    stage.setPuyo(2, 1, 1);

    // ゲームモードを設定
    game['mode'] = 'newPuyo';

    // ゲームループを実行
    game.loop();

    // ゲームモードがgameOverになっていることを確認
    expect(game['mode']).toBe('gameOver');
});

「このテストでは何を確認しているんですか?」このテストでは、ゲームオーバー条件が満たされた場合に、ゲームの状態(モード)がgameOverに変わることを確認しています。具体的には:

  1. ステージの上部にぷよを配置して、ゲームオーバー条件を作ります
  2. ゲームモードをnewPuyo(新しいぷよを生成するモード)に設定します
  3. ゲームループを実行します
  4. ゲームモードがgameOverに変わっていることを確認します

「なるほど、ゲームの状態遷移をテストしているんですね!」そうです!ゲームオーバーになると、ゲームの状態が変わり、それに応じた演出が行われるようになります。では、このテストが通るように実装していきましょう。

実装: ゲームオーバー演出

「テストが失敗することを確認したら、実装に進みましょう!」そうですね。では、ゲームオーバー演出を実装していきましょう。

// src/game.ts(続き)
loop(): void {
    // フレームカウントを更新
    this.frame++;

    // ゲームの状態に応じた処理
    switch (this.mode) {
    // 省略...

case 'newPuyo':
    // 新しいぷよの生成
    this.player.createNewPuyo();

    // ゲームオーバー判定
    if (this.player.checkGameOver()) {
        this.mode = 'gameOver';
    } else {
        this.mode = 'playing';
    }
    break;

case 'playing':
    // プレイヤーの操作
    this.player.control();
    break;

case 'gameOver':
    // ゲームオーバー演出
    this.drawGameOver();
    break;
}

// 画面の更新
this.stage.draw();
this.player.draw();
this.score.draw();

// 次のフレームの処理を予約
requestAnimationFrame(this.loop.bind(this));
}

private drawGameOver(): void {
    // ゲームオーバー表示
    const stageElement = document.getElementById('stage')!;
    const gameOverElement = document.createElement('div');
    gameOverElement.id = 'gameOver';
    gameOverElement.textContent = 'GAME OVER';
    gameOverElement.style.position = 'absolute';
    gameOverElement.style.top = '50%';
    gameOverElement.style.left = '50%';
    gameOverElement.style.transform = 'translate(-50%, -50%)';
    gameOverElement.style.color = 'red';
    gameOverElement.style.fontSize = '36px';
    gameOverElement.style.fontWeight = 'bold';

    // すでにゲームオーバー表示がある場合は追加しない
    if (!document.getElementById('gameOver')) {
    stageElement.appendChild(gameOverElement);
}
}

解説: ゲームオーバー演出

ゲームオーバー演出では、以下のことを行っています。

  1. ゲームループ内でゲームオーバー判定を行い、条件が満たされた場合はゲームモードをgameOverに変更
  2. gameOverモードでは、ゲームオーバー表示を画面に追加

ゲームオーバー表示は、ステージの中央に「GAME OVER」というテキストを表示します。これにより、プレイヤーはゲームが終了したことを視覚的に認識できます。

実装: ゲームループとの統合

「ゲームオーバー判定はいつ行われるんですか?」良い質問ですね!ゲームオーバー判定は、新しいぷよを生成するタイミングで行われます。具体的には、newPuyo モードで新しいぷよを作成した直後にチェックします。

まず、ゲームオーバー判定のロジックを handleNewPuyo() メソッドに統合しました:

// src/game.ts
private handleNewPuyo(): void {
    // 新しいぷよを作成
    this.player.createNewPuyo()

    // ゲームオーバー判定
    if (this.player.checkGameOver()) {
    this.mode = 'gameOver'
} else {
    this.mode = 'playing'
}
}

「なるほど、新しいぷよを作った直後にチェックするんですね!」そうです。新しいぷよを配置しようとした時点で、配置位置にすでにぷよがあれば、それ以上ゲームを続けられないと判断します。

次に、update() メソッドの newPuyo ケースでこのメソッドを呼び出します:

private update(deltaTime: number): void {
    this.frame++

    switch (this.mode) {
case 'newPuyo':
    this.handleNewPuyo()
    break

    // ... 他のケース

case 'gameOver':
    // ゲームオーバー演出
    this.drawGameOver()
    break
}
}

ゲームオーバー演出の実装

drawGameOver() はどのように実装されているんですか?」ゲームオーバー演出は非常にシンプルです。HTML にあらかじめ用意されている gameOver 要素を表示するだけです:

private drawGameOver(): void {
    // ゲームオーバー表示
    const gameOverElement = document.getElementById('gameOver')
    if (!gameOverElement) return

// ゲームオーバー要素を表示
gameOverElement.style.display = 'block'
}

「なぜ新しく要素を作らないんですか?」良い質問です。HTML 側で既にスタイル設定済みの要素を用意しておくことで、JavaScript 側のコードをシンプルに保つことができます。また、デザインの変更も HTML/CSS 側で簡単に行えます。

HTML 側では以下のように定義されています:

<div
        id="gameOver"
        style="
    display: none;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: red;
    font-size: 48px;
    font-weight: bold;
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
    z-index: 1000;
  "
>
    GAME OVER
</div>

position: fixedtransform: translate(-50%, -50%) で画面中央に配置しているんですね!」その通りです。この組み合わせは CSS でよく使われるセンタリングテクニックです。

コード品質の改善

実装中に update() メソッドの複雑度(cyclomatic complexity)が 11 に達し、ESLint の設定値(10)を超えてしまいました。そこで、各モードの処理を独立したメソッドに分離しました:

  • handleNewPuyo(): 新しいぷよ生成とゲームオーバー判定
  • handleCheckFall(): 重力適用処理
  • handleCheckErase(): 消去判定処理(既存)

「メソッドを分離することで、複雑度が下がったんですね!」そうです。各メソッドが単一の責任を持つようになり、コードの可読性と保守性が向上しました。これは、リファクタリングの基本原則である「メソッド抽出(Extract Method)」パターンです。

テスト結果

実装が完了したら、すべてのテストが通ることを確認しましょう:

npm test

結果:

Test Files  4 passed (4)
     Tests  46 passed (46)

「46 個すべてのテストが通りましたね!」素晴らしいです。これまでのイテレーションで書いたテストも含めて、すべてが正常に動作していることが確認できました。

実装: ユーザーインターフェース

ゲームオーバー機能の実装が完了したので、ユーザーインターフェースをさらに改善していきましょう。

UI 改善 1: スコア・連鎖・次のぷよの表示

まず、ゲームの状態をユーザーに分かりやすく伝えるため、スコア、連鎖数、次のぷよを画面に表示します。

スコア表示の実装 (src/score.ts):

constructor() {
    const element = document.getElementById('score')
    if (!element) {
        throw new Error('Score element not found')
    }
    this.scoreElement = element
    this.draw() // 初期表示
}

draw(): void {
    this.scoreElement.textContent = `スコア: ${this.score}`
}

スコア表示は Score クラスのコンストラクタで初期化し、スコアが更新されるたびに draw() メソッドで表示を更新します。

連鎖表示の実装 (src/game.ts):

private combinationCount: number = 0
private maxChainCount: number = 0
private chainElement!: HTMLElement

private updateChainDisplay(): void {
    if (!this.chainElement) return

if (this.combinationCount > 0) {
    this.chainElement.textContent = `${this.combinationCount}連鎖`
} else {
    this.chainElement.textContent = `最大連鎖: ${this.maxChainCount}`
}
}

連鎖中は現在の連鎖数を表示し、連鎖が終了すると最大連鎖数を表示します。これにより、プレイヤーは現在の連鎖状況と自己ベストを確認できます。

次のぷよ表示の実装 (src/game.ts):

private drawNextPuyo(): void {
    const nextElement = document.getElementById('next')
    if (!nextElement) return

let canvas = nextElement.querySelector('canvas') as HTMLCanvasElement
if (!canvas) {
    canvas = document.createElement('canvas')
    canvas.width = this.config.puyoSize * 2
    canvas.height = this.config.puyoSize * 2
    nextElement.appendChild(canvas)
}

const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)

const nextPuyoPair = this.player.getNextPuyoPair()
if (nextPuyoPair.axis > 0 && nextPuyoPair.sub > 0) {
    this.puyoImage.draw(ctx, nextPuyoPair.axis, 0.5, 1) // 軸ぷよ(下)
    this.puyoImage.draw(ctx, nextPuyoPair.sub, 0.5, 0)   // 連結ぷよ(上)
}
}

次のぷよはペアで表示し、実際のゲームプレイ時の配置(軸ぷよが下、連結ぷよが上)と同じ向きで表示します。

UI 改善 2: スタート・リセットボタンの追加

ゲームの開始とリセットをユーザーが制御できるように、ボタンを追加します。

ボタンの HTML 実装 (index.html):

<button id="startButton" class="button">スタート</button>
<button id="resetButton" class="button reset">リセット</button>

ゲーム状態管理の実装 (src/game.ts):

private isRunning: boolean = false
private animationFrameId: number = 0

start(): void {
    if (this.isRunning) return

const gameOverElement = document.getElementById('gameOver')
if (gameOverElement) {
    gameOverElement.style.display = 'none'
}

this.mode = 'newPuyo'
this.isRunning = true
this.lastTime = performance.now()
this.loop()
}

reset(): void {
    if (this.animationFrameId) {
    cancelAnimationFrame(this.animationFrameId)
}

// 各コンポーネントをリセット
this.stage.clear()
this.score.reset()

// 連鎖カウントをリセット
this.combinationCount = 0
this.maxChainCount = 0
this.updateChainDisplay()

// ゲームオーバー表示を非表示
const gameOverElement = document.getElementById('gameOver')
if (gameOverElement) {
    gameOverElement.style.display = 'none'
}

// 次のぷよ表示をクリア
const nextElement = document.getElementById('next')
if (nextElement) {
    const canvas = nextElement.querySelector('canvas')
    if (canvas) {
        const ctx = canvas.getContext('2d')
        if (ctx) {
            ctx.clearRect(0, 0, canvas.width, canvas.height)
        }
    }
}

// ゲーム状態をリセット
this.mode = 'start'
this.isRunning = false
this.frame = 0

// ステージを再描画(空の状態)
this.stage.draw()
}

リセット機能では、新しいインスタンスを作成するのではなく、既存のコンポーネントをクリアすることで、DOM 要素の重複を防いでいます。

UI 改善 3: キー操作説明の追加

ユーザーがゲームの操作方法を理解しやすいように、キー操作説明をステージの下に配置します。

キー操作説明の実装 (index.html):

<div style="display: flex; flex-direction: column; gap: 10px">
    <div id="stage" style="position: relative"></div>
    <div style="color: white; font-size: 14px; text-align: center">
        <div style="margin-bottom: 5px; font-weight: bold">キー操作</div>
        <div>← → : 移動 | ↑ : 回転 | ↓ : 高速落下</div>
    </div>
</div>

この説明により、初めてゲームをプレイするユーザーでも直感的に操作方法を理解できます。

最終的なレイアウト構成

[ステージエリア]          [情報エリア]
┌─────────────┐        ┌──────────┐
│             │        │ スコア: 0 │
│   ステージ   │        ├──────────┤
│             │        │ 最大連鎖: 0│
└─────────────┘        ├──────────┤
┌─────────────┐        │ 次のぷよ  │
│  キー操作    │        │  [ペア]   │
│ ←→:移動     │        ├──────────┤
│ ↑:回転      │        │[スタート] │
│ ↓:高速落下  │        ├──────────┤
└─────────────┘        │[リセット] │
                       └──────────┘

このレイアウトにより、ゲームプレイに必要な情報が整理され、ユーザーフレンドリーなインターフェースが完成しました。

イテレーション9のまとめ

イテレーション 9 では、以下を実装しました:

  1. ゲームオーバー判定: 新しいぷよの配置位置にぷよがある場合を検出
  2. ゲームループへの統合: newPuyo モードでゲームオーバー判定を実行
  3. ゲームオーバー演出: 画面中央に「GAME OVER」を表示
  4. コード品質の向上: メソッド抽出による複雑度の削減
  5. UI の改善: スコア、連鎖、次のぷよ表示の追加
  6. ゲーム制御: スタート・リセットボタンの実装
  7. ユーザビリティ向上: キー操作説明の追加

「ゲームが終わる条件を実装し、さらにユーザーインターフェースも充実させることができましたね!」そうですね。これで、プレイヤーはゲームの明確な終了を体験でき、直感的に操作できる完成度の高いゲームになりました。

イテレーション10: 衝突判定の修正

発見されたバグ

ゲームをプレイテストした結果、以下の重大なバグが発見されました:

  1. 回転時の衝突判定の欠落

    • 縦にしたぷよ(rotation=2)が既に着地したぷよの位置に回転できてしまう
    • 回転によって既存のぷよを上書きしてしまう
  2. 移動時の衝突判定の欠落

    • 左右に移動する際、既に着地したぷよの位置に移動できてしまう
    • 移動によって既存のぷよを上書きしてしまう

なぜテストで検出されなかったのか

イテレーション 3 で回転機能を実装した際、以下のテストケースしか作成していませんでした:

  • 壁際での回転(壁キック機能のテスト)
  • 回転状態の遷移(0→1→2→3→0)

欠けていたテストケース

  • 既存のぷよがある位置への回転
  • 既存のぷよがある位置への移動

これは TDD における重要な教訓です。「テストされていない機能は動作しない」という原則を再確認できました。

実装内容

1. 回転時の衝突判定(player.ts)

canRotateTo() メソッドを新規追加:

private canRotateTo(nextX: number, nextY: number): boolean {
    if (!this.stage) return false

    // 2つ目のぷよが画面外(y < 0)の場合は範囲チェックをスキップ
    if (nextY < 0) {
        return true
    }

    // 範囲外チェック
    if (
        nextX < 0 ||
        nextX >= this.config.stageCols ||
        nextY >= this.config.stageRows
    ) {
        return false
    }

    // 既存のぷよとの衝突チェック
    if (this.stage.getPuyo(nextX, nextY) > 0) {
        return false
    }

    return true
}

重要な修正点: ゲーム開始直後、軸ぷよが y = 0、上向き(rotation = 0)の場合、2つ目のぷよは y = -1(画面外)になります。この場合、範囲チェックをスキップすることで、ゲーム開始時の回転を正常に動作させます。draw() メソッドでも同様に nextPos.y >= 0 の場合のみ描画しています。

performRotation() メソッドを修正:

private performRotation(newRotation: number): void {
    // 回転後の2つ目のぷよの位置を計算
    const offsetX = [0, 1, 0, -1][newRotation]
    const offsetY = [-1, 0, 1, 0][newRotation]
    let newX = this.puyoX
    const nextX = newX + offsetX
    const nextY = this.puyoY + offsetY

    // 壁キック処理
    if (nextX < 0) {
    newX++
} else if (nextX >= this.config.stageCols) {
    newX--
}

// 回転後の位置を再計算
const finalNextX = newX + offsetX
const finalNextY = this.puyoY + offsetY

// 回転後の位置が有効かチェック
if (!this.canRotateTo(finalNextX, finalNextY)) {
    // 回転できない場合は何もしない
    return
}

// 回転を確定
this.puyoX = newX
this.rotation = newRotation
}

2. 移動時の衝突判定(player.ts)

canMoveTo() メソッドを新規追加:

private canMoveTo(
    axisX: number,
    axisY: number,
    nextX: number,
    nextY: number
): boolean {
    if (!this.stage) return false

    // 軸ぷよの範囲チェック
    if (
        axisX < 0 ||
        axisX >= this.config.stageCols ||
        axisY < 0 ||
        axisY >= this.config.stageRows
    ) {
        return false
    }

    // 2つ目のぷよが画面外(y < 0)の場合は範囲チェックをスキップ
    if (nextY >= 0) {
        // 2つ目のぷよの範囲チェック
        if (
            nextX < 0 ||
            nextX >= this.config.stageCols ||
            nextY >= this.config.stageRows
        ) {
            return false
        }

        // 2つ目のぷよの衝突チェック
        if (this.stage.getPuyo(nextX, nextY) > 0) {
            return false
        }
    }

    // 軸ぷよの衝突チェック
    if (this.stage.getPuyo(axisX, axisY) > 0) {
        return false
    }

    return true
}

重要な修正点: canRotateTo() と同様に、2つ目のぷよが画面外(y < 0)の場合は範囲チェックと衝突チェックをスキップします。これにより、ゲーム開始時の移動が正常に動作します。

moveLeft()moveRight() メソッドを修正:

moveLeft(): void {
    // 移動後の位置を計算
    const newAxisX = this.puyoX - 1

    // 2つ目のぷよの位置を計算
    const offsetX = [0, 1, 0, -1][this.rotation]
    const offsetY = [-1, 0, 1, 0][this.rotation]
    const newNextX = newAxisX + offsetX
    const newNextY = this.puyoY + offsetY

    // 移動可能かチェック
    if (this.canMoveTo(newAxisX, this.puyoY, newNextX, newNextY)) {
    this.puyoX--
}
}

moveRight(): void {
    // 移動後の位置を計算
    const newAxisX = this.puyoX + 1

    // 2つ目のぷよの位置を計算
    const offsetX = [0, 1, 0, -1][this.rotation]
    const offsetY = [-1, 0, 1, 0][this.rotation]
    const newNextX = newAxisX + offsetX
    const newNextY = this.puyoY + offsetY

    // 移動可能かチェック
    if (this.canMoveTo(newAxisX, this.puyoY, newNextX, newNextY)) {
    this.puyoX++
}
}

3. テストケースの追加(player.test.ts)

describe('衝突判定', () => {
    it('既存のぷよがある位置には左に移動できない', () => {
        // 軸ぷよを (3, 5) に配置(上向き rotation=0)
        player['puyoX'] = 3
        player['puyoY'] = 5
        player['rotation'] = 0

        // 左側に障害物を配置
        stage.setPuyo(2, 5, 1)

        // 左に移動を試みる
        player.moveLeft()

        // 移動していないことを確認
        expect(player['puyoX']).toBe(3)
    })

    it('既存のぷよがある位置には右に移動できない', () => {
        // 軸ぷよを (2, 5) に配置(上向き rotation=0)
        player['puyoX'] = 2
        player['puyoY'] = 5
        player['rotation'] = 0

        // 右側に障害物を配置
        stage.setPuyo(3, 5, 1)

        // 右に移動を試みる
        player.moveRight()

        // 移動していないことを確認
        expect(player['puyoX']).toBe(2)
    })

    it('2つ目のぷよの位置に障害物がある場合も移動できない', () => {
        // 軸ぷよを (2, 5) に配置、右向き (rotation=1)
        player['puyoX'] = 2
        player['puyoY'] = 5
        player['rotation'] = 1

        // 2つ目のぷよの右側(3, 5 の右 = 4, 5)に障害物を配置
        stage.setPuyo(4, 5, 1)

        // 右に移動を試みる
        player.moveRight()

        // 移動していないことを確認
        expect(player['puyoX']).toBe(2)
    })

    it('既存のぷよがある位置には回転できない', () => {
        // 軸ぷよを (2, 5) に配置(上向き rotation=0)
        player['puyoX'] = 2
        player['puyoY'] = 5
        player['rotation'] = 0

        // 右側(回転先)に障害物を配置
        stage.setPuyo(3, 5, 1)

        // 現在の回転状態を記録
        const beforeRotation = player['rotation']

        // 右回転を試みる(0 → 1 で右向きになる)
        player.rotateRight()

        // 回転していないことを確認
        expect(player['rotation']).toBe(beforeRotation)
    })

    it('回転後の位置が空いていれば回転できる', () => {
        // 軸ぷよを (2, 5) に配置(上向き rotation=0)
        player['puyoX'] = 2
        player['puyoY'] = 5
        player['rotation'] = 0

        // 右回転を試みる(0 → 1)
        player.rotateRight()

        // 回転していることを確認
        expect(player['rotation']).toBe(1)
    })
})

4. 既存テストの修正

既存の回転・移動テストが画面外のぷよの処理を追加したことで正常に動作するようになりました。

外側の beforeEach()player.setStage(stage) を追加:

beforeEach(() => {
    // DOMの準備
    document.body.innerHTML = `
        <div id="app">
            <div id="stage"></div>
        </div>
    `
    config = new Config()
    puyoImage = new PuyoImage(config)
    stage = new Stage(config, puyoImage)
    player = new Player(config)
    player.setStage(stage) // Stageへの参照を設定
})

衝突判定テストでは、既存のぷよとの衝突を検証するため、テスト内で明示的に位置を設定します(例: player['puyoY'] = 5)。これにより、2つ目のぷよが画面内に配置され、衝突判定が正しく動作することを確認できます。

学んだ教訓

  1. テストカバレッジの重要性

    • 機能を実装したら、その機能の「正常系」だけでなく「異常系」もテストする
    • 境界値テストだけでなく、衝突判定のような制約条件もテストする
  2. プレイテストの重要性

    • 自動テストでカバーできない部分は手動テストで補完
    • 実際にゲームを遊んでみることで、テストケースの漏れに気づける
  3. TDD のサイクルを守る

    • Red(失敗するテストを書く)
    • Green(テストを通す最小限の実装)
    • Refactor(リファクタリング)
    • 重要: テストを書かずに実装すると、バグを見逃す可能性が高い
  4. バグ発見時の対応

    • まずテストを書いてバグを再現
    • テストが失敗することを確認(Red)
    • バグを修正してテストを通す(Green)
    • 同様のバグを防ぐためのテストを追加
  5. 境界条件の扱い

    • ゲーム開始時の画面外のぷよ(y < 0)のような特殊なケースに注意
    • 範囲チェックを実装する際は、すべての境界条件を考慮する
    • draw() メソッドと衝突判定メソッドで一貫した境界条件の扱いを保つ

テスト結果

✓ app/typescript-4/tests/example.test.ts (2)
✓ app/typescript-4/tests/stage.test.ts (6)
✓ app/typescript-4/tests/game.test.ts (6)
✓ app/typescript-4/tests/player.test.ts (19)

Test Files  4 passed (4)
     Tests  33 passed (33)

すべてのテストがパスし、衝突判定のバグが修正されました。player.test.ts に衝突判定テストを5つ追加したことで、テスト総数が28から33に増加しました。

ふりかえり

ぷよぷよゲームの実装を完了することができたので、これまでのふりかえりをしておきましょう。

Keep(続けること)

まず TODOリスト を作成して テストファースト で1つずつ小さなステップで開発を進めていきました。テスト駆動開発の「Red-Green-Refactor」サイクルに従うことで、コードの品質を保ちながら、段階的に機能を追加していくことができました。

各章では、ユーザーストーリーに基づいた機能を、テスト、実装、解説の順に進めていきました。この流れは非常に効果的で、実装の方針が明確になり、必要最小限のコードで機能を実現することができました。

特に以下の点は今後も続けていきたいプラクティスです:

  1. ユーザーストーリーからTODOリストへの分解
  2. テストファーストの原則に従った実装
  3. 小さなステップでの開発
  4. リファクタリングによるコードの継続的な改善

Problem(課題)

開発を進める中で、いくつかの課題も見えてきました。

まず、テストの粒度をどの程度にするかという判断が難しい場面がありました。細かすぎるテストは冗長になり、大きすぎるテストは問題の特定が難しくなります。

また、リファクタリングのタイミングも課題でした。機能追加とリファクタリングのバランスをどう取るか、特に初期段階でのリファクタリングの重要性と、後回しにすることのリスクを実感しました。

ゲームロジックの複雑さが増すにつれて、テストの維持も難しくなりました。特に、ゲームの状態遷移や重力処理のテストは工夫が必要でした。

Try(挑戦)

今後は以下のことに挑戦していきたいと思います:

  1. より効率的なテスト戦略の模索 - ユニットテストとインテグレーションテストのバランスを考慮する
  2. 自動化テストのカバレッジを高める工夫
  3. より複雑なゲーム機能(マルチプレイヤー、ネットワーク対戦など)への挑戦
  4. パフォーマンス最適化とそのテスト手法の習得

これらの知識と経験は、他のプロジェクトにも応用できるものです。テスト駆動開発は、コードの品質を高め、保守性を向上させるための強力な手法です。ぜひ、自分のプロジェクトにも取り入れてみてください。

私がかつて発見した、そして多くの人に気づいてもらいたい効果とは、反復可能な振る舞いを規則にまで還元することで、規則の適用は機会的に反復可能になるということだ。

— Kent Beck 『テスト駆動開発』

アーキテクチャ図解

実装したぷよぷよゲームのアーキテクチャを視覚的に理解するために、状態遷移図、クラス図、シーケンス図を示します。

状態遷移図

ゲームの状態(モード)遷移を示します。

クラス図

各クラスの責務と関係性を示します。

シーケンス図(通常プレイ)

通常のプレイフローを示します。

シーケンス図(連鎖発生時)

連鎖が発生した場合の詳細フローを示します。

これらの図を参照することで、ゲーム全体のアーキテクチャと動作フローが理解しやすくなります。

参考資料

1
2
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
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?