Как сделать игру наподобие Space Invaders с использованием Sprite Kit. Туториал: Часть 1

spaceinvaders.png
Space Invaders - одна из наиболее популярных видеоигр когда-либо разработанных. Игра создана Томохиро Нишикадо и выпущена в 1978 году компанией Taito Corporation, принесла миллиарды долларов дохода. Она стала культурной иконой, вдохновляя легионы не-гиков сделать видеоигры своим хобби.

Space Invaders раньше играли на больших игровых автоматах с видео аркадами, тратя четвертак за раз. Когда игровая консоль Atari 2600 вышла на рынок, Space Invaders была вирусной игрой, которая обеспечила продажи оборудования Atari..

В этом уроке Вы создадите iOS версию Space Invaders с использование SpriteKit - 2D игрового движка, который появился в iOS 7.

Предполагается, что Вы уже знакомы с основами Sprite Kit. Если Вы новичок в Sprite Kit, Вам нужно пройти сначала урок Sprite Kit tutorial for beginners

Также, Вам нужно иметь iPhone или iPod Touch с iOS версии 7 или выше и аккаунт разработчика Apple для прохождения большинства этих уроков. Причиной этому является то, что управление кораблем в игре будет производиться с помощью Акселерометра, которого просто нету в iOS симуляторе. Если у Вас нету iOS девайса или аккаунта разработчика, Вы все же можете пройти данный урок - Вы просто не сможете перемещать корабль.

Итак, меньше слов - больше дела, давайте приготовимся взорвать нескольких пришельцев!

space_invaders_cabinet_at_lyme_regis1-173x320.jpg

Начинаем

Apple предоставляет нам темплейт под названием Sprite Kit Game, который довольно хорошо подходит для создания нашего хита с нуля. Несмотря на это, для того, чтобы вы могли начать быстро, скачайте стартовый проект для этого туториала. Основой для него служит шаблон Sprite Kit, который включает в себя некоторые наиболее скучные части нашей работы, которые уже сделаны для Вас.

Когда Вы скачаете и распакуете проект, перейдите в директорию SKInvaders и дважды кликните на SKInvaders.xcodeproj для того, чтобы открыть наш проект в Xcode.

Соберите и запустите проект, нажав на кнопку Run, которая расположена на верхней панели Xcode (первая кнопка слева вверху) или используйте комбинацию клавиш Command + R. Вы должны увидеть следующее на экране Вашего устройства или в окне симулятора:

appscreenshot_firstrun-333x500.png

Жуть - захватчики уже смотрят на тебя! Тем не менее, если у Вас появилось тоже самое, что и на изображении выше - значит вы готовы двигаться вперед.

Роль GameScene

Для завершения Вашей версии Space Invaders, Вам нужно запрограммировать несколько независимых частей игровой логики; этот туториал послужит прекрасным упражнением по конструированию и детализации игровой логики. Также, это укрепит Ваше понимание того, как элементы Sprite Kit сочетаются вместе для создания действий в игре.

Большинство действий в игре происходят в классе GameScene. Большую часть этого учебника Вы будете работать с классом GameScene.

Давайте посмотрим как игра устроена. Откройте GameViewController.m и перейдите вниз к viewDidLoad. Этот метод ключевой для всех UIKit приложений и запускается после того, как GameViewController загружает свое представление в память. Он предназначен для будущей настройки графического интерфейса Вашего приложения.

Давайте посмотрим интересующую нас часть метода viewDidLoad:

  - (void)viewDidLoad
  {
    // ... пропущено ...
 
    SKView * skView = (SKView *)self.view;
 
    // ... пропущено ...
 
    //1 Создаем и настраиваем нашу сцену.
    SKScene * scene = [GameScene sceneWithSize:skView.bounds.size];
    scene.scaleMode = SKSceneScaleModeAspectFill;
 
    //2 Показываем сцену.
    [skView presentScene:scene];
  }

Эта часть кода создает и отображает сцену следующим образом:

  1. Сначала создаем сцену с такими же размерами как родительское представление. scaleMode гарантирует, что размер сцены достаточный для заполнения представления.
  2. Далее, отображаем сцену на экране.

Как только GameScene на экране, она берет на себя основные обязанности GameViewController и управляет остальной частью игры.

