Журнал LinuxFormat - перейти на главную

LXF93:Mono

Материал из Linuxformat
Перейти к: навигация, поиск
Mono-Мания Программирование на современной платформе для новичков

Содержание

Mono: ООП для создания игры

C# для начинающих
Заявите свои права наследования в С#: на втором уроке по ООП Пол Хадсон покажет вам, как объекты передают свои знания из поколения в поколение.

Что это была бы за жизнь, если бы мы были обречены Провидением следовать пути, проторенному предками? Тогда я готовил бы проповедь на воскресенье, а не писал эту статью! Но в жизни мы способны возвыситься над исходным состоянием, делая то, чего не дано нашим родителям. C# здесь имеет аналогию: один класс может «наследовать» от другого, то есть получать все методы и переменные своего родителя, но может и добавлять свои собственные. На самом деле, как один класс наследует от другого, так и тот класс может наследовать от другого класса, и так далее – вы можете продолжать цепочку до бесконечности!

Определим суть сегодняшнего учебника: в прошлый раз мы написали простую карточную игру, чтобы разобраться, как создавать собственные классы для решения реальных задач. Сегодня мы собираемся изготовить несложное подобие стратегической игры Civilization, концентрируясь на более мощных свойствах объектно-ориентированного программирования. Признаюсь, что игра – просто способ поддержать ваш интерес, чтобы вы по ходу дела подсознательно впитали много скучной теории; надеюсь, моя уловка сработает!

Прорисовываем экран

Проект у нас большой, и вариантов его начала предостаточно. Но лучше всего нырнуть прямо в игру, заставив ее загружаться и рисовать на экране, хотя «экран» покамест будет просто последовательностью прбелов: игра-то еще пуста! Поэтому запустите MonoDevelop и создайте новый консольный проект C# под названием Snivilization. Измените строки using наверху на следующие:

 using System;
 using System.Collections.Generic;
 using System.Drawing;

Мы применим их позже. Вы также должны изменить название MainClass на Snivilization, так как это будет основной класс для нашей игры. Метод Main() в настоящий момент содержит строку Console.WriteLine(); удалите ее и вставьте следующие на ее место:

 Snivilization game = new Snivilization();
 game.Run();

Таким образом мы создадим класс Snivilization и вызовем пока не определенный метод Run(), по сути, запуская программу и передавая ей управление. Методу Run() необходимо войти в бесконечный цикл, чтобы игра продолжалась, пока пользователь не пожелает выйти. Вот черновой вариант метода Run():

 void Run() {
     DrawScreen();
     while (true) {
       ConsoleKeyInfo key = Console.ReadKey(true);
       switch (key.Key) {
           case ConsoleKey.Escape:
               break;
           case ConsoleKey.C:
               DrawScreen();
               break;
       }
   }
 }

DrawScreen() – это еще один пока не определенный метод, но мы скоро им займемся. Конструкция while(true) – бесконечный цикл в C#: он будет исполняться, пока условие истинно, а поскольку true и есть истина, вечная работа обеспечена. Остальная часть метода считывает одну клавишу с консоли (передача true в качестве параметра ReadKey() означает «не позволять консоли делать ничего другого с клавишей»), затем проверяет, что это была за клавиша. Блок switch/case позволяет легко свериться с множеством значений, но пока нам нужно только вызвать метод DrawScreen(), если нажата клавиша C. Метод DrawScreen() рисует много пробелов на экране, представляющем нашу карту. Вот он:

 public void DrawScreen() {
    Console.Clear();
    for (int i = 0; i < GameHeight; ++i) {
       for (int j = 0; j < GameWidth; ++j) {
           Console.Write(“ “);
        }
       Console.Write(“\n”);
    }
 }

Переменные GameHeight и GameWidth новые, и надо добавить их описание – следующие две строки перед методом Main():

 public int GameWidth = 100;
 public int GameHeight = 40;

