Главная » Хабрахабр » JS-битва: как я написал свой eval()

JS-битва: как я написал свой eval()

Вы можете помнить Александра Коротаева по браузерной версии «Героев Меча и Магии»: расшифровка его доклада о ней собрала на Хабре громадное количество просмотров. А теперь он сделал игру, ориентированную на программистов: играть в неё надо JS-кодом.

Как сделать, чтобы игра была удобна даже разработчикам, ранее не трогавшим JavaScript? В этот раз на разработку ушли не годы, а недели, но без интересных челленджей всё равно не обошлось. Как защититься от простых способов перехитрить игру?

В итоге Александр снова сделал доклад на HolyJS, а мы (организаторы конференции) снова подготовили для Хабра текстовую версию.

Меня зовут Александр Коротаев, я работаю в Tinkoff.ru, занимаюсь фронтендом. Помимо этого, я в составе сообщества Spb-frontend, помогаю организовывать митапы. Делаю подкаст Drinkcast, мы приглашаем интересных людей и обсуждаем различные темы.

Сначала там надо выбирать юнит из предложенных, это такая RPG-система: у каждого юнита есть свои сильные и слабые стороны. В чём суть игрушки? Затем нужно написать на JavaScript скрипт поведения своей армии — проще говоря, сценарий «что каждый юнит должен делать на поле битвы». Вы видите, каких юнитов выбрал противник, и выбираете в отместку ему.

Это делаетеся в режиме дебага: фактически вы дебажите код, а затем оба противника пушат свой код, и начинается битва между двумя сторонами.

Я всегда хотел сделать что-то такое, и теперь это наконец реализовал. Таким образом, можно увидеть, как сражаются друг с другом два скрипта, две логики, два алгоритма.

В действии всё это выглядит так:

А как выглядела работа над ней? Много труда ушло на документацию. Когда игрок садится за ноут, он видит документацию, в которой всё довольно подробно описано.

В итоге получилось понятно для сишников, джавистов и прочих разработчиков, которые вообще ничего не знают про JS. У меня ушло много времени на её вёрстку, доработку и опрос среди людей, понятна ли она. Этой игрушкой можно даже пропагандировать JavaScript: «Это не страшно, смотрите, как на нём можно писать, даже что-то фановое получается».

У нас в компании был проведен большой турнир, в котором участвовали фактически любые программисты, которые у нас есть.

Самый большой и популярный часто используемый Ace Editor. Из технологий я использовал самый популярный игровой движок из мира JS — Phaser. Еще я использовал RxJS, чтобы работать с асинхронными взаимодействиями от разных пользователей и Preact, чтобы рендерить html. Это редактор в вебе, очень похож на Sublime или VSCode, его можно встроить в веб-страницу. Из нативных технологий особенно часто работал с workers и websocket.

Игры для программистов

Что вообще такое игры для программистов? На мой взгляд, это игры, где надо кодить, потом получить какой-нибудь веселый результат, который можно с кем-то сравнить, это сражение. Из таких доступных онлайн-игр я знаю «Elevator Saga» — вы пишете скрипты для лифтов по определенным параметрам. «Screeps» — про биологию, молекулы, пишете скрипты для них.

Самая популярная из них «Code in the Dark», у нас тоже сегодня она была представлена. Есть еще игрушки, которые иногда бывают на конференциях. Кстати, «Code in the dark» в чём-то вдохновила меня на это всё.

Мне поступила задача, что нужно придумать что-то прикольное со стендом на конференцию, что-то необычное. Зачем это было сделано? Понятно, что всем хочется привлечь к себе внимание и собрать контакты. Не так, чтобы стояли эйчары с опросниками. Я понял, что программисты хотят сражаться, посостязаться, и надо дать им такую возможность. Мы решили пойти дальше и придумали что-то классное и фановое для программистов. Нужно создать стенд, на который они придут и будут кодить.

Мы проводили это не только среди практикующих программистов, но и среди учащихся. Геймификация. Нам нужно было как-то посмотреть, какие там ребята, годятся нам или нет. Мы проводили такие матчи в институтах в «День карьеры». Они играли и были отвлечены, но это давало нам информацию. Мы использовали геймификацию, чтобы завлечь людей в процесс, посмотреть, как они действуют, что они делают. Некоторые пушили код, даже ни разу не запустив его, и было сразу видно, что в разработку им пока рановато.

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

История о нехватке времени

