JavaScript
Node.js
オブジェクト指向
es6
ボードゲーム

ボードゲーム実装における思考フレームワーク 〜2-2.ルールの施行(実装編)〜


関連記事


ブログ

ゲームとは何か

ボードゲームはプログラミングしやすい

趣味的な考察です。長めです。

この記事との関連箇所はちゃんと記事内で抜粋しているので、無理して読まなくて大丈夫です。

興味ある方だけどうぞ。


Qiita

ボードゲーム実装における思考フレームワーク 〜1.ゲームの状態の管理〜

ボードゲーム実装における思考フレームワーク 〜2-1.ルールの施行(思考編)〜

ボードゲーム実装における思考フレームワーク 〜2-2.ルールの施行(実装編)〜(本記事)

ボードゲーム実装における思考フレームワーク 〜3.プレイヤーの代行〜


実装:機能2. ルールの施行


残りActionとGameStateの実装

前回実装したGameStateとActionはまだ不完全です。

「構成要素をざっとイメージ」で触れた通り、降参アクションや強制終了アクション、それとこれらに対応するゲームの状態も実装が必要です。

まずはゲームの状態から。


game-state.js

const Board = require('./board')

class GameState {
constructor (
board,
nextPlayerChar,
resignedPlayerChar = null,
isAborted = false
) {
this._board = board
this._nextPlayerChar = nextPlayerChar
this._resignedPlayerChar = resignedPlayerChar
this._isAborted = isAborted
}
get board () { return this._board }
// 次に指すプレイヤー("o" or "x")
get nextPlayerChar () { return this._nextPlayerChar }
// 降参したプレイヤー("o" or "x")
get resignedPlayerChar () { return this._resignedPlayerChar }
// 強制終了されたかどうか
get isAborted () { return this._isAborted }

static initial () {
const board = Board.fromNotation('[---,---,---]')
const nextPlayerChar = 'o'
return new this(board, nextPlayerChar)
}
}

module.exports = GameState


降参アクションや強制終了アクションに対応するゲームの状態を実装しました。

それと、前回不要だった手番の情報も加えています。

初期状態は「○」からスタートと定義しました。

前回実装したFillAction(記入アクション)は、実は手番を交代する処理が抜けています。

追加しましょう。


fill-action.js

const Board = require('../game-states/board')

const GameState = require('../game-states/game-state')

class FillAction {

// (略)

applyTo (state) {
// アクション後の盤面(前回と同様)
const squareChars = Array.from(state.board.squaresString)
squareChars[this._squareIndex] = this._playerChar
const newSquaresString = squareChars.join('')
const newBoard = new Board(newSquaresString)

// アクション後の手番プレイヤー
// 想定外(ox以外)の手番は変えずに放置します。
const nextPlayerChar = ((currentPlayerChar) => {
switch (currentPlayerChar) {
case 'o': return 'x'
case 'x': return 'o'
default: return currentPlayerChar
}
})(state.nextPlayerChar)

return new GameState(
newBoard,
nextPlayerChar,
state.resignedPlayerChar,
state.isAborted
)
}
}

module.exports = FillAction


手番交代のロジックはFillActionにしか出てきませんが、本来様々なアクションが使ってもおかしくない部分です。

本当はちゃんと共通化すべきですね。

"o"とか"x"とかを司るPlayerCharみたいなクラスがあると良さそうです。

まあ今回は単純さ最優先なので省略。

降参アクションと強制終了アクションを追加します。


resign-action.js

const GameState = require('../game-states/game-state')

// 降参アクション
class ResignAction {
constructor (playerChar) {
if (!['o', 'x'].includes(playerChar)) {
throw new Error('A player character must be "o" or "x".')
}
this._playerChar = playerChar
}

// 記法の妥当性チェック
static isValidNotation (notation) {
// 例:x:resign
return /^[ox]:resign$/.test(notation)
}

// 記法からオブジェクト生成
static fromNotation (notation) {
if (!this.isValidNotation(notation)) {
throw new Error('Invalid notation.')
}
const playerChar = notation[0]
return new this(playerChar)
}

// オブジェクトから記法に変換
toNotation () {
return `${this._playerChar}:resign`
}

// アクションの適用
applyTo (state) {
return new GameState(
state.board,
state.nextPlayerChar,
this._playerChar, // 降参者として自プレイヤー文字が入った状態にする
state.isAborted
)
}
}