Перед запуском проекта необходимо сделать две вещи. Первое, щелкните правой кнопкой мыши на References и добавьте ссылку на System.Drawing. Второе, перейдите в меню Project > Options, выберите Runtime Options и проверьте, что выбрана среда выполнения .NET 2.0. Теперь можно смело запускать программу, но – и это большое но! – не пытайтесь ее запустить с помощью F5. Панель вывода в MonoDevelop превосходна для вывода информации, но не является полноценным терминалом и поэтому не может считывать нажатия клавиш пользователем. Вместо F5 надо нажать F8, чтобы собрать проект, затем открыть терминал и перейти в каталог с проектом. Внутри подкаталога bin/Debug вы найдете Snivilization.exe, который можно запустить, набрав

mono Snivilization.exe.

Сотворение мира

Вы-то видите пустой экран, но для жителей Snivilization мы только что создали небо и Землю, объявив «да будет свет»; а сейчас настало время создать из праха людей. Если вы раньше не играли в Civilization, знайте, что там есть два типа игровых объектов: города и боевые единицы. Оба они отличаются разнообразием: города могут быть выстроены в некоем стиле (Классический, Восточный и т.д.) и имеют различные размеры, а боевые единицы со временем эволюционируют, и вы можете сражаться чем угодно, начиная от Воина (первобытного мужика с дубиной) до межконтинентальных ракет. Но независимо от того, город ли это с десятимиллионным населением или танк, у всех этих объектов есть три общих свойства. Все они:

  имеют X- и Y-позиции на карте;
  должны обновляться каждый ход;
  должны прорисовываться на экране.

Общие свойства могут использоваться для создания базового класса, который будет предком обоих наших классов. В С# такие классы назы- ваются абстрактными, потому что им не соответствует конкретный объект: это просто определения. Так как все объекты нашей игры обладают перечисленными свойствами, создадим абстрактный класс SnivObject: пусть другие классы наследуют их от него. Вставьте следующий код до строки class Snivilization:

Скорая помощь

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

 abstract class SnivObject {
    public int XPos;
    public int YPos;
    public abstract void Update();
    public abstract void Draw();
 }

Объявление методов Update() и Draw() абстрактными означает, что любой класс – наследник SnivObject должен предоставить собственную реализацию Update() и Draw(), в противном случае Mono будет жаловаться. Фактически, это гарантия, что все объекты в игре могут прорисовывать себя сами. Мы можем проверить это, создав подкласс SnivObject с именем SnivCity. Для тех, кто не понял: подкласс наследует все методы и свойства базового класса, то есть класс SnivCity будет автоматически содержать переменные XPos и YPos. Под определением SnivObject добавьте такой код:

 class SnivCity : SnivObject {
    private float Size = 100;
    public override void Draw() {
       Console.Write(*);
    }
    public override void Update() { }
 }

Обратите внимание, что класс определен как SnivCity : SnivObject – так C#' сообщает, что «SnivCity наследует от SnivObject». Помните, что SnivCity должен иметь свои собственные (не абстрактные методы) Draw() и Update(). Пусты эти методы (как, например, метод Update()) или нет – неважно, лишь бы они существовали. Заметьте, что у SnivCity есть свойство Size, которого не было в SnivObject. Мы будем использовать его для отслеживания численности населения каждого объекта SnivCity.

Покажи и расскажи

Объекты, которые ничего не делают, бесполезны, поэтому с помощью нового класса SnivCity расширим Snivilization, чтобы игроки могли создавать города на карте. Нам при этом понадобится генератор случайных чисел (для размещения городов случайным образом), а также массив для хранения всех городов. Добавьте две строки кода после объявления переменных GameWidth и GameHeight:

 public static Random Rand = new Random();
 public List<SnivCity> Cities = new List<SnivCity>();

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

