♣︎. はじめに
コンソール画面で操作するブラックジャックゲームを、PHPで作りました。その個人開発の過程を記事にします。
操作した時のコンソール画面はこんな感じです。
(テキストだとこんな感じです)
root@9444efa94780:/var/www/html# php Main.php
ブラックジャックの設定をします。
プレイヤーの人数を入力してください。(1〜3)
🙋 1
プレイヤー1名でゲームを開始します。
あなたの持っているチップは100ドルです。
ベットする額を入力してください。(1〜1000ドル)
💲 10
10ドルをベットしました。
ブラックジャックを開始します。
あなたの引いたカードはハートの6です。
あなたの引いたカードはスペードのQです。
ディーラーの引いたカードはスペードの10です。
ディーラーの引いた2枚目のカードはわかりません。
あなたの現在の得点は16です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 SR
サレンダーを宣言しました。
ベットしたチップ10ドルの半分5ドルを戻してゲームを降ります。
ディーラーの引いた2枚目のカードはダイヤのAでした。
あなたは負けたため、チップ 5 ドルは没収されます。
あなたのチップ残高は 95 ドルです。
ブラックジャックゲームを続けますか?(Y/N)
👉 Y
あなたの持っているチップは95ドルです。
ベットする額を入力してください。(1〜1000ドル)
💲 10
10ドルをベットしました。
ブラックジャックを開始します。
あなたの引いたカードはクラブのAです。
あなたの引いたカードはクラブの3です。
ディーラーの引いたカードはスペードのJです。
ディーラーの引いた2枚目のカードはわかりません。
あなたの現在の得点は14です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 Y
あなたの引いたカードはハートの6です。
あなたの現在の得点は20です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 N
カードを引きません。
ディーラーの引いた2枚目のカードはスペードの7でした。
ディーラーの現在の得点は17です。
カードを引きません。
ディーラーの得点は17です。
あなたの勝ちです!🎉
あなたは勝ったため、チップ 10 ドルと同額の配当を得られます。
あなたのチップ残高は 105 ドルです。
ブラックジャックゲームを続けますか?(Y/N)
👉 Y
あなたの持っているチップは105ドルです。
ベットする額を入力してください。(1〜1000ドル)
💲 105
105ドルをベットしました。
ブラックジャックを開始します。
あなたの引いたカードはスペードのKです。
あなたの引いたカードはクラブの5です。
ディーラーの引いたカードはスペードの9です。
ディーラーの引いた2枚目のカードはわかりません。
あなたの現在の得点は15です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 Y
あなたの引いたカードはハートの2です。
あなたの現在の得点は17です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 N
カードを引きません。
ディーラーの引いた2枚目のカードはクラブの7でした。
ディーラーの現在の得点は16です。
ディーラーの引いたカードはハートの9です。
ディーラーの得点は25です。
合計値が21を超えたので、ディーラーはバーストしました。
あなたの勝ちです!🎉
あなたは勝ったため、チップ 105 ドルと同額の配当を得られます。
あなたのチップ残高は 210 ドルです。
ブラックジャックゲームを続けますか?(Y/N)
👉 Y
あなたの持っているチップは210ドルです。
ベットする額を入力してください。(1〜1000ドル)
💲 210
210ドルをベットしました。
ブラックジャックを開始します。
あなたの引いたカードはクラブの3です。
あなたの引いたカードはダイヤの4です。
ディーラーの引いたカードはダイヤの8です。
ディーラーの引いた2枚目のカードはわかりません。
あなたの現在の得点は7です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 Y
あなたの引いたカードはスペードの4です。
あなたの現在の得点は11です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 Y
あなたの引いたカードはクラブのKです。
あなたの現在の得点は21です。
カードを引きますか?(Y/N / DD/SP/SR)
※ 特殊ルール(DD: ダブルダウン, SP: スプリット, SR: サレンダー)は、最初に手札が配られたときのみ有効
👉 N
カードを引きません。
ディーラーの引いた2枚目のカードはハートの7でした。
ディーラーの現在の得点は15です。
ディーラーの引いたカードはハートの4です。
ディーラーの現在の得点は19です。
カードを引きません。
ディーラーの得点は19です。
あなたの勝ちです!🎉
あなたは勝ったため、チップ 210 ドルと同額の配当を得られます。
あなたのチップ残高は 420 ドルです。
ブラックジャックゲームを続けますか?(Y/N)
👉 N
ブラックジャックを終了します。
ゲームの概略です。
- トランプで遊ぶブラックジャックゲームの基本ルールそのままで遊べる
- チップ(架空の賭け金)をベットして、ゲームに勝ってチップを増やして遊べる
- チップに影響する特殊ルール(ダブルダウン、スプリット、サレンダー)を宣言できる
♣︎. きっかけ
作成しようと思ったきっかけは、Web開発の動画学習教材『独学エンジニア』の課題の一つだったからです。
「PHPとオブジェクト指向」パートの集大成の課題として用意されています。
ちなみに、受講生が自力で臨むために、教材には回答例は用意されていません。
♣︎. 目的
作る目的は、PHP、オブジェクト指向でのコードを書く経験値を得るためです。
現在、私は異業種から転職して9ヶ月経過したところですが、実務でコードを書く機会を得られていません。そのため、いつか来る実務でコードを書く機会に備え、継続的にコードと向き合うための題材として取り組みました。
ちなみに、ブラックジャックゲームの作成にかけた時間としては、ざっと 100 時間はかけています。ブラックジャックに時間をベットして報酬として得られた経験値はとても大きかったと感じています。
♣︎. 環境
- macOS Big Sur バージョン: 11.6
- Visual Studio Code バージョン: 1.70.2
- Docker バージョン: 20.10.14
- PHP バージョン: 8.1.7
※ 言語フレームワークは使いません
♣︎. 目次
記事の前半で、開発過程を振り返り、後半で、開発過程で守るようにしたことを書きます。
♠︎. 開発過程の振り返り
♠︎. 開発の進め方
♠︎. 開発環境の用意
♠︎A. 基本ルールの実装(ステップ1)
♠︎2. カードAのルール追加(ステップ2)
♠︎3. プレイヤー人数設定の追加(ステップ3)
♠︎4. チップ機能、特殊ルールの追加(ステップ4)
♦︎. 開発過程で守るようにしたこと
♦︎A. UML を書く
♦︎2. テストを先に書きながら進める
♦︎3. デバッグしながらコーディングする
♦︎4. 静的解析ツールは全て通る状態にする
♦︎5. 何度もリファクタリングする
♠︎. 開発過程の振り返り
♠︎. 開発の進め方
個人開発なので実装するのは自分だけですが、ブランチ作成、コード変更、プルリク、マージしながら、開発を進めました。
GitHub 上で Public リポジトリとして公開し、今回記事にすることも想定していたため、開発の履歴がなるべく見やすくなるようにブランチを切りながら進めることを心がけました。
今回の記事は、プルリクの履歴をベースに開発プロセスを振り返っていきます。
♠︎. 開発環境の用意
まずは、開発環境を用意しました。
用意すると言っても、一からではなく講師の山浦さんが講義用に作成された Docker 環境(php 8 系, apache )に、静的解析ツールなどのライブラリを追加して使用しました。
♠︎A. 基本ルールの実装(ステップ1)
最初の課題「ステップ1」は以下の内容でした。
◯ステップ1
ディーラーとプレイヤーの2人で対戦するコンソールゲームを作成しましょう。
以下のルールの元、コンソール(ターミナル)上で動作するようにします。
- プレイヤーは実行者、ディーラーはCPUが自動実行する
- 実行開始時、プレイヤーとディーラーはそれぞれ、カードを2枚引く。引いたカードは画面に表示する。ただし、ディーラーの2枚目のカードは分からないようにする
- その後、先にプレイヤーがカードを引く。プレイヤーのカードの合計値が21を超えたらプレイヤーの負け
- プレイヤーはカードを引くたびに次のカードを引くか選択できる
- プレイヤーがカードを引き終えたら、ディーラーは自分のカードの合計値が17以上になるまで引き続ける
- プレイヤーとディーラーが引き終えたら勝負。カードの合計値が21により近い方が勝ち
- Aは1点として取り扱う
コンソール画面のイメージです。
ブラックジャックを開始します。 あなたの引いたカードはハートの7です。 あなたの引いたカードはクラブの8です。 ディーラーの引いたカードはダイヤのQです。 ディーラーの引いた2枚目のカードはわかりません。 あなたの現在の得点は15です。カードを引きますか?(Y/N) Y あなたの引いたカードはスペードの5です。 あなたの現在の得点は20です。カードを引きますか?(Y/N) N ディーラーの引いた2枚目のカードはダイヤの2でした。 ディーラーの現在の得点は12です。 ディーラーの引いたカードはハートのKです。 あなたの得点は20です。 ディーラーの得点は22です。 あなたの勝ちです! ブラックジャックを終了します。
ステップ 1 の実装は、以下の順序で進めました。
- UML の作成
- ゲームスタート部分の実装
- 基本ルールの実装完了
- 静的解析ツールでのリファクタリング
- テストコードの追記
UML の作成
実装前にまずは PlantUML で、ユースケース図、クラス図、シーケンス図を書きました。
ゲームスタート部分の実装
実装については、ここからです。
クラス図をもとにファイル作成し、シーケンス図をもとにゲームをスタートする処理を書き始めました。
この段階で心がけていたことは、 タスクを小さくばらしながら作業を進める ことです。
タスクを分解することで、コードを書き始めるハードルを下げたり、途中で手が止まって時間だけが経過することがないよう、詰まったらタスクばらしをするようにしました。
基本ルールの実装完了
このブランチで、ディーラーとプレイヤーの2人で対戦するコンソールゲームとして、基本的な実装が完成しました。
いきなり良いコードを書こうと気負わず、 まずはコードが汚くてもとりあえず動く状態にしよう というスタンスで進めました。この時点では書いた自分でも読みにくいコードと感じます。
静的解析ツールでのリファクタリング
ステップ1の仕様を満たすよう動くようになったところで、続いて、静的解析ツール(PHP_CodeSniffer, PHPMD, PHPStan)でのリファクタリングをしました。
テストコードの追記
書けていないテストコードを追記しました。
♠︎2. カードAのルール追加(ステップ2)
課題の「ステップ2」は以下の内容でした。
◯ステップ2
Aを1点あるいは11点のどちらかで扱うようにプログラムを修正しましょう。
Aはカードの合計値が21以内で最大となる方で数えるようにします。
カードAのルール追加完了
機能追加として、A(エース)を1点 or 11点の都合の良い方でカウントする変更をしました。
/**
* A の点数については、デフォルト 11 でカウントされており、
* 得点が21点を超えている場合は、 1 でカウントする
*
* @return void
*/
private function calcAceScore(): void
{
for ($i = 0; $i < $this->countAce; $i++) {
if ($this->scoreTotal > 21) {
$this->scoreTotal -= 10;
}
}
}
calcAceScore
メソッドの計算例は以下のとおりです。(「Aと5」→「A」→「7」の場合)
- 最初の手札→「Aと5」
-
scoreTotal
は、16
(11+5)
-
- 1枚引く→「A」
-
scoreTotal
は、27
(11+5+11)- Aの枚数(
countAce
)は2
枚 - 21を超えない範囲で最大になるように、Aは1か11か切り替わる
- for文は2回
- 1回目は21を超えているので27-10=17
- 2回目は21を超えていないので17のまま
-
scoreTotal
は、17
-
- Aの枚数(
-
- もう1枚引く →「7」
-
scoreTotal
は、34
(11+5+11+7) - Aの枚数(
countAce
)は2
枚- for文は2回
- 1回目は21を超えているので34-10=24
- 2回目も21を超えているので24-10=14
-
scoreTotal
は、14
-
- for文は2回
-
♠︎3. プレイヤー人数設定の追加(ステップ3)
課題の「ステップ3」は以下の内容でした。
◯ステップ3
最大3人までのプレイヤーでプレイできるようにしましょう(ディーラーと合わせて合計4人)。増えたプレイヤーはCPUが自動的に操作します。
プレイヤー人数設定の機能追加完了
プレイヤー人数設定の機能を追加しました。
「最大3人までのプレイヤーでプレイできるよう変更する」という機能追加としてはシンプルなもので、動作としては5日間(10hくらい)で完了しましたが、コードの変更がしづらいな…という感触でした。
静的解析ツールでのリファクタリング
静的解析ツール(PHP_CodeSniffer, PHPMD, PHPStan)でのリファクタリングを実施しました。
2回目の静的解析ツールでのリファクタリングですが、 PHPMD での解析で、コードがかなり複雑ですよー、と指摘があったため、大幅に書き換えました。
具体的には、Game
クラスの start
メソッドに処理が集中していたため、ゲームの進行ごとに処理を分散しました。分散した上で、各プレイヤーの行動や、ディーラーの行動はそれぞれのクラスへ処理を移しました。
その他、Message
クラスを作成して、ゲーム中のメッセージをまとめました。
UML の修正
現状のコードと合うように、 UML(クラス図、シーケンス図)を修正しました。
SOLID 原則に則っての修正
クラス図を整理していて、図を見ているだけでも分かりにくい点があると感じたため、SOLID 原則の「単一責任の原則」に則って、 複数の責務を負っているクラスを切り離すにはどうすれば良いかを考えて、コードを修正をしました。
-
Dealer
クラスについては、カードを配る役割、プレイヤーとしての役割、勝敗を判定する役割といった複数の役割を持っている状態であったため、まずはプレイヤーとしての責務を別のクラスを作成して委譲しようとしました。 -
Dealer
クラス、Player
クラス、NonPlayerCharacter
クラスについては、カードをひいて 21 点以内でより高い点数を目指すという行動が同じですが、共通化できていなかったため、その点を修正しました。 - 修正方法としては、
Player
クラスを抽象クラス化し(Template Method パターン)、共通しているプレイヤーとしてのプロパティはまとめました。 - まだ、
Dealer
メソッドは、カードを配る役割、勝敗を判定する役割を持っているため、次のステップ4 実装で修正しました。 - 参考:この時点のクラス図
♠︎4. チップ機能、特殊ルールの追加(ステップ4)
課題の「ステップ4」は以下の内容でした。
◯ステップ4(任意)
ダブルダウン、スプリット、サレンダーのルールを追加しましょう。ルールは各自調べてみてください。
チップ機能、特殊ルールの追加完了
ブラックジャックゲームのことを元々知らなかったため、ルールをそれぞれ調査することから始めました。
調べた結果、チップ(架空の賭け金)をベットして、ゲームに勝ってチップを増やして遊べる要素を追加するところから必要ということがわかりました。ざっくり下記の追加が必要と整理し、実装しました。
- プレイヤーにチップ(賭け金)を持たせる。
- チップをベットする(賭ける)。
- 勝敗に応じて、チップ残高を計算する。
- 特殊ルール(ダブルダウン、スプリット、サレンダー)を追加する。
ダブルダウンとサレンダーの2つは楽に実装できましたが、スプリットの実装が難しかったです。
ちなみにスプリットは、「最初に配られたカードの値が同数の場合、カードを2つに分けてそれぞれ別の手札とすることができる(最初に賭けたチップと同額がさらに必要となる)」というルールです。
ステップ4については、機能としては動いたけど、リファクタリングやテストコードの漏れなど確認できていないため、引き続き作業していきます。
「開発過程の振り返り」は以上です。
♦︎. 開発過程で守るようにしたこと
ブラックジャックゲームを作る過程では、以下のことを守るようにしました。
- UML を書く
- テスト を、先に書きながら進める
- デバッグ しながらコーディングする
- 静的解析ツール は、全て通る状態にする
- 何度も リファクタリング する
♦︎A. UML を書く
スムーズに実装できるよう、設計時に UML を書き、 UML を手直しながら、実装しました。
PlantUML の利用
下記の3つの図を PlantUML で作成しました。
- ユースケース図
- クラス図
- シーケンス図
ブラウザ上でも上記ページ内で UML を編集できますが、 VSCode の拡張機能としてインストールして使用しています。
作るものを明確にするのが、UMLの目的 なので、下記3点に注意しました。
-
完璧を求めない
- 図をきれいに整えることに価値があるわけじゃなくて、図がわかりやすいことで、見た人の理解が捗り開発がスムーズにいくことに価値がある。
-
図を整えるために時間をかけすぎない
- 最初にコードを書き始める前に UML を作成するときは、仕様を明確に図にするのは難しいので、コードを書いていて要件についての理解が深まったときに更新する。
- 時間をかける費用対効果を考える。
-
UML図の間で整合性が取れているようにする
- クラス図とシーケンス図で整合性が取れていない、というようなことがないようにはする。
「クラス図」については、実装をし始めてからも、何度も図を見返しながら実装内容を考えました。「シーケンス図」も同様に、全体の流れの把握や、どのクラスのメソッドとして実装すれば良いかなど、何度も図を見返しました。 UML を作成したり見返したりすることで、どうすれば良いか行き詰まってしまうことが少なくなると感じます。作るものが明確になる UML 作成の価値を感じられました。
作成した UML は下記のとおりです。
ユースケース図
クラス図
シーケンス図
♦︎2. テストを先に書きながら進める
テストを実装より先に書きながら進めようとしました。
PHPUnit での自動テスト
テストには、 PHPUnit を使いました。
実装したい機能のテストを先に書いてからその機能を実装する 形で進めるつもりでしたが、全然守れませんでした…。
テストの肝をわかっていないが故、どうしても手が止まるため、後回しになりがちで、気づいたら後で書くようになっていました。書きながら学ぶという段階にすらないと感じました。体系的なインプットが必要だと思っています。
♦︎3. デバッグしながらコーディングする
デバッグツール Xdebug の利用
PHP のデバッグツールである Xdebug で、必要に応じてステップ実行して、デバッグしながらコーディングしました。
Xdebug を利用する方法としては、 Visual Studio Code の拡張機能として導入した Remote Development を利用して、 Docker コンテナ内でデバッグ機能を設定する方法で使用しています。
開発過程で守るようにしたことのリストですが、デバッグツールはむしろ便利すぎて手放せないくらいです。
♦︎4. 静的解析ツールは全て通る状態にする
静的解析ツールは、以下の3つを導入して、チェックを通る(エラーが出ない)状態にしました。
- PHP_CodeSniffer
- PHPMD
- PHPStan
PHP_CodeSniffer
PHP_CodeSniffer は、実装したコードがコーディング規約に則っているかをチェックしてくれます。
例えば、実行時このようなことをチェックしてくれました。
root@9444efa94780:/var/www/html# composer phpcs
> ./vendor/bin/phpcs --standard=phpcs.xml
FILE: /var/www/html/lib/Deck.php
--------------------------------------------------------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------------------------------------------------------
41 | ERROR | [x] Parentheses must be used when instantiating a new class
| | (PSR12.Classes.ClassInstantiation.MissingParentheses)
--------------------------------------------------------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------------------------------------------------------
新しいクラスをインスタンス化する際には、括弧を使用する必要があります
このような、コーディング規約(PSR12)に則っていない箇所を教えてくれます。
- $card = new Card;
+ $card = new Card();
PHPMD
PHPMD は、考えられるバグや最適でないコード、複雑なコード、未使用のパラメーター、メソッド、プロパティなど、問題のありそうなコードを検知して教えてくれるツールで、良いコードに近づけてくれます。
例えば、実行時このようなことをチェックしてくれました。
root@9444efa94780:/var/www/html# composer phpmd
> ./vendor/bin/phpmd . text rulesets.xml --suffixes php --exclude node_modules,resources,storage,vendor
/var/www/html/lib/Game.php:13 ExcessiveClassComplexity The class Game has an overall complexity of 55 which is very high. The configured complexity threshold is 50.
クラスGameの全体的な複雑度は55で、非常に高いです。設定されている複雑さの閾値は50です。
「複雑度」とは if
, while
, for
などを足し合わせて算出されるようで、複雑さの閾値を超えるとチェックに引っ掛かります。
PHPStan
PHPStan も、バグになりそうなところを見つけてくれるツールです。
例えば、実行時このようなことをチェックしてくれました。
------ --------------------------------------------------------------------------------------------------------------------
Line lib/Player.php
------ --------------------------------------------------------------------------------------------------------------------
96 Variable $message might not be defined.
------ --------------------------------------------------------------------------------------------------------------------
変数 $message が定義されていない可能性があります。
という指摘があり、下記を追記して定義してから使うと解消されました。
$message = '';
♦︎5. 何度もリファクタリングする
リファクタリングについては、変更しづらかったり読みづらい箇所は読みやすいコードになるよう試行錯誤して、臆さず何度も修正しまくろうという気持ちで実装しました。
また、リファクタリングするためのチェックの観点としては、以下のとおりです。
- SOLID原則 に則っているか
- デザインパターン で、適用できるものはないか
- 静的解析ツールで、複雑度のチェックをクリアしているか(→♦︎4)
SOLID原則
SOLID原則は下記の5つの原則ですが、それぞれをチェック項目として、確認しました。
- 単一責任の原則
- オープン・クローズドの原則
- リスコフの置換原則
- インターフェース分離の原則
- 依存性逆転の原則
主に、「単一責任の原則」に則っていないと考えた箇所を書き換えました。
デザインパターンの利用
これまでに学んだデザインパターンは以下の3つです。
- Adapterパターン
- Abstract Factoryパターン
- Compositeパターン
この3つのうちで、少なくとも、使えるパターンがないかを見比べて、導入できそうな箇所がないか照合しました。またその他利用できるパターンがないか調べて参考にしました。
♡. おわりに
第三者にレビューしていただくことなく、ああでもないこうでもないと、ひとりで試行錯誤して書いたため、過程は泥臭く悩みながら書いたボロボロのコードです。
ただ、完成したものが少しでも良いコードになるよう、これまで学んできたことを振り返りながら、まだわからないことは新たに学びながら進んでいこう!という気概で臨みました。
機能としては完成させて、やり切った思いはあります。ただ、もっとこうしたら良くなるという、自分では気付かないことが多々あるはず...という状態なので、第三者にコードレビューをしてもらう機会を得ようと思います。(コメント欄やGitHubでのレビュー大歓迎です)
多くの指摘があるとしても、成長の機会としてありがたく受け取ります。
ブラックジャックに時間をベットして報酬として得た経験値はとても大きかったと感じています。
ありがとうございました。
追記:2022/12/09
コードレビューをしてもらう機会を得ましたので記事を書きました。