Структура кода в Unity3d — личное мнение и пара трюков

в 18:19, , рубрики: application structure, game development, unity3d, Программирование

image
Хотелось бы поделиться личными впечатлениями о разработке мобильных игр на основе Unity3d. Изначально думал уместить в одном посте все мелкие «Tip&Trick» с которыми столкнулся при работе с Unity3d за последнее время. Но их оказалось черезчур много. Так что в этом посте будут только те, которые касаются непосредственно написания кода.

Главная тема поста — разделение классов по «слоям», связывание их через события и чуть-чуть о том, как наладить взаимодействие объектов на сцене.
Кому интересно — добро пожаловать под кат!

Структура приложения

image
Изначально никакой структуры я не продумывал — слишком плохо знал Unity, не понимал, что можно от чего отделить, что объединить и как это связать. Однако, в процессе разработки в приложении как-то сами собой выделились следующие слабосвязанные между собой слои:

  1. Интерфейс — сюда вошли всякие меню и средства управления.
  2. Игровые объекты — все, что находится на сценах уровней и связано непосредственно с геимплеем.
  3. Состояние игры — это те классы, которые отвечают за процесс прохождения игры. Какой уровень игрок прошел? За какое время? Сколько очков набрал? Какой уровень ему нужно загружать сейчас? Нужно ли разблокировать какой-либо контент? И т. п.
  4. Взаимодействие с сервисами Google – классы, отвечающие за постинг результата и разблокирование ачивок.
  5. Реклама — классы, отвечающие за показ рекламы.
  6. Социальные сервисы — постинг в Facebook, ВКонтакте и т.п.

Данный список — это личный пример в случае с парой конкретных приложением. В других приложениях он может сильно отличаться. Но общая идея по нему будет понятна.

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

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

Дело в том, что чтобы подписаться на события конкретного объекта в сцене нужно иметь ссылку на него. А это добавляет кучу лишних связей и идея разделения «слоев» теряется.
В ряде случаев решение оказалось еще проще — достаточно было добавить синглтон для общения со слоем, и взаимодействие сильно упрощалось.
Далее постараюсь на примерах описать, как это работает в каждом слое.

Интерфейс

image
Туториалы по Unity полны вот таких примеров:

public class ExampleClass : MonoBehaviour {
    public GameObject projectile;
    public float fireRate = 0.5F;
    private float nextFire = 0.0F;
    void Update() {
        if (Input.GetButton("Fire1") && Time.time > nextFire) {
            nextFire = Time.time + fireRate;
            Instantiate(projectile, transform.position, transform.rotation) as GameObject;
        }
    }
}

Этот, кстати, из официальной документации.
Что здесь не так?

Во время разработки и отладки в Unity пользоваться кнопками очень удобно. Но при переходе на мобильное устройство они заменяются на элементы интерфейса. Если завязаться на кнопки — придется потом редактировать класс игрового объекта. К тому же, что делать, если на нажатие кнопки должны реагировать несколько объектов?
Оказалось удобнее создать отдельный класс, например, UserInterface, в нем объявить глобальное событие OnFireButtonClick, и подписываться на него везде, где нужно.

Основная идея по интерфейсу такая: не делать обработку Input в игровых объектах. Сделать отдельные объекты интерфейса, которые будут принимать все команды от пользователя, а в игровых объектах подписываться на события. События лучше порождать в синглтоне, т. к. конкретные объекты интерфейса, могут меняться, и это не должно влиять на игровые объекты.
Бррр… Как-то сумбурно получилось, но, надеюсь общую идею я донес.

Кстати, если подписываетесь на события, не забудьте добавить «отписку»:

void OnDestroy()
    {
        <ваше_событие> -= <ваш_обработчик>;
    }

А то будет забавная ситуация, когда сцену закрыли, а объект живет и обрабатывает event'ы. Да и сборщик мусора корректно не отработает. Не отписываться можно только в том случае, если объект порождающий событие находится в той же сцене, что и обработчик и будет удален вместе с ним.

Тут непонятная ситуация — я предполагал, что закрытие сцены означает уничтожение всех ее объектов и автоматическую «отписку» от всего. Оказалось, что это не так. Больше похоже на то, что приложение просто выходит из блока, отвечающего за сцену. Предполагается, что на объекты больше нет ссылок и, их удалит сборщик мусора. Не понял, баг это или фича. Если кто понимает суть этой ситуации — поделитесь, плизз, знаниями. Очень любопытно.

Игровые объекты

image
Это все, что бегает, прыгает, крутится, стреляем и взаимодействует между собой на нашей сцене.
Для их общения Unity предлагает несколько подходов.

1. Связать их через public свойства. Выглядит это примерно так:

public class PlatformMoveController : MonoBehaviour {
	
	public GameObject Player;
	...		
	 void OnCollisionStay(Collision col) 
	{
		if (col.gameObject.name == Player.name)
			onTheGround = true;
	}
	...
}

Минус в том, что нам нужно связывать объекты между собой. А если мы их динамически генерируем?

Тут нам на помощь приходит второй подход:

2. Найти объект на сцене по имени или тегу. Например:

private GameObject _car;
...
void Start () 
	{
	_car = GameObject.Find ("Car");
	if (_car == null)
		throw new UnityException ("Cannot find 'Car' object!");
	_car.OnCrash += DoSomething();
	}
…

Минус этого способа виден в самом примере — нужного объекта в сцене может и не оказаться. Хорошо, если его там вообще нет, и мы можем ничего не делать, а что, если его туда добавили, но с другим именем?

Тут опять можем обойти через статическое событие. Если каждый инстанс Car будет пробрасывать свое столкновение через статическое событие класса (CarManager), нам не придется искать объект или привязываться к нему.

Получится что-то вроде:

class Car
{
	void Crash()
	{
	...
	CarManager.CrashCar();
	}
}

public static class CarManager
{
	public static void CrashCar()
	{
	if (OnCarCrash != null)
		OnCarCrash();
	}
}

Обработчик:

void Start () 
{
	CarManager.OnCarCrash += DoSomething();
}
void OnDestroy()
{
 	CarManager.OnCarCrash -= DoSomething();
}

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

Состояние игры

Состояние игры — это обобщенное название классов, отвечающих за фиксацию прохождения уровней, загрузку следующих, набранные очки и т. п.
Главное, что тут нужно учесть — не нужно делать Application.LoadLevel(<имя_сцены>); из игровых объектов — рано или поздно, вам потребуется ввести дополнительные проверки, подсчеты, промежуточные экраны и т. п. Лучше вынести все это в отдельный класс. Туда же можно вынести команды паузы и восстановления игры и т. п.

Например

public static class GameController
{
	public static event EmptyVoidEventType OnPause;
	public static event EmptyVoidEventType OnResume;
	public static event EmptyVoidEventType OnReplay;

	public static void PauseGame()
	{
		if (OnPause != null)
			OnPause ();
	}
	public static void ResumeGame()
	{
		Time.timeScale = 1;
		if (OnResume != null)
			OnResume ();
	}
	public static void ReplayGame()
	{
		LevelManager.LoadLastLevel();
		if (OnReplay != null)
			OnReplay ();
	}
	public static void FinishLevel ()
	{
	...
	}
	public static void LoadLastLevel ()
	{
	...
	}
	public static void GoToMenu()
	{
	...
	}
	...
}

Т.е. в сцене уже не придется заботиться о том, что нужно делать при завершении уровня и какое имя было у предыдущей сцены, если игрок вдруг захочет ее переиграть. Просто вызываем класс-менеджер и все.
Аналогично, если нам нужно в сцене реагировать на приостановку игры, например, проставить Time.timeScale = 0; достаточно подписаться на соответствующее событие.
Все эти мелочи сильно упрощают разработку.

