概要
- 前回記事 に引き続き、ゲームプログラム初心者が作ると勉強になるゲーム20選という動画で紹介されている20種類のゲーム全てを今年中に実装しきる、ということを目標に個人でちまちまゲーム開発を続けている
- 今回は8作品目に作成したオセロゲームの紹介
- リポジトリ: https://github.com/ryota37/Reverse
動画
工夫点
-
前回に引き続き、『ゲームプログラマのためのコーディング技術』が良かった話 に書いてあった内容を実践した
-
なるべく小さい単位で処理を関数として切り出す
-
単一責務の原則に従い、関数の責務をなるべく単純化。1つの関数にたくさんのことをやらせようとしない
- こういったスタイルで書こうとすると自然と処理を細分化しようという思考になる
- このような思考法でコードを書いていると、一見実装方法が思いつかないような処理でもいつの間にか実装出来ていることがあり、「細分化」という手法の強力さを改めて実感した
- また、「ある処理を複数のタスクに分割する」ということは必ずしもPC画面の前でなくてもできることに気づき、散歩中に考えを進めることもあった
-
具体的なコードで言うと、
AbleToPutStone()
だろうか(一次元的な石の並びを見て石を置けるかどうか判定する
AbleToPutStone()
関数)bool AbleToPutStone(StoneColor playercolor, Array<StoneColor> linestones) { if (linestones.isEmpty()) return false; // If the neighboring stone color is the same as playercolor, the return value should be false. if (linestones[0] != reverseColor(playercolor)) return false; // When all the stones are reverse color, the return value should be false because the stones can't be sandwitched. if (linestones.all([&](const StoneColor& color) { return color == reverseColor(playercolor); })) return false; // When None comes before reverseColor(playercolor), the return value should be false. for (int i = 0; i < linestones.size(); ++i) { if (linestones[i] == playercolor) break; if (linestones[i] == StoneColor::None) return false; } return true; }
- この関数は、石の並びの一次元配列と手番のプレイヤーの石の色を与えられて、石を置くことが出きるかどうかのみをboolで返す関数である
- 最初は「盤面全体を見渡して石を置ける場所をハイライトする」といった処理を考えようとしたものの、複雑すぎて思い浮かばなかった
- そんな時、「盤面全体や座標の情報は一切関知せずに一方向だけみて石を置けるかどうかを判定する」という所まで処理を細分化することを思いつき、この閃きによりその後の実装がグッと楽になった
- 一度この判定が可能になってしまえば、方向を全方位に増やしたり盤面の全座標に対してこの処理を実行したりと処理を拡張することで、当初の目的である「盤面全体を見渡して石を置ける場所をハイライトする」を達成できることに気づいた (実際そのような順番で実装を勧めた)
- また、処理を細分化することにより細かいステップを刻みながら単体テストを実施できたため、実装したロジックに誤りがあっても早期に気づくことが出きるという副次的なメリットがあることにも気づいた
- 現状のリポジトリでは削除しているが、開発途中では実際に単体テストのためのコードをちょくちょく書いていた
- 「一切関知せずに」という発想はクラスの
private
に近いものを感じた。カプセル化が推奨される理由をようやく実感できた気がする
-
-
enum class
を使用して、マジックナンバーに名前を付けた- 特に上下左右ナナメといった「方向」は数字と方向が直感的に結びつかず、一度定義してしまうとかなり楽になった
- ただ、現状のリポジトリでは
enum class
時点ではただ方向名を宣言しているだけで、理想的な実装ではない
(
Direction
構造体で8つの方向を定義している)enum class Direction { Up, Down, Left, Right, Upleft, Upright, Downleft, Downright };
(
ReverseLine()
内で方向を参照しているコード)if (direction == Direction::Up) { dx = 0; dy = -1; } if (direction == Direction::Down) { dx = 0; dy = 1; } if (direction == Direction::Left) { dx = -1; dy = 0; } if (direction == Direction::Right) { dx = 1; dy = 0; } if (direction == Direction::Upleft) { dx = -1; dy = -1; } if (direction == Direction::Upright) { dx = 1; dy = -1; } if (direction == Direction::Downleft) { dx = -1; dy = 1; } if (direction == Direction::Downright) { dx = 1; dy = 1; }
-
-
実装・仕様面でのその他の工夫
- 置ける場所をハイライトしている。しかも、手番の色に合わせてハイライトの色を微妙に変えている
-
オセロゲームにおいて必ずしも必須ではない仕様だが、遊びやすさ及び実装時のテストのしやすさを考慮してこの仕様を盛り込んだ
-
下記の
HighlightValidgrid()
にて実装しているvoid HighlightValidgrid(const Grid<int32>& grid, const Grid<StoneColor>& boardState, const Grid<RectF>& gridContainer, StoneColor playercolor) { for (int32 y = 0; y < grid.height(); ++y) { for (int32 x = 0; x < grid.width(); ++x) { if (boardState[y][x] != StoneColor::None) continue; auto search_result = SearchAllDirections(boardState, x, y); if (search_result.any([&](Array<StoneColor> line) {return AbleToPutStone(playercolor, line); })) { if (playercolor == StoneColor::White) gridContainer[y][x].stretched(-1).draw(Palette::Green.lerp(Palette::White, 0.5)); if (playercolor == StoneColor::Black) gridContainer[y][x].stretched(-1).draw(Palette::Green.lerp(Palette::Black, 0.5)); } } } }
-
今どちらのプレイヤーのターンなのかを分かりやすくするため、白側なら白っぽく、黒側なら黒っぽくハイライトするようにした
-
-
boardstate
に盤面の情報をまとめた- 盤面のどの座標にどの色の石があるかという情報は、
boardstate
という二次元配列に格納したGrid<StoneColor> boardState(8, 8, StoneColor::None);
- Siv3Dには、二次元配列を分かりやすく書くことが出きる
Grid
というものがあるため、それを使用した-
Grid
は概ねstd::vector
のようなもので内部的な実装も一次元配列になっているようだが、二次元配列のようにアクセスできるという少し不思議な仕様がある - あまりに二次元配列のように扱うことが出きるため、たまに一次元配列であることを忘れてしまう。それに由来したエラーが出たこともあったが、慣れると使いやすいと思う
-
- 盤面の情報をこの
boardstate
にまとめて、関数に参照渡しして盤面の情報を書き換えるという方針で実装した。このスタイルはやりやすく感じた
- 盤面のどの座標にどの色の石があるかという情報は、
- 置ける場所をハイライトしている。しかも、手番の色に合わせてハイライトの色を微妙に変えている
苦労したポイント
- 「挟まれた石をひっくり返す」というオセロゲームにおいてキモとなるロジックを実装するのが存外難しかった
- 人間の直感的にはどうということのない処理だが、プログラムに落とし込もうとすると煩雑になることが分かった
- 上述の
AbleToPutStone()
にて実装(およびコメントで記述)しているように、以下のようなチェックを経てはじめて「石を置ける」と判定することができる- チェックポイント①:まず、隣接するマスに置かれている石の色が手番のプレイヤーの色と逆であること
- チェックポイント②:ある一方向に石の並びを見た時に、全て同じ色の石の場合は石を置けない (挟むことができないため)
- チェックポイント③:ある一方向に石の並びを見た時に、手番のプレイヤーの色の石が来る前に石が置かれていないマスが来たら石を置けない
- 思わぬ例外ケースがあることにも悩まされた
- 手を動かし始める前は「盤面が全て石で埋め尽くされるまで、白と黒で交互に手番を切り替える」という実装にしようと考えていた
- しかし、以下のケースでは上記の実装では不十分となる
- 例外ケース①:ある手番のプレイヤーが石を置くことができなくなった場合、その手番をスキップしなければならない
- 例外ケース②:盤面が全て一色になった時、ゲームを続行することができないためその時点で勝敗判定をしなければならない
- なお、例外ケース①を取り扱う実装については、実はちょっとしたズルをしている
- 本来であれば、ある手番のプレイヤーが石を置くことができなくなった場合は自動でスキップできた方がよいためそのような実装にしようとしたのだが、「盤面の状態に応じて手番をスキップする」という実装が思いのほか難しく、結局実現できずに断念した
- 代わりに、任意のタイミングでSキーを押すとスキップできるようにした
- スキップ判定をプレイヤー側に押し付けてしまっているという点であまりプレイヤーに優しくない実装だが、石を置ける場所をハイライトするようにしたお陰でスキップが必要な場面は明確なので、ギリギリOK(ということに勝手に自分でしている)
- この例外ケースについては、実装もそうだがテストも大変だった
- 石を置けなくなる状態や盤面が全て一色になる状態を手元で再現しなければならないが、一人で打っていてもオセロに精通していないと自力で再現するのは難しい
- 結局、 「推論に優れた」という触れ込みのChatGPT-o3に、石を置けなくなる状態や盤面が全て一色になる状態を再現する打ち方を教えてもらった
動画
課題点
- 先述の通り、石を置ける場所がない時のスキップ処理は結局自動化できず、ユーザーが手動でスキップする形にしてしまっている
- AIの実装は時間がかかりそうなので、今回は見送った。よって両方とも人間が操作しないといけない
- 十分にテストを実施していないため、仕様の漏れやバグ等が潜んでいる可能性がある
- この点は今まで実装した他のゲームについても同様だが、特にオセロゲームは見た目以上に複雑なため何かバグが潜んでいないかという懸念が他のゲームより大きい