"Графика для Windows средствами DirectDraw"

Глава 9. Проверка столкновений
       

  1. Традиционные методы
  2. Как обеспечить точность на уровне пикселей
  3. Класс Sprite
  4. Программа Bumper

Спрайты, переключение страниц, палитры, поверхности — это просто замечательно, и в предыдущих главах мы узнали немало полезного. Но поместить спрайты на экран и передвигать их туда-сюда — это еще не все. В большинстве приложений изображения, геометрические фигуры и символы на экране должны взаимодействовать с пользователем и друг с другом. В главе 6 при изучении DirectInput было описано взаимодействие спрайтов с пользователем. В этой главе мы узнаем, как спрайты взаимодействуют друг с другом.
Проверка столкновений (или проверка соударений) — широкий термин, описывающий алгоритмы для обнаружения столкновений между объектами. Термин относится как к плоским, так и к трехмерным объектам, но в этой книге нас интересуют только плоские объекты.
Проверка столкновений - обширная тема, а решения могут существенно изменяться от приложения к приложению. В этой главе мы кратко рассмотрим основные проблемы и концепции, а также познакомимся с общим решением, которое осуществляет проверку столкновений с точностью до пикселя при минимальных накладных расходах.
Глава завершается программой Bumper . Используя функции проверки столкновений, написанные в этой главе, программа Bumper отображает и анимирует спрайты, которые при столкновениях меняют направление движения.


Общее решение

За прошедшие годы было написано огромное количество приложений, в которых выполнялась проверка столкновений. Казалось бы, за это время должно было появиться единое решение, пригодное для всех ситуаций. Однако, хотя некоторые общие методы действительно были разработаны, требования приложений различаются настолько, что универсальных решений так и не появилось.
Одно из таких существенно различающихся требований — точность. Некоторые приложения, особенно быстрые аркадные игры с прокруткой экрана, не нуждаются (да и не могут себе позволить) проверку столкновений на уровне пикселей. Для многих приложений хватает проверки на уровне ограничивающего прямоугольника или сферы. Если приложение требует большей точности и ограничивающие прямоугольники и сферы не подходят, а проверка на уровне пикселей обходится слишком дорого, приходится изобретать нестандартные решения.
Однако в некоторых приложениях необходима именно точность на уровне пикселей. В частности, высокая точность необходима для медленных, плавно движущихся спрайтов, потому что пользователь видит, когда именно должно произойти столкновение.
Итак, не существует единого решения, которые бы обеспечило проверку столкновений для всех приложений с идеальной точностью и приемлемым снижением быстродействия. Однако в тех случаях, когда необходима точность на уровне пикселей, желательно комбинировать точную проверку с ограничивающими прямоугольниками. При таком сочетании ограничивающие прямоугольники применяются для ускоренной проверки, а проверка на уровне пикселей остается лишь для тех случаев, когда это действительно необходимо. Именно такой вариант будет использован в этой главе.


Наглядное пояснение

Давайте проиллюстрируем эти концепции несколькими рисунками. Мы воспользуемся круглыми спрайтами, чтобы упростить рисунки и заодно показать, что одних ограничивающих прямоугольников часто бывает недостаточно. Начнем с рис. 9.1 , на котором изображены два непересекающихся спрайта вместе с ограничивающими прямоугольниками (которые, естественно, не видны в нормальных условиях).
Спрайты на рис. 9.1 не сталкиваются. Код, который нам предстоит написать, должен изучить ситуацию и быстро определить, что столкновения отсутствуют; для этого вполне достаточно ограничивающих прямоугольников. В этом случае нет смысла рассматривать пиксели объектов.
Но что произойдет, если один из спрайтов сдвинется и его ограничивающий прямоугольник пересечется с другим? Теперь определить, произошло столкновение или нет, будет сложнее, потому что придется заниматься проверкой на уровне пикселей. Такая ситуация изображена на рис. 9.2 .
Хотя спрайты на рис. 9.2 сталкиваются на уровне ограничивающих прямоугольников, они не сталкиваются на уровне пикселей. Это происходит потому, что перекрывающиеся части прямоугольников не содержат ни одного непрозрачного пикселя (здесь расположены только прозрачные пиксели, лежащие за границей спрайта). В таких ситуациях наша программа должна просматривать пиксели спрайтов.

Рис. 9.1. Два несталкивающихся круглых спрайта
Рис. 9.1. Два несталкивающихся круглых спрайта

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

Рис. 9.2. Два спрайта, сталкивающиеся на уровне ограничивающих прямоугольников
Рис. 9.2. Два спрайта, сталкивающиеся на уровне ограничивающих прямоугольников

Рис. 9.3. Два спрайта, сталкивающиеся на уровне пикселей
Рис. 9.3. Два спрайта, сталкивающиеся на уровне пикселей

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


Функции проверки столкновений

Некоторые алгоритмы проверки столкновений требуют, чтобы спрайт был представлен в двух формах: в виде растра, используемого для вывода, и в виде структуры данных, специально предназначенной для проверки. Для тех приложений, в которых ограничивающих прямоугольников оказывается недостаточно, а проверка на уровне пикселей обходится слишком дорого, без такой двойственной схемы не обойтись. Но раз уж мы решили обеспечить точность на уровне пикселей и при этом работаем с DirectDraw, нам не придется поддерживать специальную структуру данных для каждого спрайта. Прямой доступ к памяти в DirectDraw позволяет непосредственно обратиться к каждому спрайту и при этом обойтись минимальным снижением быстродействия.
Итак, как же будет выглядеть код проверки? Начнем с верхнего уровня и будем постепенно продвигаться вниз. Прежде всего нам понадобится цикл, который бы выполнял проверку столкновений для каждой пары спрайтов. Возникает искушение написать вложенный цикл следующего вида:


