Эволюция на React+Redux

в 7:21, , рубрики: Gamedev, javascript, node.js, nodejs, open source, React, ReactJS, redux, настольные игры, разработка игр

КДПВ

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

Сначала немного об игре, прячу под спойлер для тех, кто пришел за техническими подробностями:

Об игре.

Игра состоит из колоды карт и фишек еды. Каждый ход делится на фазы:

Фаза развития: все выкладывают карты по очереди. Карту можно положить двумя способами — рубашкой, как животное, или же как свойство на уже существующее.

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

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

Когда колода закончится, все подсчитывают очки за животных и накопленные свойства.

Свойства самые разные, я не буду перечислять все, а приведу пару примеров: "Жировой Запас": животное может взять дополнительную фишку еды и “отложить” её как, собственно, жировой запас, так что в голодный ход оно выживет. Есть ещё парные свойства, связывающие два вида, например, "Сотрудничество": Когда одно животное получает еду, второе получает фишку еды бесплатно.

И одно, особенное свойство "Хищник +1": животному для выживания требуется на единицу больше еды, но зато оно может атаковать и кушать других.

Собственно, в этом и заключается игра — не просто брать фишки еды, а ещё и защищаться от хищников.

Если хотите ещё примеров — то есть “Большое +1” (Большому животному нужна дополнительная еда, но зато скушать его может только хищник с таким же свойством) или же "Камуфляж" — животное можно атаковать, только если у хищника есть свойство "Острое Зрение".

Некоторые, например "Паразит +2", можно выложить только на животное соперника, тогда ему потребуется на 2 фишки еды больше, что усложнит его выживание.

В целом, игра отличается довольно простыми базовыми правилами, однако просчитывать все взаимодействия довольно интересно и иногда сложновато. Отдельно стоит упомянуть дополнения, которых примерно три штуки, они переворачивают всё вверх дном. То есть, если первое ещё нормальное, просто добавляет девять новых свойств (хоть и с хитрой механикой), то второе, "Континенты", делит стол на три части и вся игра происходит на трех непересекающихся континентах. А "Растения" убирают из игры кубики, и кормовой базой становятся, собственно, растения, которыми тоже можно управлять.

Так, вот, теперь о проекте, его я прятать под кат не буду, вы же за этим и пришли:

Как-то раз, я решил изучить тогда ещё новомодные React и Redux… Нет, неправильно начинать сразу с них, сначала про то, что позволило мне дописать хоть одну игру в своей жизни и вообще спасло проект:

Тесты

Эволюция на React+Redux - 2

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

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'а и его детерминированности.

It fills you with determination

Не всегда хватает воли для жесткого MVC/MVVM, однако React таки заставляет выносить всю логику вовне и гарантирует, что при состоянии X (которое легко узнать), UI будет вот такой-то. Как я прочитал у кого-то "React — это функция, которая принимает состояние и возвращает UI". Вместе с Redux это избавляет от сайд-эффектов и "наполняет определенностью", я точно знаю, что, где и когда у меня происходит. Это очень круто, плюс, я не испытываю отвращения к jsx, наоборот, не надо запоминать всякие фишки шаблонов типа {%<{{x | filter % sdfsdf}}>%}, а так же не надо определять области видимости. Не знаю, как с этим в vue и angular 2, но в первом, ох уж эти скоупы. Да и в целом проще дебажить.

Ну и всякие фичи типа порталов меня прямо поразили. Действительно, я пишу компонент для комнаты, почему бы в нём же не протянуть что-то в header? И не гокодерски запихнуть туда, а только при наличии в нем компонента <PortalTarget name='header'/>

export class Room extends Component {
  ...
  render() {
    const {room, roomId, userId} = this.props;
    return (<div className='Room'>
      <Portal target='header'>
        <RoomControlGroup inRoom={true}/> // <= вот эта штука рисуется в Header'е
      </Portal>
      <h1>{T.translate('App.Room.Room')} «{room.name}»</h1>
      <div className='flex-row'>
        <Card className='RoomSettings'>
          <CardText>
            <RoomSettings {...this.props}/>

Мультиязычность мне показалось самым удобным сделать через 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 объектов в, собственно, модели.

Работает это так:

1) Юзер жмет кнопку “Начать игру”.
2) React-redux диспатчит действие gameCreateRequest
3) Клиентское middleware:

const nextResult = next(action);
if (action.meta && action.meta.server) {
  action.meta.token = store.getState().getIn(['user', 'token']);
  socket.emit('action', action);
}
return nextResult;

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 / помочь с дизайном (это все ещё не тонкие намеки), чуть ниже можете высказать всё, что думаете обо всяких хипстерах, лезущих кодить на богомерзком недоязыке. Чтобы не попасть в Я пиарюсь, кину ссылку на сайт в комменты.

Автор: Fen1kz

Источник

Поделиться

* - обязательные к заполнению поля