Найти тему
ElandGames

Основы языка С# для создания игр #6: Пространства имён, наследование и модификаторы доступа

Оглавление

Чем сложнее будет становится наша игра, тем больше её логики будет записано в виде кода. Если попытаться записать всё в одном файле, то можно получить что-то вроде рулона туалетной бумаги, да простят меня эстеты за такое сравнение. :) Представьте, что на этой туалетной бумаге записаны тысячи строчек кода и чтобы найти нужный фрагмент нам нужно этот рулон каждый раз раскручивать и перебирать в руках метры этого манускрипта!

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

Вот для этого и создана идея разбиения логики программы на разные классы, во избежание появления "мега-классов", которые как рулоны туалетной бумаги очень неудобны для чтения и навигации. :)

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

Пространства имён

В маленьких проектах эта штука не особо активно применяется и больше полезна, когда над проектом работают несколько программистов, но хотя бы в общих чертах понимать её надо. Во-первых надо понимать что using - это оператор подключения пространства имён к нашему скрипту. А при правильном использовании это оператор ускорения разработки игры! :)

Вот был у нас, например, скрипт Monster, который жил себе спокойно, самостоятельно, никого не трогал. А нас внезапно возникла идея, что давайте параметры монстра считывать из специального скрипта с настройками.

Настройки по-английски будут Settings, а вот название MonsterSettings, что-то нам показалось длинноватым и неудобным. И тогда мы можем объявить пространство имён Settings с помощью оператора namespace, и прописать в нём классы с точно такими же названиями, как те, настройки которых, они будут хранить.

Заметьте, что в одном проекте не могут существовать классы с одинаковыми названиями, а вот в разных пространствах имён внутри одного проекта могут! И будет у нас класс Monster, и класс с настройками, к которому будем обращаться Settings.Monster. Выглядит весьма недурно.

Класс и экземпляр класса

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

Так вот, чтобы получить поля и методы класса Monster из пространства имён Settings, его нужно создать. Для этого объявляется поле с типом, которым является нужный нам класс, задаётся имя и присваивается экземпляр этого класса, созданный с помощью оператора new НазваниеКласса().

Создав экземпляр класса мы сможем обращаться к его полям и методам. Если класс находится в пространстве имён, то к его названию нужно добавиться название этого пространства имён.
Создав экземпляр класса мы сможем обращаться к его полям и методам. Если класс находится в пространстве имён, то к его названию нужно добавиться название этого пространства имён.

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

Подключение пространства имён

Мы можем избавиться от необходимости указывать пространство имён при обращении к классу, подключив это пространство имён к нашему скрипту, с помощью добавления директивы using.

Для этого нужно прописать в самом начале скрипта: using Settings. Но в таком случае одинаковые названия классов начнут конфликтовать, т.к. мы будем обращаться и к основному классу Monster, и к классу с настройками по одному имени. Получается неоднозначность! Программа не поймёт, что именно мы имеем ввиду и выдаст что-то одно, что поближе.

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

Пространства имён позволяют разбивать сложный проект на отдельные части и структурировать его, не теряя взаимосвязь логики. С помощью пространства имён, мы как настоящие ленивые программисты, получаем еще и возможность не писать то, что уже написано. Иными словами мы избегаем дублирования кода!

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

Наследование во избежание дублирования кода

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

Мы могли бы создать три разных класса FlyingUnit, RunningUnit, JumpingUnit и просто прописать в каждом из них набор параметров, а к ним добавить уникальные методы передвижения. Но что если мы захотим изменить этот набор параметров - что-то добавить и что-то убрать? Придётся в каждом классе это делать и ничего не забыть и не перепутать. Это может стать причиной многих ошибок и неоднозначностей.

Гораздо эффективнее будет сделать один общий базовый класс со всеми параметрами, чью логику унаследуют классы всех типов монстров, добавив свою уникальную логику передвижения. Наследуемый класс указывается через двоеточие рядом с названием класса. По умолчанию, все классы в Unity наследуют класс MonoBehaviour, который наделяет их функционалом игровых объектов.

Наследуем классы всех типов юнитов от базового класса Unit
Наследуем классы всех типов юнитов от базового класса Unit

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