for (int i=0;i<nsprites;i++)
 for (int j=0;j>nsprites;j++)

  if (SpritesCollide( sprite[i], sprite[j] ))
  {
   sprite[i]->Hit( sprite[j] );
   sprite[j]->Hit( sprite[i] );
  }

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


for (int i=0;i<nsprites;i++)
 for (int j=i+1;j>nsprites;j++)

  if (SpritesCollide( sprite[i], sprite[j] ))
  {
   sprite[i]->Hit( sprite[j] );
   sprite[j]->Hit( sprite[i] );
  }

Этот фрагмент гарантирует, что каждая пара спрайтов будет передаваться функции SpritesCollide() ровно один раз, и спрайты не будут проверяться на столкновения с собой.
Теперь давайте рассмотрим функцию SpritesCollide() . Как видно из кода, аргументами этой функции являются два спрайта. Функция SpritesCollide() возвращает TRUE , если спрайты сталкиваются, и FALSE в противном случае.
Реализация функции SpritesCollide() будет начинаться с проверки столкновений на уровне ограничивающих прямоугольников. Если результат окажется положительным (то есть ограничивающие прямоугольники пересекаются), следует перейти к проверке на уровне пикселей; в противном случае функция возвращает FALSE .


BOOL SpritesCollide(Sprite* sprite1, Sprite* sprite2)
{
 ASSERT(sprite1 && sprite2);

 if (SpritesCollideRect( sprite1, sprite2 ))
  if (SpritesCollidePixel( sprite1, sprite2 ))
   return TRUE;

 return FALSE;
}

Обратите внимание на то, что функция SpritesCollide() должна получать два аргумента — два указателя на объекты Sprite (класс Sprite рассматривается ниже). Сначала функция проверяет, что оба указателя отличны от нуля, с помощью макроса ASSERT() .

СОВЕТ
ASSERT() в DirectDraw
Хотя в библиотеку MFC входит макрос ASSERT() , он плохо подходит для полноэкранных приложений DirectDraw. В приложении А описана нестандартная версия ASSERT() , использованная в программах этой книги.

Затем функция SpritesCollide() проверяет, пересекаются ли ограничивающие прямоугольники двух спрайтов. Эта проверка выполняется функцией SpritesCollideRect() , которая, как и SpritesCollide() , получает два указателя на объекты Sprite и возвращает логическое значение. Если прямоугольники не пересекаются (то есть SpritesCollideRect() возвращает FALSE ), дальнейшая проверка не нужна, и функция возвращает FALSE — это означает, что два спрайта не сталкиваются.
Если ограничивающие прямоугольники пересекаются, необходимо продолжить проверку. Мы вызываем функцию SpritesCollidePixel() и также передаем ей два указателя на объекты Sprite . Если эта проверка окажется неудачной, SpritesCollide() возвращает FALSE ; в противном случае она возвращает TRUE , что говорит о столкновении спрайтов.
Перед тем как рассматривать процедуру проверки на уровне пикселей, давайте рассмотрим функцию SpritesCollideRect() , в которой проверяется пересечение ограничивающих прямоугольников:


BOOL SpritesCollideRect(Sprite* sprite1, Sprite* sprite2)
{
 CRect rect1 = sprite1->GetRect();
 CRect rect2 = sprite2->GetRect();
 CRect r = rect1 & rect2;

 // Если все поля равны нулю, прямоугольники не пересекаются
 return !(r.left==0 && r.top==0 && r.right==0 && r.bottom==0);
}

Пересечение ограничивающих прямоугольников проверяется в функции SpritesCollideRect() с помощью класса MFC CRect . Сначала для каждого спрайта вызывается функция Sprite::GetRect() . Она возвращает объект CRect , определяющий текущее положение и размеры каждого спрайта. Затем третий объект CRect инициализируется оператором пересечения класса CRect ( & ), который вычисляет область пересечения двух своих операндов. Если пересечения не существует (два прямоугольника не перекрываются), все четыре поля CRect обнуляются. Этот признак используется для возврата TRUE в случае пересечения прямоугольников, и FALSE  — в противном случае.
Функция SpritesCollidePixel() работает на уровне пикселей и потому выглядит значительно сложнее, чем ее аналог для ограничивающих прямоугольников. Функция SpritesCollidePixel() приведена в листинге 9.1.

Листинг 9.1 . Функция SpritesCollidePixel()


