Pull to refresh

Завариваем геймдев. Часть 1

Reading time14 min
Views9.4K

Введение


История началась с хакатона по разработке игр на блокчейне. На старте мероприятия я встретил человека, который в качестве хобби создает настольные деловые игры (я был на плейтесте одной такой игры), мы объединились, вместе нашли команду, с которой за выходные “слепили” простую стратегическую игру. Хакатон прошел, а задор остался. И у нас родилась идея многопользовательской карточной игры о счастье, мировом сообществе и выборах.

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

  • Краткая информация об игре
  • Как принималось решение на чем делать backend. Где это будет “жить” так, чтобы за это не платить на этапе разработки
  • Первые шаги в разработке — аутентификация игроков и организация поиска игры (matchmaking)
  • Дальнейшие планы

О чем игра


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

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

В конце 3, 6 и 9 раундов из игры удаляется игрок с наименьшим количеством голосов. Если у нескольких игроков одинаковое минимальное количество голосов, то из игры выбывают все игроки с данным результатом. Голоса этих игроков переходят в общий пул электората.

В конце 12 раунда выбирается победитель, набравший наибольшее количество голосов.

Выбор инструмента для backend


Из описания игры следует:

  1. Это multiplayer
  2. Нужно как то идентифицировать игроков и управлять учетными записями
  3. Наличие социальной составляющей пошло бы игре на пользу — друзья, сообщества (кланы), чаты, достижения (ачивки)
  4. Потребуются лидерборды и функционал поиска игры (matchmaking)
  5. В будущем будет полезен функционал управления турнирами
  6. Учитывая, что игра карточная, потребуется управление каталогом карт, возможно потребуется хранить доступные игроку карты и составленные колоды
  7. В будущем может потребоваться внутриигровая экономика, включая игровую валюту, обмен виртуальными товарами (карты)

Посмотрев на список потребностей сразу пришёл к выводу, что делать свой backend на начальном этапе не имеет смысла и отправился гуглить какие есть другие варианты. Так я узнал, что есть специализированные облачные игровые backend’ы, среди которых выделяются PlayFab (куплен Microsoft) и GameSparks (куплен Amazon).

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

PlayFab


Положительные отличительные черты:

  • Аккаунты из разных игр объединяются в мастер аккаунт
  • Игровая экономика описывается без единой строчки кода, включая ценообразование до отдельного виртуального магазина
  • Дружественный пользовательский интерфейс
  • После приобретения Microsoft’ом продукт развивается
  • Стоимость владения в продакшене по подписке Indie Studio составляет $99 (до 100к MAU), при переходе на Professional подписку 1к MAU будет обходиться в $8 (минимальный счет $300)

Отрицательные отличительные черты:

  • Хранение игровых данных жестко лимитировано, например, в бесплатной подписке для хранения данных конкретной игровой сессии (если я правильно все понял, для этого используется Entity Groups) доступны 3 слота по 500 байт каждый
  • Для организации Multiplayer нужно подключать сторонние сервера, которые будут обрабатывать события с клиентов и просчитывать игровую логику. Это либо Photon на своем железе или Azure Thunderhead, причем нужно не только организовать сервера, но и повысить подписку как минимум до Инди студии
  • Нужно мириться с тем, что облачный код без автокомплита и нет возможности разбивать на модули (или не нашел?)
  • Нет нормального дэбагера, можно только писать логи в CloudScript и просматривать

GameSparks


Положительные отличительные черты:

  • Хранение игровых данных. Мало того, что есть много мест, где можно сохранить данные (общие метаданные игры, виртуальные товары, профиль игрока, мультиплеерные сессии и т.д.), так платформа еще предоставляет полноценный database-as-a-service не привязанный ни к чему, причем доступны сразу и MongoDB и Redis для разного вида данных. В разработческом окружении можно хранить 10 MB, в боевом 10 GB
  • Multiplayer доступен в бесплатной подписке (Development) с ограничением на 10 одновременных подключений и 10 запросов в секунду
  • Удобная работа с CloudCode, включая встроенный инструмент для тестирования и дэбагинга (Test Harness)

Отрицательные отличительные черты:

  • Ощущение, что с момента покупки Amazon’ом (зима 2018) инструмент стагнирует, нововведений никаких нет
  • Опять же после приобретения Amazon тарифы стали хуже, раньше можно было бесплатно пользоваться в продакшене до 10 000 MAU
  • Стоимость владения в продакшене начинается от $300 (подписка Standard)

