PHP

続・ReactとPHP、WebSocketでゲーム開発 地形を生成してみよう

2017/09/19

Christopher Pitt

32

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

「ReactとPHPでStardew Valleyのような経営ゲームを作りたい!」と考えた著者の挑戦は続きます。

以前、ゲームを作ろうと思った経緯を話しました。そして、非同期PHPサーバー、Laravel Mixによるビルドチェーン、ReactJSのフロントエンド、すべてをつなぐWebSocketのセットアップ方法を説明しました。ReactJS、PHP、WebSocketを組み合わてゲームの仕組みの作り方を解説します。

本記事のコードはここにあります。PHP 7.1と最新バージョンのGoogle Chromeでテスト済みです。

Final image

牧場の作成

最初は簡単なものにしよう。10×10のタイルを使ってグリッドを作り、ランダムに生成した要素を入れていこう

このように考えて、牧場をFarmとして定義し、それぞれのタイルをPatchとして定義することにしました。

app/Model/FarmModel.pre
namespace App\Model;

class Farm
{
    private $width
    {
        get { return $this->width; }
    }

    private $height
    {
        get { return $this->height; }
    }

    public function __construct(int $width = 10,
        int $height = 10)
    {
        $this->width = $width;
        $this->height = $height;
    }
}

パブリックゲッター付きのプライベートプロパティを宣言して『あったらいいな! PHPをプリプロセッサーで自分好みの言語に拡張する』で説明したクラスアクセッサーのマクロを試すにはちょうど良いタイミングだと考えました。そこで、composer requireを使ってpre/class-accessorsをインストールしました。

次に、ソケットのコードを変えて、リクエストに応じて新たな牧場を作成できるようにしました。

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

use Aerys\Request;
use Aerys\Response;
use Aerys\Websocket;
use Aerys\Websocket\Endpoint;
use Aerys\Websocket\Message;
use App\Model\FarmModel;

class GameSocket implements Websocket
{
    private $farms = [];

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

        if ($body === "new-farm") {
            $farm = new FarmModel();

            $payload = json_encode([
                "farm" => [
                    "width" => $farm->width,
                    "height" => $farm->height,
                ],
            ]);

            yield $this->endpoint->send(
                $payload, $clientId
            );

            $this->farms[$clientId] = $farm;
        }
    }

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

    // ...
}

GameSocket以前の説明からほとんど変わりません。違いは、echoをブロードキャストする代わりにnew-farmをチェックして、呼び出し元のクライアントにのみメッセージを返すことだけです。

次はReactJSのコードを使って具体的に作り込んでいくのが良さそうだ。component.jsxの名前を変えてfarm.jsxにしよう。

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

class Farm extends React.Component
{
    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("new-farm")
        })
    }
}

export default Farm

実は、hello worldの代わりにnew-farmを送るようにしただけです。それ以外はすべて同じです。一方、app.jsxのコードは変更する必要がありました。

assets/js/app.jsx
import React from "react"
import ReactDOM from "react-dom"
import Farm from "./farm"

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

目標はなにも達成できていませんが、変更によりクラスアクセッサーの動作と、あとでWebSocketを使って通信するための、ある種のリクエスト/レスポンスパターンのプロトタイプ動作を確認できるようになりました。コンソールを開くと{"farm":{"width":10,"height":10}}が表示されました。

よし!

続いて、それぞれのタイルを表すPatchクラスを作りました。多くのゲームはここでロジックを作り込んでいる様子です。

app/Model/PatchModel.pre
namespace App\Model;

class PatchModel
{
    private $x
    {
        get { return $this->x; }
    }

    private $y
    {
        get { return $this->y; }
    }

    public function __construct(int $x, int $y)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

Farmにある空間と同じ数だけパッチを作る必要があります。FarmModelのコンストラクタの中にこの処理を実装しました。

app/Model/FarmModel.pre
namespace App\Model;

class FarmModel
{
    private $width
    {
        get { return $this->width; }
    }

    private $height
    {
        get { return $this->height; }
    }

    private $patches
    {
        get { return $this->patches; }
    }

    public function __construct($width = 10, $height = 10)
    {
        $this->width = $width;
        $this->height = $height;

        $this->createPatches();
    }

    private function createPatches()
    {
        for ($i = 0; $i < $this->width; $i++) {
            $this->patches[$i] = [];

            for ($j = 0; $j < $this->height; $j++) {
                $this->patches[$i][$j] =
                    new PatchModel($i, $j);
            }
        }
    }
}

それぞれのセル用に新たなPatchModelオブジェクトを作りました。これらのオブジェクトは単純ですが、木、草、花を育てるためのランダムな要素が必要です。ひとまず、以下のようにしました。

app/Model/PatchModel.pre
public function start(int $width, int $height,
    array $patches)
{
    if (!$this->started && random_int(0, 10) > 7) {
        $this->started = true;
        return true;
    }

    return false;
}

最初はパッチを単にランダムに成長させることにしました。以下のコードにしてもパッチの外部状態は変わりませんが、牧場内でパッチがどのように成長するかテストできるようになりました。

app/Model/FarmModel.pre
namespace App\Model;

use Amp;
use Amp\Coroutine;
use Closure;

class FarmModel
{
    private $onGrowth
    {
        get { return $this->onGrowth; }
    }

