Создаем чит панель в Unity

Tulenber 1 May, 2020 ⸱ Intermediate ⸱ 11 min ⸱ 2019.3.11f1 ⸱

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

Конами код - это, наверное, самый известный чит код в истории видеоигр. Он был создан Кадзухисой Хасимото во время портирования игры Gradius на консоль NES. Код был добавлен, так как Кадзухиса посчитал, что игра слишком сложна и при его введении можно было получить полное вооружение, впоследствии его имплементировали в сотни игр для самых разнообразных целей.

В новой игре, буквально с добавлением первой механики, может возникнуть ситуация аналогичная с Gradius. Её тестирование будет занимать довольно большое количество времени, в качестве примера, если ваша игра состоит из десятков уровней, то навряд ли ради тестирования 52 уровня вы пройдёте всю игру сначала, чтобы выяснить, что какой-нибудь элемент на карте расположен на 5 пикселей левее положенного места. Именно для решения таких проблем обычно в игру добавляют читы, которые помогают срезать углы и создать необходимые условия для проверки механик или других игровых элементов.

Чит панель

Если отнестись к этой теме не очень серьёзно, то встраивание читов начнётся с простых механизмов, например, по клику на иконку с жизнями, полностью их восстановить или, при нажатии “Ctrl + M”, начислить сто монет. Впоследствии это выливается в то, что будет довольно сложно запомнить все эти неочевидные комбинации и места, что говорить о новых на проекте людях, которые просто не будут о них знать. Ещё сложнее будет вычистить и проверить такие места при сборке релиза перед выпуском.

Элегантным решением данного вопроса служат чит панели, которые фактически стали стандартом для подобных механик. Они предоставляют возможность централизованного управления и информирования о состоянии игры, исключены из нормального взаимодействия с интерфейсами, остаются такие мелочи как: сделать их дешёвыми в разработке, легко исключаемыми из финальной сборки и встроить в механики игры.

Чит панель из Conan Exiles:
Conan admin panel

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

  1. Инструмент для создания UI
  2. Логика появления-скрытия панели
  3. Механизм исключения из финальной сборки

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 файлы, что может упростить автоматизацию.

Тестовый проект

Для примера был создан проект, содержащий два текстовых элемента, кнопку и простую механику с монетками.

Изначально есть максимум пятьсот монет, по клику на кнопку снимается сто монет, каждые три секунды сто монет восстанавливается.
Conan admin panel

Реализация

Учитывая всё выше перечисленное, реализация будет выглядеть как панель написанная при помощи IMGUI, которая будет появляться по долгому клику(3 секунды) в левой нижней части экрана, а в коде будут использоваться директивы препроцессора #if и #endif, которые мы включим при помощи настройки в проекте.

Класс управляющий механикой и UI:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.UI;

public class CoinsHandler : MonoBehaviour
{
    // UI элементы
    [SerializeField] public Text coinsTitle = null;
    [SerializeField] public Text countdownTitle = null;
    [SerializeField] public Button spendButton = null;

    // Характеристики для механики с монетками
    public const int MaxCoinsCount = 500;
    private const int SpendCoinsCount = 100;
    private int _coinsCount;

    public int CoinsCount => _coinsCount;

    // Таймер для восстановления монеток
    private readonly TimeSpan _timeToRestore = new TimeSpan(0,0,3);
    private DateTime _restoreCoinsTimeMark;

    public DateTime RestoreCoinsTimeMark => _restoreCoinsTimeMark;

    // Отдельная перерисовка UI элементов
    private bool _updateUi = true;

    void Start()
    {
        // Выставляем начальное значение для монет
        _coinsCount = MaxCoinsCount;

        // Запускаем таймер для проверки монет
        StartCoroutine(UpdateTimer());

        // При наличии флага DEBUG_CHEATS, создаём объект, отвечающий за читы 
#if DEBUG_CHEATS
        GameObject cheatsHandler = new GameObject {name = "CheatsHandler"};
        cheatsHandler.AddComponent<CheatsHandler>();
#endif
    }

    // Обновление UI
    private void LateUpdate()
    {
        if (_updateUi)
        {
            _updateUi = false;
            UpdateUi();    
        }
    }

    // Обновление UI
    private void UpdateUi()
    {
        coinsTitle.text = "Coins: " + _coinsCount;

        // Таймер отображается только в случае, когда монет меньше максимума
        countdownTitle.gameObject.SetActive(_coinsCount < MaxCoinsCount);
        if (_coinsCount < MaxCoinsCount)
        {
            // Разница между будущим временем начисления монет и текущим
            TimeSpan timeDiff = _restoreCoinsTimeMark - DateTime.UtcNow;
            countdownTitle.text = "Countdown: " + timeDiff.ToString("mm\\:ss");    
        }

        // Кнопка активна в случае возможности списания монет
        spendButton.interactable = CanSpendCoins();
    }

    // Таймер обновления UI
    IEnumerator UpdateTimer()
    {
        while (true)
        {
            CheckCoinsTimer();
            _updateUi = true;
            yield return new WaitForSecondsRealtime(1);
        }
    }

    // Функция снимает монеты
    public void SpendCoins()
    {
        if (CanSpendCoins())
        {
            SetCoinsTo(_coinsCount - SpendCoinsCount);
        }
    }