Размышления


Сначала предстоит проверить концепт игры. Для этого хочется без денежных вложений построить прототип из палок и скотча и начать плей тесты игровых механик. Поэтому на первое место при выборе поднимаю возможность вести разработку и тестирование механик на бесплатной подписке.
GameSparks этому критерию удовлетворяет, а PlayFab нет, потому что потребуется сервер, который будет обрабатывать события игровых клиентов и еще подписка уровня Инди студии ($99).

При этом принимаю риск, что Amazon не развивает GameSparks, а значит он может “умереть”. Учитывая это и ещё стоимости владения в продакшене держу в голове потенциальную необходимость переезда либо на другую платформу, либо на свой собственный backend.

Первые шаги в разработке


Подключение и аутентификация


Итак, выбор пал на GameSparks в качестве backend на этапе прототипирования. Первым делом предстоит научиться подключаться к платформе и аутентифицировать игрока. Важным моментом считаю, что пользователь должен иметь возможность играть без регистраций и смс сразу после установки игры. Для этого GameSparks предлагает вариант создания анонимного профиля вызовом метода DeviceAuthenticationRequest, позднее на основе анонимного профиля можно сделать полноценный, связав, например, с учетной записью Google.

Учитывая, что у меня TDD головного мозга, я начал с создания теста по подключению клиента к игре. Так как в дальнейшем CloudCode нужно будет писать на JS, то и интеграционные тесты буду делать на JS с применением mocha.js и chai.js. Первый тест получился такой:

var expect = require("chai").expect;
var GameClientModule = require("../src/gameClient");

describe("Integration test", function () {
    this.timeout(0);

    it("should connect client to server", async function () {
        var gameClient = new GameClientModule.GameClient();
        expect(gameClient.connected()).is.false;

        await gameClient.connect();

        expect(gameClient.connected()).is.true;
    });
})

По-умолчанию timeout в mocha.js равен 2 сек, сразу делаю его бесконечным, потому что тесты интеграционные. В тесте создаю нереализованного пока еще клиента игры, проверяю отсутствие подключения к серверу, вызываю команду подключения к backend и проверяю, что клиент успешно подключился.

Для того, чтобы тест позеленел, нужно скачать и добавить в проект GameSparks JS SDK, а так же подключить его зависимости (crypto-js и ws), ну и, конечно же, реализовать GameClientModule:

var GameSparks = require("../gamesparks-javascript-sdk-2018-04-18/gamesparks-functions");
var config = new require("./config.json");

exports.GameClient = function () {
    var gamesparks = new GameSparks();

    this.connected = () => (gamesparks.connected === true);

    this.connect = function () {
        return new Promise(function (resolve, reject) {
            gamesparks.initPreview({
                key: config.gameApiKey,
                secret: config.credentialSecret,
                credential: config.credential,
                onInit: () => resolve(),
                onMessage: onMessage,
                onError: (error) => reject(error),
                logger: console.log
            });
        });
    }

    function onMessage(message) {
        console.log("GAME onMessage: " + JSON.stringify(message));
    }
}

В стартовой реализации игрового клиента из конфига считываются ключи, необходимые для технической авторизации на создание соединения от клиентского приложения. Метод connected оборачивает одноименное поле из SDK. Самое важное происходит в методе connect, он возвращает промис с колбеками на успешное подключение или на ошибку, также привязывает обработчик onMessage к одноименному колбеку. onMessage будет выступать диспетчером по обработке сообщений от backend, пока же пусть логирует сообщения в консоль.

Казалось бы работа выполнена, но тест остается красным. Оказывается GameSparks JS SDK не работает с node.js, ему, видите ли, не хватает контекста браузера. Сделаем так, чтобы он думал, что node это Chrome на маке. Идем в gamesparks.js и в самом начале добавляем:

if (typeof module === 'object' && module.exports) { // node.js
    var navigator = {
        userAgent: "Chrome/73.0.3683.103",
        vendor: "Google Inc.",
        platform: "Mac"
    };
    var window = {};
    window.setInterval = setInterval; // <<< используется в KeepAlive сообщениях
}

Тест позеленел, двигаемся дальше.

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

it("should auth two anonymous players", async function () {
    var gameClient1 = new GameClientModule.GameClient();
    expect(gameClient1.playerId).is.undefined;
    var gameClient2 = new GameClientModule.GameClient();
    expect(gameClient2.playerId).is.undefined;

    await gameClient1.connect();
    await gameClient1.authWithCustomId("111");

    expect(gameClient1.playerId).is.equals("5b5f5614031f5bc44d59b6a9");

    await gameClient2.connect();
    await gameClient2.authWithCustomId("222");

    expect(gameClient2.playerId).is.equals("5b5f6ddb031f5bc44d59b741");
});

Проверять решил сразу на 2-х клиентах, чтобы удостовериться, что каждый клиент создает собственный профиль на backend’е. Для этого в игровом клиенте понадобится метод, в который можно передать некий внешний по отношению к GameSparks идентификатор, а потом проверить, что клиент связался с нужным профилем игрока. Профили заранее подготовил на портале GameSparks.

Для реализации в GameClient добавляем:

this.playerId = undefined;

this.authWithCustomId = function (customId) {
    return new Promise(resolve => {
        var requestData = {
            "deviceId": customId
            , "deviceOS": "NodeJS"
        }
        sendRequest("DeviceAuthenticationRequest", requestData)
            .then(response => {
                if (response.userId) {
                    this.playerId = response.userId;
                    resolve();
                } else {
                    reject(new Error(response));
                }
            })
            .catch(error => { console.error(error); });
    });
}

function sendRequest(requestType, requestData) {
    return new Promise(function (resolve) {
        gamesparks.sendWithData(requestType, requestData, (response) => resolve(response));
    });
}

Реализация сводится к отправке запроса DeviceAuthenticationRequest, получения из ответа идентификатора игрока и помещения в свойство клиента. Сразу вынес в отдельный метод хелпер отправку запросов к GameSparks с оберткой в промис.

Оба тесты зеленые, осталось добавить закрытие соединения и провести рефакторинг.
В GameClient добавил метод закрывающий соединение с сервером (disconnect) и connectAsAnonymous объединяющий connect и authWithCustomId. C одной стороны connectAsAnonymous нарушает принцип единой ответственности, а вроде и не нарушает… При этом добавляет удобство использования, ведь в тестах нужно будет часто клиентов аутентифицировать. Что думаете на этот счет?

В тестах добавил фабричный метод хелпер, который создает новый экземпляр игрового клиента и добавляет в массив созданных клиентов. В специальном обработчике mocha после каждого запущенного теста для клиентов в массиве вызываю метод disconnect и очищаю этот массив. Еще не люблю “magic strings” в коде, поэтому добавил словарь с используемыми в тестах кастомными идентификаторами.

Итоговый код можно будет посмотреть в хранилище, ссылку на которое дам в конце статьи.

Организация поиска игры (matchmaking)


Приступаю к супер важной для мультиплеера фиче — matchmaking. Эта система начинает работать, когда нажимаем в игре кнопку “Найти игру”. Она подбирает либо соперников, либо напарников, либо и тех и других (в зависимости от игры). Как правило, в таких системах у каждого игрока есть числовой показатель MMR (Match Making Ratio) — персональный рейтинг игрока, который используется для подбора других игроков с аналогичным уровнем мастерства.

Для проверки этого функционала придумал следующий тест:

it("should find match", async function () {
    var gameClient1 = newGameClient();
    var gameClient2 = newGameClient();
    var gameClient3 = newGameClient();

    await gameClient1.connectAsAnonymous(playerCustomIds.id1);
    await gameClient2.connectAsAnonymous(playerCustomIds.id2);
    await gameClient3.connectAsAnonymous(playerCustomIds.id3);

    await gameClient1.findStandardMatch();
    expect(gameClient1.state)
        .is.equals(GameClientModule.GameClientStates.MATCHMAKING);
    await gameClient2.findStandardMatch();
    expect(gameClient2.state)
        .is.equals(GameClientModule.GameClientStates.MATCHMAKING);
    await gameClient3.findStandardMatch();
    expect(gameClient3.state)
        .is.equals(GameClientModule.GameClientStates.MATCHMAKING);

    await sleep(3000);

    expect(gameClient1.state)
        .is.equals(GameClientModule.GameClientStates.CHALLENGE);
    expect(gameClient1.challenge, "challenge").is.not.undefined;
    expect(gameClient1.challenge.challengeId).is.not.undefined;

    expect(gameClient2.state)
        .is.equals(GameClientModule.GameClientStates.CHALLENGE);
    expect(gameClient2.challenge.challengeId)
        .is.equals(gameClient1.challenge.challengeId);

    expect(gameClient3.state)
        .is.equals(GameClientModule.GameClientStates.CHALLENGE);
    expect(gameClient3.challenge.challengeId)
        .is.equals(gameClient1.challenge.challengeId);
});

