Загружаемый контент в Unity [DLC]

Были времена, когда обычный патч для игры, вносил столько изменений и контента, что его волне можно сравнить с современными платными дополнениями. Сегодня тренд игроделов в том, чтобы отрезать от игры кусочки, которые там изначально должны были быть, назвать это DLC и продавать отдельно. Но тем не менее, сама по себе система DLC, вещь интересная в плане расширения игрового контента. Или можно представить такую ситуацию, собрать игровой клиент, который будет мало весить и включать только демо, а все остальные элементы клиент будет докачивать с удаленного сервера. В этом уроке мы попробуем разобраться, как в Unity организовать систему DLC.

Загружаемый контент в Unity [DLC]

Первое с чем нужно определиться, это то, какие именно ассеты можно запаковывать в отельные DLC. Практически все ассеты, которые мы используем в процессе разроботки игры, можно завернуть в отдельный AssetBundle. Что такое AssetBundle? Если говорить просто, это файлы, которые содержат в себе ассеты (сцены, музыка, текстуры, материалы и прочее), и которые можно импортировать в игру. Такие файлы создаются, когда мы делаем сборку игры. А для создания DLC нам нужно научиться создавать такие файлы вручную и запаковывать в них нужные нам ассеты.

Чтобы понять, какой именно ассет можно запаковать в DLC, его нужно выбрать:


Если мы видим такое окошко ассета с меню AssetBundle, значит этот ассет можно добавить в сборку.


Для этого кликаем по меню и выбираем "New..." после чего, можно вписать название бандла и его расширение.


Например, если мы делаем сборку музыки, то можно назвать music.dlc или music.unity3d не суть. Важно понимать, что если вы хотите собрать несколько ассетов в один AssetBundle, то для всех этих файлов нужно выбрать в меню имя music.dlc. Тогда они будут объединены в один архив.

Внимание! В архив AssetBundle нельзя собрать скрипты, потому что компиляция скриптов происходит в процессе сборки приложения. Если вы делаете сборку бандла из префабов или сцен, где есть скрипты, то чтобы всё работало соответствующим образом, эти скрипты должны быть на стороне клиента, который будет подгружать эти бандлы. А это значит, что после того как вы сделали сборку самой игры, а только потом делаете для нее DLC, то нельзя изменять скрипты, так как они не будут включены в бандл.

Примечание! Например, если мы хотим сделать бандл из сцены или нескольких сцен, то достаточно в меню ассета, добавить только сами сцены. В бандл будут добавлены все объекты, которые находятся на сцене. Тоже относится и к префабу, если он ссылается на 3D модель, материалы и прочее, всё это будет автоматически включено в AssetBundle. Соответственно, когда мы делаем сборку таких бандлов, убедитесь чтобы у других ассетов была выключена опция, в окошке ассетов о которых мы говорили выше. Иначе говоря, если у нас есть сцена, на которую, например, добавлен трек, и если мы добавим сцену в бандл levels.dlc, а сам трек добавим в бандл music.dlc, то тогда они будут разбиты по разным архивам. Это нужно учитывать. Делая сборку допустим сцены, уберите опцию с ассетов другого типа.

Теперь о том, как делать сборку бандла:

#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using System.IO;

public class CreateAssetBundles : EditorWindow {

	private string path = "AssetBundles";
	private BuildAssetBundleOptions options = BuildAssetBundleOptions.UncompressedAssetBundle;
	private BuildTarget platform = BuildTarget.StandaloneWindows;


	[MenuItem("Window/Build AssetBundles")]
	public static void ShowWindow()
	{
		EditorWindow.GetWindow(typeof(CreateAssetBundles));
	}

	void OnGUI()
	{
		GUILayout.Label("AssetBundles Settings", EditorStyles.boldLabel);

		EditorGUILayout.Separator();
		path = EditorGUILayout.TextField("Output Path:", path);

		EditorGUILayout.Separator();
		GUILayout.BeginHorizontal();
		GUILayout.Label("Options:");
		options = (BuildAssetBundleOptions)EditorGUILayout.EnumPopup(options);
		GUILayout.EndHorizontal();

		EditorGUILayout.Separator();
		GUILayout.BeginHorizontal();
		GUILayout.Label("Platform:");
		platform = (BuildTarget)EditorGUILayout.EnumPopup(platform);
		GUILayout.EndHorizontal();

		EditorGUILayout.Separator();
		if(GUILayout.Button("Create AssetBundles"))
		{
			if(!Directory.Exists(path)) Directory.CreateDirectory(path);
			BuildPipeline.BuildAssetBundles(path, options, platform);
			EditorUtility.RevealInFinder(path);
		}
	}
}
#endif

