Форум программистов, компьютерный форум, киберфорум
Наши страницы
Storm23
Войти
Регистрация
Восстановить пароль
Оценить эту запись

Добавление скриптинга и динамических аддонов в Unity (часть 1)

Запись от Storm23 размещена 30.05.2019 в 13:55
Обновил(-а) Storm23 30.05.2019 в 19:10

Постановка задачи

Требуется разделить игру на движок и игровой контент (сюжет, геймплей, диалоги, задания для игрока, и так далее).
Контент будет содержать сложную логику, поэтому контент нужно представить не просто в виде текстовых файлов или БД. Для описания контента нужно использовать скриптовый язык программирования.
При этом нужно разделить разработку движка и сюжета на несколько независимых проектов. А еще лучше сделать систему аддонов и плагинов, с помощью которой дополнительные сюжетные линии могут разрабатывать сторонние разработчики и подгружаться в игру прямо в рантайме.

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

Сразу определимся с терминологией: здесь и далее под скриптами я буду подразумевать не те скрипты, которые MonoBehaviour из Unity, а именно скриптинг - то есть скрипты которые описывают контент игры.

Скриптинг на C#

В целом скриптинг работает так. В основном движке разрабатывается абстрактный класс (назовем его ScriptingBase) который содержит набор методов для взаимодействия скриптов с основным движком игры. ScriptingBase вынесем в отдельный namespace.

Например это может выглядеть так:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    //Базовый класс для всех скриптов
    //Предоставляет функционал для взаимодействия скрипта с движком
    public abstract class ScriptingBase
    {
        // Этот метод должен переопределяться в наследниках
        // Он будет автоматически вызываться движком игры в каждом фрейме
        public abstract void Update();
 
        // Метод для показа сообщения в игре
        // Этот метод можно вызывать из скрипта
        protected void ShowMessage(string message)
        {
            ///...
        }
 
        //...
    }
Сюжетные скрипты наследуются от ScriptingBase, в них описывается игровая логика и сюжет, а взаимодействие с движком происходит через вызов методов базового класса ScriptingBase.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    // Скрипт, который будет просто выводить сообщение игроку
    class MyStory : ScriptingBase
    {
        bool messageShown = false;
 
        public override void Update()
        {
            //просто показываем сообщение игроку, если еще не показывали
            if (!messageShown)
            {
                messageShown = true;
                ShowMessage("Привет из скрипта!");
            }
        }
    }
Скрипт просто вызывает метод для показа сообщения игрока и запоминает, что сообщение уже показано, что бы не показывать его снова.

Далее в движке пишем простой контроллер, который может показывать текст игроку. И еще пишем MonoBehaviour класс PluginsController который будет заниматься загрузкой и выполнением скриптов.

Полный код на этом этапе:

ScriptingBase
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System;
 
namespace Scripts
{
    //Базовый класс для всех скриптов
    //Предоставляет функционал для взаимодействия скрипта с движком
    public abstract class ScriptingBase
    {
        //событие для отображения окна с сообщением
        public static event Action<string> ShowMessageNeeded = delegate { };
 
        // Этот метод должен переопределяться в наследниках
        // Он будет автоматически вызываться движком игры в каждом фрейме
        public abstract void Update();
 
        // Метод для показа сообщения в игре
        // Этот метод можно вызывать из скрипта
        protected void ShowMessage(string message)
        {
            ShowMessageNeeded(message);
        }
    }
}

MyStory
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
 
namespace Scripts
{
    // Скрипт, который будет просто выводить сообщение игроку
    class MyStory : ScriptingBase
    {
        bool messageShown = false;
 
        public override void Update()
        {
            //просто показываем сообщение игроку, если еще не показывали
            if (!messageShown)
            {
                messageShown = true;
                ShowMessage("Привет из скрипта!");
            }
        }
    }
}

MessageController
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;
using UnityEngine.UI;
using Scripts;
 
public class MessageController : MonoBehaviour
{
    public Text Text;
 
