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