Как ни странно, читы это важная часть игры, которая сильно помогает в её разработке, а не то что вы подумали.
Конами код - это, наверное, самый известный чит код в истории видеоигр. Он был создан Кадзухисой Хасимото во время портирования игры Gradius на консоль NES. Код был добавлен, так как Кадзухиса посчитал, что игра слишком сложна и при его введении можно было получить полное вооружение, впоследствии его имплементировали в сотни игр для самых разнообразных целей.
В новой игре, буквально с добавлением первой механики, может возникнуть ситуация аналогичная с Gradius. Её тестирование будет занимать довольно большое количество времени, в качестве примера, если ваша игра состоит из десятков уровней, то навряд ли ради тестирования 52 уровня вы пройдёте всю игру сначала, чтобы выяснить, что какой-нибудь элемент на карте расположен на 5 пикселей левее положенного места. Именно для решения таких проблем обычно в игру добавляют читы, которые помогают срезать углы и создать необходимые условия для проверки механик или других игровых элементов.
Чит панель
Если отнестись к этой теме не очень серьёзно, то встраивание читов начнётся с простых механизмов, например, по клику на иконку с жизнями, полностью их восстановить или, при нажатии “Ctrl + M”, начислить сто монет. Впоследствии это выливается в то, что будет довольно сложно запомнить все эти неочевидные комбинации и места, что говорить о новых на проекте людях, которые просто не будут о них знать. Ещё сложнее будет вычистить и проверить такие места при сборке релиза перед выпуском.
Элегантным решением данного вопроса служат чит панели, которые фактически стали стандартом для подобных механик. Они предоставляют возможность централизованного управления и информирования о состоянии игры, исключены из нормального взаимодействия с интерфейсами, остаются такие мелочи как: сделать их дешёвыми в разработке, легко исключаемыми из финальной сборки и встроить в механики игры.
Чит панель из Conan Exiles:
На основании всего вышесказанного можно составить список необходимых для создания чит панелей инструментов:
Инструмент для создания UI
Логика появления-скрытия панели
Механизм исключения из финальной сборки
Immediate Mode GUI (IMGUI)
Ответом на первое требование будет основное средство создания служебных интерфейсов в Unity, а именно Immediate Mode GUI (IMGUI). Этот же инструмент используется и для создания кастомных компонентов редактора самого Unity, о которых мы рассказывали в одной из наших предыдущих статей. Это довольно хорошо документированный и не очень большой пакет, ознакомление с его возможностями сильно облегчит вашу жизнь как при взаимодействии с редактором, так и при создании служебных инструментов.
Вызов панели
Учитывая, что панель не должна взаимодействовать с основным UI, так как это не самое очевидное поведение и может сказаться на финальной сборке продукта, то её вызов становится интересной задачей. На устройствах с клавиатурой самым очевидным способом выглядит добавление комбинации клавиш, которые покажут панель, однако, на мобильном устройстве решение не сразу бросается в глаза. Одним из вариантов является добавление на экран области отслеживающей долгий клик или тап.
Условная компиляция
В зависимости от размера проекта и команды, работающей над ним, проект может находиться на разных уровнях своего развития. Если представить, что проект находится на начальном уровне, то вполне допустимым методом решения данного вопроса выглядит обычное комментирование кусков кода. Конечно же, это очень опасно, некрасиво, долго, в общем, никогда так не делайте если это пойдёт хоть в какой-то продакшн.
Интересным предложением, которое можно встретить на просторах комьюнити, выглядит добавление скриптов в папку Editor. Они не будут включены в финальную сборку, а проверку на возможность использования можно выстроить на основе метода Type.GetType(“type_name”). Конечно, этот механизм предназначен для других задач, а также не предоставит возможность протестироваться на устройстве, но определённо является самым простым и быстрым из доступных.
Основным же средством исключения читов из сборки остаётся старая добрая условная компиляция с применением препроцессорных символов. Фактически есть два способа их добавления:
Платформозависимый - через настройки проекта Edit > Project Settings... > Other Settings > Scripting Define Symbols
Глобальный - через добавление в папку Assets файла mcs.rsp/csc.rsp(в зависимости от компилятора) с параметром -define:CUSTOM_NAME
Настройка через проект является приоритетным способом. Самым большим минусом которого является необходимость копирования всех значений на каждую из используемых платформ. Если говорить про возможность автоматизации, то будет необходимо добавить в проект статический метод, который во время сборки будет передаваться через аргумент -executeMethod и в случае необходимости добавлять символы через вызов PlayerSettings.SetScriptingDefineSymbolsForGroup (BuildTargetGroup.iOS, “DEBUG_CHEATS”);.
Глобальная настройка сработает для всех скриптов, за исключением содержащихся в папках Editor и тоже является допустимым методом. Однако, она представляет бо́льшую опасность, так как файлы mcs.rsp и csc.rsp могут содержать в себе другие параметры для настройки компилятора и может потребоваться создание дополнительных инструментов для внесения параметров в случае автоматической сборки.
Исключение кода на основе препроцессорных символов также можно разделить на два способа:
Использование в коде директив #if define_name, #elif define_name, #else и #endif.
Использование Assembly Definitions
Директивы самый простой и доступный вариант, который проще всего применить на существующей базе кода.
Assembly Definitions вносит больше требований к архитектуре кода и применению различных паттернов, так как разделение на библиотеки требует более ответственного отношения к коду. Однако их использование ценно само по себе и может сильно упростить жизнь на больших проектах, ускорить пересборку проекта и сделать более удобным тестирование, как в нашей статье о юнит-тестах. Еще хочется отметить, что для разных assembly можно использовать и разные mcs/csc файлы, что может упростить автоматизацию.
Тестовый проект
Для примера был создан проект, содержащий два текстовых элемента, кнопку и простую механику с монетками.
Изначально есть максимум пятьсот монет, по клику на кнопку снимается сто монет, каждые три секунды сто монет восстанавливается.
Реализация
Учитывая всё выше перечисленное, реализация будет выглядеть как панель написанная при помощи IMGUI, которая будет появляться по долгому клику(3 секунды) в левой нижней части экрана, а в коде будут использоваться директивы препроцессора #if и #endif, которые мы включим при помощи настройки в проекте.
usingSystem;usingSystem.Collections;usingUnityEngine;usingUnityEngine.UI;publicclassCoinsHandler:MonoBehaviour{// UI элементы
[SerializeField]publicTextcoinsTitle=null; [SerializeField]publicTextcountdownTitle=null; [SerializeField]publicButtonspendButton=null;// Характеристики для механики с монетками
publicconstintMaxCoinsCount=500;privateconstintSpendCoinsCount=100;privateint_coinsCount;publicintCoinsCount=>_coinsCount;// Таймер для восстановления монеток
privatereadonlyTimeSpan_timeToRestore=newTimeSpan(0,0,3);privateDateTime_restoreCoinsTimeMark;publicDateTimeRestoreCoinsTimeMark=>_restoreCoinsTimeMark;// Отдельная перерисовка UI элементов
privatebool_updateUi=true;voidStart(){// Выставляем начальное значение для монет
_coinsCount=MaxCoinsCount;// Запускаем таймер для проверки монет
StartCoroutine(UpdateTimer());// При наличии флага DEBUG_CHEATS, создаём объект, отвечающий за читы
#if DEBUG_CHEATS
GameObjectcheatsHandler=newGameObject{name="CheatsHandler"};cheatsHandler.AddComponent<CheatsHandler>();#endif
}// Обновление UI
privatevoidLateUpdate(){if(_updateUi){_updateUi=false;UpdateUi();}}// Обновление UI
privatevoidUpdateUi(){coinsTitle.text="Coins: "+_coinsCount;// Таймер отображается только в случае, когда монет меньше максимума
countdownTitle.gameObject.SetActive(_coinsCount<MaxCoinsCount);if(_coinsCount<MaxCoinsCount){// Разница между будущим временем начисления монет и текущим
TimeSpantimeDiff=_restoreCoinsTimeMark-DateTime.UtcNow;countdownTitle.text="Countdown: "+timeDiff.ToString("mm\\:ss");}// Кнопка активна в случае возможности списания монет
spendButton.interactable=CanSpendCoins();}// Таймер обновления UI
IEnumeratorUpdateTimer(){while(true){CheckCoinsTimer();_updateUi=true;yieldreturnnewWaitForSecondsRealtime(1);}}// Функция снимает монеты
publicvoidSpendCoins(){if(CanSpendCoins()){SetCoinsTo(_coinsCount-SpendCoinsCount);}}// Проверка на достаточное количество монет
publicboolCanSpendCoins(){return_coinsCount>=SpendCoinsCount;}// Функция начисления монет
publicvoidGetCoins(){if(_coinsCount<MaxCoinsCount){SetCoinsTo(_coinsCount+SpendCoinsCount);}}// Функция проверки таймера начисления монет
privatevoidCheckCoinsTimer(){// Текущее время
DateTimetimeNow=DateTime.UtcNow;// Если текущее время больше отметки для начисления и монет меньше максимума
if(timeNow>=_restoreCoinsTimeMark&&_coinsCount<MaxCoinsCount){GetCoins();// Сдвигаем отметку начисления монет
_restoreCoinsTimeMark+=_timeToRestore;}}// Унифицированная функция для выставления количества монет
publicvoidSetCoinsTo(intcount){if(_coinsCount>=MaxCoinsCount){DateTimetimeNow=DateTime.UtcNow;_restoreCoinsTimeMark=timeNow+_timeToRestore;}_coinsCount=count;_updateUi=true;}}
#if DEBUG_CHEATS
usingSystem;usingSystem.Globalization;usingUnityEngine;publicclassCheatsHandler:MonoBehaviour{// Прямоугольник для чит панели
privatereadonlyRect_cheatsBoxRect=newRect(10,10,Screen.width-20,200);// Строка ввода количества монет
privatestring_coinsInput="0";// Объект с механикой монет
privateCoinsHandler_coinsHandler=null;// Флаг отображения чит панели
privatebool_showPanel=false;// Таймер активации панели
privateconstfloatTimeToActivate=3;privatefloat_activationTimeCounter=0;// Прямоугольник для проверки активации
privateRect_checkRect=newRect(0,0,50,50);privatebool_buttonPressed=false;voidStart(){// Информирование о присутствии читов
Debug.LogWarning("Debug cheats enabled");_coinsHandler=FindObjectOfType<CoinsHandler>();}voidUpdate(){// Увеличиваем таймер клика по зоне активации панели
if(_buttonPressed){_activationTimeCounter+=Time.deltaTime;}// При достаточно долгом клике показываем панель
if(_activationTimeCounter>=TimeToActivate){_showPanel=true;}// Сочетание клавиш для показа панели
if(Input.GetKey(KeyCode.LeftAlt)&&Input.GetKey(KeyCode.C)){_showPanel=true;}// Отслеживаем начало нажатия в зоне активации панели
if(Input.GetMouseButtonDown(0)&&_checkRect.Contains(Input.mousePosition)){_buttonPressed=true;}// Сбрасываем информацию по нажатию
if(Input.GetMouseButtonUp(0)){_buttonPressed=false;_activationTimeCounter=0;}/*
// Отслеживаем начало нажатия пальцем в зоне активации панели
if (Input.touchCount > 0)
{
Touch touch = Input.GetTouch(0);
if (_checkRect.Contains(touch.position))
{
_buttonPressed = true;
}
}
// Сбрасываем информацию по нажатию
if (Input.touchCount == 0 && _buttonPressed)
{
_buttonPressed = false;
_activationTimeCounter = 0;
}*/}// Основная функция отрисовки чит панели
privatevoidOnGUI(){// Не рисуем панель без активации
if(!_showPanel){return;}// Основной объект панели
GUI.Box(_cheatsBoxRect,"Cheats panel");// Начало зоны ограничивающей элементы размерами панели
GUILayout.BeginArea(newRect(20,35,Screen.width-40,180));// Начало вертикального размещения элементов
GUILayout.BeginVertical();// Отображение количества жизней с пометкой достижения максимума
GUILayout.Label("Lives count: "+_coinsHandler.CoinsCount+(_coinsHandler.CoinsCount>=CoinsHandler.MaxCoinsCount?" (Max)":""));// Отображение текущего времени
GUILayout.Label("Current time: "+DateTime.UtcNow.ToString("G",CultureInfo.InvariantCulture));// Расчёт разницы между текущим временем и временем начисления монет
TimeSpantimeDiff=_coinsHandler.RestoreCoinsTimeMark-DateTime.UtcNow;// Добавляем - если время начисления уже пройдено
stringminus=timeDiff<TimeSpan.Zero?"-":"";// Отображаем точное время начисления и разницу в минутах:секундах
GUILayout.Label("Restore time: "+_coinsHandler.RestoreCoinsTimeMark.ToString("G",CultureInfo.InvariantCulture)+" ("+minus+timeDiff.ToString("mm\\:ss")+")");// Начало горизонтального размещения элементов
GUILayout.BeginHorizontal();// Поле ввода количества монет для зачисления
stringtestStr=GUILayout.TextField(_coinsInput);// Обновления текста в поле ввода в случае его валидности
if(IsDigitsString(testStr)){_coinsInput=testStr;}// Зачисление количества монет из поля ввода в случае его валидности
if(GUILayout.Button("Set")&&Int32.TryParse(_coinsInput,outintlives)){_coinsHandler.SetCoinsTo(lives);}// Конец горизонтального размещения элементов
GUILayout.EndHorizontal();// Начало горизонтального размещения элементов
GUILayout.BeginHorizontal();// Кнопка установки монет в 0
if(GUILayout.Button("0")){_coinsHandler.SetCoinsTo(0);}// Кнопка снятия 100 монет
if(GUILayout.Button("-100")){_coinsHandler.SpendCoins();}// Кнопка добавления 100 монет
if(GUILayout.Button("+100")){_coinsHandler.GetCoins();}// Кнопка установки максимального количества монет
if(GUILayout.Button("Max")){_coinsHandler.SetCoinsTo(CoinsHandler.MaxCoinsCount);}// Конец горизонтального размещения элементов
GUILayout.EndHorizontal();// Кнопка закрытия панели
if(GUILayout.Button("Close panel")){_showPanel=false;}// Конец вертикального размещения элементов
GUILayout.EndVertical();// Конец зоны ограничивающей элементы размерами панели
GUILayout.EndArea();}// В поле ввода количества монет можно ввести только цифры
privatestaticboolIsDigitsString(strings){if(s==null||s=="")returnfalse;for(inti=0;i<s.Length;i++)if(s[i]<'0'||s[i]>'9')returnfalse;returntrue;}}#endif
Добавление Scripting Define Symbol в Project Settings
Результат
Помимо открытия по долгому клику, также была добавлена комбинация клавиш. Вариант с тапами закомментирован, так как не тестировался.
Заключение
Чит панели это одна из основных вещей, которые помогают тестировать игру и чем больше сложных систем и механик будет вводиться в игру, тем больше внимания будет уделено созданию таких панелей. На первый взгляд, они не видятся чем-то сложным, однако, порой встраивание чита может потребовать не только грамотно выстроенной архитектуры, но и даже расширения самой механики. Так что это может стать не самой тривиальной задачей, а если включить сюда требование к разведению билдов на тестирование и продакшн, то это выходит за рамки просто программирования и входит в зону ответственности dev_ops с настройкой CI и всего остального, что поможет сократить ручной труд. А также современные проекты просто обязаны использовать возможности Assembly Definitions как для организации кода, так и для других возможностей, но это выходит за рамки чит панелей и об этом мы поговорим отдельно. Читерите поменьше! Пока! =)