SpriteKit 기본튜토리얼

기본적인 Sprite Kit 기반 애니메이션 앱을 만들어보면서 스프라이트 킷의 대략의 흐름을 살펴보자.

Sprite Kit 구성요소

SpriteKit의 주요 플레이어는 다음의 세 종류가 있다.

  1. SKView : Sprite Kit의 기본 뷰이다. 이 뷰는 각 장면 컨텐츠를 렌더링하여 표시하는 일을 한다. 이는 UIView를 서브클래싱하여 만들어진다.

  2. SKScene : 뷰는 장면들을 전환하여 보여줄 수 있다. SKScene은 각 장면에 해당하는 클래스이다. 장면은 화면에 등장하는 컨텐츠 구성요소인 Node들을 관리하게 된다. 또한 사용자 터치 이벤트를 이곳에서도 처리할 수 있다.

  3. SKNode : 장면 내의 배경, 캐릭터, UI요소 등은 모두 SKNode로 표현된다. SKNode는 노드트리를 구성하며, 각각의 노드는 액션(SKAction)을 실행하여 애니메이팅 된다고 보면 된다.

그리고 장면 내 각 노드들이 물리적인 상호작용 (충돌, 중력의 적용, 힘을 받아 움직이거나 회전)을 하는 경우라면 노드의 physicsBody 속성을 정의하고, 그 속성들을 조정할 수 있다.

프로젝트 시작하기

Xcode에서 “게임” 템플릿으로 시작해도 되지만, 그냥 싱글뷰 애플리케이션 템플릿으로 시작한다. 이 템플릿은 스프라이트 킷을 포함하지 않기 때문에 프로젝트 속성에서 라이브러리에 SpriteKit.framework를 검색해서 추가해주어야 한다.

그리고 스토리보드에서는 뷰 컨트롤러의 뷰 클래스가 UIView로 되어 있는데, 이를 SKView로 타입을 변경한다. 그리고 그 외 나머지 모든 것들은 코드를 통해서 구현할 것이다.

뷰 컨트롤러

뷰 컨트롤러에서는 뷰 로딩 후 첫씬을 추가하는 일만 하면 된다. 이는 뷰 컨트롤러의 -viewDidLoad를 오버라이드한다.

/*
 * from: MainViewController.m
 * 스토리보드상에서 뷰컨트롤러의 `view' 속성은 SKView 클래스로 지정되어 있어야함
 * 
 * 관련 내용:
 *  -viewDidLoad -> 뷰 로딩이 되면, 초기 씬 객체를 생성하고 설치한다.
 */
// 씬을 만들기 위해서 해당 클래스의 헤더 추가
#import <Scene0.h>
// 구현부 시작
@implementation MainViewController
//
- (void)viewDidLoad {
    // 뷰가 로딩되고 나면, Scene을 뷰에 추가해서 화면에 컨텐츠를 표시함. 
    // SKView는 Scene을 호스팅해주는 역할이라 생각하면 됨
    //
    Scene0 *s0 = [[Scene0 alloc] initWithSize:self.view.frame.size];
    [s0 setBackgroundColor:[UIColor blackColor]];
    [s0 setScaleMode:SKSceneScaleModeAspectFill];
    //
    // 뷰에 씬을 추가함
    SKView *spriteView = (SKView *)self.view;
    // !!! 이 코드가 잘 이해가 안되는데, 뷰를 UIView로 만든 경우에 이런 형태로
    // 강제 캐스팅은 실패한다.... 
    //
    // 아래 코드는 뷰가 씬을 인스톨한다. 이 때 씬의 .view 속성은 뷰 자신이 된다.
    [spriteView presentScene: s0];
}

실제 코드는 단 네줄이다. 씬을 만들고, 장면의 배경색과 크기 맞춤 속성을 지정한다음, 뷰에 장면을 인스톨한다.

첫번째 장면

첫번째 장면을 만들어보자. 일종의 인트로 화면이다.

이 글에서 작성하고 있는 클래스들은 모두 좋은 디자인이 아니다. 단지 파일이 너무 많이 쪼개지면 설명이 복잡해질 것 같이서 이렇게 하는 것 뿐이다.