module.exports = ResignAction



abort-action.js

const GameState = require('../game-states/game-state')

// 強制終了アクション
class AbortAction {
constructor (playerChar) {
if (!['o', 'x'].includes(playerChar)) {
throw new Error('A player character must be "o" or "x".')
}
this._playerChar = playerChar
}

// 記法の妥当性チェック
static isValidNotation (notation) {
// 例:x:abort
return /^[ox]:abort$/.test(notation)
}

// 記法からオブジェクト生成
static fromNotation (notation) {
if (!this.isValidNotation(notation)) {
throw new Error('Invalid notation.')
}
const playerChar = notation[0]
return new this(playerChar)
}

// オブジェクトから記法に変換
toNotation () {
return `${this._playerChar}:abort`
}

// アクションの適用
applyTo (state) {
return new GameState(
state.board,
state.nextPlayerChar,
state.resignedPlayerChar,
true // 強制終了フラグを立てる
)
}
}

module.exports = AbortAction


FillActionの時は"o""x"以外の文字を記入できるよう考えましたが、この辺のアクションではそこまで考える必要はなさそうです。

できれば複雑なことは考えたくないので、コンストラクタの時点で"o""x"以外を入れられなくしました。


ルールの実装


ルールはどこに定義すべきか

前回の思考で「ルールは誰が施行するのか」「ルールは誰が使用できるか」を考えました。

これを踏まえると、ルールは誰でも使えるようにグローバルなオブジェクトとして存在する必要があります。

(ゲームマスターの中にカプセル化すべきではないということです。)

1つの案として、Rulesのようなオブジェクトを作って5つのルールを固めてしまう作戦があります。

ルールは全て状態を持たない関数です。

JavaScriptにおいてはRules(rules)はクラス(コンストラクタ)無しのただのオブジェクトとして表現できるところですが、クラス必須の言語ならばstaticメソッドを集めたstaticなクラスということになります。

これはこれで分かりやすいでしょう。

ここでは別の案で行きます。

各ルールを、オブジェクトの振る舞いとして関連するクラスに振り分けてしまう作戦です。

各ルールの実装はそれぞれ以下のデータ構造に依存します。

(データ構造を知らないと実装できない、ということ。)

ルール
依存先

1. 初期状態
GameState

2. アクション定義
Action(, GameState)

3. 可能/禁止アクション
Action(, GameState)

4. 結果判定
GameState

5. ゲームの状態のアクセシビリティ
GameState(, Player)

依存先のクラスにメソッドとしてルールを埋め込んじゃいましょう。

喩えていうなら、盤に説明書をセットでつけた感じです。

それだけでなく、「おい、盤!初期状態に戻れ!」とか「今どっちが勝ったか教えろ!」とかにも対応してくれるわけです。

便利ですね。

しかも「誰でも自由に使える」という条件をちゃんと満たしています。

まあしかし、こういう喩えをあまり強調しすぎると変に勘違いする人が出てくるので、ほどほどにしましょう。

この設計の肝はあくまで、「データと関連の深い関数を結びつけてあげた」と、それだけです。

ちゃんとまずはプログラム世界の論理で考えてくださいね。

私の理解では、ドメインモデル貧血症を防ごうとすると自然とこうなってきます。

それと、GRASP情報エキスパートパターンという考え方にも当てはまりそうです。

詳しくないので確かではありませんが。

この辺のキーワードも興味がある方は調べてみてください。

ちゃんと全部プログラム世界の論理の話になるはずです。


ルール1(初期状態)の実装

具体的に見てみましょう。

ルール1は実は前回からもう実装しています。

GameState.initial()ですね。


ルール2(アクション定義)の実装

こちらも各ActionクラスのapplyTo(state)で実装済み。


ルール3(可能/禁止アクション)の実装

ここからまだ実装していない部分です。