На данный момент блок switch/case проверяет только клавишу С, но несложно расширить его так, чтобы обрабатывалась клавиша N. Вот простой код:

 case ConsoleKey.N:
      NewCity();
      DrawScreen();
      break;

Метод NewCity() новый, но ему всего лишь надо создать город, выдать ему произвольную позицию и добавить в список Cities:

 public void NewCity() {
      SnivCity city = new SnivCity();
      city.XPos = Rand.Next(0, GameWidth);
      city.YPos = Rand.Next(0, GameHeight);
      Cities.Add(city);
 }

Само по себе добавление города в массив фактически ничего не делает, так как город еще не видим на экране. Чтобы сделать наши города видимыми, надо изменить метод DrawScreen(): пусть проверяет, есть ли город в каждом поле, и если есть, сообщает городу, чтобы он себя прорисовал. Вы думаете, это потребует много кода? Отнюдь:

 for (int i = 0; i < GameHeight; ++i) {
      for (int j = 0; j < GameWidth; ++j) {
           foundcity = null;
           foreach(SnivCity city in Cities) {
               if (city.YPos == i && city.XPos == j) {
                    foundcity = city;
                    break;
             }
         }
         if (foundcity != null) {
             foundcity.Draw();
         } else {
             Console.Write(“ “);
         }
    }
    Console.Write(“\n”);
 }

На данный момент в прорисовке городов интересного мало: были на поле пробелы, стали звездочки. Но будь у вас SDL, ваши города могли бы обзавестись графикой – рисовали бы свое имя, размер и так далее; поэтому подарить городам самостоятельную прорисовку –хорошая идея.

Попытайтесь-ка снова запустить программу – прогресс налицо: все попрежнему начинается с пустого экрана, но стоит нажать N, как возникает новый город, готовый населить мир. Чем дальше, тем круче! Проблемы возникновения Sniv-городов решены, но дальнейших перемен не просматривается: каждый город навеки обречен быть крошечной звездочкой на карте. Такой застой раздосадует даже самых неприхотливых игроков. Немного оживим процесс: сделаем так, чтобы города с каждым ходом росли при нажатии клавиши С, затем изменим метод Draw() для городов, поставив цвет отображения в зависимость от размера.

Скорая помощь

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

У наших городов уже есть метод Update(), и чтобы обновить любой город, достаточно вызывать для него этот метод. Итак, изменим действие клавиши С в блоке switch/case метода Run() следующим образом:

 case ConsoleKey.C:
      UpdateCities();
      DrawScreen();

Те, кто читает эту серию уроков с первого выпуска, должно быть, уже представляют метод UpdateCities(). А если нет – вот он!

 public void UpdateCities() {
      foreach (SnivCity city in Cities) {
          city.Update();
      }
 }

Теперь наш класс SnivCity содержит метод Update(); и что же этот метод делает? Да ничего! Вы можете при обновлении заставить город делать все что угодно (в зависимости от степени реалистичности вашей игры); ну, а мы будем просто увеличивать его население:

 public override void Update() {
      float growthrate = 25;
      Size *= 1 + (growthrate / 200.0f);
 }

