♟️

Vue.js のための BOARDGAME.IO チュートリアル

はじめに

BOARDGAME.IO というターン制ゲーム用の JavaScript 製ゲームエンジンがあります。
https://boardgame.io/

今回は BOARDGAME.IO + Vue.js で公式ドキュメントに用意されている Tutorial を行う方法をご紹介します。
Vue プラグインを用いて BOARDGAME.IO に関する処理を隠蔽しつつ、コンポーネントにゲームの UI を記述していきます。

BOARDGAME.IO とは

Tutorial をはじめる前に BOARDGAME.IO について前提となる知識を共有します。

冒頭でもご紹介した通り、BOARDGAME.IO はターン制ゲームを作成するための JavaScript 製ゲームエンジンです。

boardgame.io is an engine for creating turn-based games using JavaScript.
https://www.npmjs.com/package/boardgame.io

状態管理

BOARDGAME.IO ではゲームの状態を Gctx という 2 つのオブジェクトで管理しています。

G は開発したゲームのルールに基づく状態、ctx はゲームのメタデータに関する状態です。
G は我々が、ctx はフレームワークがそれぞれ管理します。

たとえば、G には盤面の状態を表す配列だったり、プレイヤーの得点を表す数値だったりをプロパティとして保持しておきます。
また、ctx にはここまでの経過ターン数や、現在操作中のプレイヤーIDなどが保持されています。

これらを各所に受け渡しながらゲームのロジックを記述していきます。
これにより、どのターン制ゲームにおいても必要になるような機能の実装をフレームワークに任せることができます。

基本的な記法

ゲームクライアントを作るには、BOARDGAME.IO から提供される Client() にゲームのルールを記述したオブジェクト、今回は変数 game を渡して実行する必要があります。
また、その返り値のオブジェクトに含まれる start() メソッドを実行することでゲームを開始できます。

import { Client } from "boardgame.io/client";
const client = Client({ game });
client.start();
Client() の引数の型

Client() の引数は以下のような型になっています。
今回は game プロパティに次項で説明する「ゲームオブジェクト」をセットしています。

export interface ClientOpts<G extends any = any, PluginAPIs extends Record<string, unknown> = Record<string, unknown>> {
    game: Game<G, PluginAPIs>;
    debug?: DebugOpt | boolean;
    numPlayers?: number;
    multiplayer?: (opts: TransportOpts) => Transport;
    matchID?: string;
    playerID?: PlayerID;
    credentials?: string;
    enhancer?: StoreEnhancer;
}

ゲームオブジェクト

先ほど Client() に渡した、変数 game について詳しく見ていきます。
この変数はゲームのルールを定義するためのオブジェクトです。
この game のことを、以降は「ゲームオブジェクト」と呼称することにします。

ゲームオブジェクトに存在する代表的なプロパティは以下です。
今回のチュートリアルではこれらを定義しながらゲームを作成していきます。

プロパティ名 値の型 役割
setup 関数 ゲーム開始時の G の状態を定義します。
G と同じ型のオブジェクトを返却する必要があります
turn オブジェクト ターンに関するルールを定義できます
moves オブジェクト moves オブジェクトのプロパティとして、G を変化させるような関数を定義します。
特定のゲームイベントに対するイベントハンドラのような理解です。
endIf 関数 ゲーム終了判定を行う関数です。
ai オブジェクト コンピュータプレイヤーの動作を定義できます。
変数 game のサンプルコード
type GameState = {
  cells: (string | null)[];
};
const game: Game<GameState> = {
  setup: (): GameState => ({
    // ゲーム開始時、 cells の要素はすべて null とする
    cells: Array(9).fill(null),
  }),

  turn: {
    // プレイヤーは少なくとも 1 回操作(moves の発火)を行う必要がある
    minMoves: 1
  },

  moves: {
    // cell をクリックした際、cells の当該要素を更新する
    clickCell: ({ G, ctx }, id: number): typeof INVALID_MOVE | void => {
      G.cells[id] = ctx.currentPlayer;
    },
  },

  endIf: ({ G, ctx }) => {
    // もし勝利した場合、自身で定義したゲーム結果オブジェクトを返却する
    if (IsVictory(G.cells)) {
      return { winner: ctx.currentPlayer };
    }
  },

  ai: {
    // 空いているセルを見つけたらそれをクリックする
    enumerate: (G, ctx) => {
      let moves = [];
      for (let i = 0; i < 9; i++) {
        if (G.cells[i] === null) {
          moves.push({ move: 'clickCell', args: [i] });
        }
      }
      return moves;
    },
  },
};

