ScriptVM - реализация встраиваемого скрипт-языка

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

Готовые решения

Для подобных задач уже существуют готовые решения, самые известные из них это Lua и AngelScript. Эти проекты проверены временем и предоставляют богатый функционал, который… практически всегда избыточен, когда стоит задача не написать всю логику приложения на скрипт-языке, а дать минимальную возможность что-то настраивать и при этом быть гибче, чем просто набор строк в конфигурационном файле.

ScriptVM

Так появился ScriptVM - интерпретатор простого скрипт-языка, реализация которого занимает порядка 1к строк на C# и без проблем может быть портирована на другие языки безо всяких зависимостей.

Особенности

Скрипт-язык представляет собой набор именованных функций с опциональными параметрами, умеет крутить циклы, понимает условное ветвление, умеет в переменные 2 типов (“строка” и “число”) и вызывать другие именованные функции. И это все, что он умеет. Нет штатной математики, работы со строками, нет скобок приоритетов из-за отсутствия инфиксных операторов - реализация максимально проста и весь требуемый функционал предлагается реализовывать в виде именнованных функций из основного приложения (далее - Хост), в которое встраивается скрипт-язык.

Синтаксис

Синтаксис максимально упрощен - никаких точек с запятой и лишних скобок, на каждой строке может быть только одно выражение:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Поддерживаются однострочные комментарии до конца строки, начинающиеся со знака решетки.

# Это именованная функция без параметров, имя может быть
# любым (включая знаки пунктуации) за исключением пробела и скобок.
main() {
# Создает переменную с именем "age" и записывает в нее число 50.
set age 50
# Создает переменную "name" и записиывает в нее строкове значение "Ярослав".
set name 'Ярослав'

# Условное выражение должно возвращать число, которое
# сравнивается с 0 и если не равно, то считается истинным.
if age {
# Можно досрочно прерывать текущую функцию
# с опциональным возвратом результата.
ret 1
} else {
ret 0
}
}

Хост-функции

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Описание хост-функции.
(ScriptVar, string) OnAdd (ScriptArgs args) {
// Можно обращаться к любому аргументу по его индексу и пытаться преобразовать
// его к требуемому типу. В случае ошибки результат операции будет false.
(ScriptVar p1, bool p1ok) = args.Get (0).NumValue ();
(ScriptVar p2, bool p2ok) = args.Get (1).NumValue ();
if (!p1ok || !p2ok) {
return (ScriptVar.NewUndef (), "add(): требуются два числовых параметра");
}
// Возвращаем результат операции и отсутствие ошибки.
return (ScriptVar.NewNum (p1 + p2), null);
}
// Подключение.
ScriptVm vm = new ScriptVm ();
vm.AddFunc ("add", OnAdd);

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

1
2
3
4
main() {
set x add(123, 456)
# x будет равен 579.
}

Интеграция в редакторы

Для ScriptVM реализовано расширение для vscode, предоставляющее подсветку синтаксиса, работу с комментариями, всплывающие подсказки по базовым ключевым словам и шаблоны кода для функций, условий и циклов.

Пример применения

Допустим, мы хотим реализовать логику поведения для книги-игры (или визуальной новеллы), где каждая локация представляет собой “страницу”. Мы хотим добавить интерактивности и иметь возможность работать с инвентарем, а так же менять варианты ответов игрока в зависимости наличия предметов в карманах.

Требования к функционалу скрипта

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

Реализация

Набросаем тестовое событие, на основне которого были сформулированы требования:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Стартовая локация.
entry() {
# Параметр - текст-описание локации.
setText('Вы стоите у входа в пещеру, в которой живет страшный Гоблин.')
# Первый параметр - текст сообщения, второй - имя страницы-функции.
addAnswer('Войти в темную-претемную пещеру', 'fight')
}
# Локация с монстром, нужен меч.
fight() {
setText('Вы видите уродливого Гоблина, что вы будете делать?')
addAnswer('Плюнуть ему в рожу!', 'spitToGoblin')
if getItem('sword') {
# Если есть меч, то один вариант действия.
addAnswer('У меня есть меч, получай, монстр!', 'killGoblin')
} else {
# Иначе другой вариант.
addAnswer('Поискать что-нибудь для защиты вокруг', 'searchSword')
}
}
# Плохой конец игры.
spitToGoblin() {
setText('Гоблин утерся, а потом выпустил вам кишки! Вы умерли...')
# Ответы не добавляем, это будет признаком конца игры.
}
# Хороший конец игры.
killGoblin() {
setText('Взмахнув мечом, вы отрубаете монстру его уродливую башку. У нас новый герой!')
# Даем награду, по которой можем определить успешное прохождение задания.
addItem('goblinSlayerReward', 1)
# Ответы не добавляем, это будет признаком конца игры.
}
# Находим оружие.
searchSword() {
addItem('sword', 1)
setText('Вы находите новенький меч и готовы его применить!')
addAnswer('Развернуться к Гоблину', 'fight')
}

