Физический движок. Часть 3

В наших прошлых статьях: «Физический движок: взгляд изнутри. Часть 1» и «Физический движок: взгляд изнутри. Часть 2» — мы рассмотрели теоретическую часть физики и вскользь упомянули основные элементы, которые могут быть использованы для имитации снарядов и выстрелов в играх, похожих на Angry Birds. В этой статье мы закрепим пройденный материал и применим на практике то, о чем мы говорили ранее: детально посмотрим на код простой игры и разберемся что к чему.

Для тех, кому интересно, скажем, что игра написана с использование Sprite Kit API для платформы iOS. Упомянутый API использует возможности портированного на Objective-C физического движка Box2D. Но принцип остается неизменным и то, о чем пойдет речь ниже, можно реализовать на любом другом движке.

Построение игрового мира

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

Общая концепция игры такова:

  1. На каждом уровне есть некоторая башня, которая состоит из различных физических тел.
  2. Внутри каждой башни находится один или несколько объектов-целей.
  3. Рядом с башней находится спусковой механизм — катапульта. Он создает снаряд и придает ему мгновенный импульс. Когда происходит столкновение снаряда с каким-либо объектом, физический движок начинает моделировать физический мир со всеми вытекающими обстоятельствами законами.
  4. В случае, если выпущенный нами снаряд или какой-либо игровой объект касается цели, игрок побеждает. Разумеется, сбить нужно все существующие на экране цели. Обнаружение столкновения происходить опять-таки при помощи физического движка.

Первым делом нам придется создать границу вокруг экрана. Мы же не хотим, чтобы наши объекты провалились в бездну под действием силы тяжести? Следующий код добавляется в инициализацию сцены или в функцию -(void)loadLevel:

//Создадим физические границы по краям экрана
self.physicsBody = [SKPhysicsBody bodyWithEdgeLoopFromRect:self.frame];

Добавление объектов

Давайте посмотрим, как добавлять спрайты на сцену. Да не простые, а золотые с поддержкой физики. Но для начала рассмотрим код добавления объектов, из которых будет состоять наша башня. Объекты могут быть трех типов: квадратные, прямоугольные и треугольные.

-(void)createPlatformStructures:(NSArray*)platforms {
     
    for (NSDictionary *platform in platforms) {
        //Считаем информацию из platforms и инициализируем переменные
        int type = [platform[@"platformType"] intValue];
        CGPoint position = CGPointFromString(platform[@"platformPosition"]);
        SKSpriteNode *platSprite;
        platSprite.zPosition = 10;
        //Создание платформы в зависимости от ее типа
        if (type == 1) {
            //Квадрат
            platSprite = [SKSpriteNode spriteNodeWithImageNamed:@"SquarePlatform"]; //создание спрайта
            platSprite.position = position; //позиция спрайта на сцене
            platSprite.name = @"Square";
            CGRect physicsBodyRect = platSprite.frame; //инициализация прямоугольника, размером с спрайт
            platSprite.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:physicsBodyRect.size]; //инициализация физического тела
            platSprite.physicsBody.categoryBitMask = otherMask; //задаем категорию телу
            platSprite.physicsBody.contactTestBitMask = objectiveMask; //задаем маску для вызова нашей функции при столкновении
            platSprite.physicsBody.usesPreciseCollisionDetection = YES;
             
        } else if (type == 2) {
            //Прямоугольник
            platSprite = [SKSpriteNode spriteNodeWithImageNamed:@"RectanglePlatform"];
            platSprite.position = position;
            platSprite.name = @"Rectangle";
            CGRect physicsBodyRect = platSprite.frame;
            platSprite.physicsBody = [SKPhysicsBody bodyWithRectangleOfSize:physicsBodyRect.size];
            platSprite.physicsBody.categoryBitMask = otherMask;
            platSprite.physicsBody.contactTestBitMask = objectiveMask;
            platSprite.physicsBody.usesPreciseCollisionDetection = YES;
             
        } else if (type == 3) {
            //Треугольник
            platSprite = [SKSpriteNode spriteNodeWithImageNamed:@"TrianglePlatform"];
            platSprite.position = position;
            platSprite.name = @"Triangle";
             
            //Создадим границы для треугольника, используя границы спрайта в качестве ориентира
            CGMutablePathRef physicsPath = CGPathCreateMutable();
            CGPathMoveToPoint(physicsPath, nil, -platSprite.size.width/2, -platSprite.size.height/2);
            CGPathAddLineToPoint(physicsPath, nil, platSprite.size.width/2, -platSprite.size.height/2);
            CGPathAddLineToPoint(physicsPath, nil, 0, platSprite.size.height/2);
            CGPathAddLineToPoint(physicsPath, nil, -platSprite.size.width/2, -platSprite.size.height/2);
             
            platSprite.physicsBody = [SKPhysicsBody bodyWithPolygonFromPath:physicsPath]; 
            platSprite.physicsBody.categoryBitMask = otherMask;
            platSprite.physicsBody.contactTestBitMask = objectiveMask;
            platSprite.physicsBody.usesPreciseCollisionDetection = YES;
            CGPathRelease(physicsPath);
             
        }
         
        [self addChild:platSprite];
         
    }
	
}

Отлично! Давайте немного рассмотрим приведенный код. С квадратом и прямоугольником все ясно: для инициализации физического объекта (16 и 27 строки соответственно) достаточно указать размеры самого спрайта. Но если мы также поступим и в случае с треугольником, то визуально мы получим треугольник, а на деле к нему будет прикреплено физическое тело-прямоугольник (или квадрат, если треугольник равнобедренный). Чтобы избежать такого расклада, мы создаем массив точек, который и будет “обрамлять” треугольник.

Цель игры — звезду — мы создадим аналогичным образом, однако тело у нее будет круглое:

-(void)addObjectives:(NSArray*)objectives {
     
    for (NSDictionary* objective in objectives) {
         
        //Считаем информацию об объекте
        CGPoint position = CGPointFromString(objective[@"objectivePosition"]);
         
        //Создадим спрайт объекта
        SKSpriteNode *objSprite = [SKSpriteNode spriteNodeWithImageNamed:@"star"];
        objSprite.position = position;
        objSprite.name = @"objective";
         
        //Свяжем физическое тело с самим спрайтом
        objSprite.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:objSprite.size.width/2];
        objSprite.physicsBody.categoryBitMask = objectiveMask;
        objSprite.physicsBody.contactTestBitMask = otherMask;
        objSprite.physicsBody.usesPreciseCollisionDetection = YES;
        objSprite.physicsBody.affectedByGravity = NO;
        objSprite.physicsBody.allowsRotation = NO;
         
        //Добавим спрайт на сцену
        [self addChild:objSprite];
         
        //Добавим вращение спрайту для красоты
        SKAction *turn = [SKAction rotateByAngle:1 duration:1];
        SKAction *repeat = [SKAction repeatActionForever:turn];
        [objSprite runAction:repeat];
    }
     
}

Готовьсь. Цельсь. Пли!

Сам пусковой механизм — катапульта — не нуждается в каком-либо физическом теле. Мы никак не собираемся обрабатывать столкновения с ним. Он используется только в качестве отправной точки для снаряда.

А вот, собственно, и сам метод создания снаряда:

-(void) addProjectile {
    //Создадим спрайт снаряда
    projectile = [SKSpriteNode spriteNodeWithImageNamed:@"ball"];
    projectile.position = cannon.position;
    projectile.zPosition = 20;
    projectile.name = @"Projectile";
     
    //Свяжем спрайт с физическим телом
    projectile.physicsBody = [SKPhysicsBody bodyWithCircleOfRadius:projectile.size.width/2];
     
    //Придадим физическому объекту несколько свойств
    projectile.physicsBody.restitution = 0.5;
    projectile.physicsBody.density = 5;
    projectile.physicsBody.friction = 1;
    projectile.physicsBody.dynamic = YES;
    projectile.physicsBody.allowsRotation = YES;
    projectile.physicsBody.categoryBitMask = otherMask;
    projectile.physicsBody.contactTestBitMask = objectiveMask;
    projectile.physicsBody.usesPreciseCollisionDetection = YES;
     
    //Добавим снаряд на сцену
    [self addChild:projectile];
     
}

Здесь мы видим чуточку больше свойств, чем раньше. Когда мы закончим игру, попробуйте поменять значения restitution, friction, и density и посмотрите на то, как это скажется на геймплее. (Вы можете посмотреть нашу прошлую статью, в которой вкратце говорится об используемых здесь свойствах.)

Далее нам нужно создавать снаряды при касании экрана. Давайте посмотрим, как это делается:

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    /* Вызывается в начале тапа по экрану */
     
    for (UITouch *touch in touches) {
         
        CGPoint location = [touch locationInNode:self];
        NSLog(@"Touched x:%f, y:%f", location.x, location.y);
         
        //Проверяется, нет ли уже снаряда на сцене
        if (!isThereAProjectile) {
             
            //Если нет, то добавляем новый
            isThereAProjectile = YES;
            [self addProjectile];
             
            //Создаем вектор для придания импульса
            projectileForce = CGVectorMake(18, 18);
         
         
            for (SKSpriteNode *node in self.children){
             
                if ([node.name isEqualToString:@"Projectile"]) {
                     
                    //Применяем импульс к выпущенному нами снаряду
                    [node.physicsBody applyImpulse:projectileForce];
                }
             
            }
        }
    
         
    }
}

Теперь у нас есть башня, состоящая из различных блоков, и цель (звезда). Но как узнать, что мы действительно попали в звезду?

Обработка столкновений

Для начала, парочка определений:

  • Контакт (contact) используется, когда два тела коснулись друг друга.
  • Коллизия (collision) используется, когда два тела пересеклись.

Слушатель контактов

До сих пор наш физический движок сам решал, что делать, если два тела пересекаются. Но как поступить, если мы сами хотим определять поведение тел после столкновения? Для начала мы должны рассказать нашей игре, что мы хотим “слушать” такой контакт. Для этого используем делегат.

Добавим следующий код в начало нашего файла:

@interface MyScene ()<SKPhysicsContactDelegate>
 
@end

… и скажем игре, что слушателем является текущий класс:

self.physicsWorld.contactDelegate = self

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

-(void)didBeginContact:(SKPhysicsContact *)contact
{
    //тело функции
}

Но прежде чем мы начнем писать тело функции — пару слов о категориях.

Категории

Для того, чтобы сортировать по группам физические тела, используют категории. Sprite Kit, в частности, использует битовые категории. Это означает, что мы ограничены 32 категориями в момент контакта. Мы предпочитаем объявлять категории таким образом:

static const  uint32_t objectiveMask = 1 << 0; 
static const  uint32_t otherMask = 1 << 1;

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

Зададим категории, используя следующие свойства:

platSprite.physicsBody.categoryBitMask = otherMask; //свяжем маску категории с физическим телом
platSprite.physicsBody.contactTestBitMask = objectiveMask; //зададим маску для вызова нашей функции при столкновении

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

-(void)didBeginContact:(SKPhysicsContact *)contact
{
    
	uint32_t collision = (contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask); //define a collision between two category masks
    if (collision == (otherMask| objectiveMask)) {
        //Проверим, какие тела столкнулись
        if (!isGameReseting) {
             
            NSLog(@"You Win!");
            isGameReseting = YES;
             
            //Воспроизведем некоторую анимацию
            SKAction *scaleUp = [SKAction scaleTo:1.25 duration:0.5];
            SKAction *tint = [SKAction colorizeWithColor:[UIColor redColor] colorBlendFactor:1 duration:0.5];
            SKAction *blowUp = [SKAction group:@[scaleUp, tint]];
            SKAction *scaleDown = [SKAction scaleTo:0.2 duration:0.75];
            SKAction *fadeOut = [SKAction fadeAlphaTo:0 duration:0.75];
            SKAction *blowDown = [SKAction group:@[scaleDown, fadeOut]];
            SKAction *remove = [SKAction removeFromParent];
            SKAction *sequence = [SKAction sequence:@[blowUp, blowDown, remove]];
             
            //Выясним, какое тело является целью (A или B), а затем запустим анимацию
            if ([contact.bodyA.node.name isEqualToString:@"objective"]) {
                     
                [contact.bodyA.node runAction:sequence];
                     
            } else if ([contact.bodyB.node.name isEqualToString:@"objective"]) {
                 
                [contact.bodyB.node runAction:sequence];
                 
            }
             
            //Перезагрузим уровень по истечении 3 секунд
            [self performSelector:@selector(gameOver) withObject:nil afterDelay:3.0f];
        }
 
    }
}

Вывод

Итак, мы узнали об основах работы 2D движка, а теперь еще и применили наши знания на практике.

Рабочий проект доступен в этом репозитории в GitHub. Исходный код прокомментирован вплоть до мелочей, а потому разобраться с ним не составит труда.

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

Перевод статьи «Projectile Physics Engines: Building a Game World»