본문 바로가기

SpriteKit

SpriteKit 시작해보기

 

Sprite kit을 이용해 단순한 게임을 만들어 보자

일단 기본적으로 알아야 하는 사항은 다음과 같다.

뷰 계층 구조

  • ViewController
    • 가장 상위 레벨의 컨트롤러
    • 화면에 표시되는 SKView를 포함한다.
  • SKView
    • SpriteKit의 전용 뷰 객체
    • SkScene을 표시
  • SKScene
    • SKView에 표시되는 장면 객체
    • 게임 로직과 게임 오브젝트를 포함한다.
  • SKSpriteNode
    • SKScene에 포함된 개별 오브젝트 노드
    • 이미지, 물리 엔진, 애니메이션을 적용하는 게 가능하다.

 

SpriteNode

https://developer.apple.com/documentation/spritekit/skspritenode

  • 이미지나 단색으로 초기화가 가능한 화면상의 그래픽 요소다.
  • 위치를 잡아주고 scene에 addChild를 통해 scene위에 보여주는 것이 가능하다.
    • view의 addSubView와 비슷하다고 생각하면 된다.
  • removeFromParent를 통해 제거가 가능하다.

 

SKAction

SKAction | Apple Developer Documentation

  • Scene의 노드에서 실행되는 애니메이션
  • 액션을 통해서 노드를 처리하거나 변경한다고 생각하면 된다.

 

DIdMove(to view: SkView)

didMove(to:) | Apple Developer Documentation

  • scene이 처음 로드 되거나 다른 장면으로 전환될 때 호출되는 메서드
  • 내부 구현을 통해 scene의 콘텐츠를 만들 수 있다

 

SKPhysicsContactDelegate

SKPhysicsContactDelegate | Apple Developer Documentation

  • 물체끼리 접촉을 할때 반응을 할 수 있도록 한다.

 

 

 

간단한 게임 만들어 보기

SpriteKit Tutorial for Beginners

  • 코데코의 튜토리얼을 통해 진행한다.

일단 첫 번째로 GameScene이라는 SKScene파일을 만들고 ViewController 에서 해당 GameScene을 불러온다.

import UIKit
import SpriteKit

final class GameViewController: UIViewController {
  
  override func viewDidLoad() {
    super.viewDidLoad()
    
    let scene = GameScene(size: view.bounds.size)
    let skView = view as! SKView
    skView.showsFPS = true
    skView.showsNodeCount = true
    skView.ignoresSiblingOrder = true
    scene.scaleMode = .resizeFill
    skView.presentScene(scene)

  }
}
  • showFPS는 현재 현재 프레임을 보여준다
  • showNodeCount는 SpriteNode의 숫자
  • ignoresSiblingOrder
    • https://developer.apple.com/documentation/spritekit/skview/1520215-ignoressiblingorder
    • 기본적으로 Sprite Kit은 노드의 추가 순서에 따라 렌더링 순서가 결정된다.
    • 나중에 추가된 노드가 전에 추가된 노드를 가리게 된다.
    • 하지만 ignoresSiblingOrder를 true로 변경하면 노드의 추가 순서와 상관없이 zPostion의 값에 따라 렌더링 순서가 결정
    • 순서가 무시되면 SpriteKit은 렌더링 성능을 향상시키기 위해 추가 최적화를 적용한다.

 

GameScene부분

final class GameScene: SKScene {
  
  let player = SKSpriteNode(imageNamed: "player")
  var monstersDestroyed = 0
  
  override func didMove(to view: SKView) {
    super.didMove(to: view)
    
    // BGM
    let backgroundMusic = SKAudioNode(fileNamed: "background-music-aac.caf")
    backgroundMusic.autoplayLooped = true
    addChild(backgroundMusic)
    
    // 중력은 없지만 물체끼리 접촉에 대한 반응은 한다
    physicsWorld.gravity = .zero
    physicsWorld.contactDelegate = self
    
    backgroundColor = SKColor.white
    player.position = CGPoint(x: size.width * 0.1,
                              y: size.height * 0.5)
    
    addChild(player)
    
    run(SKAction.repeatForever(
      SKAction.sequence([
        SKAction.run(addMonster),
        SKAction.wait(forDuration: 1.0)
      ])
    ))
  }
  
}
  • scene이 전환되면서 addMonster라는 Action을 1초 간격으로 계속 실행을 하게 된다.

 

 

  • addMonster를 살펴보자