    void Start()
    {
        //подписываемся на событие показа окна
        ScriptingBase.ShowMessageNeeded += (msg) => Text.text = msg;
    }
}

PluginsController
C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using System;
using UnityEngine;
using Scripts;
 
public class PluginsController : MonoBehaviour
{
    ScriptingBase script;
 
    private void Start()
    {
        //создаем скрипт
        script = new MyStory();
    }
 
    private void Update()
    {
        //обновляем скрипт
        script.Update();
    }
}


PluginsController создает объект MyStory и периодически вызывает его метод Update. Сейчас этот класс создает объект MyStory непосредственно, но мы потом изменим это поведение, потому что он не должен заранее знать о скриптах, которые он будет вызывать.

Компилируем проект, проверяем работоспособность:
Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 11.22.28.png
Просмотров: 53
Размер:	61.3 Кб
ID:	5367
Проект целиком на данном этапе: ScriptingExample 1.zip

Работает!

Разделение на проекты, выделение API

Если посмотреть на код из предыдущей части, то видно, что классы из неймспейса Scrpits используют только System и ничего не знают ни о контролерах движка ни о UnityEngine. Это значит, что они независимы от движка игры. Это хорошо, потому что это позволит вынести скрипт MyStory в отдельную dll, которая будет независима от Unity и от игрового движка.

С другой стороны, контроллеры знают о неймспейсе Scrpits и даже о классе скрипта MyStory. Это плохо, эту зависимость нужно разорвать.

Для этого, изменим класс PluginsController так, что бы он не создавал MyStory непосредственно, а искал классы унаследованные от ScriptingBase, и автоматически создавал их:

PluginsController
C#
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
using System;
using UnityEngine;
using Scripts;
using System.Linq;
 
public class PluginsController : MonoBehaviour
{
    ScriptingBase script;
 
    private void Start()
    {
        //ищем все классы из всех загруженных dll
        foreach (var type in AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()))
        {
            //этот тип унаследован от ScriptingBase?
            if (typeof(ScriptingBase).IsAssignableFrom(type))
            // не абстрактный?
            if (!type.IsAbstract)
            {
                //создаем экземпляр скрипта
                script = Activator.CreateInstance(type) as ScriptingBase;
                break;
            }
        }
    }
 
    private void Update()
    {
        //обновляем скрипт
        script.Update();
    }
}


Запускаем, работает. Отлично!

Теперь у нас движок знает только о классе ScriptingBase, но ничего не знает о скрипте MyStory. Таким образом, связь между скриптом и движком разорвана и они не знают друг о друге.

Но проблема все же остается. Дело в том, что и движок и скрипт знают о классе ScriptingBase. Это неизбежно, потому что этот класс является связующим звеном между скриптом и остальной игрой.

Если бы мы разрабатывали отдельное приложение, то можно было бы просто вынести класс ScriptingBase в отдельный проект, и тогда его могли бы использовать в виде dll и движок и скрипт.

Но проблема в том, что Unity не позволяет делать отдельные проекты в виде dll. В Unity все пользовательские классы компилируется в единое целое, и выделить часть в отдельный проект - нельзя.

Будем решать эту проблему так: создадим отдельный проект типа Class Library в VisualStudio. И назовем его API.
Его папку расположим где-то рядом с папкой Assets.

В этот проект добавим класс ScriptingBase как ссылку (As Link) и скомпилируем проект.

API
Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 12.08.52.png
Просмотров: 59
Размер:	89.6 Кб
ID:	5369 Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 12.09.37.png
Просмотров: 57
Размер:	60.1 Кб
ID:	5370 Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 12.10.22.png
Просмотров: 54
Размер:	127.1 Кб
ID:	5371


Проект успешно компилируется (потому что ScriptingBase независим от Unity и движка, поэтому dll-ки Unity не нужны).
В результате, мы получаем API.dll в которой находится откомпилированный класс ScriptingBase.

