Перейти к основному содержанию

Сохраняем данные

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

Конфиг

Пожалуй наиболее простым способом обработки данных остается конфиг, или же __config__. Он сохраняет сугубо клиентские настройки и может быть изменен через интерфейс браузера модов. Ранее, в статье Конфигурация свойств были рассмотрены форматы файлов config.json и config.info.json, теперь же будет рассмотрено считывание и изменение этих данных.

__config__.getBool("change_quantum_suit_recipe"); // true
__config__.getDouble("energy_text.scale"); // 135.0
__config__.getFloat("energy_text.y"); // 30.0
__config__.getInteger("energy_text.scale"); // 135
__config__.getString("energy_text.pos"); // "right"

Эти базовые методы позволяют получить конкретный тип данных: булевые значения, числа с фиксированной или плавающей точкой, целочисленные и строковые значения соответственно. Если значение в конфиге отсутствует или не соответствует запрашиваему типу, будут возвращены false, 0.0, 0.0, 0 и null в том же порядке.

Создайте отдельный файл или класс и загружайте эти данные единожды, обычно они обновляются лишь на этапе загрузке мода. Не знаете какой тип данных должен быть получен из конфига или просто хотите проверить, есть ли нужное значение? Запросите значение с помощью универсального метода:

__config__.get("energy_text");

Вне зависимости от полученного значения, оно будет возвращено в нужном типе; если значение содержит в себе еще несколько, то есть является объектом, будет возвращен экземляр конфига по заданному пути. Если же значения нет, будет возвращено null как и в случае с отсутствием строкового.

Но ведь мы же хотим не только считывать, но и изменять данные:

const ENERGY_OFFSET_Y = Math.min(Math.max(
__config__.getInteger("energy_text.y"),
UI.getScreenHeight() - __config__.getInteger("energy_text.scale")
), 0);
__config__.set("energy_text.y", ENERGY_OFFSET_Y);
__config__.save();

В этом случае мы считываем целочисленное значение energy_text.y, и проверяем, чтобы оно было в диапазоне от 0 до размера экрана минус размер элемента. И здесь же, сохраняем значение на случай если оно было изменено. Рассмотрите config.info.json для ограничения этих значений еще и в интерфейсе.

Цепочек установки значений перед сохранением может быть сколько угодно:

__config__.set("energy_text.y", 60);
__config__.set("energy_text.pos", "left");
__config__.set("change_quantum_suit_recipe", false);
__config__.save();

Установка значения с другим типом данных (отличном от существующего в конфиге) не является проблемой. И на случай если проекту в любом случае необходимо значение, и просто для восстановления данных, существует:

__config__.checkAndRestore({
change_quantum_suit_recipe: true,
energy_text: {
pos: "right",
scale: 135,
y: 30
}
});

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

Для каждого клиента — свой конфиг

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

Дополнительные конфиги

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

const PROTO_CONFIG = new Config(__dir__ + "config.proto.json");
const GLOBAL_CONFIG = new Config(new java.io.File(__dir__, "config.json"));

К примеру, мы можем восстановить конфиг на основе заранее созданного прототипа:

__config__.checkAndRestore(FileTools.ReadText(
PROTO_CONFIG.getPath()
));

Конфигов может быть сколько угодно, сохраняйте изменения с помощью config.save() и обновляйте их, используя config.reload().

Сохранения

Сохранения используются для хранения большого количества данных или любых других в виде объектов. Хотя само сохранение данных может быть зарегистрировано где угодно, использованы они будут лишь на стороне сервера. Хранение данных происходит с помощью Saver и нескольких методов в нем.

Saver.addSavesScope("Мод.Контекст", function read(data) {
// действия с полученными вследствие загрузки данными
}, function save() {
return {
// некие данные для сохранения
};
});

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

Воздержитесь от встроенного калбека

Раннее, сохранения на движке Core Engine реализовывались с помощью калбеков ReadSaves и WriteSaves соответственно, принципы схожи с модулем Saver:

Translation.addTranslation("<Client> Your saves are outdated, it will be rewritten!", {
ru: "<Клиент> Ваши сохранения устарели, они будут перезаписаны!"
});

Callback.addCallback("ReadSaves", function(data) {
for (let i = data.notify_legacy_saves; i >= 0; i--) {
Debug.error(Translation.translate("<Client> Your saves are outdated, it will be rewritten!"));
}
});

Callback.addCallback("WriteSaves", function(data) {
data.notify_legacy_saves = Math.floor(Math.random() * 10);
});

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

Вернемся к небольшому примеру жидкостного хранилища, созданного в статье Маппинг и обновления. У нас уже есть готовый объект placedTanksByDimension, содержащий все необходимые данные для сохранения. В таком случае, готовый вариант сохранения будет выглядеть так:

Saver.addSavesScope("AbstractMod.Tanks", function(data) {
placedTanksByDimension = data || {};
}, function() {
return placedTanksByDimension;
});

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

let despawnRange = 64;
let style = "transparent";
let energyExtension = true;

Saver.addSavesScope("AbstractMod", function(data) {
despawnRange = data.despawn_range || despawnRange;
style = data.style || style;
if (data.extensions) {
energyExtension = data.extensions.energy;
}
}, function() {
return {
despawn_range: despawnRange,
style: style,
extensions: {
energy: energyExtension
}
};
});

Желательно проверять существуют ли необходимые данные внутри объекта перед их использованием.

Объект не может отсутствовать

В любом случае, если сохранение отсутствует или не задано, в качестве входных данных в функцию чтения будет передан пустой объект. Он не привязан к скрипту, а также не имеет в себе никаких данных для использования. Будьте осторожны, так как проверка data != null не сработает.

Экземпляры и классы

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

Любой обработчик состоит из уже рассмотренных функции чтения, сохранения и идентификатора самого обработчика. Последний задействуется как раз для регистрации новых экземляров и их игнорирования. Предположим, существует некий класс LevitatingAspectItem, содержащий информацию о текущем местоположении левитирующего предмета и привязанного к нему алтаря:

class AspectableAltar extends Altar {
id: number;
...
}

class LevitatingAspectItem {
altarId: number;
position: Vector;

constructor(altar: AspectableAltar) {
const { id } = altar;
this.altarId = id;
this.position = new Vector(0, 0, 0);
...
}

update() {
...
}
}

Мы можем воспользоваться изученным ранее Saver.addSavesScope, так может регистрировать его каждый раз? Как только объект перестанет быть актуален, ритуал будет завершен, сохраняемый обработчик останется. Это явно не то, что здесь необходимо, да и есть вариант получше:

class LevitatingAspectItem {
static saverId = Saver.registerObjectSaver("AbstractMod.LevitatingAspectItem", {
save: function(instance) {
const { altarId, position } = instance;
return { altarId, position };
},
read: function(data) {
const altar = AspectableAltar.resolve(data.altarId);
if (!altar) return null;
const aspect = new LevitatingAspectItem(altar);
aspect.position = data.position;
return aspect;
}
});

constructor(altar: AspectableAltar) {
...
Saver.registerObject(this, LevitatingAspectItem.saverId);
}

...
}

Используя идентификатор созданного обработчика, классы будут воссозданы после загрузки сохранения. Не забудьте позаботиться о связке созданных объектов с обработчиком, например, привязкой к алтарю. Чтобы экземпляр сохранился, нужно либо сделать это напрямую, либо поместить его внутрь другого объекта, подверженного сохранению:

class LevitatingAspectItem {
static aspects = [];

constructor(altar: AspectableAltar) {
if (aspects.indexOf(this) == -1) {
aspects.push(this);
}
...
}

destroy() {
...
let index = aspects.indexOf(this);
if (index != -1) {
aspects.splice(index, 1);
}
Saver.setObjectIgnored(this, true);
}

...
}

Остается только добавить сохранение массива aspects по аналогии с прошлой частью. Не менее важно "выгружать" объекты из списка сохранения, используя, к примеру, destroy. Вызовите этот метод, когда класс более не требуется, к примеру после завершения ритуала. Это исключит будущие сохранения созданного экземпляра, он просто будет пропущен и его содержимое не сохранится. Свойство сохраняется и при сериализации, которая сейчас будет рассмотрена.

Сериализация

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

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

{
despawn_range: 64,
style: "transparent" + Math.round(Math.E),
// Комментарий не будет сериализован
extensions: {
energy: true
}
}

Такие данные намного легче передавать, сохранять и загружать их снова. Самым простым примером такого процесса может быть JSON.stringify(obj) как сериализатор, просто передайте в него объект и загрузите его обратно с помощью десериализатора JSON.parse(str). Но такой способ подходит лишь если объект содержит в себе примитивы вроде булевых значений, чисел и строк.

Вспомним сохранение экземляров классов, так для чего же мы этим занимались? В первую очередь, registerObjectSaver создает сериализатор и десериализатор наших данных, он обрабатывает их получение и загрузку. Давайте же сохраним и загрузим их обратно, к примеру для кеширования объектов в сжатом виде, вручную:

const ASPECTS_FILE = new java.io.File(__dir__, "aspects.bin");

