Конфигурирование через скрипты вместо XML и JSON на примере realtime multiplayer игры

в 7:00, , рубрики: dsl, game development, Gamedev, groovy, java, javascript, метки: , , , ,

Конфигурирование через скрипты вместо XML и JSON на примере realtime multiplayer игры

Shortcuts: github, tiles.js tiles.groovy

Не секрет, что объектов в играх на порядок больше чем их возможных поведений. При прототипировании описания объектов можно составлять прямо в коде на Java, С++ или C#, но там всё довольно быстро запутается. Потом объекты выносят в базу данных, либо в XML или JSON конфиг. Это сильно помогает, ведь после редактирования конфигурации пересобирать код не требуется, и этим могут заниматься не только программисты, но и спецы по предмету (для игр это гейм-дизайнеры и контентщики). Когда разрастается команда либо количество объектов переходит какую-то черту, программисты пишут удобный редактор, который позволяет визуально править этот JSON-конфиг. В результате на выходе получается какой-то трудно поддерживаемый монстр.

Если вы не собираетесь нанимать множество людей которые вообще не умеют кодить, то можно попробовать пойти другим путём: описывать метаданные с помощью Domain Specific Language.

Что даёт визуальный редактор, чего нет при редактировании базы/json/xml?

  • Отслеживание связей между объектами
  • Видны группировки объектов
  • Отсутствуют числовые ID, поиск осуществляется за счёт имен.

Что может дать отказ от полного визуального редактирования в пользу более продвинутого языка описания?

  • Читаемый diff в системе версий
  • Более быстрое встраивание новых конструкций, новых полей у объектов
  • Упрощение описания за счёт Convention over configuration

Цель: получить все эти плюшки.

Кстати о числовых ID. Если в вашем проекте числовые ID объектов записаны в коде как константы — лучше это поменять, иначе когда их станут десятки — будут проблемы. Владельцы серверов minecraft пролили целые озёра слёз из-за конфликтов ID блоков и сущностей из разных модов. Даже если этот список констант хранится в одном месте, то это тоже плохо, ведь когда вы начнёте писать дополнения, меняющие этот список, вы получите большой геморрой. А если у вас multiplayer игра и эти ID зашиты в протокол, то приготовьтесь гореть в аду.

Результат: код, выдранный из production

github, на нём код на Java с моделью и тест, который грузит данные из двух скриптов: tiles.js tiles.groovy

Ход мыслей

Имеется модель тайлов, вот например пара классов из неё:

//типы тайлов
static final String[] BLOCK_NAMES = new String[] { "floor", "building", "arrow",  "hideout", "abyss", "tunnel", "solid", "box"};

public class ScriptClass {
    int id = -1;
    String name;
}

public class Tile extends ScriptClass {
    int group = -1;       //номер группы
    int indexInGroup;     //номер внутри группы
    int type = -1;        //тип: константы в другом месте и их мало
    int image = -1;       //номер в тайлсете
    int background = -1;  //номер фона
    int subground = -1;   //если под тайлом пропасть, то отобразится эта стенка
    int onDamage = -1; //во что превращается объект при взрыве 
    int onBomb = -1;   //а если бомба прямо на нём?
    int onAtomic = -1; //а если атомкой?
    int direction = -1;//направление тайла, используется для стрелок и мостов
}

// промежуточное состояние для изменения тайла
public class TileChange extends ScriptClass {
    public static class Variant {
        int p = 1;       //вероятность выбора варианта
        int item = -1;   //ID предмета который должен выпасть
        int effect = -1; //дополнительное поле - эффект
        int result = -1; //ID в который должен перейти тайл, может быть
                         //промежуточным состоянием
    }
    int totalP = 0;            //сумма вероятностей вариантов
    List<Variant> variants;    //варианты
}

И TileSet на клиенте:

Конфигурирование через скрипты вместо XML и JSON на примере realtime multiplayer игры

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

Редактировать это как таблицы в SQL или как JSON-файл со списком сущностей было бы очень неудобно, нужно нечто помощнее.

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

  1. Парсер, создающий это дерево из текста
  2. Штука которая обходит дерево и для вершины вызывает соответствующий её типу метод, делающий что-то в модели
  3. Логика для Convention over configuration: заполнение незаполненных полей по оговоренной логике, например можно взять значение из соответствующего поля у группы

Если парсер можно взять обычный — JSON, XML то то что во второй пункте придётся писать самим. Или нет? Вот тут и заключается секрет: можно сразу использовать скриптовый движок, надо лишь создать bindings чтобы он вызывал методы для конструирования модели.

В результате получается язык который более подходит для данной области, Domain Specific Language.

Вот описание самого частого тайла — кирпичной стенки, из которой с разной вероятностью могут выпасть призы.
Надо заметить, что на groovy или coffeescript скобок будет меньше.

newTile("brick", {
    type: "solid",
    image: 44,
// сейчас будем описывать что будет если блок снесут
    onDamage: newChange("prise_in_brick", {
        "variants": [
// Прописываем выпадение предметов
                    { p : 60, item : getSlot("bomb")},
                    { p : 40, item : getSlot("power")},
                    { p : 40, item : getSlot("scate")},
                    { p : 10, item : getSlot("kick")}, 
                    { p : 10, item : getItem("random")},
// Тут даже можно описать некоторый предмет, который будет превращаться во что-то другое когда его подберут
                    { p : 10, item : newItem("surprise", {image: 11, effect: ITEM_EFFECT_SANTA_CANT_TAKE, variants: [
                            {p: 5, slot: getSlot("bomb") },
                            {p: 5, slot: getSlot("power") },
                            {p: 4, slot: getSlot("scate") },
                            {p: 4, slot: getSlot("kick") },
                            {p: 3, slot: getSlot("jelly") },
                            {p: 5, slot: getSlot("detonator") },
                            {p: 6, slot: getItem("ball") },
                            {p: 0, slot: getSlot("money"), count:20}
                        ]})
                    },
                    { p : 10, item : getSlot("heart")},
                    { p : 430 }
                ],
// В любом случае надо снести этот блок
        result: getChange("destroy_rocky") //оно ещё не объявлено, но это не мешает на него сослаться
    })
});<h5></h5>
newChange("destroy_rocky", {effect: BLOCK_EFFECT_DESTROY_BLOCK, variants: [
    {p:2, result: getTile("grass")},
    {p:1, result: getTile("grass2")},
    {p:1, result: getTile("grass3")},
    {p:1, result: getTile("rocky")}
]});
Реализация

Функции типа newXXX и getXXX создают и ищут сущности, возвращая пару (id объекта, тип объекта). newXXX ещё и умудряется проверять те ли типы у полей объекта. Все get-ы осуществляются по именам, и возвращают объект даже если он ещё не был создан — это обеспечивает модульность, то есть конфиг можно будет разбить на разные файлы, соответствующие разным аспектам игры. При этом будет всё равно, какой точно порядок выполнения будет у этих скриптов.

В результате выполнения конфигурации создаются таблицы ScriptClassTable с объектами наследованными от ScriptClass. При этом ID объектов генерируются автоматически, во время выполнения скрипта. По сети передаются только ID, без имён объектов, и они одинаковые на клиенте и на сервере, поскольку и там и там выполнялся один скрипт.

Поначалу я использовал JavaScript, так как браузерный клиент собирается под JS. Сервер при этом использовал движок Rhino. Абстракция осуществлялась за счёт разной реализации интерфейса ScriptUtils, который вообще не знает о модели игры, но знает что в скрипте бывают типы «Объект», «Строка», «Список» и «Число», и что скрипт умеет вызывать методы.

Позже я стал передавать таблицы по сети, скрипт же стал выполняться только на сервере. Сейчас я перевожу конфигурацию на groovy, чтобы можно было описывать ей не только данные, но и с помощью closures задавать поведение некоторых особенных сущностей. Конечно я мог делать это и через JS, но вызвать переданный из groovy-скрипта код в java существенно проще, ведь он собирается в бинарный код прямо в runtime. Также синтаксис Groovy более сладкий, во многих случаях скобки можно опускать. Хотя, за этим можно было и на coffee перейти.

Из-за выбранной абстракции перевод с Rhino на Groovy занял 20 минут: достаточно было прописать другую реализацию класса ScriptUtils. Сам же скрипт изменился мало: поменялись фигурные скобки на квадратные в описании объектов, и синтаксис при описании функций. После перевода оно сразу ушло в production.

Bootstrap на сервере:

  1. Сервер узнаёт какую конфигурацию использовать, может даже берёт её по http
  2. Выполняется скрипт, генерируются таблицы типов объектов
  3. Создаётся генератор карты, он достаёт из таблицы необходимые ему тайлы по их названию.
  4. Стартуют остальные контроллеры, все запрашивают и запоминают те типы объектов которые им нужны
  5. Начинается игра
  6. Каждому зашедшему пользователю передаются те таблицы и только те поля объектов которые нужны клиенту игры

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

Стоит заметить, что некоторые объекты из этой конфигурации описывают формат аналитики которая будет собираться для игроков: если присутствует слот «money» то собранные за раунд предметы, увеличившие этот «money», влияют и на то что уйдёт в базу статистики именно в колонку «money».

Вроде всё.

Конечно этот DSL ещё не использует прелести языка на котором он основан. Можно сделать чтобы конфигурация не только строила модель, но и описывала генерацию карты и структур на ней, делая инъекцию кода в соответствующие контроллеры. Также немногочисленные константы типа BLOCK_XXX и EFFECT_XXX можно передавать в скрипт через bindings.

Тем кто задаст хорошие вопросы дам доступ к скинам в игре, если напишите свой логин :) И не стесняйтесь в критике.

P.S. Пока писал эту статью, пришлось прерваться чтобы пройти на онсайт Topcoder Open

Автор: Jedi_Knight

Источник

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


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js