Библиотека API.dll содержит базовый класс для скриптов, но не содержит классы самого движка. Это дает возможность свободно распространять эту dll. И она может быть использована сторонними разработчиками для разработки плагинов к игре, не имея самого исходного кода игры.

Создание аддона

Для аддона создадим еще один проект типа Class Library в VisualStudio, тоже рядом с папкой Assets. Назовем проект MyAddon.
Перенесем класс скрипта MyStory из юнитовского проекта в новый проект. Файл MyStory.cs удалим из папки Asstes/Scripts/Scripting.

К созданному проекту присоединим библиотеку API.dll через References. В свойствах reference для API.dll выставьте Copy Local в false. Это важно.

Получим следующий проект:

MyAddon
Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 12.31.24.png
Просмотров: 58
Размер:	17.1 Кб
ID:	5372 Нажмите на изображение для увеличения
Название: Скриншот 2019-05-30 12.43.55.png
Просмотров: 54
Размер:	14.1 Кб
ID:	5373


Поскольку класс MyStory зависит только от ScriptingBase, то ему не нужны никакие другие библиотеки, кроме API.dll. Компилируем проект. Он успешно компилируется.

На выходе получаем файл MyAddon.dll. Убедитесь, что рядом с этой длл нет файла API.dll.

Загрузка плагина

Теперь сделаем так, что бы PluginsController мог найти наш аддон и загрузить его.
Для этого будем искать файлы *Addon.dll вокруг себя. И пытаться загружать плагин:

PluginsController
C#
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
using System;
using UnityEngine;
using Scripts;
using System.Linq;
using System.IO;
using System.Reflection;
 
public class PluginsController : MonoBehaviour
{
    ScriptingBase script;
 
    private void Start()
    {
        //ищем файлы *Addon.dll вокруг себя
        var addonFile = Directory.GetFiles(Path.Combine(Application.dataPath, ".."), "*Addon.dll", SearchOption.AllDirectories).FirstOrDefault();
 
        //если файл аддона найден
        if (addonFile != null)
        {
            //загружаем длл
            var ass = Assembly.LoadFile(addonFile);
 
            //ищем все классы, унаследованные от ScriptingBase
            foreach (var type in ass.GetTypes())
            {
                //этот тип унаследован от ScriptingBase?
                if (typeof(ScriptingBase).IsAssignableFrom(type))
                // не абстрактный?
                if (!type.IsAbstract)
                {
                    //создаем экземпляр скрипта
                    script = Activator.CreateInstance(type) as ScriptingBase;
                    break;
                }
            }
        }
    }
 
    private void Update()
    {
        //обновляем скрипт
        if (script != null)
            script.Update();
    }
}


Если мы в данный момент запустим проект Unity, то нас ждет облом. Выпадает исключение:

ReflectionTypeLoadException: Exception of type 'System.Reflection.ReflectionTypeLoadException' was thrown.

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

Для того, что бы это обойти, используем событие AppDomain.AssemblyResolve:

C#
1
2
        //подсовываем свою длл вместо любой другой
        AppDomain.CurrentDomain.AssemblyResolve += (o, e) => Assembly.GetExecutingAssembly();
Событие AssemblyResolve отлавливает запрос длл API.dll и вместо нее возвращает текущую ассемблю. Поскольку класс ScriptingBase есть и в API.dll и в проекте Unity, то наш аддон успешно создает экземпляр MyStory, и наследует его от класса ScriptingBase, который находится в нашем Unity проекте.

Запускаем проект на выполнение - он запускается без ошибок, и на экране выскакивает надпись из аддона, Ура!
Плагин успешно загрузился.

Полный код проекта на этом этапе:
Вложения
Тип файла: zip ScriptingExample 2.zip (112.9 Кб, 36 просмотров)
Размещено в Unity
Просмотров 88 Комментарии 0
Всего комментариев 0
Комментарии
 
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin® Version 3.8.9
Copyright ©2000 - 2019, vBulletin Solutions, Inc.
Рейтинг@Mail.ru