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 на клиенте:
Также имеется код, который действует подобно автомату, когда по данным из этих объектов определяет какой тайл поставить на месте взорванного и какой приз создать.
Редактировать это как таблицы в SQL или как JSON-файл со списком сущностей было бы очень неудобно, нужно нечто помощнее.
Попробуем описать конфигурацию не в виде списка объектов, а в виде дерева. Вершинами будут разных типов — где-то декларируется новый тайл, где-то новая группа, а где-то описывается во что превращается тайл в случае, если его задевает взрыв. В таком случае нам понадобится следующее:
- Парсер, создающий это дерево из текста
- Штука которая обходит дерево и для вершины вызывает соответствующий её типу метод, делающий что-то в модели
- Логика для 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 на сервере:
- Сервер узнаёт какую конфигурацию использовать, может даже берёт её по http
- Выполняется скрипт, генерируются таблицы типов объектов
- Создаётся генератор карты, он достаёт из таблицы необходимые ему тайлы по их названию.
- Стартуют остальные контроллеры, все запрашивают и запоминают те типы объектов которые им нужны
- Начинается игра
- Каждому зашедшему пользователю передаются те таблицы и только те поля объектов которые нужны клиенту игры
К счастью, загрузка игры занимает достаточно малое время, что позволяет быстро отлаживать конфигурацию. Визуальные средства всё-таки присутствуют в самой игре, но с их помощью пока нельзя редактировать конфиг.
Стоит заметить, что некоторые объекты из этой конфигурации описывают формат аналитики которая будет собираться для игроков: если присутствует слот «money» то собранные за раунд предметы, увеличившие этот «money», влияют и на то что уйдёт в базу статистики именно в колонку «money».
Вроде всё.
Конечно этот DSL ещё не использует прелести языка на котором он основан. Можно сделать чтобы конфигурация не только строила модель, но и описывала генерацию карты и структур на ней, делая инъекцию кода в соответствующие контроллеры. Также немногочисленные константы типа BLOCK_XXX и EFFECT_XXX можно передавать в скрипт через bindings.
Тем кто задаст хорошие вопросы дам доступ к скинам в игре, если напишите свой логин :) И не стесняйтесь в критике.
P.S. Пока писал эту статью, пришлось прерваться чтобы пройти на онсайт Topcoder Open
Автор: Jedi_Knight