このページの本文へ

ReactとPHPでリアルタイムなシミュレーションゲームを作ってみた

2017年09月15日 16時41分更新

文●Christopher Pitt

  • この記事をはてなブックマークに追加
本文印刷

複数人でプレイできて、経営シミュレーションの仕組みを取り入れたゲームを作りたい。Stardew Valleyから交流機能を省いた、経営活動ができるゲームだ。

こう考えた私は、PHPとReactJSでゲームを開発することにしました。

Stardew valley

しかし問題は、複数人でプレイするゲームの構成や、プレイヤーと経営の仕組みを実装する方法について何も知らなかったことです。

自分自身にReactJSを使うだけの知識があるのかすら分かりませんでした。初期のバージョンではサーバーの処理と経営の仕組みを実装することに重きを置いているので、インターフェイスの構築にはReactJSが適しています。経営の仕組みとして考えている農業とのインタラクションについては自信がありませんが、経営システムを単一のインターフェイスで構築するのは魅力的なアイデアです。

私がJavaScriptによるゲーム開発に関する本を執筆したきっかけは、dead_lugosiで中世を舞台にしたPHPゲーム開発に関するトークを読み、Margaretに触発されたことでした。今回は自分の失敗例から学んでもらえるだろうと考えて、これまでの経験を記事にしようと決めました。

記事のコードはここで入手でき、PHP 7.1と最新版のGoogle Chromeでテスト済みです。

バックエンドの設定

はじめに複数人でプレイする経営ゲームを構築する方法を検索したところ、StackOverflowに参考になるスレッドを見つけました。しかし半分ほど読んだところで、先に着手すべきことがあるのに気付きました。

なによりもPHPサーバーが必要なはず。そのサーバーで複数のReactJSクライアントを稼働させるので、同時実行能力が高いもの(もしかしたらWeb Socket)が良い。またプレイ中ではなくてもゲームを進行させるので、データ保存機能が必要だ。

このように考えて、同時実行能力が高くWeb Socketをサポートする非同期のPHPサーバーを立ち上げ、『あったらいいな! PHPをプリプロセッサーで自分好みの言語に拡張する』で取り組んだPHPプリプロセッサを追加して環境を整備し、エンドポイントを作りました。

config.pre
$host = new Aerys\Host();
$host->expose("*", 8080);

$host->use($router = Aerys\router());
$host->use($root = Aerys\root(.."/public"));

$web = process .."/routes/web.pre";
$web($router);

$api = process .."/routes/api.pre";
$api($router);

アプリケーションのうちHTTPとWeb Socketの部分にAerysを使うことにしました。このコードはAerysドキュメントとは大きく異なっていますが、これは考えがあってのことです。

通常Aerysアプリは、コマンドラインで次のように起動します。

vendor/bin/aerys -d -c config.php

この長いコマンドを何度も入力するのは面倒な上に、PHPプリプロセッサーを使えないので、ローダーファイルを作成しました。

loader.php
return Pre\processAndRequire(__DIR__ . "/config.pre");

そして依存項目をインストールしました。

composer.json
"require": {
    "amphp/aerys": "dev-amp_v2",
    "amphp/parallel": "dev-master",
    "league/container": "^2.2",
    "league/plates": "^3.3",
    "pre/short-closures": "^0.4.0"
},
"require-dev": {
    "phpunit/phpunit": "^6.0"
},

amphp/parallelを使ってブロックするコードを非同期サーバーからなくしたかったものの、安定版のamphp/aerysではインストールできなかったのでdev-amp_v2ブランチを使いました。

テンプレートエンジンとサービスローダーを組み込もうと考えて、それぞれのPHPリーグバージョンを選びました。最後にpre/short-closuresを追加し、config.preでのカスタム文法とあとで使う予定のshort closureを扱えるようにしました。

続いてルートファイルを作成しました。

routes/web.pre
use Aerys\Router;
use App\Action\HomeAction;

return (Router $router) => {
    $router->route(
        "GET", "/", new HomeAction
    );
};
routes/api.pre
use Aerys\Router;
use App\Action\Api\HomeAction;

return (Router $router) => {
    $router->route(
        "GET", "/api", new HomeAction
    );
};

シンプルなルートですが、config.preでテストできるようになりました。ルートファイルからクロージャーを返しているので、タイプした$routerを渡して必要なルートを追加できます。最後に類似したActionを2つ作成しました。

app/Actions/HomeAction.pre
namespace App\Action;

use Aerys\Request;
use Aerys\Response;

class HomeAction
{
    public function __invoke(Request $request,
        Response $response)
    {
        $response->end("hello world");
    }
}

最後の工夫は、Aerysサーバーの開発環境と本番環境を立ち上げるショートカットスクリプトを追加したことです。

composer.json
"scripts": {
    "dev": "vendor/bin/aerys -d -c loader.php",
    "prod": "vendor/bin/aerys -c loader.php"
},
"config": {
    "process-timeout": 0
},

ここまで終われば次のコマンドで新しいサーバーを立ち上げてhttp://127.0.0.1:8080にアクセスできます。

composer dev

フロントエンドの設定

次に考えたのがこれです。

PHP側が安定したので、ReactJSファイルに取り掛かろう。Laravel Mixを使えるかも?

ビルドチェーンをいちから作った方が設定と拡張が容易ではあるものの、強いこだわりはなかったので、MixがLaravelプロジェクト以外でも使えるようになったことを考慮してデフォルトのVueJSを選びました。

最初はNPMの依存オブジェクトのインストールです。

package.json
"devDependencies": {
    "babel-preset-react": "^6.23.0",
    "bootstrap-sass": "^3.3.7",
    "jquery": "^3.1.1",
    "laravel-mix": "^0.7.5",
    "react": "^15.4.2",
    "react-dom": "^15.4.2",
    "webpack": "^2.2.1"
},

MixはWebpackを使ってJSとCSSファイルをプリプロセスしてバンドルしています。またReactJSと、関連するBabelライブラリーをjsxファイルのビルドに使います。最後にデフォルトのスタイル設定用にBootstrapファイルを追加しました。

Mixは自動的にカスタム設定ファイルをロードするので、次のコードを追加しました。

webpack.mix.js
let mix = require("laravel-mix")

// load babel presets for jsx files

mix.webpackConfig({
    "module": {
        "rules": [
            {
                "test": /jsx$/,
                "exclude": /(node_modules)/,
                "loader": "babel-loader" + mix.config.babelConfig(),
                "query": {
                    "presets": [
                        "react",
                        "es2015",
                    ],
                },
            },
        ],
    },
})

// set up front-end assets

mix.setPublicPath("public")

mix.js("assets/js/app.jsx", "public/js/app.js")
mix.sass("assets/scss/app.scss", "public/css/app.css")
mix.version()

jsxファイルでなにをするかをMixに指示するために、通常は.babelrcに書く設定を追加しました。JSとCSSからアプリケーションへのエントリーポイントは1つにする計画です。

注意:Mixは今後のバージョンアップでReactJSのリソースをビルドする機能を標準でサポートする予定です。そうなればmix.webpackConfigコードが不要になります。

長いコマンドの入力を避けるために、またショートカットスクリプトを作りました。

