Инди за 0$ на Unity. Часть 1: Консоль

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

Инди за 0$ на Unity. Часть 1: Консоль

Базовая архитектура приложения

Каждый разработчик сталкивался с тем что ему нужно переключить сцену или сохранить какое-то значение, но не понятно где это сделать. На помощь разработчику приходит глобальный объект, который хранит в себе эти данные и вроде бы все счастливы. Назовём этот объект Manager. Со временем появляются ещё данные, потом ещё и ещё... После утомительной работы над очередной фичей в нашей игре мы смотрим в код Manager'а и ахаем от 1000+ строк кода и полной вакханалии. И хорошо если ещё всё работает, но факта проблемы это не отменяет. Мы сидим на часовой бомбе, которая может рвануть в любой момент.

Инди за 0$ на Unity. Часть 1: Консоль

Всем же знакома данная ситуация? Как незамысловатым движением руки наш класс превращается в God Object. Чтобы избежать эту проблему мы должны с любовью относится к нашему продукту и следовать принципам SOLID.

Ближе к делу!

Для реализации игры я спроектировал следующую архитектуру:
Всего у нас будет 4 синглтона, отвечающие за ключевые функции программы - Console, GameMaster(AppMaster), InputSystem, SaveLoadSystem. Каждый из этих синглтонов отвечает за свой функционал и является глобальным в контексте моего приложения.

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

GameMaster aka AppMaster - Синглтон, отвечающий за загрузку сцен, общее внутри-игровое состояние и состояние приложения вообще. Сам по себе класс будет иметь мало функционала, но от этого менее важным он не становится. Тут будут храниться все конфиги пользователя, ачивки и прочее.

InputSystem - Прослойка между встроенными ивентами ввода и нашей игрой. Стандартная система ввода юнити не позволяет нам переопределять бинды в рантайме или изменять тот чувствительность мыши. Для этих целей мы реализуем свою прослойку. Если вы пишите кросплатформенную игру (например для пк, консолей и мобильников), то такая задача будет суицидом, но т.к. я пишу игру только для пк, то всё будет проще.
Ремарка: Юнити работает над новой системой ввода, но на данный момент она находится в состоянии preview и доступна в experimental пространстве имён. Инпут система это не визуал и словив баг можно нехило закопаться в дебаггинге по этому я избегаю такие превью пакеты до релиза.

SaveLoadSystem - Сериализатор\десериализатор динамических данных. В игре я собираюсь реализовать систему диалогов, инвентарь и прогресс пользователя. Всё это надо где-либо хранить и как то переносить между игровыми сессиями. Для таких целей мы реализуем эту систему и будем использовать в нашей игре.

С помощью этих модулей мы сможем держать код в чистоте, а ноги в тепле. В консоли мы сможем определять кастомные комманды, в гейммастере мы будем держать все важные данные, инпут система позволит нам определять экшены и подписываться на них, а система сериализации позволит нам определять свои сценарии для сериализации\десериализации любых объектов. У меня будет несколько сцен, в которых я уже буду реализовывать бизнес-логику игры, отделённую от основных модулей. Помимо этих модулей мне предстоит реализовать подсистему диалогов, подсистему инвентаря и т.д., но об этом пока нет смысла думать.

Структура проекта

Все свои ассеты я складываю в папку /Assets/Game. Внутри этой папки у меня стандартная структура (Scripts, Sprites, Scenes, etc).

В корне своей папки я создаю Assembly Definition, которую прямо так и называю: Game. В папке со скриптами у меня есть поддиректория Tests, в которой я храню все тесты своих скриптов.

Структура на момент написания статьи. В Shit улетаю ассеты, которые не имеют серьёзной значимости, мусор или неудачные эксперименты. С некоторой периодичностью я её чищу, а перед релизом сношу полностью.
Структура на момент написания статьи. В Shit улетаю ассеты, которые не имеют серьёзной значимости, мусор или неудачные эксперименты. С некоторой периодичностью я её чищу, а перед релизом сношу полностью.

Давайте приступим к реализации одного из самых важных модулей - Console.

Что такое консоль и как её реализовать?

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

Реализовывать простейшую консоль я буду средствами UnityUI. Для этого создадим канвас, накинем туда ScrollView вместе с простым Text, Image и InputField. Картинку я использую для заднего фона, scrollview для лога консоли и Inputfield для пользовательского ввода. Не забываем сразу поставить канвасу высокий порядок отрисовки чтобы консоль всегда была поверх остального интерфейса.
Так же добавляем Text элементу компонент ContentSizeFitter для автоматического ресайза элемента и выставляем вертикальное выравнивание текста на bottom.

