Писать на С++ игры долго и дорого, но при этом по перфомансу получается хорошо. Но далеко не всё в играх, требовательно к производительности, особенно 2D. Например всякие окошечки не производят тяжелых расчетов внутри. А на больших проектах они могут занимать до 80% всего объема кода. И на С++ есть проблемы на продакшене - если где-то крешится или бажит, приходится перезаливать приложение.
Скрипты - решение всех бед. И код писать проще, и обновлять приложение можно через дозагрузку (да-да, сторы явно запрещают это, но на деле все так делают).
Почему JS?
В мире полно скриптовых языков - LUA, LUAu, Squirrel, AngleScript и так далее. Обычно в подобные движки встраивают LUA. Он маленький, простой и довольно производительный. Но на нем катастрофически мало пишут людей. Да, его можно быстро изучить. Но изучить и уже знать - две большие разницы. Поэтому я искал что-то уже распространенное, но в тоже время очень маленькое и простое для встраивания.
Я смотрел на два языка - JS и C#. Рантаймы для обоих языков тяжелые. Для JS казался самым очевидным выбор google V8, который требовал минимум 6mb бинаря, что крайне много в мобильном геймдеве. C# вроде как развивается, mono стал общедоступным и тоже был интересным кандидатом. Тем более что уже есть целая армия Unity-разработчиков, плотно подсевших на C#. Но его рантайм тоже был тяжелым и сложным для встраивания.
Из этих двух я больше склонялся к JS по нескольким причинам:
это язык не как в Unity;
web-сообщество огромное, и они тоже хотят делать игры;
сам по себе JS не строго типизированный, но есть TypeScript, который транслируется в JS, и он уже строго типизированный. Таким образом внедрив JS, можно было бы использовать еще и TypeScript.
Потом как-то пособеседовал одного кандидата на работе, тоже из студии мобильных игр. И он рассказал, что уже несколько лет пишут на JS на плюсовом движке. Мне, как разработчику своего движка стало интересно, что да как.
Он рассказал что у них движок, похожий на Cocos2D, написанный на С++, в который встроен JS-движок JerryScript. Бам! О таком движке я не знал. Он маленький, заточенный на производительность. Из похожих я видел только QuickJS, но у него просто отсутствует документация. И я стал изучать подробнее. Все же если целая контора использует его в продакшене, это что-то да значит.
Я бегло изучил API и примеры, собрал тестовый проект и все заработало с пол пинка. Это круто, потому что собрать какой-нибудь V8 весьма не просто и долго.
Как все устроено
Итак, я выделил 2 основные вещи для поддержания скриптинга: это подсистема скриптового движка и класс-обертка над скриптовым значением.
Подсистема скриптового движка довольно простая: она умеет парсить скрипт, выполнять скрипт, возвращать глобальный неймспейс, вызы��ать очистку мусора и подключать отладчик.
Кстати, один из плюсов jerry еще и наличие готового отладчика. Достаточно поставить плагин для VS Code, и вызвать коннект из кода. Вауля, отладчик прицепился, можно посмотреть что и как выполняется. Однако, есть один минус - он плохо показывает содержимое объектов. Точнее, он их не показывает, пишет просто [object] и все. Кажется, авторы jerry здесь как-то поленились и не довели передачу информации до конца. Что ж, не страшно, добавим сами!
ScriptValue всему голова!
У jerry для скриптового значения используется jerry_value_t. Так как все API C-шное, то и никаких удобных классов естественно нет. Есть куча C-шных функций. Это значение и C-шные функции я обернул в свой класс, ScriptValue.
Кажется что это простой класс, но он должен уметь делать многое:
хранить в себе скриптовое значение;
хранить в себе функцию, свободную или функцию класса;
хранить в себе указатель на С++ объект;
понимать что за тип там хранится;
уметь кастить содержимое в нужный тип;
понимать что за тип С++ объекта в нем хранится;
вызывать функции;
работать с полями объектов: добавлять, удалять, итерировать;
работать со значением как с массивом.
Где-то там держится за сердце один из адептов SOLID... Но это API оборачивает скриптовое значение, которое по факту все это должно уметь. Ведь в JS переменная может содержать в себе что угодно. Разберемся сначала с тем что есть переменная в JS.
В ней может хранится либо примитивный тип (int, number, string, bigInt), функция, объект или массив. По сути такой вариант, который может мимикрировать. Это и есть динамическая типизация. В переменной хранится значение какого-то типа, и она в любой момент может поменять свой тип.
Хранение значения
ScriptValue хранит внутри jerry_value_t, который по сути является неким указателем на значение в контексте jerry. Важно понимать как работать с этим значением.
Его нужно создавать и удалять. Иначе - ошибка в рантайме. Jerry подсчитает количество ссылок, и скажет тебе что ты забыл освободить чего-то. Создавать значение нужно сразу нужного типа, например jerry_create_undefined или jerry_create_object. По окончанию использования его нужно освободить, вызвав jerry_release_value.
Чтобы скопировать значение (простые типы копируются, на объекты копируется ссылка) нужно использовать jerry_acquire_value.
Оборачиваем эти функции где нужно и уже не нужно заботится об освобождении jerry_value_t.
Простые типы
Для передачи и получения значений я использовал такой же паттерн со специализацией шаблонов, что и для своего DataValue.
В самом классе объявляются шаблонные функции operator T и operator=(T), которые принимают в себя любой тип. Внутри происходит специализация шаблона Converter. Далее для разных типов T пишутся специализации Converter со всякими SFINAE-штуковинами.
Пример специализации
template<typename _type, typename _enable = void>
struct Converter
{
static constexpr bool isSupported = true;
using __type = typename std::conditional<std::is_same<void, _type>::value, int, _type>::type;
static void Write(const __type& value, ScriptValue& data);
static void Read(__type& value, const ScriptValue& data);
};
template<typename _key, typename _value>
struct Converter<Map<_key, _value>>
{
static constexpr bool isSupported = true;
static void Write(const Map<_key, _value>& value, ScriptValue& data)
{
data.jvalue = jerry_create_object();
for (auto& kv : value)
data.SetProperty(ScriptValue(kv.first), ScriptValue(kv.second));
}
static void Read(Map<_key, _value>& value, const ScriptValue& data)
{
if (data.GetValueType() == ValueType::Object)
{
value.Clear();
data.ForEachProperties([&](const ScriptValue& name, const ScriptValue& value) {
value[name.GetValue<_key>()] = name.GetValue<_value>();
});
}
}
};Таким образом описывается различное поведение для разных типов при присваивании и получении значения.
С простыми типами все просто, используем готовые API jerry. Со всем остальным уже интереснее.
Начнем с таких типов как Vec2 или Rect. У них внутри есть свои поля типа x, y; и есть какой-то функционал. Нужно чтобы эти же поля были и в скрипте, и был доступен похожий функцио��ал.
Прокидывать нативные классы в JS мы не будем, т.к. это очевидный оверхед. Конвертация между JS и C++ нужна только на моменте стыка данных. Внутри JS гораздо оптимальнее работать со своими структурами данных, чем с биндингами нативных объектов. А конвертировать уже на этапе передачи или получения значения в/из скрипта.
Поэтому пишем JS-классы этих типов, чтобы уметь с ними работать в скриптах. Теперь нужно их как-то замапить на С++ объекты. Здесь немного остановимся и разберемся что есть класс в JS и как он устроен внутри.
Классы в JS
Классы в JS - это чисто синтаксический сахар. На самом деле их нет. Объекты классов - это обычные объекты с полями внутри, но с ссылкой на некий прототип. Этот прототип один для всех классов одного типа. А сами классы - это даже не объекты, а специальные constructor функции. Само объявление класса - это замыкание, которое возвращает такую constructor-функцию. Рассмотрим пример:
class MyClass
{
constructor() { this.abc = "abc" }
myFunction() { print(this.abc) }
}Он под капотом представляет себя что-то такое:
MyClass = (function() {
function MyClass() { this.abc = "abc" }
MyClass.prototype.myFunction = function() { print(this.abc) }
return MyClass;
}) ();Выглядит хитро, но попробуем разобраться. Мы объявляем функцию и сразу же ее вызываем. Это нужно чтобы спрятать внутренности от всех. Такой вот метод сокрытия от публичности. Внутри мы объявляем функцию, которая является конструктором, т.к. она работает с this. Далее к прототипу этой функции (который потом будет прототипом инстансов класса) прицепляем нашу функцию. И в конце в нашей базовой функции возвращаем эту хитрую функцию-конструктор.
В самом языке есть ключевое слово new, которое работает с функциями-конструкторами, делая экземпляр класса.
Спрашиваете, зачем, черт тебя дери, нам это? Но это очень важно понимать чтобы понять как прокинуть классы в JS и наоборот.
JS класс в С++ и наоборот
Вернемся обратно к теме проброса Vec2 и Rect в JS и обратно. Когда мы передаем значение из C++ в JS, то в JS мы должны получить экземпляр класс Vec2, описанного в JS. Для этого мы конструируем объект, добавляем туда необходимые поля (x и y, например) и вручную назначаем прототип этого объекта, такой же как и прототип класса Vec2. Для этого нам нужно взять прототип из JS, что я сделал несколько костыльно (не нашел нужных функций API): o2Scripts.Eval("Vec2.prototype"). Затем передать его в ScriptValue через jerry_set_prototype. Таким образом мы при передаче Vec2 из С++ мы получаем Vec2 уже в JS, имеющий все нужные методы для работы.
Из JS в C++ конвертить просто, мы уже знаем тип из плюсов и просто достаем поля их объекта (x, y):
template<>
struct Converter<Vec2F>
{
static constexpr bool isSupported = true;
static void Write(const Vec2F& value, ScriptValue& data)
{
data.jvalue = jerry_create_object();
data.SetPrototype(*ScriptValuePrototypes::GetVec2Prototype());
data.SetProperty("x", ScriptValue(value.x));
data.SetProperty("y", ScriptValue(value.y));
}
static void Read(Vec2F& value, const ScriptValue& data)
{
value.x = data.GetProperty("x");
value.y = data.GetProperty("y");
}
};Теперь перейдем к С++ объектам, которые нужно передать в скрипт. Например, у нас есть такой класс:
class Test: public ISerializable
{
SERIALIZABLE(TestInside);
public:
float mFloat = 1.2f; // @SERIALIZABLE @SCRIPTABLE
String mString = String("bla bla"); // @SERIALIZABLE @SCRIPTABLE
bool mBool = true; // @SERIALIZABLE @SCRIPTABLE
ComponentRef mComponent; // @SERIALIZABLE @SCRIPTABLE
Ref<RigidBody> mRigidBody; // @SERIALIZABLE @SCRIPTABLE
public:
Test(); // @SCRIPTABLE
int DoSmth(float param); // @SCRIPTABLE
};Нужно уметь прокидывать такой класс в скрипт, уметь конструировать его из скрипта, обращаться к полям класса и вызывать его методы.
jerry предоставляет способ прокинуть в скриптовое значение указатель на свои данные. Для этого используется функция:
void jerry_set_object_native_pointer (const jerry_value_t obj_val,
void *native_pointer_p,
const jerry_object_native_info_t *native_info_p);Рассмотрим ее параметры:
const jerry_value_t obj_val- это, собственно, в какое скриптовое значение добавлять данные, тут все понятно;void *native_pointer_p- указатель на данные, ништяк;const jerry_object_native_info_t *native_info_p- указатель на структуру, описывающую способ владения данными.
Последнее - самое интересное и самое не удобное. Это указатель на некий контрольный блок, общий для определенного типа данных, который управляет освобождением данных из native_pointer_p.
Этот блок нужен чтобы работал Garbage Collector, который в некий момент может решить убить скриптовое значение, а вместе с ним нужно освободить наши данные из указателя.
Но вот незадача, в структуре этого блока функция Free - статичная. Это значит что под каждый тип данных нужно писать свою стр��ктуру управляющего бока. Неудобно.
Но есть обходное решение, хотя и несколько затратное в ресурсах. Итак, мы и правда объявляем управляющий блок для всех своих данных одинаковый. Но свои данные заворачиваем в обобщенный контейнер IDataContainer, а в нем уже храним что нам нужно.
Управляющий блок
struct IDataContainer
{
bool isDataOwner = true;
virtual ~IDataContainer() = default;
virtual void* GetData() const { return nullptr; }
virtual IObject* TryCastToIObject() const { return nullptr; }
virtual const Type* GetType() const { return nullptr; }
};
...
struct DataContainerDeleter
{
jerry_object_native_info_t info;
DataContainerDeleter();
static void Free(void* ptr);
};
static DataContainerDeleter& GetDataDeleter();
...
ScriptValueBase::DataContainerDeleter& ScriptValueBase::GetDataDeleter()
{
static DataContainerDeleter deleter;
return deleter;
}
ScriptValueBase::DataContainerDeleter::DataContainerDeleter()
{
info.free_cb = &Free;
}
void ScriptValueBase::DataContainerDeleter::Free(void* ptr)
{
delete (IDataContainer*)ptr;
}Здесь можно посмотреть как нативные данные передаются в скриптовое значение.
Для удобной работы с нативными данным в ScriptValue есть еще несколько функций:
bool IsObjectContainer() const;- чтобы понять хранится ли какой-то объект внутри впринципе;const Type* GetObjectContainerType() const;- чтобы получить тип хранимого объекта;void* GetContainingObject() const;- получить сырой указатель на объект.
Отдельный момент про владение нативным объектом. Пока что у меня сделано не очень хорошо, т.к. владение не регламентировано жестко и могут возникнуть проблемы.
Суть в том, что объект создается из нативной части, где память управляется вручную. При этом есть GC в JS, который тоже как-то управляет памятью. Соответственно могут быть ситуации, когда GS должен удалить нативный объект, и когда не должен. По сути это определяется тем, владеет ли ScriptValue нативным объектом или нет. Если владеет, то его судьба полностью подвластна GC. Если нет - то ScriptValue просто хранит указательна объект, но никак его не удаляет.
Отсюда могут возникнуть проблемы. Например, прокинули объект в скрипты и убили его. Скрипт не узнает об этом. Или наоборот, скрипт владеет объектом, а мы его прибили из нативного кода.
Сейчас владение объектов по сути на совести разработчика. Но, на мой взгляд, есть более правильный способ владения объектом - через умные указатели. Если скрипт владеет объектом, то он держит сильную ссылку, если нет - слабую. Таким образом и скрипт, и нативный код защищены от непредвиденного удаления объекта.
Такой подход я сделаю когда весь движок переведу на умные указатели. Да да, у меня ручное управление памятью, и оно мне не нравится...
Поля классов
Так, прокинули объект в скрипт. Но из скрипта с ним ничего не сделать, т.к. это просто объект без полей и плюсовые поля класса и методы никак не соотносятся со скриптом. Разберем сначала как работает биндинг полей класса в скрипт.
По сути нам нужно в объект добавить property, который соотносится с указателем поля из нативного объекта. Либо эта property является оберткой над паркой setter/getter.
Чтобы сделать кастомизируемое property, нужно использовать специальную функцию jerry_define_own_property. Она добавляет проперти в объект, но с неким описанием как это поле работает - jerry_property_descriptor_t.
Оно включает в себя параметры конфигурации поля, в котором можно задать setter и getter поля. Их мы и заиспользуем. Эти setter и getter - тоже скриптовые значения, которые должны быть функциями с определенной сигнатурой.
Для этого определим указатели на нативные функции, к которым прицепим нативный контейнер с интерфейсом setter'а или getter'а, в котором уже будем работать с указателем на поле нативного объекта. Подробнее можно глянуть в функции SetPropertyWrapper(). В результате этих операций мы получаем property в объекте, который вызывает специальные setter и getter , которые уже работают с указателем на значение.
Рядом же есть реализация property не через указатель, а через функции.
Проброс функции из C++ в JS
Так, у нас теперь есть объект с полями. Откуда они там зарегистрируются - попозже, вкратце через кодогенерацию. Теперь нам нужны функции.
Здесь jerry API тоже нас не балует удобством и предоставляет интерфейс биндинга функции в скриптовое значение статичной функции... Снова пишем обертки!
По факту мы действительно биндим одну единственную статичную функцию на все типы функций, но саму функц��ю запихиваем через нативный объект. Да, скриптовое значение, которое является функцией, может еще и держать указатель на кастомные данные.
Делаем уже по привычному пути и даже используем тот же интерфейс для хранения обертки над функцией. А в статичной функции обращаемся к этому контейнеру и вызываем функцию.
Вызов функции
jerry_value_t ScriptValue::CallFunction(const jerry_value_t function_obj,
const jerry_value_t this_val,
const jerry_value_t args_p[], const jerry_length_t args_count)
{
void* ptr = nullptr;
jerry_get_object_native_pointer(function_obj, &ptr, &GetDataDeleter().info);
IFunctionContainer* container = dynamic_cast<IFunctionContainer*>((IDataContainer*)ptr);
return container->Invoke(this_val, (jerry_value_t*)args_p, args_count);
}Пока все просто. Но еще нужно передать параметры! Тут все интересно. Ведь на стороне JS список параметров - это массив jerry_value_t . На стороне С++ - это конкретная сигнатура функции. Пахнет магией шаблонов.
При вызове функции из контейнера нам нужно упаковать параметры из JS в tuple<> для передачи в нативную функцию. Каждый отдельный параметр мы просто кастим через оператор каста в ScriptValue. А чтобы их все проитерировать, мы итерируем параметры из сигнатуры нативной функции через шаблоны - UnpackArgs.
UnpackAgs
template<size_t _i /*= 0*/, size_t _j /*= 0*/, typename... _args>
void ScriptValueBase::UnpackArgs(std::tuple<_args ...>& argst, jerry_value_t* args, int argsCount)
{
if (_j < argsCount)
{
ScriptValue tmp;
tmp.AcquireValue(args[_j]);
std::get<_i>(argst) = tmp.GetValue<std::remove_reference<decltype(std::get<_i>(argst))>::type>();
if constexpr (_i + 1 != sizeof...(_args))
UnpackArgs<_i + 1, _j + 1>(argst, args, argsCount);
}
}Эта функция принимает в качестве параметров шаблона индекс параметра! и тип аргументов. Внутри рекурсия с инкрементом индекса параметра и обращение к элементу tuple через std::get.
Оборачиваем всякой шелухой на удаление ссылок из типов, проверяем как именно должна вызываться функция - с возвращаемым значением или нет, и вызываем нативную функцию через std::apply(). В нее передаем указатель на функцию и tuple с параметрами.
Теперь, мы умеем прокинуть нативную функцию из С++ в JS и вызвать ее из JS. Немножко сложнее вызываются функции класса, там нужно еще и обработать this
Проброс функции JS в C++
А как вызвать из С++ функцию из JS? Процесс идет наоборот, но немного проще.
Сначала объявляем интерфейс вызова через передачу параметров в виде скриптовых значений - InvokeRaw. В ней просто дергаем jerry API - jerry_call_function.
Ну а чтобы иметь нормальный плюсовый интерфейс вызова функции, нам нужны variadic templates и их упаковка в массив ScriptValue. Для этого используем функцию PackArgs с магией итерирования по variadic templates.
PackArgs
template<typename ... _args>
static void PackArgs(Vector<ScriptValue>& argsValues, _args ... args)
{
([&](auto& arg) { argsValues.Add(ScriptValue(arg)); } (args), ...);
}В результате имеем человеческий вызов Invoke, который сам конвертирует параметры и передаст в скрипт.
template<typename _res_type, typename ... _args>
_res_type Invoke(_args ... args) const;Регистрация С++ классов в JS
Для начала нужно как-то объявить конструктор. Помните выше описывал как работают конструкторы в JS? Вот нам нужно все то же самое сделать для нативных классов.
Определяем функцию, которая работает с this, подсовывая туда свежесозданный нативный объект, дополняя полями и функциями, которые маппятся на этот нативный объект.
Функция определяется тут. В ней мы конструируем объект и с помощью кодгена прокидываем данные о полях и функциях, используя вышеперечисленное API ScriptValue
Определение функции-конструктора для класса
template<typename _object_type, typename ... _args>
void ScriptConstructorTypeProcessor::FunctionProcessor::Constructor(_object_type* object, Type* type)
{
ScriptValue thisFunc;
thisFunc.SetThisFunction<void, _args ...>(Function<void(ScriptValue thisValue, _args ...)>(
[](ScriptValue thisValue, _args ... args) {
_object_type* sample = mnew _object_type(args ...);
thisValue.SetContainingObject(sample, true);
if constexpr (std::is_base_of<IObject, _object_type>::value && !std::is_same<IObject, _object_type>::value)
{
ReflectScriptValueTypeProcessor processor(thisValue);
_object_type::template ProcessType<ReflectScriptValueTypeProcessor>(sample, processor);
}
}));
ScriptConstructorTypeProcessor::RegisterTypeConstructor(type, thisFunc);
}Не буду углубляться глубоко в кодогенерацию, т.к. сейчас эта часть у меня переусложнена. Вкратце у меня генерируется шаблонный метод по перебору всех метаданных класса, в который передается какой-то класс-процессор. Есть такой процессор для сериализации, а есть для биндинга скриптов.
В этот процессор на вход попадает ScriptValue и указатель на класс. Он заполняет ScriptValue полями из класса и функциями. Заполняет только теми, у которых есть атрибут @SCRIPTABLE.
Как скрипты используются для написания логики
У меня есть граф сцены, логика заключена в компонентах. Для скриптов добавлен ScriptableComponent, который держит в себе путь до скрипта и ScriptValue-инстанс класса из этого скрипта. В нем вызывает всякие OnStart, Update, OnEnabled/Disabled функции из ScriptValue. Сам инстанс обозначен как serializable. Для него написан конвертор, который просто перебирает все property и пишет в DataValue (обертка над json).
Редактор

