Unity — игровой движок, с далеко не нулевым порогом вхождения (сравнивая с тем же Game Maker Studio), и в этой статье я расскажу с какими проблемами столкнулся начиная его изучение, и какие решения этих проблем нашел. Я буду описывать подобные моменты на примере своей 2d игры-головоломки для Android (которая, надеюсь, скоро выйдет в Play Market).
Я не претендую на истину, и не призываю повторять за собой, если вам известен лучший способ, я всего-лишь покажу как делаю сам, и быть может тот, кто только начинает знакомство с Unity, свой шедевр инди геймдева создаст с меньшими трудозатратами.
Я по образованию инженер-проектировщик электростанций, но кодинг всегда меня интересовал, и с некоторыми языками программирования я знаком. Поэтому условимся, что для создания игр на Unity:
- Нужно немного знать С# или JavaScript (хотя бы си-образный синтаксис).
Все, что будет написано далее, это не туториал по Unity, коих в сети и без меня наплодили достаточно. Ниже будут собраны трудные моменты, которые могут встретиться при создании своего первого проекта на Unity.
Стоит предупредить, что в предоставленных скриптах опущена большая часть игровой логики (представляющей «коммерческую тайну»), но их работоспособность в качестве примеров проверена.
Проблема первая — ОРИЕНТАЦИЯ
Блокировка ориентации
Первая возникшая у меня трудность заключалась в том, что я не придавал должного внимания оптимизации визуального интерфейса к ориентации экрана. Решение простейшее — если смена ориентации экрана не нужна для геймплея, то лучше ее заблокировать. Не надо излишней гибкости, Вы пишете инди игру, а не проект по ту сторону миллиона долларов. Зачем тонны условных переходов и смены якорей, если игра лучше смотрится в Portrait (к примеру). Заблокировать ориентацию экрана можно тут:
Edit > Project Settings > Player
Разные разрешения
Также немаловажно протестировать визуальный интерфейс на разных разрешениях в выбранной ориентации, а при тестировании не забыть про существование девайсов с пропорциями 4:3 (ну или 3:4), поэтому смело добавляем 768х1024 (или 1024х768).
Лучшее позиционирование
Для настройки позиционирования и масштаба игровых объектов лучше использовать Rect Transform.
Проблема вторая — КОММУНИКАЦИЯ
Подобная проблема возникла у меня из-за того, что первое знакомство с геймдевом я совершал посредствам Game Maker Studio, где скрипт, это полноценная часть игрового объекта, и он сразу имеет полный доступ ко всем компонентам объекта. У Unity скрипты общие, и к объекту добавляются только их экземпляры. Если говорить упрощенно-образно, скрипт не знает напрямую на каком объекте в данный момент выполняется. Поэтому при написании скриптов нужно учитывать инициализацию интерфейсов работы с компонентами объекта или с компонентами других объектов.
Тренируемся на кошках
В моей игре есть объект GameField, на сцене существует только один его экземпляр, так же есть одноименный скрипт. Объект отвечает за отображение игрового счета и за воспроизведение всего игрового звука, так на мой взгляд экономичнее для памяти (вообще в игре всего три Audio Source — один Background Music, два других Sound Effects). Скрипт решает вопросы хранения игрового счета, выбора AudioClip для воспроизведения звука, и за некоторую игровую логику.
Подробнее остановимся на звуке, так как на этом примере легко показать взаимодействие скрипта с компонентами объекта.
Естественно у объекта должен быть сам скрипт GameField.cs и компонент AudioSource, в моем случае целых два (позже будет понятно зачем).
Как было сказано ранее, скрипт «не в курсе» что у объекта есть компонент AudioSource, потому объявляем и инициализируем интерфейс (пока считаем, что AudioSource только один):
private AudioSource Sound;
void Start(){
Sound = GetComponent<AudioSource> ();
}
Метод GetComponent<тип_компонента>() вернет первый попавшийся компонент указанного типа из объекта.
Кроме AudioSource понадобятся несколько AudioClip:
[Header ("Audio clips")]
[SerializeField]
private AudioClip OnStart;
[SerializeField]
private AudioClip OnEfScore;
[SerializeField]
private AudioClip OnHighScore;
[SerializeField]
private AudioClip OnMainTimer;
[SerializeField]
private AudioClip OnBubbMarker;
[SerializeField]
private AudioClip OnScoreUp;
Здесь и далее команды в квадратных скобках нужны для Inspector`a, подробнее тут.
Теперь у скрипта в Inspector`e появились новые поля, в которые перетаскиваем нужные звуки.
Далее создаем в скрипте метод SoundPlay, принимающий в себя AudioClip:
public void PlaySound(AudioClip Clip = null){
Sound.clip = Clip;
Sound.Play ();
}
Для воспроизведения звука в игре вызываем в нужный момент этот метод с указанием клипа.
Есть один существенный минус данного подхода, воспроизводиться может только один звук одновременно, но в процессе игры может потребоваться воспроизведение двух и более звуков, за исключением фоновой музыки играющей постоянно.
Для недопущения какофонии рекомендую избегать возможности единовременного воспроизведения больше 4-5 звуков (лучше максимум 2-3), я имею в виду именно игровые короткие звуки первого плана (прыжок, монетка, выстрел игрока ...), для фоновых шумов лучше создать собственный источник звука на объекте который издает этот шум (если нужен 2d-3d звук) или один объект отвечающий за весь фоновый шум (если «объем» не нужен).
В моей игре нет необходимости одновременного звучания более двух AudioClip`ов. Для гарантированного воспроизведения обоих гипотетических звуков я добавил к объекту GameField два AudioSource. Чтобы компоненты определились в скрипте, воспользуемся методом
GetComponents<тип_компонента>()
который возвращает массив всех компонентов указанного типа из объекта.
Код будет выглядеть так:
private AudioSource[] Sound; // Добавились квадратные скобки
void Start(){
Sound = GetComponents<AudioSource> (); // Теперь GetComponents
}
Больше всего изменения коснутся метода PlaySound. Я вижу два варианта этого метода «универсальный» (под любое количество AudioSource в объекте) и «топорный» (для 2-3 AudioSource, не самый элегантный но менее ресурсоемкий).
«Топорный» вариант для двух AudioSource (я воспользовался им)
private void PlaySound(AudioClip Clip = null){
if (!Sound [0].isPlaying) {
Sound [0].clip = Clip;
Sound [0].Play ();
} else {
Sound [1].clip = Clip;
Sound [1].Play ();
}
}
Можно растянуть на три и более AudioSource, но количество условий пожрет всю экономию производительности.
«Универсальный» вариант
private void PlaySound(AudioClip Clip = null){
foreach (AudioSource _Sound in Sound) {
if (!_Sound.isPlaying) {
_Sound.clip = Clip;
_Sound.Play ();
break;
}
}
}
Обращение к чужому компоненту
На игровом поле несколько экземпляров префаба Fishka, вроде игровой фишки. Она построена так:
- Родительский объект со своим SpriteRenderer;
- Дочерние объекты со своими SpriteRenderer.
Дочерние объекты отвечают за прорисовку тела фишки, ее цвета, дополнительных изменяемых элементов. Родительский объект рисует вокруг фишки окантовку-маркер (по игре необходимо выделять активную фишку). Скрипт только на родительском объекте. Таким образом для управления дочерними спрайтами нужно родительскому скрипту указать эти спрайты. Я организовал так — в скрипте создал интерфейсы для доступа к дочерним SpriteRenderer:
[Header ("Graphic objects")]
public SpriteRenderer Marker;
[SerializeField]
private SpriteRenderer Base;
[Space]
[SerializeField]
private SpriteRenderer Center_Red;
[SerializeField]
private SpriteRenderer Center_Green;
[SerializeField]
private SpriteRenderer Center_Blue;
Теперь у скрипта в Inspector`e появились дополнительные поля:
Перетащив дочерние элементы в соответствующие поля получаем в скрипте доступ к ним.
Пример использования:
void OnMouseDown(){ // Естественно не забываем добавить объекту компонент коллайдера
Marker.enabled = !Marker.enabled;
}
Обращение к чужому скрипту
Помимо манипуляции с чужими компонентами можно обратиться и к скрипту стороннего объекта, работать с его Public переменными, методами, сабклассами.
Приведу пример на уже известном объекте GameField.
У скрипта GameField есть публичный метод FishkiMarkerDisabled(), который нужен для «снятия» маркера со всех фишек на поле и используется в процессе установке маркера при клике по фишке, так как активной может быть только одна.
В скрипте Fishka.cs SpriteRenderer Marker является публичным, то есть к нему можно получить доступ из другого скрипта. Для этого в скрипте GameField.cs добавим объявление и инициализацию интерфейсов для всех экземпляров класса Fishka (при создании скрипта в нем создается одноименный класс) наподобии как это сделано для нескольких AudioSource:
private Fishka[] Fishki;
void Start(){
Fishki = GameObject.FindObjectsOfType (typeof(Fishka)) as Fishka[];
}
public void FishkiMarkerDisabled(){
foreach (Fishka _Fishka in Fishki) {
_Fishka .Marker.enabled = false;
}
}
В скрипте Fishka.cs добавим объявление и инициализацию интерфейса экземпляра класса GameField и при клики по объекту будем вызывать метод FishkiMarkerDisabled() этого класса:
private GameField gf;
void Start(){
gf = GameObject.FindObjectOfType (typeof(GameField)) as GameField;
}
void OnMouseDown(){
gf.FishkiMarkerDisabled();
Marker.enabled = !Marker.enabled;
}
Таким образом можно взаимодействовать между скриптами (правильнее сказать классами) разных объектов.
Проблема третья — ХРАНИТЕЛИ
Хранитель счета
Как только в игре появляется что-то типа счета, следом сразу возникает проблема его хранение, как в процессе игры так и вне ее, так же хочется сохранить рекорд, чтобы стимулировать игрока превзойти его.
Я не буду рассматривать варианты, когда вся игра (меню, игра, вывод счета) построена в одной сцене, так как, во-первых, это не лучший путь построения первого проекта, во-вторых, на мой взгляд, начальная загрузочная сцена быть должна. Потому условимся, что в проекте четыре сцены:
- loader — сцена в которой происходит загрузка сохраненных настроек, объекта фоновой музыки (подробнее будет позже);
- menu — сцена с меню;
- game — сцена игры;
- score — сцена вывода счета, рекорда, таблицы лидеров.
Примечание: Порядок загрузки сцен устанавливается в File > Build Settings
Набранные в процессе игры очки хранятся в переменной Score класса GameField. Чтобы иметь доступ к данным при переходе на сцену scores создадим публичный статический класс ScoreHolder, у которого объявим переменную для хранения значения и свойство для получения и установки значения этой переменной (способ подсмотрел у apocatastas):
using UnityEngine;
public static class ScoreHolder{
private static int _Score = 0;
public static int Score {
get{
return _Score;
}
set{
_Score = value;
}
}
}
Публичный статический класс не нужно добавлять к какому-либо объекту, он сразу доступен в любой сцене из любого скрипта.
Пример использования в классе GameField в методе перехода на сцену scores:
using UnityEngine.SceneManagement;
public class GameField : MonoBehaviour {
private int Score = 0;
// В процессе игры счет увеличился, но игрок проигрывает и нужно перейти на сцену Scores
void GotoScores(){
ScoreHolder.Score = Score; // новое значение ScoreHolder.Score доступно везде
SceneManager.LoadScene (“scores”);
}
}
Таким же образом в ScoreHolder можно добавить и хранение в течении игры рекордного счета, но при выходе он сохраняться не будет.
Хранитель настроек
Рассмотрим на примере сохранения значения булевой переменной SoundEffectsMute, в зависимости от состояния которой в игре есть или нет звуковые эффекты.
Сама переменная хранится в public static class SettingsHolder:
using UnityEngine;
public static class SettingsHolder{
private static bool _SoundEffectsMute = false;
public static bool SoundEffectsMute{
get{
return _SoundEffectsMute;
}
set{
_SoundEffectsMute = value;
}
}
}
Класс похож на ScoreHolder, можно было вообще объединить их в один, но на мой взгляд это моветон.
Как видно из скрипта, по умолчанию _SoundEffectsMute объявляется со значением false, таким образом при каждом запуске игры SettingsHolder.SoundEffectsMute будет возвращать false независимо от того, изменил его пользователь ранее или нет (изменяется при помощи кнопки на сцене menu).
Самым оптимальным для Android приложения, будет использование для сохранения метод PlayerPrefs.SetInt (подробнее в официальной документации). Есть два варианта сохранить значение SettingsHolder.SoundEffectsMute в PlayerPrefs назовем их «простой» и «элегантный».
«Простой» способ (у меня так) — в методе OnMouseDown() класса вышеупомянутой кнопки. Загрузка сохраненного значения производится в том же классе но в методе Start():
using UnityEngine;
public class ButtonSoundMute : MonoBehaviour {
void Start(){
// Все преобразование от того, что PlayerPrefs не работает с bool
switch (PlayerPrefs.GetInt ("SoundEffectsMute")) {
case 0:
SettingsHolder.SoundEffectsMute = false;
break;
case 1:
SettingsHolder.SoundEffectsMute = true;
break;
default: // Прикрой тылы сделай default
SettingsHolder.SoundEffectsMute = true;
break;
}
}
void OnMouseDown(){
SettingsHolder.SoundEffectsMute = !SettingsHolder.SoundEffectsMute;
// Все преобразование от того, что PlayerPrefs не работает с bool
if (SettingsHolder.SoundEffectsMute)
PlayerPrefs.SetInt ("SoundEffectsMute", 1);
else
PlayerPrefs.SetInt ("SoundEffectsMute", 0);
}
}
«Элегантный» способ, на мой взгляд, не самый правильный, т.к. усложнит сопровождение кода, но в нем что-то есть, и я не могу им не поделиться. Особенностью этого способа является то, что сеттер свойства SettingsHolder.SoundEffectsMute вызывается в момент, не требующий высокой производительности, и его можно нагрузить (о, ужас) использованием PlayerPrefs (читай — записью в файл). Изменим public static class SettingsHolder:
using UnityEngine;
public static class SettingsHolder
{
private static bool _SoundEffectsMute = false;
public static bool SoundEffectsMute{
get{
return _SoundEffectsMute;
}
set{
_SoundEffectsMute = value;
if (_SoundEffectsMute)
PlayerPrefs.SetInt ("SoundEffectsMute", 1);
else
PlayerPrefs.SetInt ("SoundEffectsMute", 0);
}
}
}
Метод OnMouseDown класса ButtonSoundMute упростится до:
void OnMouseDown(){
SettingsHolder.SoundEffectsMute = !SettingsHolder.SoundEffectsMute;
}
Нагружать геттер чтением из файла не стоит, поскольку он задействован в процессе критичном к производительности — в методе PlaySound() класса GameField:
private void PlaySound(AudioClip Clip = null){
if (!SettingsHolder.SoundEffectsMute) {
// Не забудь про возможность использовать “универсальный” метод (см. выше)
if (!Sound [0].isPlaying) {
Sound [0].clip = Clip;
Sound [0].Play ();
} else {
Sound [1].clip = Clip;
Sound [1].Play ();
}
}
}
Вышеописанным способом можно организовать внутриигровое хранение любых переменный.
Проблема пятая — ОДНА ДЛЯ ВСЕХ
Эта музыка будет вечной
С такой проблемой рано или поздно сталкиваются все, и я не стал исключением. По задумке фоновая музыка начинает играть еще в сцене menu, и если она не отключена, то не прерываясь играет на сценах menu, game и scores. Но если объект «играющий» фоновую музыку установлен на сцене menu, при переходе к сцене game он уничтожается и звук исчезает, а если такой же объект поместить и на сцену game, то после перехода музыка играет сначала. Решением оказалось использование метода DontDestroyOnLoad(Object target) помещенного в метод Start() класса, экземпляр скрипта которого есть у «музыкального» объекта. Для этого создадим скрипт DontDestroyThis.cs:
using UnityEngine;
public class GameField : MonoBehaviour {
void Start(){
DontDestroyOnLoad(this.gameObject);
}
}
Чтобы все заработало «музыкальный» объект должен быть корневым (на том же уровне иерархии, что и главная камера).
Почему фоновая музыка в loader
На скриншоте видно, что «музыкальный» объект расположен не на сцене menu а на сцене loader. Это мера вызванная тем, что сцена menu может загружаться не один раз (после сцены scores переход к сцене menu), и при каждой ее загрузке будет создаваться очередной «музыкальный» объект, а старый не будет удаляться. Можно сделать как в примере официальной документации, я же решил воспользоваться тем, что сцена loader гарантировано загружается только один раз.
На этом, ключевые проблемы, с которыми я сталкивался при разработке своей первой игры на Unity, до выгрузки в Play Market (еще не зарегистрировал аккаунт разработчика), благополучно закончились.