Pull to refresh

Игровой цикл в Silverlight

Reading time9 min
Views4.3K
В этой статье, опишу использование в Silverlight игрового цикла. Функции, которая содержит всю логику по работе с анимацией и обработки действий пользователя, влияющие на анимацию. На примере управление машинкой.

Кликните на картинке, что бы посмотреть пример

Silverlight имеет отличную поддержку анимации. Дергаешь свойства в Blend, ставишь ключики. И все работает, контролы реагируют на действия пользователя. Программно, через Storyboard, анимация изменения свойства из состояния А в состояние Б, без происшествий по середине, тоже делается легко. Но когда дело доходит до сложной анимации (физика, расчет столкновений, динамическое изменение анимационной кривой), то реализация анимации через Storyboard значительно усложняет код или вообще не возможна.


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

В Silverlight это могут быть таймеры:
  • System.Windows.Threading.DispatcherTimer – работающий в потоке UI
  • System.Threading.Timer – работающий в отдельном потоке.

Но более правильным для реализации программной анимации в Silverlight будет использовать глобальное событие перерисовки Rendering, статического класса System.Windows.Media.CompositionTarget.

Пример кода, простого игрового цикла:

//Время последней перерисовки
private int _lastTime;
private void MainPage_Loaded(object sender, RoutedEventArgs e){
         //создаем игровой цикл, подключаемся к событию перерисовки 
         CompositionTarget.Rendering += _gameLoopUpdate;
         
         _lastTime = Environment.TickCount;
}

private void _gameLoopUpdate(object sender, EventArgs e){
         int currentTime = Environment.TickCount;
         int frameTime = currentTime - _lastTime;
         _lastTime = currentTime;

	 //Код для обработки анимации
	
}


Переменная frameTime содержит время, прошедшее с последней перерисовки. Стоит учитывать, что это время может сильно изменяться. Что бы избежать резких рывков между кадрами, нужно делать изменяемые свойства зависимыми от этого времени. Важно понимать, что fps(частота кадров в секунду) равная 60, не означает, что каждый кадр появляется после 16 миллисекунд.
В принципе, это все, дальше осталось только написать свою анимационную логику. Т.к. Silverlight сам отвечает за перерисовку объектов, нужно только заполнять свойства значениями и не заботиться об остальном

Живой пример


Для примера создадим «игру», машинка в коробке. Стрелками на клавиатуре управляем автомобилем. Лево, право, газ, задний ход/тормоз, все как везде.
Создайте новый проект Silverlight. Откройте «MainPage.xaml». В главный Grid, по имени «LayoutRoot» поместите:

<Grid Margin="12" Background="#FF084E0F"/>        
<Viewbox IsHitTestVisible="False" Margin="12">
       	<TextBlock TextWrapping="Wrap" Text="Управлять стрелками" Foreground="#FF00228D" 
                     RenderTransformOrigin="0.5,0.5" Margin="10,0" Width="129" Height="16">
       		<TextBlock.RenderTransform>
       			<CompositeTransform Rotation="-35"/>
       		</TextBlock.RenderTransform>
       	</TextBlock>
</Viewbox>        
<Canvas x:Name="ContentPanel" Margin="12">
        <TextBlock x:Name="tbInfo" Text="TextBlock"  Foreground="White" FontSize="16" />
        <Image x:Name="car" Width="70" Source="car.png" Height="35" />           
</Canvas>


Картинку “car.png” можете использовать свою или эту:
car.png

Главное что бы нос машины смотрел вправо.

Проблемы с заданием размеров при анимации.

Есть несколько проблем, которые могут возникнуть при работе с анимацией из за размеров анимированных объектов:
  • При установленных размерах ширины или высоты в Auto, не учитывается изменение центра вращения, соответственно могут быть проблемы с анимацией вращения. Устанавливайте размеры в абсолютных единицах.
  • При старте, ActualWidth и ActualHeight равны нулю, т.к. еще не отрисован не один объект. Учитывайте это в вычислениях.


Игровой мир создан. Добавляем логику.

Обработка нажатий клавиш


С клавишами не все так просто. Изначально Silverlight не поддерживает определение одновременно нажатых клавиш, не считая модификаторов: Ctrl, Shift, Alt. Событие KeyDown обрабатывает только последнюю нажатую клавишу. Т.е. придется жать по одной клавише за раз. Например, зажали газ, и пока газуем, налево не повернуть. Как вариант решения, вешать газ/тормоз на Ctrl ,Shift и рулить двумя руками. Неудобно получается…

Но не все так плохо, а даже хорошо. На просторах интернета (Detecting multiple keypresses in Silverlight) нашелся класс, который решает эту проблему и даже позволяет обрабатывать нажатие клавиш внутри игрового цикла, что гораздо удобнее, чем внутри обработчика KeyDown.