Первая история, с которой я столкнулся в этой разработке, это история о том, что у меня было очень мало времени. Буквально за пять минут была придумала идея, за пять секунд было придумано название, когда потребовалось создать репозиторий на GitHub. Я мог потратить на это всё всего лишь четыре часа вечером, отнимая их даже у жены. В итоге у меня оставалось всего три недели до конференции, чтобы реализовать это хоть как-то. А начиналось все так, что просто нужно было выдумать идею в рамках брейншторма, за пять минут родилась идея: «Давайте писать какой-нибудь искусственный интеллект для RPG на JS». Это круто, весело, я смогу это реализовать.

Были использованы Phaser, Ace Editor и чистый Node.js как сервер без всяких фреймворков. В первой реализации на экране был редактор кода и экран битвы, на котором была сама битва. О чем я потом пожалел, правда, но тогда от сервера ничего особого не требовалось.

Самой сложной частью оказалась sandbox для JS кода, то есть песочница, в которой все изолированно должно было выполняться для каждого игрока. Я успел реализовать тогда Renderer, который рисовал саму битву. Игроки как-то меняли state, закидывали на сервер, сервер рассылал остальным по веб-сокетам. Также был State sharing, который приходил с сервера. Сервер был источником истины, и все подключенные клиенты доверяли тому, что приходило от сервера.

Песочница

Что же такого сложного в реализации песочницы? Дело в том, что песочница — это целый мир для кода, в котором код должен существовать. То есть вы создаете ему примерно такой же мир, как и вокруг, но только состоящий из каких-то условностей, из API, с которым можно взаимодействовать. Как это реализовать на JS? Кажется, что JS к этому не способен, он настолько дыряв и свободен, что просто не получится сделать так, чтобы полностью заключить код пользователя в какую-то коробочку, не используя отдельную виртуалку с отдельной ОС.

Что должна делать песочница?

Это должен быть мир, из которого нельзя прорваться наружу. Во-первых, как уже сказал, изолировать код.

Игроки должны взаимодействовать с полем боя, двигая юнитов, направляя их, задавая им вектор атаки.
И любые действия юнитов асинхронные, то есть это как-то должно работать с асинхронным кодом. Также туда должен быть прокинут API для управления юнитами.

Дело в том, что в JS она базово реализована с помощью promises. Что я хотел сказать про асинхронность? Уже много лет все знают, как с ними работать, но эта игрушка была не только для джаваскриптеров. Тут все для всех понятно, promises отличная штука, отлично работают, почти всегда были у нас. Как делать then-then-then, почему иногда не надо… Что делать с условиями или циклами? Представляете, если я начал объяснять джавистам, как писать код битвы с помощью promises?

[Слайд 8:57] Но представляете тоже, как программистам не из мира джаваскрипта объяснить, что почти перед каждой строчкой нужно ставить await? Можно, конечно, пойти лучшим путем и взять синтаксис async/await. В итоге лучший путь работы с асинхронностью — это вообще ее не использовать.

Мы делаем игрушку не только для людей, пишущих на JS, мы хотим сделать ее доступной для всех, кто умеет кодить. Делать максимально синхронный код и максимально простой API, похожий на практически любой язык программирования.

Пользователь пишет код, и нам нужно его выполнить, подвигать юнитов на карте. Нам это все надо как-то запустить. Это будет работать, но тут есть свои проблемы. Первым делом приходит в голову, что нам нужен eval() плюс нерекомендуемый оператор with, который не рекомендован к использованию на MDN.

Это код, который блокирует выполнение. Например, у нас есть код, который полностью рушит всю нашу идею, который вообще не дает нам дальше ничего выполнять. Например, бесконечный цикл может все сломать. Нужно как-то сделать так, чтобы пользователь не смог заблокировать приложение. Если alert() и prompt() ещё можно переопределить, то бесконечный цикл мы не можем переопределить вообще.

eval() is evil

Так мы доходим до того, что eval() — это зло. Не зря его называют злом, потому что это коварная функция, которая фактически вбирает в себя все самое свободное и открытое, что есть в JS, и оставляет нас полностью беззащитными. Одной простой функцией мы делаем огромную дыру в своем приложении.

Но что, если я скажу вам, [голосом Стива Джобса] что мы переизобрели eval()?

Фактически у меня в коде есть функция eval(), но реализованная с помощью Workers, оператора with и Proxy. Мы сделали eval() на других технологиях, он работает почти так же, как тот же eval(), который у нас уже есть.