Пытливый читатель может сказать "Отдельный канвас? Зачем? Я вот всё в один общий канвас сую и просто играюсь с SetActive методом у объектов внутри канваса". Это не плохой подход, но по Best Practices стоит разделять элементы на канвасы т.к. при юнити перерисовывает весь канвас при любом изменении внутри него, что может быть ресурсоёмко в некоторых случаях.

Подробнее про Canvas Best Practices: https://unity3d.com/how-to/unity-ui-optimization-tips

Итак, у нас должно получиться что-то в таком духе:

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

Когда UI элементы будут готовы, наступает время кода. Я весь свой код всегда сую в пространство имён Game. Таким образом я всегда знаю какой код мой, какой не мой и избегаю любых конфликтов имён. Для консоли я использую подпространство Game.Console.

Создаём класс Console в пространстве имён Game.Console:

using System.Collections; using System.Collections.Generic; using UnityEngine; using System; namespace Game.Console { public struct Command { public string description; public Delegate function; } public class Console : MonoBehaviour { #region Static public static Console instance = null; static Dictionary<string, Command> cmds = new Dictionary<string, Command>(); public static void RegisterCommand(string name, string desc, Delegate func) { if (cmds.ContainsKey(name)) { Debug.LogError("Command '" + name + "' is already in use"); return; } Command cmd = new Command(); cmd.description = desc; cmd.function = func; cmds.Add(name, cmd); } public static void UnregisterCommand(string name) { if (!cmds.ContainsKey(name)) { Debug.LogError("Command '" + name + "' is not registered yet"); return; } cmds.Remove(name); } #endregion #region Helpers public void PrintHelp() { foreach (string cmdName in cmds.Keys) { Print(cmdName + "\t" + cmds[cmdName].description); } } public void Print(string line) { logTMP.text += line + "\n"; } public void Print(object obj) { Print(obj.ToString()); } public void PrintError(string msg) { Print("<color=red>Error:</color> " + msg); } #endregion string motd = "Welcome to debug console. Type \"help\" for nothing\n"; public TMPro.TextMeshProUGUI logTMP; public TMPro.TMP_InputField InputField; bool isActive = false; void Start() { if (instance != null) { throw new Exception("Console instance dup detected!"); } instance = this; DontDestroyOnLoad(gameObject); SetState(isActive); Application.logMessageReceived += HandleLog; RegisterCommand("help", string.Empty, new Action(PrintHelp)); RegisterCommand("echo", "print arg to console", new Action<object>(Print)); RegisterCommand("dtf", string.Empty, new Action(() => { Print("Whatsup DTF?"); })); logTMP.text = motd; } // Временное решение пока не создали InputSystem private void Update() { if (Input.GetKeyDown(KeyCode.Tilde) || Input.GetKeyDown(KeyCode.BackQuote)) { SetState(!isActive); } } // Парсим инпут пользователя и выполняем комманду public void ProcessInput() { if (Input.GetKeyDown(KeyCode.Tilde) || Input.GetKeyDown(KeyCode.BackQuote)) { InputField.text = InputField.text.Substring(0, InputField.text.Length - 1); return; } string rawString = InputField.text; if (rawString.Length < 1) { Focus(); return; } Print("> " + rawString); InputProcessor processor = new InputProcessor(rawString); processor.Run(); InputField.text = ""; ExecuteCommand(processor.command, processor.arguments); Focus(); } // Метод, который выполняет комманду и отлавливает ошибки public void ExecuteCommand(string cmdName, object[] args) { Command cmd; if (!cmds.TryGetValue(cmdName, out cmd)) { PrintError("Command not found."); return; } try { cmd.function.DynamicInvoke(args); } catch (System.Reflection.TargetParameterCountException ex) { PrintError("Incorrect number of arguments"); } } // Коллбек для перехвата сообщений от встроенного в юнити логгера public void HandleLog(string logString, string stackTrace, LogType type) { Print(logString); } public void SetState(bool state) { isActive = state; foreach (Transform child in transform) child.gameObject.SetActive(isActive); if (isActive) Focus(); } public void Focus() { InputField.ActivateInputField(); } } }

Класс Console имеет ссылку на себя в статической переменной instance. Есть статические методы ReginsterCommand\UnregisterCommand, позволяющие мне регистрировать\удалять команды из любой точки проекта.

Кидаем этот класс на наш канвас, присваиваем через инспектор ему инпут филд и лог. В инпутфилде указываем коллбек Console.ProcessInput на событие OnEndEdit для того чтобы перехватывать ввод пользователя.

Обработка ввода

Дальше нам нужно обрабатывать данные, которые пользователь вводит в консоль. По факту мы получаем String который нам нужно распарсить, вырвав оттуда название консольной команды и аргументы для её выполнения. Т.к. мы хотим иметь гибкость при объявлении команд, используя в аргументах комманды не только строки, но и другие объекты, нам нужно в рантайме определять тип аргумента и тайпкастить его строку в нужный тип.

Для этого мы напишем класс InputProcessor

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using UnityEngine; namespace Game.Console { public class InputProcessor { private string input = string.Empty; public string command; public object[] arguments { get { return parsedArguments.Select(arg => arg.value).ToArray(); } } private List<Argument> parsedArguments = new List<Argument>(); private bool valid = false; public InputProcessor(string rawInput) { input = rawInput; } public void Run() { string[] splittedInput = input.Split(new char[] { ' ' }, 2); command = splittedInput[0]; if (splittedInput.Length == 1) { // there is no additional args, only single command name valid = true; return; } string rawArgs = splittedInput[1]; parsedArguments = ParseArguments(rawArgs); valid = true; } private List<Argument> ParseArguments(string rawArgs) { List<Argument> result = new List<Argument>(); string args = rawArgs; while(args != string.Empty) { Argument arg = ExtractArgument(ref args); result.Add(arg); } return result; } Regex stringRegex = new Regex(@"^([""])((?=(\\?)).)*?\1"); //https://stackoverflow.com/questions/171480/regex-grabbing-values-between-quotation-marks Regex intRegex = new Regex(@"^([\d]+)"); Regex floatRegex = new Regex(@"^(\d*\.\d*)"); Regex boolRegex = new Regex(@"^(true|false)"); Regex cleanFirst = new Regex(@"^\s?"); public Argument ExtractArgument(ref string rawArgs) { string arg = null; rawArgs = cleanFirst.Replace(rawArgs, ""); Match match = stringRegex.Match(rawArgs); if (match.Success) { arg = match.Value.Substring(1, match.Value.Length -2); rawArgs = stringRegex.Replace(rawArgs, ""); return new Argument(arg, ArgumentType.String); } match = boolRegex.Match(rawArgs); if (match.Success) { arg = match.Value; rawArgs = boolRegex.Replace(rawArgs, ""); return new Argument(arg, ArgumentType.Boolean); } match = floatRegex.Match(rawArgs); if (match.Success) { arg = match.Groups[1].Value; rawArgs = floatRegex.Replace(rawArgs, ""); return new Argument(arg, ArgumentType.Float); } match = intRegex.Match(rawArgs); if (match.Success) { arg = match.Groups[1].Value; rawArgs = intRegex.Replace(rawArgs, ""); return new Argument(arg, ArgumentType.Integer); } throw new Exception("Failed to extract argument"); } public bool IsValid() { return valid; } } }

Этот класс принимает в конструкторе строку и при вызове метода Run парсит её. Если что-либо идёт не так, то мы получаем Exception и прерываем выполнение кода.

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

Рекомендую сохранить эти тесты и запускать при любом апдейте процессора дабы спать спокойно как я.

using System.Collections; using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; using Game.Console; namespace Tests { public class InputProcessorTest { string i_justCommand = "help"; string i_commandAndArgument = "echo 123"; [Test] public void InputProcessorTestSimplePasses() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); processor.Run(); Assert.IsTrue(processor.IsValid()); Assert.AreEqual(0, processor.arguments.Length); } [Test] public void InputProcessorTestCommandWithArg() { Game.Console.InputProcessor processor = new InputProcessor(i_commandAndArgument); processor.Run(); Assert.IsTrue(processor.IsValid()); Assert.AreEqual(1, processor.arguments.Length); } [Test] public void TestExtractArgument_String() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); string args = "\"test\" 123 1.23"; string argsNew = args; Argument arg = processor.ExtractArgument(ref args); Assert.AreEqual("test", arg.value); Assert.AreNotEqual(args, argsNew); } [Test] public void TestExtractArgument_CombinedString() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); string args = "\"test 123 something here 3.14\" 123 1.23"; string argsNew = args; Argument arg = processor.ExtractArgument(ref args); Assert.AreEqual("test 123 something here 3.14", arg.value); Assert.AreNotEqual(args, argsNew); } [Test] public void TestExtractArgument_Int() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); string args = "420 \"test\" 3.14"; string argsNew = args; Argument arg = processor.ExtractArgument(ref args); Assert.AreEqual(420, arg.value); Assert.AreNotEqual(args, argsNew); } [Test] public void TestExtractArgument_Float() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); string args = "32.14 420 \"test\""; string argsNew = args; Argument arg = processor.ExtractArgument(ref args); //Assert.AreEqual(32.14, arg.value); float actualDelta = System.Math.Abs(32.14f - (float)arg.value); float minDelta = 0.01f; Assert.IsTrue(actualDelta <= minDelta); Assert.AreNotEqual(args, argsNew); } [Test] public void TestExtractArgument_Boolean() { Game.Console.InputProcessor processor = new InputProcessor(i_justCommand); string args = "true \"test 123 something here 3.14\" 123 1.23"; string argsNew = args; Argument arg = processor.ExtractArgument(ref args); Assert.AreEqual(true, arg.value); Assert.AreNotEqual(args, argsNew); args = "false \"test 123 something here 3.14\" 123 1.23"; argsNew = args; arg = processor.ExtractArgument(ref args); Assert.AreEqual(false, arg.value); Assert.AreNotEqual(args, argsNew); } } }

