ボードゲームの実装プロセスをなるべく一般化してみたいと思います。
今回はサンプルとして、
- JavaScript + Node.jsで
- ○×ゲーム(三目並べ、tic-tac-toe)を
- コンソールアプリとして
実装します。
ゆくゆくはブラウザ上でGUI操作を実装できたら良いなということでJavaScriptにしています。
フレームワークライブラリを作成できたらカッコ良かったのですが、コードとして共通化できる箇所が特に無さそうなのでやめます。
共通化できそうなのは、せいぜい設計モデルや思考プロセスぐらいでした。
なのでここでは、サンプルコードと実装時の私の思考プロセスを書き記そうと思います。
大それたタイトルにしてしまいましたが、まあ誰かの参考になるといいなという感じです。
ソースコードはGitHubに上げています。
関連記事
ブログ
趣味的な考察です。長めです。
この記事との関連箇所はちゃんと記事内で抜粋しているので、無理して読まなくて大丈夫です。
興味ある方だけどうぞ。
Qiita
ボードゲーム実装における思考フレームワーク 〜1.ゲームの状態の管理〜(本記事)
ボードゲーム実装における思考フレームワーク 〜2-1.ルールの施行(思考編)〜
ボードゲーム実装における思考フレームワーク 〜2-2.ルールの施行(実装編)〜
[ボードゲーム実装における思考フレームワーク 〜3.プレイヤーの代行〜]
(https://qiita.com/ikngtty/items/28098a91c52de3611bb9)
実装するもの
以下の記事で、3つの機能を掲げました。
機能1. ゲームの状態の管理
ボードゲームで言えばボードの代わりである。 ルールの演算などは全てプレイヤーに任せ、コンピューターは関知しない。 真っ白なキャンバスとして使うようなものだ。 アクションの可能/禁止も勝ち負けも気にせず、自由に状態を弄れる。
例えば詰将棋を作る時などは、この機能だけの方が都合が良いだろう。
機能2. ルールの施行
審判や、いわゆるゲームマスターのような役割を担う。 開始状態をセットアップし、プレイヤーの可能なアクションのみを受け付け、勝敗を判定する。
例えば将棋のオンライン対戦アプリなどは、機能1と2の組み合わせと言える。
機能3. プレイヤーの代行
いわゆるCPUプレイヤーだ。
例えばCPU対戦ができる将棋アプリは、機能1, 2, 3の組み合わせとなる。 また、CPU同士で戦わせて棋譜を量産しながらCPUのAIを強化する、なんてのは最近のトレンドだろう。
ここでは順に全てを実装します。
ボードゲームの構造分析
以下の記事で、こんな定義を掲げてみました。
ゲームとは、 「プレイヤーが、以下のルールに沿って、アクションを選択し、結果を決める営み」 である。
// ルール1: 初期状態
def initialState(): GameState
// ルール2: アクション定義
def createAction(args: Any): GameState => GameState
// ルール3. 可能/禁止アクション
def isAvailable(action: GameState => GameState, state: GameState): Boolean
// ルール4. 結果判定
def getGameResult(state: GameState): GameResult
// ルール5. ゲームの状態のアクセシビリティ
def getGameStateInformation(state: GameState, player: Player): GameStateInformation
ボードゲームに限定しても特に変わることはありません。
図にすると以下のような感じです。
このイメージを念頭に置きながら開発していきます。
構成要素をざっとイメージ
-
Player
- CPUプレイヤー
-
人間プレイヤー
- CLIやGUIなど何らかのUIを通してGameStateを確認し、Actionを入力します。
-
Action
- ○(×)をどこかに記入
-
降参
- ○×ゲームではあまりありませんが、普通のボードゲームにはよくあるので実装します。
-
強制終了
- ユーザーが試合に対し起こしそうなアクションは何でも定義してしまう方が作りやすいです。
-
GameState
- ○と×がどこに書き込まれているか(盤面)。
- 誰の手番か。
-
誰かが降参したか
- ゲームとは何かで書いたことですが、Actionは全てGameStateを変更させる関数と定義します。そのため、「降参」アクションに対応する状態が必要です。
-
強制終了されたか
- 同上
-
GameStateInformation
- 今回はGameStateをまるっとコピーするだけになります。
-
GameResult
-
結果
- 勝ち or 負け
- 引き分け
- 強制終了
-
勝者(結果が「勝ち or 負け」の場合のみ)
- こうしておくと多人数プレイのゲームにも拡張しやすいです。
-
決着理由
- あると便利です。
-
結果
記法の整備
ボードゲームには棋譜の書き方がよく定められています。
もし定められていなかったら、自分でちゃんと定義することをオススメします。
また、棋譜とは違い、盤面の記号化の仕方はあまり定まっていないことが多い気がします。
これも定義すると良いです。
あまり良い用語が思いつかないのですが、とりあえずこれらをまとめて「記法(notation)」と呼ぶことにします。
取らぬ狸の皮YAGNIかもしれませんが、棋譜や盤面のビッグデータを使って学習AIを育てるところまでプログラムを拡張するかもしれません。
その際、データは変に複雑なオブジェクトよりも文字列で扱えると便利です。
また、今回のようにアプリケーションをとりあえずCLIで作りたい時も役に立ちます。
ポイントとしては、
- 人間が直感的に読み取りやすいこと
- 情報の圧縮率が高いこと(文字数が短いこと)
などだと思われます。
今回は以下のように定めてみました。
┏━┳━┳━┓
┃o┃ ┃ ┃
┣━╋━╋━┫
┃x┃x┃ ┃
┣━╋━╋━┫
┃o┃o┃o┃
┗━┻━┻━┛
↓
[o--,xx-,ooo]
1 2 3
┏━┳━┳━┓
a ┃ ┃ ┃ ┃
┣━╋━╋━┫
b ┃ ┃ ┃ ┃
┣━╋━╋━┫
c ┃ ┃ ┃ ┃
┗━┻━┻━┛
↓
o:c2
x:a3
↓
1 2 3
┏━┳━┳━┓
a ┃ ┃ ┃x┃
┣━╋━╋━┫
b ┃ ┃ ┃ ┃
┣━╋━╋━┫
c ┃ ┃o┃ ┃
┗━┻━┻━┛
改行があると扱いが面倒そうなので、1行に収めています。
ちなみに入力しやすさを考え、「マル」「バツ」ではなく「オー」「エックス」にしました。
それでは実装し始めます。
実装:機能1. ゲームの状態の管理
まずはプログラムが盤の代わりを務められるところまで持っていきます。
Board(盤面)の実装
内部表現を決める
盤面の情報をどのような形で保有すると便利か決めます。
以下の場面を想像します。
-
記入アクション適用時
- プレイヤーの指定したマス目に記号を記入。
-
ゲームの結果判定時
- 1列目、2列目、3列目、a行目、b行目、c行目、\方向、/方向に記号が揃っていないか調べる。
いずれの場合も、マス目の位置を元に記号を取得する処理が必要そうです。
とすれば一番簡単なのは、以下のようにマス目の位置をナンバリングし、
┏━┳━┳━┓
┃0┃1┃2┃
┣━╋━╋━┫
┃3┃4┃5┃
┣━╋━╋━┫
┃6┃7┃8┃
┗━┻━┻━┛
配列で対応する記号を持っておくことです。
1文字Stringを9個並べた配列となるので、もっと言えば9文字のString1個でいいですね。
結果的に上で定めた記法とほとんど同じになりました。
場合によっては以下のようなデータ形式などが便利になることもありえます。
- 3×3の二次元配列
- 位置の記法("a1")をキー、○×を値としたハッシュ
- 位置オブジェクト(自作する)をキー、○×を値としたハッシュ
- ○×をキー、位置インデックスの配列を値としたハッシュ
今回は単純さを最優先しました。
尚、今回のサンプルコードは全体的に、単純さ・説明の手っ取り早さを最優先にして書いています。
代わりに拡張性がなかったり(五目並べに変えることすら大変)、DRY原則を破っていたりしますが、適宜心の目で直していってください。
記法との相互変換を実装する
class Board {
constructor (squaresString) {
if (squaresString.length !== 9) {
throw new Error('The board must contain just 9 characters.')
}
this._squaresString = squaresString
}
// 盤面のプログラム内部向け表現(9文字の文字列)
get squaresString () { return this._squaresString }
// 記法の妥当性チェック
static isValidNotation (notation) {
// 例:[o--,-x-,--1]
return /^\[.{3},.{3},.{3}\]$/.test(notation)
}
// 記法 → Boardオブジェクト の変換
static fromNotation (notation) {
if (!this.isValidNotation(notation)) {
throw new Error('Invalid notation.')
}
const squaresString =
notation.substring(1, 4) +
notation.substring(5, 8) +
notation.substring(9, 12)
return new this(squaresString)
}
// Boardオブジェクト → 記法 の変換
toNotation () {
return '[' +
this._squaresString.substring(0, 3) +
',' +
this._squaresString.substring(3, 6) +
',' +
this._squaresString.substring(6, 9) +
']'
}
}
module.exports = Board
コンストラクタはなるべく単純に、内部表現と同様にプログラムで扱いやすいような形にします。
記法ですが、"○","×","-"以外の文字列も受け入れることに注意してください。
○×ゲーム研究者(いないだろうけど)が「次はこことここが候補だから...」と"1","2"などの文字を書き込めるよう、柔軟に作ってあります。
ただし、1文字しか書き込めない仕様です。
ちなみに、なるべくイミュータブルになるよう意識して作っています。
GameStateの実装
const Board = require('./board')
class GameState {
constructor (board) {
this._board = board
}
get board () { return this._board }
// 初期状態
static initial () {
const board = Board.fromNotation('[---,---,---]')
return new this(board)
}
}
module.exports = GameState
とりあえず喫緊で必要なBoardだけ持たせておきます。
ついでに初期状態の定義もしちゃったので、ルール1を実装したことになります。
FillAction(記入アクション)の実装
棋譜の相互変換しかできないのも寂しいので、記入アクションも先行して作ってしまいます。
ルール2(アクション定義)の実装を始めたことになりますが、ルール3(可能/禁止アクション)はまだ不要なので実装していません。
const Board = require('../game-states/board')
const GameState = require('../game-states/game-state')
class FillAction {
constructor (playerChar, squareIndex) {
if (playerChar.length !== 1) {
throw new Error('The length of player character must be 1.')
} else if (squareIndex < 0 || squareIndex > 8) {
throw new Error('The index of square must be between 0 and 8.')
}
this._playerChar = playerChar // ○×
this._squareIndex = squareIndex // 記入場所
}
static isValidNotation (notation) {
// 例:x:c2
return /^.:[a-c][1-3]$/.test(notation)
}
static fromNotation (notation) {
if (!this.isValidNotation(notation)) {
throw new Error('Invalid notation.')
}
const playerChar = notation[0]
const rowNum = {
a: 0,
b: 1,
c: 2
}[notation[2]]
const colNum = Number(notation[3]) - 1
const squareIndex = rowNum * 3 + colNum
return new this(playerChar, squareIndex)
}
toNotation () {
const rowAlpha = ['a', 'b', 'c'][Math.floor(this._squareIndex / 3)]
const colNum = this._squareIndex % 3 + 1
return `${this._playerChar}:${rowAlpha}${colNum}`
}
// アクションの適用(適用前の状態を元に適用後の状態を返す)
applyTo (state) {
// 指定された位置に文字を代入するだけ
const squareChars = Array.from(state.board.squaresString)
squareChars[this._squareIndex] = this._playerChar
const newSquaresString = squareChars.join('')
const newBoard = new Board(newSquaresString)
return new GameState(newBoard)
}
}
module.exports = FillAction
試す
ゲームの状態を扱う準備がこれで整ったので、専用のCLIを作成して早速試しましょう。
const readline = require('readline')
const FillAction = require('../lib/actions/fill-action')
const Board = require('../lib/game-states/board')
const GameState = require('../lib/game-states/game-state')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
// 盤面を見やすく表示する
const displayBoard = board => {
const sq = board.squaresString
const getSq = i => sq[i] === '-' ? ' ' : sq[i]
const text =
` 1 2 3
┏━┳━┳━┓
a ┃${getSq(0)}┃${getSq(1)}┃${getSq(2)}┃
┣━╋━╋━┫
b ┃${getSq(3)}┃${getSq(4)}┃${getSq(5)}┃
┣━╋━╋━┫
c ┃${getSq(6)}┃${getSq(7)}┃${getSq(8)}┃
┗━┻━┻━┛`
console.log(text)
}
// ゲームの状態の管理
let gameState = GameState.initial()
displayBoard(gameState.board)
// 入力があるたびに発火するやつ
rl.prompt()
rl.on('line', input => {
if (input === 'quit') {
// quitと書かれたら黙って終了
rl.close()
} else if (Board.isValidNotation(input)) {
// 盤面記法が書かれたらゲームの状態を指定通りにリセット
const board = Board.fromNotation(input)
gameState = new GameState(board)
displayBoard(gameState.board)
rl.prompt()
} else if (FillAction.isValidNotation(input)) {
// 棋譜記法が書かれたらゲームの状態に適用
const action = FillAction.fromNotation(input)
gameState = action.applyTo(gameState)
displayBoard(gameState.board)
rl.prompt()
} else {
// 何か分からない入力に対しては外人風に聞き返す
console.log('What?')
rl.prompt()
}
})
1 2 3
┏━┳━┳━┓
a ┃ ┃ ┃ ┃
┣━╋━╋━┫
b ┃ ┃ ┃ ┃
┣━╋━╋━┫
c ┃ ┃ ┃ ┃
┗━┻━┻━┛
> o:a1
1 2 3
┏━┳━┳━┓
a ┃o┃ ┃ ┃
┣━╋━╋━┫
b ┃ ┃ ┃ ┃
┣━╋━╋━┫
c ┃ ┃ ┃ ┃
┗━┻━┻━┛
> x:c2
1 2 3
┏━┳━┳━┓
a ┃o┃ ┃ ┃
┣━╋━╋━┫
b ┃ ┃ ┃ ┃
┣━╋━╋━┫
c ┃ ┃x┃ ┃
┗━┻━┻━┛
> @:b2
1 2 3
┏━┳━┳━┓
a ┃o┃ ┃ ┃
┣━╋━╋━┫
b ┃ ┃@┃ ┃
┣━╋━╋━┫
c ┃ ┃x┃ ┃
┗━┻━┻━┛
> [you,are,pig]
1 2 3
┏━┳━┳━┓
a ┃y┃o┃u┃
┣━╋━╋━┫
b ┃a┃r┃e┃
┣━╋━╋━┫
c ┃p┃i┃g┃
┗━┻━┻━┛
> FXXK!!!!
What?
> quit
これでPCさえ持ち歩いていればいつでも一人○×ゲームができるようになりました。
尚、「このdisplayBoard
って関数めっちゃ良いじゃ〜ん!BoardクラスのtoString
メソッドをこれでオーバーライドしちゃおうぜ〜!」って方は脚注へ。1
2-1.ルールの施行(思考編)へ続く。
-
よくない考えです。これはCLIでBoardを表示するための処理です。これをBoardクラスに実装するということは、UIが増えて表現方法が増える度にBoardクラスに専用のメソッドを追加し続けなければいけないということです。MVCで言えば、ModelにHTMLを書くようなものです。責務分担がグダグダで分かりづらいし、とても再利用しづらいオブジェクトになります。それとも、「ユーザー向けじゃなくてデバッグ用に
toString
をこれにしようよ」と思ったでしょうか。デバッグ用の関数は安定している必要があります。このdisplayBoard
関数は、squaresString
が9文字に満たないとバグるし、9文字を超えた分は見れないので、バグが発生しているような特殊な状況ではかなり不安定です。 ↩