Добавьте в проект класс:

   //Заводим список клавиш. При событие нажатия кнопки, 
   //помечаем кнопку в списке, как нажатую.
   //Убираем пометку при отпуске клавиши и при потери фокуса
   public sealed class KeyHandler
   {
      private bool[] isPressed = new bool[256];
      private UserControl targetCanvas = null;

      public void ClearKeyPresses(){
         for (int i = 0; i < 256; i++){
            isPressed[i] = false;
         }
      }

      public void ClearKey(Key k){
         isPressed[(int) k] = false;
      }

      public void Attach(UserControl target){
         ClearKeyPresses();
         targetCanvas = target;
         target.KeyDown += new KeyEventHandler(target_KeyDown);
         target.KeyUp += new KeyEventHandler(target_KeyUp);
         target.LostFocus += new RoutedEventHandler(target_LostFocus);
      }

      public void Detach(UserControl target){
         target.KeyDown -= new KeyEventHandler(target_KeyDown);
         target.KeyUp -= new KeyEventHandler(target_KeyUp);
         target.LostFocus -= new RoutedEventHandler(target_LostFocus);
         ClearKeyPresses();
      }

      private void target_KeyDown(object sender, KeyEventArgs e){
         isPressed[(int) e.Key] = true;
      }

      private void target_KeyUp(object sender, KeyEventArgs e){
         isPressed[(int) e.Key] = false;
      }

      private void target_LostFocus(object sender, EventArgs e){
         ClearKeyPresses();
      }

      public bool IsKeyPressed(Key k){
         int v = (int) k;
         if (v < 0 || v > 82) return false;
         return isPressed[v];
      }

      public bool IsKeyPressed(Key[] keys){
         foreach (Key k in keys){
            if (this.IsKeyPressed(k))
               return true;
      }

         return false;
      }
   }



Анимация


Добавьте в MainPage.xaml.cs следующие глобальные переменные:
//Обработчик вызовов клавиатуры
private KeyHandler _keyHandler = new KeyHandler();
// Трансформация для вспомогательных вычислений угла поворота
private RotateTransform _rotateHelper = new RotateTransform();
      
// Трансформации контролирующие положение машины в пространстве
private TranslateTransform _translateTransform = new TranslateTransform();
private RotateTransform _rotateTransform = new RotateTransform();


Анимацию автомобиля будем делать через свойство RenderTransform. Оно отвечает за перемещение, вращение и др.

Т.к. трансформации анимирует объект относительно его стартового положения. Убедитесь, что для объекта car(нашей машины) не заданы Canvas.Top и Canvas.Left.

Заполните конструктор главного окна:
public MainPage(){
         InitializeComponent();
         this.Loaded += MainPage_Loaded;
         
         //Задаем итоговую трансформацию для машины
         var tg = new TransformGroup();
         tg.Children.Add(_rotateTransform);
         tg.Children.Add(_translateTransform);
         car.RenderTransform = tg;

         //Задаем стартовую позицию машины
         _translateTransform.X = 50;
         _translateTransform.Y = 50;
         
         car.MouseLeftButtonDown += car_MouseLeftButtonDown;

         //Начинаем слушать нажатия клавиш
         _keyHandler.Attach(this);
}

Функция обработки нажатия мыши, нужна по большой части просто показать, что машина ловит события от мыши:
      //При клике по машине ее скорость возрастет
      void car_MouseLeftButtonDown(object sender, MouseButtonEventArgs e){
         _speedPerSecond += 300;
      }

При отображение окна, установим фокус на плагин Silverlight, иначе он не будет ловить нажатия клавиатуры. В некоторых браузерах, это не помогает, тогда нужно установить фокус в ручную, т.е. просто кликнуть по нашей игрушке. Также, подготовим игровой цикл.
private void MainPage_Loaded(object sender, RoutedEventArgs e){
         //При старте переводим фокус на плагин, для того что бы работали горячие клавиши
         System.Windows.Browser.HtmlPage.Plugin.Focus();

         //создаем игровой цикл, подключаемся к событию перерисовки 
         CompositionTarget.Rendering += _gameLoopUpdate;
         
         _lastTime = Environment.TickCount;
      }

Добавьте глобальные переменные, которые будут использоваться при анимации:
      // Вектор направления движения
      private Point _direction = new Point(1, 0);
      //Скорость движения
      private double _speedPerSecond ;
      //Множитель ускорения/замедления скорости в секунду, >1 ускоряет,<1 замедляет
      private double _accelerationRatioPerSec = 0.60;
      //Время последней перерисовки
      private int _lastTime;

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


Для расчета пересечения машины с границей ее контейнера: вычислим ограничивающий машину прямоугольник, с учетом угла разворота автомобиля.


