フルスタックなフレームワーク「Meteor」で始める超高速ブラウザーゲーム開発

2017/09/22

Paul Orac

44

Articles in this issue reproduced from SitePoint
Copyright © 2017, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2017, KADOKAWA ASCII Research Laboratories, Inc. Japanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

Webアプリの開発からリリースまで高速で進められるフルスタックなJavaScriptフレームワーク「Meteor」。シンプルな「三目並べ」を作りながら、ブラウザーゲームの作り方を学びましょう。

Meteorプロトタイプを簡単に作成できて、開発からリリースまで超高速に進められることで人気のフルスタックWebフレームワークです。リアクティブな特性とDDPを使うことで、シンプルなマルチプレイヤー型ブラウザーゲームの作成に適しています。

このチュートリアルでは、Meteorとフロントエンドの標準テンプレートエンジンBlazeを使って「マルチプレイヤー三目並べ」を作ります。Meteorを使ったことがあり、JavaScriptのコーディングに慣れている前提で進めます。

Meteor未経験なら、まずはMeteorの公式サイトにあるToDoリストを使ったアプリのチュートリアルがおすすめです。

完成したアプリのコードは付属のGitHubリポジトリにあります。

アプリの作成

Meteorをまだインストールしていない場合は、公式サイトの手順に従い、OSに合ったものをインストールします。

ベースを用意する

Meteorをインストールしたら、ターミナルを開いて以下のコマンドを実行します。

meteor create TicTacToe-Tutorial

アプリの名前のフォルダーが作成されます(この場合はTicTacToe-Tutorial)。フォルダー内部はアプリの基本的なファイル構造が作成され、単純なアプリも入っています。

フォルダーに移動します。

cd TicTacToe-Tutorial

アプリを実行します。

meteor

覚えにくいコマンドですが、頻繁に使うので覚えてください。

成功なら、コンソールでアプリがビルドされます。完了後、ブラウザーを開いてhttp://localhost:3000にアクセスし、アプリが実行中か確認します。この作業がはじめてなら、サンプルアプリを少し触って、動作の仕組みを理解することをおすすめします。

ファイル構造を確認します。アプリのフォルダーを開いてください。(現時点での)ポイントはclientフォルダーとserverフォルダーです。clientフォルダーのファイルはクライアントがダウンロードして実行し、serverフォルダーのファイルはサーバー側でのみ実行されます。

新規作成したフォルダーには以下のファイルが入っています。

client/main.js        # a JavaScript entry point loaded on the client
client/main.html      # an HTML file that defines view templates
client/main.css       # a CSS file to define your app's styles
server/main.js        # a JavaScript entry point loaded on the server
package.json          # a control file for installing NPM packages
.meteor               # internal Meteor files
.gitignore            # a control file for git

盤の作成

三目並べの盤は単純な3×3のテーブルです。なんの変哲もない盤ですが、機能の作成に集中できるので最初のマルチプレイヤーゲームには適しています。

盤はクライアントがダウンロードするので、clientフォルダー内でファイルを編集します。最初にmain.htmlの内容を削除して以下に置き換えます。

client/main.html
<head>
  <title>tic-tac-toe</title>
</head>

<body>
  <table id="board">
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
  </table>
</body>

変更後は忘れずにファイルを保存してください。保存しないとMeteorが変更を認識しません。

次は盤にCSSを追加します。main.cssを開き、以下の内容を追加します。

client/main.css
table
{
  margin: auto;
  font-family: arial;
}

.field
{
  height: 200px;
  width: 200px;
  background-color: lightgrey;
  overflow: hidden;
}

#ui
{
  text-align: center;
}

#play-btn
{
  width: 100px;
  height: 50px;
  font-size: 25px;
}

.mark
{
  text-align: center;
  font-size: 150px;
  overflow: hidden;
  padding: 0px;
  margin: 0px;
}

.selectableField
{
  text-align: center;
  height: 200px;
  width: 200px;
  padding: 0px;
  margin: 0px;
}