Этот скрипт добавляет новое окно в редактор. Для его вызовы идем в меню Window -> Build AssetBundles.

Мы увидим вот такое окошко:


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

Подробнее об опциях сборки. Можно отметить две основные опции. Первая "UncompressedAssetBundle", данную опцию мы используем в том случаи, если хотим сделать бандл, ассеты которого можно подгружать синхронно "на лету", что крайне удобно. Вторая опция "None" создаст бандл с сжатием по умолчанию, чтобы достать ассет из такого архива, Unity в начале должен будет загрузить его полностью, распаковать, взять нужный ассет и только потом выгрузить. Иначе говоря, бандлы с сжатием подходят только для асинхронной загрузки.

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

Общая логика системы DLC. Суть проблемы в том, что в некоторых случаях можно подгружать бандлы из разных серверов в процессе игры. Но в случаи игрового дополнения, нам нужно грузить ассеты "на лету", из этого следует, чтобы наши DLC были загружены на локальный диск пользователя. Тут можно сделать магазин по продажи этих DLC или как-то еще их распространять, это не важно. Важно другое, возможность скачивания дополнений.

Делаем UI шаблон, для вывода списка доступных DLC:


Вывод иконки, названия, кнопка загрузки и прогресс бар загрузки.

Скрипт для шаблона:

using System.Collections;
using UnityEngine.UI;
using System.IO;
using UnityEngine;
using System.Threading;

public class DLCSystemComponent : MonoBehaviour {

	[SerializeField] private Image icon; // иконка bundle
	[SerializeField] private Button load; // кнопка загрузки
	[SerializeField] private Text info; // вывод информации о состоянии загрузки
	[SerializeField] private Text title; // вывод имени bundle
	[SerializeField] private Slider progress; // прогресс бар загрузки
	private string url;
	private Worker worker = new Worker();
	private bool isDone, isFailed;

	public void Init(string dlcName, string dlcURL, Sprite sprite) // инициализация
	{
		load.onClick.AddListener(()=>{Download();});
		icon.sprite = sprite;
		progress.interactable = false;
		title.text = dlcName;
		url = dlcURL;
		bool isLoad = File.Exists(DLCSystem.dlcPath + Path.GetFileName(dlcURL));
		progress.value = isLoad ? 1 : 0;
		load.interactable = isLoad ? false : true;
		info.text = isLoad ? "Complete" : "Download";

		if(!isLoad)
		{
			worker.OnWorkComplete += OnComplete;
			worker.OnWorkFailed += OnFailed;
		}
	}

	void Download()
	{
		if(Application.internetReachability == NetworkReachability.NotReachable) return; // если интернет недоступен, выходим
		StartCoroutine(LoadAssetBundle());
	}

	IEnumerator LoadAssetBundle() // скачивание файла с сервера
	{
		isFailed = false;
		isDone = false;
		load.interactable = false;
		string file = Path.GetFileName(url);

		using(WWW www = new WWW(url))
		{
			while(!www.isDone && string.IsNullOrEmpty(www.error))
			{
				info.text = "Loading: " + file;
				progress.value = www.progress;
				yield return null;
			}

			if(!string.IsNullOrEmpty(www.error))
			{
				info.text = www.error;
				yield break;
			}

			info.text = "Saving: " + file;
			worker.Start(www.bytes, DLCSystem.dlcPath + file);
		}

		StartCoroutine(Wait());
	}

	IEnumerator Wait() // ожидание сохранения файла
	{
		while(true)
		{
			yield return null;

			if(isDone)
			{
				info.text = "Complete";
				progress.value = 1;
				yield break;
			}
			else if(isFailed)
			{
				info.text = "Failed";
				progress.value = 0;
				load.interactable = true;
				yield break;
			}
		}
	}

	void OnComplete(object sender, System.EventArgs eventArgs)
	{
		isDone = true;
	}

	void OnFailed(object sender, System.EventArgs eventArgs)
	{
		isFailed = true;
	}
}

class Worker // класс в котором запускается функция сохранения в отдельном потоке
{
	public event System.EventHandler<System.EventArgs> OnWorkComplete = delegate{};
	public event System.EventHandler<System.EventArgs> OnWorkFailed = delegate{};

	public void Start(byte[] bytes, string path)
	{
		new Thread(new ThreadStart(delegate(){DoWork(bytes, path);})).Start();
	}

	void DoWork(byte[] bytes, string path)
	{
		try
		{
			FileStream stream = new FileStream(path, FileMode.Create);
			BinaryWriter writer = new BinaryWriter(stream);
			writer.Write(bytes);
			writer.Close();
			stream.Close();
			OnWorkComplete(this, null);
		}
		catch(System.Exception error)
		{
			Debug.Log("Worker: " + error.Message);
			OnWorkFailed(this, null);
		}
	}
}

Если указанное дополнение еще не скачено на локальный диск, то оно будет доступно. В ином случаи, кнопка скачивания будет недоступна.

Далее, основной класс управления:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public delegate void DLCSystemEvent(string bundleName);

public class DLCSystem : MonoBehaviour {

	public static event DLCSystemEvent OnLoadComplete; // событие, сообщающее о завершение загрузки метода LoadBundleToListAsync
	[SerializeField] private DLCSystemComponent sample; // шаблон для создания списка доступных DLC
	[SerializeField] private string DLCDirectory = "DLC"; // папка, куда будут скачиваться DLC
	[SerializeField] private string DLCIconsURL = "http://test.ru/dlc_icons.dlc"; // адрес DLC с иконками для списка DLC
	[SerializeField] private string DLCListURL = "http://test.ru/dlc_list.txt"; // текстовый список доступных для скачивания DLC
	[SerializeField] private RectTransform parent; // родитель для шаблонов списка
	[SerializeField] private bool dontDestroyOnLoad = true;
	private static string[] dlcName, dlcURL;
	public static string dlcPath { get; private set; }
	private static List<AssetBundle> bundles = new List<AssetBundle>();
	private static DLCSystem _inst;
	private static AssetBundle icons;
	private static Sprite[] assetIcons;
	private static string dataPath;

	void Awake()
	{
		dataPath = Application.persistentDataPath; // путь сохранения DLC
		dlcPath = dataPath + "/" + DLCDirectory + "/";
		if(dontDestroyOnLoad) DontDestroyOnLoad(gameObject);
		_inst = this;
		if(!Directory.Exists(dataPath + "/" + DLCDirectory)) 
			Directory.CreateDirectory(dataPath + "/" + DLCDirectory);
		StartCoroutine(LoadDLCIcons());
	}

	Sprite GetIcon(string bundleName)
	{
		for(int i = 0; i < assetIcons.Length; i++)
		{
			if(bundleName == assetIcons[i].name) return assetIcons[i];
		}

		return null;
	}

	IEnumerator LoadDLCIcons() // загрузка в память DLC с иконками
	{
		if(icons != null)
		{
			StartCoroutine(LoadDLCList());
			yield break;
		}

		using(WWW www = new WWW(DLCIconsURL))
		{
			yield return www;

			if(!string.IsNullOrEmpty(www.error))
			{
				Debug.Log(www.error);
				yield break;
			}

			icons = www.assetBundle;
			assetIcons = icons.LoadAllAssets<Sprite>();

			StartCoroutine(LoadDLCList());
		}
	}

