«Я хотел бы сделать многопользовательскую экономическую игру. Что-то вроде Stardew Valley , но без каких-либо дружеских аспектов и экономики, основанной на игроке »
Я начал думать об этом в тот момент, когда решил попробовать создать игру с использованием PHP и React. Проблема в том, что я ничего не знал о динамике многопользовательских игр или о том, как думать и реализовывать экономику, основанную на игроках.
Я даже не был уверен, что знаю достаточно о React, чтобы оправдать его использование. Я имею в виду, что первоначальный интерфейс — где я сосредоточен на сервере и экономических аспектах игры — идеально подходит для React. Но как насчет того, когда я начинаю заниматься вопросами фермерства / взаимодействия? Мне нравится идея построения изометрического интерфейса вокруг экономической системы.
Однажды я смотрел выступление dead_lugosi , где она рассказала о создании средневековой игры на PHP. Маргарет вдохновила меня, и эта беседа привела меня к написанию книги о разработке игр для JS . Я решил написать о своем опыте. Возможно, другие могли бы учиться на моих ошибках и в этом случае.
Код этой части можно найти по адресу: github.com/assertchris-tutorials/sitepoint-making-games/tree/part-1 . Я протестировал его с PHP 7.1
и в последней версии Google Chrome.
Настройка Back-end
Первое, что я искал, было руководство по построению многопользовательской экономики. Я нашел отличную ветку Stack Overflow, в которой люди объясняли разные вещи, о которых нужно подумать. Я прошел примерно половину, прежде чем понял, что, возможно, я начинаю не с того места.
«Перво-наперво: мне нужен сервер PHP. У меня будет куча клиентов React, поэтому я хочу что-то, способное с высокой степенью параллелизма (возможно, даже WebSockets). И это должно быть настойчиво: вещи должны происходить, даже когда игроков нет рядом ».
Я приступил к работе по настройке асинхронного PHP-сервера — для поддержки высокого параллелизма и поддержки WebSockets. Я добавил свою недавнюю работу с препроцессорами 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);
Я решил использовать Aerys для частей приложения HTTP и WebSocket. Этот код сильно отличался от документации 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 League каждого из них. Наконец, я добавил pre/short-closures
как для обработки пользовательского синтаксиса в config.pre
и для коротких замыканий, которые я планировал использовать после…
Затем я приступил к созданию файлов маршрутов. Из 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
, к которому они могли бы добавить свои собственные маршруты. Наконец, я создал два (похожих) действия.
Из 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" },
Смешайте используемый Webpack для предварительной обработки и объединения файлов JS и CSS. Мне также нужно было установить React и соответствующие библиотеки 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()
Мне нужно было сказать Mix, что делать с файлами jsx
, поэтому я добавил такую же конфигурацию, которую обычно можно поместить в .babelrc
. Я планировал иметь отдельные точки входа JS и CSS в различные части приложения.
Примечание: будущие версии 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" },
Все три сценария использовали переменную команду Webpack, но они отличались друг от друга тем, что они делали. dev
создал отладочную версию файлов JS и CSS. -w
запускает средство отслеживания Webpack (чтобы пакеты могли быть частично перестроены). -p
активировал производственную версию пакетов.
Поскольку я использовал пакетное управление версиями, мне нужен был способ ссылаться на такие файлы, как /js/app.60795d5b3951178abba1.js
не зная хеш. Я заметил, что Микс любит создавать файл манифеста, поэтому я сделал вспомогательную функцию для его запроса. Из 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. Когда файл был прочитан и декодирован, я смог найти соответствующий путь к файлу. Я настроил 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> "); }
Я понял, что могу продолжать создавать функции, которые возвращают обещания, и использовать их таким образом, чтобы мой код оставался асинхронным. Вот мой JS-код из 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
каждый раз дорого, особенно если мы загружаем одни и те же файлы. Вместо этого мы могли бы загрузить все шаблоны на этапе начальной загрузки сервера и ссылаться на них из наших действий при необходимости. Файл конфигурации, с которого мы запускаем Aerys, может возвращать обещание (как, например, тип Amp\all
), поэтому мы можем разрешить все шаблоны до запуска сервера.
Подключение с помощью WebSockets
Я был почти настроен. Последнее, что нужно было сделать, — это подключить внутренний и внешний интерфейсы через WebSockets. Я нашел это относительно простым, с новым классом. Из 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… } }
с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… } }
… И небольшая модификация веб-маршрутов (из 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) ); };
Теперь я могу изменить JS для подключения к этому WebSocket и отправить сообщение всем, кто к нему подключен. Из 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
, он подключался к серверу WebSocket и добавлял прослушиватель событий для новых сообщений. Я добавил немного кода отладки — чтобы убедиться, что он правильно подключается и отправляет новые сообщения обратно.
Мы не будем беспокоиться о мелочах PHP и WebSockets .
Резюме
В этой части мы рассмотрели, как настроить простой асинхронный веб-сервер PHP, как использовать Laravel Mix в не-Laravel проекте и даже как соединить внутренний и внешний интерфейсы вместе с WebSockets.
Уф! Это очень много, и мы не написали ни одной строчки кода игры. Присоединяйтесь ко мне во второй части , когда мы начнем создавать игровую логику и интерфейс React.
Эта статья была рецензирована Никласом Келлером . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!