IDとクラスをいくつか追加しました。これらはあとで使います。

最後にclient/main.jsは不要なので削除します。削除したら、ブラウザーでアプリを開いて確認します。

悪くはありませんが、改善の余地はあります。Blazeテンプレートを導入してリファクタリングします。

テンプレートの作成

テンプレートは独自の機能を持ったHTMLコードで、アプリ内の任意の場所で何度でも利用できます。テンプレートでアプリを再利用可能なコンポーネントに分解できます。

最初のテンプレートを作る前に、clientフォルダーに2つのフォルダーを追加します。フォルダー名はhtmljsです。

htmlフォルダーにboard.htmlを作成し、以下を追加します。

client/html/board.html
<template name="board">
  <table id="board">
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
    <tr>
      <td class="field"></td>
      <td class="field"></td>
      <td class="field"></td>
    </tr>
  </table>
</template>

次に、main.htmlのbodyタグを以下のコードに置き換えます。

client/main.html
<head>
  <title>tic-tac-toe</title>
</head>

<body>
  {{>board}}
</body>

name="board"プロパティを持つテンプレートがbodyタグに挿入されます。

これは以前ハードコードした盤と同じです。ただし、テンプレート内にコードがあるので、テンプレートヘルパーを利用して動的に盤を作成します。

ヘルパーの使用

盤のテンプレート内で、盤のマスと同じ長さの配列を提供するヘルパーを宣言します。

jsフォルダーにファイルboard.jsを作成し、以下の内容を追加します。

client/js/board.js
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';

Template.board.helpers({
  sideLength: () =&gt; {
    let side = new Array(3);
    side.fill(0);

    return side;
  }
});

ヘルパーを盤のテンプレートHTMLで使い、ヘルパーが提供する一次元配列の要素に対して反復処理をします。反復処理を実装するにはSpecebarsのブロックヘルパーであるEach-inを使います。

board.htmlの内容を以下に置き換えます。