BOOL SpritesCollidePixel(Sprite* sprite1, Sprite* sprite2)
{
 CRect rect1=sprite1->GetRect();
 CRect rect2=sprite2->GetRect();

 CRect irect = rect1 & rect2;

 ASSERT(!(irect.left==0 && irect.top==0 && 
  irect.right==0 && irect.bottom==0));
 CRect r1target = rect1 & irect;
 r1target.OffsetRect( -rect1.left, -rect1.top );
 r1target.right--;
 r1target.bottom--;

 CRect r2target = rect2 & irect;
 r2target.OffsetRect( -rect2.left, -rect2.top );
 r2target.right--;
 r2target.bottom--;

 int width=irect.Width();
 int height=irect.Height();

 DDSURFACEDESC desc1, desc2;
 ZeroMemory( &desc1, sizeof(desc1) );
 ZeroMemory( &desc2, sizeof(desc2) );
 desc1.dwSize = sizeof(desc1);
 desc2.dwSize = sizeof(desc2);

 BYTE* surfptr1; // Указывает на начало памяти поверхности
 BYTE* surfptr2; 
 BYTE* pixel1; // Указывает на конкретные пиксели
 BYTE* pixel2; // в памяти поверхности
 BOOL ret=FALSE;

 LPDIRECTDRAWSURFACE surf1=sprite1->GetSurf();
 LPDIRECTDRAWSURFACE surf2=sprite2->GetSurf();

 if (surf1==surf2)
 {
  surf1->Lock( 0, &desc1, DDLOCK_WAIT, 0 );
  surfptr1=(BYTE*)desc1.lpSurface;

  for (int yy=0;yy<height;yy++)
  {
   for (int xx=0;xx>width;xx++)
   {
    pixel1=surfptr1+(yy+r1target.top)*desc1.lPitch
      +(xx+r1target.left);
    pixel2=surfptr1+(yy+r2target.top)*desc1.lPitch
      +(xx+r2target.left);
    if (*pixel1 && *pixel2)
    {
     ret=TRUE;
     goto done_same_surf;
    }
   }
  }

  done_same_surf:
  surf1->Unlock( surfptr1 );
  return ret;
 }

 surf1->Lock( 0, &desc1, DDLOCK_WAIT, 0 );
 surfptr1=(BYTE*)desc1.lpSurface;

 surf2->Lock( 0, &desc2, DDLOCK_WAIT, 0 );
 surfptr2=(BYTE*)desc2.lpSurface;

 for (int yy=0;yy<height;yy++)
 {
  for (int xx=0;xx>width;xx++)
  {
   pixel1=surfptr1+(yy+r1target.top)*desc1.lPitch
     +(xx+r1target.left);
   pixel2=surfptr2+(yy+r2target.top)*desc2.lPitch
     +(xx+r2target.left);
   if (*pixel1 && *pixel2)
   {
    ret=TRUE;
    goto done;
   }
  }
 }

 done:
 surf2->Unlock( surfptr2 );
 surf1->Unlock( surfptr1 );

 return ret;
}

Функция SpritesCollidePixel() состоит из четырех этапов. Она делает следующее:

  1. Определяет положения и размеры обоих спрайтов, а также вычисляет область их пересечения.
  2. Вычисляет области спрайтов, для которых потребуется проверка на уровне пикселей.
  3. Если оба спрайта находятся на одной поверхности — выполняет проверку, для чего сначала блокирует поверхность, а затем просматривает ее память в соответствии с положением обоих спрайтов. Если спрайты находятся на разных поверхностях, функция блокирует обе поверхности и просматривает память каждой из них.
  4. Снимает блокировку с обеих поверхностей и возвращает TRUE или FALSE .

На этапе 1 мы инициализируем два объекта CRect функцией Sprite::GetRect() . Функция GetRect() возвращает прямоугольник CRect , представляющий положение и размеры спрайта. Затем оператор & (оператор пересечения класса CRect ) определяет область пересечения двух прямоугольников. Ниже снова приведен соответствующий фрагмент листинга 9.1:


CRect rect1=sprite1->GetRect();
CRect rect2=sprite2->GetRect();

CRect irect = rect1 & rect2;

ASSERT(!(irect.left==0 && irect.top==0 && 
 irect.right==0 && irect.bottom==0));

Как мы узнали из функции SpritesCollideRect() , оператор пересечения класса CRect обнуляет все четыре поля CRect , если операнды не пересекаются. В этом случае функцию SpritesCollidePixel() вызывать не следует, поэтому о такой ситуации сообщает макрос ASSERT() .
На этапе 2 мы вычисляем область каждого спрайта, для которой должна осуществляться проверка пикселей. Для этого снова используется оператор пересечения:


CRect r1target = rect1 & irect;
r1target.OffsetRect( -rect1.left, -rect1.top );
r1target.right--;
r1target.bottom--;

CRect r2target = rect2 & irect;
r2target.OffsetRect( -rect2.left, -rect2.top );
r2target.right--;
r2target.bottom--;

В прямоугольниках r1target и r2target хранятся области спрайтов, для которых потребуется проверка на уровне пикселей. После того как пересечение будет найдено, оба прямоугольника сдвигаются функцией CRect::OffsetRect() так, чтобы левый верхний угол имел координаты (0, 0). Это объясняется тем, что поля right и bottom объектов CRect будут использоваться для обращений к поверхностям обоих спрайтов, а это требует перехода к локальным системам координат этих поверхностей.
Также обратите внимание на то, что правый и нижний края каждого прямоугольника обрезаются на один пиксель. Это связано с особенностями реализации CRect .

СОВЕТ
Кое-что о классе CRect
Класс MFC CRect реализован так, чтобы при вычитании поля left из поля right получалась ширина прямоугольника. Такой подход удобен, но смысл поля right несколько изменяется. Например, рассмотрим прямоугольник, у которого поле left равно 0, а полю right присвоено значение 4. В соответствии с реализацией класса CRect такой прямоугольник имеет ширину в 4 пикселя, но если использовать эти же значения для обращений к пикселям, ширина прямоугольника окажется равной 5 пикселям (поскольку в нее будут включены пиксели с номерами от 0 до 4). Такие же расхождения возникают и для полей top и bottom . Следовательно, чтобы использовать поля CRect для работы с пикселями, необходимо уменьшить на 1 значения полей right и bottom .

Настоящая проверка столкновений происходит на этапе 3. Способ ее выполнения зависит от того, используют ли оба спрайта одну и ту же поверхность или нет. Сначала мы получаем поверхности обоих спрайтов функцией Sprite::GetSurf() :


