고양이 퍼즐 게임을 1차적으로 완성했다. 이제 내가 넣어보고 싶은 기능들을 공부하며 추가하려 한다. 오늘은 버튼
을 만들 것이다. SpriteKit
의 노드 중 버튼 개념은 없었다. 그래서 UIKit
또는 SwiftUI
의 버튼 컴포넌트를 조합시켜야 하는 건지 찾아봤는데, 그것보다는 노드를 활용하는 것이 일관성과 퍼포먼스 면에서 낫다고 한다. SKSpriteNode
로 만들고, touchesBegan
, touchesEnded
메소드를 활용하여 동작을 구현한다.
SKSpriteNode
를 생성할 때마다 이미지 Asset의 이름을 하드코딩했던 부분을 리팩토링했다. enum
을 생성했다.enum Assets: String {
case background
case gameover
case restart
}
// before
SKSpriteNode(imageNamed: "background")
// after
SKSpriteNode(imageNamed: Assets.background.rawValue)
didMove
메소드에서 한번만 생성되면 끝이기 때문에 별 차이가 없지만 게임오버 이미지는 게임이 끝날 때마다 매번 디스크로부터 로드되어 생성하는 것이 비효율적이기 때문이다. 이에 따라 다른 전역변수들 사이에서 노드 종류가 구분될 수 있도록 변수명을 명시적(background
→ backgroundImage
)으로 바꾸었다. + create
가 들어간 노드 추가 메소드 이름을 add
가 들어가게 바꾸었다.class GameScene: SKScene {
private let backgroundImage = SKSpriteNode(imageNamed: Assets.background.rawValue)
// ... //
private let gameoverImage = SKSpriteNode(imageNamed: Assets.gameover.rawValue)
// .. //
}
현재 게임은 이동 횟수를 소진하고 나면 게임 오버 화면이 뜨고, 그 상태에서 사용자가 할 수 있는 게 아무것도 없는 상태다. 아마 앱을 껐다가 다시 켜야 새로운 게임을 시작할 수 있을 것이다. 게임 오버 화면에 재시작 버튼을 추가하여 곧바로 게임을 다시 플레이할 수 있도록 하려 한다.
우선 무료 일러스트 사이트에서 버튼 이미지를 구했고, 프로젝트에 추가했다. Restart
버튼은 커스텀 노드 클래스로 생성했고, 게임 씬이 아닌 게임오버 이미지의 child
로 추가했다. 재시작 버튼을 눌렀을 때 게임오버 이미지 노드만 remove
해주면 함께 사라지게 하기 위해서다.
재시작 버튼을 커스텀 노드로 생성한 이유는 touchesBegan
, touchesEnded
같은 탭 동작 메소드 운영 때문이다. 사용자가 Restart 버튼의 영역
을 탭했을 때만 재시작 액션이 작동하도록 처리하는 과정에 차이가 있다.
touch
의 location
을 추출한 뒤 restartButton
의 location
과 일치하는지 확인하는 조건문 내에 동작을 넣어준다. (고양이 노드는 이렇게 만들어졌다)class GameScene: SKScene {
// ... //
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
let location = touch.location(in: self)
if restartButton.contains(location) {
// .. 게임 재시작 동작 .. //
}
}
}
touchesEnded
메소드에 동작을 넣어주면 된다.class RestartButton: SKSpriteNode {
// ... //
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
// 위치 check 코드 X
// ... 게임 재시작 동작 ... //
}
}
touchesEnded
메소드 코드의 차이 뿐만 아니라 씬의 코드도 줄어드는 효과가 있기 때문에 이번에는 커스텀 클래스에 touches
메소드들을 넣어주었다.
// 커스텀 노드 생성
class RestartButton: SKSpriteNode {
override init(texture: SKTexture?, color: UIColor, size: CGSize) {
let texture = SKTexture(imageNamed: "restart")
super.init(texture: texture, color: .black, size: .zero)
isUserInteractionEnabled = true // 상호작용 활성화를 해야 버튼처럼 탭을 인식한다
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
}
}
// scene에 추가
class GameScene: SKScene {
private let gameoverImage = SKSpriteNode(imageNamed: Assets.gameover.rawValue)
private let restartButton = RestartButton()
// ... //
override func didMove(to view: SKView) {
// ! 중요 ! removeFromParent가 안된 상태에서 노드를 또 추가되면 에러 발생
// >> removeFromParent 호출 시점이 없는 노드는 여러번 호출되는 메소드에서 addChild를 하면 안된다.
gameoverImage.addChild(restartButton)
}
private func gameOver() {
// ... //
addChild(gameoverImage)
// 재시작 버튼 레이아웃 지정하고 gameoverImage에 child로 추가
restartButton.size = CGSize(width: size.width / 5, height: size.width / 5)
restartButton.position = CGPoint(x: 0, y: -(size.height / 6))
restartButton.zPosition = 3
}
}
기억해야 할 점 :
gameoverImage
의 경우 게임오버 때addChild
, 재시작 때removeFromParent
를 반복하지만restartButton
의 경우gameoverImage
의child
로 추가된 뒤remove
되는 시점이 없기 때문에addChild
가 두번 이상 호출되면 에러가 발생한다. 따라서didMove
시점에 한번만 등록해주도록 한다.
커스텀 노드를 사용하고 그 내부에서 터치 함수를 활용하기 때문에, 객체 간의 소통이 필요하다. 이런 경우 나는 delegate 패턴을 주로 써왔는데 이번엔 콜백
을 처음으로 써보았다.
gameoverImage
를 씬에서 제거하고, score
과 moves
를 초기화한다.class RestartButton: SKSpriteNode {
var touchAction: (() -> Void)?
// ... //
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
touchAction?()
}
}
class GameScene: SKScene {
// ... //
private func moveCountDown() {
moves -= 1
if moves == 0 {
// 게임오버 화면이 나타나기 전 0.7초 동안 사용자가 퍼즐을 더 누르는 것 방지 - 상호작용 비활성화
isUserInteractionEnabled = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
self.gameOver()
}
}
}
private func gameOver() {
// ... //
// 점수와 이동 수 초기화, 게임오버 화면을 제거하여 게임 화면이 나타나도록 구현
restartButton.touchAction = { [weak self] in
self?.score = 0
self?.moves = 10
self?.isUserInteractionEnabled = true // 게임이 다시 시작될 때 상호작용 활성화
self?.gameoverImage.removeFromParent()
}
}
}
class RestartButton: SKSpriteNode {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 누르는 순간에 조금 커졌다가
setScale(1.2)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
// 손을 떼면 원래 크기로 돌아온다.
setScale(1.0)
// action에 딜레이를 주지 않으면 버튼이 원래 크기로 돌아오기도 전에 게임오버 화면이 사라지므로 약간의 딜레이를 주었다.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.touchAction?()
}
}
}
게임에 배경음악을 넣으려고 하는데, 그 소리를 껐다 켤 수 있는 토글 버튼을 만들 것이다.
2개의 이미지를 준비하고, Bool
값에 따라 노출되는 이미지가 바뀌도록 구현한다. 파일명을 관리하는 enum
에도 추가해준다.
enum Assets {
// ... //
case musicOn
case musicOff
}
class MusicButton: SKSpriteNode {
private var isMusicOn: Bool = true {
// 프로퍼티 옵저버를 통해 값이 바뀔 때마다 토글 동작을 호출한다.
didSet {
toggleImage()
}
}
init() {
// 이미지 기본값은 musicOn
let texture = SKTexture(imageNamed: Assets.musicOn.rawValue)
let size = CGSize(width: 30, height: 30)
super.init(texture: texture, color: .white, size: size)
isUserInteractionEnabled = true
colorBlendFactor = 1.0
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// 삼항연산자를 활용하여 bool 값에 따른 파일명과 투명도를 지정해준다.
private func toggleImage() {
let imageName = isMusicOn ? Assets.musicOn.rawValue : Assets.musicOff.rawValue
texture = SKTexture(imageNamed: imageName)
alpha = isMusicOn ? 1.0 : 0.8
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// 누르는 순간 살짝 작아졌다가
setScale(0.9)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
// 손을 떼면 원래 크기로 돌아오며, bool 값을 토글한다.
setScale(1.0)
isMusicOn.toggle()
}
}
씬에 추가하는 코드는 position
만 지정해주고 addChild
해줬다는 말로 대체하겠다.
버튼이 잘 토글되는 모습
이 버튼의 액션은 프로젝트에 오디오를 추가하는 내용과 관련있기 때문에, 다음 챕터에 다루겠다.