Пора переходить к сердцу игры, функции игрового цикла:
      private void _gameLoopUpdate(object sender, EventArgs e){
         int currentTime = Environment.TickCount;
         int frameTime = currentTime - _lastTime;
         _lastTime = currentTime;
         
         //Прошедшее время с прошлой перерисовки в секундах
         //Служит для снижения эффекта от разного промежутка времени между кадрами
         double delta = (double)frameTime / 1000;

         //Получаем текущую позицию и угол поворота машины
         double xCurrentPos = _translateTransform.X; 
         double yCurrentPos = _translateTransform.Y; 
         double currentAngle = _rotateTransform.Angle;

         //Обрабатываем нажатие клавиш
         double addToSpeedPerSec = 300;
         if (_keyHandler.IsKeyPressed(Key.Up)){
            _speedPerSecond += addToSpeedPerSec*delta;
         }
         if (_keyHandler.IsKeyPressed(Key.Down)){
            _speedPerSecond -= addToSpeedPerSec * delta;
         }

         //Размер угла поворота зависит от скорости машины
         double targetAngle = currentAngle;
         double coefAngle = .5;
         if (_keyHandler.IsKeyPressed(Key.Right)){
            targetAngle += _speedPerSecond * coefAngle*delta;
         }
         if (_keyHandler.IsKeyPressed(Key.Left)){
            targetAngle -= _speedPerSecond * coefAngle * delta;
         }

         //Поворачиваем машину относительно центра 
         _rotateHelper.CenterX = car.ActualWidth / 2;
         _rotateHelper.CenterY = car.ActualHeight / 2;
         _rotateHelper.Angle = targetAngle;

         //Отдельно поворачиваем вектор движения
         _rotateHelper.CenterX = 0;
         _rotateHelper.CenterY = 0;
         _direction = _rotateHelper.Transform(new Point(1, 0));

         //Постоянно сбрасываем скорость
         _speedPerSecond += (_speedPerSecond*_accelerationRatioPerSec - _speedPerSecond)*delta;

         //Итоговое расстояние, которое будет пройдено за текущий кадр 
         double stepPerFrame = _speedPerSecond * delta;
         
         //Вычисляем следующее местоположение
         double xTargetPos = xCurrentPos + _direction.X * stepPerFrame;
         double yTargetPos = yCurrentPos + _direction.Y * stepPerFrame;

         //Вычисляем размер и положение ограничивающей  машину рамки с учетом угла поворота
         Rect borderRect = _rotateHelper.TransformBounds(new Rect(0, 0, car.ActualWidth, car.ActualHeight));
         borderRect.X = borderRect.X + xTargetPos;
         borderRect.Y = borderRect.Y + yTargetPos;

         //Проверка на выезд за стену
         if (isInContentPanel(borderRect)){
            ////Задаем новую позицию и угол 
            _translateTransform.X = xTargetPos;
            _translateTransform.Y = yTargetPos;
            _rotateTransform.Angle = targetAngle;
         }
         else{
            //Сбрасываем скорость, если уперлись в стену
            _speedPerSecond = 0;
         }
         
         //Выводим скорость
         tbInfo.Text = string.Format("Speed:{0:F1}", _speedPerSecond);
      }

      //Проверяем не вышла ли машина, за пределы окна
      private bool isInContentPanel(Rect shapeBorder){
         if (0 <= shapeBorder.Left && shapeBorder.Right <= ContentPanel.ActualWidth)
            if (0 <= shapeBorder.Top && shapeBorder.Bottom <= ContentPanel.ActualHeight)
               return true;

         return false;
      }

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

При всех изменениях учитываем время прошедшее от предыдущего кадра(delta). Для простоты восприятия скорость измеряется в единицах в секунду. Угол поворота увеличивается со скоростью автомобиля.
Стоит отметить обработку нажатий клавиш. Т.к. они обрабатываются в игровом цикле, вносимые ими изменения не нужно отдельно синхронизировать. А такая проблема бы возникла в случае, если бы код изменяющий скорость и угол поворота вызывался из обработчика KeyDown.

Итоги


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

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

К тому же, не обязательно реализовывать все в лоб. Пример выше легко переписать в Behavior. Прицепляем в Blend`е, к кнопке такое поведение, и вот она гоняет по всему экрану (зачем оно такое нужно не понятно, но можно). В ресурсах ниже, ссылка на обертку над физической библиотекой, которая в принципе делает то же самое: в Blend помечаем объекты и они становятся чувствительны к законам физики.

Исходный код “машинки в коробке” можно скачать здесь или со страницы примера.

Ресурсы:
  1. Physics Helper for Blend, Silverlight, WP7 and Farseer — Silverlight обертка над физической библиотекой Farseer Physics Engine (http://farseerphysics.codeplex.com/). Именно в ее коде нашел, как в Silverlight обрабатывается сложная анимация.
  2. Custom Animations in Silverlight — использование игрового цикла на примере маятника. Статья на английском.
Tags:
Hubs:
+14
Comments27

Articles

Change theme settings