Дело в том, что они создают отдельный поток исполнения, то есть JS у нас однопоточный, но благодаря workers мы можем обзавестись еще одним потоком. Почему workers? Например, в рамках тех же бесконечных циклов мы можем оборвать поток, созданный через worker, из главного потока, пожалуй, это главное, почему я использовал workers. Это дает нам много преимуществ. Если нет, то мы его просто обрываем. Если worker успел отработать быстрее, чем за одну секунду, мы считаем его успешным, получаем его результат. Многие сегодня пытались писать while(true), я предупреждал, что это не будет работать. Фактически пользовательский код по какой-то причине не сработал, произошли какие-то непонятные ошибки или он затормозился из-за бесконечного цикла.

Внутри скрипта мы должны сделать обработчик сообщения из главного потока. Чтобы написать наш worker, нам всего лишь нужно в конструктор worker скормить скрипт, который будет загружен по http. Таким образом мы делаем общение между двумя потоками. При помощи функции postMessage() внутри worker мы можем направлять сообщения в главный поток. Не будем же мы каждый раз генерировать какой-то файл скрипта на сервере и скармливать его воркеру. Довольно удобный простой API, но в нем чего-то не хватает, а именно пользовательского кода, который мы должны исполнять в этом worker.

Мы делаем некий блок и скармливаем его в src worker'а. Я нашел способ при помощи URL.createObjectURL(). Кстати, этот путь работает с любыми объектами в DOM, которые имеют src — image так работает, например, и даже в iframe можно загрузить html'ку, просто сгенерировав её из строки. Таким образом, он выгружает наш код прямо из строки. Мы также можем управлять worker, просто передавая ему наш специально сгенерированный объект из URL. Довольно круто и гибко, я считаю. Мы также можем его терминировать и это уже работает как нам надо, и мы создали первую песочницу.

Какое-то сообщение мы отослали, и не можем синхронно дождаться следующего сообщения, worker нам всего лишь возвращает instance, и мы можем подписаться на сообщения. У нас дальше идут асинхронные взаимодействия, потому что любая работа с workers — это асинхронность. Два потока, которыми мы потом управлением при помощи их merge. Мы ловим сообщение при помощи RxJS, мы создаем два потока: один для успешного сообщения из worker, второй для завершения по timeout.

Фактически это как lodash для синхронных операций. В RxJS есть операторы, которые позволяют нам работать с потоками. Мы должны начать мыслить потоками, оператор merge мержит наши потоки, реагирует на любое сообщение. Мы можем указать какую-то функцию и не думать, как она внутри реализована, она снимает с нас головную боль. Нам нужно только самое первое сообщение, соответственно, после первого сообщения мы терминируем worker. Он отреагирует и на timeout, и на message. В случае ошибки выводим эту ошибку, в случае успеха мы делаем resolve.

Наш код становится декларативным, сложность асинхронности куда-то уходит. Тут все довольно просто. Главное — выучить эти операторы.

Я хотел, чтобы Unit API был устроен настолько просто, насколько это возможно. Примерно так мы работаем с Unit API. А мне хотелось сделать максимально просто: все в глобальной области, есть только область видимости Unit API, больше ничего. Говоря про JS, многие думают, что это сложно, надо куда-то лезть, что-то изучать. Все для управления юнитами, даже автокомплит.

Давайте разбираться, почему же его запрещают. [Слайд 15:20] Напрашивается решение, что все это можно засунуть в тот самый запрещённый оператор with.

Например, with, к сожалению, дырявый за пределами того скоупа, который мы в него прокинули, потому что он пытается смотреть глубже Unit API и заглядывает в глобальный скоуп. Дело в том, что у with есть свои проблемы.

Пользователь может делать все, что угодно.
Вот тут последний пример особенно классный, потому что даже четверка может быть опасна для нашего кода, поскольку все эти функции могут выполнять пользовательский код. Это игра для программистов, а они любят исследовать проблемы и пути возможного взлома чего-то.

Сколько бы мы скоупов не подсовывали к нашему пользовательскому коду, во сколько бы скоупов мы его не оборачивали, глобальная область видимости все равно будет видна. Как я уже сказал, скоупы устроены очень дыряво, соответственно, глобальный скоуп всегда доступен. И всё из-за with.

