Найти в Дзене
Помоги себе сам

Unity. Поговорим о скриптинге

В этой заметке я напишу некоторые моменты по созданию игр на Unity, связанные с программированием, о которых в обучающих роликах частенько не говорят или наоборот критикуют. Опытные разработчики считают это слишком простым, чтобы об этом говорить или непрофессиональным, предлагая как альтернативу что-то избыточное для уровня человека, который делает свою игру как хобби или только учится. Я сам - самоучка и делать игры - моё хобби, поэтому тут я выложу что-то до чего дошёл сам и использую в каждой своей игре, потому что мне так удобно, надеюсь и вам будет полезно. Если кто-то использует более элегантные походы для решения этих задач или чем дополнит заметку - пишите, буду рад. Если вы создадите скрипт с названием GameManager, то заметите что его значок выглядит не так, как у других скриптов, а в виде шестерёнки. Предлагаю именно так называть файл с основной игровой механикой и вешать его на основную камеру. Я периодически слышу, что это антипатерн программирования ("Класс Бога"), но зач

В этой заметке я напишу некоторые моменты по созданию игр на Unity, связанные с программированием, о которых в обучающих роликах частенько не говорят или наоборот критикуют. Опытные разработчики считают это слишком простым, чтобы об этом говорить или непрофессиональным, предлагая как альтернативу что-то избыточное для уровня человека, который делает свою игру как хобби или только учится. Я сам - самоучка и делать игры - моё хобби, поэтому тут я выложу что-то до чего дошёл сам и использую в каждой своей игре, потому что мне так удобно, надеюсь и вам будет полезно. Если кто-то использует более элегантные походы для решения этих задач или чем дополнит заметку - пишите, буду рад.

Если вы создадите скрипт с названием GameManager, то заметите что его значок выглядит не так, как у других скриптов, а в виде шестерёнки.

Предлагаю именно так называть файл с основной игровой механикой и вешать его на основную камеру.

Я периодически слышу, что это антипатерн программирования ("Класс Бога"), но зачем тогда Unity добавил сахар разработчикам, даже просто использующим такое название?

В какой-то момент GameManager может очень неприлично вырасти, так как в играх с одной механикой (платформер, карточная игра, пазл) удобно использовать один класс. Но категорически не удобно работать с файлом в тысячи строк. Допустим вы уже сделали много методов связанных с логикой противников, тогда добавляйте к названию класса GameManager атрибут "partial"

public partial class GameManager : MonoBehaviour

Затем создавайте ещё один скрипт (например с названием AILogicGM.cs) и в нём меняйте объявление класса на

public partial class GameManager

Обратите внимание, что повторять наследование не надо, это как бы просто продолжение основного.

И затем смело переносите в него методы из GameManager (и дальше всё подобное пишите сразу в нём).

Такой подход реализует среда разработки от Microsoft Visual Studio при создании классических приложений Windows, автоматически создавая методы, связанные с интерфейсом, в partial классе.

Также разделяя логику я рекомендую скрипт с общим статический классом

public static class CommonMethods

В нём определяю методы расширения (например перемешивание List), храню значения каких-то промежуточных значений, которые нужны из разных скриптов (например выложенная на стол карта, набор выбранных для боя артерфактов).

Для использования подключаю его в использующий скрипт, например GameManager

using static CommonMethods;

Переменные текущей игровой сессии, например текущее количество набранных очков, также можно хранить в статическом поле статического класса. Но использование статиков для этой цели именно в Unity является антипатерном, так как в движке уже определено доступное ото всюду хранилище переменных PlayerPrefs.
Например если надо увеличить число баллов при пересечении двух объектов (снаряд и мишень, машина и участок трассы) добавляем к ним компонент Collider и ставим в них галочку isTrigger. Объекту который собираем указываем tag, а тому кто считает добавляем скрипт с методом:

OntriggerEnter (Collider other) {
if(other.tag == "TagPredmetaSCollaiderom") {
PlayerPrefs.SetInt("Score", PlayerPrefs.GetInt("Score")++); } }

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

Настройки игры такие как выбранный язык и громкость музыки следует сохранять при выходе из игры. Проще всего и для этого использовать PlayerPrefs.

PlayerPrefs.SetString("PlayerName", "Test");

Часто говорят о том, что их лучше не использовать, так как игрок может сам поменять их с помощью текстового редактора, и в них масса ограничений.

Но Unity (в версии 2020.2) сама добавила в проект например возможность включить смену языка в элементах UI и он хранит эту настройку в PlayerPrefs. Такой подход может быть не удобен для более сложных случаев, например сохранение инвентаря, тогда можно сохранить в файл в формате JSON (я писал об этом раньше).

Кстати пока говорим о настройках игры, то отмечу, что вы наверняка заметили уровни графики в настройках проекта. Можно дать пользователю возможность снизить или повысить, вызывая при смене значения
UI.Dropdown.

QualitySettings.SetQualityLevel(dropdown.value, true);

И потом сохранить его в PlayerPrefs, а загружать в GameManager в методе Awake.