function deflateAspectItems() {
// Основной этап сериализации, передаем объекты для обработки
let serialized = Saver.serializeToString(LevitatingAspectItem.aspects);
// Для сжатия и многих операций нужны байты, а не сама строка
serialized = new java.lang.String(serialized).getBytes();
// Создаем файл или очищаем существующий, открывая его
ASPECTS_FILE.createNewFile();
let output = new java.io.FileOutputStream(ASPECTS_FILE);
// Теперь сожмем сериализированные байты, это процесс дефляции
output = new java.util.zip.DeflaterOutputStream(output);
output.write(serialized, 0, serialized.length);
// Закроем файл и завершим процесс дефляции, данные сохранены
try {
output.close();
} catch (e) {}
}

Callback.addCallback("WriteSaves", function(data) {
deflateAspectItems();
});

Отлично, аспекты сохраняются в файл aspects.bin папки мода. Реализуйте их хранение в папки мира если необходимо, используя __modpack__. Осталось лишь загрузить аспекты, воспользуемся нужным калбеком, обработка экземляров и их создание уже было реализовано в самом классе:

function inflateAspectItems() {
// Создадим расширяемый буфер для записи, поскольку данные нужно получить
let buffer = new java.io.ByteArrayOutputStream();
// Откроем файл и создадим на его основе инфляцию, или же расжатие
let input = new java.io.FileInputStream(ASPECTS_FILE);
input = new java.util.zip.InflaterInputStream(input);
// Подготовим буфер для подгрузки данных частями, можно прочитать и сразу все,
// не используя буфер впринципе, но это может вызвать большие затраты памяти
let bytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 1024);
let offset;
// Прочитаем файл до конца соответственно, записывая расжатые данные
while (true) {
if ((offset = input.read(bytes)) < 0) {
break;
}
buffer.write(bytes, 0, offset);
}
try {
input.close();
} catch (e) {}
// Восстановим, или же десериализируем, загруженные данные
let deserialized = new java.lang.String(buffer.toByteArray());
LevitatingAspectItem.aspects = Saver.deserializeFromString(deserialized);
}

Callback.addCallback("ReadSaves", function(data) {
if (ASPECTS_FILE.isFile()) {
inflateAspectItems();
}
})

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

Воспользуемся сжатием, используя созданный объект.

Используя словарь из 36 символов, состоящий из нижнерегистровой латиницы и цифр, произведем сжатие посредством дефляции. Сгенерируем объект со случайными значениями используя словарь, где ключ состоит из 8 символов, а значение из 24. Выполните задание самостоятельно. Начнем с создания функции дефляции, здесь все просто:

function deflateBytes(bytes, level) {
let buffer = new java.io.ByteArrayOutputStream();
let compressor = new java.util.zip.Deflater(level || -1);
let deflator = new java.util.zip.DeflaterOutputStream(buffer, compressor);
deflator.write(bytes, 0, bytes.length);
deflator.close();
compressor.end();
return buffer.toByteArray();
}

Аргумент level принимает значение от 1 до 9, либо же -1. Использование компрессора можно опустить, тогда будет использован уровень сжатия устройства по умолчанию (-1). Загрузим полученные данные обратно, создадим функцию:

function inflateBytes(bytes) {
let buffer = new java.io.ByteArrayOutputStream();
let inflator = new java.util.zip.InflaterOutputStream(buffer);
inflator.write(bytes, 0, bytes.length);
inflator.close();
return buffer.toByteArray();
}

В среднем, получаем сжатие 32-36 процентов, вот один из результатов, общее количество байт должно совпадать с вашим кодом:

--- Дефляция значений {"2fhlxd6l":"54fjkqp4... ---
По умолчанию * 244544/380001 байт * 35.65 процента
Уровень 1 * 257828/380001 байт * 32.15 процента
Уровень 2 * 256761/380001 байт * 32.43 процента
Уровень 3 * 255575/380001 байт * 32.74 процента
Уровень 4 * 245735/380001 байт * 35.33 процента
Уровень 5 * 245130/380001 байт * 35.49 процента
Уровень 6 * 244544/380001 байт * 35.65 процента <--
Уровень 7 * 244283/380001 байт * 35.72 процента
Уровень 8 * 243486/380001 байт * 35.92 процента
Уровень 9 * 243486/380001 байт * 35.92 процента

Прочие реализации

Некоторые классы заведомо включают в себя сохранение данных — такие как контейнеры, игровые объекты, тайлы и прочие. Данные не обязательно должны состоять из объектов, прямая обработка файлов в большом количестве случаев не является проблемой, движок предоставляет интерфейсы и для работы с ними. Мы рассмотрим некоторые из возможностей в следующих статьях.