Scene0이라는 클래스를 만들겠다. 인터페이스 부분은 생략하고 구현부 중심으로 설명한다.

우선 확장문법을 사용해서 내부 인터페이스를 정의했다.

#import "Scene0.h"
// Scene0.h 파일은
// #import <SpriteKit/SpriteKit.h>
// @interface Scene0 : SKScene
// @end
// 만 있고 내용이 필요없다. 
//
@interface Scene0 () 
@property BOOL contentCreated;
@property (strong, nonatomic) SKLabelNode *title;
- (void)createContent;
@end

장면 초기화

SKScene 객체가 뷰에 인스톨되면 -didMoveToView: 메소드가 호출된다. 이 메소드에서 장면의 컨텐츠를 초기화하면 된다. 초기화 작업은 단 한번만 이루어지므로, 내부 프로퍼티인 contentCreated 값을 확인하여, 컨텐츠가 생성되지 않았을 시에만 컨텐츠 생성작업을 진행하도록 한다.

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

컨텐츠 생성

첫 화면의 컨텐츠 생성은 간단히 타이틀 문구를 화면 중앙에 표시해주는 것만 해보도록 한다. 타이틀 문구노드 (장면의 각 컨텐츠는 노드라고 했다)는 현재 장면객체의 프로퍼티로 설정했으므로 이에 대한 접근자를 구성한다.

- (SKLabelNode *)title {
    if(!_title) {
        _title = [SKLabelNode labelNodeWithFontName:@"Helvetica"];
        [_title setFontColor:[UIColor white]];
        [_title setFontSize:43];
        [_title setText:@"Hello World"];
    }
    return _title;
}

컨텐츠를 장면에 배치하는 일은 -createContent에서 하는데, 다음과 같이 위치를 지정해주고, 노드를 장면의 child로 가져다 붙이면 된다.

- (void) createContent {
    [self.title setPosition:CGPointMake(CGRectGetMidX(self.view.frame), CGRectGetMidY(self.view.frame))];
    [self addChild:self.title];
}

여기까지 하고 앱을 빌드해보면, 파란 화면 한 가운데 하얀 글씨로 “Hello World!”가 표시되는 것을 볼 수 있다. 이제 두 번째 장면을 만들고, 장면을 전환해보자.

두 번째 장면

두 번째 장면을 위한 SKScene 클래스를 추가로 작성한다. 첫번째 장면과 같이 단순히 구현부 파일에서 내부 인터페이스만 추가적으로 정의하겠다.

// from Scene1.m 
@interface Scene1 ()
@property BOOL contentCreated; 
- (void)createContent;
- (SKSpriteNode *) newSpaceShip;
- (SKSpriteNode *) newFlash;
- (void) addRock;
@end

장면 초기화를 위해서는 Scene0과 비슷한 방법을 사용할 것이다. 화면에는 우주선 (그냥 사각형)이 하나 들어갈텐데, 이 우주선에는 노란 불빛이 두 개 달리는 형태이다. 따라서 총 노드는 3개 (우주선본체1 , 불빛2)의 노드가 추가된다.

일단 Scene1-didMoveToView:Scene0과 완전히 동일하다.

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

컨텐츠 추가와 관련하여 장면에 들어가는 3개의 노드는 우주선 노드가 두 개의 부빛 노드를 자식 노드로 갖는 형태가 되어야 한다. (그래서 우주선이 움직이면 불빛도 따라 움직임) 즉,

# Scene1 |--- space ship -|
                          |--flash1
                          |--flash2

이렇게 구성된다. 따라서

  1. contentCreated에서는 spaceShip을 생성하여 추가
  2. spaceship이 생성되는 -newSpaceShip에서는 -newFlash를 호출하여 각각 불빛 노드를 spaceShip에 추가

하는 과정을 거친다.

먼저 불빛노드를 추가해보도록 하자.

스프라이트 노드 추가

이 예제에서 등장하는 모든 스프라이트 노드들은 그냥 단색 사각형들이다. 따라서 -initWithColor:size:를 사용하여 노드 객체를 생성할 것이다.

해당 메소드는 아주 간단하다.