Те или иные настройки есть в большинстве игр, поэтому практически всегда есть панель настроек (или другая), которую можно открыть прямо во время игры. Тогда такая панель настроек (или какие-то иные элементы UI) заслонят игровые объекты. В этом случае клик по верхнему может вызвать событие объекта под ним. В таком случае я добавляю во все подобные места дополнительную проверку (комментарии в коде подсказывают как изменить для разных случаев). На самый верхний кликабельный объект вешать не обязательно.

private bool IsPointingOverUIwithIgnors() {
// на все UI прозрачные для рейкаста надо повесить таг UITransparentForRaycast
// для случая если нет любых UI прозрачных для рейкаста, в метод достаточно было бы написать только одну строку:
// return UnityEngine.EventSystems.EventSystem.current.IsPointerOverGameObject();
PointerEventData pointerEventData = new PointerEventData(EventSystem.current);
pointerEventData.position = Input.mousePosition;
List<RaycastResult> raycastResults = new List<RaycastResult>();
EventSystem.current.RaycastAll(pointerEventData, raycastResults);
//если таги используете для другого и не хотите их захламлять, то можно прикрепить пустой скрипт например с названием MouseUIClickthrough, тогда будет так
//return raycastResults.Select(r => r.gameObject.TryGetComponent(out MouseUIClickthrough component) == false).Count() > 1;
return raycastResults.Select(r => r.gameObject.CompareTag("UITransparentForRaycast") == false).Count() > 1;
}
private async void OnMouseDown() {
if (IsPointingOverUIwithIgnors() == false) {
. . .

Этот код можно добавлять и в обработчики кликов, задаваемые программно. Назначать событие клика программно удобно, например если вы компонуете инвентарь: при открытии панели инвентаря создали её дочернюю кнопку, назначили этой кнопке картинку и обработчик клика (логики или характеристик). Картинка и действие при клике заполняется из заранее заготовленного набора Scriptable Object'ов

existingArtefact.onClick.AddListener(() => { ... } );

Сама тема Scriptable Object'ов хорошо освещена на просторах интернета, но если эта заметка будет читаемая, то напишу гайд как сделать с помощью Scriptable Object'ов панель в UI, которая заполняется способностями, зельями и артефактами по выбору игрока.

На этом наверное советы при работе с UI надо заканчивать - иначе можно менять тему на "советы при работе с UI" или типа того. Предлагаю затронуть тему списков (List), потому что я их очень люблю и часто использую.

Может возникнуть ошибка, когда из List убираешь элемент, но сам лист всё ещё используется. Например, если все враги на карте записаны в List, и ты хочешь добить всех у кого здоровье ниже порога (другие примеры: убрать в отбой карты, продал предмет(ы) из инвентаря). Идея в том что удаление из листа откладывается до конца кадра.

В таких вариантах можно вызывать в цикле foreach (в скобках можно в одну строку отобрать все убираемые элементы с помощью Linq)

foreach (MyType item in list) {
StartCorutine DestroyOnEndOfFrame(<MyType>(list, item));
...
public IEnumerator DestroyOnEndOfFrame<T>(List<T> list, T item) {
yield return new WaitForEndOfFrame();
list.Remove(item); }

И вообще при работе с List рекомендую пользоваться Linq когда надо проверить по какому-либо условию, вместо конструкций foreach c if.

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

Поэтому я взял себе за правило разделять все предпроверки и действия. Пример:

foreach (Card card in enemyHand.Where(q => q.Strength > 1 && q.CanFly))
card.Strength = 1;

Тут могла бы быть ещё пара хороших примеров с Linq, но сейчас я ленюсь писать их сам и прибегаю к помощи ChatGPT, поэтому тут будет совет не игнорировать его.

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

Для отладки в подобных случаях я использую такую конструкцию (для удобства можно обернуть её в отдельный метод):

#if UNITY_EDITOR
Debug.Log($"Соперник хочет избавиться от = {selectedCard.name}");
Debug.Break();
#endif

Тогда в этот момент будет автоматически ставится пауза, если игра запущена в среде разработки Unity.

В этом примере пока я тестирую игру в Unity Editor (для удобства отладки при различных экранах устройств) покрасил области в которых находятся карты игрока, отбоя итд, дополнительно подписал текущие скрытые карты соперника. В момент игры всё выглядит совсем-по другому.
В этом примере пока я тестирую игру в Unity Editor (для удобства отладки при различных экранах устройств) покрасил области в которых находятся карты игрока, отбоя итд, дополнительно подписал текущие скрытые карты соперника. В момент игры всё выглядит совсем-по другому.

Часто слышу что программный код должен хорошо читаться и без комментариев. И соответственно вот такие отладочные фрагменты и комментарии следует чистить перед коммитом (про пользу и практику использования Git и других инструментов совместной разработки даже при работе в одиночку я уже писал тут) или публикацией приложения. Но я бывает возвращаюсь к проекту через долгое время или после выпуска обновления мне каждый раз нужна одна и та же отладочная информация. Поэтому у меня в коде есть комментарии, классы поделены #region описание / #endregion, а отладку я оборачиваю в #if UNITY_EDITOR / #endif.

У меня есть желание ещё поделиться субъективным опытом, но думаю лучше просто напишу потом продолжение и тут появится ссылка на него.