client/html/board.html
<template name="board">
  <table id="board">
    {{#each sideLength}}
      {{#let rowIndex=@index}}
      <tr>
        {{#each sideLength}}
        <td class="field" id="{{rowIndex}}{{@index}}">
          {{{isMarked rowIndex @index}}}
        </td>
        {{/each}}
      </tr>
      {{/let}}
    {{/each}}
  </table>
</template>

配列に対するループ処理を、縦方向の1回と横方向の1回、合計2回実施し、対応するタグ(trまたはtd)のインスタンスを生成します。また、縦の@indexと横の@indexを組み合わせてタグのidプロパティにセットします。これにより要素を識別し、要素が盤上のどのマスかを判別する2桁の数値を得られます。

http://localhost:3000で現時点のアプリの表示を確認してください。

ユーザーインターフェイス

美しい盤が出来上がったので、次はプレイボタンと、ゲームの状態を表示するタグを作ります。

htmlフォルダーにui.htmlを作ります。お決まりのパターンですね。ファイルに以下を追加します。

client/html/ui.html
<template name ="ui">
  <div id="ui">
    {{#if inGame}}
      <p id="status">
      {{status}}
      </p>
    {{else}}
      <button id="play-btn">Play</button>
    {{/if}}
  </div>
</template>

スペースバーの#ifブロックヘルパーと、inGameヘルパー(この時点では未定義)を条件に使っています。タグ内のstatusヘルパーもあとで定義します。

このコードはどう動くのでしょうか? #if文でinGameヘルパーがtrueを返すと、プレイヤーにはstatusヘルパーの内容を表示して、そうでない場合はプレイボタンを表示します。

このコンポーネントを表示するには、clientのメインテンプレートにコンポーネントを追加します。

client/main.html
<head>
  <title>tic-tac-toe</title>
</head>

<body>
  {{>ui}}
  {{>board}}
</body>

ログイン

ログインUIは作らず、便利なパッケージbrettle:accounts-anonymous-autoをインストールします。brettle:accounts-anonymous-autoはすべてのユーザーを自動的に匿名でログインさせます。

コンソールで以下のコマンドを実行します。

meteor add brettle:accounts-anonymous-auto

パッケージを追加してからアプリを開くと、ユーザーが新規作成されます。同じブラウザーでアプリを開くかぎり、パッケージはユーザーを記憶します。保存するユーザーデータがない場合はログアウト時にユーザーを削除したほうが良いのかもしれませんが、このチュートリアルでは扱いません。

ゲームの作成

ゲームの作成に取り掛かります。作業の見通しを明確にするために、実装する機能を確認します。

必要な機能

  • ゲームの作成
  • 既存のゲームへの参加
  • 行動(OやXを打つ)
  • 勝利条件の設定
  • ゲームの状態をプレイヤーに表示
  • 終了したゲームのインスタンスを破棄

Meteorの遅延補正(Latency Compensation)を利用するために、コードの大半をクライアントとサーバーの両方からアクセスできる場所に配置します。

そこで、プロジェクトのルートにフォルダー「lib」を作ります。libフォルダー内はすべてクライアントにダウンロードされます。APIキーや非公開機能を誤ってクライアントに渡さないため、細心の注意を払って扱います。

ゲームのコレクション

MeteorはMongoのコレクションを利用します。Mongoをあまり知らなくても、ドキュメント指向データベースを使ったことがあれば大丈夫です。使ったことがない人のために説明すると、コレクションはテーブルのようなものです。テーブルの各行(ドキュメント)は独立しています。ある行が6つの列(フィールド)を持っていたとしても、同じテーブルの別の行にはまったく異なる4つの列を格納できます。

コレクションを1つ作成し、クライアントとサーバーの両方からアクセスするために、libフォルダーにgames.jsを作成します。作成したファイル内でコレクションのインスタンス「games」を生成し、グローバル変数のGamesに格納します。

lib/games.js
import { Mongo } from 'meteor/mongo';

Games = new Mongo.Collection("games");

プレイヤーにデータベースとゲームロジックへのアクセス権を与えるのか疑問に思っている人がいるかもしれません。プレイヤーにはローカルアクセス権だけを与えています。Meteorは、あとで説明するPublish-Subscribeパターンでのみデータを追加できる「ローカル環境用のミニMongoデータベース」をユーザーに提供します。クライアントがアクセスできるのはこのデータベースだけです。仮にクライアントがローカルデータベースに書き込んだとしても、その情報がサーバー側のデータベースと一致しなければ、書き込んだ情報は無効になります。

しかし、Meteorにはセキュリティ上のリスクが高いパッケージが標準でインストールされています。その1つがautopublishです。自動的にすべてのコレクションを公開しクライアントが購読できます。ほかにもクライアントにデータベースへの書き込みアクセス権を付与するinsecureもあります。

これら2つのパッケージはプロトタイプの作成では重宝しますが、今回はアンインストールします。コンソールで以下のコマンドを実行します。

meteor remove insecure
meteor remove autopublish

不要なパッケージを削除しました。次はクライアント側の動作とサーバー側の動作を同期させる仕組みが必要です。Meteor Methodsに話を進めます。

games.playメソッド

Meteor.methodsはメソッドを登録できるオブジェクトです。Meteor.call関数で登録したメソッドをクライアントから呼び出します。メソッドはクライアント側で実行し、続いてサーバー側で実行します。クライアント側ではメソッドとローカルMongoデータベースで、変更を瞬時に反映します。サーバー側ではメインデータベースに対し同じコードを実行します。

gamesコレクションに空のgames.playメソッドを作成します。

lib/games.js
Meteor.methods({
  "games.play"() {

  }
});

ゲームの作成

libフォルダーにファイル「gameLogic.js」を作成し、newGameメソッドを持つGameLogicクラスを作成します。このメソッドで新たなドキュメントをゲームのコレクションに挿入します。

lib/gameLogic.js
class GameLogic
{
  newGame() {
    if(!this.userIsAlreadyPlaying()) {
      Games.insert({
        player1: Meteor.userId(),
        player2: "",
        moves: [],
        status: "waiting",
        result: ""
      });
    }
  }
}

新たなゲームを挿入する前にプレイヤーがすでにゲームをプレイしているか確認します。今回はプレイヤーが複数のゲームを同時にプレイすることをサポートしません。この処理はとても重要です。省略すると重大なバグが発生する可能性があります。

newGame()メソッドの下にuserIsAlreadyPlayingメソッドを追加します。

lib/gameLogic.js
userIsAlreadyPlaying() {
  const game = Games.findOne({$or:[
    {player1: Meteor.userId()},
    {player2: Meteor.userId()}]
  });

  if(game !== undefined)
    return true;

  return false;
}

新たなゲームの開始処理を解説します。

プレイヤーがプレイボタンを押すと、アプリは参加可能な既存のゲームを探します。参加可能なゲームが見つからなければ、ゲームを新規作成します。今回のモデルでは、player1はゲームを作成したプレイヤーで、player2は空の文字列であり、statusはデフォルトでは「waiting(待機中)」です。

別のプレイヤーがプレイボタンを押すと、アプリはplayer2フィールドが空でstatusフィールドの値が「waiting」のゲームを探します。そして、プレイヤーをplayer2にセットしてstatusを変更します。

games.jsにあるMeteorメソッドがGameLogicクラスを利用するため、クラスのインスタンスをエクスポートし、games.jsファイルにインポートします。以下の1行をgameLogic.jsのファイル末尾(クラスの外)に追加します。

export const gameLogic = new GameLogic();

以下の1行をgames.jsファイルの先頭に追加します。

import { gameLogic } from './gameLogic.js';

これで、ロジックを空のgames.play()メソッドに追加できます。最初にステータスが「waiting」のゲームを探し、見つからなければnewGame()を呼び出します。

lib/games.js
Meteor.methods({
  "games.play"() {
    const game = Games.findOne({status: "waiting"});

    if(game === undefined) {
      gameLogic.newGame();
    }
  }
});

パブリケーション

ゲームを見つけるため、クライアントにgamesコレクションへのアクセス件を与えます。パブリケーションを使えば公開したいデータだけをクライアントに見せられます。クライアントにパブリケーションを購読させれば、クライアントがデータを利用できます。

プレイヤーにgamesコレクションへのアクセス権を与えるため「Games」パブリケーションを作成します。ただし、プレイヤーが新たなゲームに追加されたら、そのゲームの全フィールドへのアクセス権をプレイヤーに与えるため「My game」パブリケーションも用意します。

serverフォルダーのmain.jsを開き、ファイルの内容を置き換えます。

server/main.js
import { Meteor } from 'meteor/meteor';

Meteor.publish('Games', function gamesPublication() {
  return Games.find({status: "waiting"}, {
    fields:{
      "status": 1,
      "player1": 1,
      "player2": 1
    }
  });
});

Meteor.publish('MyGame', function myGamePublication() {
  return Games.find({$or:[
      {player1: this.userId},
      {player2: this.userId}]
    });
});

「Games」パブリケーションを購読します。UIテンプレートのonCreatedメソッドのコールバックで実現します。

client/jsui.jsを作成し、以下を追加します。

import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';

Template.ui.onCreated(() => {
  Meteor.subscribe('Games');
});

プレイイベント

テンプレートにはeventsオブジェクトが用意されています。このオブジェクトにはイベントを登録します。UIテンプレートにイベントを作成します。プレイヤーがID「play-btn」を持ったDOM要素をクリックしたときに、セッション変数のinGametrueをセットし、games.playメソッドを呼び出し、MyGameコレクションを購読します。

セッション変数はクライアントコードのどの場所でも使用でき、テンプレートをまたいでの使用も可能です。セッション変数を使うにはSessionパッケージを追加します。

meteor add session

ui.jsを開き、onCreatedメソッドのあとに以下を追加します。

client/js/ui.js
Template.ui.events({
  "click #play-btn": () => {
    Session.set("inGame", true);
    Meteor.call("games.play");
    Meteor.subscribe('MyGame');
  }
});

使用するパッケージは各ファイル内でインポートします。ui.jsファイルはSessionパッケージを使うのでインポートします。ファイルの先頭に以下を追加します。

import { Session } from 'meteor/session';

良いですね。次はヘルパーをいくつか追加します。ui.htmlでは、inGameヘルパーとstatusヘルパーを使いました。これらをeventsオブジェクトに宣言します。

client/js/ui.js
Template.ui.helpers({
  inGame: () => {
    return Session.get("inGame");
  },
  status: () => {

  }
});

inGameヘルパーはセッション変数inGameに格納された値を返します。statusヘルパーはひとまず空にします。

ゲームへの参加

ここまで来れば、ゲームへの参加はかなり簡単に実装できます。

joinGameメソッドをGameLogicクラスに追加します。

lib/gameLogic.js
joinGame(game) {
  if(game.player2 === "" && Meteor.userId() !== undefined) {
    Games.update(
      {_id: game._id},
      {$set: {
        "player2": Meteor.userId(),
        "status": game.player1
        }
      }
    );      
  }
}

変数gameを渡し、player2のフィールドにプレイヤーの_idを、statusフィールドにplayer1_id_をセットします。これでどちらのターンか判別します。

このメソッドをgames.play()から呼び出します。games.jsを開き、games.playメソッドを以下に置き換えます。

lib/games.js
Meteor.methods({
  "games.play"() {
    const game = Games.findOne({status: "waiting"});

    if(game === undefined) {
      gameLogic.newGame();
    } else if(game !== undefined && game.player1 !== this.userId && game.player2 === "") {
      gameLogic.joinGame(game);
    }
  }
});

3つの条件を持つelse ifを追加しました。ゲームが見つかり、player1がこのプレイヤーではなく、player2が空の文字列の場合、ゲームに参加します。

行動(OやXを打つ):ロジック

新たなゲームのモデルを定義したとき、空の配列([])をデフォルト値に持つmovesフィールドを宣言します。move(指し手)は、その手を指したプレイヤーの_idと、選択したマスのJSONオブジェクトです。

games.jsを開きgames.play()の下に以下のメソッドを追加します。Meteor.methodsはJSONオブジェクトを受け取るため、各メソッドをコンマで区切ります。

lib/games.js
"games.makeMove"(position) {
  check(position, String);

  gameLogic.validatePosition(position);

  let game = Games.findOne({status: this.userId});

  if(game !== undefined) {
    gameLogic.addNewMove(position);

    if(gameLogic.checkIfGameWasWon()) {
      gameLogic.setGameResult(game._id, this.userId);
    } else {
      if(game.moves.length === 8) {
        gameLogic.setGameResult(game._id, "tie");
      } else {
        gameLogic.updateTurn(game);
      }
    }
  }
}

メソッドを順を追って解説します。メソッドは文字列positionをパラメータとして受け取り、checkパッケージで、受け取ったパラメータが文字列かつサーバーを攻撃する悪意がないことを確認して、positionを検証します。

statusフィールドが手を指したプレイヤーの_idと同じ値のゲームを探し、プレイヤーのターンかを確認します。プレイヤーのターンなら、指し手をmoves配列に追加します。指し手によってゲームに決着がついたか確認し、決着がつけば、プレイヤーを勝者に設定します。決着がついていない場合、配列内に指し手が8個あれば引き分けを宣言します。指し手が8個未満なら相手プレイヤーにターンを渡します。

ui.jsにおけるSessionパッケージと同様で、games.jscheckパッケージをインポートします。以下をファイルの先頭に追加します。

import { check } from 'meteor/check';

上のコードは、GameLogicクラスの未定義のメソッドを使っているので、これらのメソッドを定義します。

gameLogic.jsを開いてGameLogicクラスに以下のメソッドを追加します。

validatePosition()
validatePosition(position) {
  for (let x = 0; x < 3; x++) {
    for (let y = 0; y < 3; y++) {
      if (position === x + '' + y)
        return true;
    }
  }

  throw new Meteor.Error('invalid-position', "Selected position does not exist... please stop trying to hack the game!!");
}

3×3のグリッドを走査し、受け取ったpositionが範囲内に入っているか確認します。クライアントから受け取ったpositionがグリッド内に収まっていない場合、エラーを出します。

addNewMove()
addNewMove(position) {
  Games.update(
    {status: Meteor.userId()},
    {
      $push: {
        moves: {playerID: Meteor.userId(), move: position}
      }
    }
  );
}

Mongoの$push演算子で、行動中プレイヤーの_idpositionを格納した新たな指し手を配列に追加します。

setGameResult()
setGameResult(gameId, result) {
  Games.update(
    {_id: gameId},
    {
      $set: {
        "result": result,
        "status": "end"
      }
    }
  );
}

再び$set演算子を使用し、resultフィールドをresultパラメータの値に更新します。値にはどちらかのプレイヤーの_id、または「tie」が入ります。さらに、statusフィールドに「end」をセットします。

updateTurn()
updateTurn(game) {
  let nextPlayer;

  if(game.player1 === Meteor.userId())
    nextPlayer = game.player2;
  else
    nextPlayer = game.player1;

  Games.update(
    {status: Meteor.userId()},
    {
      $set: {
        "status": nextPlayer
      }
    }
  );
}

このメソッドは比較的簡単です。両方のプレイヤーをパラメータで受け取り、行動中のプレイヤーを判定し、行動していないプレイヤーの_idstatusフィールドにセットします。

ゲームの勝敗

games.makeMoveメソッドで使用するメソッドの中で宣言していないメソッドが1つあります。勝ちを判定するアルゴリズムです。このチュートリアルでは個人的に考えつくものの中でもっとも直感的で単純な方法で三目並べの勝者を求めます。

gameLogic.jsを開き、GameLogicクラスに以下のメソッドを追加します。

lib/gameLogic.js
checkIfGameWasWon() {
  const game = Games.findOne({status: Meteor.userId()});

  const wins = [
  ['00', '11', '22'],
  ['00', '01', '02'],
  ['10', '11', '12'],
  ['20', '21', '22'],
  ['00', '10', '20'],
  ['01', '11', '21'],
  ['02', '12', '22']
  ];

  let winCounts = [0,0,0,0,0,0,0];

  for(let i = 0; i < game.moves.length; i++) {
    if(game.moves[i].playerID === Meteor.userId()) {
      const move = game.moves[i].move;

      for(let j = 0; j < wins.length; j++) {
        if(wins[j][0] == move || wins[j][1] == move || wins[j][2] == move)
        winCounts[j] ++;
      }
    }
  }

  for(let i = 0; i < winCounts.length; i++) {
    if(winCounts[i] === 3)
      return true;
  }

  return false;
}

メソッドを詳しく解説します。

プレイ中のゲームを見つけて、勝ちに該当する組み合わせがすべて入った行列と、0が7つ入った配列を宣言します。この7つの要素が各組み合わせに対応します。行動中のプレイヤーが指したすべての手に対してループ処理を実行し、各組み合わせのすべてのマスと比較します。一致するたびに、winCountが対応するインデックスに1を加えます。winCountのいずれかのインデックスが3になれば、行動中のプレイヤーが勝利したということです。

一目見て分からなくても心配しないでください。一休みして、コーヒーでも飲んで、目の疲れを取ってから何度か見直してください。コードの説明が複雑なら、コードを読んで動作を理解したほうがわかりやすい場合もあります。

移動(OやXを打つ):コントローラー

このゲームのプレイヤーの操作は「クリック」だけなので、コントローラーの実装はとても簡単です。board.jsを開き、helpersのあとにテンプレートのeventsオブジェクトを追加します。

client/js/board.js
Template.board.events({
  "click .selectableField": (event) => {
    Meteor.call("games.makeMove", event.target.id);
  }
});

簡単ですよね。プレイヤーが「selectableField」クラスを持つDOM要素をクリックすると、games.makeMoveメソッドを呼び出します。メソッドにはDOM要素のIDをpositionパラメータで渡します。ID名はグリッド上の要素の位置に基づいています。必要ならboard.htmlを確認してください。

指し手の表示

同じファイルにヘルパー「isMarked」を作ります。このヘルパーはmarkselectableFieldsを入れ替えます。これにより、どのマスが選択済みか分かり、未選択のマスを選択できます。

sideLengthヘルパーの下にこのヘルパーを追加します。

client/js/board.js
isMarked: (x, y) => {
  if(Session.get("inGame")) {
    let myGame = Games.findOne();

    if(myGame !== undefined && myGame.status !== "waiting") {
      for(let i = 0; i < myGame.moves.length; i++) {
        if(myGame.moves[i].move === x + '' + y) {
          if(myGame.moves[i].playerID === Meteor.userId())
            return "<p class='mark'>X</p>";
          else
            return "<p class='mark'>O</p>";
        }
      }
      if(myGame.status === Meteor.userId())
        return "<div class='selectableField' id='"+x+y+"'></div>";
    }
  }
}

ヘルパーをテンプレートに追加します。

client/html/board.html
...
<td class="field" id="{{rowIndex}}{{@index}}">
  {{{isMarked rowIndex @index}}}
</td>
...

関数の解説をします。行と列をパラメータ(x、y)で受け取ります。inGametrueなら、そのゲームを探します。ゲームを発見し、statusが「waiting」なら、すべての指し手に対するループ処理を実行します。ある行と列のペアがプレイヤーのmovesと一致した場合、盤にXを描画します。相手プレイヤーの指し手と一致した場合、Oを描画します。

すべてのゲームで、自分の指し手はXで相手の指し手はOです。相手側では相手の指し手がXです。別のデバイスで、時には国を越えてプレイするのでどちらがXOなのかは気にしなくて大丈夫です。それぞれのプレイヤーが自分と相手の指し手を区別できると覚えてください。

状態の表示

完成まであと少しです。ui.jsの空のstatusヘルパーに以下のコードを追加します。

client/js/ui.js
status: () => {
  if(Session.get("inGame")) {
    let myGame = Games.findOne();

    if(myGame.status === "waiting")
      return "Looking for an opponent...";
    else if(myGame.status === Meteor.userId())
      return "Your turn";
    else if(myGame.status !== Meteor.userId() && myGame.status !== "end")
      return "opponent's turn";
    else if(myGame.result === Meteor.userId())
      return "You won!";
    else if(myGame.status === "end" && myGame.result !== Meteor.userId() && myGame.result !== "tie")
      return "You lost!";
    else if(myGame.result === "tie")
      return "It's a tie";
    else
      return "";
  }
}

コードの内容は明白ですが、一応説明します。inGametrueなら、プレイ中のゲームを探します。statusが「waiting」の場合、プレイヤーに対し相手が見つかるのを待つように伝えます。statusがプレイヤーの_idと一致する場合、プレイヤーのターンだと伝えます。statusがプレイヤーの_idと不一致で対戦が終了していないなら、相手のターンだと伝えます。resultがプレイヤーの_idと一致する場合、プレイヤーが勝利したことを伝えます。対戦が終了していて、resultがプレイヤーの_idと不一致なら、プレイヤーの負けだと伝えます。resultが「tie」の場合、tie(引き分け)です。当たり前ですね。

ゲームを遊べるようになりました。普通のブラウザーウインドウとプライベートタブを開き、自分自身と戦ってください。熱中しすぎると、残りの人生を独りで過ごすことになるのでご注意ください。

ログアウト

まだ完成ではありません。こちらが接続を切って相手プレイヤーを残すとどうなるのでしょうか? 完了したゲームをデータベースの貴重なスペースに保管するのでしょうか? プレイヤーの接続を監視し、接続状態に合わせて対応する必要があります。

まずはゲームを削除する方法と、プレイヤーをゲームから削除する方法を解説します。gamesLogic.jsを開き、GameLogicクラスに以下のメソッドを追加します。

lib/gameLogic.js
removeGame(gameId) {
  Games.remove({_id: gameId});
}

removePlayer(gameId, player) {
  Games.update({_id: gameId}, {$set:{[player]: ""}});
}

removeGameメソッドはgameIdを引数で受け取り、該当するゲームを削除します。

removePlayer()gameIdplayer(player1またはplayer2が入る文字列)を引数として受け取り、該当するゲームの対象プレイヤーのフィールドを空にします。

ユーザーの接続を監視するためのパッケージmizzao:user-statusをインストールします。コンソールに移動し、ctrl+cで実行中のアプリを閉じてから以下のコマンドを実行します。

meteor add mizzao:user-status

このパッケージのconnectionLogoutコールバックで、接続を切ったユーザーのuserIdをはじめ、重要な情報が格納されたパラメータを利用できます。

serverフォルダーのmain.jsを開き、末尾にコールバックを追加します。

/server/main.js
UserStatus.events.on("connectionLogout", (fields) => {
  const game = Games.findOne(
  {$or:[
    {player1: fields.userId},
    {player2: fields.userId}]
  });

  if(game != undefined) {
    if(game.status !== "waiting" && game.status !== "end") {
      if(game.player1 === fields.userId) {
        gameLogic.setGameResult(game._id, game.player2);
        gameLogic.removePlayer(game._id, "player1");
      } else if(game.player2 === fields.userId) {
        gameLogic.setGameResult(game._id, game.player1);
        gameLogic.removePlayer(game._id, "player2");
      }
    } else {
      if(game.player1 === "" || game.player2 === "") {
        gameLogic.removeGame(game._id);
      } else {
        if(game.player1 === fields.userId)
          gameLogic.removePlayer(game._id, "player1");
        else if(game.player2 === fields.userId)
          gameLogic.removePlayer(game._id, "player2");
      }
    } 
  }
});

どちらかのplayerが切断されたゲームが見つかり、ゲームの状態が「waiting」ではなく、ゲームが終了していないことを確認します。条件が成立したら、接続を切ったプレイヤーを削除し、相手を勝者に設定します。条件が成立しない場合、playerフィールドが片方でも空ならゲームを削除し、両方とも空でないなら接続を切ったプレイヤーをゲームから削除します。

ほかのパッケージと同様、UserStatusパッケージをインポートします。connectionLogoutコールバックでGameLogicクラスのメソッドを使ったので、server/main.jsの先頭でインポートします。

import { UserStatus } from 'meteor/mizzao:user-status';
import { gameLogic } from '../lib/gameLogic.js';

最後に

ゲームが完成しました! このゲームをアップロードして友人と遊んだり、1人で遊んだりできます。

以上の内容がほとんど理解できなかったとしても、心配しないでください。コードの勉強を続けていけば、そう遠くないうちに理解できるようになります。いくつかのコンセプトを飲み込む時間が必要なだけで、いたって自然なプロセスです。行き詰まったときは完成したアプリのコードを確認してください。

コードを十分理解したら、機能を追加してください。盤の大きさを拡大しても対応する別の勝敗判定アルゴリズムを実装してください。プレイヤーに持続性を持たせて成績やゲーム記録を保存するのも良いでしょう。ログインインターフェイスを実装してプレイヤーがユーザー名を決めたり、友達に対戦を申し込んだりするのはどうでしょう。さらに、同じコンセプトで別のゲームを作るのも良いと思います。

このチュートリアルを楽しんでくれたなら幸いです。

(原文:Building a Multiplayer TicTacToe Game with Meteor

[翻訳:薮田佳佑/編集:Livit

Copyright © 2017, Paul Orac All Rights Reserved.

Paul Orac

Paul Orac

ソフトウェア開発のほかにも、マッサージセラピストをしたり、音楽に打ち込んだり、フィクション作品を趣味で書いたりしています。好きなことは旅行、良質なテレビ番組を楽しむ、もちろん、ゲームを遊ぶことです。

Loading...