その他の機能

その他にも以下のような特徴が紹介[1]されています。

  • リアルタイムかつクロスプラットフォームでゲーム状態を同期できる
  • フェーズごとに異なるルールやターン順を設定できる
  • プレイヤーのマッチメイキングやゲーム作成を簡単に行うための機能が用意されている
  • プラグインシステムを備えており、新しい抽象化や機能を追加しやすい
  • ゲームのログを保存し、「タイムトラベル」によって過去の盤面の状態を確認できる

チュートリアル

これ以降は BOARDGAME.IO > Documentation > Tutorial に準じて記事を構成します。
ハンズオン形式で、BOARDGAME.IO + Vue.js を用いて 〇×ゲーム(TicTacToe) を作成していきます。

セットアップ

本項では Vue アプリケーションの作成、boardgame.io のインストール、ディレクトリの整理をします。

1. Vue アプリケーションの作成

Vue.js 公式ドキュメントの通り、create-vue を利用してプロジェクトを作成します。

npm create vue@latest
✔ Project name: … <your-project-name>
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … No
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit testing? … No
✔ Add an End-to-End Testing Solution? … No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No

Scaffolding project in ./<your-project-name>...
Done.

2. BOARDGAME.IO のインストール

cd <your-project-name>
npm install boardgame.io

3. ディレクトリの整理

基本方針

まずはディレクトリ構成を決定するために基本方針を共有します。
以下のような基本方針で〇×ゲームを作成していきます。

整理

よって、作成されたプロジェクトを以下のように変更します。

  • src/assets フォルダ、src/components フォルダを削除する
    • ※チュートリアルでは利用しないため、念のため削除
  • src/game フォルダを作成し、src/game/client.ts ファイルを作成
  • src/plugin フォルダを作成し、src/game/boardgame.ts ファイルを作成
  vue-project
  ├── .vscode
  │   ├── extensions.json
  │   └── settings.json
  ├── public
  │   └── favicon.ico
  ├── src
- │   ├── assets
- │   │   └── ...
- │   ├── components
- │   │   └── ...
+ │   ├── game
+ │   │   └── client.ts
+ │   ├── plugin
+ │   │   └── boardgame.ts
  │   ├── App.vue
  │   └── main.ts
  ├── .editorconfig
  ...
  └── vite.config.ts

また、それに伴い App.vue から template タグ、main タグだけを残して削除、main.ts から ./assets/main.css の import 文を削除します。

App.vue
- <script setup>
- import HelloWorld from './components/HelloWorld.vue'
- import TheWelcome from './components/TheWelcome.vue'
- </script>
-
  <template>
-   <header>
-     <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
-
-     <div class="wrapper">
-       <HelloWorld msg="You did it!" />
-     </div>
-   </header>
-
    <main>
-     <TheWelcome />
    </main>
  </template>
-
- <style scoped>
- header {
-   line-height: 1.5;
- }
-
- .logo {
-   display: block;
-   margin: 0 auto 2rem;
- }
-
- @media (min-width: 1024px) {
-   header {
-     display: flex;
-     place-items: center;
-     padding-right: calc(var(--section-gap) / 2);
-   }
-
-   .logo {
-     margin: 0 2rem 0 0;
-   }
-
-   header .wrapper {
-     display: flex;
-     place-items: flex-start;
-     flex-wrap: wrap;
-   }
- }
- </style>
src/main.ts
- import './assets/main.css'
-
  import { createApp } from 'vue'
  import App from './App.vue'

  createApp(App).mount('#app')

ゲームオブジェクトを定義する

