7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Link-UAdvent Calendar 2020

Day 2

TypeScript+React+Cloud Firestore+Stateモナドで、ブラウザで動くボードゲームの対戦ツールを作った (1/3 概要編)

Last updated at Posted at 2020-12-01

概要

image.png

  • 好きなボードゲーム(ナショナルエコノミー)のオンライン対戦環境が欲しかったので作った。
  • 遊びたいときに手軽に共有したかったので、ブラウザで動くものとした。
  • バックエンドを用意したり書いたりするのが面倒であり、また公開するつもりもなかったので、Cloud Firestoreでデータベースを用意して、ロジックはすべてフロントに書くサーバレス構造にした。
  • ゲーム中の状態を丸ごとDBに突っ込んで全プレイヤー(および観戦者)に共有し、行動によるゲーム状態変化をすべてStateモナドによって記述した。これによって、様々な種類が存在するゲーム内効果を平易に、かつ再利用性とテスタビリティが高い形で記述できるようにした。
  • フロントエンドはReactで記述した。
  • Webフロントエンド業務においてTypeScriptを利用しているので、その流れで全体をTypeScriptで記述した。
  • 本記事は概要編として全体の構造と、ゲーム状態エンティティについての説明を行う。
  • 全記事を読むことで、同様の構造を用いて様々なゲームのオンライン対戦環境を作成できるようになる。(たぶん)

注意

本記事で作成したオンライン対戦環境は、特に原作者に許諾を取って作成したものではなく、一般に公開もしていません。
抽象的な「ゲームのルール」は表現物ではなく、著作権による保護対象とはされていません。したがってゲームのアートや文章を流用しなければ、たとえ原作者に許諾を得ずにオンライン対戦環境を公開しても、知的財産権を侵害することはないと考えられます。

とはいえ、この対戦環境を作成したきっかけが、そもそも身内でのみ楽しみたいというものであったこと、また単純に気が引けたため、特定少数にのみ共有して遊んでいました(現在は動かしていません)。
本記事は技術的な側面についてのみ記述しています。

はじめに

環境

一般公開の予定がなく、ゲーム好きの友人とのみ遊べればそれでよかったので、Windowsにおいて新しめのバージョンのGoogle Chromeで動けばとりあえず問題ないだろう、という雑な想定で、各種ライブラリを適当に引っ張ってきました。そのため、本項目についてはあまり厳密に見ないことを推奨します。

  • TypeScript 3.8.3
  • Firebase JavaScript SDK 7.14.0
  • React 16.13.1
  • Mocha 7.1.1
  • power-assert 1.6.1
  • その他開発環境のためのwebpackとかいろいろ

ゲームについて

本記事で対象とする非電源ゲームナショナルエコノミーは、スパ帝国が作成し販売するゲームです。
プレイヤーは事業家となり、雇用した従業員を職場へ派遣したり、あるいは職場を作成して、彼らに給料を支払いつつ事業を拡大します。これによって最も多くの資産を形成した者が勝者となります。
このゲームをオンライン対戦環境作成の対象とした理由は、単に気に入ったので遊びたかったというごくシンプルなものですが、それ以上にこのゲームはオンライン対戦環境を作成する上で、以下の有利な特徴を持っています。

  • いわゆるワーカープレイスメントであり、各プレイヤーの手番が独立していて他プレイヤーの行動に対する割り込みが存在しない。そのため、リアルタイム性が要求されず、メカニクスの実装が容易である。
  • プレイヤー間の貿易といった、テキスト/ボイスチャットだけで完結しない交渉要素が存在しない。そのため、純粋にゲームメカニクス部分だけを実装すればシステムが完結する。
  • すべてのコンポーネントがカードで構成されており、いわゆるゲームボードやトークン類がない。そのため、UIが作成しやすい。

非電源ゲームの中には、人狼系ゲームのように数値的・記述的なメカニクスでなくコミュニケーションを主とするものや、他プレイヤーの行動に割り込むような動作が許可されるものも数多く存在します。このようなゲームのオンライン対戦環境作成においては、単にメカニクスを実装するのみならず、フレキシブルな交渉システムやラグの考慮など、実装難易度を引き上げる要素が存在するでしょう。