- (SKSpriteNode *)newFlash {
    SKSpriteNode *flash = [[SKSpriteNode alloc] 
                initWithColor:[UIColor yellowColor] size:CGPointMake(8, 8)];
    [flash runAction: blink];
    return flash;
}

액션을 추가하여 애니메이션 하기

불빛을 깜빡이게 하는 동작을 추가해보겠다. 장면에 등장하는 모든 노드들은 개별적인 액션을 통해서 애니메이션을 추가할 수 있다. 이러한 동작들은 기본적으로 SKAction 클래스에 정의되어 있다. 이동하거나, 회전하거나, 크기가 변경되고, 색상, 투명도 변경과 같은 것들이 애니메이션 가능하다.

예를 들어 투명도가 작아지면서 사라지는 액션은 +fadeOutWithDuration:으로 생성할 수 있고, 이런 액션 객체를 만든 다음에는 SpriteNode의 -runAction: 메시지를 통해서 시실행할 수 있다. (그리고 이 액션이 실행되는 과정의 애니메이션을 스프라이트 킷이 자동으로 처리한다.)

SKSpriteNode *someNode = /* ... */;
SKAction *fadeOut = [SKAction fadeOutWithDuration:0.5]; // 0.5초간 희미해짐
[someNode runAction:fadeOut]; // 액션 실행 

compound action

하지만 불빛이 깜빡이는 액션을 생각해보자. “깜빡인다”라는 액션은 미리 정의되지 않았기 때문에 구현해야 하는데, SKAtction을 서브클래싱하기보다는 사용 가능한 기본액션들을 조합해서 새로운 액션으로 만들 수 있다. 불빛이 깜빡이는 과정을 생각해보자.

  1. 먼저 짧은 시간동안 fadeOut된다.
  2. 그리고 다시 fade-in 되어 다시 불이 켜진다.
  3. 다음 점멸 주기까지 잠깐 동안 기다린다.
  4. 1~3의 과정을 계속 반복한다.

여러 액션을 순차적으로 연결하여 움직이는 것은 +sequence: 메소드를 통해서 각 단위 액션들의 배열을 넘기는 것으로 가능하며, 동일한 액션을 무한 반복하는 것은 +repeateActionForever:를 통해서 무한 반복 버전의 액션을 만들 수 있다. 따라서 깜빡임이 추가된 flash 노드를 만들기 위해 아래와 같이 액션을 추가하고 실행해주도록 -newFlash 메소드를 수정한다.

각 액션이 순차적으로 실행되지 않고 동시에 실행하는 것 (빙글빙글돌면서 날아가거나..)은 +group: 메소드를 사용하면 된다.

- (SKSpriteNode *)newFlash {
    SKSpriteNode *flash = [[SKSpriteNode alloc] initWithColor:[UIColor yellowColor] size:CGPointMake(8, 8)];
    /*******************************************
     * 액션 추가 --> */
    SKAction *fadeOut = [SKAction fadeOutWithDuration:0.2];
    SKAction *fadeIn = [SKAction fadeInWithDuration:0.2];
    SKAction *wait = [SKAction waitForDuration:0.6];
    SKAction *blinkOnce = [SKAction sequence:@[fadeOut, fadeIn, wait]];
    SKAction *blinkForever = [SKAction repeatActionForever:blinkOnce];
    /* 추가 끝
    ********************************************/
    [flash runAction:blinkForever];
    return flash;
}

compound sprite node

이번에는 우주선 노드를 만들어 보겠다. 불빛과 같은 방식으로 사각형 노드를 만들고, 불빛 노드를 자식 노드로 추가해주면 된다.

- (SKSpriteNode *)newSpaceShip {
    SKSpriteNode *s = [[SKSpriteNode alloc] 
            initWithColor:[UIColor grayColor] size:CGSizeMake(80, 20)];
    SKSpriteNode *f1, *f2;
    f1 = [self newFlash];
    f2 = [self newFlash];
    f1.position = CGPointMake(-12, 6);
    f2.position = CGPointMake(12, 6);
    [s addChild:f1];
    [s addChild:f2];
    return s;
}