GAMEBOARD.IO では、「ゲームがどのように動作するか」を定義したオブジェクト(= ゲームオブジェクト)を作成し、それを GAMEBOARD.IO に伝えることでゲームを作成します。
ゲームオブジェクトのほとんどのプロパティがオプションであり、シンプルな形からはじめ、段階的に複雑度を上げることができます。

今回のチュートリアルでもこれを活かし、最低限の要素のみを含むゲームオブジェクトを実装し、徐々に機能を追加していきます。

本項ではまず、setup 関数と moves オブジェクトを含んだゲームオブジェクトを作成します。
setup 関数を定義してゲーム開始時の状態を、
moves オブジェクト内に関数を定義してプレイヤーのアクションによる状態変化を実装します。
また、 これらは src/game/client.ts に実装し、export します。

setup 関数

ゲーム開始時に行いたい処理を定義します。
関数の返り値として、ゲーム開始時の G となるオブジェクトを返却します。

今回は盤面を表す配列 cells を定義し、すべてに null を格納後、その配列をオブジェクトに格納して返却します。

src/game/client.ts
+ import type { Game } from "boardgame.io";
+
+ export type TicTacToeState = {
+   cells: (string | null)[];
+ };
+
+ export const TicTacToe: Game<TicTacToeState> = {
+   setup: (): TicTacToeState => ({
+     cells: Array(9).fill(null),
+   }),
+ }

moves オブジェクト

オブジェクトの値にゲーム内の任意のタイミングで実行したい関数を定義します。
これらは G をどのように更新するかを定義するための関数です。
この関数には第一引数として Gctx をフィールドとして含むオブジェクトが渡され、それ以降の引数は関数の実行時引数が渡されます。

今回はプレイヤーがセルを取得する関数を定義します。
セルがクリックされた際に発火し、該当のセルに紐づく配列の要素をプレイヤーIDに更新します。
この時、関数の実行時引数としてセルの id を定義します。

src/game/client.ts
  import type { Game } from "boardgame.io";

  export type TicTacToeState = {
    cells: (string | null)[];
  };

  export const TicTacToe: Game<TicTacToeState> = {
    setup: (): TicTacToeState => ({
      cells: Array(9).fill(null),
    }),
+
+   moves: {
+     clickCell: ({ G, ctx }, id: number): void => {
+       G.cells[id] = ctx.currentPlayer;
+     },
+   },
  }

ゲームクライアントを作成する

src/game/boardgame.tsVue プラグインを定義し、その中にゲームクライアントを作成と開始の処理を実装します。
また、ゲームクライアントとゲームの状態をコンポーネントへ提供する処理も併せて実装します。
boardgame.io の初期化処理と Vue.js 内へ状態を提供する処理を一箇所にまとめることで、アプリ全体で統一した状態管理とクライアントの利用が可能になります。

Vue プラグインの作り方

Vue の公式ドキュメントを参考に、Vue プラグインについて簡単におさらいします。

Vue プラグインとは、「Vue にアプリケーションレベルの機能を追加する」ためのコードです。[2]
Vue アプリケーションインスタンスの use() メソッドを利用し、プラグインを Vue アプリケーションへインストールします。
上記は一般的に、以下のようなコードで記述されます。
app.use(plugin) がプラグインのインストールを行うための記述です。

import { createApp } from 'vue'
import App from './App.vue'
import plugin from './plugin.ts'

const app = createApp(App)
app.use(plugin)

Vue プラグインをインストールするには以下のどちらかが必要です。
つまり、以下のどちらかを use() メソッドの引数として渡さなければなりません。

  1. install() メソッドを公開するオブジェクト
  2. インストール処理が記述された関数
一般的なサンプルコード
install() メソッドを公開するオブジェクト
const myPlugin = {
  install(app, options) {
    // アプリの設定をする
  }
}
インストール処理が記述された関数
function myPlugin(app, options) {
  // アプリの設定をする
}

BOARDGAME.IO の Vue プラグインを実装する

今回は、src/plugin/boardgame.ts に、「install() メソッドを公開するオブジェクト」を返却する関数を実装します。
この関数の実行結果を use() メソッドへ渡すことでプラグインのインストールを行います。