Этот тест файл я не рефакторил, рекомендую это сделать при расширении функционала процессора.

Итог

Мы разработали консоль, которая позволяет нам объявлять, удалять команды, парсит, тайпкастит аргументы комманд и перехватывает события встроенного в юнити логгера. Теперь мы можем в любом компоненте объявить комманды, которые будут доступны для вызова в консоли, например:

private void OnEnable() { Console.RegisterCommand("pl_ms", "set player movement speed", new System.Action<float>(SetSpeed)); } private void OnDisable() { Console.UnregisterCommand("pl_ms"); } public void SetSpeed(float val) { Debug.Log("Overriding mov speed. Was " + MovementSpeed + ", new " + val); MovementSpeed = val; }

Таким незамысловатым способом мы можем отлаживать\тестировать нужный нам функционал и это будет быстрее чем мы будем отрисовывать кастомные InspectorGUI элементы.

Домашнее задание

  • Объявить кастомный exception при ошибке парсинга аргументов в InputProcessor и отлавливать его в Console. Сейчас при фейле консоль ничего не говорит о том, что ей не удалось определить тайп аргумента. Нужно это исправить.
  • Стилизация лога. Все сообщения из встроенного в юнити логгера приходят прямо в консоль как есть. Было бы круто сделать стилизацию по типу лог мессейджа.
  • Тайпкастинг boolean сейчас case-sensitive. Нужно бы это исправить и дополнить тесты.