このルールを施行するのは審判なのかゲームマスターなのかプレイヤーなのか前回色々考えた部分ですが(結論はプレイヤーでしたね)、上記の通り、実装先はActionクラスです。

Playerは「おい、アクション!生成してみたはいいが、お前実はズルいやつなんじゃないのか?」と聞くだけで、具体的なルールの判定をActionに丸投げしながらちゃんと施行することができます。

ルールは誰でも使えるべきだとしましたが、実際Player以外も自由にActionを生成して「お前ズルいやつか?」と聞くことで、ルールを確認することができます。

それではAction毎に実装していきます。まずはFillActionから。


fill-action.js

class FillAction {

// (略)

// このstateにこのactionを適用するのはvalidかどうかチェック
checkValidation (state) {
if (state.nextPlayerChar !== this._playerChar) {
// 手番じゃないのにプレイしようとするタイプのズル
return {
isValid: false,
description: `It is a turn of "${state.nextPlayerChar}".`
}
}
const overridenChar = state.board.squaresString[this._squareIndex]
if (['o', 'x'].includes(overridenChar)) {
if (overridenChar === this._playerChar) {
// 一回自分が記入したマスにもう一回記入しようとするタイプのズル
return {
isValid: false,
description: 'You have already filled this square.'
}
} else {
// 一回相手が記入したマスにもう一回記入しようとするタイプのズル
return {
isValid: false,
description: `The square has already been filled with "${overridenChar}".`
}
}
}

// ズル無し
return {
isValid: true,
description: ''
}
}
}

module.exports = FillAction


返し値はisValidだけでなく、description属性としてどうinvalid(ズル)なのかの説明文もつけるようにしました。

○×ゲームだとinvalidなアクションのバリエーションが少なかったので、「一度記入したマスにもう一回記入しようとする」パターンを更に「相手のマスか自分のマスか」で二分割してみました。

次はResignActionとAbortAction。


resign-action.js

class ResignAction {

// (略)

checkValidation (state) {
return {
isValid: true,
description: ''
}
}
}

module.exports = ResignAction



abort-action.js

class AbortAction {

// (略)

checkValidation (state) {
return {
isValid: true,
description: ''
}
}
}

module.exports = AbortAction


特にinvalidなパターン無しです。

手番も無視していつでも降参/強制終了できる仕様です。


ルール4(結果判定)の実装

まずはゲームの結果を分類する定数を定義します。


result-type.js

module.exports = {

WIN_OR_LOSE: 1, // 誰かが勝ち誰かが負けた
DRAW: 2, // 引き分け
ABORTED: 3 // 強制終了
}

そして、GameStateに結果判定ロジックを追加します。


game-state.js

const resultType = require('../consts/result-type')

class GameState {

// (略)

checkResult () {
if (this._isAborted) {
// 強制終了
return {
resultType: resultType.ABORTED,
winner: null,
description: `The game is aborted.`
}
} else if (this._resignedPlayerChar) {
// 降参
// 降参してない方を勝者とする
const winner = (resined => {
switch (resined) {
case 'o': return 'x'
case 'x': return 'o'
default: throw new Error('An irregular player character.')
}
})(this._resignedPlayerChar)
return {
resultType: resultType.WIN_OR_LOSE,
winner: winner,
description: `"${this._resignedPlayerChar}" resigned.`
}
}

const squareChars = Array.from(this._board.squaresString)
const lines = [
{ indexes: [0, 1, 2], type: 'horizontal' }, // ヨコ
{ indexes: [3, 4, 5], type: 'horizontal' },
{ indexes: [6, 7, 8], type: 'horizontal' },
{ indexes: [0, 3, 6], type: 'vertical' }, // タテ
{ indexes: [1, 4, 7], type: 'vertical' },
{ indexes: [2, 5, 8], type: 'vertical' },
{ indexes: [0, 4, 8], type: 'diagonal' }, // ナナメ
{ indexes: [2, 4, 6], type: 'diagonal' }
]
for (const line of lines) {
const chars = line.indexes.map(i => squareChars[i])
if (
['o', 'x'].includes(chars[0]) &&
chars[1] === chars[0] &&
chars[2] === chars[0]
) {
return {
// 誰かが勝ち
resultType: resultType.WIN_OR_LOSE,
winner: chars[0],
description: `A ${line.type} line is completed !`
}
}
}

// 引き分け
if (squareChars.every(char => ['o', 'x'].includes(char))) {
return {
resultType: resultType.DRAW,
winner: null,
description: 'No line is completed.'
}
}

// 試合続行
return {
resultType: null,
winner: null,
description: ''
}
}
}