今回のプラグインでは以下の 3 つの処理を実装します。

  • BOARDGAME.IO を開始する
  • BOARDGAME.IO の状態をコンポーネントへ提供する
  • BOARDGAME.IO のゲームクライアントをコンポーネントへ提供する
プラグインのひな型を作る

各処理の実装の前に、プラグインのひな型を作成します。
今回は、プラグインオブジェクトを生成する関数を boardgameIo() と命名します。

src/plugin/boardgame.ts
+ export function boardgameIo() {
+   return {
+     install() {
+     },
+   };
+ }
BOARDGAME.IO を開始する

client.start() の実行によって BOARDGAME.IO を開始します。
boardgameIo() の引数としてゲームオブジェクトを受け取り、ゲームクライアントを作成した後、ゲームを開始します。

src/plugin/boardgame.ts
+ import type { Game } from "boardgame.io";
+ import { Client } from "boardgame.io/client";
+
- export function boardgameIo() {
+ export function boardgameIo(game: Game) {
+   const client = Client({ game });
+
    return {
      install() {
+       client.start();
      },
    };
  }
BOARDGAME.IO の状態、ゲームクライアントをコンポーネントへ提供する

Vue.js の Provide を用いて BOARDGAME.IO の状態とゲームクライアントをコンポーネントへ提供します。
Provide を用いた状態の提供では、リアクティブにした BOARDGAME.IO の状態をアプリケーション内のコンポーネント全体へ提供します。[3]

src/plugin/boardgame.ts
  import type { Game } from "boardgame.io";
  import { Client } from "boardgame.io/client";
+ import type { App } from "vue";
+ import { ref } from "vue";

  export function boardgameIo(game: Game) {
    const client = Client({ game });
+   const state = ref(client.getState());

    return {
-     install() {
+     install(app: App) {
        client.start();
+
+       // BOARDGAME.IO のステートが変わるたびに ref オブジェクトを更新
+       client.subscribe((s) => (state.value = s));
+       app.provide("BoardGameIo", { client, state });
      },
    };
  }
Provide / Inject の型付けを行う

先ほどのコードでは provide() のキーに文字列を指定しましたが、このままでは Inject した際に型が維持されません。
これを回避するため、Vue が提供している InjectionKey インタフェースを利用します。
これは Symbol を継承したジェネリック型で、Inject した際に Provide した値の型を維持させるために使用できます。

注入された値を適切に型付けするために、Vue は InjectionKey インターフェースを提供します。これは、Symbol を継承したジェネリック型で、provider(値を提供する側)と consumer(値を利用する側)の間で注入された値の型を同期させるために使用できます
https://ja.vuejs.org/guide/typescript/composition-api.html#typing-provide-inject

src/symbols.ts ファイルを作成し、本プラグイン用の InjectionKey インタフェースを定義します。

src/symbols.ts
+ import type { InjectionKey, Ref } from "vue";
+ import { Client } from "boardgame.io/client";
+ import type { TicTacToeState } from "./game/client";
+ import type { ClientState } from "boardgame.io/dist/types/src/client/client";
+
+ type BoardGameIo = {
+   client: ReturnType<typeof Client<TicTacToeState>>;
+   state: Ref<ClientState<TicTacToeState>>;
+ };
+ export const BoardGameIoKey: InjectionKey<BoardGameIo> = Symbol("boardgameIo");

Vue プラグインの実装も併せて変更します。

src/plugin/boardgame.ts
+ import { BoardGameIoKey } from "../symbols";
  import type { Game } from "boardgame.io";
  import { Client } from "boardgame.io/client";
  import type { App } from "vue";
  import { ref } from "vue";

  export function boardgameIo(game: Game) {
    const client = Client({ game });
    const state = ref(client.getState());

    return {
      install(app: App) {
        client.start();

        // BOARDGAME.IO のステートが変わるたびに ref オブジェクトを更新
        client.subscribe((s) => (state.value = s));
-       app.provide("BoardGameIo", { client, state });
+       app.provide(BoardGameIoKey, { client, state });
      },
    };
  }
Vue アプリケーションへインストールする

作ったプラグインを Vue アプリケーションへインストールします。

src/main.ts
  import { createApp } from 'vue'
  import App from './App.vue'
+ import { boardgameIo } from './plugin/boardgame'
+ import { TicTacToe } from './game/client'

- createApp(App).mount('#app')
+ createApp(App).use(boardgameIo(TicTacToe)).mount('#app')

サーバーの起動

この状態で Dev サーバーを起動するとゲームが開始されます。

npm run dev

画面表示に関する実装はまだ何もしていませんが、画面右端にデバッグパネルが表示されています。
これは BOARDGAME.IO がゲームの開始に伴ってレンダリングしたもので、このパネルを操作することで既に〇×ゲームをプレイできるようになっています。

いくつかの操作を紹介します。
セルの取得: デバッグパネル > Main > MOVES > clickCell() をクリック → キーボードで数字を入力 → キーボードでエンターを入力 で実行できます。
この時入力した数字はセルの id です。
これは clickCell() を定義した際、関数を実行するときに渡す引数として定義したものです。
実行後、デバッグパネル > Main > G > Object.cells を確認すると値が変化していることが確認できます。

ターンの終了: デバッグパネル > Main > EVENTS > endTurn() をクリック → キーボードでエンターを入力 で実行できます。
ターンの終了後にセルの取得処理を実行すると、選択したセルの値は "0" ではなく "1" となっており、ターンの終了に伴い現在操作中のプレイヤーIDが変化したことを確認できます。

これらの操作を組み合わせれば最後まで〇×ゲームをプレイできるはずです。

ゲームを完成させる

まだ最低限のゲームロジックしか実装していないので、〇×ゲームに必要なロジックを追加していきます。

空いているセルのみ取得できるようにする

現在の実装ではすでに取得済みのセルに対して clickCell() を実行するとそのセルは上書きされてしまいます。
それを防ぐため、clickCell() に「選択されたセルが null でない場合、取得できない」という処理を追加します。

moves に登録した関数の戻り値として BOARDGAME.IO が提供している INVALID_MOVE を返却することで、その手が無効であることを表すことができます。
これによりゲームエンジンは操作が失敗したと理解し、それに沿ってゲームが進行されます。

src/game/client.ts
  import type { Game } from "boardgame.io";
+ import { INVALID_MOVE } from "boardgame.io/core";

  export type TicTacToeState = {
    cells: (string | null)[];
  };

  export const TicTacToe: Game<TicTacToeState> = {
    setup: (): TicTacToeState => ({
      cells: Array(9).fill(null),
    }),

    moves: {
-     clickCell: ({ G, ctx }, id: number): void => {
+     clickCell: ({ G, ctx }, id: number): typeof INVALID_MOVE | void => {
+       if (G.cells[id] !== null) {
+         return INVALID_MOVE;
+       }
        G.cells[id] = ctx.currentPlayer;
      },
    },
  }

ターンの管理を実装する

先ほどデバッグパネルでゲームをプレイする方法を紹介しましたが、ターンの管理は endTurn() をクリックすることで行っていました。
〇×ゲームの場合、手番が終了するのは常にセルの取得が行われた時であるべきですので、そのように実装します。

GAMEBOARD.IO には手番を管理する方法がいくつかありますが、今回はMoves の実行回数でターンを制御します。
ゲームオブジェクトに turn プロパティを追加し、moves に登録した関数の最小実行回数、最大実行回数を定義することで実現できます。

turn.minMoves を 1 とすることで必ずセルの取得が行わなければならないことを表します。
また、turn.maxMoves を 1 とすることでセルの取得が 1 回行われたらターンを終了することを表します。

src/game/client.ts
  import type { Game } from "boardgame.io";
  import { INVALID_MOVE } from "boardgame.io/core";

  export type TicTacToeState = {
    cells: (string | null)[];
  };

  export const TicTacToe: Game<TicTacToeState> = {
    setup: (): TicTacToeState => ({
      cells: Array(9).fill(null),
    }),
+
+   turn: {
+     minMoves: 1,
+     maxMoves: 1,
+   },
+
    moves: {
      clickCell: ({ G, ctx }, id: number): void => {
        if (G.cells[id] !== null) {
          return INVALID_MOVE;
        }
        G.cells[id] = ctx.currentPlayer;
      },
    },
  }

ゲーム終了処理を実装する

勝利条件をロジックに反映し、ゲームの終了を実装します。
ゲームの終了を実装するには endIf プロパティに関数を定義する必要があります。
この関数でなんらかの値を返したとき、BOARDGAME.IO はゲームが終了したと理解します。

〇×ゲームには勝利と引き分けの 2 種類の終了条件が存在するため、それらの条件に合致する場合は何らかの値を返す必要があります。
今回は、勝利の場合に { winner: プレイヤーID }、引き分けの場合に { draw: true } を返却することにします。
また、勝利、引き分けを判定する関数をそれぞれゲームオブジェクト外に切り出して定義し、endIf の関数内で利用することにします。

src/game/client.ts
  import type { Game } from "boardgame.io";
  import { INVALID_MOVE } from "boardgame.io/core";

  export type TicTacToeState = {
    cells: (string | null)[];
  };

  export const TicTacToe: Game<TicTacToeState> = {
    setup: (): TicTacToeState => ({
      cells: Array(9).fill(null),
    }),

    turn: {
      minMoves: 1,
      maxMoves: 1,
    },

    moves: {
      clickCell: ({ G, ctx }, id: number): void => {
        if (G.cells[id] !== null) {
          return INVALID_MOVE;
        }
        G.cells[id] = ctx.currentPlayer;
      },
    },
+
+   endIf: ({ G, ctx }) => {
+     if (IsVictory(G.cells)) {
+       return { winner: ctx.currentPlayer };
+     }
+     if (IsDraw(G.cells)) {
+       return { draw: true };
+     }
+   },
  }
+
+ // 盤面が勝利条件に合致している場合、true を返す
+ function IsVictory(cells: TicTacToeState["cells"]) {
+   const positions = [
+     [0, 1, 2],
+     [3, 4, 5],
+     [6, 7, 8],
+     [0, 3, 6],
+     [1, 4, 7],
+     [2, 5, 8],
+     [0, 4, 8],
+     [2, 4, 6],
+   ];
+
+   const isRowComplete = (row: (typeof positions)[number]) => {
+     const symbols = row.map((i) => cells[i]);
+     return symbols.every((i) => i !== null && i === symbols[0]);
+   };
+
+   return positions.map(isRowComplete).some((i) => i === true);
+ }
+
+ // すべてのセルが埋まっている場合、true を返す
+ function IsDraw(cells: TicTacToeState["cells"]) {
+   return cells.filter((c) => c === null).length === 0;
+ }

ゲームの UI を実装する

src/App.vue にゲームの UI を実装します。
HTML の表要素を利用して盤面を表現し、セルのクリックイベントが発火したらゲームオブジェクトで定義したセルの取得関数が実行されるように定義します。

3 × 3 の盤面を作る

v-for を用いて 3 × 3 の盤面を作成します。
セルのクラス名を cell とし、簡単なスタイルも設定します。

App.vue
  <template>
    <main>
+     <table>
+       <tr v-for="(_, row) in 3" :key="row">
+         <td v-for="(_, col) in 3" :key="col" class="cell">
+         </td>
+       </tr>
+     </table>
    </main>
  </template>
+
+ <style>
+ .cell {
+   border: 1px solid #555;
+   width: 50px;
+   height: 50px;
+   line-height: 50px;
+   text-align: center;
+ }
+ </style>

BOARDGAME.IO を Inject する

先ほど作った Vue プラグインで Provide されている、BOARDGAME.IO のクライアントと状態を Inject します。

その際、バリデーションを行うことで値の有無を確かめます。
結果的に型安全になり、型エラーが解消されます。

また、クライアント、状態、セルの配列を変数へ格納しておきます。
BOARDGAME.IO 側で値が変化する状態とセルの配列は算出プロパティを使って値の変化を追従できるようにしておきます。

App.vue
+ <script setup lang="ts">
+ import { computed, inject } from "vue";
+ import { BoardGameIoKey } from "./symbols";
+
+ // BOARDGAME.IO の Inject
+ const boardGameIo = inject(BoardGameIoKey);
+ if (boardGameIo == undefined) {
+   throw new Error("injection failed")
+ }
+
+ const client = boardGameIo.client
+ const state = computed(() => {
+   const value = boardGameIo.state.value
+   if (value === null) {
+     throw new Error("Client state is null")
+   }
+   return value
+ })
+ const cells = computed(() => state.value.G.cells)
+ </script>
+
  <template>
    <table>
      <tr v-for="(_, row) in 3" :key="row">
        <td v-for="(_, col) in 3" :key="col" class="cell">
        </td>
      </tr>
    </table>
  </template>

  <style>
  .cell {
    border: 1px solid #555;
    width: 50px;
    height: 50px;
    line-height: 50px;
    text-align: center;
  }
  </style>

セルにイベントハンドラを設定する

ゲームオブジェクトに定義した clickCell() を使ってセルのクリックイベントを設定します。
引数にはセルの配列のインデックス番号を渡さなければならないので、行番号と列番号から該当のセルのインデックス番号を計算する関数も一緒に定義します。

App.vue
  import { computed, inject } from "vue";
  import { BoardGameIoKey } from "./symbols";

  <script setup lang="ts">
  // BOARDGAME.IO の Inject
  const boardGameIo = inject(BoardGameIoKey);
  if (boardGameIo == undefined) {
    throw new Error("injection failed")
  }

  const client = boardGameIo.client
  const state = computed(() => {
    const value = boardGameIo.state.value
    if (value === null) {
      throw new Error("Client state is null")
    }
    return value
  })
  const cells = computed(() => state.value.G.cells)
+
+ const culcCellId = (row: number, col: number) => row * 3 + col
  </script>

  <template>
    <table>
      <tr v-for="(_, row) in 3" :key="row">
-       <td v-for="(_, col) in 3" :key="col" class="cell">
+       <td v-for="(_, col) in 3" :key="col" class="cell" @click="client.moves.clickCell(culcCellId(row, col))">
        </td>
      </tr>
    </table>
  </template>

  <style>
  .cell {
    border: 1px solid #555;
    width: 50px;
    height: 50px;
    line-height: 50px;
    text-align: center;
  }
  </style>

セルの内容を表示する

セルの配列を用いてセルの内容を表示します。
配列の要素が null の場合は空文字が表示されるように実装します。

App.vue
  import { computed, inject } from "vue";
  import { BoardGameIoKey } from "./symbols";

  <script setup lang="ts">
  // BOARDGAME.IO の Inject
  const boardGameIo = inject(BoardGameIoKey);
  if (boardGameIo == undefined) {
    throw new Error("injection failed")
  }

  const client = boardGameIo.client
  const state = computed(() => {
    const value = boardGameIo.state.value
    if (value === null) {
      throw new Error("Client state is null")
    }
    return value
  })
  const cells = computed(() => state.value.G.cells)

  const culcCellId = (row: number, col: number) => row * 3 + col
  </script>

  <template>
    <table>
      <tr v-for="(_, row) in 3" :key="row">
        <td v-for="(_, col) in 3" :key="col" class="cell" @click="client.moves.clickCell(culcCellId(row, col))">
+         {{ cells[culcCellId(row, col)] ?? "" }}
        </td>
      </tr>
    </table>
  </template>

  <style>
  .cell {
    border: 1px solid #555;
    width: 50px;
    height: 50px;
    line-height: 50px;
    text-align: center;
  }
  </style>

結果を表示する

ゲーム終了時に表示するメッセージを実装します。

App.vue
  import { computed, inject } from "vue";
  import { BoardGameIoKey } from "./symbols";

  <script setup lang="ts">
  // BOARDGAME.IO の Inject
  const boardGameIo = inject(BoardGameIoKey);
  if (boardGameIo == undefined) {
    throw new Error("injection failed")
  }

  const client = boardGameIo.client
  const state = computed(() => {
    const value = boardGameIo.state.value
    if (value === null) {
      throw new Error("Client state is null")
    }
    return value
  })
  const cells = computed(() => state.value.G.cells)

  const culcCellId = (row: number, col: number) => row * 3 + col
+
+ const resultMsg = computed(() => {
+   const isGameOver = state.value.ctx.gameover
+   if (isGameOver) {
+     return isGameOver.winner !== undefined ? `Winner: ${isGameOver.winner}` : "Draw"
+   } else {
+     return ""
+   }
+ })
  </script>

  <template>
    <table>
      <tr v-for="(_, row) in 3" :key="row">
        <td v-for="(_, col) in 3" :key="col" class="cell" @click="client.moves.clickCell(culcCellId(row, col))">
          {{ cells[culcCellId(row, col)] ?? "" }}
        </td>
      </tr>
    </table>
+   <p>{{ resultMsg }}</p>
  </template>

  <style>
  .cell {
    border: 1px solid #555;
    width: 50px;
    height: 50px;
    line-height: 50px;
    text-align: center;
  }
  </style>

ボットを追加する

BOARDGAME.IO では、作成したゲームをプレイするボットを追加できます。
moves に登録した関数とその引数の組み合わせを選択肢としてボットへ伝えると、より勝利に近い手を選択してそれを実行します。
実装はゲームオブジェクトの ai プロパティに行います。

src/game/client.ts
  import type { Game } from "boardgame.io";
  import { INVALID_MOVE } from "boardgame.io/core";

  export type TicTacToeState = {
    cells: (string | null)[];
  };

  export const TicTacToe: Game<TicTacToeState> = {
    setup: (): TicTacToeState => ({
      cells: Array(9).fill(null),
    }),

    turn: {
      minMoves: 1,
      maxMoves: 1,
    },

    moves: {
      clickCell: ({ G, ctx }, id: number): void => {
        if (G.cells[id] !== null) {
          return INVALID_MOVE;
        }
        G.cells[id] = ctx.currentPlayer;
      },
    },

    endIf: ({ G, ctx }) => {
      if (IsVictory(G.cells)) {
        return { winner: ctx.currentPlayer };
      }
      if (IsDraw(G.cells)) {
        return { draw: true };
      }
    },
+
+   ai: {
+     enumerate: (G) => {
+       const moves = [];
+       for (let i = 0; i < 9; i++) {
+         if (G.cells[i] === null) {
+           moves.push({ move: "clickCell", args: [i] });
+         }
+       }
+       return moves;
+     },
+   },
  }

  // 盤面が勝利条件に合致している場合、true を返す
  function IsVictory(cells: TicTacToeState["cells"]) {
    const positions = [
      [0, 1, 2],
      [3, 4, 5],
      [6, 7, 8],
      [0, 3, 6],
      [1, 4, 7],
      [2, 5, 8],
      [0, 4, 8],
      [2, 4, 6],
    ];

    const isRowComplete = (row: (typeof positions)[number]) => {
      const symbols = row.map((i) => cells[i]);
      return symbols.every((i) => i !== null && i === symbols[0]);
    };

    return positions.map(isRowComplete).some((i) => i === true);
  }

  // すべてのセルが埋まっている場合、true を返す
  function IsDraw(cells: TicTacToeState["cells"]) {
    return cells.filter((c) => c === null).length === 0;
  }

これらの実装後、サーバーを起動させると画面のデバッグパネルに「AI」タブが追加されます。
AI タブでできる操作は以下のようなものです。

  • デバッグパネル > AI > Controls > play: ボットが 1 回 Moves を実行する
  • デバッグパネル > AI > Controls > simulate: ボットがゲームを最後までプレイする

ボットは内部で MCTS(モンテカルロ木探索) を利用してゲームツリーを探索し、より勝利に近い手を探し出します。
デフォルトでは 1 回の操作ごとに 1000 回のイテレーションを実行します。

脚注
  1. https://www.npmjs.com/package/boardgame.io#features ↩︎

  2. Vue.js 公式ドキュメントからの引用です。
    https://ja.vuejs.org/guide/reusability/plugins#introduction:~:text=プラグインは通常、 Vue にアプリケーションレベルの機能を追加する自己完結的なコードです。 ↩︎

  3. これは公式ドキュメントで「アプリケーションレベルの Provide」と呼ばれているものです。
    https://ja.vuejs.org/guide/components/provide-inject#app-level-provide ↩︎

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion