CastleDB

CastleDB - это статическая база структурированных данных, для редактирования которых используется одноименный клиент. Данные в CastleDB хранятся в виде JSON файлов, которые можно использовать в проектах на Haxe с помощью библиотеки
castle, значительно упрощающей работу с данными, автоматически генерируя типы данных, хранимых в базе. Кроме использования в проектах на Haxe, есть пример интеграции CastleDB в проектах на Unity - https://blog.kylekukshtel.com/castledb где также используется автоматическая кодогенерация для работы с типизированными данными.

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

Для работы нам понадобится скачать сам клиент с официального сайта, установить библиотеки castle и heaps с haxelib, а также пример базы данных (немного модифицированный пример с официального сайта). Клиент распространяется в виде простого архива и не требует дополнительных действий по установке, нужно его только распаковать в любую удобную для вас папку. Также скачайте и распакуйте куда-нибудь пример базы.

Запускаем клиент и открываем пример (ищем файл data.cdb в папке с примером).

Данные представлены в виде таблицы, где у каждого столбца задан свой тип данных. Узнать тип данных можно кликнув правой кнопкой мыши по заголовку столбца и выбрав пункт “Edit”. При этом типы могут быть составными (об этом чуть позже):

Unique Identifier - уникальный идентификатор, по которому можно будет обращаться к данным в строке с указанным идентификатором. Для полей такого типа макрос генерирует абстрактный enum;

Integer, Float, Bool, Text - думаю, что не нужно объяснять что они означают и какие значения могут хранить;

Color - значение цвета (только RGB), хранится как целое число;

Enumeration - перечислимое. Может принимать единственное значение из заданного списка, который задается как значения, разделенные запятыми:

Для Enumeration, как можно догадаться, при импорте базы в проект также генерируется абстрактный enum. Имя типа генерируемого enum’а формируется как Имялиста_имястолбца. Например, на листе npc есть столбец type с типом Enumeration, тогда enum будет иметь имя Images_type. Стоит отметить, что листам в базе не стоит давать имена с заглавной буквы, иначе имя листа будет совпадать с именем генерируемого для него типа. Для значений Enumeration нет ограничений, можно даже задать им имена-числа, только потом нельзя будет обратится к ним по имени.

Flags - несколько значений из заданного списка, которые храняться в базе как одно целочисленное значение (с помощью битовых операций);

Reference - ссылка на строку из другого листа в данной базе;

File - относительный путь к файлу, хранится как строка;

Image - изображение (не ссылка!), которое хранится в базе. При этом создается дополнительный файл с расширением .img, в котором сохраняются данные изображения в кодировке base64, а в самой базе сохраняется лишь ключ (md5-сумма), по которому эти данные можно найти в img-файле.

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

List - список структурированных значений. Своего рода таблица, которая встраивается в ячейку. Для таких данных создается скрытая от пользователя таблица. На следующей гифке столбцы triggers и nps как раз имеют тип List:

Custom - собственный enum, конструктор которого может иметь аргументы. Для создания таких enum’ов используется простое текстовое поле, доступное по ссылке “Edit Types” (в нижнем правом углу окна редактора);

Dynamic - любые данные в формате JSON;

Tile Layer - слой карты, в котором хранится информация о тайлах;

Data Layer - слой карты, в котором хранятся дополнительные данные карты, например, описание областей карты, на которых срабатывают определенные триггеры.

Рассмотрев поддерживаемые типы данных, давайте теперь перейдем к практике. Попробуем загрузить данные из официального примера в проект на Heaps.

Создадим проект (как это сделать можно посмотреть здесь), и кроме библиотек heaps и hlsdl (или hdx, у меня, например, проекты, собранные с hlsdl не запускаются из-за того, что встроенная видеокарта не поддерживает OpenGL 3.2 и выше) добавим в зависимости библиотеку castle:

# Подключение библиотеки heaps
-lib heaps
# Подключение библиотеки hldx (работает только под Windows)
# Альтернативно можно использовать hlsdl
-lib hldx
# Для работы с базой CastleDB нам понадобится библиотека castle
-lib castle
# Задаем основной класс приложения с точкой входа
-main Game
# Выходной файл
-hl game.hl

(возможно вам придется еще установить / обновить библиотеку format с haxelib)

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

-D dump=pretty

тогда в проекте будет создана папка dump, где можно будет с ними ознакомиться.

