Pull to refresh

Пишем игру-клон Super Mario Brothers (часть 1)

Reading time23 min
Views88K
Original author: Jacob Gundersen
imageДля многих из нас Super Mario Brothers была первой игрой, которая по-настоящему завораживала своим игровым процессом.
Интуитивное управление SMB и великолепный дизайн уровней от Nintendo заставляли проводить часы напролет в виртуальной вселенной сантехника и его напарника.

В этом чудесном туториале от Джейкоба Гандерсена мы создадим собственный платформер; но, так как главным героем будет Коала, мы назовем нашу игру «Super Koalio Brothers!» ;]
Также, чтобы упростить механику, мы забудем о движущихся врагах. Вместо них мы будем использовать шипованные блоки, встроенные в пол. Это позволит нам полностью сконцентрироваться на сердце платформера — физическом движке.

Внимание! Под катом невероятное количество переведенного текста, картинок, кода (код не переведен) и руководство по созданию собственного физического движка!

Этот туториал заранее подразумевает, что Вы знакомы с основами программирования на Cocos2D. Иначе, я настоятельно рекомендую сначала ознакомиться с парой-тройкой начальных уроков на сайте Рея.

Начнем


Для начала скачайте стартовый проект для этого туториала. Распакуйте его, откройте в Xcode, запустите. На экране эмулятора должно появится нечто подобное:

image

Все правильно — просто скучный пустой экран! :] Мы полностью его заполним по мере прохождения туториала
В стартовый проект уже добавлены все необходимые картинки и звуки. Пробежимся по содержимому проекта:

  • Гейм арт. Включает в себя бесплатный пакет гейм артов от жены Рея Вики.
  • Карта уровня. Я нарисовал карту уровня специально для вас, отталкиваясь от первого уровня в SMB.
  • Великолепные звуковые эффекты. Как-никак, туториал с raywenderlich.com! :]
  • Подкласс CCLayer. Класс с именем GameLevelLayer, который реализовывает большую часть нашего физического движка. Хотя сейчас он пуст как пробка. (Да, эта детка так и ждет, чтобы ее заполнили!)
  • Подкласс CCSprite. Класс с именем Player, который содержит логику Коалы. Прямо сейчас наша Коала так и норовит улететь вдаль!

Основы физических движков


Платформеры оперирут на основе физических движков, и в этом туториале мы напишем собственный физический движок.
Есть две причины, по которым нам нужно написать собственный движок, а не брать те же Box2D или Chipmink:

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

Физический движок выполняет две главные задачи:
image
  1. Симулирует движение. Первая задача физического движка — симулировать противодействующие силы гравитации, передвижения, прыжков и трения.
  2. Определяет столкновения. Вторая задача — определять столкновения между игроком и другими объектами на уровне.

Например, во время прыжка на нашу Коалу действует сила, направленная вверх. Спустя некоторое время, сила гравитации превосходит силу прыжка, что дает нам классическое параболическое изменение скорости.
При помощи определения столкновений, мы будем останавливать нашу Коалу каждый раз, когда она захочет пройти сквозь пол под действием гравитации, и определять, когда наша Коала наступила на шипы (ай!).
Давайте посмотрим, как это работает на практике.

Создание физического движка


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

  1. Выбрано ли действие прыжка или движения?
  2. Если да, применить силу прыжка или движения на Коалу.
  3. Также, применить силу гравитации на Коалу.
  4. Вычислить полученную скорость Коалы.
  5. Применить полученную скорость на Коалу и обновить ее позицию.
  6. Проверить на предмет столкновений Коалы с другими объектами.
  7. Если произошло столкновение, то либо сдвинуть Коалу на такое расстояние от препятствия, что столкновений больше не происходит; либо нанести урон бедной Коале.
image
Мы будем проходить через эти действия каждый шаг программы. В нашей игре гравитация постоянно заставляет Коалу опускаться все ниже и ниже сквозь пол, но определение столкновений каждый раз возвращает ее обратно на точку над полом. Так же можно использовать эту особенность для того, чтобы определять, касается ли Коала земли. Если нет, то можно запретить игроку прыгать, когда Коала находится в состоянии прыжка или только что спрыгнула с какого-либо препятствия.
Пункты 1-5 происходят внутри объекта Коалы. Вся необходимая информация должна храниться внутри этого объекта и довольно логично разрешить Коале самой обновлять ее переменные.
Однако когда дело доходит до 6го пункта — определения столкновений — нам нужно принимать во внимание все особенности уровня, такие как: стены, пол, враги и другие опасности. Определение столкновений будет осуществляться каждый шаг программы при помощи GameLevelLayer — напомню, это подкласс CCLayer, который будет осуществлять большинство физических задач.
Если мы разрешим Коале обновлять ее позицию собственноручно, то в конце концов Коала коснется стены или пола. А GameLevelLayer вернет Коалу назад. И так вновь и вновь — что заставит Коалу выглядеть, как будто она вибрирует. (Слишком много кофе с утра, Коалио?)
И так, мы не будем разрешать Коале обновлять свое состояние. Вместо этого, мы добавим Коале новую переменную desiredPosition, которую Коала и будет обновлять. GameLevelLayer будет проверять можно ли переместить Коалу в точку desiredPosition. Если да, то GameLevelLayer обновит состояние Коалы.
Все ясно? Давайте посмотрим, как это выглядет в коде!