Что дальше?

В следующей главе мы создадим InputSystem для объявления кастомных экшенов и прослушивания событий по этим экшенам.

9494
81 комментарий

Удивили. Статья о настоящем, голом геймдеве на DTF ^_^

Из быстрых советов при беглом просмотре могу посоветовать делать инстансы реальными синглтонами, которые при первом запросе создают себя сами и таким образом их дублирование невозможно (способ есть даже для моно-бехов) И вешать на сцену их не придется

12
Ответить

Вы геймдев с программированием перепутали.

3
Ответить

Комментарий недоступен

4
Ответить

Так, я не понял. Почему так мало плюсов и комментариев? Это ж свежевыжатая статья о геймдеве на портале, посвящённом геймдеву.

2
Ответить

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

8
Ответить

Судя по комментам, потому как эта статья понятно только тем, кто и так это знает. А тем кто это не знает - статья совершенно бесполезна.
Я вот решил изучить статью, чтобы что-то почерпнуть для себя и возможно применить на UE4 (методологию например). Но ничего из этого не вышло: что такое солид не понятно (даже если изучить статью в вики, да), что такое синглтон - тоже. Т.е. по умолчанию я уже должен был разобраться, а если я с этим всем разобрался и всё это умею, то какая ценность этой статьи для меня?
Короче, да хотелось бы чтобы статьи в геймдеве были похожи на эту - т.е. было написано что конкретно делается, но хотелось бы ещё и понимать что происходит.

1
Ответить

На мой взгляд, начинать разработку инди игры (особенно когда ты работаешь один и крайне ограничен в человеко-часах) с создание консоли - несколько странно. Но это мое ИМХО конечно...

1
Ответить