Чтобы отобразить все параметры в редакторе есть специальное поле редактора ScriptValueProperty, которая показывает содержимое ScriptValue и позволяет его редактировать. По умолчанию воспринимает ScriptValue как объект и вытаскивает поля из него. Но может работать с ним и как с массивом.
Пример скрипта из скриншота
test = class test {
constructor() {
this.a = 5;
this.b = 'bla bla bla';
this.c = false;
this.d = { a: 5, b: false, c: 'stroka' };
this.e = new Vec2(3, 50);
this.f = new Rect(0, 1, 2, 3);
this.g = new Border(0, 1, 2, 3);
this.h = new Color4(255, 255, 255, 127);
this.ref = new o2.RefAnimationAsset('xxx.anim');
this.ref2= new o2.RefActorAsset();
this.sprite = new o2.Sprite();
this.actorRef = new o2.RefActor();
this.bodyRef = new o2.RefRigidBody();
this.curve = new o2.Curve();
this.obj = new o2.EditorTestComponent.TestInside();
this.array = [ 1, 2, 3, 4, 5 ];
}
OnStart() {
if (this.actorRef.IsValid()) {
this.actorRef.Get().transform.position = new Vec2(10, 20);
}
}
Update(dt) {
this.a += dt;
this.e = new Vec2(Math.sin(this.a), Math.cos(this.a));
if (this.actorRef.IsValid()) {
this.actorRef.Get().transform.size = this.e;
}
}
}P.S. Мою имплементацию ни в коем случае не стоит считать эталонной. Эта первая версия, не обкатанная. Она не претендует на идеал решения, но я бы с радостью узнал мнение других.