Добавим в проект файл Data.hx, поля которого будут генерироваться макросом, для чего необходимо объявить следующий typedef в этом файле:

package;
private typedef Init = haxe.macro.MacroType<[cdb.Module.build("res/data.cdb")]>;               

Как можно догадаться, для генерации полей нужен файл базы данных, который поместим в папку res проекта вместе с остальными файлами из примера базы данных с сайта CastleDB.

Теперь добавим в проект файл основного класса Game.hx:

class Game extends hxd.App
{
        static function main()
        {
                // Инициализация системы ресурсов Heaps
                #if hl
                // В HashLink будем работать с файлами локально
                hxd.Res.initLocal();
                #else
                // В JavaScript будем использовать встраиваемые в js-файл данные
                hxd.Res.initEmbed();
                #end
                new Game();
        }
       
        // Инициализация проекта
        override function init()
        {
                // Загрузка данных для базы из файла data.cdb
                Data.load(hxd.Res.data.entry.getText());

                // Получаем данные о всех игровых уровнях, которые хранятся на листе levelData
                var allLevels = Data.levelData;
                // Загружаем данные о первом уровне (его индекс = 0), который хранится в базе
                // и добавляем его отображение на 2d-сцену
                var level = new h2d.CdbLevel(allLevels, 0, s2d);

                // Доступ к каждому из тайловых слоев уровня
                for (layer in level.layers)
                {
                        trace(layer.name);
                }

                // Также можно запросить тайловый слой по его имени
                var objectsLayer = level.getLevelLayer("objects");

                // Посмотрим размеры уровня в тайлах:
                trace(level.width);
                trace(level.height);
        }
}              

Как видно, в Heaps уже есть класс h2d.CdbLevel для загрузки и отображения уровней, созданных в CastleDB. К сожалению он несколько ограничен и сохраняет не все данные, которые могут быть в уровне, но для знакомства его более чем достаточно.

Итак, скомпилировав и запустив результат мы увидим уровень из базы, однако на нем будут отсутствовать пара спрайтов персонажей. Дело в том, что персонажи на карте из примера хранятся не в слое тайлов (Tile Layer), а в отдельном слое данных (Data Layer), и выступают как маркеры, по которым можно добавить на игровой уровень соответствующие им сущности.

Чтобы добавить на карту такой слой данных необходимо на лист с данными об уровнях добавить столбец с типом List:

При этом у добавленного List’а должны присутствовать числовые поля x и y. В примере таким столбцом является столбец npc, у которого помимо этих полей добавлены поля kind (ссылка на тип npc), item (ссылка на тип item) и id (простой текст).

После добавления такого столбца в таблице свойств уровней он появится в списке слоев в редакторе уровня:

При этом если в добавленном List’е есть столбец с типом Tile или ссылка на страницу в таблице, в которой есть такой столбец, то в редакторе объекты этого слоя будут отображаться с помощью иконок из столбца с типом Tile.

Редактирование объектов на слое Data Layer можно осуществлять двумя способами:

  1. В таблице свойств уровня.
  2. В редакторе уровня, для этого нужно навести курсор на объект и нажать клавишу E, тогда появится окно свойств объекта, где их можно изменить.

Давайте попробуем считать данные из этого слоя и как-нибудь их отобразить с помощью следующего кода, который поместим в конец метода init():

        // Определяем размер тайла на уровне:
        var tileSize:Int = level.layers[0].tileset.size;

        // В этот массив будем добавлять наших npc
        var npcs:Array<h2d.Bitmap> = [];

        // Итерируем по всем npc на первом уровне.
        // Здесь all - это список всех строк в таблице allLevels
        for (npc in allLevels.all[0].npcs)
        {
                // У npc может быть задан item, который является ссылкой
                // на тип (лист) item с полями id и tile
                if (npc.item != null)
                {
                        trace("NPC Item: " + npc.item.id);
                }

                // У npc есть поле kind, являющееся ссылкой
                // на тип (лист в таблице) npc с полями: id, name, image и т.д.
                // Нас интересует поле image с типом Tile
                // У таких полей есть свойства size, file, x, y, ?width, ?height
                var npcImage = npc.kind.image;

                // Определяем размер тайла в изображении
                var npcTileSize = npc.kind.image.size;

                // Свойства тайла width и height являются необязательными:
                var npcWidth = (npcImage.width == null) ? 1 : npcImage.width;
                var npcHeight = (npcImage.height == null) ? 1 : npcImage.height;

                // Загружаем файл изображения, из которого берется тайл для npc
                var image = hxd.Res.load(npcImage.file).toImage();
                // И создаем из изображения тайл с необходимыми параметрами
                var npcTileX = npcImage.x * npcTileSize;
                var npcTileY = npcImage.y * npcTileSize;
                var npcTileWidth = npcWidth * npcTileSize;
                var npcTileHeight = npcHeight * npcTileSize;
                var npcTile = image.toTile().sub(npcTileX, npcTileY, npcTileWidth, npcTileHeight);

                // Используем этот тайл для создания объекта на сцене
                var b = new h2d.Bitmap(npcTile, s2d);
                // Позиционируем объект на сцене в соответствии данными из редактора
                // и размеров объекта
                b.x = tileSize * npc.x - (npcWidth - 1) * npcTileSize;
                b.y = tileSize * npc.y - (npcHeight - 1) * npcTileSize;

                npcs.push(b);
        }