회색의 사각형으로 몸체를 만들고, 불빛 노드 두개를 생성하여 추가했다. 그럼 최종적으로 -createContent 메소드를 만들어보자. 사전 준비가 거의 된 상황이므로, 우주선 노드를 적당한 위치에 올려놓으면 된다.

- (void)createContent {
    SKSpriteNode *spaceShip = [self newSpaceShip];
    [spaceShip setName:@"spaceship"];
    [spaceShip setPosition:CGPointMake(CGRectGetMidX(self.view.frame), self.size.height.y - 150);
    [self addChild:spaceShip];
}

여기서 주의할 것 하나, iOS의 CGGraphic에서는 화면 왼쪽 상단을 원점으로 하는 좌표계를 사용하지만, SpriteKit에서는 화면 왼쪽 하단을 원점으로 하는 좌표계를 사용한다.

그리고 각 노드의 포지션은 주로 중앙 (무게중심)의 위치를 지정하는 것으로 간주한다. 따라서 화면 위쪽의 가운데 정도에 배치하기 위해서는 y 좌표를 **scene의 크기(뷰의 크기와 같다.)중 높이값 보다 150이 적으므로, 화면 위쪽에서 150pt만큼 내려온 위치에 표시될 것이다.

장면 전환

SpriteKit에서는 장면 전환 시 뷰 트랜지션을 사용하지 않아도 된다. SKView가 장면을 호스팅하는 역할을 담당한다고 했는데, 장면의 전환을 뷰가 담당하게 된다. 장면의 전환시에 트랜지션 효과를 사용하고 싶다면 SKTransition 객체를 만들어서 (역시 미리 정의된 종류의 트랜지션 객체들이 있다)이를 적용하면 된다.

다시 앱의 첫 장면으로 돌아가서, 화면을 터치하면 장면이 전환되는데, 이 때 화면이 뒤집히는 효과를 내보도록 하겠다. 화면의 터치 처리는 Scene0에서 할 것이다. SKScene은 UIResponder 클래스의 후손으로 터치 이벤트를 직접처리할 수 있기 때문이다.

// Scene0.m
#import "Scene0.h"
/* Scene1.h 를 추가 */
#import "Scene1.h"

이제 장면이 터치를 받으면 변전환되도록 한다. 전환되기 이전에 타이틀 텍스트에 애니메이션을 주고, 애니메이션이 완료되면 장면 전환을 하도록 하겠다.

애니메이션을 주는 것은 SKAction 객체를 만들어서 실행하면 된다. 액션 실행의 또 다른 메소드인 -runAction:completion:은 액션 실행 완료 후 실행될 코드 블럭을 파라미터로 받으므로, 해당 블럭 내에서 뷰가 정면을 전환하도록 한다.

이 때 SKView 객체는 장면의 view 프로퍼티로 참조할 수 있다.

- (void)touchBegan:(UITouch*)touch withEvent:(UIEvent*)event {
    SKAction *moveUp = [SKAction moveByX:0.0 y:100.0 duration:0.5];
    SKAction *zoom = [SKAction scaleTo:2.0 duration:0.5];
    SKAction *pause = [SKAction waitForDuration:0.5];
    SKAction *fadeAway = [SKAction fadeOutWithDuration:0.25];
    SKAction *remove = [SKAction removeFromParent];
    // 이상의 액션을 연쇄적으로 사용
    SKAction *seq = [SKAction sequence:@[moveUp, zoom, pause, fadeAway, remove]];
    // 타이틀이 해당 액션을 수행한다.
    // [self.title runAction:seq];
    // 액션이 수행된 뒤 장면이 전환되도록 함
    //[self.title runAction:seq completion:^{
        //Scene1 *s1 = [[Scene1 alloc] initWithSize:self.size];
        //// 장면 전환을 위한 트랜지션 객체
        //// 화면이 아래위로 갈라지면서 새 장면이 등장한다.
        //SKTransition *doors = [SKTransition doorsOpenVerticalWithDuration:1.0];
        //[self.view presentScene:s1 transition:doors];
    //}];

compound sprite node의 애니메이션

이번엔 다시 두 번째 장면으로 돌아와서 우주선이 좌/우로 계속해서 움직이도록 설정해보자. 한 가운데서 출발하므로,

  1. 왼쪽으로 100만큼 이동,
  2. 1초 대기
  3. 오른쪽으로 100만큼 이동 (원위치)
  4. 오른쪽으로 100만큼 이동
  5. 1초 대기
  6. 왼쪽으로 100만큼 이동
  7. 1~6을 무한반복

하면 된다.

// from Scene1.m
- (SKSpriteNode *)newSpaceShip {
    SKSpriteNode *s = [[SKSpriteNode alloc] initWithColor:[UIColor grayColor] size:CGSizeMake(80, 20)];
    SKSpriteNode *f1, *f2;
    f1 = [self newFlash];
    f2 = [self newFlash];
    f1.position = CGPointMake(-12, 6);
    f2.position = CGPointMake(12, 6);
    [s addChild:f1];
    [s addChild:f2];
    /****************************************/
    * 우주선의 움직임 추가
    SKAction *left = [SKAction moveByX:-100 y:0 duration:1.0];
    SKAction *right = [SKAction moveByX:100 y:0 duration:1.0];
    SKAction *wait = [SKAction waitForDuration:1.0];
    SKAction *slide = [SKAction: repeatActionForever:
                        [SKAction sequence:@[left, wait, right, right, wait, left]
                        ]];
    [s runAction:slide];
    /*****************************************/
    return s;
}

이제 앱을 빌드하고 실행해보면 장면이 전환 가능하고, 두 번째 장면에서는 불빛을 반짝이면 사각형이 좌우로 이동하는 것을 볼 수 있다.

SKPhysicsBody를 사용한 물리 시뮬레이션

장면의 개별 노드들은 물리 효과를 적용할 수 있다. 따라서 별도의 계산없이 중력의 영향을 받아 아래로 떨어지거나, 특정한 방향으로 힘을 받거나, 회전력을 갖거나, 두 물체가 충돌하는 등의 효과를 간단히 구현할 수 있다.

이는 가 노드의 physicsBody 라는 속성이 계산을 담당해주는데 (일종의 델리게이트임) 이 역할을 하는 물체(SKPhysicsBody)를 생성해서 부여해주면 된다. 이 객체는 특별히 물리적인 힘의 계산 (가속도, 각가속도, 중력)하여 그 효과를 만들어내고 이와 관련된 질량(크기와 밀도), 마찰계수 등의 속성을 가질 수 있다.

이 예제에서 등장하는 모든 노드들은 사각형으로 생겼으니, +bodyWithRectangleOfSize: 를 통해서 손쉽게 생성할 수 있다. 그럼 두 번째 장면의 addRock 메소드를 구현해보자.

이 메소드에서는 화면 맨 위에 바위에 해당하는 노드를 만들어서 이 것이 아래로 떨어지게 할 것이다. 노드의 X좌표 위치를 임의로 생성해내기 위해서 간단한 C함수를 하나 작성하겠다.

static inline CGFloat skRand(CGFloat min, CGFloat max) {
    return (CGFloat)(((arc4random() / (CGFloat)RAND_MAX * (max - min)))+min);
}

그리고 바위를 추가하는 메소드는 다음과 같이 구현했다.

- (void)addRock {
    SKSpriteNode *rock = [[SKSpriteNode alloc] initWithColor:[UIColor brownColor] size:CGSizeMake(20, 20)];
    rock.position = CGPointMake(skRand(), self.size.height - 10);
    [rock setPhysicsBody:[SKPhysicsBody bodyWithRectangleOfSize:rock.size]];
    [rock.physicsBody setUsePreciseCollisionDetection:YES];
    [self addChild:rock];
}

임의의 가로 위치에 바위를 추가한다. 바위는 physicsBody 속성을 부여받고, 이 physicsBody 속성은 충돌 시뮬레이션을 적용한다. physicsBody 속성을 부여받는 노드들은 화면상에 등장하면 자동으로 중력의 영향을 받아 아래로 떨어진다!

보다 정교한 충돌 시뮬레이션을 위해서 -setUsePreciseCollisionDetection:을 YES로 준다. (사실 이 예에서는 빼도 적절한 수준으로 잘 동작한다)

화면에 바위를 추가하는 이벤트를 어떻게 발생시킬까? 바위는 계속 떨어지는 게 좋으니, 특정 주기로 계속 addRock이 호출되면 좋겠다. 이 시점에서 NSTimer의 사용을 고려해볼 수 있겠지만, SpriteKit이 제공하는 액션 중에는 +performSelector:onTarget:이 있고, 장면은 최상위 노드이므로 장면에도 액션을 추가할 수 있다. 따라서 -didMoveToView: 메소드에 이 내용을 추가해준다.

// from Scene1.m 
    - (void)didMoveToView:(SKView *)view {
        if (!self.contentCreated) {
            self.contentCreated = YES;
            [self createContent];
/*****************************************
* 바위 추가 액션 **/
            [self runAction:[SKAction repeateActionForever:
                [SKAction sequence:@[
                    [SKAction waitForDuration:0.5], 
                    [SKAction performSelector:@selector(addRock) onTarget:self]
                    ]]]];
/*****************************************/
        }
    }

이제 앱을 빌드하고 실행해보면 바위는 우수수 떨어지는데, 우주선과 부딪히지는 않는다. 그도 그럴 것이 우주선과 부딪치려면 우주선에도 physicsBody 속성이 부여되어 있어야 하는 것이다. 그래서 -newSpaceShip 메소드를 수정하여, 우주선에도 이를 설치해준다.

방법은 같은데 주의할 것이 있다.

  1. 우주선은 중력의 영향을 받아 아래로 같이 떨어지지 말아야 한다.
  2. SpriteKit의 충돌계산은 생각보다 아주 우수한 편이라, 바위와 충돌한 우주선은 아래쪽으로 튕겨나가게 된다. 따라서 우주선은 충돌에 의해 받는 힘을 무시하도록 한다.

이 두가지를 고려하여, 수정한 코드는 다음과 같다.

- (SKSpriteNode *)newSpaceShip {
    SKSpriteNode *s = [[SKSpriteNode alloc] initWithColor:[UIColor grayColor] size:CGSizeMake(80, 20)];
    SKSpriteNode *f1, *f2;
    f1 = [self newFlash];
    f2 = [self newFlash];
    f1.position = CGPointMake(-12, 6);
    f2.position = CGPointMake(12, 6);
    [s addChild:f1];
    [s addChild:f2];
    /****************************************************************
    * 바위와 충돌할 수 있도록 함 */
    [s setPhysicsBody:[SKPhysicsBody bodyWithRectangleOfSize:s.size]];
    [s.physicsBody setAffectedByGravity:NO]; //중력의 영향을 받지 않음.
    [s.physicsBody setDynamic:NO]; // 충돌 후 운동 상태가 변화하지 않음.
    /*****************************************************************/
    SKAction *hover = [SKAction sequence:@[
                        [SKAction waitForDuration:1.0],
                        [SKAction moveByX:100 y:0 duration:1.0],
                        [SKAction waitForDuration:1.0],
                        [SKAction moveByX:-100 y:0 duration:1.0]
                    ];
    [s runAction:[SKAction repeatActionForever:hover];
    return s;
}

physicsBody의 여러 속성들을 변경해보면서 테스트해보면 재밌는 것들을 구현해볼 수 있을 것이다.

참고

스프라이트 킷은 꽤나 상세하고 정밀한 물리 시뮬레이션을 지원한다. 만약 연료를 실은 로켓이 움직이는 과정을 시뮬레이트한다고 생각해보자.

  • 로켓의 속도는 로켓이 가속하는 힘과 중력에 의해 가속된다.
  • 로켓의 속도는 로켓 자체의 질량에 영향을 받는다. (질량이 크면 적게 가속되므로)
  • 로켓의 질량은 로켓자체의 질량 + 연료의 질량인데, 연료의 질량은 연료를 소모하면서 줄어들게 된다.
  • 따라서 이 연료 무게에 의한 효과를 시뮬레이트하려면
    • 로켓 클래스(로켓물체클래스)를 만들면서
    • mass 프로퍼티를 오버라이딩해서 연료 사용량에 따라 점차 감소하게 한다.