Статьи

Сгенерированный процедурно игровой ландшафт с React, PHP и WebSockets

В прошлый раз я начал рассказывать вам историю о том, как я хотел сделать игру. Я описал, как настроить асинхронный PHP-сервер, цепочку сборки Laravel Mix, интерфейс React и WebSockets, соединяющие все это вместе. Теперь позвольте мне рассказать вам о том, что произошло, когда я начал создавать игровую механику с помощью этой смеси React, PHP и WebSockets…


Код этой части можно найти по адресу github.com/assertchris-tutorials/sitepoint-making-games/tree/part-2 . Я протестировал его с PHP 7.1 в последней версии Google Chrome.


Конечное изображение

Создание фермы

«Давайте начнем с простого. У нас есть сетка плиток 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; } } 

Я подумал, что это будет забавное время, чтобы опробовать макрос аксессоров класса , объявив закрытые свойства с общедоступными получателями. Для этого мне пришлось установить pre/class-accessors (через composer require ).

Затем я изменил код сокета, чтобы разрешить создание новых ферм по запросу. Из 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]); } // … } с 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]); } // … } с 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 на предыдущий, который у меня был — за исключением того, что вместо того, чтобы транслировать эхо, я проверял наличие new-farm и отправлял сообщение только клиенту, который спросил.

«Возможно, сейчас самое время стать менее универсальным с помощью кода React. Я собираюсь переименовать 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 

На самом деле, единственное, что я изменил, — это отправка new-farm вместо hello world . Все остальное было таким же. Я действительно должен был изменить код 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 обрабатывает ключевое слово yield путем разрешения Promises. Более конкретно: когда 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 = () => { $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()); } 

Я начал createPatches метод createPatches как и раньше, создавая новые объекты PatchModel для каждого x и y в сетке. Затем я запустил еще один цикл, чтобы вызывать метод start для каждого патча. Я сделал бы это на том же этапе, но я хотел, чтобы мой метод start мог проверять окружающие патчи. Это означало, что мне придется сначала создать их все, прежде чем выяснить, какие патчи были вокруг друг друга.

Я также изменил FarmModel чтобы принять закрытие onGrowth . Идея состояла в том, чтобы я мог назвать это закрытие, если патч вырос (даже на этапе начальной загрузки).

Каждый раз, когда патч рос, я сбрасывал переменную $changes . Это гарантировало, что участки будут расти, пока весь проход фермы не даст никаких изменений. Я также вызвал закрытие onGrowth . Я хотел позволить onGrowth быть нормальным закрытием или даже вернуть Coroutine . Вот почему мне нужно было сделать createPatches async функцией.

Примечание: по общему признанию, onGrowth сопрограмм onGrowth усложняет ситуацию, но я считаю, что это важно для разрешения других асинхронных действий, когда патч вырос. Возможно, позже я захочу отправить сообщение сокета, и я смогу сделать это, только если yield работает внутри onGrowth . Я мог дать только onGrowth если createPatches был async функцией. А поскольку createPatches была async функцией, мне нужно было бы GameSocket ее внутри GameSocket .

«При создании первого асинхронного PHP-приложения легко отключиться от всего, что необходимо изучить. Не сдавайся слишком рано!

Последний GameSocket кода, который мне нужно было написать, чтобы убедиться, что все это работает, был в 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 и получить $farm->createPatches() чтобы у каждого была возможность рандомизации. После этого мне просто нужно было передать снимок исправлений в полезную нагрузку сокета.

Случайные патчи возвращаются

Случайные патчи для каждой фермы

«Что, если я начну каждый участок как сухую грязь? Тогда я мог бы сделать у некоторых участков сорняки, а у других деревья… »

Я приступил к настройке патчей. Из 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, ]); } ); 

Рендеринг фермы

Пришло время показать ферму, используя рабочий процесс React, который я настроил ранее. Я уже получал width и height фермы, так что я мог сделать каждый блок сухой грязью (если не предполагалось, что она будет выращивать сорняки). Из 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 качестве способов подключения к другим точкам данных (например, WebSockets). И когда я получал обновления через WebSocket, я мог обновлять состояние компонента, если я установил начальное состояние в конструкторе.

Это привело к уродливому, хотя и функциональному набору элементов 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; } } } 

Созданные фермы теперь имеют немного цвета:

Произведена случайная ферма

Вы получаете ферму, вы получаете ферму …

Резюме

Это была далеко не полная игра. В нем не хватало таких важных вещей, как ввод игрока и персонажи игрока. Это был не очень многопользовательский режим. Но этот сеанс привел к более глубокому пониманию компонентов React, связи WebSocket и макросов препроцессора.

Я с нетерпением ждал следующей части, в которой я мог начать принимать информацию от игроков и менять ферму. Возможно, я бы даже начал в системе входа игрока. Возможно, однажды!