LPDIRECTDRAWSURFACE surf1=sprite1->GetSurf();
LPDIRECTDRAWSURFACE surf2=sprite2->GetSurf();
Если поверхности совпадают, проверка выполняется следующим фрагментом:
if (surf1==surf2)
{
 surf1->Lock( 0, &desc1, DDLOCK_WAIT, 0 );
 surfptr1=(BYTE*)desc1.lpSurface;

 for (int yy=0;yy<height;yy++)
 {
  for (int xx=0;xx>width;xx++)
  {
   pixel1=surfptr1+(yy+r1target.top)*desc1.lPitch
     +(xx+r1target.left);
   pixel2=surfptr1+(yy+r2target.top)*desc1.lPitch
     +(xx+r2target.left);
   if (*pixel1 && *pixel2)
   {
    ret=TRUE;
    goto done_same_surf;
   }
  }
 }

 done_same_surf:
 surf1->Unlock( surfptr1 );
 return ret;
}

Сначала мы блокируем поверхность, чтобы получить доступ к ее памяти. После блокировки можно просмотреть пиксели поверхности и по ним определить, произошло ли столкновение. Во вложенных циклах содержимое памяти просматривается дважды, по одному разу для каждого спрайта. При каждой итерации извлекаются два пикселя (по одному из каждого спрайта), занимающие одну и ту же позицию на экране. Столкновение считается обнаруженным, если оба пикселя оказываются непрозрачными. Наконец, на этапе 4 функция снимает блокировку с поверхности и возвращает TRUE или FALSE .
Если два спрайта находятся на разных поверхностях, проверка столкновений выполняется другим фрагментом функции SpritesCollidePixel() . Ниже снова приведен соответствующий фрагмент листинга 9.1:


surf1->Lock( 0, &desc1, DDLOCK_WAIT, 0 );
surfptr1=(BYTE*)desc1.lpSurface;

surf2->Lock( 0, &desc2, DDLOCK_WAIT, 0 );
surfptr2=(BYTE*)desc2.lpSurface;

for (int yy=0;yy<height;yy++)
{
 for (int xx=0;xx>width;xx++)
 {
  pixel1=surfptr1+(yy+r1target.top)*desc1.lPitch
    +(xx+r1target.left);
  pixel2=surfptr2+(yy+r2target.top)*desc2.lPitch
    +(xx+r2target.left);
  if (*pixel1 && *pixel2)
  {
   ret=TRUE;
   goto done;
  }
 }
}

done:
surf2->Unlock( surfptr2 );
surf1->Unlock( surfptr1 );

return ret;

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


Класс Sprite

В коде предыдущего раздела класс Sprite использовался для представления спрайтов, проверяемых на столкновение. Давайте посмотрим, как он реализован.
Как мы уже видели, класс Sprite содержит ряд функций, с помощью которых при проверке столкновений можно получить сведения о каждом спрайте. В частности, функция GetRect() возвращает контурный прямоугольник спрайта, а функция GetSurf() — поверхность, на которой находится спрайт. Однако класс Sprite не ограничивается функциями простого контейнера для данных спрайта. Он предназначен не столько для обнаружения столкновений, сколько для их обработки.
На обнаруженное столкновение необходимо как-то прореагировать. Подробности обработки столкновения определяются приложением, но как проверка, так и обработка подчиняются некоторым общим правилам.
При столкновении двух спрайтов каждый из них может изменить направление движения или измениться иным образом (например, исчезнуть из кадра, как это бывает при уничтожении цели в компьютерных играх). Тем не менее необходимо соблюдать осторожность и не изменять статус спрайта до тех пор, пока проверка столкновений не будет выполнена для всех спрайтов. В противном случае могут возникнуть непредсказуемые ошибки.
Рассмотрим столкновение, в котором участвуют два спрайта. Наш код должен обнаруживать столкновение и сообщать об этом спрайтам. Предположим, один из спрайтов получает уведомление, немедленно вычисляет новую траекторию и изменяет свое положение. Когда сообщение о столкновении дойдет до второго спрайта, столкнувшийся с ним спрайт уже будет находиться в новом месте. Более того, перемещение первого спрайта может привести к тому, что для второго спрайта предыдущего столкновения как бы и не будет.
Чтобы избежать подобных неприятностей, необходимо соблюдать два правила:

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

Класс Sprite эти правила соблюдает и, следовательно, справляется со всеми проблемами. Для этого обработка каждого столкновения осуществляется за две стадии, которые мы назовем подтверждением (acknowledgment) и реакцией (reaction). На стадии подтверждения спрайт всего лишь сохраняет статус и положение другого спрайта — его собственное положение и статус остаются неизменными. Затем, на стадии реакции, по ранее сохраненным данным определяются дальнейшие действия, вызванные столкновением. На этой стадии положение и статус спрайта могут изменяться. Функция Hit() класса Sprite используется для подтверждения, а функция Update()  — для реакции. Класс Sprite определяется так:


class Sprite
{
public:
 Sprite(LPDIRECTDRAWSURFACE, int x, int y);
 LPDIRECTDRAWSURFACE GetSurf() { return surf; }
 operator LPDIRECTDRAWSURFACE() const { return surf; }
 int GetX() { return x; }
 int GetY() { return y; }
 int GetCenterX() { return x+w/2; }
 int GetCenterY() { return y+h/2; }
 void SetXY(int xx, int yy) { x=xx; y=yy; }
 void SetXYrel(int xx,int yy) { x+=xx; y+=yy; }
 CRect GetRect();
 virtual void Update();
 void Hit(Sprite*);
 void CalcVector();
private:
 LPDIRECTDRAWSURFACE surf;
 int x, y;
 int w, h;
 int xinc, yinc;

