[SpriteKit] 고양이 퍼즐 게임 기능추가 chapter.01

Emily·2025년 6월 13일
0

GridPopGame

목록 보기
6/8

고양이 퍼즐 게임을 1차적으로 완성했다. 이제 내가 넣어보고 싶은 기능들을 공부하며 추가하려 한다. 오늘은 버튼을 만들 것이다. SpriteKit의 노드 중 버튼 개념은 없었다. 그래서 UIKit 또는 SwiftUI의 버튼 컴포넌트를 조합시켜야 하는 건지 찾아봤는데, 그것보다는 노드를 활용하는 것이 일관성과 퍼포먼스 면에서 낫다고 한다. SKSpriteNode로 만들고, touchesBegan, touchesEnded 메소드를 활용하여 동작을 구현한다.

00) 자잘한 리팩토링

  1. SKSpriteNode를 생성할 때마다 이미지 Asset의 이름을 하드코딩했던 부분을 리팩토링했다. enum을 생성했다.
enum Assets: String {
    case background
    case gameover
    case restart
}
// before
SKSpriteNode(imageNamed: "background")

// after
SKSpriteNode(imageNamed: Assets.background.rawValue)
  1. 배경 이미지와 게임오버 이미지를 메소드의 지역변수로 선언했던 부분을 클래스의 전역변수로 옮겼다. 사실 배경 이미지의 경우 didMove 메소드에서 한번만 생성되면 끝이기 때문에 별 차이가 없지만 게임오버 이미지는 게임이 끝날 때마다 매번 디스크로부터 로드되어 생성하는 것이 비효율적이기 때문이다. 이에 따라 다른 전역변수들 사이에서 노드 종류가 구분될 수 있도록 변수명을 명시적(backgroundbackgroundImage)으로 바꾸었다. + create가 들어간 노드 추가 메소드 이름을 add가 들어가게 바꾸었다.
class GameScene: SKScene {
	private let backgroundImage = SKSpriteNode(imageNamed: Assets.background.rawValue)
    
    // ... //
    
    private let gameoverImage = SKSpriteNode(imageNamed: Assets.gameover.rawValue)
    
    // .. //
}

01) restart 버튼 만들기

현재 게임은 이동 횟수를 소진하고 나면 게임 오버 화면이 뜨고, 그 상태에서 사용자가 할 수 있는 게 아무것도 없는 상태다. 아마 앱을 껐다가 다시 켜야 새로운 게임을 시작할 수 있을 것이다. 게임 오버 화면에 재시작 버튼을 추가하여 곧바로 게임을 다시 플레이할 수 있도록 하려 한다.

UI 구현

우선 무료 일러스트 사이트에서 버튼 이미지를 구했고, 프로젝트에 추가했다. Restart 버튼은 커스텀 노드 클래스로 생성했고, 게임 씬이 아닌 게임오버 이미지의 child로 추가했다. 재시작 버튼을 눌렀을 때 게임오버 이미지 노드만 remove 해주면 함께 사라지게 하기 위해서다.

재시작 버튼을 커스텀 노드로 생성한 이유는 touchesBegan, touchesEnded 같은 탭 동작 메소드 운영 때문이다. 사용자가 Restart 버튼의 영역을 탭했을 때만 재시작 액션이 작동하도록 처리하는 과정에 차이가 있다.

  • 씬에 전역변수로 생성했을 경우 : touchlocation을 추출한 뒤 restartButtonlocation과 일치하는지 확인하는 조건문 내에 동작을 넣어준다. (고양이 노드는 이렇게 만들어졌다)
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의 경우 gameoverImagechild로 추가된 뒤 remove 되는 시점이 없기 때문에 addChild가 두번 이상 호출되면 에러가 발생한다. 따라서 didMove 시점에 한번만 등록해주도록 한다.

Action 구현

커스텀 노드를 사용하고 그 내부에서 터치 함수를 활용하기 때문에, 객체 간의 소통이 필요하다. 이런 경우 나는 delegate 패턴을 주로 써왔는데 이번엔 콜백을 처음으로 써보았다.

  1. 게임 재시작하기 : gameoverImage를 씬에서 제거하고, scoremoves를 초기화한다.
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()
        }
    }
}
  1. 버튼에 시각효과 주기 : 재시작 버튼을 누르면 순간적으로 버튼이 커졌다 작아지도록 하여 좀더 생동감을 준다.
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?()
        }
    }
}

02) music on/off toggle 버튼 만들기

게임에 배경음악을 넣으려고 하는데, 그 소리를 껐다 켤 수 있는 토글 버튼을 만들 것이다.

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 해줬다는 말로 대체하겠다.

버튼이 잘 토글되는 모습

이 버튼의 액션은 프로젝트에 오디오를 추가하는 내용과 관련있기 때문에, 다음 챕터에 다루겠다.

profile
iOS Junior Developer

0개의 댓글