Писать на С++ игры долго и дорого, но при этом по перфомансу получается хорошо. Но далеко не всё в играх, требовательно к производительности, особенно 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).

Редактор

Редактор Scriptable Component
Редактор Scriptable Component

Чтобы отобразить все параметры в редакторе есть специальное поле редактора 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. Мою имплементацию ни в коем случае не стоит считать эталонной. Эта первая версия, не обкатанная. Она не претендует на идеал решения, но я бы с радостью узнал мнение других.