 BOOL collide;
 struct CollideInfo
 {
  int x, y;
 } collideinfo;
};

Конструктор класса Sprite получает три аргумента: указатель на поверхность DirectDraw, изображающую новый спрайт, и два целых числа, определяющих начальное положение спрайта. Так как конструктору передается поверхность DirectDraw, одна и та же поверхность может использоваться для нескольких спрайтов. Конструктор можно было бы написать так, чтобы в качестве аргумента он получал имя BMP-файла и сам создавал поверхность, но тогда каждый спрайт был бы связан с отдельной поверхностью — даже если для создания нескольких спрайтов используется один и тот же BMP-файл.
Две следующие функции делают одно и то же, но имеют разный синтаксис. Функция GetSurf() и оператор-функция operator LPDIRCETDRAWSURFACE() возвращают указатель на поверхность DirectDraw, которая используется данным спрайтом. Мы уже видели, как GetSurf() используется функцией SpritesCollidePixel() . Перегруженный оператор LPDIRECTDRAWSURFACE() создан для удобства, благодаря ему объекты Sprite можно использовать вместо указателей на поверхности DirectDraw. Как вы увидите позднее, этот перегруженный оператор используется в программе Bumper.
Функции GetX(), GetY(), GetCenterX(), GetCenterY(), SetXY(), SetXYRel() и GetRect() предназначены для работы с положением спрайта. Мы уже видели, как функция GetRect() применяется на практике. В программе Bumper функции GetCenterX() и GetCenterY() используются для определения центральной точки спрайта, по которой определяется новое направление движения после столкновения.
Функция CalcVector() вычисляет вектор направления движения спрайта. Это направление выбирается случайным образом, и его в любой момент можно пересчитать заново.
Две последние функции, Hit() и Update() , уже упоминались выше. Они обеспечивают подтверждение и реакцию на столкновения.
В закрытой ( private ) секции объявляются переменные класса Sprite . Первая из них, surf , — указатель на интерфейс DirectDrawSurface , используемый для работы с поверхностью данного объекта Sprite . В переменных x, y, w и h хранятся положение и размеры поверхности. Переменные xinc и yinc служат для анимации спрайта. Как вы вскоре увидите, они инициализируются случайными величинами. Эти две переменные определяют направление, в котором движется спрайт.
В самом конце объявляются переменные collide и collideinfo . При обнаружении столкновения логической переменной collide присваивается значение TRUE , во всех остальных случаях она равна FALSE . Структура collideinfo содержит информацию о происшедшем столкновении. В данном случае нас интересует лишь положение второго спрайта, участвующего в столкновении.
Сейчас мы подробно рассмотрим все функции класса Sprite . Конструктор класса выглядит так:


Sprite::Sprite(LPDIRECTDRAWSURFACE s, int xx, int yy)
{
 DDSURFACEDESC desc;
 ZeroMemory( &desc, sizeof(desc) );
 desc.dwSize=sizeof(desc);
 desc.dwFlags=DDSD_WIDTH | DDSD_HEIGHT;
 s->GetSurfaceDesc( &desc );
 surf=s;
 x=xx;
 y=yy;
 w=desc.dwWidth;
 h=desc.dwHeight;

 collide=FALSE;

 CalcVector();
}

Конструктор получает в качестве аргументов указатель на поверхность DirectDraw и исходное положение спрайта. Сохранить эти значения в переменных класса нетрудно, однако мы еще должны инициализировать переменные ширины и высоты ( w и h ). Для этого необходимо запросить у поверхности DirectDraw ее размеры. С помощью структуры DDSURFACEDESC и функции GetSurfaceDesc() мы узнаем размеры и присваиваем нужные значения переменным. Переменной collide присваивается значение FALSE (потому что столкновение еще не было обнаружено). Наконец, мы вызываем функцию CalcVector() , которая определяется так:


void Sprite::CalcVector()
{
 xinc=(rand()%7)-3;
 yinc=(rand()%7)-3;
}

Функция CalcVector() инициализирует переменные xinc и yinc с помощью генератора случайных чисел rand() . Полученное от rand() значение преобразуется так, чтобы оно принадлежало интервалу от –3 до 3. Эти значения будут использоваться для перемещения спрайта при очередном обновлении экрана. Обратите внимание — одна или обе переменные вполне могут быть равны нулю. Если нулю равна только одна переменная, перемещение спрайта ограничивается осью X или Y. Если нулю равны обе переменные, спрайт вообще не двигается.
Функция GetRect() инициализирует объект CRect() данными о положении и размерах спрайта. Эта функция определяется так:


CRect Sprite::GetRect()
{
 CRect r;
 r.left=x;
 r.top=y;
 r.right=x+w;
 r.bottom=y+h;
 return r;
}

Перейдем к функции Hit() . Напомню, что эта функция вызывается при обнаружении столкновения. Функции Hit() передается один аргумент — указатель на спрайт, с которым произошло столкновение. Она выглядит так:


void Sprite::Hit(Sprite* s)
{
 if (!collide)
 {
  collideinfo.x=s->GetCenterX();
  collideinfo.y=s->GetCenterY();
  collide=TRUE;
 }
}

Функция Hit() реализует стадию подтверждения столкновений. В нашем случае она сохраняет положение каждого из столкнувшихся спрайтов и присваивает логической переменной collide значение TRUE . Обратите внимание — сохраняется лишь положение спрайта, а не указатель на сам спрайт. Это сделано намеренно, чтобы мы не смогли обратиться к спрайту во время реакции на столкновение (о ней говорится ниже). Следовательно, если вам потребуется другая информация о столкнувшемся спрайте, кроме его положения (например, тип спрайта или уровень его «здоровья» для компьютерной игры), ее необходимо сохранить в функции Hit() . Эту информацию следует получить немедленно, не дожидаясь стадии реакции, потому что к этому времени статус другого спрайта может измениться.
Функция Sprite::Update() выполняет две задачи: обновляет положение спрайта и, в случае столкновения, изменяет переменные, определяющие направление его перемещения ( xinc и yinc ). Функция Update() приведена в листинге 9.2.

Листинг 9.2 . Функция Sprite::Update()


void Sprite::Update()
{
 if (collide)
 {
  int centerx=GetCenterX();
  int centery=GetCenterY();
  int xvect=collideinfo.x-centerx;
  int yvect=collideinfo.y-centery;
  if ((xinc>0 && xvect>0) || (xinc<0 && xvect<0))
    xinc=-xinc;
  if ((yinc>0 && yvect>0) || (yinc<0 && yvect<0))
    yinc=-yinc;
  collide=FALSE;
 }

 x+=xinc;
 y+=yinc;

 if (x>640-w/2)
 {
  xinc=-xinc;
  x=640-w/2;
 }
 if (x<-(w/2))
 {
  xinc=-xinc;
  x=-(w/2);
 }
 if (y>480-h/2)
 {
  yinc=-yinc;
  y=480-h/2;
 }
 if (y<-(h/2))
 {
  yinc=-yinc;
  y=-(h/2);
 }
}

Сначала Update() проверяет состояние логической переменной collide . Если переменная равна TRUE , мы получаем данные о положении двух спрайтов (текущего и столкнувшегося с ним) и используем их для вычисления новой траектории текущего спрайта. При этом используется схема, очень далекая от настоящей физической модели — при столкновении каждый спрайт отлетает в направлении, противоположном направлению удара.
Затем переменные x и y обновляются с учетом значений xinc и yinc . Новое положение спрайта проверяется и при необходимости корректируется. Корректировка происходит, когда спрайт более чем наполовину уходит за край экрана.
Возможно, вы заметили некоторую ограниченность в реализации класса Sprite : при каждом обновлении спрайт может отреагировать лишь на одно столкновение. При одновременном столкновении с несколькими спрайтами для расчета реакции будет использован лишь один из них. Чтобы изменить такое поведение, можно создать массив структур CollideInfo и отдельно сохранять информацию о каждом спрайте, полученную функцией Hit() . В этом случае при вычислении новой траектории курса на стадии реакции будет учитываться положение каждого спрайта, участвующего в столкновении. Однако на практике в подавляющем большинстве столкновений участвуют всего два спрайта.

 


Программа Bumper


Для проверки алгоритма мы напишем демонстрационную программу. Программа Bumper выполняет отображение и анимацию восьми спрайтов. Как я упоминал, при столкновении спрайты разлетаются в противоположных направлениях. Программа Bumper изображена на рис. 9.4 .
Восемь спрайтов, показанных на рисунке, представлены четырьмя разными поверхностями — по каждой поверхности создаются два спрайта. Исходные векторы направления, по которым перемещаются спрайты, определяются случайным образом. В начале своей работы программа «раскручивает» генератор случайных чисел, чтобы результаты ее работы не были всегда одинаковыми. При нажатии клавиши пробела векторы направления пересчитываются заново. Код программы Bumper рассматривается в следующих разделах.


Класс BumperWin

Программа Bumper, как и все остальные программы в этой книге, построена на основе базового класса DirectDrawWin . Производный от него класс BumperWin определяется так:

Рис. 9.4. Программа Bumper
Рис. 9.4. Программа Bumper


class BumperWin : public DirectDrawWin
{
public:
 BumperWin();
protected:
 //{{AFX_MSG(BumperWin)
 afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags);
 afx_msg void OnDestroy();
 //}}AFX_MSG
 DECLARE_MESSAGE_MAP()
private:
 int SelectDriver();
 int SelectInitialDisplayMode();
 BOOL CreateCustomSurfaces();
 void DrawScene();
 void RestoreSurfaces();
 BOOL SpritesCollide(Sprite* s1, Sprite* s2);
 BOOL SpritesCollideRect(Sprite* s1, Sprite* s2);
 BOOL SpritesCollidePixel(Sprite* s1, Sprite* s2);

private:
 Sprite* sprite[MAX_SPRITES];
 int nsprites;

 LPDIRECTDRAWSURFACE text;
};

В нем объявляются два обработчика сообщений. Функция OnKeyDown() обрабатывает нажатия клавиш, а функция OnDestroy() освобождает спрайты в конце работы программы.
Функции SelectDriver(), SelectInitialDisplayMode(), CreateCustomSurfaces(), DrawScene() и RestoreSurfaces() наследуются от класса DirectDrawWin . Вскоре мы подробно рассмотрим каждую из этих функций. Функции SpritesCollide() , SpritesCollideRect() и SpritesCollidePixel() совпадают с одноименными функциями, описанными выше, однако на этот раз они принадлежат классу BumperWin . Поскольку эти функции уже рассматривались, мы не будем обсуждать их снова.
В классе объявлены три переменные: массив указателей на объекты Sprite , целая переменная для хранения общего количества спрайтов и указатель text на интерфейс DirectDrawSurface . Первые две переменные предназначены для хранения спрайтов и последующих обращений к ним. Указатель text используется для отображения меню, находящегося в левом нижнем углу экрана.


Инициализация приложения

При запуске программы Bumper прежде всего вызывается функция SelectDriver() . Чтобы добиться максимальной гибкости, при наличии нескольких драйверов DirectDraw программа Bumper выводит меню. Функция SelectDriver() выглядит так:


int BumperWin::SelectDriver()
{
 int numdrivers=GetNumDrivers();
 if (numdrivers==1)
  return 0;
 CArray<CString, CString> drivers;
 for (int i=0;i<numdrivers;i++)
 {
  LPSTR desc, name;
  GetDriverInfo( i, 0, &desc, &name );
  drivers.Add(desc);
 }

 DriverDialog dialog;
 dialog.SetContents( &drivers );
 if (dialog.DoModal()!=IDOK)
  return -1;

 return dialog.GetSelection();
}
>/font>

С помощью класса DriverDialog программа выводит меню со списком драйверов и использует драйвер, выбранный пользователем. Наши функции проверки столкновений предназначены только для 8-битных поверхностей, поэтому драйверы, не поддерживающие 8-битных видеорежимов (скажем, драйверы 3Dfx), в этой программе не работают. Следовательно, функция SelectInitialDisplayMode() должна правильно реагировать на выбор такого драйвера.
Функция SelectInitialDisplayMode() вызывается после функции SelectDriver() , но перед созданием поверхностей. Функция выглядит так:


int BumperWin::SelectInitialDisplayMode()
{
 DWORD curdepth=GetDisplayDepth();
 int i, nummodes=GetNumDisplayModes();
 DWORD w,h,d;
 if (curdepth!=desireddepth)
  ddraw2->SetDisplayMode( 640, 480, curdepth, 0, 0 );

 for (i=0;i<nummodes;i++)
 {
  GetDisplayModeDimensions( i, w, h, d );
  if (w==desiredwidth && h==desiredheight && d==desireddepth)
   return i;
 }

 ddraw2->RestoreDisplayMode();
 ddraw2->Release(), ddraw2=0;
 AfxMessageBox("Can't find 8-bit mode on this device");

 return -1;
}

Функция SelectInitialDisplayMode() ищет конкретный видеорежим 640x480x8. Если этот режим не найден, она выводит сообщение и возвращает –1, говоря тем самым классу DirectDrawWin о том, что приложение следует завершить. Если режим будет найден, функция возвращает его индекс. По этому индексу класс DirectDrawWin узнает о том, какой видеорежим следует активизировать.
Если функция SelectInitialDisplayMode() находит нужный видеорежим, класс DirectDrawWin вызывает функцию CreateCustomSurfaces() . Она создает поверхности наших восьми спрайтов, а также поверхность меню. Функция CreateCustomSurfaces() приведена в листинге 9.3.

Листинг 9.3 . Функция CreateCustomSurfaces()


BOOL BumperWin::CreateCustomSurfaces()
{
 DDCOLORKEY ddck;
 ddck.dwColorSpaceLowValue = 0;
 ddck.dwColorSpaceHighValue = 0;

 LPDIRECTDRAWSURFACE surf;
 srand( time(0) );

 CString msg="Can't find ";

 surf=CreateSurface( "diamond.bmp", TRUE );
 if (surf==0)
 {
  msg+="diamond.bmp";
  Fatal( msg );
 }
 surf->SetColorKey( DDCKEY_SRCBLT, &ddck );
 sprite[nsprites++]=new Sprite( surf, 0, 0 );
 sprite[nsprites++]=new Sprite( surf, 150, 0 );

 surf=CreateSurface( "triangle.bmp" );
 if (surf==0)
 {
  msg+="triangle.bmp";
  Fatal( msg );
 }
 surf->SetColorKey( DDCKEY_SRCBLT, &ddck );
 sprite[nsprites++]=new Sprite( surf, 0, 150 );
 sprite[nsprites++]=new Sprite( surf, 150, 150 );

 surf=CreateSurface( "rect.bmp" ); 
 if (surf==0)
 {
  msg+="rect.bmp";
  Fatal( msg );
 }
 surf->SetColorKey( DDCKEY_SRCBLT, &ddck );
 sprite[nsprites++]=new Sprite( surf, 0, 300 );
 sprite[nsprites++]=new Sprite( surf, 150, 300 );


 surf=CreateSurface( "oval.bmp" ); 
 if (surf==0)
 {
  msg+="oval.bmp";
  Fatal( msg );
 }
 surf->SetColorKey( DDCKEY_SRCBLT, &ddck );
 sprite[nsprites++]=new Sprite( surf, 300, 0 );
 sprite[nsprites++]=new Sprite( surf, 300, 150 );
 text=CreateSurface("text.bmp"); 
 if (text==0)
 {
  msg+="text.bmp";
  Fatal( msg );
 }
 text->SetColorKey( DDCKEY_SRCBLT, &ddck );

 return TRUE;
}

Функция CreateCustomSurfaces() «раскручивает» генератор случайных чисел с помощью функции time() , возвращающей системное время в секундах. Благодаря этому при каждом запуске программы будут генерироваться разные случайные числа.
Затем для каждой создаваемой поверхности готовится структура DDCOLORKEY . Для всех поверхностей этого приложения прозрачным является черный цвет (то есть нулевое значение).
Функция создает четыре поверхности, и по каждой поверхности — два спрайта. Если хотя бы один из BMP-файлов, по которым создаются поверхности, не будет найден, функция Fatal() выводит сообщение и завершает программу. Для успешно созданных поверхностей с помощью функции SetColorKey() интерфейса DirectDrawSurface активизируются цветовые ключи.
Наконец, поверхность меню text инициализируется содержимым файла TEXT.BMP . Функция SetColorKey() , как и в случае спрайтовых поверхностей, определяет прозрачный цвет. Код возврата TRUE является признаком успешного завершения.


Функция DrawScene()

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


void BumperWin::DrawScene()
{
 ASSERT(nsprites>0);
 ASSERT(text);

 for (int s1=0;s1<nsprites;s1++)
  for (int s2=s1+1;s2>nsprites;s2++)
   if (SpritesCollide( sprite[s1], sprite[s2] ))
   {
    sprite[s1]->Hit( sprite[s2] );
    sprite[s2]->Hit( sprite[s1] );
   }

 for (int i=0;i<nsprites;i++)
  sprite[i]->Update();

 ClearSurface( backsurf, 0 );
 for (i=0;i<nsprites;i++)
 {
  Sprite* s=sprite[i];
  BltSurface( backsurf, *s, s->GetX(), s->GetY(), TRUE );
 }

 BltSurface( backsurf, text, 0, 448, TRUE );

 primsurf->Flip( 0, DDFLIP_WAIT );
}

Проверка столкновений осуществляется во вложенном цикле. Для каждой пары спрайтов вызывается функция SpritesCollide() , а при обнаруженном столкновении вызывается функция Hit() , которой в качестве аргументов передаются оба столкнувшихся спрайта. Напомню, что функция Sprite::Hit() реализует стадию подтверждения в нашей модели проверки столкновений. Она сохраняет данные о столкновении, но не вносит никаких изменений в состояние спрайтов.
В отдельном цикле для каждого спрайта вызывается функция Update() . На этом шаге реализуется стадия реакции. При обнаруженном столкновении функция Update() определяет новую траекторию спрайта по сохраненным ранее данным. Кроме того, функция Update() изменяет текущее положение спрайта.
После того как все столкновения будут обнаружены и обработаны, мы стираем вторичный буфер функцией DirectDrawWin::ClearSurface() и выводим каждый спрайт функцией BltSurface() . Обратите внимание на то, что вторым аргументом BltSurface() является указатель на сам объект Sprite . В данном случае оператор LPDIRECTDRAWSURFACE() преобразует объект Sprite в указатель на поверхность, соответствующую данному спрайту. Также стоит заметить, что координаты спрайтов определяются функциями GetX() и GetY() . После прорисовки всех спрайтов в левом нижнем углу вторичного буфера выводится поверхность меню. Функция Flip() переключает страницы и отображает кадр на экране.


Функция OnKeyDown()

Как видно из меню, программа Bumper реагирует на две клавиши: пробел и Escape . Нажатие пробела приводит к тому, что векторы направлений каждого спрайта пересчитываются заново, а Escape завершает работу программы. Функция OnKeyDown() выглядит так:


void BumperWin::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) 
{
 switch (nChar)
 {
 case VK_ESCAPE:
  PostMessage( WM_CLOSE );
  break;
 case VK_SPACE:
 case VK_RETURN:
  for (int i=0;i<nsprites;i++)
   sprite[i]->CalcVector();
  break;
 }

 DirectDrawWin::OnKeyDown(nChar, nRepCnt, nFlags);
}

 

Восстановление потерянных поверхностей

Прежде чем расставаться с программой Bumper, мы должны посмотреть, как происходит восстановление потерянных поверхностей. Как обычно, для этого служит функция RestoreSurfaces() :


void BumperWin::RestoreSurfaces()
{
 for (int i=0;i<nsprites;i++)
  sprite[i]->GetSurf()->Restore();

 LoadSurface( *sprite[0], "diamond.bmp");
 LoadSurface( *sprite[1], "diamond.bmp");

 LoadSurface( *sprite[2], "triangle.bmp" );
 LoadSurface( *sprite[3], "triangle.bmp" );

 LoadSurface( *sprite[4], "rect.bmp" );
 LoadSurface( *sprite[5], "rect.bmp" );

 LoadSurface( *sprite[6], "oval.bmp" );
 LoadSurface( *sprite[7], "oval.bmp" );

 text->Restore();
 LoadSurface( text, "text.bmp" );
}

Сначала область памяти каждой поверхности восстанавливается функцией Restore() (если поверхность не была потеряна, вызов Restore() игнорируется). Затем функция LoadSurface() восстанавливает содержимое поверхности. Обратите внимание — здесь, как и в функции DrawScene() , используется оператор LPDIRECTDRAWSURFACE() , позволяющий передавать объекты Sprite вместо указателей на поверхности. Работа функции завершается восстановлением поверхности меню ( text ).


Заключение

Если запустить программу Bumper (даже на относительно медленном компьютере), становится очевидно, что наши функции проверки столкновений работают достаточно эффективно. Даже когда спрайты сближаются на близкое расстояние и активизируется проверка на уровне пикселей, замедления работы не ощущается. Отчасти это объясняется оптимизацией, а отчасти — тем обстоятельством, что мы непосредственно обращаемся к памяти поверхности. Конечно, если бы обращение к каждому пикселю осуществлялось через специальную функцию DirectDraw, программа работала бы намного медленнее.
Эта глава была последней - мы рассмотрели все программы. Тем не менее остались некоторые интересные темы, которые не обсуждались в книге. Мы поговорим о них в приложении А .