Теперь классы различных врагов будут иметь поля родительского класса Unit и смогут с ними работать.

В классе FlyingUnit параметр moveSpeed не объявлен, но он унаследован от класса Unit
В классе FlyingUnit параметр moveSpeed не объявлен, но он унаследован от класса Unit

В C# нельзя наследовать сразу несколько классов, но можно наследовать их друг за другом последовательно.

Наследовать можно не только поля, но и методы. Например, можно сделать классы отвечающие за механику атаки MeleeAttackUnit и ShootingUnit - рукопашная атака и стрельба.

Теперь мы сможем унаследовать MeleeAttackUnit от RunningUnit, который в свою очередь уже наследует базовый класс Unit. Как вариант, рукопашные юниты будут наследовать механику бега и общий набор параметров. Но это будет иметь смысл, только если предполагается какое-то ветвление. Поэтому лучше пронаследовать наоборот - вот по такой схеме.

Таким наследованием мы получим 4 разных по способу передвижения типа монстров, образованных из 2 видов атаки и одним общим набором параметров.
Таким наследованием мы получим 4 разных по способу передвижения типа монстров, образованных из 2 видов атаки и одним общим набором параметров.

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

Класс FlyingUnit обращается к полю класса Unit и методу класса ShootingUnit, так как он наследует эти классы, а их поля и методы публичные.
Класс FlyingUnit обращается к полю класса Unit и методу класса ShootingUnit, так как он наследует эти классы, а их поля и методы публичные.

Модификаторы доступа

Поля и методы внутри класса могут иметь специальные модификаторы доступа private, либо public. К приватным полям и методам можно обратиться только внутри класса, в котором они объявлены. А вот к публичным полям и методам можно обратиться имея ссылку на класс, либо из другого класса, который будет его наследовать.

По умолчанию, если мы ничего не указываем, то модификатор доступа всегда private, поэтому для краткости private можно вообще не писать. Но кто-то предпочитает, чтоб все было наглядно прописано.

Есть также модификатор доступа protected, позволяющий пользоваться полями и методами только наследникам класса.

Вот как модификаторы доступа выглядят в Visual Studio.
Вот как модификаторы доступа выглядят в Visual Studio.

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

Нам же в основном для предоставления доступа к нужным полям и методам пригодятся только public и protected.

Модификатор public кроме доступа к полям, делает их еще и отображаемыми в инспекторе Unity, что также можно сделать с помощью атрибута [SerializeField], который прописывается перед или вместо модификатора доступа private, если мы хотим исключить изменение полей из других классов, но наблюдать за ними в инспекторе.

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

Переопределение методов при наследовании

Полезная штука, о которой стоит узнать как можно раньше - это переопределение методов. Другими словами это изменение наследуемых методов.

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

Чтобы переписать или другими словами переопределить метод, мы должны в родительском классе его пометить как виртуальный с помощью модификатора virtual и само собой публичный. А вот в дочернем классе написать точно такое же название метода, с точно таким же набором параметров (в нашем случае без параметров) с модификатором override.

Если мы в переопределенном методе ничего не напишем, то логика родительского класса выполнятся не будет и наш SuperUnit стрелять не будет.
Если мы в переопределенном методе ничего не напишем, то логика родительского класса выполнятся не будет и наш SuperUnit стрелять не будет.

Чтобы использовать механику стрельбы родительского класса нам нужно к нему обратится с помощью специально обученной ссылки base и вызвать оригинальный метод Shoot().

Отлично, теперь наш супер-босс хотя бы будет стрелять как обычный юнит.
Отлично, теперь наш супер-босс хотя бы будет стрелять как обычный юнит.

А в дополнение к обычной атаке давайте добавим супер атаку.

Всё, теперь немного изменив его внешний вид, мы получим настоящего босса в конце уровня!
Всё, теперь немного изменив его внешний вид, мы получим настоящего босса в конце уровня!

Таким образом, наш супер босс будет нет только стрелять, как обычный стреляющий монстр, но и совершать свою супер атаку, злобно посмеиваясь!

Красиво! Изящно! А сколько строчек кода и времени мы сэкономили! Да ещё и внесение правок в такой проект будет намного проще и быстрее! Сплошные плюсы. :)