Добавляем субтитры в игру

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


В качестве подготовки нам понадобится два UI объекта: Image и Text. Для этих объектов нужно установить пресеты трансформа, как показано на скриншоте:

Добавляем субтитры в игру

Значит Image и Text ставим эти настройки, это важно (!), для правильного позиционирования объектов. Затем, останется только настроить цвет Image (это фон субтитров) и настроить отступы с лева и с права, на свое усмотрение.

Субтитры будут выводиться объектом Text, на него вешаем SubtitlesComponent:

using System.Collections;
using UnityEngine.UI;
using UnityEngine;

public class SubtitlesComponent : MonoBehaviour {

	[SerializeField] private Text text;
	public Text sub {get{return text;}}
	public float timeout { get; set; }
	public bool isActive { get; set; }
	public Vector2 target { get; set; }
	public Color color { get; set; }
}

Этот класс хранит ссылку на компонент Text, не забываем ее добавить, дополнительно тут присутствуют еще ряд переменных, которые используются в процессе работы скрипта.

Теперь добавляем на сцену скрипт Subtitles:

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

public class Subtitles : MonoBehaviour {

	[SerializeField] private string localeFolder = "Locale"; // папка, где будут лежать локали
	[SerializeField] private string defaultLocale = "Russian"; // язык по умолчанию
	[SerializeField] private float textTime = 5; // время показа текста
	[SerializeField] private float textOffset = 10; // отступ между субтитрами
	[SerializeField] private float animSpeed = 100; // скорость анимации
	[SerializeField] private float fadeSpeed = 3.5f; // скорость затухания текста
	[SerializeField] private int maxSub = 3; // макс число субтитров на экране одновременно
	[SerializeField] private Image subPanel; // фон субтитров
	[SerializeField] private float panelOffset = 15; // отступ от нижнего края экрана
	[SerializeField] private Color panelColor = Color.black;
	[SerializeField] private SubtitlesComponent subText; // компонент вывода текста
	private List<SubtitlesComponent> pool = new List<SubtitlesComponent>();
	private List<Subs> subs = new List<Subs>();
	private static Subtitles _inst;
	private Vector2 deltaPanel, posPanel;

	void Awake()
	{
		_inst = this;	
	}

	void Start()
	{
		subPanel.gameObject.SetActive(false);
		subPanel.rectTransform.anchoredPosition = new Vector2(subPanel.rectTransform.anchoredPosition.x, 0);
		subPanel.color = Color.clear;
		subPanel.raycastTarget = false;
		subPanel.rectTransform.sizeDelta = new Vector2(subPanel.rectTransform.sizeDelta.x, 0);
		subText.gameObject.SetActive(false);
		subText.sub.alignment = TextAnchor.MiddleLeft;
		subText.sub.raycastTarget = false;
		subText.sub.rectTransform.localScale = Vector3.one;
		subText.gameObject.name = "Sub-0";
		pool.Add(subText);

		for(int i = 1; i < maxSub; i++)
		{
			SubtitlesComponent comp = Instantiate(subText, transform) as SubtitlesComponent;
			comp.sub.rectTransform.localScale = Vector3.one;
			comp.gameObject.name = "Sub-" + i;
			pool.Add(comp);
		}

		if(Directory.Exists(localeFolder)) LoadXML(defaultLocale);
	}

	public static void SwitchLocale(string locale)
	{
		_inst.LoadXML(locale);
	}

	void LoadXML(string xml)
	{
		if(!Directory.Exists(localeFolder)) return;

		string path = localeFolder + "/" + xml + ".xml";

		if(!File.Exists(path))
		{
			Debug.Log(this + " --> такого файла нет: ../" + localeFolder + "/" + xml + ".xml");
			return;
		}

		subs.Clear();

		XmlTextReader reader = new XmlTextReader(path);

		while(reader.Read())
		{
			if(reader.IsStartElement("sub"))
			{
				Subs data = new Subs();
				data.key = reader.GetAttribute("id");
				data.value = reader.ReadString();
				subs.Add(data);
			}
		}

		reader.Close();
	}

	public static void AddFromText(string text, Color color, FontStyle style)
	{
		_inst.Show_inst(text, color, style);
	}

	public static void AddFromText(string text, FontStyle style)
	{
		_inst.Show_inst(text, Color.white, style);
	}

	public static void AddFromText(string text, Color color)
	{
		_inst.Show_inst(text, color, FontStyle.Normal);
	}

	public static void AddFromText(string text)
	{
		_inst.Show_inst(text, Color.white, FontStyle.Normal);
	}

	public static void AddFromXML(string key, Color color, FontStyle style)
	{
		_inst.Find_inst(key, color, style);
	}

	public static void AddFromXML(string key, FontStyle style)
	{
		_inst.Find_inst(key, Color.white, style);
	}

	public static void AddFromXML(string key, Color color)
	{
		_inst.Find_inst(key, color, FontStyle.Normal);
	}

	public static void AddFromXML(string key)
	{
		_inst.Find_inst(key, Color.white, FontStyle.Normal);
	}

	void Find_inst(string key, Color color, FontStyle style)
	{
		for(int i = 0; i < subs.Count; i++)
		{
			if(subs[i].key == key)
			{
				Show_inst(subs[i].value, color, style);
				break;
			}
		}
	}

	void Show_inst(string text, Color color, FontStyle style)
	{
		if(string.IsNullOrEmpty(text)) return;
		subPanel.gameObject.SetActive(true);
		SubtitlesComponent comp = GetComp();
		comp.gameObject.SetActive(false);
		comp.timeout = 0;
		comp.color = color;
		comp.sub.text = text;
		comp.sub.color = new Color(comp.color.r, comp.color.g, comp.color.b, 0);
		comp.sub.fontStyle = style;

		float posY = 0, size = 0, delta = 0;
		bool panel = true;

		for(int i = 0; i < pool.Count; i++)
		{
			if(pool[i].isActive || pool[i].gameObject.activeSelf)
			{
				pool[i].sub.rectTransform.sizeDelta = new Vector2(pool[i].sub.rectTransform.sizeDelta.x, pool[i].sub.preferredHeight);
				size += pool[i].sub.rectTransform.sizeDelta.y + textOffset;
			}
		}

		deltaPanel = new Vector2(subPanel.rectTransform.sizeDelta.x, size + textOffset);

		for(int i = 0; i < pool.Count; i++)
		{
			if(pool[i].gameObject.activeSelf)
			{
				pool[i].target += new Vector2(0, comp.sub.rectTransform.sizeDelta.y + textOffset);
				panel = false;
			}
		}

		if(panel) subPanel.rectTransform.sizeDelta = deltaPanel;
		delta = comp.sub.rectTransform.sizeDelta.y / 2f;
		posY += delta + textOffset;
		comp.target = new Vector2(0, posY + panelOffset);

		comp.isActive = false;
		comp.gameObject.SetActive(true);
	}

	SubtitlesComponent GetComp()
	{
		SubtitlesComponent comp = null;
		float j = 0;

		for(int i = 0; i < pool.Count; i++)
		{
			if(!pool[i].gameObject.activeSelf && !pool[i].isActive)
			{
				pool[i].isActive = true;
				return pool[i];
			}

			if(!pool[i].isActive && pool[i].timeout > j)
			{
				comp = pool[i];
				j = pool[i].timeout;
			}
		}

		comp.gameObject.SetActive(false);
		comp.sub.rectTransform.anchoredPosition = new Vector2(comp.sub.rectTransform.anchoredPosition.x, panelOffset);
		comp.isActive = true;
		return comp;
	}

	void ReBuildWindow()
	{
		float size = 0;

		for(int i = 0; i < pool.Count; i++)
		{
			if(pool[i].gameObject.activeSelf)
			{
				size += pool[i].sub.rectTransform.sizeDelta.y + textOffset;
			}
		}

		deltaPanel = new Vector2(subPanel.rectTransform.sizeDelta.x, size + textOffset);
	}