Загрузка TMXTiledMap


Я предполагаю, что вы знакомы, как работают карты типа Tile Maps. Если нет, то я советую прочесть о них в этом туториале.
Давайте взглянем на уровень. Запустите ваш Tiled map editor (загрузите, если вы не сделали этого раньше) и откройте level1.tmx из папки вашего проекта. Вы увидите следующее:

image

Если вы посмотрите на боковую панель, вы увидите, что у нас есть три разных слоя:

  • hazards: Этот слой содержит вещи, которых Коала должна остерегаться, чтобы остаться вживых.
  • walls: Этот слой содержит ячейки, через сквозь которые Коала не может пройти. В основном это ячейки пола.
  • background: Этот слой содержит исключительно эстетические вещи, такие как облака или холмики.

Пришло время кодить! Откройте GameLevelLayer.m и добавьте следующее после #import, но перед @implementation:

@interface GameLevelLayer()  {
  CCTMXTiledMap *map;
}
 
@end

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

CCLayerColor *blueSky = [[CCLayerColor alloc] initWithColor:ccc4(100, 100, 250, 255)];
[self addChild:blueSky];
 
map = [[CCTMXTiledMap alloc] initWithTMXFile:@"level1.tmx"];
[self addChild:map];

Во-первых, мы добавили задник (CCLayerColor) цвета синего неба. Следующие две строки кода это просто подгрузка переменной map (CCTMXTiledMap) и добавление ее на слой.

Далее, в GameLevelLayer.m импортируем Player.h:

#import "Player.h"

Все еще в GameLevelLayer.m добавим следующую локальную переменную в секцию @ interface:

Player * player;

Далее добавим Коалу на уровень следующим кодом в методе init:

player = [[Player alloc] initWithFile:@"koalio_stand.png"];
player.position = ccp(100, 50);
[map addChild:player z:15];

Этот код загружает спрайт-объект Коалы, задает ему позицию и добавляет его на объект нашей карты.
Вы спросите, зачем добавлять объект коалы на карту, вместо того, чтобы просто добавить его напрямую на слой? Все просто. Мы хотим непосредственно контролировать какой слой будет перед Коалой, а какой за ней. Так что мы делаем Коалу ребенком карты, а не главного слоя. Мы хотим, чтобы Коала была спереди, так что даем ей Z-порядок, равный 15. Так же, когда мы прокручиваем карту, Коала все еще находится на той же позиции, относительно карты, а не главного слоя.
Отлично, давайте попробуем! Запустите ваш проект и вы должны увидеть следующее:

image

Выглядит как игра, но Коалио игнорирует гравитацию! Пришло время опустить его с небес на землю — при помощи физического движка :]

Ситуация с гравитацией Коалио

image
Чтобы создать симуляцию физики, можно написать сложный набор разветвляющейся логики, который бы учитывал состояние Коалы и применял бы к ней силы, отталкиваясь от полученной информации. Но этот мир сразу станет слишком сложным — a реальная физика ведь так сложно не работает. В реальном мире гравитация просто постоянно тянет объекты вниз. Так, мы добавляем постоянную силу гравитации и применяем ее к Коале каждый шаг программы.
Другие силы тоже не просто отключаются и включаются. В реальном мире сила действуюет на объект пока другая сила не превзойдет или не будет равной первой.
Например, сила прыжка не отключает гравитацию; она какое-то время превосходит силу гравитации, до тех пор, пока гравитация вновь не прижмет Коалу к земле.
Вот так моделируется физика. Вы не просто решаете, применять или не применять гравитационную силу к Коале. Гравитация существует всегда.

Играем в бога

image
В логике нашего движка заложено, что если на объект действует сила, то он будет продолжать двигаться пока другая сила на превзойдет первую. Когда Коалио спрыгивает с уступа, он продолжает двигаться вниз с определенным ускорением, пока не встретит препятствие на своем пути. Когда мы двигаем Коалио, он не перестанет двигаться, пока мы не перестанем применять на него силу движения; трение будет действовать на Коалио, пока тот не остановится.
По мере создания физического движка вы увидите, как настолько простая игровая логика помогает решать сложные физические задачи, такие как: ледяной пол или падение со скалы. Эта поведенческая модель позволяет игре изменяться динамически.
Так же такой ход конем позволит нам сделать имплементацию проще, так как нам не нужно постоянно спрашивать состояние нашего объекта — объект просто будет следовать законам физики из реального мира.
Иногда нам нужно играть в бога! :]

Законы планеты Земля: CGPoint'ы и Силы


Давайте обозначим следующие понятия:

  • Скорость описывает, насколько быстро объект движется в определенном направлении.
  • Ускорение описывает, как скорость и направление объекта изменяются со временем.
  • Сила — это влияние, которое является причиной изменения в скорости или направлении.