	IEnumerator LoadDLCList() // загрузка и создание списка DLC
	{
		using(WWW www = new WWW(DLCListURL))
		{
			yield return www;

			if(!string.IsNullOrEmpty(www.error))
			{
				Debug.Log(www.error);
				yield break;
			}

			string[] lines = www.text.Split(new string[]{System.Environment.NewLine}, System.StringSplitOptions.RemoveEmptyEntries);
			dlcName = System.Array.ConvertAll(lines, dlc => dlc.Split(new char[]{'|'}, System.StringSplitOptions.None)[0].Trim());
			dlcURL = System.Array.ConvertAll(lines, dlc => dlc.Split(new char[]{'|'}, System.StringSplitOptions.None)[1].Trim());

			for(int i = 0; i < lines.Length; i++)
			{
				DLCSystemComponent clone = Instantiate(sample, parent) as DLCSystemComponent;
				clone.transform.localScale = Vector3.one;
				clone.Init(dlcName[i], dlcURL[i], GetIcon(dlcName[i]));
				clone.gameObject.SetActive(true);
			}
		}
	}

	// загружен ли указанный bundle, проверяем по имени (bundleName указывается с расширением файла, например: levels.dlc)
	public static bool IsAssetBundle(string bundleName)
	{
		if(string.IsNullOrEmpty(bundleName)) return false;

		if(File.Exists(dlcPath + bundleName)) return true;

		return false;
	}

	// синхронная загрузка ассета из указанного bundle с локального диска
	public static T LoadAsset<T>(string assetName, string bundleName) where T : Object
	{
		if(string.IsNullOrEmpty(assetName) || string.IsNullOrEmpty(bundleName)) return default(T);

		string path = dlcPath + bundleName;

		if(!File.Exists(path)) return default(T);

		AssetBundle asset = AssetBundle.LoadFromFile(path);

		T result = asset.LoadAsset<T>(assetName);

		asset.Unload(false);

		return result;
	}

	// синхронная массива из указанного bundle с локального диска
	public static T[] LoadAllAssets<T>(string bundleName) where T : Object
	{
		if(string.IsNullOrEmpty(bundleName)) return default(T[]);

		string path = dlcPath + bundleName;

		if(!File.Exists(path)) return default(T[]);

		AssetBundle asset = AssetBundle.LoadFromFile(path);

		T[] result = asset.LoadAllAssets<T>();

		asset.Unload(false);

		return result;
	}

	// асинхронная загрузка bundle с локального диска
	public static void LoadBundleToListAsync(string bundleName)
	{
		if(string.IsNullOrEmpty(bundleName)) return;

		foreach(AssetBundle t in bundles) if(t.name == bundleName) return;

		_inst.StartCoroutine(_inst.DownloadBundle(bundleName));
	}

	IEnumerator DownloadBundle(string bundleName)
	{
		string path = dlcPath + bundleName;

		if(File.Exists(path))
		{
			var request = AssetBundle.LoadFromFileAsync(path);
			yield return request;
			bundles.Add(request.assetBundle);
			if(OnLoadComplete != null) OnLoadComplete(bundleName);
		}

		yield return null;
	}

	// синхронная загрузка ассета из списка bundle, который был загружен асинхронно
	public static T LoadAssetList<T>(string assetName, string bundleName) where T : Object
	{
		if(string.IsNullOrEmpty(assetName)) return default(T);

		AssetBundle bundle = null;

		for(int i = 0; i < bundles.Count; i++)
		{
			if(bundles[i].name == bundleName)
			{
				bundle = bundles[i];
				break;
			}
		}

		if(bundle == null) return default(T);

		return bundle.LoadAsset<T>(assetName);
	}

	// синхронная загрузка массива из списка bundle, который был загружен асинхронно
	public static T[] LoadAllAssetsList<T>(string bundleName) where T : Object
	{
		if(string.IsNullOrEmpty(bundleName)) return default(T[]);

		AssetBundle bundle = null;

		for(int i = 0; i < bundles.Count; i++)
		{
			if(bundles[i].name == bundleName)
			{
				bundle = bundles[i];
				break;
			}
		}

		if(bundle == null) return default(T[]);

		return bundle.LoadAllAssets<T>();
	}

	// выгрузить только указанный bundle из списка
	public static void UnloadBundleFromList(string bundleName)
	{
		if(string.IsNullOrEmpty(bundleName)) return;

		for(int i = 0; i < bundles.Count; i++)
		{
			if(bundles[i].name == bundleName)
			{
				bundles[i].Unload(true);
				bundles.RemoveAt(i);
				return;
			}
		}
	}

