Pull to refresh

Скриптинг без скриптинга

Reading time 7 min
Views 5.6K
Original author: No Time To Play

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

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

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

["print", "2 + 3 =", ["+", 2, 3]]

Да, это JSON. Я написал его вручную, но представим, что его нам экспортировали из визуального редактора. Видите, как он устроен? Это последовательность вложенных списков, где первый элемент указывает на действие, а остальные становятся аргументами или операндами. При таком подходе не нужно прочесывать список в поиске, например, оператора сложения, и обрабатывать списки становится сильно проще. Попробуем сделать это на Python:

def evals(form, scope):
    name = form[0]
    function = scope[name]
    arguments = form[1:]
    return function(arguments)

Моя функция называется evals, чтобы случайно не перепутать ее со встроенной в Python. Что касается аргументов, то form - это что угодно, что мы хотим исполнить (долгая история), а scope - хранилище всех важных для нас данных, которое на данный момент просто словарь с фунциями:

scope = {
    "print": lambda args: print(*args),
    "+": lambda args: args[0] + args[1],
}

Теперь мы можем в буквальном смысле сложить два и два (хехе), и попробовать выполнить команды из списка:

script = ["print", "2 + 3 =", ["+", 2, 3]]
evals(script, scope)

// 2 + 3 = ["+", 2, 3]

Ой-ой. Со строковым литералом программа справилась, а вот со вложенным списком - нет. Что же делать? Можно заставить каждую функцию обрабатывать аргументы самостоятельно, но это выльется в обилие бойлерплейта. А можно написать более умную версию evals:

def evals2(form, scope):
    if type(form) == list:
        name = form[0]
        function = scope[name]
        arguments = form[1:]
        evaluated = [evals2(i, scope) for i in arguments]
        return function(evaluated)
    else:
        return form

(Надеюсь, про конструирование списков и рекурсию вы и так знаете)
Теперь, дубль два:

evals2(script, scope)

// 2 + 3 = 5

Ух ты, заработало! У нас получился скриптовый движок!

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

Посмотрим, что еще мы можем с ней сделать.

В первую очередь - условия! Как и в Python, иногда бывает полезно выполнить только один из двух блоков, но не оба сразу. Обычной функцией это, по понятным причинам, не сделать:

scope["<"] = lambda args: args[0] < args[1]
scope["if"] = lambda args: args[1] if args[0] else args[2]
script = ["if", ["<", 2, 3], ["print", "yay!"], ["print", "nay..."]]

evals2(script, scope)

// yay!
// nay...

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

def evals3(form, scope):
    if type(form) != list:
        return form
    elif form[0] == "if":
        if evals(form[1], scope):
            evals(form[2], scope)
        else:
            evals(form[3], scope)
    else:
        name = form[0]
        function = scope[name]
        arguments = form[1:]
        evaluated = [evals3(i, scope) for i in arguments]
        return function(evaluated)

evals3(script, scope)

Обратите внимание, что if выглядит как обычная функция, если просто читать JSON, но это не так. Можно сказать, что это особый случай.

Теперь вы можете реализовать цикл или блок утверждений по аналогии. Они все равно понадобятся. Так оно и работает - чтобы реализовать условие, приходится использовать условие. Не просто так они существуют во всех языках программирования. А знаете, что еще в них есть? Переменные.

Пока не будем думать о том, как их объявлять. Может быть, движок считывает их все из отдельного файла с настройками. Но как к ним обратиться-то? В Python можно просто написать print(a) , чтобы получить значение a. Но наша система основана на JSON, в котором только строки, а они нам нужны для строковых литералов.

Нужна хитрость: определим специальную форму, которая принимает строку и находит по ней значение. Где? Конечно, в области видимости:

def evals4(form, scope):
    if type(form) != list:
        return form
    elif form[0] == "get":
        name = form[1]
        return scope[name]
    elif form[0] == "if":
        if evals(form[1], scope):
            evals(form[2], scope)
        else:
            evals(form[3], scope)
    else:
        name = form[0]
        function = scope[name]
        arguments = form[1:]
        evaluated = [evals4(i, scope) for i in arguments]
        return function(evaluated)

scope["a"] = 2
scope["b"] = 3
script = ["print", "a + b =", ["+", ["get", "a"], ["get", "b"]]]

evals4(script, scope)

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