module.exports = GameState


判定結果としてresultType, winner, descriptionの3属性を返します。

checkResultgetResultみたいな名前でも良かったんですが、checkValidationと雰囲気が色々と近かったので名前も揃えてみました。


ルール5(ゲームの状態のアクセシビリティ)の実装

今回は完全情報ゲームなのであまり意味ないですが、形だけ。


game-state.js

class GameState {

// (略)

// プレイヤーが認知するのはどんな情報になるかを返す
perceivedBy (playerChar) {
// 状態の全情報をそのまま返す
return this
}
}

module.exports = GameState


ブログでもちょっと触れましたが、GameStateに関する情報は同じGameStateクラスで表現するのが便利そうな気がしてます。

プレイヤーが認知できない情報を、nullなり、認知できないことを表すNullObjectなりに置き換えたようなGameStateクラスをGameStateInformationとして扱うイメージです。

余裕があれば別の例題でこの辺のビジョンも具体的に検証&説明します。


Playerの実装

今回は同じCLIから二人で交互に棋譜を打ち込んで対戦するような形式とします。

まずは打ち込まれた棋譜がどのActionにあたるかを判別できる仕組みが必要です。


action-factory.js

const FillAction = require('./fill-action')

const ResignAction = require('./resign-action')
const AbortAction = require('./abort-action')

class ActionFactory {
// 記法から対応するアクションを生成
createFromNotation (notation) {
const actions = [
FillAction,
ResignAction,
AbortAction
]

// 記法から変換できそうなアクションを探す。あれば変換して返却。
for (const actionClass of actions) {
if (actionClass.isValidNotation(notation)) {
return actionClass.fromNotation(notation)
}
}

// 対応するアクションが見つからなければnullを返却
return null
}
}

module.exports = ActionFactory


ActionFactoryとしました。

各アクションがisValidNotationfromNotationなどの共通のインターフェースを持っていることを利用してポリモーフィってます。

JavaScriptではインターフェースを明示する仕組みが弱いのでダックタイピングで済ませていますが、JavaやC#ならinterfaceとして宣言する場面ですね。

それではCLIを利用した人間プレイヤーの実装です。


cli-player.js

const ActionFactory = require('../actions/action-factory')

class CliPlayer {
constructor (char, readlineInterface) {
this._char = char
// CLIは2プレイヤーで共通で使うので、外部で生成して受け取ります。
this._rl = readlineInterface
}
// "o" or "x"
get char () { return this._char }

// ゲームの状態の情報を受け取り、次に指すアクションを決めて返す
async play (stateInfo) {
// ユーザーとのやり取りを行うとNode.jsでは非同期処理になってしまうので、
// 実際には「(ユーザーとのやり取りが終わり次第)アクションを返すよ」というPromiseを返します。
return new Promise(resolve => this._ask(stateInfo, resolve))
}

// 再帰呼び出しできるように、ユーザーに一回入力させる部分を括り出してあります。
// thisの中身が変わらないようにbindしています。
get _ask () { return this.__ask.bind(this) }
__ask (stateInfo, resolve) {
// ユーザーに盤面の情報を見せてから
this._displayBoard(stateInfo.board)
// 対象プレイヤー("o" or "x")に入力を促します。
this._rl.question(`"${this._char}" to play. > `, answer => {
// 受け取った入力を元に記法を作ります。
// 例:'o'プレイヤーが'c2'と入力 => 記法は'o:c2'
const notation = `${this._char}:${answer}`
// 記法を元にアクション生成
const action = new ActionFactory().createFromNotation(notation)
if (!action) {
// 記法が解釈できない場合
console.log('Invalid notation.')
console.log()
// 再帰呼び出しで再入力を促します。
this._ask(stateInfo, resolve)
return
}
const validation = action.checkValidation(stateInfo)
if (!validation.isValid) {
// アクションがズルな場合
console.log(`The action is rejected: ${validation.description}`)
console.log()
// 再帰呼び出しで再入力を促します。
this._ask(stateInfo, resolve)
return
}
console.log()
// ここまで来て始めてPromiseが完了し、生成したアクションを返します。
resolve(action)
})
}

// 前もあったやつですが、ちょっと横幅を広げました。
_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)
}
}