В физической симуляции, примененная к объекту сила ускорит объект до определенной скорости, и объект будет двигаться с этой скоростью, пока не встретит на пути другую силу. Скорость — это величина, которая изменяется от одного кадра к следующему по мере появления новых действующих сил.
Мы будем представлять три вещи при помощи структур CGPoint: скорость, сила/ускорение и позиция. Есть две причины использования CGPoint структур:

  1. Они 2D. Скорость, сила/ускорение и позиция — всё это 2D величины для 2D игры. Можете заявить, что гравитация действует только в одном направлении, но что, если в один из моментов игры нам срочно нужно будет сменить направление гравитации? Подумайте о Super Mario Galaxy!
  2. Это удобно. Используя CGPoint, мы можем пользоваться различными функциями, встроенными в Cocos2D. В частности мы будем использовать ccpAdd (сложение), ccpSub (вычитание) и ccpMult (умножение на переменную типа float). Все это сделает наш код гораздо более удобным для чтения и отладки!

Объект нашей Коалы будет иметь переменную скорости, которая будет изменяться по появлению различных сил, включая гравитацию, движение, прыжки, трение.
Каждый шаг игры, мы будем складывать все силы вместе, и полученная величина будет добавляться к текущей скорости Коалы. В итоге мы будем получать новую текущую скорость. Ее мы уменьшим, используя скорость изменения кадров. Уже после этого мы будем двигать Коалу.
Обратите внимание: если что-либо из вышенаписанного вводит вас в заблуждение, то прекрасный человек, Daniel Shiffman написал великолепный туториал на тему векторов, который полностью объясняет действия сил над структурами, которые мы используем.
Давайте начнем с гравитацией. Напишем цикл run, в котором мы будем применять силы. Добавьте в метод init файла GameLevelLayer.m следующий код прямо перед закрытием условного блока if:

[self schedule:@selector(update:)];

Далее добавьте новый метод в класс:

- (void)update:(ccTime)dt {
    [player update:dt];
}

Далее откройте Player.h и измените его, чтобы он выглядил так:

#import <Foundation/Foundation.h>
#import "cocos2d.h"
 
@interface Player : CCSprite 
 
@property (nonatomic, assign) CGPoint velocity;
 
- (void)update:(ccTime)dt;
 
@end

Добавьте следующий код в Player.m:

Нажми меня
#import "Player.h"
 
@implementation Player
 
@synthesize velocity = _velocity;
 
// 1
- (id)initWithFile:(NSString *)filename {

    if (self = [super initWithFile:filename]) {
        self.velocity = ccp(0.0, 0.0);
    }
    return self;
}
 
- (void)update:(ccTime)dt {
 
    // 2
    CGPoint gravity = ccp(0.0, -450.0);
 
    // 3
    CGPoint gravityStep = ccpMult(gravity, dt);
 
    // 4
    self.velocity = ccpAdd(self.velocity, gravityStep);
    CGPoint stepVelocity = ccpMult(self.velocity, dt);
 
    // 5
    self.position = ccpAdd(self.position, stepVelocity);
}
 
@end

Давайте пройдемся по коду выше ступень за ступенью

  1. Здесь мы добавили новый метод init чтобы инициализировать объект и приравнять переменную скорости к нулю.
  2. Здесь мы обозначили значение вектора гравитации. Каждую секунду мы ускоряем скорость Коалы на 450 пикселов вниз.
  3. Здесь мы использовали ccpMult для того, чтобы уменьшить значение гравитационного вектора для удовлетворения скорости смены кадров. ccpMult получает float и CGPoint и возвращает CGPoint.
  4. Здесь, как только мы посчитали гравитацию для текущего шага, мы добавляем ее к текущей скорости.
  5. Наконец, когда мы посчитали скорость для одного шага, мы используем ccpAdd для обновления позиции Коалы.

Поздравляю! Мы на прямом пути к созданию нашего первого физического движка! Запустите свой проект, чтобы увидеть результат!

image

Уууууупс — Коалио падает сквозь пол! Давайте это починим.

Удары в ночи – определение столкновений


Определение столкновений это основа любого физического движка. Есть множество различных видов определения столкновений, от простого использования рамок изображений, до комплексных столкновений 3D объектов. К счастью для нас, платформер не требует сложных структур.
Чтобы определять столкновения Коалы с объектами, мы будем использовать TMXTileMap для ячеек, которые непосредственно окружают Коалу. Далее, используя несколько встроенных в iOS функций мы будем проверять пересекает ли спрайт Коалы спрайт какой-либо ячейки.
Функции CGRectIntersectsRect и CGRectIntersection делают такие проверки очень простыми. CGRectIntersectsRect проверяет, пересекаются ли два прямоугольника, а CGRectIntersection возвращает прямоугольник пересечения.
Во-первых, нам нужно определить рамку нашей Коалы. Каждый загруженный спрайт имеет рамку, которая является размером текстуры и к которой можно получить доступ при помощи параметра с именем boundingBox.
Зачем определять рамку, если она уже есть в boundingBox? Текстура обычно имеет вокруг себя прозрачные края, которые мы совсем не хотим учитывать при определении столкновений.
Иногда нам не нужно учитывать даже пару-тройку пикселей вокруг реального изображения спрайта (не прозрачного). Когда марио врезается в стену, разве он чуть-чуть касается ее, или его нос слегка утопает в блоке?
Давайте попробуем. Добавьте в Player.h:

-(CGRect)collisionBoundingBox;

И добавьте в Player.m:

- (CGRect)collisionBoundingBox {
  return CGRectInset(self.boundingBox, 2, 0);
}

CGRectInset сжимает CGRect на количество пикселов из второго и третьего аргументов. В нашем случае, ширина нашей рамки столкновений будет на шесть пикселов меньше — три пиксела с каждой стороны.

Поднятие тяжестей


Пришло время поднимать тяжести. («Эй, ты сейчас назвал меня толстым?» — говорит Коалио).
Нам потребуется ряд методов в нашем GameLevelLayer для определения столкновений. В частности:

  • Метод, возвращающий координаты восьми ячеек, окружающих текущую ячейку Коалио.
  • Метод, определяющий, которая из ячеек является препятствием (и имеются ли таковые в общем). Некоторые ячейки не имеют физических свойств (облака), и Коалио не будет сталкиваться с ними.
  • Метод, обрабатывающий столкновения в приоритетном порядке.

Мы создадим две вспомогательные функции, которые упростят методы, описанные чуть выше.

  • Метод, который определяет позицию ячейки Коалио.
  • Метод, который получает координаты ячейки и возвращает прямоугольник ячейки в Cocos2D координатах.

Добавьте следующий код в GameLevelLayer.m:

- (CGPoint)tileCoordForPosition:(CGPoint)position {

  float x = floor(position.x / map.tileSize.width);
  float levelHeightInPixels = map.mapSize.height * map.tileSize.height;
  float y = floor((levelHeightInPixels - position.y) / map.tileSize.height);
  return ccp(x, y);
}
 
- (CGRect)tileRectFromTileCoords:(CGPoint)tileCoords {

  float levelHeightInPixels = map.mapSize.height * map.tileSize.height;
  CGPoint origin = ccp(tileCoords.x * map.tileSize.width, levelHeightInPixels - ((tileCoords.y + 1) * map.tileSize.height));
  return CGRectMake(origin.x, origin.y, map.tileSize.width, map.tileSize.height);
}

Первый метод возвращает нам координаты ячейки, находящейся на координатах в пикселях, которые мы передаем в метод. Чтобы получить позицию ячейки, мы просто делим координаты на размер ячеек.
Нам нужно инвертировать координаты высоты, так как координаты системы Cocos2D/OpenGL начинаются с левого нижнего угла, а системные координаты начинаются с левого верхнего угла. Стандарты — ну разве это не круто?
Второй метод делает все наоборот. Он умножает координату ячейки на размер ячеек и вовзращает CGRect данной ячейки. Опять же, нам нужно развернуть высоту.
Зачем нам добавлять единицу к y-координате высоты? Запомните, координаты ячеек начинаются с нуля, так 20 ячейка имеет реальную координату 19. Если мы не добавим единицу к высоте, точка будет 19 * tileHeight.

Я окружен ячейками!


Теперь перейдем к методу, который определяет окружающие Коалу ячейки. В этом методу мы создадим массив, который и будем возвращать. Этот массив будет содержать GID ячейки, координаты ячейки и информацию о CGRect этой ячейки.
Мы организуем этот массив в порядке приоритета, в котором мы будем определять столкновения. Например, мы ведь хотим определять столкновения сверху, слева, справа, снизу перед тем, как определять диагональные. Также, когда мы определим столкновение Коалы с нижней ячейкой мы выставляем флаг касания земли.
Добавим этот метод в GameLevelLayer.m:

Нажми меня
- (NSArray *)getSurroundingTilesAtPosition:(CGPoint)position forLayer:(CCTMXLayer *)layer {
 
  CGPoint plPos = [self tileCoordForPosition:position]; //1
 
  NSMutableArray *gids = [NSMutableArray array]; //2
 
  for (int i = 0; i < 9; i++) { //3
    int c = i % 3;
    int r = (int)(i / 3);
    CGPoint tilePos = ccp(plPos.x + (c - 1), plPos.y + (r - 1));
 
    int tgid = [layer tileGIDAt:tilePos]; //4
 
    CGRect tileRect = [self tileRectFromTileCoords:tilePos]; //5
 
    NSDictionary *tileDict = [NSDictionary dictionaryWithObjectsAndKeys:
                 [NSNumber numberWithInt:tgid], @"gid",
                 [NSNumber numberWithFloat:tileRect.origin.x], @"x",
                 [NSNumber numberWithFloat:tileRect.origin.y], @"y",
                 [NSValue valueWithCGPoint:tilePos],@"tilePos",
                 nil];
    [gids addObject:tileDict];
 
  }
 
  [gids removeObjectAtIndex:4];
  [gids insertObject:[gids objectAtIndex:2] atIndex:6];
  [gids removeObjectAtIndex:2];
  [gids exchangeObjectAtIndex:4 withObjectAtIndex:6];
  [gids exchangeObjectAtIndex:0 withObjectAtIndex:4]; //6
 
  for (NSDictionary *d in gids) {
    NSLog(@"%@", d);
  } //7
 
  return (NSArray *)gids;
}

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

  • Koala и hazards. Если произошло столкновение, то мы убиваем Коалу (достаточно брутально, не так ли?).
  • Koala и walls. Если произошло столкновение, то мы не разрешаем Коале дальше двигаться в этом направлении. «Стой, кобыла!»
  • Koala и backgrounds. Если произошло столкновение, то мы ничего и не делаем. Ленивый программист — лучший программист. Ну или как там, в народе, говорят?