Но мы можем изменить это поведение при помощи Proxy. Фактически он ничего не изолирует, он просто добавляет нам новый слой абстракции, новый глобальный скоуп.

Дело в том что Proxy смотрит за всеми нашими обращениями к объекту, которые проксируются через новый API, и мы можем управлять тем, как будут вести себя запросы новых данных в этом объекте.

Когда мы скармливаем ему какую-то переменную, он под капотом проверяет, есть ли эта переменная в объекте (то есть он выполняет оператор in), и если есть, то выполняет её в объекте, а если нет, то выполняет в верхнем скоупе, в нашем случае в глобальном. Фактически with работает довольно просто. Главное, чем нам поможет Proxy — мы можем переопределить это поведение. Тут довольно просто.

Замечательная штука, которая позволяет нам проксировать любые запросы к объекту. В Proxy есть такая вещь как хуки. Там есть хук has, которому мы можем вернуть только true. Мы можем изменить поведение запроса атрибута, изменить поведение задания атрибута, а главное — можем изменить поведение этого оператора in. Таким образом, мы возьмем и полностью обманем наш оператор with, делая наш API уже куда сохраннее, чем было до этого.

Кажется, это первый случай, когда я радуюсь этой ошибке! Если мы попробуем запустить eval(), он сначала спросит, есть ли этот eval() в unitApi, ему ответят «есть», и получим «undefined is not a function». Мы взяли и сказали пользователю: «Извини, забудь про всё, что ты знал об объекте window, этого больше нет». Эта ошибка — именно то, что мы должны были получить. Мы уже ушли от части проблем, но это еще не всё.

Странноватый в том, что не всё работает так, как хотелось бы, без заглядывания в спецификацию. Дело в том, что оператор with все-таки из JS, JS — динамичный и немного странноватый. То есть мы банально можем скормить ему массив, выполнить этот непонятный код. Дело в том, что with работает ещё и со свойствами прототипа. Все функции массива доступны как глобальные в этом скоупе, что выглядит немного странно.

Прямо взять и забрать, посмотреть, что там в нём есть. Нам важно не это, нам важно то, что пользователь может выполнить valueOf() и получить весь наш sandbox. То есть в новой спецификации по символам завели Symbol.unscopables специально для оператора with, который запрещён. Этого тоже не хотелось, поэтому в спецификации завели интересную штуку: Symbol.unscopables. Например, я! Потому что они верят, что его кто-то ещё использует.

Если нет, то возвращаем его, а вот если да — тогда извините, не возвращаем. Таким образом, мы сделаем еще один перехватчик, где мы специально проверяем, а находится ли этот символ в списке всех атрибутов unscopables. И, таким образом, с with мы не можем получить даже прототип нашего сэндбокса. Мы его тоже не используем.

Это что-то такое, что висит в глобальной области и всё равно доступно. У нас осталось еще окружение Worker. Через прототип в JS можно вытащить практически всё. Дело в том, что если просто переопределить this, оно будет доступно в прототипе. Удивительно, но все эти методы все равно доступны через прототип.

Мы проходимся по всем ключам и все это чистим. Пришлось просто взять и вычистить весь this.

Мы берем обычную функцию, главное, что не стрелочную, у которой есть скоуп, и меняем ее скоуп на наш объект, в котором оставляем маленькую пасхалку для особо любопытного пользователя, который захочет вывести в консоли какой-нибудь this или self. А дальше мы оставляем маленькую пасхалку для пользователя, который все-таки попробуем вызвать this. Я считаю, что пасхалки — это замечательно, и нужно их оставлять в коде.

Мы полностью все заблокировали — по сути, оставили whitelist. Далее оказывается, что остались только с нашим Unit API. Например, API Math, который имеет полезную функцию random, которую многие используют во время написания кода для юнитов. Нам нужно добавить те API, которые полезны и нужны.

Мы создаем while list для наших API. Также нам нужна console и многие другие утилитарные функции, которые не несут никакой разрушительной функции. Это хорошо, потому что если бы мы создали blacklist, мы бы зависели от любого обновления браузера, которое происходит без нашего ведома.

Наш обёрнутый код уже ловит ошибки и может отправлять их пользователю, что очень важно для дебага. Создав whitelist, мы можем начать использовать try-catch в нашем коде.