	void LateUpdate()
	{
		if(!subPanel.gameObject.activeSelf) return;
		
		bool hide = true;

		for(int i = 0; i < pool.Count; i++)
		{
			if(pool[i].gameObject.activeSelf)
			{
				pool[i].timeout += Time.deltaTime;

				if(pool[i].timeout > textTime)
				{
					pool[i].sub.color = Color.Lerp(pool[i].sub.color, new Color(pool[i].sub.color.r, pool[i].sub.color.g, pool[i].sub.color.b, 0), fadeSpeed * Time.deltaTime);

					if(pool[i].sub.color.a < .1f)
					{
						pool[i].sub.color = Color.clear;
						pool[i].gameObject.SetActive(false);
						pool[i].sub.rectTransform.anchoredPosition = new Vector2(pool[i].sub.rectTransform.anchoredPosition.x, panelOffset);
						ReBuildWindow();
					}
				}
				else
				{
					pool[i].sub.color = Color.Lerp(pool[i].sub.color, pool[i].color, fadeSpeed * Time.deltaTime);
				}

				pool[i].sub.rectTransform.anchoredPosition = Vector2.MoveTowards(pool[i].sub.rectTransform.anchoredPosition, pool[i].target, animSpeed * Time.deltaTime);
				subPanel.rectTransform.sizeDelta = Vector2.MoveTowards(subPanel.rectTransform.sizeDelta, deltaPanel, animSpeed * Time.deltaTime);
				subPanel.color = Color.Lerp(subPanel.color, panelColor, fadeSpeed * Time.deltaTime);
				hide = false;
			}
		}

		if(hide)
		{
			subPanel.color = Color.Lerp(subPanel.color, Color.clear, fadeSpeed * Time.deltaTime);

			if(subPanel.color.a < .1f)
			{
				subPanel.color = Color.clear;
				subPanel.gameObject.SetActive(false);
				subPanel.rectTransform.sizeDelta = new Vector2(subPanel.rectTransform.sizeDelta.x, 0);
			}
		}

		subPanel.rectTransform.anchoredPosition = new Vector2(subPanel.rectTransform.anchoredPosition.x, subPanel.rectTransform.sizeDelta.y / 2f + panelOffset);
	}

	struct Subs
	{
		public string key, value;
	}

	#if UNITY_EDITOR
 	public string CreateExample()
	{
		if(!Directory.Exists(localeFolder)) Directory.CreateDirectory(localeFolder);
		string path = localeFolder + "/Example.xml";

		XmlNode userNode;
		XmlAttribute attribute;
		XmlDocument xmlDoc = new XmlDocument();

		XmlDeclaration declaration = xmlDoc.CreateXmlDeclaration("1.0", "UTF-8", null);
		xmlDoc.AppendChild(declaration);
		XmlNode rootNode = xmlDoc.CreateElement("locale");
		xmlDoc.AppendChild(rootNode);

		XmlComment newComment;
		newComment = xmlDoc.CreateComment(" это образец файла, как должен выглядеть формат локали ");
		xmlDoc.InsertBefore(newComment, rootNode);

		userNode = xmlDoc.CreateElement("sub");
		userNode.InnerText = "Text";
		attribute = xmlDoc.CreateAttribute("id");
		attribute.Value = "key";
		userNode.Attributes.Append(attribute);
		rootNode.AppendChild(userNode);
		xmlDoc.Save(path);

		Debug.Log(this + " создан файл по адресу: " + path);
		return path;
	}
	#endif
}

Собственно здесь и происходит вся магия... Если внимательно посмотреть его функции, то можно обнаружить, что никакой сложности тут нет. Важно не упустить здесь одну деталь, папка для XML файлов localeFolder по умолчанию имеет адрес такой же, как и конечный exe файл приложения, это значит что после сборки игры, вам нужно вручную создать создать эту папку и положить туда XML файлы. Преимущество такого подхода в том, что эти файлы общедоступны, в любой момент можно отредактировать субтитры или даже добавить новые, без необходимости делать новую сборку игры.

Чтобы получить образец XML файла, добавим небольшой скрипт:

#if UNITY_EDITOR
using System.Collections;
using UnityEditor;
using UnityEngine;
[CustomEditor(typeof(Subtitles))]
public class SubtitlesEditor : Editor {

	public override void OnInspectorGUI()
	{
		DrawDefaultInspector();
		Subtitles t = (Subtitles)target;
		if(GUILayout.Button("Создать образец XML файла"))
		{
			string path = t.CreateExample();
			EditorUtility.RevealInFinder(path);
		} 
	}
}
#endif

Теперь в инспекторе скрипта Subtitles появится соответствующая кнопка.

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

Subtitles.AddFromText("мой текст"); // добавить любой текст

Subtitles.AddFromXML("key"); // получить текст по ключу из XML файла

Subtitles.SwitchLocale("Russian"); // загрузить другой XML


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

Внимание! Посетители, находящиеся в группе Гости, не могут скачивать файлы.
Тестировалось на: Unity 2017.3.1
Информация
Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
  • Дешевый хостинг
  • Яндекс.Метрика