Конечно, есть различные пути определения различных столкновений с разными блоками, но то, что мы имеем — слои на карте, довольно эффективно.
Ладненько, давайте пройдемся по коду шаг за шагом.

1. Для начала мы получаем координаты ячейки для ввода (которыми и будут координаты Коалы).
2. Далее, мы создаем новый массив, который будет возвращать информацию о ячейке.
3. Далее, мы запускаем цикл 9 раз — так как у нас есть 9 возможных ячеек перемещения, включая ячейку, в которой коала уже находится. Следующие несколько строк определяют позиции девяти ячеек и сохраняют из в переменной tilePos.

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

4. В четвертой секции мы вызываем метод tileGIDAt:. Этот метод возвращает GID ячейки на определенной координате. Если на полученных координатах нет ячейки, метод возвращает ноль. Далее мы будем использовать ноль в значении «не найдено ячейки».
5. Далее мы используем вспомогательный метод, чтобы вычислить CGRect для ячейки на данных Cocos2D координатах. Полученную информацию мы сохраняем в NSDictionary. Метод возвращает массив из полученных NSDictionary.
6. В шестой секции мы убираем ячейку Коалы из массива и сортируем ячейки в приоритетном порядке.
image
Часто, в случае определения столкновений с ячейкой под Коалой, мы так же определяем столкновения с ячейками по-диагонали. Смотрите рисунок справа. Определяя столкновения с ячейкой под Коалой, веделенной красным, мы так же определяем столкновения с блоком #2, выделенным синим.
Наш алгоритм определения столкновений будет использовать некоторые допущения. Эти допущения верны скорее для прилегающих, нежели для диагональных ячеек. Так что мы постараемся избегать действий с диагональными ячейками настолько, насколько это возможно.
А вот и картинка, которая наглядно показывает нам порядок ячеек в массиве до и после сортировки. Можно заметить, что верхняя, нижняя, правая и левая ячейки обрабатываются в первую очередь. Зная порядок ячеек, вам будет легче определять, когда Коала касается земли или летает в облаках.
image

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

Мы почти готовы к следующему запуску нашей игры! Однако все еще нужно сделать пару вещиц. Нам нужно добавить слой walls как переменную в класс GameLevelLayer так, чтобы мы смогли ее использовать.

Внутри GameLevelLayer.m осуществите следующие изменения:

// Добавить в @interface
CCTMXLayer *walls;
 
// Добавить в метод init, после того, как на слой добавляется карта
walls = [map layerNamed:@"walls"];
 
// добавить в метод update
[self getSurroundingTilesAtPosition:player.position forLayer:walls];

Запускайте! Но, к сожалению, игра крашится. Мы видим в консоли нечто следующее:

image

Сначала мы получаем информацию о позициях ячеек и значения GID (хотя в основном нули, так как сверху пустая местность).
В конце, все крашится с ошибкой «TMXLayer: invalid position». Такое происходит, когда в метод tileGIDat: передается позиция, которая находится вне краев карты.
Мы избежим этой ошибки чуть позже — но сначала, мы собираемся изменить существующее определение столкновений.

Отбираем привилегии Коалы назад


До этого момента Коала сама обновляла себе позицию. Но сейчас мы забираем у нее эту привилегию.

image

Если Коала будет самостоятельно обновлять свою позицию, то в конце концов она начнет скакать как бешеная! А мы же этого не хотим, нет?
Так что Коала требует дополнительной переменной desiredPosition, при помощи которой она будет взаимодействовать с GameLevelLayer.
Мы хотим, чтобы класс Коалы самостоятельно высчитывал свою следующую позцию. Но GameLevelLayer должен перемещать Коалу в желаемую позицию только после проверки ее на валидность. То же самое применимо и к циклу определения столкновений — мы не хотим обновлять реальный спрайт до того, как все ячейки были проверены на предмет столкновений.
Нам нужно поменять несколько вещей. Сначала, добавьте следующее в Player.h

@property (nonatomic, assign) CGPoint desiredPosition;

И синтезируйте добавленное в Player.m:

@synthesize desiredPosition = _desiredPosition;

Теперь, измените метод collisionBoundingBox в Player.m, чтобы он выглядел так:

- (CGRect)collisionBoundingBox {

  CGRect collisionBox = CGRectInset(self.boundingBox, 3, 0);
  CGPoint diff = ccpSub(self.desiredPosition, self.position);
  CGRect returnBoundingBox = CGRectOffset(collisionBox, diff.x, diff.y);
  return returnBoundingBox;
}

Этот кусок кода вычисляет рамку, основываясь на желаемой позиции, которую GameLevelLayer будет использовать для определения столкновений.
Обратите внимание: Есть множество различных способов вычисления рамок столкновений. Вы можете написать код, похожий на тот, что уже имеется в классе CCNode, но наш нынешний способ гораздо проще, несмотря на некоторую неочевидность.
Далее, осуществите следующие изменения в методе update так, чтобы он обновлял desiredPosition заместо текущей позиции:

// Замените 'self.position = ccpAdd(self.position, stepVelocity);' на:
self.desiredPosition = ccpAdd(self.position, stepVelocity);

Давайте начнем определять столкновения!


Пришло время для серьезных свершений. Мы собираемся собрать все вместе. Добавьте следующий метод в GameLevelLayer.m:

Нажми меня
- (void)checkForAndResolveCollisions:(Player *)p {  

  NSArray *tiles = [self getSurroundingTilesAtPosition:p.position forLayer:walls ]; //1
 
  for (NSDictionary *dic in tiles) {
    CGRect pRect = [p collisionBoundingBox]; //2
 
    int gid = [[dic objectForKey:@"gid"] intValue]; //3
 
    if (gid) {
      CGRect tileRect = CGRectMake([[dic objectForKey:@"x"] floatValue], [[dic objectForKey:@"y"] floatValue], map.tileSize.width, map.tileSize.height); //4
      if (CGRectIntersectsRect(pRect, tileRect)) {
        CGRect intersection = CGRectIntersection(pRect, tileRect); //5
 
        int tileIndx = [tiles indexOfObject:dic]; //6
 
        if (tileIndx == 0) {
          //Ячейка прямо под Коалой
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height);
        } else if (tileIndx == 1) {
          //Ячейка прямо над Коалой
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y - intersection.size.height);
        } else if (tileIndx == 2) {
          //Ячейка слева от Коалы
          p.desiredPosition = ccp(p.desiredPosition.x + intersection.size.width, p.desiredPosition.y);
        } else if (tileIndx == 3) {
          //Ячейка справа от Коалы
          p.desiredPosition = ccp(p.desiredPosition.x - intersection.size.width, p.desiredPosition.y);
        } else {
          if (intersection.size.width > intersection.size.height) { //7
            //Ячейка диагональна, но решаем проблему вертикально
            float intersectionHeight;
            if (tileIndx > 5) {
              intersectionHeight = intersection.size.height;
            } else {
              intersectionHeight = -intersection.size.height;
            }
            p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height );
          } else {
          	//Ячейка диагональна, но решаем проблему горизонтально
            float resolutionWidth;
            if (tileIndx == 6 || tileIndx == 4) {
              resolutionWidth = intersection.size.width;
            } else {
              resolutionWidth = -intersection.size.width;
            }
            p.desiredPosition = ccp(p.desiredPosition.x , p.desiredPosition.y + resolutionWidth);
          } 
        } 
      }
    } 
  }
  p.position = p.desiredPosition; //7
}

Отлично! Давайте посмотрим на код, который мы только что написали.

1. Сначала мы получаем набор ячеек, окружающих Коалу. Далее мы проходимся циклом по каждой ячейке из этого набора. Каждый раз, когда мы проходимся по ячейке, мы проверяем ее на предмет столкновений. Если произошло столкновение, мы меняем desiredPosition у Коалы.
2. Внутри каждой петли цикла, мы сначала получаем текущую рамку Коалы. Каждый раз, когда определяется столкновение, переменная desiredPosition меняет свое значение на такое, при котором столкновения больше не происходит.
3. Следующий шаг это получение GID, который мы хранили в NSDictionary, который может являться нулем. Если GID равен нулю, то текущая петля завершается и мы переходим к следующей ячейке.
4. Если в новой позиции находится ячейка, нам нужно получить ее CGRect. В ней может быть, а может и не быть столкновения. Мы осуществляем этот процесс при помощи следующей строчки кода и сохраняем в переменную tileRect. Теперь, имея CGRect Коалы и ячейки, мы можем проверить их на предмет столкновения.
5. Чтобы проверить ячейки на предмет столкновения, мы запускаем CGRectIntersectsRect. Если произошло столкновение, то мы получим CGRect, описывающий CGRect пересечения при помощи функции CGRectIntersection().

Остановимся подумать на дилеммой...


Довольно интересный случай. Нам нужно додуматься как правильно определять столкновения.
Можно подумать, что лучший способ двигать Коалу — двигать ее в противоположную сторону от столкновения. Некоторые физические движки и вправду работают по этому принципу, но мы собираемся применить решение по-лучше.
Подумайте: гравитация постоянно тянет Коалу вниз в ячейки под ней, и эти столкновения происходят постоянно. Если вы представите Коалу, движущуюся вперед, то, в то же время, Коалу все еще тянет вниз гравитацией. Если мы будем решать эту проблему простым изменением движения в обратную сторону, то Коала будет двигаться вверх и влево — а ведь нам нужно нечто иное!
Наша Коала должна смещаться на достаточное расстояние, чтобы все еще оставаться над этими ячейками, но продолжать двигаться вперед с тем же темпом.
image
Та же проблема произойдет, если Коала будет скатываться вниз по стене. Если игрок будет прижимать Коалу к стене, то желаемая траектория движения Коалы будет направлена диагонально вниз и в стену. Просто обратив направление, мы заставим Коалу двигаться вверх и от стены — опять, совсем не то! Мы то хотим, чтобы Коала оставалась вне стены, но все еще спускалась вниз с тем же темпом!
image
Так что, нам нужно решить, когда работать со столкновениями вертикально, а когда горизонтально, и обрабатывать оба действия взаимоисключающе. Некоторые физические движки постоянно обрабатывают сначала первое событие, а потом второе; но мы-то хотим сделать решение по-лучше, основываясь на позиции ячейки Коалы. Так, например, когда ячейка прямо под Коалой, мы хотим, чтобы определитель столкновений возвращал Коалу вверх.
А что если ячейка диагональна позиции Коалы? В этом случае мы используем CGRect пересечения, чтобы понять, как мы должны двигать Коалу. Если ширина этого прямоугольника больше высоты, то возвращать Коалу нужно вертикально. Если высота больше ширины, то Коала должна смещаться горизонтально.
image
Этот процесс будет работать правильно до тех пор, пока скорость Коалы и скорость смены кадров будут в пределах определенных рамок. Чуть позднее мы научимся избегать случаев, когда Коала падает слишком быстро и проскакивает через ячейку вниз.
Как только мы определили, как двигать Коалу — вертикально или горизонтально, мы используем размер CGRect пересечения для определения, насколько нужно сместить Коалу. Смотрим на ширину или высоту соответственно и используем эту велечину как дистанцию смещения Коалы.
Зачем же проверять ячейки в определенном порядке? Вам всегда нужно сначала работать с прилегающими ячейками, а потом с диагональными. Ведь если вы захотите проверить на столкновение ячейку справа снизу от Коалы, то вектор смещения будет направлен вертикально.
image
Однако все еще есть шанс, что CGRect столкновения будет вытянутым вверх, когда Коала чуть-чуть касается ячейки.
Посмотрите на рисунок справа. Синяя область вытянута вверх, потому что прямоугольник столкновения — это лишь малая часть общего столкновения. Однако если мы уже решили проблему с ячейкой прямо под Коалой, то нам уже не нужно определять столкновения с ячейкой снизу справа от Коалы. Так мы и обходим появившиеся проблемы.

Назад к коду!


Вернемся к монструозному методу checkForAndResolveCollisions:

6. Шестая секция позволяет нам получить индекс текущей ячейки. Мы используем индекс ячейки чтобы получать позицию ячейки. Мы собираемся оперировать над прилегающими ячейками индивидуально, смещая Коалу, вычитая или добавляя длину или высоту столкновения. Довольно просто. Однако как только дело доходит до диагональных ячеек, мы собираемся применять алгоритм, описаный в предыдущей секции.
7. В седьмой секции мы определяем, какая наша область столкновения: широкая или вытянутая вверх? Если широкая — работаем вертикально. Если индекс ячейки больше 5, то двигаем Коалу вверх. Если область вытянута вверх — работаем горизонтально. Действуем по похожему принципу порядка индексов ячейки. В конце мы присваиваем Коале полученую позицию.

Этот метод — мозг нашей системы определения столкновений.

Давайте используем все имеющиеся знания на практике! Измените метод update (все еще в GameLevelLayer:)

// Замените "[self getSurroundingTilesAtPosition:player.position forLayer:walls];" на:
[self checkForAndResolveCollisions:player];

Также вы можете удалить или закомментировать блок getSurroundingTilesAtPosition:forLayer:

	/*
  for (NSDictionary *d in gids) {
    NSLog(@"%@", d);
  } //8 */

Запускаем! Удивлены результатом?

image

Пол останавливает Коалио, но тот тут же в него утопает! Почему?
Можете догадаться, что мы упустили? Помните — каждый шаг игры мы добавляем силу гравитации к скорости Коалы. Это обозначает, что Коала постоянно ускоряется вниз.
Мы постоянно добавляем скорость к траектории Коалы вниз, пока она не становится размером с ячейку — мы перемещаемся сквозь целую ячейку за один шаг, что и вызывает проблемы (помните, мы недавно об этом говорили).
Как только мы засекаем столкновение, нам нужно обнулять скорость коалы в направлении ячейки, с которой столкнулись! Коала перестала двигаться, так что и скорость должна с ней считаться.
Если мы этого не осуществим, то у нас будет довольно странное поведение игры. Как мы уже заметили ранее, нам нужен способ определять, касается ли Коала земли, чтобы Коала не смогла прыгать еще выше. Мы выставим этот флажок прямо сейчас. Добавьте следующие строки в checkForAndResolveCollisions:

Нажми меня
- (void)checkForAndResolveCollisions:(Player *)p {
 
  NSArray *tiles = [self getSurroundingTilesAtPosition:p.position forLayer:walls ]; //1
 
  p.onGround = NO; //////Здесь
 
  for (NSDictionary *dic in tiles) {
    CGRect pRect = [p collisionBoundingBox]; //3
 
    int gid = [[dic objectForKey:@"gid"] intValue]; //4
    if (gid) {
      CGRect tileRect = CGRectMake([[dic objectForKey:@"x"] floatValue], [[dic objectForKey:@"y"] floatValue], map.tileSize.width, map.tileSize.height); //5
      if (CGRectIntersectsRect(pRect, tileRect)) {
        CGRect intersection = CGRectIntersection(pRect, tileRect);
        int tileIndx = [tiles indexOfObject:dic];
 
        if (tileIndx == 0) {
          //ячейка под Коалой
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + intersection.size.height);
          p.velocity = ccp(p.velocity.x, 0.0); //////Здесь
          p.onGround = YES; //////Здесь
        } else if (tileIndx == 1) {
          //ячейка над Коалой
          p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y - intersection.size.height);
          p.velocity = ccp(p.velocity.x, 0.0); //////Здесь
        } else if (tileIndx == 2) {
          //ячейка слева
          p.desiredPosition = ccp(p.desiredPosition.x + intersection.size.width, p.desiredPosition.y);
        } else if (tileIndx == 3) {
          //ячейка справа
          p.desiredPosition = ccp(p.desiredPosition.x - intersection.size.width, p.desiredPosition.y);
        } else {
          if (intersection.size.width > intersection.size.height) {
            //tile is diagonal, but resolving collision vertially
			p.velocity = ccp(p.velocity.x, 0.0); //////Здесь
            float resolutionHeight;
            if (tileIndx > 5) {
              resolutionHeight = intersection.size.height;
              p.onGround = YES; //////Здесь
            } else {
              resolutionHeight = -intersection.size.height;
            }
            p.desiredPosition = ccp(p.desiredPosition.x, p.desiredPosition.y + resolutionHeight);
 
          } else {
            float resolutionWidth;
            if (tileIndx == 6 || tileIndx == 4) {
              resolutionWidth = intersection.size.width;
            } else {
              resolutionWidth = -intersection.size.width;
            }
            p.desiredPosition = ccp(p.desiredPosition.x + resolutionWidth, p.desiredPosition.y);
          } 
        } 
      }
    } 
  }
  p.position = p.desiredPosition; //8
}

Каждый раз, когда под Коалой есть ячейка (либо прилегающая, либо диагональная), мы выставляем значение переменной p.onGround равное YES и обнуляем скорость. Также, если под Коалой есть прилегающая ячейка, мы обнуляем его скорость. Это позволит нам правильно реагировать на текущую скорость Коалы.
Мы выставляем значение переменной onGround равное NO в начале цикла. В этом случае, у onGround будет значение YES только тогда, когда мы обнаружим столкновение Коалы с ячейкой под ней. Мы можем использовать эту особенность для того, чтобы определять может Коала прыгать или нет в текущий момент времени.
Добавьте следующий код в загаловочный файл (и затем синтезируйте все необходимое в исполнительном) в Player.h:

@property (nonatomic, assign) BOOL onGround;

И в Player.m:

@synthesize onGround = _onGround;

Запускаем! Все работает так, как и задумывалось? Да! О, этот великолепный день! Ура!

image

Что дальше?


Поздравляю! Вы полностью закончили свой физический движок! Если вы добрались до этого текста, то можете вздохнуть с облегчением. Это была сложная часть — ничего сложного во второй части туториала не будет.
А вот и исходники проекта который мы сейчас закончили.
Во второй части мы заставим нашего Коалио бегать и прыгать. Так же мы сделаем шипованные блоки в полу опасными для нашей Коалы и создадим экраны выигрыша и проигрыша.
Если вы хотите получить еще больше знаний о физических движках для платформеров, то я советую вам посетить следующие ресурсы:
The Sonic the Hedgehog Wiki — отличное объяснение тому, как Sonic взаимодействует с твердыми ячейками.
Возможно, лучший гайд по созданию платформеров от Higher-Order Fun.


Примечание переводчика

Решил перевести этот туториал, вдохновившись этой статьей.
Сам сейчас пишу игру-платформер на iOS и активно пользуюсь туториалами на сайте raywenderlich.com. Советую всем!
После сравнительно большого количества потраченного времени на перевод первой части, задумался о переводе второй части. Напишите в комментариях, нужно ли. Если будет востребована — переведу.
Обо всех найденных неточностях и опечатках, пожалуйста, пишите в хабрапочте или тут в комментариях.
С радостью отвечу на все вопросы по туториалу!
Tags:
Hubs:
+113
Comments16

Articles

Change theme settings