Привет, хабр, я тут написал онлайн версию замечательной настольной игры «Эволюция: Происхождение видов» и хотел бы поделиться своими заметками насчет архитектуры и технических моментов. Сразу уточню — я не пиарюсь, скорее, мне интересно рассказать про ошибки и фичи, а взамен услышать много нового и хорошего о своих решениях и коде.
Сначала немного об игре, прячу под спойлер для тех, кто пришел за техническими подробностями:
Об игре.
Игра состоит из колоды карт и фишек еды. Каждый ход делится на фазы:
Фаза развития: все выкладывают карты по очереди. Карту можно положить двумя способами — рубашкой, как животное, или же как свойство на уже существующее.
Фаза питания: первый игрок кидает кубики и выкладывает фишки еды в кормовую базу. По очереди каждый игрок берет оттуда по одной фишке и кормит ею свое животное.
Фаза вымирания: те животные, кому не хватило еды, умирают, затем игроки получают новые карты из колоды и начинают все заново.
Когда колода закончится, все подсчитывают очки за животных и накопленные свойства.
Свойства самые разные, я не буду перечислять все, а приведу пару примеров: «Жировой Запас»: животное может взять дополнительную фишку еды и «отложить» её как, собственно, жировой запас, так что в голодный ход оно выживет. Есть ещё парные свойства, связывающие два вида, например, «Сотрудничество»: Когда одно животное получает еду, второе получает фишку еды бесплатно.
И одно, особенное свойство «Хищник +1»: животному для выживания требуется на единицу больше еды, но зато оно может атаковать и кушать других.
Собственно, в этом и заключается игра — не просто брать фишки еды, а ещё и защищаться от хищников.
Если хотите ещё примеров — то есть «Большое +1» (Большому животному нужна дополнительная еда, но зато скушать его может только хищник с таким же свойством) или же «Камуфляж» — животное можно атаковать, только если у хищника есть свойство «Острое Зрение».
Некоторые, например «Паразит +2», можно выложить только на животное соперника, тогда ему потребуется на 2 фишки еды больше, что усложнит его выживание.
В целом, игра отличается довольно простыми базовыми правилами, однако просчитывать все взаимодействия довольно интересно и иногда сложновато. Отдельно стоит упомянуть дополнения, которых примерно три штуки, они переворачивают всё вверх дном. То есть, если первое ещё нормальное, просто добавляет девять новых свойств (хоть и с хитрой механикой), то второе, «Континенты», делит стол на три части и вся игра происходит на трех непересекающихся континентах. А «Растения» убирают из игры кубики, и кормовой базой становятся, собственно, растения, которыми тоже можно управлять.
Так, вот, теперь о проекте, его я прятать под кат не буду, вы же за этим и пришли:
Как-то раз, я решил изучить тогда ещё новомодные React и Redux… Нет, неправильно начинать сразу с них, сначала про то, что позволило мне дописать хоть одну игру в своей жизни и вообще спасло проект:
Тесты
Дело в том, что писал я вечерами после работы и, естественно, не каждый день, однако даже спустя месяц я мог открыть проект, в котором ничего не помню, и спокойно начать кодить очередную фичу. Не уверен, что у меня получились именно юнит-тесты, потому что в основном я тестирую так:
it('User0 creates Room, User1 logins', () => {
const serverStore = mockServerStore(); // На самом деле не mock, а просто серверный стор, с подмененным сетевым middleware
const clientStore0 = mockClientStore().connect(serverStore); // Аналогично не mock
const clientStore1 = mockClientStore().connect(serverStore);
// Диспатчим логин
clientStore0.dispatch(loginUserFormRequest('/test', 'User0', 'User0'));
// Диспатчим создание комнаты
clientStore0.dispatch(roomCreateRequest());
const Room = serverStore.getState().get('rooms').first();
clientStore1.dispatch(loginUserFormRequest('/test', 'User1', 'User1'));
expect(clientStore0.getState().get('room'), 'clientStore0.room').equal(Room.id);
expect(clientStore0.getState().getIn(['rooms', Room.id]), 'clientStore0.rooms').equal(Room);
expect(clientStore1.getState().get('room'), 'clientStore1.room').equal(null);
expect(clientStore1.getState().getIn(['rooms', Room.id]), 'clientStore1.rooms').equal(Room);
});
То есть, с одной стороны я старался тестировать максимально изолированный кусок функциональности, с другой — диспатчу действие на клиенте, который сам «отсылает» его на сервер, получает ответ, а я только проверяю создание комнаты.
Кстати, если заметили — тесты у меня синхронные и работают за счет синхронного мока для socket.io. Не нашел ничего подобного на npm, поэтому завелосипедил. Нет, я признаю, на самом деле это очень спорный момент, потому что весь проект также должен быть синхронным, но на каждый помидор я отвечу KISS. Конечно, я пытался переписать всё на асинхронные тесты (с async/await), однако понял, что клиентский dispatch должен будет отдавать promise с сервера, и мне придется корячить сетевой middleware только для тестов, а как-то не хочется всё менять. Однако, в теории, это возможно.
Пример более продвинутого теста:
Когда существо со свойством «Хищник» нападает на существо со свойством «Мимикрия», тот оно, если возможно, перенаправит атаку на другое существо того же игрока:
it('$A > $B m> $C', () => { // Это типа существо A нападает на B, а то мимикрирует под C
const [{serverStore, ParseGame}, {clientStore0, User0, ClientGame0}, {clientStore1, User1, ClientGame1}] = mockGame(2);
// mockGame(количество игроков) создает сервер и клиенты игроков и возвращает массив из [{serverStore, ParseGame}, ...и тут пошли игроки]
// ParseGame принимает описание игры в yml формате и возвращает ID'шник игры.
// А внутри оно создает игру и запускает в нее игроков.
const gameId = ParseGame(`
phase: 2 // Фаза кормления (потому что нападать можно только в нее)
food: 10 // Количество фишек еды на столе = 10 штук, просто так
players: // Массив игроков
- continent: $A carn // Существо с id "$A" и свойством Хищник, которое в игре зовется TraitCarnivorous, и резолвится по подстроке.
- continent: $B mimicry, $C // Два существа - одно с ID "$B" и свойством Мимикрия, а другое просто с ID "$C".
`);
const {selectAnimal, selectTrait} = makeGameSelectors(serverStore.getState, gameId); // Я не использую reselect (а зря), поэтому тут такие хелперские селекторы
expect(selectTrait(User1, 0, 0).type).equal('TraitMimicry'); // Надо бы удалить, но тут я проверяю что у второго игрока у первого животного первое свойство и правда мимикрия.
// А активирует навык "Хищник" на существо Б
clientStore0.dispatch(traitActivateRequest('$A', 'TraitCarnivorous', '$B'));
expect(selectAnimal(User0, 0).getFoodAndFat()).equal(2); // А получило еду за успешную охоту
expect(selectAnimal(User1, 0).id).equal('$B'); // Однако В живо
expect(selectAnimal(User1, 1)).undefined; // А вот С мертвое = В успешно перенаправило атаку.
});
Таких тестов на мимикрию у меня 7 штук:
А атакует Б с мимикрией, С с камуфляжем (Б не может перенаправить атаку на С, ведь оно невидимое, и А съедает Б)
А атакует Б с мимикрией, просто С (вышеописанный случай)
А (Хищник), Б (Мимикрия), С (Мимикрия): А атакует Б, Б перенаправляет атаку на С, С перенаправляет атаку на Б обратно, но игра не входит в бесконечный цикл, а А съедает Б
А (Хищник), Б (Мимикрия), С, D: А атакует Б, и игра спрашивает у игрока 2, каким именно существом (C или D) он хотел бы пожертвовать? Тот отвечает что C, и А съедает C.
А (Хищник), Б (Мимикрия), С (Мимикрия), D: А атакует Б, игра спрашивает у игрока 2, каким именно существом (C или D) он хотел бы пожертвовать? Тот отвечает, что C, то опять мимикрирует, и игра спрашивает во второй раз, каким именно существом (B или D) на этот раз тот пожертвует. Игрок отвечает, что B, и оно умирает.
А (Хищник), Б (Мимикрия), С, D: А атакует Б, и игра спрашивает у игрока 2, каким именно существом (C или D) он хотел бы пожертвовать? А тот не отвечает, и игра сама принимает решение, кого убить.
Асинхронный тест, аналогичный предыдущему, но где игрок никак не отвечает за отведенный промежуток времени в 1 мс. В качестве «игрок не ответил» я использую await new Promise(resolve => setTimeout(resolve, 1));
И последний тест, видимо, связан с каким-то багом: он проверяет, что, после охоты на существо с мимикрией, наступает новый раунд. Не помню, зачем.
К чему это всё? К тому, что я могу не беспокоиться, что где-то у меня мимикрия сработает неправильно. Я могу переписать всю логику охоты или «задавания вопросов», а тесты покажут, что я облажался всё работает.
Поэтому, кстати, не надо проверять детали. Только существенный логический исход, типа существо С умерло, существо А получило еду итд. Одно время я пытался проверять какие-то скрытые параметры (типа, у игрока стоит флаг «походил»), однако, по итогу, я просто стал проверять, что игрок не может походить снова.
Так что в своих, особенно домашних, проектах я рекомендую обкладывать всю логику тестами. Кроме улучшения стабильности, они ещё и помогают возвращаться к проекту.
Отдельно про клиентские тесты — тут у меня не всё так радужно, я часто переписывал клиент и после четвертого раза я бросил их писать.
Клиент и дизайн.
Да и сейчас игровая часть клиента меня вообще не устраивает, но я не могу придумать ничего лучше. В идеале, должен был получиться «Material UI Hearthstone» с крутым «visual language», который «synthesizes the classic principles of good design with the innovation and possibility of technology and science» Material design. Introduction, а получились серые прямоугольнички с Roboto посередине. Нет, ладно, на самом деле меня вообще не колышет дизайн, но есть же ещё сам «стол», то место, где лежат карты, еда и существа. И вот тут-то полный швах, начиная от того, что мне не вместить всю информацию, и заканчивая тем, что у меня парадоксально много свободного места.
Дело вот в чем — во-первых, я отвратительный дизайнер и из стилей предпочитаю брутализм. Во-вторых, мне лень. И, в-третьих, сама игра подкладывает свинью — у игрока может быть как одно, так и двадцать существ. И на них также может быть от одного до двадцати свойств. А самих игроков — от двух до восьми. Так что я не представляю как сделать что-то вменяемое, что будет масштабироваться от пары объектов до сотни. Возможно, вариант сделать всё «как в Hearthstone» с его принципом «как настольная игра» здесь не самый лучший.
React
Пусть оно так себе на вид, зато работает, и в этом большая заслуга React’а и его детерминированности.
Не всегда хватает воли для жесткого MVC/MVVM, однако React таки заставляет выносить всю логику вовне и гарантирует, что при состоянии X (которое легко узнать), UI будет вот такой-то. Как я прочитал у кого-то «React — это функция, которая принимает состояние и возвращает UI». Вместе с Redux это избавляет от сайд-эффектов и «наполняет определенностью», я точно знаю, что, где и когда у меня происходит. Это очень круто, плюс, я не испытываю отвращения к jsx, наоборот, не надо запоминать всякие фишки шаблонов типа {%<{{x | filter % sdfsdf}}>%}, а так же не надо определять области видимости. Не знаю, как с этим в vue и angular 2, но в первом, ох уж эти скоупы. Да и в целом проще дебажить.
Ну и всякие фичи типа порталов меня прямо поразили. Действительно, я пишу компонент для комнаты, почему бы в нём же не протянуть что-то в header? И не гокодерски запихнуть туда, а только при наличии в нем компонента
Мультиязычность мне показалось самым удобным сделать через i18n-react, для дизайна я использую использую react-mdl. Отдельные лучи любви вперемешку с ненавистью высылаю библиотеке react-dnd, она крута.
Однако, у React«а есть и минус — анимации. Что-то сложнее чем CSS Transitions сделать уже не так просто. Да и получается, что состояние одно, а UI должен быть разным.
Я решил эту проблему отвратительнейшим образом, породив чудовищного монстра — AnimationService. Вкратце, он сует свой middleware в клиента, отлавливает все действия и запускает анимацию для первого из них, остальные кладет в очередь и, как только анимация завершена, запускает следующее. Что дает кучу багов, например с тем, что пока карты красиво летят вам в руку, вы не можете выйти из игры.
С другой стороны — я могу анимировать компоненты с Velocity.js как-то так:
export const createAnimationServiceConfig = () => ({ // уже по названию можно определить, что дело нечисто
animations: ({subscribe, getRef}) => { // subscribe - подписаться на Action, getRef - получить компонент по строке
// Подписываться так:
subscribe("тип действия", (done (надо вызвать по окончанию анимации), actionData, getState) => {
// Вот тут можно императивно анимировать
...
На самом деле, зря я его написал, и единственная анимация, для которой пригодился этот монстр — это раздача карт (зато как в Hearthstone!11!), так что хватит о нём.
Итак, в общем, с React’ом почти всё хорошо, во многом благодаря тому, что он не лезет не в свое дело, а логикой занимается Redux.
Redux
Именно он делает всю работу и на клиенте, и на сервере. И даже общаются между собой они через middleware с socket.io. Я сделал некое подобие RPC, выглядит как-то так (приготовьтесь, сейчас будет большой кусок кода из game.js)
// Game Create
// Request на конце обозначает, что действие клиентское
export const gameCreateRequest = (roomId, seed) => ({
type: 'gameCreateRequest' // Да, типы действий у меня строкой, сорри
, data: {roomId, seed} // Это данные
, meta: {server: true} // Middleware на клиенте поймает этот параметр и перешлет действие серверу
});
// Это действие сервер вышлет тем клиентам, которые начинают игру
const gameCreateSuccess = (game) => ({
type: 'gameCreateSuccess'
, data: {game}
});
// А это - всем клиентам
const gameCreateNotify = (roomId, gameId) => ({
type: 'gameCreateNotify'
, data: {roomId, gameId}
});
// Вызывается самим сервером
export const server$gameCreateSuccess = (game) => (dispatch, getState) => {
// Сначала сервер создает игру в своем Store
dispatch(gameCreateSuccess(game));
// Потом высылаем всем Notify, что игра создана
dispatch(Object.assign(gameCreateNotify(game.roomId, game.id)
, {meta: {users: true}}));
// Потом каждому игроку высылаем свою версию игры.
selectPlayers4Sockets(getState, game.id).forEach(userId => {
dispatch(Object.assign(gameCreateSuccess(game.toOthers(userId).toClient())
, {meta: {userId, clientOnly: true}}));
});
// Немного криво сделано, потому что раньше игра высылалась игрокам сразу вместе с картами и, соотвественно, требовалось высылать каждому игроку свою копию игры.
// Теперь все не так и метод можно переписать на что-нибудь типа:
// dispatch(Object.assign(
// gameCreateSuccess(game.toOthers(null).toClient())
// , {meta: {clientOnly: true, users: selectPlayers4Sockets(getState, game.id)}}
// ));
// Но мне лень.¯\(°_o)/¯
};
// ... Ещё 40 действий ...
// И потом ноу хау:
export const gameClientToServer = {
gameCreateRequest: ({roomId, seed = null}, {userId}) => (dispatch, getState) => {
// Тут всякие проверки, создание игры и прочее, и потом
dispatch(server$gameCreateSuccess(game));
}
// ...
}
export const gameServerToClient = {
// А это то, что поймает клиент
gameCreateSuccess: (({game}, currentUserId) => (dispatch) => {
dispatch(gameCreateSuccess(GameModelClient.fromServer(game, currentUserId)));
dispatch(redirectTo('/game'));
})
...
}
Объект gameClientToServer состоит из разрешенных серверу на прием действий, так что напрямую действие типа «shutdownServer» послать не получится. А обратный просто переводит какие-то модели или ещё что-нибудь из JSON объектов в, собственно, модели.
nextResult нужен для тестов (которые у меня, напомню, синхронные), если вызывать next (action) после socket.emit (), то клиентский reducer обработает действие отсылки позже ответа от сервера.
4) Сервер принимает действие:
socket.on('action', (action) => {
if (clientToServer[action.type]) { // clientToServer есть объект, собранный из всех xxxClientToServer, будь то roomClientToServer или gameClientToServer
const meta = {connectionId: socket.id} // Иногда серверу в ActionCreator'е нужен id сокета. Например, для логина юзера.
if (!~UNPROTECTED.indexOf(action.type)) { // Если тип действия не в массиве UNPROTECTED, то валидируем токен
// валидация токена
}
const result = store.dispatch(clientToServer[action.type](action.data, meta));
// собственно вот тут и вызывается gameClientToServer.gameCreateRequest со всеми параметрами
5) Как я писал выше, вызывается server$gameCreateSuccess, которые диспатчит gameCreateSuccess только серверу, затем gameCreateNotify и gameCreateSuccess каждому из игроков
6) Reducer сервера ловит gameCreateSuccess и создает игру
7) Middleware сервера ловит gameCreateNotify и отправляет его всем клиентам (чтобы они знали, что игра в такой-то комнате началась)
8) Так же оно ловит последующие gameCreateSuccess (с игрой для каждого игрока), отправляет и не пускает к серверному Reducer«у (потому что в meta указано clientOnly: true)
Вот как-то так оно все и работает.
Окружение
Работает оно на herokuapp на бесплатном аккаунте. Что не очень хорошо, так как они требуют 6 часов даунтайма. Однако, в связи с полумертвой посещаемостью (иногда, ночью, по будням играют 3 чувака из Сибири), меня это не очень беспокоит.
Потому же, меня не беспокоит и то, что логин через ВК у меня не читается из базы, а запрашивается каждый раз заново. Забавно, конечно — как-то раз я подумал, что проект достаточно вырос для использования базы данных, прикрутил бесплатную монго от mlab.com, даже пишу туда ВК токены и… просто запрашиваю новые. Нет, я не спорю что когда-нибудь я все-таки буду при логине запрашивать статистику и Oauth токены, но пока что БД бесполезна чуть более, чем полностью.
Состояние всех игр хранится прямо в redux. Я где-то видел сумрачных гениев, что хранят состояние в базе, но лично я не понимаю, зачем. Возможно, я не прав.
Собирается первым вебпаком, второй тогда ещё не вышел. В разработке клиент идет через webpackMiddleware, а сервер — через nodemon+babel-node. Единственный минус — при изменении на бекенде приходится долго ждать пока пересоберётся фронтенд. Я пытался сделать hot reloading для ноды, но как-то не пошло. Да и зачем, для сервера у меня есть тесты.
Вкратце ещё упомяну «нетрадиционный» логгинг — в файл писать не вариант, ибо heroku всё стирает, а всякие специализированные сервисы либо неудобные, либо платные, поэтому я нашел замечательный модуль для winston — winston-google-spreadsheet. Да, он пишет логи в гуглотабличку. Мне нравится больше чем тот же loggly.
Выводы:
Технические:
React, хоть уже и устарел (: trollface:), но сознание переворачивает, и, я считаю, к ознакомлению обязателен.
То же и про Redux.
Синхронные тесты хороши, но именно настолку или пошаговую игру я бы сделал через асинхронно и с promise«ами. То есть, отправил — дождался ответа. Тогда на сервере не придется страдать от невозможности задать какому-либо действию коллбек.
Любые коллекции надо делать Map«ами или объектами. В самом начале я подумал — хммм, KISS, зачем мне объект с животными, когда я могу хранить их в списке. В результате, game.getAnimalById идет поиск по массиву. Да, ошибка, мне стыдно, когда-нибудь я это перепишу.
Гуманитарные:
Во-первых, переводить настолки в онлайн — дело неблагодарное. В том плане, что тонкостей и правил много, вещи, которые решаются между игроками буквально парой слов, превращаются в мегабайты кода, запросов и костылей. А настольщики всегда будут недовольны какой-то мелочью, которую вот никак не сделать. Плюс — это всегда мультиплеер, причем долгий по геймплею, а значит и игроков будет мало.
Во-вторых — я взял неправильную игру. Основная сложность и геймплей эволюции — в вычислении комбинаций и их взаимодействия. Компьютер забирает все просчеты себе и человеку остается лишь выбрать из пары вариантов. Таким образом, геймплей пусть и не уничтожен, но порушен знатно, так как продумывать его следует наперед,. Ну и, спасибо авторам, они радуют дополнениями, которые ставят всё с ног на голову. То есть был у игрока один «континент» с животными, а тут их хоп, три. Круто! Интересно! Половину игры перепиши, ага-да : D
Суммируя — у меня получилось то, что я хотел. Код, я считаю, местами даже красивый, а в целом — не отвратительный (кроме AnimationService, конечно). Вот тут можете форкнуть / прислать пулл-реквест / помочь с разработкой / запостить issue / перевести на английский ru-ru.json / помочь с дизайном (это все ещё не тонкие намеки), чуть ниже можете высказать всё, что думаете обо всяких хипстерах, лезущих кодить на богомерзком недоязыке. Чтобы не попасть в Я пиарюсь, кину ссылку на сайт в комменты.
Комментарии (3)
Fen1kz
7 апреля 2017 в 10:28
0
↑
↓
Ах да, естественно только хром, хотя баги для файрфокса я правлю. На деве можно играть с двух обычных вкладок, на сайте — только с обычной и приватной.
http://evo2.herokuapp.com
pewpew
7 апреля 2017 в 10:42
0
↑
↓
Спасибо за open source!
Сам являюсь поклонником этой игры и настолок в принципе.
Обе найденные на просторах интернета реализации как-то стухли. И конечно они были без исходников.
Теперь в случае чего можно будет поиграть с друзьями, даже если нас отделяют тысячи километров.
raveclassic
7 апреля 2017 в 10:45
0
↑
↓
Если вам нужно что-то сложнее обычного transition, используйте css transition group, если еще сложнее, то такое состояние нужно выносить в стор. Особенно, если его нужно уметь согласовывать с другими сложными анимациями и/или отменять.
В качестве «борьбы с асинхронщиной», могу посоветовать посмотреть в сторону redux-saga.
PS. ну и, собственно, как начать игру я так и не разобрался :)