extension GameScene {
  
  func random() -> CGFloat {
    return CGFloat.random(in: 0...4294967296) / 4294967296
  }
  
  func random(min: CGFloat, max: CGFloat) -> CGFloat {
    return random() * (max - min) + min
  }
  
  func addMonster() {
    
    // spriteNode 생성
    let monster = SKSpriteNode(imageNamed: "monster")
   
    // 어디에 생성 될 것인지 y포지션을 결정
    let actualY = random(min: monster.size.height/2, max: size.height - monster.size.height/2)
    
    // 화면 밖에 몬스터 생성을 해준다.
    monster.position = CGPoint(x: size.width + monster.size.width/2, y: actualY)
    
    // 몬스터의 물리값 부여
    // 1.히트 박스 부여
    monster.physicsBody = SKPhysicsBody(rectangleOf: monster.size)
    //2. 물리엔진이 제어를 하는것이 아닌 액션으로 제어가 된다.
    monster.physicsBody?.isDynamic = true
    //3. 몬스터의 카테고리
    monster.physicsBody?.categoryBitMask = PhysicsCategory.monster
    //4. 어떤 카테고리랑 만났을때 반응을 하는 것인가
    monster.physicsBody?.contactTestBitMask = PhysicsCategory.projectile
    //5. 충돌 비트 마스크로 물리 엔진이 접촉 처리하는 것
    // 벽에 닿으면 팅기는 것처럼
    monster.physicsBody?.collisionBitMask = PhysicsCategory.none
    
    // scene에 추가하는 작업
    addChild(monster)
    
    // 몬스터의 속도 계산
    let actualDuration = random(min: CGFloat(2.0), max: CGFloat(4.0))
    
    // 몬스터의 움직임
    let actionMove = SKAction.move(to: CGPoint(x: -monster.size.width/2,
                                               y: actualY),
                                   duration: TimeInterval(actualDuration))
    
    let actionMoveDone = SKAction.removeFromParent()
    
    let loseAction = SKAction.run() { [weak self] in
      guard let self = self else { return }
      let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
      let gameOverScene = GameOverScene(size: self.size, won: false)
      self.view?.presentScene(gameOverScene, transition: reveal)
    }
    
    monster.run(SKAction.sequence([actionMove, loseAction, actionMoveDone]))
    
  }
  
}
  • 여기서 주의 깊게 볼것은 몬스터하는 Node를 추가한 뒤 필요 없어지는 경우 제거를 해주는 것이다.

 

 

  • 터치이벤트와 관련된 로직
extension GameScene {
  
  override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
    // 1. 터치 이벤트를 받아오고 터치의 위치를 추적한다.
    guard let touch = touches.first else {
      return
    }
    
    let touchLocation = touch.location(in: self)
    
    //수리검 날릴때 사운드
    run(SKAction.playSoundFileNamed("pew-pew-lei.caf", waitForCompletion: false))
    
    // 2. 수리검을 플레이어의 위치에 생성을 해준다.
    let projectile = SKSpriteNode(imageNamed: "projectile")
    projectile.position = player.position
    
    // 카테고리 비트 마스크를 이용해서 물체에 대한 인식을 해준다.
    projectile.physicsBody = SKPhysicsBody(circleOfRadius: projectile.size.width/2)
    projectile.physicsBody?.isDynamic = true
    projectile.physicsBody?.categoryBitMask = PhysicsCategory.projectile
    projectile.physicsBody?.contactTestBitMask = PhysicsCategory.monster
    projectile.physicsBody?.collisionBitMask = PhysicsCategory.none
    
    // 정밀하게 물체 탐지
    projectile.physicsBody?.usesPreciseCollisionDetection = true
    
    //3. 터치 지점과 수리검의 위치 차이를 계산
    let offset = touchLocation - projectile.position
    
    // 4. 뒤로 터치를 했다면 바로 return
    if offset.x < 0 { return }
    
    // 5. 수리검을 추가
    addChild(projectile)
    
    //6. 방향을 알려주는 벡터 , 단위 벡터
    let direction = offset.normalized()
    
    // 7.화면 밖까지 나가기 위해서
    // direction이 단위 벡터이기 때문에 1000을 곱한다.
    let shootAmount = direction * 1000
    