То есть, если открыть консоль и перейти в окружение worker, то они будут доступны, но говорить пользователю «открой консоль и посмотри, что у тебя произошло» было бы неправильно. Но дело в том, что консольные методы из Worker никак не проявляют себя в пользовательском коде. Я решил сделать дружественную консоль для игрока, где игрок даже без опыта в JavaScript мог бы сам видеть в дружественной форме, что произошло.

Всё это у нас в консоли, поэтому нам нужно ловить ошибки. Ему пишут, что в коде ошибка, что-то пошло не так. В любом случае нужно фильтровать сообщения, чтобы не показывать пользователю пути к файлам из webpack и прочие вещи.

Фактически мы шлём postMessage() на каждый console log, error, warn, info. Для этого я использую магический patchMethod(), который просто патчит консольные методы, заменяя их на обычный postMessage(). Это нужно, чтобы выводить это всё в обычный <div>, который будет красиво всё это показывать, когда у пользователя ошибка, когда код выполнился и когда нет, чтобы всегда давать пользователю фидбек, что произошло с игрой в конкретный момент времени. Мы пропатчили все консольные методы и мы знаем, когда пользователь вызывает консоль.

Все действия у юнита также асинхронны: какой-нибудь проход к какой-нибудь клетке — это обязательно анимация, это проход через несколько клеток, спустя какое-то время нужно выполнять следующее действие. Я много говорил про асинхронность, что все действия у пользователя асинхронны. Дело в том, что внутри этих функций у меня банально actions, которые в виде объектов передают данные дальше. Я, скажем, вообще не использую promises.

Почему именно так, почему у меня не real-time битва? После выполнения всего пользовательского кода у меня получается массив actions. Я реализовал это максимально быстро и более-менее качественно, чтобы ничего не отваливалось. Дело в том, что real-time битва с участием workers дает проблемы с тем, что нужно настраивать общение между клиентом и worker, а у меня было всего три недели. Поэтому весь код, который вы пишете на экране игрока, выполняется до битвы. Основная часть этой игрушки, на которой всё держится, работала. И потом вся битва уже идет по сценарию.

Эти workers изолированы, чтобы ошибка в коде каждого конкретного юнита не била по другим. У меня получаются изолированные workers, которые делают сценарии для каждого конкретного юнита. Это нужно, чтобы если игрок написал код, который не выполняется (как делали некоторые студенты, тестировавшие игру), оппонент все равно исполнял свой код и мог победить в этой битве. Если у одного юнита код отвалился, остальные продолжают ходить. Мораль проста: пишете плохой код — проигрываете.

Разрушительный Math.random()

Всё было хорошо, я всё сделал, у меня остался буквально один вечер до первой конференции, на которой мы запускали игрушку. И тут я вспомнил про Math.random().

А это значит, что Math.random() каждый раз будет выдавать что-то разное. Вроде он работает как надо, но я вспомнил, что игрушка выполняется только на клиенте, а клиентов у нас может быть несколько и на нескольких клиентах может быть запущена одна и та же битва.

То есть у разных игроков получается разный исход битвы. То есть, если игрок пытается написать код, который стреляет по юнитам в рандомном порядке (и это абсолютно нормально), JS выдаёт разные цифры, но такие же цифры выдаются на разных клиентах.

Я бы им ничего не смог доказать, мне нужно было что-то найти. А если учесть, что мы устраивали турнир у себя в компании и давали за всё это призы, вы представляете, сколько человек с вилами и факелами за мной бы побежало.

Конечно, можно было сделать костыль в виде запрета random(), но я нашёл лучший способ. И в итоге я буквально за один вечер получил сразу много проблем, которые могли серьезно подкосить мою игру.

В итоге я понял, что нужно найти какую-то уникальную соль, которая бы сделала этот random() псевдослучайным, но для игроков он должен оставаться случайным. Я знаю, что random() не является полностью случайным — для «честного рандома» всё ещё ищут достаточно недорогое решение, подходящее для использования в персональных компьютерах. Но в случае, когда одна и та же битва запускается на разных клиентах, этот random() должен работать полностью одинаково. Как только игрок что-то меняет и перезапускает код, он должен выдавать случайное число.

Это самая простая функция, которую я нашёл, для создания random() с солью. В итоге я использовал линейный конгруэнтный метод. Мы кидаем какой-то seed и путём нехитрых расчетов делаем из него случайное число (в том смысле, что мы не можем его предугадать без расчётов, которые мы совершаем).