Откройте GameScene.m и посмотрите как она организована:

  #import "GameScene.h"
  #import "GameOverScene.h"
  #import <CoreMotion/CoreMotion.h>
 
  #pragma mark - Custom Type Definitions
 
  #pragma mark - Private GameScene Properties
 
  @interface GameScene ()
  @property BOOL contentCreated;
  @end
 
  @implementation GameScene
 
  #pragma mark Object Lifecycle Management
 
  #pragma mark - Scene Setup and Content Creation
 
  - (void)didMoveToView:(SKView *)view
  {
      if (!self.contentCreated) {
          [self createContent];
          self.contentCreated = YES;
      }
  }
 
  - (void)createContent
  {
    // ... omitted ...
  }
 
  #pragma mark - Scene Update
 
  - (void)update:(NSTimeInterval)currentTime
  {
  }
 
  #pragma mark - Scene Update Helpers
 
  #pragma mark - Invader Movement Helpers
 
  #pragma mark - Bullet Helpers
 
  #pragma mark - User Tap Helpers
 
  #pragma mark - HUD Helpers
 
  #pragma mark - Physics Contact Helpers
 
  #pragma mark - Game End Helpers
 
  @end

Вы заметили, что в данном коде много строк вида #pragma mark - Something or Other. Эти строки называются директивами компилятора так как они управляют компилятором. В данном случае, эти pragma метки используются исключительно для того, чтобы сделать навигацию по файлу более простой.

Вы можете спросить - как pragma метки могут сделать навигацию проще? Заметьте, что в верхней части навигационной панели, сразу за GameScene.m написанно “No Selection”, как изображено ниже:

xcodescreenshot_pragmamenulocation.png

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

Создание злых захватчиков из космоса

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

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

Представление вызывает метод сцены didMoveToView: для ее отображения. Перейдите к didMoveToView: и Вы увидите следующее:

  - (void)didMoveToView:(SKView *)view
  {
      if (!self.contentCreated) {
          [self createContent];
          self.contentCreated = YES;
      }
  }

Этот метод просто вызывает createContent, используя булерное свойство contentCreated чтобы удостовериться, что Вы не создадите контент сцены больше чем один раз. Это свойство определено в Расширении класс и находиться в самом начале файла:

    #pragma mark - Private GameScene Properties
 
    @interface GameScene ()
    @property BOOL contentCreated;
    @end

Как показывает pragma маркер, это расширение класса позволяет Вам добавлять "приватные" свойства к Вашему GameScene классу, без отображения его в другом внешнем классе или коде. Вы все еще получаете плюсы использования Objective-C свойств, но состояние GameScene храниться внутри и не может быть модифицировано другими внешними классами. Также, это не загромождает пространство имен типов данных, которые ваши остальные классы видят.

Также как Вы сделали в приватных свойствах сцены, Вы можете определять важные константы как приватные в Вашем файле. Перейдите к #pragma mark - Custom Type Definitions и добавьте следующий код:

//1
typedef enum InvaderType {
    InvaderTypeA,
    InvaderTypeB,
    InvaderTypeC
} InvaderType;
 
//2
#define kInvaderSize CGSizeMake(24, 16)
#define kInvaderGridSpacing CGSizeMake(12, 12)
#define kInvaderRowCount 6
#define kInvaderColCount 6
//3
#define kInvaderName @"invader"

Выше определенные типы и константы служат для следующих задач:

  1. Объявление возможных типов захватчиков-врагов. Вы можете увидеть их использование внутри оператора switch немного позже, когда Вам будет необходимо отображать различные типы спрайтов для каждого типа врагов. typedef, также, создает формальный тип Objective-C, который является типом-ключем для аргументов методов и переменных. Это гарантирует, что вы не использовали неправильный аргумент метода или не инициализировали его на неправильным значением.
  2. Определяет размеры захватчиков для отображения в сетке на экране.
  3. Определяет имя, которое Вы будете использовать для идентификации захватчиков, когда будете искать их внутри сцены.

Это хорошая практика определять константы как мы сделали выше и это намного лучше чем использовать просто числа, например, 5(также известные как "магические числа") или просто строки, как @"invader"("магические строки"), которые часто становятся причиной опечаток. Представьте себе, что при вводе @"invader" Вы случайно набрали @"Invader" и потратили часы времени на отладку, чтобы найти эту простую опечатку, испортившую все. Использование констант как kInvaderRowCount и kInvaderName защитит от раздражающих багов и сделает код более читаемым для других программистов.

Ладно, пора создать нескольких захватчиков! Добавьте следующий метод к GameScene.m прямо после createContent:

-(SKNode*)makeInvaderOfType:(InvaderType)invaderType {
    //1
    SKColor* invaderColor;
    switch (invaderType) {
        case InvaderTypeA:
            invaderColor = [SKColor redColor];
            break;
        case InvaderTypeB:
            invaderColor = [SKColor greenColor];
            break;
        case InvaderTypeC:
        default:
            invaderColor = [SKColor blueColor];
            break;
    }
 
    //2
    SKSpriteNode* invader = [SKSpriteNode spriteNodeWithColor:invaderColor size:kInvaderSize];
    invader.name = kInvaderName;
 
    return invader;
}

makeInvaderOfType: как и предполагает имя, созданет спрайт захватчика определенного типа. Вы предпринимаете следующие действия в приведенном выше коде:

  1. Используете параметр invaderType для определения цвета захватчика.
  2. Вызываете удобный для этого метод spriteNodeWithColor:size класска SKSpriteNode чтобы выделить память и инициализировать спрайт, который отобразится как прямоугольник заданного цвета invaderColor с размером kInvaderSize.

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

Добавление makeInvaderOfType: не совсем достаточно для отображения захватчиков на экране. Вам нужно откуда-то вызвать makeInvaderOfType: и разместить сованные спрайты внутри сцены.

По прежнему в GameScene.m добавьте следующий метод, зразу за makeInvaderOfType: :

-(void)setupInvaders {
    //1
    CGPoint baseOrigin = CGPointMake(kInvaderSize.width / 2, 180);
    for (NSUInteger row = 0; row < kInvaderRowCount; ++row) {
        //2
        InvaderType invaderType;
        if (row % 3 == 0)      invaderType = InvaderTypeA;
        else if (row % 3 == 1) invaderType = InvaderTypeB;
        else                   invaderType = InvaderTypeC;
 
        //3
        CGPoint invaderPosition = CGPointMake(baseOrigin.x, row * (kInvaderGridSpacing.height + kInvaderSize.height) + baseOrigin.y);
 
        //4
        for (NSUInteger col = 0; col < kInvaderColCount; ++col) {
            //5
            SKNode* invader = [self makeInvaderOfType:invaderType];
            invader.position = invaderPosition;
            [self addChild:invader];
            //6
            invaderPosition.x += kInvaderSize.width + kInvaderGridSpacing.width;
        }
    }
}

Указанный выше метод размещает захватчиков в сетке из строк и столбцов. Каждая строка содержит только один тип захватчиков. Логика выглядит сложной, но если вы разобрать ее, появляется смысл:

  1. Цикл по строкам.
  2. Выбираем один InvaderType для всех захватчиков в этой строке в зависимости от количества строк.
  3. Делаем некоторые вычисления, чтобы выяснить, где первый захватчик в этом ряду будет располагаться.
  4. Цикл по столбцам.
  5. Создаем захватчика для текущей строки/столбца и добавляем его на сцену.
  6. Обновляем invaderPosition, так чтобы она была правильной для следующего захватчика.

Теперь Вы просто отобразите захватчиков на экране. Замените текущий код в createContent следующим:

[self setupInvaders];

Запустите Ваше приложение. Вы должны увидеть группу захватчиков на экране, как на показано ниже:
appscreenshot_setupinvadersfirstrun-333x500.png

Прямоугольные пришельцы господствуют здесь! :]

Создайте свой доблестный корабль

С захватчиками на экране, Ваш доблестный корабль не может отставать. Также как Вы сделали для захватчиков, Вам нужно определить несколько констант.

Добавьте следующий код сразу после строчки #define kInvaderName:

#define kShipSize CGSizeMake(30, 16)
#define kShipName @"ship"

kShipSize содержит размер корабля, а kShipName - его имя, которое Вы назначите спрайты ноды, таким образом Вы сможете просто найти его позже.

Далее, добавьте следующие два метода сразу после setupInvaders:

-(void)setupShip {
    //1
    SKNode* ship = [self makeShip];
    //2
    ship.position = CGPointMake(self.size.width / 2.0f, kShipSize.height/2.0f);
    [self addChild:ship];
}
 
-(SKNode*)makeShip {
    SKNode* ship = [SKSpriteNode spriteNodeWithColor:[SKColor greenColor] size:kShipSize];
    ship.name = kShipName;
    return ship;
}

Вот самые интересные части логики в этих двух методах выше:

  1. Создание корабля, используюя makeShip. Вы можете просто повторно использовать makeShip позже, если будет необходимо создать другой корабль(н.п. если текущий корабль будет разрушен захватчиком и игрок потеряет жизнь).
  2. Размещение корабля на экране. В Sprite Kit начало отсчета координат находится в левом нижнем углу экрана. anchorPoint основывается на квадрате с координатами (0, 0) в левом нижнем углу спрайта и (1, 1) в правом верхнем углу. Исходя из этого SKSpriteNode имеет anchorPoint (опорную точку) с координатами (0.5, 0.5), то есть, его центр, местоположение корабля является положением его центральной точки. Расположение корабля по координатам kShipSize.height/2.0f означает, что половина высоты корабля будет выступать ниже своего положения и выше половины. Если Вы проверите с точки зрения математики, Вы увидите, что низ корабля прилягает прямо к низу сцены.

Для отображения Вашего корабля на экране, добавьте следующую строку прямо в конец createContent:

[self setupShip];

Соберите и запустите Ваше приложение; Вы должны увидеть что Ваш корабль появился в сцене как на показано ниже:

appscreenshot_addedship-333x500.png

Не бойтесь, граждане Земли! Ваш надежный космический корабль здесь, чтобы спасти!

Добавляем HUD (индикатор отображения прогресса во время игры)

Будет не очень интересно играть в Space Invaders, если Вы будете знать свой счет, не так ли? Давайте добавим heads-up display (or HUD) к нашей игре. Как космический пилот защищающейся Землю, ваша эффективность будет отслеживаться Вашими командующими офицерами. Они заинтересованы как в количестве убитых захватчиков, так и в боеготовности (здоровье).

Добавьте следующие константы в начало GameScene.m, сразу после #define kShipName:

#define kScoreHudName @"scoreHud"
#define kHealthHudName @"healthHud"

Теперь, добавьте Ваш HUD вставив следующий метод сразу после makeShip:

-(void)setupHud {
    SKLabelNode* scoreLabel = [SKLabelNode labelNodeWithFontNamed:@"Courier"];
    //1
    scoreLabel.name = kScoreHudName;
    scoreLabel.fontSize = 15;
    //2
    scoreLabel.fontColor = [SKColor greenColor];
    scoreLabel.text = [NSString stringWithFormat:@"Score: %04u", 0];
    //3
    scoreLabel.position = CGPointMake(20 + scoreLabel.frame.size.width/2, self.size.height - (20 + scoreLabel.frame.size.height/2));
    [self addChild:scoreLabel];
 
    SKLabelNode* healthLabel = [SKLabelNode labelNodeWithFontNamed:@"Courier"];
    //4
    healthLabel.name = kHealthHudName;
    healthLabel.fontSize = 15;
    //5
    healthLabel.fontColor = [SKColor redColor];
    healthLabel.text = [NSString stringWithFormat:@"Health: %.1f%%", 100.0f];
    //6
    healthLabel.position = CGPointMake(self.size.width - healthLabel.frame.size.width/2 - 20, self.size.height - (20 + healthLabel.frame.size.height/2));
    [self addChild:healthLabel];
}

Это стандартный код для создания и добавления текстовых меток на экран. Соответствующие пункты следующие:

  1. Устанавливаем имя метки, чтобы потом Вы могли ее найти для обновления отображаемого счета очков.
  2. Цвет метки будет зеленым.
  3. Позиция метки сверху слева экрана.
  4. Устанавливаем имя метки для отображения здоровья, чтобы Вы могли ссылаться на нее позже, когда нужно будет обновлять отображаемое здоровье.
  5. Цвет текста, отображающего здоровье будет красным; красный и зеленый индикаторы привычные цвета для этих индикаторов в играх, и их просто отличить друг от друга во время неистовой игры.
  6. Текст, отображающий здоровье, будет расположен в правом верхнем углу экрана.

Добавьте следующую строку в конец createContent для вызова метода инициализации Вашего HUD:

[self setupHud];

Соберите и запустите Ваше приложение; Вы должны увидеть на экране индикаторы здоровья и очков как показано ниже:

appscreenshot_addedhud-333x500.png

Захватчики? Проверено. Корабль? Проверено. HUD? Проверено. Теперь все что Вам нужно, это добавить немного динамических действий для связи всего этого вместе!

Добавляем перемещение Захватчиков

Для рендеринга Вашей игры, Sprite Kit использует игровой цикл, который бесконечно ищет изменения состояний, которые необходимы для обновления элементов на экране. Игровой цикл выполняет некоторые вещи, но Вам будет интересен механизм обновления Вашей сцены. Это делается с помощью метода update:, заглушка на который Вы найдете в файле GameScene.m.

Когда Ваша игра работает сладко и рендерит 60 кадров в секунду(iOS устройства имеет аппаратные ограничения максимум 60 кадров в секунду), метод update: будет вызываться 60 раз в секунду. Это место, где Вы будете изменять состояния Вашей сцены, такие как обновление очков скорости, удаления спрайтов мертвых захватчиков или перемещение Вашего корабля...

Вы будете использовать update: для перемещения Ваших захватчиков на экране. Каждый раз, когда Sprite Kit вызывает update:, он спрашивает Вас "Изменилась ли Ваша сцена?", "Изменилась ли Ваша сцена?"... это Ваша работа отвечать на эти вопросы - и Вы напишите немного кода только для этого.

Вставьте следующий код в начале GameScene.m, прямо после определения перечисления InvaderType:

typedef enum InvaderMovementDirection {
    InvaderMovementDirectionRight,
    InvaderMovementDirectionLeft,
    InvaderMovementDirectionDownThenRight,
    InvaderMovementDirectionDownThenLeft,
    InvaderMovementDirectionNone
} InvaderMovementDirection;

Захватчики перемещаются по заданному шаблону: вправо, вправо, вниз, влево, влево, вниз, вправо, вправо,... таким образом, Вы будете использовать тип InvaderMovementDirection для отслеживания прогресса по этому шаблону. Например, InvaderMovementDirectionRight означает, что захватчики в право, право части их шаблона.

Далее, найдите расширение класса в этом же файле и вставьте следующие свойства, прямо после существующего свойства для contentCreated:

@property InvaderMovementDirection invaderMovementDirection;
@property NSTimeInterval timeOfLastMove;
@property NSTimeInterval timePerMove;

Добавьте следующий код в самый верх createContent:

//1
self.invaderMovementDirection = InvaderMovementDirectionRight;
//2
self.timePerMove = 1.0;
//3
self.timeOfLastMove = 0.0;

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

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

Теперь Вы готовы к созданию перемещения захватчиков. Добавьте следующий код сразу после #pragma mark - Scene Update Helpers:

// This method will get invoked by update:
-(void)moveInvadersForUpdate:(NSTimeInterval)currentTime {
    //1
    if (currentTime - self.timeOfLastMove < self.timePerMove) return;
 
    //2
    [self enumerateChildNodesWithName:kInvaderName usingBlock:^(SKNode *node, BOOL *stop) {
        switch (self.invaderMovementDirection) {
            case InvaderMovementDirectionRight:
                node.position = CGPointMake(node.position.x + 10, node.position.y);
                break;
            case InvaderMovementDirectionLeft:
                node.position = CGPointMake(node.position.x - 10, node.position.y);
                break;
            case InvaderMovementDirectionDownThenLeft:
            case InvaderMovementDirectionDownThenRight:
                node.position = CGPointMake(node.position.x, node.position.y - 10);
                break;
            InvaderMovementDirectionNone:
            default:
                break;
        }
    }];
 
    //3
    self.timeOfLastMove = currentTime;
}

Вот разбивка логики в приведенном выше коде, комментарий за комментарием:

  1. Если еще не наступило время для перемещения, то выходим из метода. moveInvadersForUpdate: вызывается 60 раз в секунду, но Вам не нужно, чтобы захватчики перемещались так часто потому, что эти перемещения будут чересчур быстрыми для нормального человека, чтобы он мог их заметить.
  2. Напомним, что ваша сцена хранит всех захватчиков как дочерние узлы; Вы добавляете их на сцену используя addChild: в setupInvaders идентифицируя каждого захватчика по значению его свойства name. Вызываемый enumerateChildNodesWithName:usingBlock: пройдется только по захватчикам, потому-что их свойство name равно kInvaderName это позволит циклу пропустить Ваш корабль и HUD. Содержимое этого блока переместит захватчиков в каком-то из направлений: вправо, влево или вниз, в зависимости от значения invaderMovementDirection .
  3. Запоминаем, что вы только что переместили захватчиков, так что когда в следующий раз этот метод вызывается (1/60 секунды), захватчики не будут двигаться снова, пока установленный период времени в одну секунду не истечет.

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

-(void)update:(NSTimeInterval)currentTime {
    [self moveInvadersForUpdate:currentTime];
}

Соберите и запустите Ваше приложение; Вы должны увидеть Ваших захватчиков медленно перемещающимися по экрану. Продолжайте наблюдать и в конечном счете Вы увидите следующий экран:

appscreenshot_invadersmovedoffscreen-333x500.png

Хммммм, что случилось? Почему захватчики пропали? Возможно захватчики не такие уж грозные как Вы думали!

Захватчики пока что не знают, что им нужно перемещаться вниз и менять направление их движения, как только они достигли одной из сторон игрового поля. Предполагаю, что Вам нужно помочь этим захватчикам найти их путь!

Управление направлением захватчиков

Добавьте следующий метод, зразу за #pragma mark - Invader Movement Helpers :

-(void)determineInvaderMovementDirection {
    //1
    __block InvaderMovementDirection proposedMovementDirection = self.invaderMovementDirection;
 
    //2
    [self enumerateChildNodesWithName:kInvaderName usingBlock:^(SKNode *node, BOOL *stop) {
        switch (self.invaderMovementDirection) {
            case InvaderMovementDirectionRight:
                //3
                if (CGRectGetMaxX(node.frame) >= node.scene.size.width - 1.0f) {
                    proposedMovementDirection = InvaderMovementDirectionDownThenLeft;
                    *stop = YES;
                }
                break;
            case InvaderMovementDirectionLeft:
                //4
                if (CGRectGetMinX(node.frame) <= 1.0f) {
                    proposedMovementDirection = InvaderMovementDirectionDownThenRight;
                    *stop = YES;
                }
                break;
            case InvaderMovementDirectionDownThenLeft:
                //5
                proposedMovementDirection = InvaderMovementDirectionLeft;
                *stop = YES;
                break;
            case InvaderMovementDirectionDownThenRight:
                //6
                proposedMovementDirection = InvaderMovementDirectionRight;
                *stop = YES;
                break;
            default:
                break;
        }
    }];
 
    //7
    if (proposedMovementDirection != self.invaderMovementDirection) {
        self.invaderMovementDirection = proposedMovementDirection;
    }
}

Описание того, что происходит в коде Выше:

  1. Так как локальные переменные доступны внутри блока в виде констант(поэтому они не могут быть изменены), Вы должны инициализировать proposedMovementDirection с использованием __block, чтобы модифицировать в //2.
  2. Цикл, который проходиться по всем захватчика в сцене и вызывает блок в качестве аргумента.
  3. Если правый край захватчика находится в пределах 1-го пикселя к границам правого края сцены, он может выйти за пределы экрана. Установим proposedMovementDirection, чтобы захватчики перемещались вниз и влево. Вы сравниваете кадр захватчика(кадр, который содержит его контент в координатной системе сцены) с шириной сцены. Так как сцена имеет anchorPoint (0,0) по умолчанию, и она масштабирована для заполнения родительского представления, это сравнение гарантирует, что Вы будете сравнивать края представления.
  4. Если левый край захватчика находиться в пределах 1-го пикселя к границам левого края сцены, он близок к выходу за пределы экрана. Установим proposedMovementDirection, чтобы захватчики перемещались вниз, а потом направо.
  5. Если захватчики перемещаются вниз, а затем влево, они уже опускались вниз на этом шаге, таким образом, они теперь должны перемещаться влево. Как это работает, станет более понятно, когда Вы свяжете determineInvaderMovementDirection с moveInvadersForUpdate:.
  6. Если захватчики перемещаются вниз, а затем вправо, они уже опускались вниз на этом шаге, таким образом, они теперь должны перемещаться вправо.
  7. Если предполагаемое направление перемещение захватчиков отличается от направления текущего захватчика, обновляем текущее направление предполагаемым.

Добавьте следующий код для determineInvaderMovementDirection к moveInvadersForUpdate:, сразу после проверки self.timeOfLastMove:

[self determineInvaderMovementDirection];

Почему важно, чтобы Вы добавили determineInvaderMovementDirection только после проверки на self.timeOfLastMove? Причиной является то, что Вы хотите, чтобы направление движения захватчиков изменялось только когда они действительно перемещаются. Захватчики перемещаются только тогда, когда проверка self.timeOfLastMove будет успешной - т.е. условное выражение будет истинным.

Что могло бы произойти, если бы Вы добавили эту строчку кода вначале moveInvadersForUpdate:? Если бы Вы сделали это, тогда бы появилось два бага:

  • Вы бы пытались обновлять направление перемещения слишком часто - 60 раз в секунду, хотя нам известно, что оно может измениться только 1 раз в секунду.
  • Захватчики никогда бы не двигались вниз, так как изменение состояния из InvaderMovementDirectionDownThenLeft в InvaderMovementDirectionLeft будет происходить без движения захватчик между ними. Следующий вызов moveInvadersForUpdate:, который выполняется для проверки self.timeOfLastMove вызывался бы с self.invaderMovementDirection == InvaderMovementDirectionLeft и продолжал бы перемещать захватчиков влево, пропуская движение вниз. Похожий баг существовал бы для InvaderMovementDirectionDownThenRight и InvaderMovementDirectionRight

Скомпилируйте и запустите Ваше приложение; Вы увидите захватчиков, перемещающихся вдоль и вниз экрана, как изображено ниже:

appscreenshot_invadersmovingright-213x320.png

appscreenshot_invadersmovingdownthenleft-213x320.png

appscreenshot_invadersmovingleft-213x320.png

Заметка: Возможно Вы заметили, что перемещения захватчиков происходит толчками. Это является следствием того, что Ваш код перемещает захватчиков только 1 раз в секунду, и перемещает их при этом на приличное расстояние. Но перемещения в оригинальной игре тоже происходили толчками, следовательно, оставим эту особенность, чтобы сделать нашу игру более достоверной.

Добавление движения к Вашему кораблю

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

Важно запомнить о мобильных играх следующее:

Мобильные игры - это не настольные игры, и управление в настольных играх не подходит для мобильных.

В настольной версии игры Space Invaders у Вас был джойстик и кнопка выстрела для перемещения Вашего корабля и выстрелов по захватчикам. Однако это не подходит для мобильного устройства, такого как iPhone или IPad.

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

Подумайте о том, как Вы используете iPhone наиболее часто: держа его в одной руке. Это оставляет только одну руку для нажатий / свайпа / жест на экране.

Сосредоточимся на управлении iPhone одной рукой и рассмотрим несколько потенциальных схем управления для перемещения вашего корабля и выстрелов из Вашей лазерной пушки:

Одно касание для движения, два для выстрела:

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

Первое - это распознавание одиночного и двойного касание требует от Вас задержки после первого касания пока второе касание не произойдет или не сработает таймаут. Когда Вы неистово кликаете по экрану, эта задержка сделает управление недопустимо глючным. Второе, это то, что одиночные и двойные касания к экрану иногда могут быть достаточно запутанными, для Вас - пилота и для кода. Третье - перемещение корабля одиночным касанием не будет работать приемлемо, когда Ваш корабль очень близко к левому или правому краю экрана. Забудем эту схему управления!

Свайп для перемещения, одиночное касание для огня:

Этот подход немного лучше. Одиночное касание для выстрела имеет смысл. Это интуитивно понятно. Но как насчет использование свайпа для перемещения Вашего корабля?

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

Наклон Вашего устройства Вправо/Влево для перемещения, одиночный клик для выстрела:

Мы только что пришли к выводу, что одиночный клик для выстрела работает хорошо. Но как насчет наклонов Вашего устройства влево и вправо для перемещения корабля влево или вправо? Это Ваш наилучший выбор, учитывая то, что Вы держите Ваш iPhone в одной руке и наклоняете Ваше устройство в разные стороны, и это требует лишь немного поворачивать запястье. Мы имеем победителя!

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

Управление кораблем с помощью движений устройством

Возможно, Вы знакомы с классом UIAccelerometer для определения наклонов устройства, который доступен начиная с iOS 2.0. Тем не менее, UIAccelerometer был признан устаревшим в iOS 5.0, таким образом приложения для iOS 7.0 должны использовать CMMotionManager, который является частью фреймворка Apple CoreMotion.

Библиотека CoreMotion уже добавлена к нашему проекту и Вам нет необходимости добавлять ее.

Ваш код может получить данные акселерометра из CMMotionManager двумя разными путями:

Отправлять данные акселерометра в Ваш код

В этом сценарии, Вы предоставляете CMMotionManager блок, который будет регулярно вызываться с данными акселерометра. Это не совсем хорошо подходит для update: метода Вашей сцены, который вызывается раз в 1/60 секунды. Вам только нужно получить эти данные во время этих срабатываний - и эти срабатывания не будут подстраиваться под моменты когда CMMotionManager решить отправить данные в Ваш код.

Считывать данные акселерометра из Вашего кода

В этом сценарии Вы вызываете CMMotionManager и запрашиваете данные, когда они Вам необходимы. Размещая эти вызовы внутри update: метода Вашей сцены прекрасно вписывается в ритм Вашей системы. Вы будете получать данные акселерометра 60 раз в секунду, таким образом не прийдется волноваться насчет сбоев.

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

@property (strong) CMMotionManager* motionManager;

Теперь, добавьте следующий код в метод didMoveToView:, сразу после строчки self.contentCreated = YES;:

self.motionManager = [[CMMotionManager alloc] init];
[self.motionManager startAccelerometerUpdates];

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

Добавьте следующий метод сразу после moveInvadersForUpdate:

-(void)processUserMotionForUpdate:(NSTimeInterval)currentTime {
    //1
    SKSpriteNode* ship = (SKSpriteNode*)[self childNodeWithName:kShipName];
    //2
    CMAccelerometerData* data = self.motionManager.accelerometerData;
    //3
    if (fabs(data.acceleration.x) > 0.2) {
      //4 How do you move the ship?
      NSLog(@"How do you move the ship: %@", ship);
    }
}

Проанализировав этот метод, Вы обнаружите следующее:

  1. Получаем корабль из сцены, чтобы можно было перемещать его.
  2. Получаем данные акселерометра из менеджера движений.
  3. Если Ваше устройство повернуто экраном вверх и кнопка home находится внизу, значит наклоны устройства вправо генерируют data.acceleration.x > 0, в то время как наклон его влево вернет data.acceleration.x < 0. Сравнение с 0,2 означает, что устройство не будет считаться идеально ровным (технически, data.acceleration.x == 0) так как это достаточно близко к нулю (data.acceleration.x в диапазоне [-0.2, 0.2]). В этом нету ничего особенного, просто со значением 0,2 это должно нормально работать для меня. Такие маленькие трюки как этот сделают Ваше управление системой более надежной и менее разочаровывающей для пользователей.
  4. Хммммм, как Вы используете data.acceleration.x для перемещения корабля? Вы хотите, чтобы маленькие наклоны перемещали корабль на небольшое расстояние, а большие - на большие. Ответ следующий - физика, которую мы рассмотрим в следующей секции!

Реализация управления движением с помощью физики

Sprite Kit имеет мощную встроенную систему физика, базирующуюся на Box 2D, которая может симулировать широкий спектр возможностей, например, сила, перемещение, вращение, столкновения и обработка контакта. Каждый объект SKNode и поэтому каждый SKScene и SKSpriteNode имеет SKPhysicsBody присоединенное к нему. SKPhysicsBody отвечает за симуляцию физики ноды.

Добавьте следующий код, сразу перед последней return ship; строкой в makeShip:

//1
ship.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:ship.frame.size];
//2
ship.physicsBody.dynamic = YES;
//3
ship.physicsBody.affectedByGravity = NO;
//4
ship.physicsBody.mass = 0.02;

Рассмотрим каждую строку кода:

  1. Создает прямоугольное физическое тело, того же размера, что и корабль.
  2. Делает корабль динамическим; это позволяет применять к нему коллизии и другие внешние силы.
  3. Вам не нужно, чтобы корабль упал вниз экрана, поэтому Вы указываете, что он не будет подвержен силе гравитации.
  4. Устанавливаем массу корабля, чтобы его перемещения были природными.

Теперь, замените оператор NSLog в processUserMotionForUpdate: (сразу после комментария //4) следующим кодом:

[ship.physicsBody applyForce:CGVectorMake(40.0 * data.acceleration.x, 0)];

Новый код применяет силу к физическому телу в том же направлении, что и data.acceleration.x. Значение 40.0 примерное значение, чтобы сделать движения корабля натуральными.

В завершение, добавьте следующую строку в начало update:

[self processUserMotionForUpdate:currentTime];

Ваш новый метод processUserMotionForUpdate: теперь вызывается 60 раз в секунду, вместе с обновлением сцены.

Заметка: Если Вы до сих пор тестируете Ваш код в симуляторе - самое время переключиться на реальное устройство. Вы не сможете протестировать кода обрабатывающего наклоны без реального устройства.

Запустите Вашу иру и попробуйте наклонять Ваше устройство вправо и лево; Ваш корабль должен реагировать на изменения данных акселерометра, как здесь:

no_player_ship-2_0.png

Что Вы видите? Ваш корабль вылетел за пределы экрана и потерялся в глубоком мраке космоса. Если Вы сделаете наклон устройства в другую сторону, Возможно Ваш корабль вернется снова. Но в настоящее время, средства управления слишком чувствительные. Так Вы никогда не убьете ни одного захватчика!

Простой и надежный путь предотвратить выход за края экрана во время симуляции физики - это построить edge loop(контуры краев) вокруг краев экрана. Контуры краев - это физически тела, которые не имеют массы, но все же могут взаимодействовать с Вашим кораблем. Думайте о них как о тонких контурах вокруг Вашей сцены.

Так как Ваша GameScene является потомком SKNode , Вы можете дать ей собственное физическое тело для создания контуров.

Добавьте следующий код в createContent сразу после строки [self setupInvaders];:

self.physicsBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame];

Новый код добавит физическое тело к Ваше сцене.

Соберите и запустите Вашу игру еще раз и попробуйте наклонять устройство для перемещения корабля, как показано ниже:

no_player_ship-2_1.png

Что Вы видите? Если Вы наклоните Ваше устройство достаточно сильно в одну из сторон, Ваш корабль столкнется с краем экрана. Он больше не выходит за рамки экрана. Проблема решена!

В зависимости от импульса корабля, Вы можете также увидеть, что корабль отскакивает от краев экрана, вместо того, чтобы остановиться там. Это бонус, который пришел к нам из физического движка Sprite Kit - это свойство называется restitution . Это не только выглядит круто, но это также может быть полезной фишкой, поскольку отталкивает корабль обратно к центру экране, четко указывая пользователю, что край экрана не может быть пересечен.

Что делать дальше?

Здесь пример проекта игры на этот момент.

До этого момента Вы создали захватчиков, Ваш корабль и Heads Up Display (HUD) и отобразили их на экране. Вы также запрограммировали логику, чтобы захватчики могли автоматически перемещаться и чтобы сделать Ваш корабль перемещаемым с помощью наклонов устройства.

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

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

А тем временем, если у Вас есть какие либо вопросы или комментарии, не стесняйтесь присоединиться к обсуждению ниже!

Это авторский перевод статьи. Оригинал находится по адресу http://www.raywenderlich.com/51068/how-to-make-a-game-like-space-invaders-with-sprite-kit-tutorial-part-1