    private $patches
    {
        get { return $this->patches; }
    }

    public function __construct(int $width = 10,
        int $height = 10, Closure $onGrowth)
    {
        $this->width = $width;
        $this->height = $height;
        $this->onGrowth = $onGrowth;
    }

    public async function createPatches()
    {
        $patches = [];

        for ($i = 0; $i < $this->width; $i++) {
            $this->patches[$i] = [];

            for ($j = 0; $j < $this->height; $j++) {
                $this->patches[$i][$j] = $patches[] =
                    new PatchModel($i, $j);
            }
        }

        foreach ($patches as $patch) {
            $growth = $patch->start(
                $this->width,
                $this->height,
                $this->patches
            );

            if ($growth) {
                $closure = $this->onGrowth;
                $result = $closure($patch);

                if ($result instanceof Coroutine) {
                    yield $result;
                }
            }
        }
    }

    // ...
}

このコードでは説明することがたくさんあります。最初に、マクロを使ってasyncという関数キーワードを導入しました。また、AmpはPromiseの解決によってyieldキーワードを処理します。さらに、Ampはyieldキーワードを見つけたときに、(ほとんどの場合)コルーチンがyieldされていると見なします。

createPatches関数を通常の関数として作り、単にコルーチンを返すようにすることもできましたが、普通のコードになってしまうので特別なマクロを作るほうが良いと考えました。同時に、以前作成したコードを以下に置き換えました。

helpers.pre
async function mix($path) {
    $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");
}

以前は以下のようにジェネレーターを作り、新たなCoroutineの中にラップする必要がありました。

use Amp\Coroutine;

function mix($path) {
    $generator = () =&gt; {
        $manifest = yield Amp\File\get(
            ..&quot;/public/mix-manifest.json&quot;
        );

        $manifest = json_decode($manifest, true);

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

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

    return new Coroutine($generator());
}

createPatchesメソッドを再び編集し、グリッドのそれぞれのxy用にPatchModelオブジェクトを作りました。次に、別のループ処理を書き、各パッチに対してstartメソッドを呼び出すようにしました。これらは同時に処理できましたが、startメソッドが周囲のパッチを確認できるようにしたいと考えました。つまり、各パッチが互いの周辺にあるパッチを確認できるようにするために最初にパッチをすべて作っておく必要があったのです。

また、onGrowthクロージャを受け取るようにFarmModelを変更しました。パッチが成長したときに(ブートストラップフェーズの途中であっても)クロージャを呼び出せるようにしたのです。

パッチが1つ成長するたびに、$changes変数をリセットするようにしました。牧場のパス全体が変更をyeildしなくなるまで各パッチを成長させられるようにしました。また、onGrowthクロージャーを呼び出すようにしました。onGrowthを普通のクロージャーとして使うというよりも、Coroutineを返すようにしたかったからです。これがcreatePatchesasync関数にしなければならなかった理由です。

注記:確かに、onGrowthがコルーチンを返すようにするのは少し複雑ですが、パッチが成長したときにほかの非同期動作を実行できるようにするためには必要不可欠でした。もしかしたら、あとでソケットメッセージを送りたいと考えるかもしれませんが、これはyieldonGrowth内部で動作する場合にのみ実行できます。onGrowthyeildcreatePatchesasync関数である場合にのみ可能です。そして、createPatchesasync関数であるため、createPatchesGameSocket内でyeildする必要があります。

最初の非同期PHPアプリケーションを作るときに、学ばないといけないことの多さにやる気を削がれることはよくある。簡単にあきらめないで!

動作確認する前に最後に必要なものがGameSocketのコードでした。

app/Socket/GameSocket.pre
if ($body === "new-farm") {
    $patches = [];

    $farm = new FarmModel(10, 10,
        function (PatchModel $patch) use (&$patches) {
            array_push($patches, [
                "x" => $patch->x,
                "y" => $patch->y,
            ]);
        }
    );

    yield $farm->createPatches();

    $payload = json_encode([
        "farm" => [
            "width" => $farm->width,
            "height" => $farm->height,
        ],
        "patches" => $patches,
    ]);

    yield $this->endpoint->send(
        $payload, $clientId
    );

    $this->farms[$clientId] = $farm;
}

以前のコードよりわずかに複雑なだけです。FarmModelのコンストラクタに第3引数を与える必要がありました。また、それぞれの牧場で乱数を処理するために$farm->createPatches()yeildする必要がありました。そのあとに必要なのは、パッチのスナップショットをソケットのペイロードに渡すことだけでした。

Random Patches being returned

牧場ごとにランダムなパッチ

それぞれのパッチを最初は乾いた土にしたらどうだろう? そうすれば草のあるパッチや木のあるパッチを作れるのでは…

パッチをカスタマイズすることにしました。

app/Model/PatchModel.pre
private $started = false;

private $wet {
    get { return $this->wet ?: false; }
};

private $type {
    get { return $this->type ?: "dirt"; }
};

public function start(int $width, int $height,
    array $patches)
{
    if ($this->started) {
        return false;
    }

    if (random_int(0, 100) < 90) {
        return false;
    }

    $this->started = true;
    $this->type = "weed";

    return true;
}

ロジックの順序を少し変更し、パッチがすでに成長している場合は早めに処理を終了させるようにしました。また、パッチが成長する確率を減らしました。これらの終了処理がどちらも実行されなければ、パッチの種類を草に変えます。

次に、ソケットのメッセージペイロードの一部に以下の形を使いました。

app/Socket/GameSocket.pre
$farm = new FarmModel(10, 10,
    function (PatchModel $patch) use (&$patches) {
        array_push($patches, [
            "x" => $patch->x,
            "y" => $patch->y,
            "wet" => $patch->wet,
            "type" => $patch->type,
        ]);
    }
);

牧場のレンダリング

以前セットアップしたReactJSのワークフローを使って牧場を表示するときが来ました。牧場のwidthheightをすでに取得済みなので、すべてのブロックを乾いた土に変えられます(草を成長させる予定がない場所については)。

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

class Farm extends React.Component
{
    constructor()
    {
        super()

        this.onMessage = this.onMessage.bind(this)

        this.state = {
            "farm": {
                "width": 0,
                "height": 0,
            },
            "patches": [],
        };
    }

    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("new-farm")
        })
    }

    onMessage(e)
    {
        let data = JSON.parse(e.data);

        if (data.farm) {
            this.setState({"farm": data.farm})
        }

        if (data.patches) {
            this.setState({"patches": data.patches})
        }
    }

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

    render() {
        let rows = []
        let farm = this.state.farm
        let statePatches = this.state.patches

        for (let y = 0; y < farm.height; y++) {
            let patches = []

            for (let x = 0; x < farm.width; x++) {
                let className = "patch"

                statePatches.forEach((patch) => {
                    if (patch.x === x && patch.y === y) {
                        className += " " + patch.type

                        if (patch.wet) {
                            className += " " + wet
                        }
                    }
                })

                patches.push(
                    <div className={className}
                        key={x + "x" + y} />
                )
            }

            rows.push(
                <div className="row" key={y}>
                    {patches}
                </div>
            )
        }

        return (
            <div className="farm">{rows}</div>
        )
    }
}

