Базовый подкурс C# позади, и теперь наша небольшая команда берётся за первый игровой прототип. Художник уже подготовил героев для тестовой сцены, сценарист придумал им реплики, а нам досталась боевая часть: к вечеру персонажи должны хотя бы выполнять свои основные действия и расходовать нужные ресурсы.

Два мага и два воина готовятся к тестовой сцене на лесной поляне

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

Соберите тестовую сцену средствами, которые вы уже знаете:

  • создайте данные для двух магов и двух воинов;
  • напишите функцию CastSpell, которая сообщает о заклинании и расходует 5 единиц маны;
  • напишите функцию Attack, которая сообщает об атаке и расходует 5 единиц выносливости;
  • не выполняйте действие, если соответствующего ресурса меньше 5, и не допускайте отрицательного остатка;
  • вызовите по одному действию для каждого героя так, чтобы проверить и успешное выполнение, и отказ из-за нехватки ресурса.

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

Обсуждение гипотезыНаставник поможет уточнить мысльПопытка 1 из 3
Наставник

Предложите свою версию. Здесь не нужен идеальный ответ: важно показать ход мысли. Если понадобится, я задам один наводящий вопрос.

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

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

using System;

string albertName = "Альберт";
int albertMana = 20;

string merlinName = "Мерлин";
int merlinMana = 3;

string edwardName = "Эдвард";
int edwardStamina = 25;

string gleinName = "Глейн";
int gleinStamina = 4;

int CastSpell(string name, int mana)
{
    if (mana < 5)
    {
        Console.WriteLine($"{name}: недостаточно маны.");
        return mana;
    }

    mana -= 5;
    Console.WriteLine($"{name} произносит заклинание. Осталось маны: {mana}");
    return mana;
}

int Attack(string name, int stamina)
{
    if (stamina < 5)
    {
        Console.WriteLine($"{name}: недостаточно выносливости.");
        return stamina;
    }

    stamina -= 5;
    Console.WriteLine($"{name} наносит удар. Осталось выносливости: {stamina}");
    return stamina;
}

albertMana = CastSpell(albertName, albertMana);
merlinMana = CastSpell(merlinName, merlinMana);
edwardStamina = Attack(edwardName, edwardStamina);
gleinStamina = Attack(gleinName, gleinStamina);

Программа делает именно то, что от неё требуется. Альберт произносит заклинание и остаётся с 15 единицами маны, Эдвард атакует и тратит 5 единиц выносливости. Мерлину и Глейну ресурсов не хватает, поэтому функции выводят предупреждения и сохраняют прежние значения. Ни мана, ни выносливость в минус не уходят.

Для короткой демонстрации этого достаточно: решение работает и для четырёх героев остаётся понятным. Однако прототип — это начало проекта, а не его конец, и при расширении требований в коде становятся заметны две проблемы.

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

Пока характеристик две, порядок удержать несложно. Но у героя быстро появятся здоровье, уровень, опыт и другие данные. Для нового мага придётся снова создавать весь набор:

string morganaName = "Моргана";
int morganaMana = 12;
int morganaHealth = 100;
int morganaLevel = 1;

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

albertMana = CastSpell(merlinName, albertMana);

Код скомпилируется. Функция получила строку и число нужных типов, поэтому C# не знает, что перед ней имя Мерлина и запас маны Альберта. Ошибка находится не в синтаксисе и не в типах, а в нашем замысле: связанные данные существуют отдельно, и их правильное сочетание пока держится только на внимательности разработчика.

Вторая проблема заметна даже при нынешних четырёх переменных. Обе функции принимают строку и целое число. Для CastSpell число означает ману, а для Attack — выносливость, но тип int не хранит такой смысл. Поэтому мы можем написать следующее:

edwardStamina = CastSpell(edwardName, edwardStamina);
albertMana = Attack(albertName, albertMana);

Маг пытается поднять меч, а воин — произнести заклинание

Для компилятора оба вызова допустимы: функции получили string и int. Для нашей игры они бессмысленны. Эдвард не должен расходовать выносливость на заклинание, а Альберт — ману на удар оружием, но текущее устройство программы не умеет выразить это ограничение.

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

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

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

Программа вокруг объектов

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

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

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

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

Общий журнал и картотека с отдельными карточками

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

Возвращаемся к героям

В нашей игре Альберт и Мерлин станут двумя отдельными объектами. Каждый объект будет хранить собственное имя и собственный запас маны, а действие заклинания будет работать с данными именно того мага, к которому мы обратились. Если Альберт потратит 5 единиц маны, значение Мерлина от этого не изменится.

Общий класс Mage опишет, что должно быть у любого мага: имя, мана и возможность произносить заклинания. Сам класс не является Альбертом или Мерлином — это общий шаблон. Альберт и Мерлин будут конкретными объектами, созданными по этому описанию; такие объекты также называют экземплярами класса.

Один шаблон класса Mage и несколько созданных по нему объектов-магов

Для Эдварда и Глейна потребуется другое описание — класс Warrior. У воинов тоже есть имя, но вместо маны они хранят выносливость и умеют атаковать. Разделив роли таким образом, мы выражаем правило игры в самой структуре программы: действие заклинания относится к магам, а атака — к воинам.

Это меняет характер ошибки, которую мы видели раньше. Когда функция CastSpell принимает просто строку и число, в неё можно передать данные любого героя подходящих типов. Когда же действие принадлежит объектам типа Mage, у объекта типа Warrior такого действия нет. Попытка заставить воина произнести заклинание уже не выглядит для C# обычным корректным вызовом: компилятор сможет остановить нас до запуска программы.

ООП не требует превращать в объект каждое существительное, встретившееся в условии задачи. Мы сами выбираем сущности, которые важны для программы, и описываем только нужные данные и действия. В нашей тестовой сцене имеет значение имя мага, его мана и заклинание; любимый завтрак Альберта пока можно спокойно оставить сценаристу.

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

В этом уроке мы остановились на концепции. Нам было важно увидеть, почему рабочее процедурное решение становится неудобным при развитии проекта и какое устройство программы нам требуется вместо набора несвязанных переменных. В следующем уроке «Классы, поля и методы» мы перенесём эту модель в C#: опишем классы Mage и Warrior, создадим конкретных героев и увидим, как данные и действия объединяются в коде.

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

  • Процедурный подход организует программу как последовательность функций и действий, работающих с переданными данными. Он остаётся удобным для многих небольших и линейных задач.
  • ООП организует программу вокруг объектов, в которых собственные данные объединены со связанными действиями.
  • Объект — это одна конкретная сущность программы со своими значениями. Альберт и Мерлин будут разными объектами и станут хранить независимые запасы маны.
  • Класс — общее описание объектов одного типа. Класс Mage задаст устройство магов, а класс Warrior — устройство воинов.
  • ООП не заменяет переменные, условия, циклы и функции, а помогает организовать их вокруг сущностей и правил программы.

Следующий шаг — перейти от этой модели к синтаксису. В уроке «Классы, поля и методы» мы впервые запишем классы Mage и Warrior и создадим на их основе объекты героев.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.