К игре подключается три клиента (в будущем для проверки некоторых сценариев это необходимый минимум) и регистрируются для поиска игры. После регистрации 3-го игрока на сервере формируется игровая сессия и игроки должны к ней подключиться. При этом у клиентов меняется состояние, и появляется контекст игровой сессии с одним и тем же идентификатором.

Сначала подготовлю backend. В GameSparks есть готовый инструмент для настройки поиска игр, доступен по пути “Configurator->Matches”. Создаю новый и приступаю к настройке. Кроме стандартных параметров типа кода, наименования и описания матча указывается минимальное и максимальное количество игроков, нужное для настраиваемого режима игры. Созданному матчу присваиваю код “StandardMatch” и указываю количество игроков от 2 до 3.

Теперь необходимо настроить правила подбора игроков в разделе “Thresholds” (пороги). Для каждого порога указывается время его действия, тип (абсолютный, относительный и в процентах) и границы.



Предположим, начал поиск игрок с MMR 19. В приведенном примере первые 10 секунд будет происходить подбор других игроков с MMR от 19 до 21. Если подобрать игроков не получилось, то активируется вторая граница поиска, которая на следующие 20 секунд расширяет диапазон поиска от 16 (19-3) до 22 (19+3). Далее включается третий порог, в рамках которого будет идти поиск в течении 30 секунд в диапазоне от 14 (19-25%) до 29 (19+50%), при этом матч считается укомплектованным, если набралось минимальное необходимое количество игроков (отметка Accept Min. Players).

На самом деле механизм сложнее, так как учитывает MMR всех игроков, которые успели присоединиться к конкретному матчу. Эти детали буду разбирать, когда придет время делать рейтинговый режим игры (не в этой статье). Для стандартного режима игры, где пока не планирую использовать MMR, нужен только один порог любого вида.

Когда произошел подбор всех игроков, нужно создать игровую сессию и подключить к ней игроков. В GameSparks функцию игровой сессии выполняет “Challenge”. В рамках этой сущности хранятся данные игровой сессии, а также происходит обмен сообщениями между игровыми клиентами. Чтобы создать новый тип Challenge, нужно пройти по пути “Configurator->Challenges”. Там добавляю новый тип с кодом “StandardChallenge” и указываю, что игровая сессия такого типа пошаговая (Turn Based), т.е. игроки ходят по очереди, а не одновременно. GameSparks при этом берет на себя контроль за очередностью ходов.

Для того, чтобы клиент зарегистрировался на поиск игры, можно воспользоваться запросом типа MatchmakingRequest, но я бы не рекомендовал, потому что в качестве одного из параметров требуется значение MMR игрока. Это может привести к махинациям со стороны игрового клиента, да и не должен клиент знать никаких MMR, это дело backend. Для правильной регистрации на поиск игры создаю произвольное событие со стороны клиента. Делается это в разделе “Configurator->Events”. Событие называю FindStandardMatch без атрибутов. Теперь нужно настроить реакцию на это событие, для этого перехожу в раздел облачного кода “Configurator->Cloud Code”, там в разделе “Events” прописываю следующий обработчик для FindStandardMatch:

var matchRequest = new SparkRequests.MatchmakingRequest();
matchRequest.matchShortCode = "StandardMatch";
matchRequest.skill = 0;
matchRequest.Execute();

Этот код регистрирует игрока в StandardMatch с MMR равным 0, таким образом любые игроки зарегистрированные на поиск стандартной игры будут подходить для создания игровой сессии. В подборе рейтингового матча тут могло быть обращение к приватным данным профиля игрока для получения MMR этого вида матча.

Когда наберется достаточное количество игроков для начала игровой сессии GameSparks отправит сообщение MatchFoundMessage всем подобранным игрокам. Тут можно автоматически сгенерировать игровую сессию и добавить в нее игроков. Для этого в “User Messages->MatchFoundMessage” добавляю код:

var matchData = Spark.getData();
if (Spark.getPlayer().getPlayerId() != matchData.participants[0].id) {
    Spark.exit();
}
var challengeCode = "";
var accessType = "PRIVATE";
switch (matchData.matchShortCode) {
    case "StandardMatch":
        challengeCode = "StandardChallenge";
        break;
    default:
        Spark.exit();
}

var createChallengeRequest = new SparkRequests.CreateChallengeRequest();
createChallengeRequest.challengeShortCode = challengeCode;
createChallengeRequest.accessType = accessType;
var tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
createChallengeRequest.endTime = tomorrow.toISOString();
        
createChallengeRequest.usersToChallenge = [];
var participants = matchData.participants;
var numberOfPlayers = participants.length;
for (var i = 1; i < numberOfPlayers; i++) {
    createChallengeRequest.usersToChallenge.push(participants[i].id)
}
createChallengeRequest.Send();

Сначала код проверяет, что это первый игрок в списке участников. Далее от имени первого игрока создается экземпляр StandardChallenge и приглашаются остальные игроки. Приглашенным игрокам отправляется сообщение ChallengeIssuedMessage. Здесь можно предусмотреть поведение, когда приглашение присоединиться к игре высвечивается на клиенте и требует подтверждения отправкой AcceptChallengeRequest, либо можно в “тихом” режиме принять приглашение. Так и поступлю, для этого в “User Messages->ChallengeIssuedMessage” добавлю следующий код:

var challangeData = Spark.getData();
var acceptChallengeRequest = new SparkRequests.AcceptChallengeRequest();
acceptChallengeRequest.challengeInstanceId = challangeData.challenge.challengeId;
acceptChallengeRequest.message = "Joining";
acceptChallengeRequest.SendAs(Spark.getPlayer().getPlayerId());

Следующим шагом GameSparks отправляет событие ChallengeStartedMessage. Глобальный обработчик этого события (“Global Messages->ChallengeStartedMessage”) — идеальное место для инициализации игровой сессии, займусь этим при реализации игровой логики.

Пришло время клиентского приложения. Изменения в модуле клиента:

exports.GameClientStates = {
    IDLE: "Idle",
    MATCHMAKING: "Matchmaking",
    CHALLENGE: "Challenge"
}

exports.GameClient = function () {
    this.state = exports.GameClientStates.IDLE;
    this.challenge = undefined;

    function onMessage(message) {
        switch (message["@class"]) {
            case ".MatchNotFoundMessage":
                this.state = exports.GameClientStates.IDLE;
                break;
            case ".ChallengeStartedMessage":
                this.state = exports.GameClientStates.CHALLENGE;
                this.challenge = message.challenge;
                break;
            default:
                console.log("GAME onMessage: " + JSON.stringify(message));
        }
    }
    onMessage = onMessage.bind(this);

    this.findStandardMatch = function () {
        var eventData = { eventKey: "FindStandardMatch" }
        return new Promise(resolve => {
            sendRequest("LogEventRequest", eventData)
                .then(response => {
                    if (!response.error) {
                        this.state = exports.GameClientStates.MATCHMAKING;
                        resolve();
                    } else {
                        console.error(response.error);
                        reject(new Error(response));
                    }
                })
                .catch(error => {
                    console.error(error);
                    reject(new Error(error));
                });
        });
    }
}

В соответствии с тестом появилась пара полей у клиента — state и challenge. Метод onMessage приобрел осмысленный вид и теперь реагирует на сообщения о старте игровой сессии и на сообщение, что подобрать игру не получилось. Еще добавился метод findStandardMatch, который посылает соответствующий запрос в backend. Тест зеленый, а я довольный, подбор игр освоен.

Что дальше?


В следующих статьях опишу процесс разработки игровой логики, начиная от инициализации игровой сессии до обработки ходов. Разберу особенности хранения разного вида данных — описание метаданных игры, характеристики игрового мира, данные игровых сессий, данные об игроках. Игровая логика будет разработана через два вида тестирования — модульное и интеграционное.

Исходники буду выкладывать на github порциями, привязанными к статьям.

Есть понимание, что для эффективного продвижения в создании игры нужно расширять нашу команду энтузиастов. Художник/дизайнер присоединится в ближайшее время. А гуру в, например, Unity3D, который сделает фронт для мобильных платформ, еще предстоит найти.
Tags:
Hubs:
+6
Comments8

Articles