export default Farm

Farmコンポーネントがなにをしているのか説明するのを忘れていました。Reactコンポーネントはインターフェイスの構築をこれまでと違う方法で捉えており、その思考プロセスは「なにかを変えたいときにDOMをどのように操作すれば良いか」ではなく「あるコンテキストにおいてDOMはどうあるべきか」だとしています。

renderメソッドを一度だけ実行するようにし、生成したものすべてをDOMに出力するようにしました。componentWillMountメソッドとcomponentWillUnmountメソッドを使って、ほかのデータポイント(Web Scoketなど)に接続できるようにしました。また、コンストラクタで初期状態をセットしたあとは、Web Scoketから得られた更新情報を使って、コンポーネントの状態を更新できるようにしました。

その結果、見た目は良くありませんが機能的なdivのセットが得られました。次に、スタイルの追加に着手しました。

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

use Aerys\Request;
use Aerys\Response;

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

        $response->end("
            <link rel='stylesheet' href='{$css}' />
            <div class='app'></div>
            <script src='{$js}'></script>
        ");
    }
}
assets/scss/app.scss
.row {
    width: 100%;
    height: 50px;

    .patch {
        width: 50px;
        height: 50px;
        display: inline-block;
        background-color: sandybrown;

        &.weed {
            background-color: green;
        }
    }
}

生成した牧場に少しですが色を付けられました。

A random farm rendered

牧場ができた…はず…

最後に

ゲームはもちろん未完成です。プレイヤーによる入力やプレイヤーのキャラクターといった重要な要素が欠けています。多人数参加型ゲームでもありません。しかし、この記事を通じて、ReactJSコンポーネント、WebScoket通信、プリプロセッサーマクロをより深く理解できたはずです。

作業の続きが楽しみです。プレイヤーによる入力を実装して、牧場の姿を変えていきたいと思っています。プレイヤーのログインシステムも検討しています。

(原文:Procedurally Generated Game Terrain with ReactJS, PHP, and Websockets

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

Copyright © 2017, Christopher Pitt All Rights Reserved.

Christopher Pitt

Christopher Pitt

ライター兼プログラマーでSilverStripeに勤務しています。普段はアプリケーションアーキテクチャーに取り組んでいますが、ときどきコンパイラーやロボットを作ることもあります。

Loading...