WASM-моддинг для ваших игр на Godot .NET
Содержание
При разработке Arcomage с использованием Godot .NET я столкнулся с проблемой: как добавить поддержку моддинга на основе скриптов в игру? Проблема заключалась в том, что я не хотел разрешать прямую инъекцию DLL — это крайне небезопасно и подвержено злоупотреблениям, позволяющим внедрять вредоносный код, который может скомпрометировать системы пользователей или украсть конфиденциальные данные, такие как пароли.
Вместо этого я выбрал подход с использованием песочницы, при котором моды выполняются безопасно и не представляют угрозы для конечных пользователей. Моя цель состояла в создании единого API для всех модов, чтобы сделать их не только безопаснее, но и проще в написании. Именно тогда я наткнулся на видео от безымянного разработчика, в котором чётко и понятно объясняется, как реализовать поддержку модов на WASM. Огромное спасибо автору за его идеи!
Я уже знал о WebAssembly, но никогда не рассматривал его как песочницу для запуска скриптовых модов в игре. Было удивительно узнать, что Microsoft Flight Simulator 2020 использует WebAssembly для скриптов модов — и это действительно круто!
Итак, я решил попробовать нечто подобное для Arcomage. В этой статье я поделюсь своими находками и идеями по реализации WASM-моддинга в Godot .NET.
Что такое WebAssembly и как им пользоваться? #
WASM — это формат байткода, который выполняется в виртуальной машине. В отличие от CIL .NET, WASM предназначен для исполнения в браузерах или в любых приложениях, которые его поддерживают. В нашем случае мы используем версию Godot с поддержкой .NET. Для .NET существует несколько встроенных WASM-рантаймов. В моем проекте я использовал Wasmtime от Bytecode Alliance.
Пример: Создание WASM-мода #
Настройка #
Сначала добавьте пакет Wasmtime через NuGet в ваш проект Godot .NET:
dotnet add package wasmtime
Либо добавьте следующую ссылку на пакет в ваш файл .csproj
:
<PackageReference Include="Wasmtime" Version="22.0.0" />
Не забудьте выполнить dotnet restore
после внесения изменений в файл проекта.
Чтобы компилировать файлы WASM из AssemblyScript, установите Node.js (в комплекте с npm). Вы можете скачать его с официального сайта Node.js. Проверьте установку с помощью команд:
node -v
npm -v
В этом примере мы будем компилировать WASM с помощью AssemblyScript. Если вы предпочитаете другой язык (например, Rust с wasm-pack), ознакомьтесь с его документацией. Также настоятельно рекомендую изучить документацию компилятора AssemblyScript.
После установки Node.js выполните:
npm install -g assemblyscript
Эта команда устанавливает компилятор AssemblyScript глобально (либо локально, если предпочитаете).
После установки необходимых инструментов, пора писать код!
WASM #
Выберите рабочую директорию и создайте два файла: env.ts
и mod.ts
.
В файле env.ts
определите экспортируемые функции, которые будут доступны вашему WASM-моду:
@external("env", "host_log")
declare function host_log(ptr: i32, len: i32): void;
export function log(message: string): void {
const encoded = String.UTF8.encode(message);
host_log(changetype<i32>(encoded), encoded.byteLength);
}
В этом примере функция log
отправляет сообщение хосту через host_log
, которая принимает указатель на строку, закодированную в UTF-8, и её длину.
В файле mod.ts
напишите код, который будет выполняться в WASM-моде:
import { log } from "./env";
export function init(): void {
log("Hello, World!");
}
Здесь экспортированная функция init
будет вызвана при инициализации мода, что вызовет отправку лог-сообщения. На стороне Godot вы обработаете это (например, выведя сообщение в консоль с помощью GD.Print
).
Эта простая настройка иллюстрирует концепцию. Как разработчик, поделитесь файлом env.ts
с мододелами, чтобы они могли использовать определённые функции. В этой схеме mod.ts
служит точкой входа для мода, где вы указываете функцию входа (в данном случае, init
), которая автоматически вызывается игрой при загрузке WASM-файла.
Чтобы скомпилировать ваш WASM-мод, выполните:
asc .\mod.ts -o mod.wasm
Если AssemblyScript не установлен глобально, убедитесь, что вы указали корректный путь. Выполнение этой команды создаёт файл mod.wasm
— точку входа для вашего мода.
Интеграция с Godot .NET #
Поместите скомпилированный WASM-файл в директорию user
вашего проекта Godot (например, user://mod.wasm
).
Рекомендую использовать пользовательскую директорию, включив её с помощью флага application/config/use_custom_user_dir
в файле project.godot
. Также задайте имя папки через application/config/custom_user_dir_name
.
Создайте новый C# класс, наследующий от Node
— в моем примере он называется WasmLoader
:
using Godot;
using System.Text;
using Wasmtime;
using Engine = Wasmtime.Engine;
public partial class WasmLoader : Node
{
public override void _Ready()
{
byte[] wasmBytes;
using (var file = FileAccess.Open("user://mod.wasm", FileAccess.ModeFlags.Read))
wasmBytes = file.GetBuffer((int)file.GetLength());
using var engine = new Engine();
using var module = Module.FromBytes(engine, "mod", wasmBytes);
using var store = new Store(engine);
using var linker = new Linker(engine);
linker.Define("env", "host_log", Function.FromCallback(store, (Caller caller, int ptr, int len) =>
{
var memory = caller.GetMemory("memory");
if (memory is null)
return;
var span = memory.GetSpan<byte>(0);
var message = Encoding.UTF8.GetString(span.Slice(ptr, len).ToArray());
GD.Print(message);
}));
linker.Define("env", "abort", Function.FromCallback(store, (int msg, int file, int line, int column) =>
{
GD.Print($"Abort called at {file}:{line}:{column}");
}));
var instance = linker.Instantiate(store, module);
instance.GetAction("init")?.Invoke();
}
}
Разберем, что происходит в этом коде:
- WASM-файл загружается из
user://mod.wasm
и его байты передаются вModule.FromBytes
. - Создаются
Engine
,Module
,Store
иLinker
для выполнения WASM-мода. - Определяются функции, экспортируемые модом. Здесь
host_log
принимает указатель и длину, декодирует строку из памяти и выводит её. - Также определяется функция
abort
; она вызывается при ошибках внутри WASM-мода. Хотя она может и не понадобиться, компилятор включает её по умолчанию. Если не определить её, вы получите исключениеWasmtime.WasmtimeException
из-за неопределённого импорта. - Наконец, WASM-мод инициализируется, и вызывается его функция
init
, которая выводит “Hello, World!” в консоль.
После создания класса WasmLoader
добавьте его в сцену и запустите игру. Если всё настроено правильно, вы увидите “Hello, World!” в консоли. Либо вы можете добавить загрузчик в Autoload
вашего проекта, чтобы он запускался при старте.
Следующие шаги #
Вот несколько идей для расширения вашей системы моддинга:
-
Передача обратных вызовов процесса:
Передавайте обратные вызовы_Process
и_PhysicsProcess
в WASM-мод (не забудьте включить параметрdelta
), чтобы он мог взаимодействовать с игрой каждый кадр — например, обновлять позиции объектов:public override void _Process(double delta) { foreach (var mod in _mods.Values) mod.Instance.GetFunction("process")?.Invoke(delta); }
Здесь
_mods
— этоDictionary
, хранящий загруженные моды, где ключом является имя мода, а значением — запись (содержащая экземпляр мода и его путь). Аналогично можно передавать события (например, событие выхода), когда игра закрывается или мод выгружается. -
Создание API для модов:
Разработайте API для модов, которое будет предоставлять функции (например,log
,spawn
,destroy
,move
) для мододелов. Стремитесь к простоте и ясности, а также подробно задокументируйте API, чтобы мододелы знали, что доступно и как это использовать. -
Автоматическая загрузка модов:
Реализуйте функциональность для загрузки WASM-модов из определенной папки, позволяя мододелам просто помещать свои файлы модов в папку для автоматической загрузки. Также рассмотрите возможность добавления поддержки выгрузки модов, чтобы избежать проблем с производительностью при слишком большом количестве активных модов. Полноценный интерфейс управления модами (например, интегрированный в меню игры) станет отличным улучшением.
Надеюсь, эта небольшая статья поможет вам реализовать WASM-моддинг в ваших играх на Godot .NET. Если у вас возникнут вопросы или потребуется дополнительная помощь, не стесняйтесь оставить комментарий — я постараюсь помочь!