Теперь все города растут при каждом вызове метода Update(), то есть нажатии на C. Но выглядят города всегда одинаково, поскольку изображаются с помощью звездочки, а по ней нельзя определить их размер. Это задача на пару минут, и вот как она выглядит на С#:

 public override void Draw() {
      if (Size < 1000) {
          Console.ForegroundColor = ConsoleColor.DarkRed;
          Console.Write(“.”);
      } else if (Size < 10000) {
          Console.ForegroundColor = ConsoleColor.Red;
          Console.Write(:);
      } else if (Size < 100000) {
          Console.ForegroundColor = ConsoleColor.DarkYellow;
          Console.Write(%);
    } else if (Size < 1000000) {
          Console.ForegroundColor = ConsoleColor.Yellow;
          Console.Write(*);
    } else if (Size < 10000000) {
          Console.ForegroundColor = ConsoleColor.DarkGreen;
          Console.Write(“@”);
    } else {
         Console.ForegroundColor = ConsoleColor.Green;
         Console.Write(#”);
    }
 }

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

Надеюсь, вы теперь осознали пользу объектной ориентированности. Мы переделали методы Update() и Draw() в классе SnivCity, значительно изменив то, что происходит в игре, причем пальцем не тронули главный класс Snivilization. Это называется инкапсуляцией: функциональность каждого объекта заключена внутри него, а не находится где-то снаружи, поэтому изменения этой функциональности отображаются везде. В примере со Snivilization, каждый город сам себя прорисовывает. Мы могли бы переместить этот код в главный цикл DrawScreen(), но вдруг нам понадобится нарисовать город где-либо еще, допустим, на каком-нибудь экране города? Потребуется скопировать код туда. Любые изменения в функциональности придется дублировать в разных участках кода, а это лишь увеличивает вероятность ошибки и замедляет код – куда лучше инкапсулировать функцию в Draw().

Инкапсулируемся глубже

Для разъяснения, как лучше использовать инкапсуляцию, я перестрою нашу игру, чтобы каждый город возвращал налоги и выручку от научных исследований (и такая бывает!) в родительскую цивилизацию. Как и с прорисовкой, заставить класс Snivilization лезть в каждый SnivCity и вытаскивать налоговые суммы – не очень хорошая идея: мы ведь можем изменить алгоритм в любой момент, и неплохо бы централизовать код.

Скорая помощь

Нажмите Ctrl+C, чтобы выйти из игры в любой момент. Возможно, вы захотите добавить что-нибудь более интеллектуальное – попробуйте вместо бесконечного цикла Run() использовать булеву переменную finished, устанавливающуюся в true при нажатии определенной клавиши.

Сначала добавьте пять переменных сразу после объявления Rand в классе Snivilization:

 public static int TaxRate = 8;
 public static int ScienceRate = 25;
 public int TotalCash;
 public int CashLastTurn;
 public int ScienceLastTurn;

Переменные TaxRate и ScienceRate статические, и они могут быть доступны напрямую через класс Snivilization. Например, должна ли налоговая ставка влиять на рост городов? Конечно, должна; поэтому изменим теперь метод Update() класса SnivCity, чтобы учесть налоговую ставку:

 public override void Update() {
   float growthrate = 25;
   growthrate -= Snivilization.TaxRate;
   Size *= 1 + (growthrate / 200.0f);
 }

Согласно этой формуле, высокие налоги заставляют города уменьшаться – так на самом деле и бывает! Мы также можем использовать ставки на налоги и науку, чтобы города сами рассчитывали свои налоги и прибыль от науки; кроме того, численность населения у нас рассчитывается с плавающей запятой – добавим метод GetSize(), преобразующий ее в целое число, пригодное для других методов. Вот как выглядит этот код:

 public int GetTax() {
    return (int)Math.Round((Size / 400.0f) * Snivilization.TaxRate);
 }
 public int GetScience() {
    return (int)Math.Round((GetTax() / 100.0f) * Snivilization.
 ScienceRate);
 }
 public int GetSize() {
    return (int)Math.Floor(Size);
 }
Скорая помощь

В коде на нашем диске я добавил класс SnivUnit, чтобы показать, как несколько дочерних классов могут наследовать от одного базового класса. Однако сам SnivUnit помечен как абстрактный, потому что содержит абстрактный метод CanMove(). SnivUnit спроектирован как абстрактный класс, потому что нет такой сущности, как «боевая единица» – есть воздушные боевые единицы, морские боевые единицы, наземные и так далее. Чтобы создать морскую боевую единицу, нужен класс SnivSeaUnit, наследованный от SnivUnit, и метод CanMove(), возвращающий true, если клетка на карте является морем.

Каждый город может теперь рассчитывать свои налоговые и научные показатели; у нас есть переменные, сохраняющие значения прибыли от науки и налоговых отчислений с прошлого хода, да еще и цикл foreach, обновляющий все города. Как же рассчитать полные показатели налогов и науки? Ответ: легко! Новый метод UpdateCities() должен очищать CashLastTurn и ScienceLastTrun, затем добавлять к этим значениям значения методов GetTax() и GetScience() для каждого города, например так:

 public void UpdateCities() {
    CashLastTurn = 0;
    ScienceLastTurn = 0;
    foreach (SnivCity city in Cities) {
       city.Update();
       CashLastTurn += city.GetTax();
       ScienceLastTurn += city.GetScience();
    }
   TotalCash += CashLastTurn;
 }

Эти числа можно использовать для получения информации о благосостоянии игрока. Пока что наш метод DrawScreen() просто рисует карту, но добавим в конец метода следующий код:

 Console.WriteLine(“”);
 Console.ForegroundColor = ConsoleColor.Cyan;
 Console.WriteLine(“Income last turn:+ CashLastTurn);
 Console.WriteLine(“Science last turn:+ ScienceLastTurn);
 Console.WriteLine();
 Console.ForegroundColor = ConsoleColor.White;
 Console.WriteLine(“Total cash:+ TotalCash);

и получим краткое описание статуса игры и карту. На диске вы обнаружите, что я расширил этот код так, что игрок может нажимать P, S, T и получать информацию о населении, науке и налогах для своих городов. С ростом городов код начисления налогов и успехов науки может изменяться, поэтому надо быть сумасшедшим, чтобы не оставить их в методах GetTax() и GetScience().

Хотя мы рассмотрели только функции, возвращающие значения, можно также использовать функции для установки переменных. Например, у вас есть переменная GovernmentType и вы установили ее в «Democracy», то наверняка захотите что-то изменить. Присваивание GovernmentType = Governments.Democracy не окажет мгновенного эффекта на игру. Но действие вроде SetGovernment(Goverments.Democracy) может меняться во времени по мере того, как города будут бунтовать, подвергаться нападению врагов и так далее.

Отведенное нам место исчерпано, но, надеюсь, вы убедились в пользе объектной ориентированности. Она помогает формировать «контракты» с вашим кодом, например, заставляя всех наследников SnivObject реализовывать Draw() и Render(). Она также позволяет добавлять объектам функциональности без копирования больших участков кода. Наш проект допускает множество вариантов его расширения. Интересуетесь ли вы интеллектом противника, графикой или поиском пути, дерзайте. Будете в городе, черкните мне пару строк о ваших успехах (paul.hudson@futurenet.co.uk)! LXF

Тайная инкапсуляция

Не каждый любит писать методы для всех своих переменных, тем более что это затрудняет понимание кода. Однако C# имеет опцию получше, известную как свойства. Они позволят вам завести переменные, которые на самом деле являются функциями. Покажем, как это работает в практической – и довольно простой! – ситуации: добавьте переменную Name в класс SnivCity, чтобы мы могли идентифицировать города по имени.

Обычно это делается с помощью следующих строк:

public string Name;

Вместо этого мы собираемся включить открытую переменную Name и закрытую переменную _Name. Открытая переменная на самом деле будет свойством, то есть при обращении к ней она будет выполнять код. Вот как это выглядит в C#:

 private string _Name;
 public string Name {
     get {
       return _Name;
     }
   set {
       _Name = value;
   }
 }

Таким образом, когда Name извлекается (то есть считывается), C# вернет значение, хранящееся в _Name. Когда Name записывается (при вызове set), С# сохранит значение в переменной Name:value – это специальное слово, означающее «значение value будет присвоено переменной Name». Конечно, этот пример не великое свершение, но легко представить, что в более сложных ситуациях вы можете захотеть вызвать другие функции при установке переменной.

Персональные инструменты
купить
подписаться
Яндекс.Метрика