    // Проверка на достаточное количество монет
    public bool CanSpendCoins()
    {
        return _coinsCount >= SpendCoinsCount;
    }

    // Функция начисления монет
    public void GetCoins()
    {
        if (_coinsCount < MaxCoinsCount)
        {
            SetCoinsTo(_coinsCount + SpendCoinsCount);
        }
    }

    // Функция проверки таймера начисления монет
    private void CheckCoinsTimer()
    {
        // Текущее время
        DateTime timeNow = DateTime.UtcNow;
        // Если текущее время больше отметки для начисления и монет меньше максимума
        if (timeNow >= _restoreCoinsTimeMark && _coinsCount < MaxCoinsCount)
        {
            GetCoins();
            // Сдвигаем отметку начисления монет
            _restoreCoinsTimeMark += _timeToRestore;
        }
    }

    // Унифицированная функция для выставления количества монет
    public void SetCoinsTo(int count)
    {
        if (_coinsCount >= MaxCoinsCount)
        {
            DateTime timeNow = DateTime.UtcNow;
            _restoreCoinsTimeMark = timeNow + _timeToRestore;
        }
        
        _coinsCount = count;

        _updateUi = true;
    }
}

Класс управляющий читами:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#if DEBUG_CHEATS

using System;
using System.Globalization;
using UnityEngine;

public class CheatsHandler : MonoBehaviour
{
    // Прямоугольник для чит панели
    private readonly Rect _cheatsBoxRect = new Rect (10, 10, Screen.width - 20, 200);
    // Строка ввода количества монет
    private string _coinsInput = "0";

    // Объект с механикой монет
    private CoinsHandler _coinsHandler = null;

    // Флаг отображения чит панели
    private bool _showPanel = false;

    // Таймер активации панели
    private const float TimeToActivate = 3;
    private float _activationTimeCounter = 0;
    
    // Прямоугольник для проверки активации
    private Rect _checkRect = new Rect(0,0,50,50);
    private bool _buttonPressed = false;

    void Start()
    {
        // Информирование о присутствии читов
        Debug.LogWarning("Debug cheats enabled");
        _coinsHandler = FindObjectOfType<CoinsHandler>();
    }

    void Update()
    {
        // Увеличиваем таймер клика по зоне активации панели
        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;
        }*/
    }

    // Основная функция отрисовки чит панели
    private void OnGUI()
    {
        // Не рисуем панель без активации
        if (!_showPanel)
        {
            return;
        }

        // Основной объект панели
        GUI.Box(_cheatsBoxRect, "Cheats panel");

        // Начало зоны ограничивающей элементы размерами панели
        GUILayout.BeginArea (new Rect(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));
        // Расчёт разницы между текущим временем и временем начисления монет
        TimeSpan timeDiff = _coinsHandler.RestoreCoinsTimeMark - DateTime.UtcNow;
        // Добавляем - если время начисления уже пройдено
        string minus = timeDiff < TimeSpan.Zero ? "-" : "";
        // Отображаем точное время начисления и разницу в минутах:секундах
        GUILayout.Label("Restore time: " + _coinsHandler.RestoreCoinsTimeMark.ToString("G", CultureInfo.InvariantCulture) + " (" + minus + timeDiff.ToString("mm\\:ss") + ")");

        // Начало горизонтального размещения элементов
        GUILayout.BeginHorizontal();
        // Поле ввода количества монет для зачисления
        string testStr = GUILayout.TextField(_coinsInput);
        // Обновления текста в поле ввода в случае его валидности
        if (IsDigitsString(testStr))
        {
            _coinsInput = testStr;
        }

        // Зачисление количества монет из поля ввода в случае его валидности
        if (GUILayout.Button("Set") && Int32.TryParse(_coinsInput, out int lives))
        {
            _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();
    }

    // В поле ввода количества монет можно ввести только цифры
    private static bool IsDigitsString(string s)
    {
        if (s == null || s == "") 
            return false;
        
        for (int i = 0; i < s.Length; i++) 
            if (s[i] < '0' || s[i] > '9') 
                return false; 
        return true;
    }
}

#endif

Добавление Scripting Define Symbol в Project Settings
Scripting define symbols

Результат

Помимо открытия по долгому клику, также была добавлена комбинация клавиш. Вариант с тапами закомментирован, так как не тестировался.
Result

Заключение

Чит панели это одна из основных вещей, которые помогают тестировать игру и чем больше сложных систем и механик будет вводиться в игру, тем больше внимания будет уделено созданию таких панелей. На первый взгляд, они не видятся чем-то сложным, однако, порой встраивание чита может потребовать не только грамотно выстроенной архитектуры, но и даже расширения самой механики. Так что это может стать не самой тривиальной задачей, а если включить сюда требование к разведению билдов на тестирование и продакшн, то это выходит за рамки просто программирования и входит в зону ответственности dev_ops с настройкой CI и всего остального, что поможет сократить ручной труд. А также современные проекты просто обязаны использовать возможности Assembly Definitions как для организации кода, так и для других возможностей, но это выходит за рамки чит панелей и об этом мы поговорим отдельно. Читерите поменьше! Пока! =)



Privacy policyCookie policyTerms of service
Tulenber 2020