В этой заметке я напишу некоторые моменты по созданию игр на 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.
Часто слышу что программный код должен хорошо читаться и без комментариев. И соответственно вот такие отладочные фрагменты и комментарии следует чистить перед коммитом (про пользу и практику использования Git и других инструментов совместной разработки даже при работе в одиночку я уже писал тут) или публикацией приложения. Но я бывает возвращаюсь к проекту через долгое время или после выпуска обновления мне каждый раз нужна одна и та же отладочная информация. Поэтому у меня в коде есть комментарии, классы поделены #region описание / #endregion, а отладку я оборачиваю в #if UNITY_EDITOR / #endif.
У меня есть желание ещё поделиться субъективным опытом, но думаю лучше просто напишу потом продолжение и тут появится ссылка на него.