package.json
"scripts": {
    "dev": "$npm_package_config_webpack",
    "watch": "$npm_package_config_webpack -w",
    "prod": "$npm_package_config_webpack -p"
},
"config": {
    "webpack": "webpack --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},

この3つのスクリプトはすべてWebpack変数コマンドを使っていますが、それ以外はそれぞれ異なります。devはJSとCSSファイルのデバッグバージョンをビルドし、-wスイッチはWebpackウォッチャーを開始し(バンドルを部分的に再ビルドするため)、-pスイッチはバンドルの簡易プロダクションバージョンを使用可能にします。

バンドルでバージョン管理しているので、ハッシュを調べることなく/js/app.60795d5b3951178abba1.jsのようにファイルを参照できる方法が必要でした。Mixはマニフェストファイルを作成するのが流儀のようなので、クエリのヘルパー関数を作りました。

helpers.pre
use Amp\Coroutine;

function mix($path) {
    $generator = () => {
        $manifest = yield Amp\File\get(.."/public/mix-manifest.json");
        $manifest = json_decode($manifest, true);

        if (isset($manifest[$path])) {
            return $manifest[$path];
        }

        throw new Exception("{$path} not found");
    };

    return new Coroutine($generator());
}

Aerysは$val = yield $promise形式で受け取ったpromiseを扱えるので、AmpのPromise機能を使いました。ファイルを読み込んでデコードすれば、マッチするファイルパスを探せます。次にHomeActionを修正しました。

app/Actions/HomeAction.pre
public function __invoke(Request $request,
    Response $response)
{
    $path = yield mix("/js/app.js");

    $response->end("
        <div class='app'></div>
        <script src='{$path}'></script>
    ");
}

promiseを返す関数をもっと作成して、同じような方法でコードを非同期にすれば良いことに気が付きました。JSコードを次に示します。

from assets/js/component.jsx
import React from "react"

class Component extends React.Component
{
    render() {
        return <div>hello world</div>
    }
}

export default Component
assets/js/app.jsx
import React from "react"
import ReactDOM from "react-dom"
import Component from "./component"

ReactDOM.render(
    <Component />,
    document.querySelector(".app")
)

結局のところ、Mixでjsxファイルをコンパイルできるか試したかっただけで、非同期のmix関数をここでも使えばコンパイルできることが分かりました!

注意:毎回mix関数を使うのは高コストで、同じファイルを読み込んでいる場合はなおさらです。それよりはサーバーのbootstrapフェーズにすべてのテンプレートを読み込んで、必要時にactionの内部から参照するほうがよいでしょう。Aerysを立ち上げる設定ファイルはpromiseを戻り値にできるので(Amp\allの戻り値と同様)、サーバーが立ち上がる前にすべてのテンプレートを解決できます。

WebSocketと接続

設定完了まであと少しです。残されたタスクはWebSocketでバックエンドとフロントエンドを接続することです。これは新しいクラスを作って比較的簡単にできます。

app/Socket/GameSocket.pre
namespace App\Socket;

use Aerys\Request;
use Aerys\Response;
use Aerys\Websocket;
use Aerys\Websocket\Endpoint;
use Aerys\Websocket\Message;

class GameSocket implements Websocket
{
    private $endpoint;
    private $connections = [];

    public function onStart(Endpoint $endpoint)
    {
        $this->endpoint = $endpoint;
    }

    public function onHandshake(Request $request,
        Response $response)
    {
        $origin = $request->getHeader("origin");

        if ($origin !== "http://127.0.0.1:8080") {
            $response->setStatus(403);
            $response->end("<h1>origin not allowed</h1>");
            return null;
        }

        $info = $request->getConnectionInfo();

        return $info["client_addr"];
    }

    public function onOpen(int $clientId, $address)
    {
        $this->connections[$clientId] = $address;
    }

    public function onData(int $clientId,
        Message $message)
    {
        $body = yield $message;

        yield $this->endpoint->broadcast($body);
    }

    public function onClose(int $clientId,
        int $code, string $reason)
    {
        unset($this->connections[$clientId]);
    }

    public function onStop()
    {
        // nothing to see here...
    }
}

次にWebのルートを少しだけ修正します。

routes/web.pre
use Aerys\Router;
use App\Action\HomeAction;
use App\Socket\GameSocket;

return (Router $router) => {
    $router->route(
        "GET", "/", new HomeAction
    );

    $router->route(
        "GET", "/ws", Aerys\websocket(new GameSocket)
    );
};

次にWeb Socketに接続し接続中の全員にメッセージを送れるようにJSを修正します。

assets/js/component.jsx
import React from "react"

class Component extends React.Component
{
    constructor()
    {
        super()
        this.onMessage = this.onMessage.bind(this)
    }

    componentWillMount()
    {
        this.socket = new WebSocket(
            "ws://127.0.0.1:8080/ws"
        )

        this.socket.addEventListener(
            "message", this.onMessage
        )

        // DEBUG

        this.socket.addEventListener("open", () => {
            this.socket.send("hello world")
        })
    }

    onMessage(e)
    {
        console.log("message: " + e.data)
    }

    componentWillUnmount()
    {
        this.socket.removeEventListener(this.onMessage)
        this.socket = null
    }

    render() {
        return <div>hello world</div>
    }
}

export default Component

新しいComponentオブジェクトを作ると、Web Socketサーバーに接続して新しいメッセージを受け取るイベントリスナーを追加します。正しく接続できて新しいメッセージを返送できるか確認するデバッグコードも追加しました。

PHPとWeb Socketの核心については、機会があれば取り上げます。

最後に

この記事では、シンプルな非同期PHP Webサーバーの設定方法、Laravelプロジェクト以外でLaravel Mixを使う方法、そしてバックエンドとフロントエンドをWebSocketで接続する方法を紹介しました。

今回だけで盛りだくさんでしたので、またゲームのコードは1行も書いていません。機会があればゲームのロジックとReactJSのインターフェイスの構築について取り上げます。

※本記事はNiklas Kellerが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Game Development with ReactJS and PHP: How Compatible Are They?

[翻訳:内藤夏樹/編集:Livit

Web Professionalトップへ

この記事の編集者は以下の記事をオススメしています