Как создать игру с астероидами на Silverlight

ОГЛАВЛЕНИЕ

Для знакомства с Silverlight в статье создается маленькая игра с астероидами.

•    Скачать исходники - 2.34 Мб

Подготовка

Для разработки игры сначала пришлось установить "Microsoft Silverlight Tools Alpha для Visual Studio 2008".
Хорошо, что игру на Silverlight можно было разработать полностью в Visual Studio. Не пришлось тратить время на освоение Expression Blend или любых других, предназначенных для мастеров, инструментов Silverlight.

Использование кода

Для разработки игры использовалась базовая стратегия основного цикла, в котором перерисовываются спрайты на основе нескольких переменных. Для достижения этого был создан DispatcherTimer, вызывающий процедуру рисования в коде с предопределенным интервалом. Процедура рисования проходит в цикле по всем объектам на холсте и обновляет их положения. Например, когда астероид появляется на экране, ему уже назначен случайный угол, скорость и координаты X, Y. Когда таймер срабатывает, значения X, Y астероида пересчитываются с использованием простых математических формул. Ниже приведен пример:

public Page() {
    InitializeComponent();

    //создать таймер, используемый как основной цикл

    _mainLoop.Stop();
    _mainLoop.Interval = TimeSpan.Zero;

    //подключить события
    _mainLoop.Tick += new EventHandler(mainLoop_Tick);

    StartGame();
}

Во фрагменте кода выше есть закрытая переменная по имени _mainLoop. _mainLoop является стандартным объектом таймера .NET. Он имеет методы Start() и Stop() и событие по имени Tick. Для перерисовки объектов на экране обработчик события подключается к событию Tick.

Чтобы игра работала, пришлось разработать код для перерисовки астероидов, звезд и редкого НЛО. Также надо перерисовывать корабль и его снаряды. Для упрощения каждая категория объектов хранится в отдельном списке объектов. Были созданы отдельные методы вроде DrawAsteroids, проходящего по списку объектов астероидов и обновляющего их положения.

Метод DrawAsteroids показан ниже. Он проходит по списку объектов в цикле и вызывает метод MoveForward класса Asteroid. Также в цикле проверяется, ушел ли астероид с экрана. Если астероид ушел с экрана, то изменяется координата X или Y, чтобы астероид вновь появился на строго противоположной стороне экрана. Так работает оригинальная игра с астероидами.

void DrawAsteriods() {
   for(int i = _asteroids.Count - 1; i >= 0; i--) {
     Asteroid a = _asteroids[i];
     a.MoveForward();
     if(a.X >= (this.Width - a.Width))
       a.X = 1;
     else if(a.X <= 0)
       a.X = this.Width - a.Width;
     if(a.Y >= (this.Height - a.Height))
       a.Y = 1;
     else if(a.Y <= 0)
       a.Y = this.Height - a.Height;
   }
}

Метод MoveForward астероида очень простой. Он заставляет вспомнить школьную математику! На первом шаге градусы переводятся в радианы. Затем обновляется координата X с помощью метода Sin, принимающего значение радиан в качестве входного параметра. Затем результат умножается на коэффициент скорости. Координата Y вычисляется так же, исключая использование метода Cos.

public void MoveForward()
{
   double radians = Math.PI * _angle / 180.0;
   X += Math.Sin(radians) * SPEED;
   Y -= Math.Cos(radians) * SPEED;
}

Обработка клавиатурных событий

Одной из проблем, возникших при разработке игры, была обработка клавиатурных событий. Вскоре выяснилось, что нельзя полагаться на стандартное событие KeyDown, поскольку оно не возбуждалось должным образом. Игра должна была очень быстро реагировать на нажатие клавиши пользователем. Стандартная обработка событий не подходила. В результате поиска в интернете был найден класс KeyState. Класс KeyState является статическим классом, отвечающим за обработку всех событий отпускания клавиши и нажатия клавиши. Он хранит состояние клавиш, которые были нажаты, и обеспечивает более быструю реакцию игры. Для проверки, была ли нажата клавиша, вызывается метод GetKeyState. Чтобы подключить класс KeyState, надо вызвать метод HookEvents. Код ниже заставляет корабль двигаться по экрану.

if(KeyState.GetKeyState(Key.Up) == true) {
   ship.Thrust();
}
else {
   ship.Drift();
}