Каждая “страница” является функцией с уникальным именем, первая “страница” пусть будет называться “entry”.

Реализуем обвязку для “книги” на стороне хоста:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public class Book {
// Описание варианта ответа игрока.
public struct Answer {
public string Text;
public string Page;
}

readonly ScriptVm _vm;
// Хранилище предметов - "инвентарь".
readonly Dictionary<string, int> _items;
// Хранилище вариантов ответов, если пусто, то конец игры.
readonly List<Answer> _answers;
// Описание окружения.
string _text;

public Book () {
_items = new();
_answers = new();
_vm = new ScriptVm ();
// Подключение хост-функций.
_vm.AddFunc ("setText", OnSetText);
_vm.AddFunc ("addAnswer", OnAddAnswer);
_vm.AddFunc ("addItem", OnAddItem);
_vm.AddFunc ("getItem", OnGetItem);
}

public string Load (string script) {
return _vm.Load (script);
}

public string OpenPage (string page) {
_answers.Clear ();
_text = null;
var (_, err) = _vm.Run (page, null);
return err;
}

// Доступ к предметам, описанию локации и вариантам ответов.
public Dictionary<string, int> GetItems () => _items;
public List<Answer> GetAnswers () => _answers;
public string GetText () => _text;

// Хост-функция, обрабатывающая "setText".
(ScriptVar, string) OnSetText (ScriptArgs args) {
var (str, ok) = args.Get (0).StrValue ();
if (!ok) {
return (ScriptVar.NewUndef (), "setText() требует строку.");
}
_text = str;
return (ScriptVar.NewUndef (), null);
}

// Хост-функция, обрабатывающая "addAnswer".
(ScriptVar, string) OnAddAnswer (ScriptArgs args) {
var (str1, ok1) = args.Get (0).StrValue ();
var (str2, ok2) = args.Get (1).StrValue ();
if (!ok1 || !ok2) {
return (ScriptVar.NewUndef (), "addAnswer() требует две строки.");
}
_answers.Add (new Answer { Text = str1, Page = str2 });
return (ScriptVar.NewUndef (), null);
}

// Хост-функция, обрабатывающая "addItem".
(ScriptVar, string) OnAddItem (ScriptArgs args) {
var (str1, ok1) = args.Get (0).StrValue ();
var (num2, ok2) = args.Get (1).NumValue ();
if (!ok1 || !ok2) {
return (ScriptVar.NewUndef (), "addItem() требует строку и число.");
}
if (_items.TryGetValue (str1, out var count)) {
count += (int) num2;
}
if (count > 0) {
_items[str1] = count;
} else {
// Если предметов больше нет - удаляем запись.
_items.Remove (str1);
}
return (ScriptVar.NewUndef (), null);
}

// Хост-функция, обрабатывающая "getItem".
(ScriptVar, string) OnGetItem (ScriptArgs args) {
var (str, ok) = args.Get (0).StrValue ();
if (!ok) {
return (ScriptVar.NewUndef (), "getItem() требует строку.");
}
_items.TryGetValue (str, out var count);
return (ScriptVar.NewNum (count), null);
}
}

И псевдокод инициализации, загрузки и обработки логики “книги”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var book = new Book ();
string err;
err = book.Load ("тут код скрипта выше");
if (err != null) {
// Ошибка, обработка опущена для упрощения.
return;
}
// "Открываем" первую страницу.
err = book.OpenPage ("entry");
if (err != null) {
// Ошибка, обработка опущена для упрощения.
return;
}
// Обновляем в интерфейсе описание окружения "открытой" страницы.
SetDescription (book.GetText ());
// Получаем варианты ответов, если их нет - конец игры.
var answers = book.GetAnswers ();
while (answers.Count > 0) {
for (var i = 0; i < answers.Count; i++) {
var answer = answers[i];
// Показать вариант в интерфейсе.
ShowAnswer (answer.Text);
}
// Получить пользовательский выбор (номер варианта ответа).
var answerIdx = GetUserInput ();
err = book.OpenPage (answers[answerIdx].Page);
if (err != null) {
// Ошибка, обработка опущена для упрощения.
return;
}
answers = book.GetAnswers ();
}
var isHero = book.GetItems ().ContainsKey ("goblinSlayerReward");
// isHero == true если победили.

Заключение

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

Актуальные версии пакетов доступны в закрытом discord-сервере для vk/boosty-подписчиков.
Оформить подписку можно здесь: