Арканоид. Часть первая
Дата создания: 2010-06-25 16:44:33
Последний раз редактировалось: 2012-03-24 16:23:20
Сегодня мы рассмотрим арканоид. Вместе с морским боем эти две программы дают отличную практику по написанию программ небольшого размера.
Перед чтением рекомендую посмотреть на упражнение.
Важное замечание:
Обычно, когда рассматривают код арканоида, обращают внимание на перемещение шара и его столкновения с платформой/блоками/стенками. В нашей сегодняшней программе всё это есть. Но гораздо большую ценность представляют другие возможности этой игры. Когда будете читать урок обращайте внимание, как определяется активность элементов интерфейса, как происходит сохранение/загрузка уровней, как они устроены и как взаимодействуют с элементами интерфейса. Это гораздо более важные вещи, которые найдут применение в следующих программах.
Начнём с версии 0.1. Полный код можете скачать из раздела "Листинги и программы". Версию 0.1 я написал ещё в декабре 2009). Надеялся её поправить и причесать, но в итоге оказалось, что на это нет времени. Поэтому код находится в первозданном виде. На недостатки кода я буду указывать, когда мы будем разбирать отдельные блоки кода.
Арканоид v0.1
Если вы запустите программу, то на экране отобразится стандартное окно размером 500*500 пикселей. В игре только один уровень. Если "мяч" касается нижней части экрана, то уровень начинается заново. Если игрок сумел выбить все блоки, то мяч всё равно продолжает двигаться. Как видите, данная версия довольно примитивная.
Глобальная область видимости
В программе используется DirectInput для контроля платформы (paddle), поэтому необходимо объявить указатели IDirectInput8 и IDirectInputDevice8. Графика выводится в фоновый буфер напрямую - нужно объявить указатель на IDirect3DSurface9.
В программе три состояния (начальное - GAME_STATE_INIT):
enum GameState {GAME_STATE_INIT, GAME_STATE_START_LEVEL, GAME_STATE_RUN}; GameState gameState = GAME_STATE_INIT;
Когда игра находится в первом состоянии, происходит назначение координат "мяча" и платформы. В состоянии GAME_STATE_START_LEVEL рисуются блоки. В данном случае эти два состояния можно объединить в одно, но в более сложных случаях (и в версии 0.2) необходимо иметь отдельные состояния и для начала игры, и для начала уровня.
В состоянии GAME_STATE_RUN работает основная логика игры.
Далее в коде идёт ряд констант: количество блоков, размеры одного блока, расстояние между блоками (gap), координаты самого первого блока (origin), координаты и размеры платформы и "мяча". Эти константы используется в состоянии GAME_STATE_INIT.
Для отслеживания координат "мяча" и платформы используются переменные:
int paddle_x=0, paddle_y=0; int ball_x = 0, ball_y = 0; int ball_dx = 0, ball_dy = 0;
ball_dx и ball_dy задают скорость "мяча". После этого кода создаются переменные типа D3DLOCKED_RECT И RECT:
D3DLOCKED_RECT lockedBrick; RECT brickRect; D3DLOCKED_RECT lockedPaddle; RECT paddleRect; D3DLOCKED_RECT lockedBall; RECT ballRect;
Напоминаю, что версия 0.1 писалась в декабре. На данный момент у нас есть более надёжный способ - мы замыкаем буфер только один раз, а не для каждого объекта.
Ну и последнее:
unsigned char brick[BRICK_WIDTH*BRICK_HEIGHT*4]; unsigned char paddle[PADDLE_WIDTH*PADDLE_HEIGHT*4]; unsigned char ball[BALL_WIDTH*BALL_HEIGHT*4]; unsigned char bricks[NUM_BRICK_ROWS][NUM_BRICK_COLUMNS];
Первые три массива - это картинки для блока, платформы и мяча. В игре используются сплошные цвета: блок - зелёный цвет, платформа - чёрный, мяч - красный. Здесь я использовал тип char. Конечно, с int было бы чуть легче писать вывод, но char мне был нужен, чтобы показать одну интересную штуку (ниже).
Четвёртый массив - это непосредственно блоки. В игре два типа блоков: 1 - блок есть, 0 - блока в данной позиции уже нет. В данном случае можно было бы обойтись типом bool. Но в версии 0.2 будет уже четыре блока.
Функция main
В функции main довольно стандартный код для наших программ. До основного цикла интерес представляет только заполнение "картинок" цветом. Рассмотрим только код для мяча:
for (int i = 0; i < BALL_HEIGHT*BALL_WIDTH*4; i+=4) { ball[i] = 0; ball[i+1] = 0; ball[i+2] = 255; ball[i+3] = 0; }
Если вы читали статью о порядке байтов (little-endian, big-endian), то у вас могло сложиться впечатление, что порядок байтов сложно увидеть на практике. Собственно, little-endian можно увидеть выше.
Фоновый буфер хранит байты пикселей в обратном порядке (littile-endian). Поэтому, несмотря на то, что формат называется D3DFMT_X8R8G8B8, фактически каналы хранятся так: BGRX. Точно так же пиксели хранятся и в BMP, поэтому не нужно преобразовывать формат пикселей при копировании BMP в фоновый буфер.
Основной цикл
Цикл очень прост:
videocard->Clear(0,NULL,D3DCLEAR_TARGET, D3DCOLOR_XRGB(255,255,255),1.0f,0); GameMain(); videocard->Present(NULL,NULL,NULL,NULL); Sleep(25);
Как видим, каждый кадр программа зависает на 25 миллисекунд. Это позволяет ограничить скорость выполнения программы. Конечно, лучше использовать таймеры (что было сделано в версии 0.2).
Основная работа происходит в функции GameMain.
функция GameMain
В функции GameMain проверяется значение gameState - это конечный автомат игры. Сразу приведу проверку двух состояний:
if (gameState == GAME_STATE_INIT) { paddle_x = PADDLE_START_X; paddle_y = PADDLE_START_Y; ball_x = paddle_x+PADDLE_WIDTH/2-4; ball_y = paddle_y-BALL_SIZE; ball_dx = 0; ball_dy = 0; gameState = GAME_STATE_START_LEVEL; } if (gameState == GAME_STATE_START_LEVEL) { InitBricks(); gameState = GAME_STATE_RUN; } if (gameState == GAME_STATE_RUN) {
Думаю, здесь всё понятно. Первые два состояния инициализируют данные: координаты платформы/мяча, инициализация блоков - и последовательно переходят к состоянию GAME_STATE_RUN.
В функции InitBricks всем блокам присваивается единица - это значит, что блок рисуется и нужно просчитывать с ним столкновение.
Обратите внимание, что ball_dx и ball_dy присваивается ноль. Эти переменные задают скорость мяча.
Далее проверяется состояние GAME_STATE_RUN, но о нём чуть позже. Сейчас же мы кратко затронем вопрос оптимизации (чего мы никогда не делали ранее). Большую часть времени программа находится в состоянии GAME_STATE_RUN, поэтому проверку состояний желательно организовать вот так:
if (gameState = GAME_STATE_RUN) { // код } else if (gameState = GAME_STATE_START_LEVEL) { // код } else if (gameState = GAME_STATE_INIT) { // код }
В данном случае сначала проверяется GAME_STATE_RUN. Если программа находится в этом состоянии (а она находится в этом состоянии большую часть времени), процессор не будет тратить дополнительные циклы на проверку других состояний, так как конечный автомат может находиться только в одном состоянии.
Данный пример позволяет сэкономить очень мало времени, но в некоторых ситуациях подобная оптимизация может дать существенный выигрыш в производительности.
Всегда старайтесь организовать ветвление таким образом, чтобы первым проверялось самое часто выполняемое условие.
Возвращаемся к коду для состояния GAME_STATE_RUN. Вначале идёт код движения платформы:
keyboard->GetDeviceState(sizeof(buffer),buffer); if (buffer[DIK_SPACE] & 0x80) { ball_dx = -4 + rand()%8; ball_dy = -8 + rand()%2; } if (buffer[DIK_RIGHT] & 0x80) { paddle_x+=8; if (paddle_x > WINDOW_WIDTH-PADDLE_WIDTH) paddle_x -= 8; } else if (buffer[DIK_LEFT] & 0x80) { paddle_x-=8; if (paddle_x < 0) paddle_x += 8; }
Ещё бы хорошо было проверять доступность клавиатуры (Acquire)!
При нажатие стрелочек влево/вправо, платформа двигается на 8 пикселей. После этого осуществляется проверка: не заехала ли платформа за пределы окна. Если заехала, то платформа перемещается на 8 пикселей назад.
Считаю, нужно пояснить, что означает следующий код:
paddle_x > WINDOW_WIDTH-PADDLE_WIDTH
Координатами объекта (мяч, платформа, блок) считается левый верхний угол. Чтобы проверить, находится ли объект за правой границей окна, нужно, так или иначе, использовать ширину объекта. В данном случае ширина объекта вычитается от правой границы окна, и это значение сравнивается с левой границе объекта. Эту проверку можно записать ещё и вот так:
paddle_x+PADDLE_WIDTH > WINDOW_WIDTH
Здесь правая граница объекта сравнивается с правой границей окна. Так, даже более понятно.
Ещё один вариант назначения координат объекта - центр картинки. Нам ещё доведётся им воспользоваться. Какой бы вариант вы не выбрали, в любой момент можно преобразовать координаты в другой.
Далее в коде происходит рисование блоков:
DrawBricks();
Код функции хорошо нам знаком. Для каждого отдельного блока сначала считается отступ, затем замыкается прямоугольник в фоновом буфере и в него копируется картинка. Блок рисуется только если его значение не равно нулю. В противном случае он не существует и его не нужно рисовать.
Осталось рассмотреть нажатие пробела.
Движение мяча
Я постарался сделать движения мяча как можно проще. Отсюда проистекают некоторые проблемы.
Скорость и направление движение задаются с помощью dx, dy. Достаточно изменить знак одной из этих переменных, поменяется направление движения. Если менять значение переменных, то будет меняться скорость.
dx - перемещение мяча по оси x за один кадр.
dy - перемещение мяча по оси y за один кадр.
При нажатии пробела вычисляется dx и dy (проверка осуществляется в коде выше):
ball_dx = -4 + rand()%8; ball_dy = -8 + rand()%2;
dx может принимать значения от -4 до 4. dy - от -8 до -6. Надеюсь, код понятен. Заметьте, dy не может в начале игры двигаться вниз. При этом скорость движения мяча по оси y почти в два раза больше скорости движения по оси x.
В любой момент игры можно нажать на пробел и мяч взмоет вверх. Это такой небольшой чит-код. Чтобы от него избавиться, достаточно проверку на нажатие пробела поместить в другое состояние.
После отрисовки блоков меняется местоположение мяча:
ball_x+=ball_dx; ball_y+=ball_dy;
Т.е. к координатам мяча прибавляются dx и dy. После этого нужно проверить столкновение мяча со стенками окна:
if (ball_x > (WINDOW_WIDTH - BALL_SIZE) || ball_x < 0) { ball_dx = -ball_dx; ball_x += ball_dx; }
При касании левой или правой стенки окна, нужно поменять знак dx - мяч полетит в противоположную сторону. dy при этом не меняется.
if (ball_y < 0) { ball_dy = -ball_dy; ball_y += ball_dy; }
При касании верхней стенки (напоминаю, что ось y идёт сверху вниз) меняется знак dy.
Обратите внимание, как просто меняется направление мяча и контролируется скорость. В некоторых других примерах арканоида можно встретить использование уравнения прямой, формул скорости, синусы и косинусы. В нашей игре всё это есть, но в другой форме (надеюсь, вы помните информацию из начала прошлого выпуска).
Осталось проверить вылет мяча за нижний край окна:
if (ball_y > WINDOW_HEIGHT-15) gameState = GAME_STATE_INIT;
Не долетая 15 пикселей до нижней границы окна произойдёт смена состояния на GAME_STATE_INIT и игра начнётся заново.
Осталось проверить столкновения мяча с платформой и с блоками. Это происходит в функции ProcessBall. В GameMain после вызова ProceccBall ничего интересного не происходит: рисуется платформа и мяч в текущих координатах.
Функция ProcessBall
Функция состоит из двух частей. В первой части проверяется столкновение с платформой, во второй - с блоками. В обоих случаях мяч представляется точкой. Т.е. проверяется столкновение точки и прямоугольника - это хорошо знакомый нам тест. Чтобы столкновения просчитывались более точно, лучше брать центр мяча, а не левый верхний угол:
int x = ball_x + (BALL_SIZE/2); int y = ball_y + (BALL_SIZE/2);
Далее проверяется столкновение мяча (центра мяча) с платформой:
if ((x >= paddle_x && x <= paddle_x+PADDLE_WIDTH) && (y >= paddle_y && y <= paddle_y+PADDLE_HEIGHT)) { ball_dy = -ball_dy; ball_y += ball_dy; ball_dx += -2+rand()%4; }
Если произошло столкновение с платформой, то меняем значение dy. А вот для dx мы используем небольшую хитрость. Чтобы движение мяча выглядело более естественно, мы меняем dx на значение от -2 до 2. Мяч будет рикошетить довольно неплохо (здесь я немного подредактировал код, мяч двигается получше, чем в версии 0.2).
Дальше проверяется столкновение с каждым блоком. Если столкновение произошло, то dy и dx меняются также как в коде выше, помимо этого значение для блока меняется на ноль - блок перестаёт существовать.
Вот и всё с первой версией.
Недостатки арканоида v0.1
Главный недостаток - слабая "физика" мяча. Её довольно просто улучшить. Нужно поэкспериментировать со значениями в выражениях, где используется rand. Помимо этого платформу необходимо разбить на три сектора. При столкновения мяча с каждым из этих секторов dx нужно вычислять по разным формулам - это позволит игроку лучше контролировать поведение мяча. Сами формулы могут выглядеть так:
ball_dx += -3+rand()%2; // левый край платформы ball_dx += -1+rand()%2; // центр ball_dx += 1+rand()%2; // правый край платформы
Это как пример (лучше подобрать другие значения).
Кроме этого можно реагировать на движение платформы во время столкновение с мячом.
Также можно увеличить платформу и мяч. Но в первом случае нужно увеличивать окно программы, а во втором необходимо нормально просчитывать столкновение двух прямоугольников, а не прямоугольника и точки.
Во второй части мы рассмотрим версию 0.2