Мы суммируем все индексы символов в коде пользователя и получаем некую соль, из которой потом делаем функцию random().
Соль получается из пользовательского кода, ещё я докидываю туда юниты. Это позволяет работать прозрачно для меня и непрозрачно для пользователей и избавляет нас от кучи проблем с тем, что random() на разных клиентах исполняется по-разному.

Оранжевая «вставка» — это тот самый пользовательский код. По ссылке весь код, который отправляется в worker, он нужен для того, чтобы сделать JS полностью безопасным. Вот насколько немало кода нужно, чтобы просто сделать JS безопасным. который туда инжектится. Путем инжектов я получаю ещё больший код, который отправляется в worker.
Там же инжектятся random() и unit API.

State sharing: RxJS, сервер и клиенты

Так мы разобрали, что у нас с клиентом. Сейчас поговорим про state sharing, зачем это нужно и как было организовано. У нас state хранится на сервере, сервер должен шарить его подключенным клиентам. [Слайд 28:48]

У нас есть четыре роли разных клиентов, которые могут подключаться к серверу: «левый пользователь», «правый пользователь», зритель, который смотрит на главный экран, и администратор, который может делать что угодно.

Левый экран не может изменять state правого игрока, зритель не может менять ничего, а админ может делать всё.

Все устроено довольно просто. Почему это было проблемой? Он шарит любые изменения, которые в него приходят. Любой подключенный клиент может кидать сессию, сервер её принимает и мерджит со state, который есть внутри сервера и потом раздает всем клиентам. Нужно было это как-то фильтровать.

Все взаимодействия с двумя и более подключенными пользователями становятся асинхронными. Для начала скажу, почему на сервере тоже RxJS. Например, оба пользователя нажали кнопку «Готово», надо дождаться, пока оба нажмут, и только потом выполнит действие. Надо ждать результатов от обоих пользователей. Это всё довольно просто разруливалось на RxJS вот каким способом:

Чтобы сделать один поток, который следит только за левым игроком, мы просто берём и фильтруем сообщения из этого сокета по левому игроку (и аналогично с правым). Снова оперируем потоками, есть поток от сокета, который так и назван socket. Мы ждём оба этих действия и вызываем метод setState(), который устанавливает наше состояние как «ready». Дальше мы можем объединить их при помощи оператора forkJoin(), который работает как Promise.all() и является его аналогом. На RxJS это получается максимально декларативно, поэтому я его и использовал. Получается, что мы ждём обоих игроков и меняем состояние сервера.

Надо им запретить это делать. Остаётся проблема с тем, что игроки могут менять state друг другу. Создадим для них отдельные классы, которые унаследованы от Client. Всё-таки они программисты, были прецеденты, что кто-то пытался.

У них будет базовая логика общения игрока с сервером, а в каждом конкретном классе будет его кастомная логика для фильтрации данных.

Client — это фактически пул connections, соединений с клиентами.

Мы эти сырые сообщения записываем в поток. Он просто их хранит и у него есть поток onUnsafeMessage, который полностью unsafe: ему нельзя доверять, это просто сырые сообщения от пользователя, которые он принимает.

Дальше при реализации конкретного игрока мы берем этот onUnsafeMessage и фильтруем его.

Левый игрок может изменять state только левого игрока, соответственно мы берём из всех данных, которые он мог прислать, только state левого игрока. Нам нужно фильтровать только те данные, которые мы можем получить от этого игрока, которым мы можем доверять. Если прислал — берём. Если не прислал — ладно. Таким образом мы из полностью unsafe сообщений мы получаем safe сообщения, которым мы можем доверять при работе в комнате.

Внутри комнаты мы можем писать те самые функции, которые могут изменять state напрямую, просто подписываясь на эти потоки, которым мы уже можем доверять. У нас есть игровые комнаты, которые объединяют игроков. Мы сделали проверки, завязанные на ролях, и назвали их отдельными классами. Мы абстрагировались от кучи проверок. Разделили код таким образом, что внутри контроллера, где выполняются важные функции смены state, код стал максимально простым и декларативным.

Также RxJS используется на клиенте, он подключен к сокету с обратной стороны, эмиттит события и всячески их перенаправляет.

В данном случае хотел бы разобрать пример, когда мне нужно изменить армию правого противника.

Убеждаемся, что это действительно правый игрок, и берём у него сообщение о том, какая у него армия. Чтобы на неё подписаться, мы создаём поток из того же сокета и фильтруем его. Мы сразу декларативно решаем проблему фильтрации событий, у нас её банально не существует. Если сообщения такого нет, поток ничего не вернёт, в нём не будет ни одного сообщения, он будет молчать, пока игрок не изменит армию.