Во фрагменте выше проверяется, нажал ли пользователь стрелку вверх на клавиатуре. Если да – вызывается метод Thrust(создать тягу). Thrust похож на метод  MoveForward астероида, не считая того, что он отображает маленькое пламя за кораблем, дающее иллюзию зажигания ракетного двигателя.

Динамический XAML

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

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

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

public Asteroid(AsteroidSize size, Canvas parent )

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

public string GetPathData()
{
   int radius = (int)_size * BASE_RADIUS;
   string pathData = String.Empty;
   for (int i = 0; i < 18; i++)
   {
      float degrees = i * 20;
      Point pt = CreatePointFromAngle(degrees,
                   radius * (rand.Next(70,99) * .01));
      if (degrees == 0) {
         pathData += string.Format("M{0},{1} L",
                       (int)pt.X + radius, (int)pt.Y + radius);
      }
      else{
         pathData += string.Format("{0},{1} ",
                       (int)pt.X + radius, (int)pt.Y + radius);
      }
   }
   pathData += "z";
   return String.Format("<Path xmlns='http://schemas.microsoft.com/" +
                        "winfx/2006/xaml/presentation' xmlns:x='http://schemas." +
                        "microsoft.com/winfx/2006/xaml\' Data='{0}'/>",
                        pathData);
}

Обнаружение столкновений

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

Метод CheckCollision проводит двухходовую проверку. Во-первых, он проверяет, пересекается ли внешний прямоугольник вокруг объекта A с объектом B. Полезно представить, что каждый элемент заключен в квадрат или прямоугольник. К тому же, во время отладки код астероидов был изменен так, чтобы вокруг всех них была яркая желтая рамка. Это помогло наглядно увидеть происходящее.

Если внешние прямоугольники объектов пересекаются, то производится вторая, более точная проверка. Теперь проверяются отдельные подробные траектории этих объектов, чтобы узнать, совпадают ли отдельные пиксели. Если да, то наблюдается столкновение.

Вероятно, есть более изящные или надежные способы обнаружения столкновений.

// <summary>
/// Определяет, сталкиваются ли два элемента,
/// с помощью 2-ходовой проверки (пересечение прямоугольников, затем проверка совпадения)
/// </summary>
/// <param name="control1">Контейнерный управляющий элемент для первого элемента</param>
/// <param name="controlElem1">Первый элемент</param>
/// <param name="control2"> Контейнерный управляющий элемент для второго элемента</param>
/// <param name="controlElem2">Второй элемент</param>
/// <returns>Истина, если объекты сталкиваются, иначе ложь</returns>
public static bool CheckCollision(FrameworkElement control1,
              FrameworkElement controlElem1, FrameworkElement control2,
              FrameworkElement controlElem2) {
   // сначала проверяется, сталкиваются ли прямоугольники спрайтов
   Rect rect1 = UserControlBounds(control1);
   Rect rect2 = UserControlBounds(control2);
   rect1.Intersect(rect2);

   if(rect1 == Rect.Empty) {
      // нет столкновения - уходи!
      return false;
   } else {
     bool bCollision = false;
     Point ptCheck = new Point();
     // теперь производится более точная проверка совпадения пикселей
     for(int x = Convert.ToInt32(rect1.X); x <
         Convert.ToInt32(rect1.X + rect1.Width); x++) {
        for(int y = Convert.ToInt32(rect1.Y); y <
            Convert.ToInt32(rect1.Y + rect1.Height); y++) {
           ptCheck.X = x;
           ptCheck.Y = y;
           List<UIElement> hits = (List<UIElement>)
             System.Windows.Media.VisualTreeHelper.
             FindElementsInHostCoordinates(ptCheck, control1);
           if(hits.Contains(controlElem1)) {
              // есть совпадение в первом управляющем элементе,
              // проверяется, есть ли аналогичное совпадение во втором элементе
              List<UIElement> hits2 = (List<UIElement>)
                System.Windows.Media.VisualTreeHelper.
                FindElementsInHostCoordinates(ptCheck, control2);
              if(hits2.Contains(controlElem2)) {
                 bCollision = true;
                 break;
              }
     }
   }
   if(bCollision)
     break;
   }
   return bCollision;
  }
}

Интересные особенности

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