module.exports = CliPlayer


前回決めた通り、Playerクラスはinvalidなアクションを棄却し、validなアクションだけを返すようにしています。


GameMasterの実装

ゲームマスター(GM)に持たせたい役割は以下でした。


  • ゲームの状態の保有

  • ルール1(初期状態)の施行

  • ゲームフローの施行 → 各プレイヤーに適切なタイミングでアクションを請求する

  • ルール4(結果判定)の施行

  • ルール5(ゲームの状態のアクセシビリティ)の施行 → ゲームの状態を各プレイヤー向けの情報に変換(不可知な部分を隠す)してプレイヤーに伝える

ゲームフローについてはブログでも触れた通り、今までの要素で既に表現はできています。



  • GameState.initial()


    • 初期状態は「○」の手番




  • FillAction#checkValidation(state)


    • 手番でないプレイヤーの手は必ずinvalid




  • FillAction#applyTo(state)


    • 記入すると手番が交代される



なので、GMはゲームフローを全く知らなくともゲームを進めることが可能です。

game_master_without_flow.png

このモデルで実装する場合、以下の記事が参考になります。

早押しクイズでNode.jsの非同期処理とかイベント駆動とかを試す

というか上の記事は、実はこの設計モデルを試すために色々書いてみた際の副産物だったりします。

結論としては、できなくないけど色々めんどくさいなという印象です。

「プレイヤーはprocess.nextTickしてからプレイすることで、プレイしない方のプレイヤーについてのキューをターン毎に消化させないといけない」とかとか。

そもそもプレイしないPlayerオブジェクトを無駄に呼んだりするのはパフォーマンス的にも良くなさそうです。

どんなゲームフローのゲームにも使える汎用的なGMを作れる、というメリットもあるにはあるんですけどね。

一方で、ゲームフローを知り尽くしたGMにゲームを進めさせると以下のようになります。

game_master_with_flow.png

特に今回みたいなシンプルなゲームフローの場合はこの方がスッキリしますね。

GMが本当に必要なのか疑問視した時もありました。

今回のような2人ゲームだと、結局のところ手を指した後に相手プレイヤーに状態を投げるだけなので、確かにGM無しでもそんなに不便はありません。

しかし複数人数ゲーム、しかもフローが複雑なゲームになってくると、自分の次は誰なのか、全プレイヤーがゲームフローを意識して状態を投げる先を判断しなければいけなくなります。

そうするとプレイヤーのやることが増えてきて、プレイヤークラスのコードはごちゃごちゃしてきます。

一方でGMが交通整理してくれれば、プレイヤーは次が誰かを気にすることなくとりあえずGMに状態を投げればいいわけです。

捗りますね。

というわけで、GM有り、かつ、GMはゲームフローをちゃんと踏まえるという方針で行きます。


game-master.js

const EventEmitter = require('events')

const GameState = require('./game-states/game-state')

class GameMaster extends EventEmitter {
constructor (oPlayer, xPlayer) {
super()
this._players = new Map()
this._players.set('o', oPlayer)
this._players.set('x', xPlayer)
}

// 引数無しで呼べばルール1(初期状態)を施行します。
// ゲームの状態はローカル変数として保有しており、ゲームの間中は保有していることになります。
async startGame (state = GameState.initial()) {
// ルール4(結果判定)の施行。決着がついていたら"gameend"イベントを発火して終わります。
const result = state.checkResult()
if (result.resultType) {
this.emit('gameend', result, state)
return
}

// GMはゲームの状態を元に次のプレイヤーが誰かちゃんと判断します。ゲームフローを施行していますね。
const nextPlayer = this._players.get(state.nextPlayerChar)

// ルール5(ゲームの状態のアクセシビリティ)の施行。
const stateInfo = state.perceivedBy(state.nextPlayerChar)

// プレイヤーが次の手を返すのを非同期で待ちます。
const action = await nextPlayer.play(stateInfo)

// ルール2(アクション定義)もここで施行しました。
const nextState = action.applyTo(state)

// 再帰呼び出しで決着が着くまで続けます。
this.startGame(nextState)
}
}

module.exports = GameMaster


ルールをたくさん施行していますが、定義場所はGameStateやActionなのでGMはそれを呼ぶだけです。

結果、GMのコードは短く済みました。(その代わりいっぱいコメントを入れました。)

Player#playが非同期であるため、startGameも非同期になります。

そのためゲームが終了するタイミングも非同期になり、何かしらの方法で通知しなければいけません。

Promiseを返すかイベントを発火するかで、なんとなくイベントの方がわかりやすいし楽かなーということでgameendイベントを発火することにしました。


試す

GameMasterにCLIプレイヤーを2人登録することで2人対戦が可能になります。

2人はCLIを共有し、仲睦まじく肩を寄せ合って同じ画面を覗きながら交互に棋譜を打ち込む想定です。


as-a-game-master.js

const readline = require('readline')

const resultType = require('../lib/consts/result-type')
const CliPlayer = require('../lib/players/cli-player')
const GameMaster = require('../lib/game-master')

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)
}

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
const oPlayer = new CliPlayer('o', rl)
const xPlayer = new CliPlayer('x', rl)
const gm = new GameMaster(oPlayer, xPlayer)

// ゲーム終了時の処理。
// 同じCLIに「あなたの勝ちです。」「あなたの負けです。」と2回分出るのもアレなので、
// メインルーチンで一回だけ`gameend`イベントをlistenしてCLIへのゲーム結果表示を行うことにします。
gm.on('gameend', (result, state) => {
displayBoard(state.board)
switch (result.resultType) {
case resultType.WIN_OR_LOSE:
console.log(`"${result.winner}" win !`)
break

case resultType.DRAW:
console.log('Draw !')
break

case resultType.ABORTED:
console.log('The game is end.')
break
}
console.log(result.description)
rl.close()
})

gm.startGame()


displayBoardが完全にWETですが、例によってリファクタリングは各自の脳内で。

実行してみます。

    1   2   3

┏━━━┳━━━┳━━━┓
a ┃ ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ ┃
┗━━━┻━━━┻━━━┛
"o" to play. > b2

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ ┃
┗━━━┻━━━┻━━━┛
"x" to play. > b3

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ ┃
┗━━━┻━━━┻━━━┛
"o" to play. > a1

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ ┃
┗━━━┻━━━┻━━━┛
"x" to play. > c3

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ ┃ ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ x ┃
┗━━━┻━━━┻━━━┛
"o" to play. > a3

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ ┃ o ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ x ┃
┗━━━┻━━━┻━━━┛
"x" to play. > b2
The action is rejected: The square has already been filled with "o".

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ ┃ o ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ x ┃
┗━━━┻━━━┻━━━┛
"x" to play. > a2

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ x ┃ o ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ ┃ ┃ x ┃
┗━━━┻━━━┻━━━┛
"o" to play. > c1

1 2 3
┏━━━┳━━━┳━━━┓
a ┃ o ┃ x ┃ o ┃
┣━━━╋━━━╋━━━┫
b ┃ ┃ o ┃ x ┃
┣━━━╋━━━╋━━━┫
c ┃ o ┃ ┃ x ┃
┗━━━┻━━━┻━━━┛
"o" win !
A diagonal line is completed !

resignと入れれば降参できますし、abortと入れれば強制終了できます。

色々と試してみてください。

これでPCさえ持ち歩いていればいつでも友達と○×ゲームができるようになりました。

3.プレイヤーの代行へ続く。