Все довольно просто.

Также у уровня в примере добавлен слой (столбец данных) triggers, в котором задаются данные не по отдельным тайлам на уровне, а для областей тайлов. У таких слоев кроме свойств x и y должны быть свойства width и height (в примере также есть свойство action c пользовательским типом). Данные таких слоев редактируются также, как и в предыдущем примере:

На гифке видно, что при заполнении свойства action у области тайлов осуществляется проверка введенного значения - значение подсвечивается красным до тех пор, пока не будет введено поддерживаемое значение.

А вот как можно считать данные подобного слоя:

        // Создаем тайл и объект TileGroup, с помощью которых будем отображать слой
        var colorTile = h2d.Tile.fromColor(0x0000ff, 16, 16, 0.5);
        var triggerGroup = new h2d.TileGroup(colorTile, s2d);
       
        // Получаем данные слоя triggers у уровня с идентификатором FirstVillage
        // (если в таблице нет столбца с типом Unique Identifier,
        // то для такой таблицы возможно только итерирование с помощью свойства all)
        var triggers = allLevels.get(FirstVillage).triggers;

        // Итерируем по всем заданным областям
        for (trigger in triggers)
        {
                // В зависимости от типа триггера можем делать все что угодно
                switch (trigger.action)
                {
                        case ScrollStop:
                                trace("Stop scrolling the map");
                        case Goto(level, anchor):
                                trace('Travel to $level-$anchor');
                        case Anchor(label):
                                trace('Anchor zone $label');
                        default:

                }

                for (x in 0...trigger.width)
                {
                        for (y in 0...trigger.height)
                        {
                                triggerGroup.add((trigger.x + x) * tileSize, (trigger.y + y) * tileSize, colorTile);
                        }
                }
        }

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

При редактировании слоев на уровне доступны несколько режимов:

Tile - каждый тайл на слое задается вручную, либо рандомно из выбранного набора тайлов, либо можно залить область тайлов:

Object - режим, в котором тайлы (объекты) могут располагаться не по сетке, а произвольным образом (но если необходимо поместить объект с привязкой к сетке, то такой режим включается клавишей G). Кроме того, такие объекты можно вращать и зеркалить (клавиши D и F), а также размеры объектов могут быть больше, чем один тайл:

Объекты, помещенные на такой слой, сортируются по Y-координате и могут перекрывать друг друга.

Ground - режим, в котором работает автотайлинг - автоматическое построение границ между областями разных тайлов.

Но для того, чтобы он работал, необходимо задать у используемого тайлсета ряд свойств.

Для начала нужно задать типы тайлов (типы местности). Для этого в панели тайлсета выбираем режим Ground, затем на этой же панели выбираем тайл местности (например, зеленый тайл травы), задаем ему имя (grass), а также приоритет (по-умолчанию он равен нулю). И таким образом задаем все типы местности (на гифке я задал еще тайл для воды).

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

В CastleDB заложено 4 типа таких границ. К сожалению единственная документация по ним есть только в виде комментария в классе cdb.TileBuilder в библиотеке castle:

Corners - для границ такого типа нужен 3х3 блок тайлов. Такие тайлы используются для построения “выпуклых” границ:

Lower corners - для границ такого типа нужен 2х2 блок тайлов. Такие тайлы используются для построения “вогнутых” границ, причем ширина “вогнутой” области должна быть больше одного тайла:

Для “вогнутых” областей шириной в один тайл используются границы типа U corners, для задания которых используются 3х3 блоки тайлов:

Задание границ осуществляется после того, как были заданы Ground тайлы.

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

При выборе тайлов, между которыми будут строится задаваемые границы, выбираются “внутренние” (In) и “внешние” (Out) тайлы. При этом можно выбрать либо конкретный тип тайла (например, grass), либо приоритет (lower или upper), который задавался при создании типов тайлов. Таким образом, есть возможность задать границу между каким-то конкретным типом местности и группой других типов с высшим или низшим приоритетом:

Для тайлсетов имеется также возможность создания именованных групп тайлов (режим Group в панели редактирования свойств тайлсета), которые затем можно как-то интерпретировать при загрузке базы данных в игре (пример будет приведен далее):

Кроме стандартных свойств тайлов (ground, border, group) возможно задание собственных. Для этих целей используется стандартный столбец tileProps (по-умолчанию он создается пустым). Тип этого столбца - List, следовательно тайлам можно задать сколько угодно дополнительных свойств. Однако следует иметь в виду, что такие свойства являются общими для всех карт, использующих одинаковые тайлсеты.

В примере базы данных в столбце tileProps добавлены столбцы collide (ссылка на лист collide) и hideHero (тип Enumeration). На следующей гифке показано задание свойства collide у тайлов:

Если в качестве изображения для тайлсета выбрано изображение, которое уже использовалось, то новый тайлсет не будет создан, а будет использоваться уже существующий со всеми его свойствами.

Попробуем теперь как-нибудь использовать свойство collide в проекте на Heaps (здесь для простоты я сделаю только отображение свойств тайлов):

        // Берем строку с идентификатором Full на странице collide
        // и читаем в этой строке свойство icon,
        // используя которое загружаем изображение
        var collideImage = hxd.Res.load(Data.collide.get(Full).icon.file).toImage();
        // Создаем группу для отображения свойства collide
        var collideGroup = new h2d.TileGroup(collideImage.toTile(), s2d);
       
        // Читаем свойство collide у всех слоев уровня:
        var tileProps = level.buildStringProperty("collide");
        // buildStringProperty - возвращает массив строк,
        // длина этого массива равна количеству тайлов на уровне.
        // Также доступен метод buildIntProperty, возвращающий массив Int'ов.
        // Кроме того, свойства можно считывать не только у всего уровня,
        // но и у каждого из слоев по отдельности - для этого у слоев есть
        // одноименные методы.

        // Создаем тайлы для отображения свойств на экране
        for (ty in 0...level.height)
        {
                for (tx in 0...level.width)
                {
                        var index = tx + ty * level.width;

                        // Свойство тайла в позиции (tx, ty)
                        var tileProp = tileProps[index];

                        if (tileProp != null)
                        {
                                // Считываем данные со страницы collide для соответствующего типа тайла
                                var collideData = Data.collide.get(cast tileProp);
                                var collideIcon = collideData.icon;
                                var collideSize = collideIcon.size;

                                // создаем тайл
                                var collideTile = collideImage.toTile().sub(collideIcon.x * collideSize, collideIcon.y * collideSize, collideSize, collideSize);
                                // и добавляем его на экран
                                collideGroup.addAlpha(tileSize * tx, tileSize * ty, 0.4, collideTile);
                        }
                }
        }

И для полноты картины приведу еще пару примеров загрузки разных типов данных из базы.

Загрузка свойств групп тайлов (режим Group на панели редактирования свойств тайлсета) в настоящий момент не поддерживается в классе h2d.CdbLevel, однако я внес в него небольшие изменения и отправил Pull Request для этого. Найти дополненную версию класса можно здесь. Используем ее и покажем анимацию, состоящую из тайлов группы:

        var tileGroups = objectsLayer.tileset.groups;

        // Просто считываем размеры группы в тайлах
        for (key in tileGroups.keys())
        {
                var group = tileGroups.get(key);
                trace('$key: ${group.x}; ${group.y}; ${group.width}; ${group.height}');
        }
       
        // Покажем на экране анимацию из тайлов группы anim_fall
        var animFall = tileGroups.get("anim_fall");
        var animTiles = animFall.tile.gridFlatten(animFall.tileset.size);
        var anim = new h2d.Anim(animTiles, 10, s2d);

Рассмотрим как загрузить изображение (тип Image, а не Tile), сохраненное в базе данных. В примере я добавил лист, в котором один из столбцов имеет такой тип:

После добавления изображений на этот лист рядом с файлом базы данных data.cdb автоматически был создан файл data.img, который на самом деле является json. В нем в качестве ключей используются md5-суммы для изображений, а в качестве значений - данные изображений в кодировке base64. А в самой базе (в файле data.cdb) хранятся только md5-суммы изображений, по которым мы можем найти нужные нам данные.

Функция загрузки данных изображений может иметь следующий вид:

        /**
        * Загрузка изображений из img-файла
        **/

        function loadImagesFromImg(fileName:String):Map<String, hxd.res.Image>
        {
                var images = new Map<String, hxd.res.Image>();

                // Загружаем img-файл и парсим его
                var jsonData = haxe.Json.parse(hxd.Res.load(fileName).toText());
                var fields = Reflect.fields(jsonData);

                // Проходим по всем полям полученного объекта
                for (field in fields)
                {
                        var imgString:String = Reflect.field(jsonData, field);
                        // удаляем префикс, который CastleDB добавляет перед данными изображения
                        imgString = imgString.substr(imgString.indexOf("base64,") + "base64,".length);

                        // Декодируем данные изображения и загружаем их в Image (контейнер данных изображения)
                        var bytes = haxe.crypto.Base64.decode(imgString);
                        var bytesFile = new hxd.fs.BytesFileSystem.BytesFileEntry(field, bytes);
                        var image = new hxd.res.Image(bytesFile);

                        images.set(field, image);
                }

                return images;
        }  

А использовать результаты ее работы можно так:

        var images = loadImagesFromImg("data.img");

        for (image in Data.images.all)
        {
                var name = image.name;
                trace(name);
               
                // Используем загруженный Image для создания экранного объекта
                var tile = images.get(image.image).toTile();
                var b = new h2d.Bitmap(tile, s2d);
                b.x = image.x;
                b.y = image.y;
        }

Пример чтения значений типа Enumeration из базы данных:

        // Проходим по всем npc (записям на листе npc)
        for (npc in Data.npc.all)
        {
                // на листе npc есть столбец type с типом Enumeration, читаем значение
                trace(npc.type);
                // enum, сгенерированный библиотекой castle
                // Его имя состоит из имени листа с заглавной буквы и имени столбца,
                // разделенных подчеркиванием:
                trace(Data.Npc_type.Normal);

                // Этот enum можно использовать в switch:
                switch (npc.type)
                {
                        case Data.Npc_type.Normal:
                                trace("You've met a normal npc");
                        case Data.Npc_type.Huge:
                                trace("You've met a HUGE npc");
                        default:
                                trace("Ehm, i don't know what to say...");
                }
        }

        // У созданного enum’а можно посмотреть список имен его значений:
        trace(Data.Npc_type.NAMES);

Пример чтения значений типа Flags из базы данных:

        for (image in Data.images.all)
        {
                var name = image.name;

                // В примере на листе images есть столбец stats,
                // имеющий тип Flags с допустимыми значениями
                // canClimb, canEatBamboo, canRun.
                // Здесь я хотел бы показать как работать с таким типом в Haxe.
                // У объектов такого типа есть метод has(), позволяющий
                // определить выставлен ли определенный флаг
                var canClimb = image.stats.has(canClimb);
                // или так:
                canClimb = image.stats.has(Data.Images_stats.canClimb);
                // Читаем значения остальных флагов
                var canEatBamboo = image.stats.has(canEatBamboo);
                var canRun = image.stats.has(canRun);

                // А также есть метод-итератор, позволяющий прочитать значения флагов
                for (stat in image.stats.iterator())
                {
                        trace("stat: " + stat);
                }
               
                trace(name);
                trace("canClimb: " + canClimb);
                trace("canEatBamboo: " + canEatBamboo);
                trace("canRun: " + canRun);
        }

И в качестве последнего простейшего примера приведем загрузку текстового файла, ссылка на который хранится в столбце datafile листа npc:

        // Проходим по всем npc
        for (npc in Data.npc.all)
        {
                trace(hxd.Res.load(npc.datafile).toText());
        }

Вот мы и рассмотрели основные типы данных в CastleDB, а также работу с ними в проектах на Haxe. Надеюсь, что материал вам понравился и показался интересным.

При написании этого материала использовались следующие источники:

  1. Официальный сайт CastleDB
  2. Статья и видео с сайта Game from scratch, посвященные редактору.