概要
前回はボードゲーム、ナショナルエコノミーオンライン対戦ツール全体の構成、および用いるエンティティの定義について解説しました。
今回はゲームメカニクス部分とそのテストをStateモナドを用いて実装する方法について解説します。
はじめに
ゲームメカニクス部は、先に定義した各ゲーム状態エンティティを用いて様々な種類のStateモナド、あるいはその生成関数を作成する形で行います。また、その動作確認は単体テストの記述によって簡単に行えます。
UIや通信部にはまだ手を出さないので、Reactアプリケーションを書くときのようにwebpack-dev-serverでローカルサーバを立てたりするような準備が必要なわけではありません。
Stateモナド自体の定義は自分で1から書きますし、ゲームの各種操作はその組み合わせによって実現されます。Stateモナドを提供するライブラリはたぶん世の中にあるとは思いますが、ここでは勉強もかねて自分で書きます。
外部ライブラリとしては、単体テスト用のMocha、power-assertのみ導入しました。
Stateモナドの定義
メカニクス部をStateモナドを用いて完全に副作用なしで書くのであれば、本来はPureScriptなどを用いるべきなのでしょうが、習得が面倒なのでここではTypeScriptでジェネリクスを用いて記述します。
export default class State<S, T> {
readonly run: (s: S) => [T, S];
constructor(run: (s: S) => [T, S]) {
this.run = run;
}
static returnS<S, T>(t: T): State<S, T> {
return new State(s => [t, s]);
}
flatMap<Tr>(f: (t: T) => State<S, Tr>): State<S, Tr> {
return new State(s => {
const [tr, sr] = this.run(s);
return f(tr).run(sr);
});
}
static get<S>(): State<S, S> {
return new State(s => [s, s]);
}
static put<S>(s: S): State<S, void> {
return new State(_ => [undefined, s]);
}
modify(f: (s: S) => S): State<S, T> {
return new State(s => {
const [t, sin] = this.run(s);
return [t, f(sin)];
});
}
map<Tr>(f: (t: T) => Tr): State<S, Tr> {
return new State(s => {
const [t, sr] = this.run(s);
return [f(t), sr];
});
}
}
今見ると型引数のHaskellの同等メソッド名には合わせたり合わせなかったりしています。S
とT
が逆な気がしますが
ゲーム内操作の実装
Stateモナドが記述できたところで、次はこれをどのように用いてゲーム内操作を構成すればよいかを説明します。
書いたはいいけど、結局Stateモナドって何なの?という説明はこれまで意図的に省略していました。これは厳密に正しい説明をする自信がなかった、ということもありますが、ひとえに私自身の「様々な解説や記事を読んでも意味がよく理解できなかった」という経験により、とりあえず便利に使う方法を先に示せば、理解はあとから付いてくるだろうという見通しに基づいています。
これに従い、いきなり具体的なゲーム内操作の記述からはじめます。
プレイヤー状態の更新
持ち金の増加
世のボードゲームには、手札や持ち金、資源トークンなど様々な「プレイヤーの状態」を表現するシステムが存在しています。ナショナルエコノミーも例に漏れずそれらの要素を備えています。
プレイヤー状態を更新する最も単純な例として、まずは「家計から金を獲得して持ち金に加える」動作を表現するStateモナドの作成メソッドを、先に定義したStateモナドの各メソッドを用いて実装してみます。
ゲームシステム上「家計から金を獲得」する際には、同時に同量の金を家計から取り除く必要がありますが、ここでは対象プレイヤーの状態についてのみ見ます。
function earn(amount: number): State<Player, string> {
return new State<Player, string>(player => [`${player.name || player.id}が家計から$${amount}を獲得しました`, {...player, cash: player.cash + amount}]);
}
先に定義したTypeScriptのState<S, T>
は、「元状態$s\in S$を引数として受け取り、改変後の状態$s'\in S$を、何らかの付加情報$t \in T$と共に返す関数」を内部に閉じ込めたclass
として解釈されます。これを「適用」するとは、現在の状態$s_\mathrm{current}\in S$を閉じ込めた関数run
に渡し、出力を取り出すことを意味しています。
上のメソッドは「プレイヤーの元状態を受け取り、改変後のプレイヤー状態とログ文字列を返す関数」を閉じ込めています。任意のプレイヤー状態に対して、求める改変されたプレイヤー状態は単純なスプレッド構文で作成できます。
これによって得られたStateモナドを実際に用いて改変後のプレイヤー状態を次のように得ることができます。
const previousPlayer: Player = ...
...
const modifiedPlayer = earn(10).run(previousPlayer);
また、earn
メソッドの作成方法にはState
のコンストラクタ以外にも表現方法があります。
function earn(amount: number): State<Player, string> {
return State.get<Player>()
.modify(player => {...player, cash: player.cash + amount})
.map(player => `${player.name || player.id}が家計から$${amount}を獲得しました`);
}
この表現方法では、get
メソッドで「run
で渡されるプレイヤー状態」を型引数T
に渡し、modify
メソッドでプレイヤー状態を更新し、map
メソッドでT = Player
(とamount
)からログ文字列を導出するような抽象的なイメージが可能です。
あるいは、flatMap
を用いても表現できます。
function earn(amount: number): State<Player, string> {
return State.get<Player>()
.flatMap(player => {
const nextPlayer = {...player, cash: player.cash + amount};
return State.put<Player>(nextPlayer)
.map(_ => `${player.name || player.id}が家計から$${amount}を獲得しました`)
});
}
get
メソッドは前のものと同様ですが、flatMap
メソッドに「元状態player
から新状態newPlayer
を導出する。それをput
メソッドを用いて『一連のState
において、以降に渡すPlayer
』として置き換え、さらに前と同様にmap
でログ文字列を導出する」ようなState
の変換・生成関数を渡しています。
手札の破棄
プレイヤー状態の変更における他の例として、何らかの原因により手札を捨て札にする場合を考えます。
手札のうち捨て札とするカードは当該プレイヤーの意思によって選択されます。したがって、その実装にはプレイヤーの操作待ち状態を介入させる必要があります。
これは後でも説明しますが、ゲームの全状態Game
のstate
メンバを用いることで表現します。State
と同じ名前を使用してしまっていますが、無関係であることに注意してください(詳細は前回参照)。
捨て札の選択など、プレイヤーによる意思決定が途中で必要となるような操作を実現する場合、「元状態を操作待ち状態にするStateモナド」と「操作待ち状態に対して、プレイヤー操作によってその解決をした状態にするStateモナド」の2つに分け、前者はstate
に間の「操作待ち状態」を代入するStateモナドとして表現します。
この状態のときに操作を行うと、操作に応じたStateモナドを後者の「解決」Stateモナドとして表現します。
これら2つのモナドによって、途中にプレイヤーの意思決定を要求するゲーム内操作が表現されます。
前半のモナドはゲームの全状態Game
に改変を加える必要があるため後回しとし、ここでは後半のモナドを作成できるようにします。
function disposeHand(targetHandIndices: number[]): State<Player, string> {
return State.get<Player>()
.flatMap(player => {
const removedCards = targetHandIndices.map(i => player.hand[i]).join(",");
const nextHand = player.hand.filter((_, i) => !targetHandIndices.includes(i));
const nextPlayer = {...player, hand: nextHand};
return State.put<Player>(nextPlayer)
.map(_ => `${player.name || player.id}が手札から${removedCards}を破棄しました`)
})
}
合成
ここで、作成した「家計から金を得る」「選択した手札を破棄する」という2つのStateモナドを結合して「選択した手札を破棄し、家計から金を得る」という新たなStateモナドを簡単に得ることができます。
const disposeState = disposeHand([0]);
const earnState = earn(6);
export const disposeAndEarn = disposeState.flatMap(disposeLog => earnState.map(earnLog => disposeLog + "\n" + earnLog));
// 手札の最初のカードを捨て、$6得る
上のように、単純にflatMap
で繋げてログを適当に整形するだけでよく、部品となる単純なStateモナドをひととおり用意すれば、ゲーム内に存在する「プレイヤー状態の変更」がその組み合わせだけでいくらでも作成できます。
テスト
このようにして作成されたState
のテストは、適切かつ単純な元状態$s\in S$を作成し、それを引数としたrun
メソッドの結果が、求める後状態と一致しているかどうかを確認するだけで書くことができます。
import "mocha";
...
describe("手札を1枚捨てて$6獲得", () => {
const originState: Player = {
id: "red",
name: "田中太郎",
hand: ["農場", "焼畑"],
cash: 10,
...
};
const result = disposeAndEarn.run(originState);
it("State.runの結果が「0番カードを捨てて持ち金が$6増えた状態」である", () => {
assert.deepEqual(result[1], {
id: "red",
name: "田中太郎",
hand: ["焼畑"],
cash: 16,
...
}, "出力状態が異なる");
assert.equal(result[0], "田中太郎が手札から農場を破棄しました\n田中太郎が家計から$6を獲得しました", "ログが異なる");
});
);
ゲーム全状態の更新
State
型の変換
これで「様々な操作によるプレイヤー状態の更新」が簡単に書けるようになりました。一方で、前の記事で示した通り、Cloud Firestoreを通じて共有されるゲームの全状態は、Player
をメンバに持つBoard
、それをさらにメンバに持つGame
でした。個人の状態ではなくゲーム全体に影響を与えるようなゲーム内操作も多く存在することを考えると、それらはすべてState<Game, string>
として表現することで、結合が簡単になるとともに、DBの更新方法も1種類に限定することができます。
つまり、State<Player, string>
をどうにかしてState<Game, string>
に変換する、いわば昇格させる必要があります。State<S, T>
のT
はmap
やflatMap
によって変換できますが、S
はどのように変換するのでしょうか?
ここで、Player
とGame
は一連の親子関係にあることを思い出しましょう。Game
からBoard
を辿って、改変したいPlayer
へたどり着き、そのPlayer
だけが改変された新たなGame
を次のように導くことができます。
function affectPlayer<T>(state: State<Player, T>, to: PlayerIdentifier): State<Board, T> {
return State.get<Board>()
.flatMap(board => {
const targetPlayer = Object.values(board.players).findIndex(p => p.id == to);
const result = state.run(board.players[targetPlayer]);
const nextBoard = {...board, players: {...board.players, [targetPlayer]: result.1}};
return State.put(board).map(_ => result.0);
});
}
function affectBoard<T>(state: State<Board, T>): State<Game, T> {
...
}
これによって、State<Player, T>
を、State<Board, T>
またはState<Game, T>
へ変換することが可能になりました。
「プレイヤー1人にだけ影響するゲーム内操作」をState<Game, string>
で記述すると、そのたびに対象プレイヤーをGame
から導出しなければならないため、そのような操作はState<Player, string>
で記述してから変換する方が単純に記述できます。
操作待ち状態の表現
最後に、「途中でプレイヤーの意思決定が要求される一連の操作」の表現を行いましょう。
とはいえ、その方針は既に示したように、「中間状態を作成して一連の操作を前半Stateと後半Stateに分ける」という単純なものです。後半Stateは既に示しており、さらに今ではそれをState<Game, string>
へ変換することもできますから、あとは前半部分をState<Game, string>
として実装すればおしまいです。前半・後半の操作の呼び分けはUI層で行うため、メカニクスの実装においては単純に、前半と後半それぞれのStateが生成できればOKです。
function marketFirstHalf(): State<Game, string> {
return State.get<Game>()
.flatMap(game => {
const prevState = game.currentState as InRoundState;
const player = game.board.players.find(p => p.id == prevState.currentPlayer);
return State.put({...game, state: {...prevState, phase: "oncardeffect", effecting: "市場"}})
.map(_ => `${player.name || player.id}は手札から破棄する2枚のカードを選んでいます`);
});
}
function marketSecondHalf(disposedHandIndices: number[], playerId: PlayerIdentifier): State<Game, string> {
const state = disposeHand(disposedHandIndices).flatMap(disposeLog => earn(12).map(earnLog => disposeLog + "\n" + earnLog));
return affectBoard(affectPlayer(state, playerId));
}
Game
は「中間状態ではその原因となったカード名を代入する」という要件で定義しているので、ここでは具体的なカードの例として市場(手札を2枚選んで捨て、家計から$12獲得する効果)を採用しました。
marketSecondHalf
の戻り値がState<Game, string>
であり、かつ「市場」の効果が発動するのはプレイヤーターン中だけ(Game.state
がInRoundState
)であることから、引数playerId
なしでも求めるState<Game, string>
を導出できます。ここでは省略しますがmarketFirstHalf
を見れば大まかな方針が分かると思われます。よかったら考えてみてください。
まとめ
今回は、Stateモナドを利用してゲーム内の各種操作を記述する手法について解説しました。
最終回はUIと通信部分を実装し、オンライン対戦ツールとして動くようにします。