	// выгрузить все ассеты из списка загруженных
	public static void UnloadBundleListAll()
	{
		foreach(AssetBundle t in bundles) t.Unload(true);
		bundles.Clear();
	}
}

Итак, тут важно обратить внимание на URL бандла с иконками. Смысл в том, что для своих DLC мы создаем специальный бандл, который содержит в себе спрайты иконки для всех загружаемых дополнений, то есть, в начале скрипт загружает иконки с сервера, а только потом загружает список доступных DLC. Плюс такой реализации в том, что разработчик в любое время может редактировать иконки, что сразу отразится на стороне клиента после запроса информации с сервера.

Чтобы создать бандл с иконками, нам понадобится обычный атлас спрайтов:


Где название каждой иконки должно быть таким же, как и название бандла в текстом списке http://test.ru/dlc_list.txt Файл бандла иконок нужно делать с сжатием и следить за его размером, чтобы он быстро загружался с сервера.

Текстовый список, который хранится на сервере:


Содержание текстового файла, должно иметь такой вид:

Music | http://test.ru/music.dlc
Levels | http://test.ru/levels.dlc
Res Music | http://test.ru/res_music.dlc


Пишем название название бандла, затем разделительный символ "|" и после пишем прямой адрес.

Использование системы:

Если мы хотим отследить статус фоновой загрузки бандла:

void Start()
{
	// если нам нужно отследить, что конкретный бандл, делаем подписку на событие
	// работает совместно с методом DLCSystem.LoadBundleToListAsync("name.dlc");
	DLCSystem.OnLoadComplete += Test;
}

void OnDestroy()
{
	// если текущий скрипт уничтожен, обязательно (!) делаем отписку метода
	DLCSystem.OnLoadComplete -= Test;
}

void Test(string bundleName) // метод будет выполнен после успешной загрузки бандла
{
	Debug.Log("Загружен Bundle: " + bundleName);
}

Можно использовать конструкция вот такого вида.

Примеры взаимодействия:

// асинхронная загрузка bundle
DLCSystem.LoadBundleToListAsync("name.dlc");

if(DLCSystem.IsAssetBundle("name.dlc"))
{
	// проверка загружен ли бандл на локальный диск
}

// синхронно загрузить ассет из бандла
AudioClip audio = DLCSystem.LoadAsset<AudioClip>("name", "name.dlc");

// загрузка из бандла, который был загружен с помощью асинхронной загрузки
GameObject prefab = DLCSystem.LoadAssetList<GameObject>("name", "name.dlc");

// выгрузить бандл, который был загружен асинхронно
DLCSystem.UnloadBundleFromList("name.dlc");

// выгрузить все бандлы, которые были загружены асинхронно
DLCSystem.UnloadBundleListAll();

Внимание! Имя бандла нужно указывать вместе с расширением файла.

Если же асинхронно загрузить бандл сцен, то:

SceneManager.LoadScene("name");

Для запуска сцены, достаточно просто вызвать стандартный метод и указать имя.

Скачать скрипты:

У вас нет доступа!
Тестировалось на: Unity 2017.1.0

Комментариев 5

Офлайн
LDblue 3 сентября 2017
ООО, спасибо большое!!! Я долго не мог понять как делать DLC под юньку
Офлайн
Frejk 23 октября 2017
ок, но тогда представим, что у меня игра стрелялка и в новом длс я добавляю новые пушки, как тогда быть со скриптами на нее?
Офлайн
Doomby 31 октября 2017
Frejk, в статье довольно криво сформулирована мысль. Имеется в виду, что все скрипты находятся в сборке и не могут быть включены в бандл, поэтому при необходимости добавить или изменить их, нужно обновить саму игру.
Офлайн
Light 5 ноября 2017
Красным по белому написано, что в AssetBundle нельзя упаковать скрипты, а это автоматом означает, что игру надо либо пересобирать заново с новыми скриптами, либо заранее писать скрипты с учетом установки ДЛС.
Офлайн
Prog-Maker 13 ноября 2017
Frejk,
К любой игре для загрузки дополнений как правило выходит новая версия игры.
Это не новость я думаю.
Информация
Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
  • Дешевый хостинг
  • Яндекс.Метрика