**記事中では、特に説明なくこのゲーム内の概念・呼称を用いることがあります。**厳密なルールはルール説明や各種レビュー、プレイ動画などを参照してください。厳密なルールを把握しなくても、技術的な要素については理解できるような書き方になるよう努めています。

全体構造

本環境においては、次のような関数型言語ガチ勢にツッコまれそうな構造でオンライン対戦機能が実現されます。

image.png

Cloud FireStoreにはゲームの全状態$s\in S$が置かれています。この状態$s$は、プレイヤーがhtmlを開いたとき、(1)Firebase SDKを通じて取得され、以降リアルタイムで自動的に同期されます。
取得した状態$s$に応じて、(2)Reactのコンポーネントがゲーム状態をDOMとして出力し、プレイヤーはそれを画面で見ることになります。
状態$s$がそのプレイヤーから見て「手出しできない」、つまり他プレイヤーの手番であったり、何かの処理待ちであったりする場合にはこれで終わりです。

image.png

しかし、何らかのゲーム的に合法な操作が可能である場合、Reactコンポーネントはその部分についてクリックなどの操作が可能であるようDOMを出力しています。これに**(3)プレイヤーが操作を行うと、(4)Reactコンポーネントは操作に対応するStateモナド$M_{Log, S}: S \rightarrow (Log, S)$をロジックに問い合わせ**ます。

image.png

(5)得られたStateモナドを現在のゲーム状態$s$に適用(run)すると、新状態$s'$(と操作ログ)が得られます。これをふたたび**(6)Firebase SDKを通じてCloud FireStoreに戻せ**ば、プレイヤー操作による状態更新が完了し、他プレイヤーにも自動的に新たな状態が同期されます。

なぜこのような構造にしたか?

ボードゲームにおける様々な操作は、共通点の多い、より小さな要素の結合とみなせることがしばしばあります。特に、カードベースのワーカプレイスメントであるナショナルエコノミーは、そのような小要素を非常に多く見出せます。

例えば、「山札からカードを1枚引く」という操作は、

  1. 山札の上から1枚のカードを取り除く。取り除かれたカードとともにその後の状態を返す
  2. カードを受け取って、手札にそれらのカードを加えた状態を返す

という小要素の結合であるとみなすことができます。これらはStateモナドか、または「ある引数を受け取ってStateモナドを返す関数」であるため、結合は非常に容易であり、単にbindするだけです。

また、これらの小要素は、それぞれを単独で生成し、適当な$s_{\mathrm{test}}\in S$にrunして、得られた$s_{\mathrm{test}}'$を理想の結果$s_{\mathrm{expected}}$と比較することで、簡単に単体テストを書くことができます。
もちろん、その結合である「手札からカードを2枚捨て、山札からカードを4枚引く」操作のStateモナドに対しても同様に単体テストを行えます。

そして、これらの小要素の一部は、さらに別の操作「手札からカードを2枚捨て、山札からカードを4枚引く」に再利用することができます。
単体テストされた部分操作Stateモナドの再利用を繰り返して、最終的にはゲーム内のすべての操作を記述できるため、ゲームメカニクスの実装が非常に堅牢かつ高速になります。
ゲームメカニクスのバグは発見も修正も非常にコストが大きいため、この再利用性とテスタビリティは見逃せないでしょう。

さらに、ゲームメカニクスの実装と完全に分離した形で、UIや通信部分を実装・テストすることができます。メカニクス部のテストと同様に、適当な状態$s\in S$を表示したり、通信したりすればよいだけだからです。

エンティティ構造

ゲームの各種状態、すなわちプレイヤー手札だの場札だの所持金だのを格納するためのエンティティはtypeで定義します。
これらのオブジェクトがCloud Firestoreからリアルタイム共有されることでゲームの全状態が判明し、必要なものをUIで表示します。
操作によって新たな状態へ遷移する際には、変数の書き換えではなく単に新状態オブジェクトを作成することで行い、実質的な「書き換え」はCloud Firestoreへの送信によってのみ行います。
そのため、メカニクス部にはvarletも一切使われません。

メンバの型

エンティティの保持をCloud Firestoreのみが行う以上、エンティティのメンバはすべて保持可能なものである必要があります。すなわち、数値か文字列、あるいはその配列やオブジェクトのみが許されるという意味です。
とはいえ、ボードゲームの状態を記述するエンティティに関数が必要になることはないはずです。たとえば、プレイヤーの状態は次のように記述されます。

player.ts
import { CardName } from "./card";
import { Building } from "./building";

export type PlayerIdentifier = "red" | "purple" | "orange" | "blue";
export type Player = {
    id: PlayerIdentifier;
    name?: string;
    hand: CardName[];
    workers: {available: number, training: number, employed: number};
    buildings: Building[];
    cash: number;
    victoryToken: number;
    reservedCards: CardName[];
    penalty: number;
};

CardName型は単にカード名文字列を並べた共用体型であり、DB上では文字列で扱われます。Building型はプレイヤーが建てたり、公共の建物として用意される「職場」であり、説明は省きますがやはり文字列だけで表現できるように定義されています。

また、これらを格納するための「盤上の全状態」が次のように記述されます。

board.ts
import { Player, PlayerIdentifier } from "./player";
import { Building } from "./building";
import { CardName } from "./card";

export type Board = {
    currentRound: number;
    deck: CardName[];
    trash: CardName[];
    houseHold: number;
    players: {[index: number]: Player};
    startPlayer: PlayerIdentifier;
    publicBuildings: Building[];
    soldBuildings: Building[];
};

このBoardはさらに、ゲームの全状態を格納するGame型のメンバとなり、それがまとめてCloud Firestoreを通じて全ユーザーに共有されます。

「操作待ち状態」の表現

ボードゲームにおいて物理的に表現される状態は、ルールどおりに数値や文字列を定義することで実装できます。一方で、「プレイヤーが効果の選択肢が複数あるような操作を行った。現在、当該プレイヤーはどの選択肢を選ぶかの待ち状態である」という表現も状態変数の中に盛り込みたいところです。それには次のような理由が挙げられます。

  • 「山札の上から複数枚見て、そのうち1枚を手札に加える」という操作があったとき、その途中状態に遷移したことをDBに知らせない、すなわち手番プレイヤーのローカルでのみ状態を保持している場合、手番プレイヤーは次の選択操作を行わずにページを一度閉じることで、実質的に山札の上を見たうえでキャンセルができてしまう。
  • 操作待ち状態の進捗も他プレイヤーに共有することで、待ち時間のストレス軽減につながる。
  • 複数プレイヤーが同時に進行できる操作があるとき、各プレイヤーの状態を確認しつつゲームプレイが高速化できる。

このツールでは、次のように「操作待ち状態」を定義しています。

game.ts
import { Board } from "./board";
import { InRoundState, ExRoundState, ResultState } from "./state";

export type Game = {
    board: Board;
    state: InRoundState | ExRoundState | ResultState;
};
state.ts
import { PlayerIdentifier } from "./player";
import { CardName } from "./card";

export type InRoundState = {
    currentPlayer: PlayerIdentifier;
    phase: "dispatching" | "oncardeffect";
    effecting?: CardName;
    revealing?: CardName[]
};

export type ExRoundPlayerStatus = "selling" | "discarding" | "confirm" | "finish";
export type ExRoundState = {[key: string]: ExRoundPlayerStatus};

export type PlayerResult = {
    cash: number;
    buildings: number;
    victoryToken: number;
    penalty: number;
    bonus: number; 
    total: number;
};

export type ResultState = {
    winner: string;
};

Game型のstateInRoundState(プレイヤー手番の行動選択または効果選択待ち)、ExRoundState(ラウンド終了時の清算処理における各プレイヤーの操作待ち)、ResultState(結果表示)のいずれかを取ることにより、今誰が何の操作を行えるのかが判断できるようになっています。

まとめ

本記事では、ボードゲームのオンライン対戦環境作成において、そのさわりとなる概要とエンティティの説明を行いました。
次回はコアロジックであるゲームメカニクスを、Stateモナドを用いて実装する手法について解説します。

参考文献

Haskell 状態系モナド 超入門
Cloud Firestore

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?