Это довольно просто, тот самый подход, который позволяет нам делать все прозрачно и декларативно. А когда из потока уже что-то пришло, мы вызываем функцию setState(). То самое, ради чего я взял на проект RxJS и в чем он мне отлично помог.

Я создаю потоки, которые у меня довольно понятно названы, с которыми понятно как работать, всё декларативно, вызываются нужные функции, нет возни с большим количеством if и фильтрацией событий, всё это делает RxJS.

История: из синглплеера в мультиплеер

Можно сказать, что это был синглплеер, потому что в неё могли играть только два игрока, количество подключенных клиентов было фиксированным. Итак, первая версия моей игрушки была написана. Все было захардкожено, всё-таки за три недели делалось. У нас был левый игрок, правый игрок и экран позади, всё это подключалось напрямую к серверу.

Чтобы у нас получился список лидеров, мультиплеер, чтобы они могли играть вместе. Мне поступило новое предложение: расширить игрушку на всех программистов в компании, чтобы они могли у себя на компьютерах открывать и играть в неё. Тут я понял, что мне предстоит большой рефакторинг.

Я просто объединил все сущности, которые у меня были, в отдельные комнаты. В итоге оказалось не так сложно. Теперь напрямую с сервером общаются не сами игроки, а комнаты. У меня появилась сущность «Комната», которая могла объединять все роли. Комнаты уже проксировали запросы напрямую к серверу, заменяя state, и стейт стал у каждой комнаты отдельно.

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

JS Gamedev и его проблемы

Прошлый проект я вразвалочку делал около трёх лет, периодически отдыхая. Таким образом я уже серьёзнее познакомился с JS-геймдевом. Я каждый день сидел и что-то делал вечерами. А тут у меня оба раза было по три недели.

Всё отличается от наших бизнес-приложений, где не проблема написать что-то с нуля. Какие же проблемы есть в разработке игр на JS? Более того, много где это даже приветствуется: сделаем всё своё, сами помните истории с NPM и left-pad.

Если бы я взялся за эту игрушку и начал писать её с нуля на WebGL, я бы тоже просидел около полугода за ней, просто пытаясь разбираться в каких-то странных багах. В JS Gamedev так поступить невозможно, потому что все технологии для вывода графики являются настолько низкоуровневыми, что писать что-то на них банально экономически невыгодно. Самый популярный игровой движок Phaser снял с меня эти проблемы…

И с этим ничего нельзя было сделать, он вообще не знает, что такое treeshaking. … и добавил мне новые: 5 мегабайт в бандле. До этого Phaser подключался только в html-теге script, это было странно для меня. Более того, только последняя версия Phaser умеет работать с webpack и бандлами.

Всякие модули имеют крайне скудную типизацию или не имеют её вообще, либо в принципе не умеют в webpack, надо было находить способы оборачивать. Я прихожу из всяких вебпаков-тайпскриптов, а в JS-геймдеве почти ничего в это не умеет. Чтобы начал работать, нужно скачивать отдельный пакет, где он уже обёрнут (brace). Как оказалось, даже Ace Editor в чистом виде вообще не работает с webpack.

Я продолжал писать на Phaser и нашел, как сделать так, чтобы все работало с webpack так, как мы привыкли: чтобы была и типизация, и тесты можно было прикрутить к этому всему. Примерно так же раньше было с Phaser, но в новой версии сделали более-менее нормально. Нашел, что можно взять отдельно PixiJS, который является рендером у webpack, и найти для него массу модулей, которые готовы с ним работать.

Более того, можно даже писать код будто для Canvas, и он будет рендериться в WebGL. PixiJS — замечательная библиотека, которая может рендерить либо на WebGL, либо на Canvas. Главное знать, как она работает с памятью, чтобы не попасть в положение, когда память закончилась. Эта библиотека умеет очень быстро рендерить 2D.

Больше всего мне понравился React-pixi. Я отдельно рекоменду на GitHub репозиторий awesome-pixijs, где можно найти разные модули. Мы всё можем разметить в JSX. Мы можем просто абстрагироваться от решения проблем с вьюхой, когда мы прямо в контроллере пишем императивные функции для рисования геометрических фигур, спрайтов, анимации и прочего. То самое, за что я люблю абстракции. Мы пришли из мира JSX с нашим бизнес-приложением и можем использовать их дальше. React-pixi даёт нам эту знакомую абстракцию.