Наш JSON становится довольно многословным. Будем продолжать делать вид, что он не редактируется вручную, но по этой причине многие люди предпочли бы просто написать
(print "2 + 3 = " (+ $a $b)) и не париться. Вот еще пара моментов:

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

  • Если запрашиваемой переменной не существует, evals кидает ошибку. Python тоже так работает, и это правильный подход - позволяет быстро найти одну из самых распространенных ошибок.

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

Мы можем реализовать это именно так: изменить evals (снова), чтобы при передаче JSON вместо функции выполнить его на лету, так же как в случае с переменными. Но вместо этого я добавлю немного магии Python:

def say_hi():
    print("Hi!")

class Greeting:
    def __call__(self):
        print("Hi!")

say_hi2 = Greeting()

say_hi()
say_hi2()

Благодаря методу __call__ я могу поместить say_hi2 в область видимости с подходящим именем, и evals даже не заметит разницы. Но задача была выполнять скрипты, объявленные пользователем:

class Function:
    def __init__(self, code, scope):
        self.code = code
        self.scope = scope

    def __call__(self, args):
        return evals4(self.code, self.scope)

scope["say-hi"] = Function(["print", "Hi!"], scope)
evals4(["say-hi"], scope)

Обратите внимание на то, что наша функция даже не знает собственного имени, но принимает ссылку на область видимости, потому что без нее она не может даже выполнить evals. Это также удобным образом дает нам доступ к нашей функции print. Увы, функция не использует список аргументов, хотя и должна его принимать, поскольку в противном случае evals будет ругаться (функция say-hi вызывается из evals).

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

class Function2:
    def __init__(self, code, scope):
        self.code = code
        self.scope = scope

    def __call__(self, args):
        local_scope = dict(self.scope)
        for index, value in enumerate(args):
            local_scope[index] = value
        return evals4(self.code, local_scope)

scope["double"] = Function2(["+", ["get", 0], ["get", 0]], scope)
evals4(["print", "Two times 5 is", ["double", 5]], scope)

Я не хотел заморачиваться с именами аргументов, поэтому использовал индексы в списке. Можно поправить, но понадобится еще больше кода и объяснений. И кроме того - все и так работает: наши функции могут принимать аргументы, как если бы они были написаны на Python.

У такого подхода есть и ограничения. Может быть, вы хотите дать функции доступ к области видимости, из которой она была вызвана (так называемая динамическая область видимости). Или вы захотите поделить функции на группы, так чтобы каждая функция видела только другие функции из свой группы, но все могли видеть глобальные объявления. Для таких хитрых трюков нам понадобятся вложенные области видимости. Как же их сделать?

Было бы здорово уметь связывать словари в цепочку, как прототипы объектов в Javascript. Увы, Python так не работает, поэтому нам понадобится новый класс. Также нам могло бы потребоваться снова изменить функцию evals, поскольку она ожидает словарь, но к счастью в Python есть еще немного волшебства:

class Scope:
    def __init__(self, parent=None):
        self.names = {}
        self.parent = parent

    def __getitem__(self, key):
        if key in self.names:
            return self.names[key]
        elif self.parent != None:
            return self.parent[key]
        else:
            return None

Да, вот так просто. Осталось только скопировать все в нашу новую область видимости:

scope2 = Scope()
scope2.names.update(scope)
evals4(["print", "Two times 5 is", ["double", 5]], scope2)

Даже наш класс Function2 не заметил разницы. Давайте доработаем его так, чтобы он использовал новые области видимости:

class Function3:
    def __init__(self, code, scope):
        self.code = code
        self.scope = scope

    def __call__(self, args):
        local_scope = Scope(self.scope)
        for index, value in enumerate(args):
            local_scope.names[index] = value
        return evals4(self.code, local_scope)

Теперь нам осталось использовать новый класс Function3 внутри области видимости:

scope2.names["double"] = Function3(
    ["+", ["get", 0], ["get", 0]], scope2)
evals4(["print", "Two times 5 is", ["double", 5]], scope2)

Мало того, что это работает - это еще и самый гибкий вариант. Посмотрим, сколько потребовалось кода:

  • 10 строчек в Function3

  • 12 в Scope

  • 17 в evals4

Итого - примерно 40 строк, меньше листа А4. Да, конечно, если начать добавлять функции и специальные формы, то его легко раздует в десять раз, но он не станет более сложным - так что можно перестать париться по поводу интерпретации и сфокусироваться на прикладной задаче.

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

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

Tags:
Hubs:
+5
Comments 12
Comments Comments 12

Articles