    // 8. 수리검의 위치에서 목적지 까지 더하게 된다.
    // 좌표 시스템이 좌상단이 0,0 으로 그려지기 때문에
    let realDest = shootAmount + projectile.position
    
    //9. 액션 생성 2초동안 해당 위치로 이동하고 제거를 하는것
    let actionMove = SKAction.move(to: realDest, duration: 2.0)
    let actionMoveDone = SKAction.removeFromParent()
    projectile.run(SKAction.sequence([actionMove, actionMoveDone]))
  }
  
  func projectileDidCollideWithMonster(projectile: SKSpriteNode, monster: SKSpriteNode) {
    
    monstersDestroyed += 1
    // 이기면 이겼다는 Secene을 띄우는 것
    if monstersDestroyed > 30 {
      let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
      let gameOverScene = GameOverScene(size: self.size, won: true)
      view?.presentScene(gameOverScene, transition: reveal)
    }
    
    projectile.removeFromParent()
    monster.removeFromParent()
  }
}

 

 

  • 물리 반응과 관련된 부분
extension GameScene: SKPhysicsContactDelegate {

  func didBegin(_ contact: SKPhysicsContact) {
    // 1. 충돌한 물체가 어떤 것인지 판단을 하게 하는 것
    var firstBody: SKPhysicsBody
    var secondBody: SKPhysicsBody
    
    if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
      firstBody = contact.bodyA
      secondBody = contact.bodyB
    } else {
      firstBody = contact.bodyB
      secondBody = contact.bodyA
    }
   
    // 2. 물체가 서로 몬스터와 수리검이 맞으면 제거하는 것
    if ((firstBody.categoryBitMask & PhysicsCategory.monster != 0) &&
        (secondBody.categoryBitMask & PhysicsCategory.projectile != 0)) {
      if let monster = firstBody.node as? SKSpriteNode,
        let projectile = secondBody.node as? SKSpriteNode {
        projectileDidCollideWithMonster(projectile: projectile, monster: monster)
      }
    }
  }
  
}

 

 

  • CGPoint 계산을 위해 연산자 오버로딩을 사용
extension CGPoint {

  func length() -> CGFloat {
    return sqrt(x*x + y*y)
  }
  
  // 단위 벡터로 만들기 위한 방법
  func normalized() -> CGPoint {
    return self / length()
  }
  
  static func +(left: CGPoint, right: CGPoint) -> CGPoint {
    return CGPoint(x: left.x + right.x, y: left.y + right.y)
  }

  static func -(left: CGPoint, right: CGPoint) -> CGPoint {
    return CGPoint(x: left.x - right.x, y: left.y - right.y)
  }

  static func *(point: CGPoint, scalar: CGFloat) -> CGPoint {
    return CGPoint(x: point.x * scalar, y: point.y * scalar)
  }

  static func /(point: CGPoint, scalar: CGFloat) -> CGPoint {
    return CGPoint(x: point.x / scalar, y: point.y / scalar)
  }
}

 

 

  • 물체의 종류 카테고리라고 생각하면 된다
struct PhysicsCategory {
  static let none      : UInt32 = 0
  static let all       : UInt32 = UInt32.max
  static let monster   : UInt32 = 0b1       // 1
  static let projectile: UInt32 = 0b10      // 2
}

 

 

  • 게임오버 장면
import SpriteKit

final class GameOverScene: SKScene {
  
  init(size: CGSize, won:Bool) {
    super.init(size: size)

    backgroundColor = SKColor.white
    
    // 2
    let message = won ? "You Won!" : "You Lose :["
    
    // 3
    let label = SKLabelNode(fontNamed: "Chalkduster")
    label.text = message
    label.fontSize = 40
    label.fontColor = SKColor.black
    label.position = CGPoint(x: size.width/2, y: size.height/2)
    addChild(label)
    
    // 4
    run(SKAction.sequence([
      SKAction.wait(forDuration: 3.0),
      SKAction.run() { [weak self] in
        // 5
        guard let self = self else { return }
        let reveal = SKTransition.flipHorizontal(withDuration: 0.5)
        let scene = GameScene(size: size)
        self.view?.presentScene(scene, transition:reveal)
      }
      ]))
   }
  
  // 6
  required init(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }
}

'SpriteKit' 카테고리의 다른 글

SpriteKit을 이용한 수박게임 만들기  (0) 2024.06.30
SpriteKit을 이용한 "벽돌 깨기" 만들기  (0) 2024.06.23