Также советую взять tween.js — тот самый знаменитый движок анимации из Phaser, который позволяет делать декларативно анимацию, чем-то похожую на CSS-анимацию: делаем переход между состояниями, а tween.js решает за нас, каким именно образом подвинуть объект.

Типы игроков: кто они и как с ними подружиться

Я столкнулся с разными игроками, и хотел бы ещё рассказать вам про тестирование игрушки. Я собирал коллег в одной закрытой комнате и не выпускал, пока они не доиграют. К сожалению, доиграть смогли не все, потому что в самом начале у меня было много багов. К счастью, я начал тестирование, как только появился хоть какой-то рабочий прототип. Честно говоря, первое тестирование завалилось, потому что у пары игроков ничего не завелось. Было обидно, но это дало мне пинок, который позволил двигаться дальше.

Все люди ждут от игр какого-то фана, ждут счастья, которое вы им дадите. Когда ваша игрушка готова, вас могут принять очень хорошо, а могут с вилами и факелами. Когда у вас онлайн-игрушка, то таких багов становится еще больше. А вы им даёте что-то, что вообще не работает, хотя у вас вроде работало.

Они приятно могут дополнить ее всякими мелочами, подсказав тебе что-то добавить. В итоге самые приятные люди из тех, с кем я столкнулся, — «исследователи», которые всегда находят в твоей игрушке больше, чем там есть на самом деле. Но, к сожалению, общение с этими людьми не давало важного — стабильности игрушки.

Они могут иногда даже не замечать багов, как-то проскакивая через них на своём пути к удовольствию. Есть рядовые игроки, которые приходят исключительно ради фана.

С такими людьми надо дружить, хотя они будут говорить кучу негатива. Ещё одна категория — собиратели багов, у которых практически всё не работает. Нужно работать с ними, потому что в итоге именно эти люди сделают вашу игру качественной. С ними нужно завязать странные отношения: они делают вам больно, а вы пытаетесь у них взять что-то полезное для себя, «давай посидим за твоим компом, посмотрим».

Ваш глаз замыливается, а тестирование обязательно покажет то, что скрыто. Тестировать нужно только на живых людях. Вы идете напрямую к вашим потребителям, показываете им и смотрите, как они играют, на какие клавиши нажимают. Вы разрабатываете игрушку и погружаетесь всё глубже, пилить какие-то фичи, а они, возможно, даже не нужны. Видишь, что некоторые люди постоянно нажимают Ctrl+S, потому что привыкли сохранять код — ну сделай хотя бы запуск кода по Ctrl+S, игрок почувствует себя комфортнее. Это даёт вам стимул делать именно то, что нужно. Нужно создавать комфортную среду для игрока, для этого нужно любить его и следить за ним.

Восприятие работает так, что основная механика готова, всё двигается и работает, значит, игра почти готова, и разработчик скоро допилит. Работает правило 80/20: вы делаете демку 20% времени от всей разработки игры, а для игрока это выглядит как на 80% завершённая игра. Как я уже говорил, довольно долго пришлось работать над документацией, чтобы она была понятна для всех. Но на самом деле разработчику ещё предстоит путь из 80%. И много времени у меня ушло на поиск багов. Я показывал её многим людям, которые говорили свои комментарии, я их фильтровал, пытаясь понять суть высказываний.

Доделывать игры — скучный процесс, а вот начинать — замечательный. Так что в геймдеве я бы мог вам посоветовать делать только демки: они всех радуют, не требуют много времени, и от демок никто ничего особо не ждет.

Напоследок я вам оставляю ссылки:

На сайте уже появились первые спикеры.
Вы тоже можете подать заявку на доклад, Call for Papers открыт до 11 марта.
Цены на билеты поднимутся 1 февраля. 24-25 мая в Санкт-Петербурге пройдет HolyJS 2019 Piter, конференция для JavaScript-разработчиков.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Ложные срабатывания в PVS-Studio: как глубока кроличья нора

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

Онлайн контест по решению задачи из теории игр

Привет, Хабр! На факультативе по теории игр мы решаем различные интересные задачи, и я хотел бы поделиться с вами одной из таких. Меня зовут Миша, и я студент. Описание игры «Я люблю вархаммер, поэтому решил адаптировать условие» Играют двое. 1. ...