Взаимодействие с сервисами Google

image
Google расщедрился на замечательные сервисы, которые можно вызывать из мобильной игрушки. Теперь не нужно писать собственные лидерборды, ачивки и многое другое. К тому же, эти сервисы развиваются и обещают стать какой-то супер-пупер-вундервафлей.
Учитывая все это, выглядит логичным не размазывать их вызов тонким слоем по всему приложению, а сконцентрировать в одном-двух класс, связав с остальными частями приложения через события. Это позволит развивать «социальную» составляющую игры не сильно влияя на геимплей.

Маленький трюк, как быстро и просто проверить, есть ли связь с гуглом

public static bool HasConnection()
    {
        try
        {
            using (var client = new WebClient())
            using (var stream = new WebClient().OpenRead("http://www.google.com"))
            {
                return true;
            }
        }
        catch
        {
            return false;
        }
    }

Не уверен, что это наилучший вариант, но выглядит наиболее простым и однозначным.

Реклама

image
Каждый провайдер рекламы предоставляет свои инструменты для показа. Разбираться в игровых классах со всем этим зоопарком нет никакого желания. Я для себя сделал простенький статически класс AdsController, с одним публичным методом: ShowBanner() и вызываю его там, где хочу показать рекламу. Вся работа с AdMob спрятана внутри и, если я решу перейти на что-то другое, лазить по коду и отлавливать вызовы не придется. Мелочь, а приятно.
Завязаться так же можно через события, но в моем случае больше подошел синглтон, т. к. интерфейс вызова рекламного блока я вряд ли буду менять, а вот игровые события могут измениться.

Маленький трюк для сохранения нервных клеток игрока

public static class AdsController
{	
    static DateTime lastAdCloseTime;
    static TimeSpan AdsInterval = new TimeSpan(0,3,0);
...
// Это событие - обработчик успешно закрытия показанного баннера
    static void AdClosed(object sender, System.EventArgs e)
    {
        lastAdCloseTime = DateTime.Now;        
    }

    public static void ShowBanner()
    {       
        if (DateTime.Now - lastAdCloseTime > AdsInterval)
            <показать_рекламу>
    }
	...
}

Т.е. вводим ограничение на частоту успешных показов. Актуально для показа баннеров. Например, я показываю их при завершении уровня или гибели. Но первые уровни проходятся за десяток секунд. Чтобы не нервировать игрока, добавил такое ограничение.
Главное — учитывать именно успешные показы. А то при глюках с соединением игрок рекламу не увидит никогда и будет огорчен тем, что не отблагодарил разработчика своим вниманием.

Социальные сервисы

Этот «слой» по своей роли очень похож на рекламный, но суть его еще проще — все команды для социальных сетей собрать в кучу, наружу выставить один статический класс с методами: PostToFacebook, PostToVk и т. п. Все, что можно спрятать — спрятать внутри классов. Через параметры передавать минимум. У меня он свелся к одному параметру — количеству набранных очков. В принципе, использование этого класса сводится просто к вызову нужного метода, когда пользователь жмет кнопку «Поделиться».
В таком виде его можно таскать из проекта в проект практически без изменений.

Заключение

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

Если есть интерес, могу рассказать про шишки, которые я набил при импорте 3d объектов и выстраивании структуры проекта.

Предыстория поста для любопытных

Около года назад я писал на Хабр о том, как разработка мобильных игр стала моим хобби: GameDev как хобби. После этого поста я еще много узнал и много на чем «обжегся». После каждого «неожиданного» опыта было желание сесть и написать о нем, но было либо лень, либо хотелось получше проверить идею, а потом ею уже делиться.
Установил для себя «дидлайн» — обновить игрушку до второй версии и сесть за написание поста. Но т. к. параллельно пошел другой проект и на работе был напряг, затянулся этот процесс на полгода. Поэтому мыслей было много, и пост получился довольно сумбурным.

Автор: VlaZ

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js