[SpriteKit] 고양이 퍼즐 게임 만들기 day.05

Emily·2025년 6월 10일
0

GridPopGame

목록 보기
5/8
post-thumbnail

오늘 만들기로 목표하는 부분은 상단에 있는 scoremoves 레이블이다. 점수는 깨뜨린 퍼즐만큼 늘어나고, 남은 이동 수는 퍼즐을 누를 때마다 1씩 줄어든다. moves가 0이 되면 게임 오버가 되도록 구현할 것이다.

이번 챕터에서는 점수와 이동 수 계산 로직보단 레이블 위치를 고정시키는 레이아웃 로직이 더 중요하다. UIKit이나 SwiftUI의 오토레이아웃과는 다르게 작동하는 것을 체득할 수 있기 때문이다. 내용을 정리하면서 SpriteKit의 노드마다 레이아웃 속성이 다르다는 것을 알았다. 쉬운 이해를 위해 트러블을 겪은 내용부터 해결한 내용까지 순서대로 다룰 것이다.

01) 레이블 노드 추가 : SKLabelNode

레이블은 SKLabelNode을 사용하여 만들었다. SKLabelNode는 이름만 봐도 텍스트를 표현하는 노드라는 것을 알 수 있을 것이다. 우선 같은 디자인의 레이블이 2개 이상이기 때문에, 재활용을 위해 커스텀 클래스를 생성했다. GameLabel이라는 이름으로 생성하고, 생성자에는 공통으로 들어가는 디자인 초기설정(폰트 종류, 색상, 크기 등)을 구현했다.

class GameLabel: SKLabelNode {
    override init() {
        super.init()
        fontColor = .white
        fontName = "HelveticaNeue-Bold"
        fontSize = 27
        zPosition = 1
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

그리고 씬에 추가하였다.

class GameScene: SKScene {
	// ... //
    
    // 점수 레이블 선언
    private let scoreLabel = GameLabel()
    
    // 점수 변수를 기본값과 함께 선언하고, 프로퍼티 옵저버(didSet)를 통해 점수가 바뀔 때마다 레이블 텍스트도 바뀌도록 구현한다.
    private var score: Int = 0 {
    	didSet {
        	scoreLabel.text = "score : \(score)"
        }
    }
    
    // 남은 이동 수 레이블 선언
    private let movesLabel = GameLabel()
    
    // 기본값 10 : 10회 tap 시 game over
    private var moves: Int = 10 {
    	didSet {
        	movesLabel.text = "moves : \(moves)"
        }
    }
    
    /* 레이아웃 */
    
    // scoreLabel의 leading을 column 0 고양이 노드의 leading과 같게 설정
    private var scoreXOffset: CGFloat {
        let spareWidth = size.width - (itemSize * CGFloat(itemsPerRow))
        return (scoreLabel.bounds.width / 2) + (spareWidth / 2)
    }
    
    // movesLabel의 trailing을 column 7 고양이 노드의 trailing과 같게 설정
    private var movesXOffset: CGFloat {
        let spareWidth = size.width - (itemSize * CGFloat(itemsPerRow))
        return size.width - (movesLabel.bounds.width / 2) - (spareWidth / 2)
    }
    
    // 두 label 모두 y : 스크린 top ~ 고양이 그리드 top의 70% 지점
    private var labelYOffset: CGFloat {
        return size.height - (gridStartY * 0.7)
    }

    // ... //
    
    private func createLabels() {
    	score = 0
        scoreLabel.position = CGPoint(x: scoreXOffset, y: labelYOffset)
        addChild(scoreLabel)
        
        moves = 10
        movesLabel.position = CGPoint(x: movesXOffset, y: labelYOffset)
        addChild(movesLabel)
    }
    
    override func didMove(to view: SKView) {
    	// ... //
        
        createLabels()
    }
}

코드에 주석으로 설명했듯이, 점수 레이블의 leading과 이동 횟수 레이블의 trailing이 고양이 그리드의 양 끝에 맞춰지도록 구현했다.

2개의 레이블 노드가 추가된 모습

02) 점수, 이동 횟수 증감 로직 추가

  • 점수 : 2의 고양이 개수 제곱
func scoreUp() {
	let matchCount = matchedItems.count
    let finalCount = min(matchCount, 6)		// 한번에 64점 초과 방지
    let scoreToAdd = pow(2, Double(finalCount))
    score += Int(scoreToAdd)
}
  • 이동 횟수 : -1
func moveCountDown() {
	moves -= 1
    
    if moves == 0 {
    	// ... game over action ... //
    }
}

고양이를 1개만 눌렀을 시 호출을 막아야하기 때문에, guard 처리가 되어있는 removeMatches 메소드 내부에서 호출한다.

func removeMatches() {
	guard matchedItems.count > 1 else { return }	// 고양이 노드 1개만 탭했을 시 스코프 탈출
    
    // ... 노드 제거 로직 ... //
    
    moveCountDown()
    scoreUp()
}

03) 트러블 슈팅 : 텍스트의 길이 변화에 따라 레이블이 움직임

증감 로직까지 구현한 뒤 레이블 노드의 텍스트가 잘 변경되는지 확인하다가 트러블이 발생했다.

UIKit의 오토레이아웃처럼 equalTo: grid.leadingAnchor만 해도 되면 얼마나 편할까? 안타깝게도 SpriteKit의 노드 레이아웃은 그렇게 작동하지 않는다. 고양이 그리드를 만들면서 그 정도는 깨달았다고 생각했는데, 사고가 여전히 UIKit에 머물러 있었나보다. 문제상황을 눈으로 보고 나서야 노드의 정중앙을 기준으로 offset을 잡았을 때의 문제점을 깨달았다.

문제상황

scoreLabel은 길이가 길어짐에 따라 leading이 화면 밖으로 나가고,
movesLabel은 길이가 짧아짐에 따라 trailing이 화면 안쪽으로 이동한다.

원인분석

createLabels() 메소드에서 두 레이블의 위치를 잡을 때

  1. 두 텍스트가 score : 0, moves : 10일 때의 레이블 길이 기준으로 노드의 중심 위치(앵커포인트)에 position을 잡았고
  2. 텍스트 길이가 변해도 노드의 position은 변하지 않기 때문에(didMove 호출 시점에 정해지고 이후에 바뀌지 않음) 해당 position으로 노드의 앵커포인트가 유지되어 텍스트가 단방향이 아니라 양방향으로 길어지거나 짧아짐

위와 같은 현상이 일어나는 것이다.

탐구

SKLabelNode에 대해 공부를 좀 더 해보니, 레이블 노드에는 anchorPoint가 없다고 한다. 레이블 노드는 앵커포인트 대신 AlignmentMode 속성을 통해 앵커포인트의 효과를 조정할 수 있다.

// 수평 정렬
label.horizontalAlignmentMode = .left   // 왼쪽 정렬
label.horizontalAlignmentMode = .center // 중심 정렬 (기본값)
label.horizontalAlignmentMode = .right  // 오른쪽 정렬

// 수직 정렬
label.verticalAlignmentMode = .baseline    // 텍스트의 기준선 기준 (기본값)
label.verticalAlignmentMode = .top         // 상단 기준
label.verticalAlignmentMode = .center      // 중심 기준
label.verticalAlignmentMode = .bottom      // 하단 기준
여기서 baseline은 알파벳의 아래쪽 부분이 놓이는 위치라고 한다. (p, g, y와 같은 문자는 기준선보다 아래로 내려감)
>> position의 y값이 기준선과 일치하게 된다.

horizontalAlignmentMode의 기본값이 .center인 것이 SKSpriteNodeanchorPoint(0.5, 0.5)인 것과 같이 작동하고 있었던 것이다.

해결

SKLabelNodehorizontalAlignmentMode 프로퍼티를 활용하여 기준점을 중앙이 아닌 양 끝으로 조정한다.

  1. 레이블 종류에 따라 정렬을 분기처리하기 위한 enum 정의 : 점수 레이블은 왼쪽에 붙고, 남은 이동 수 레이블은 오른쪽에 붙도록 지정
enum LabelType {
    case score
    case moves
    
    // 텍스트 정렬 프로퍼티
    var horizontalAlignmentMode: SKLabelHorizontalAlignmentMode {
        switch self {
        case .score:
            return .left
        case .moves:
            return .right
        }
    }
}
  1. GameLabelinithorizontalAlignmentMode 설정값 추가
class GameLabel: SKLabelNode {
    init(type: LabelType) {
        super.init()
        
        // ... //
        
        horizontalAlignmentMode = type.horizontalAlignmentMode
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}
  1. 씬에서 GameLabel 생성 시 레이블 타입 할당
class GameScene: SKScene {
	// ... //
    
    private let scoreLabel = GameLabel(type: .score)
    
    private let movesLabel = GameLabel(type: .moves)
    
    // ... //
}
  1. scoreXOffsetmovesXOffset값 수정 : 이제 레이블 크기의 절반을 신경쓰지 않고, 화면의 leading으로부터 노드의 기준점을 얼마나 떨어뜨릴 것인지만 계산
private var scoreXOffset: CGFloat {
	let spareWidth = size.width - (itemSize * CGFloat(itemsPerRow))
	return (spareWidth / 2)
}
    
private var movesXOffset: CGFloat {
	let spareWidth = size.width - (itemSize * CGFloat(itemsPerRow))
	return size.width - spareWidth / 2
}

scoreLabel의 leading이 그리드의 leading,
movesLabel의 trailing이 그리드의 trailing에 잘 고정된 모습

04) 게임 오버 화면 추가 : anchorPoint

게임오버 화면 이미지를 프로젝트에 추가해준 뒤 moves가 0이 되면 해당 이미지가 나타나도록 구현하여 게임이 끝나도록 한다. 이 때, 배경 이미지를 넣었던 방식과 다르게 구현해보겠다.

고양이 퍼즐 실습을 글로 정리하는 과정에서 계속해서 노드들에 대한 새로운 내용을 배우는데, 이번에 알게 된 내용을 활용하고 소개해보려 한다.

  • 우선 기존 배경 이미지를 넣었던 코드 중 position 지정 부분을 보면, anchorPoint의 기본값을 고려하여 너비와 높이의 절반씩 각각 이동시켜 주는 것을 볼 수 있다.
func createBackground() {
	let background = SKSpriteNode(imageNamed: "background")
        
    // 노드의 앵커포인트 (0.5, 0,5)를 고려하여 너비와 높이의 절반만큼 이동시켜 준다.
	background.position = CGPoint(x: size.width / 2, y: size.height / 2)
    
    background.zPosition = -1
    background.size = size
        
    addChild(background)
}
  • 하지만 SKSpriteNodeanchorPoint를 프로퍼티로 갖고 있으며, 여기에 새로운 값을 할당함으로써 기준점을 바꿀 수 있다는 걸 알게되었다. 그 점을 활용하여 게임오버 화면을 구현하였다.
func gameOver() {
	let gameOver = SKSpriteNode(imageNamed: "gameover")
    
    // 노드의 position을 지정하는 대신 앵커포인트를 좌하단으로 이동시켜 준다.
    gameOver.anchorPoint = CGPoint(x: 0, y: 0)
    
    gameOver.zPosition = 2        
    gameOver.size = size
    
    addChild(gameOver)
}
// moves가 0이 되면 0.7초 뒤에 게임오버 화면 호출
func moveCountDown() {
	moves -= 1
        
    if moves == 0 {
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
            self.gameOver()
        }
    }
}

여기까지 유튜브 영상에서 나오는 내용은 전부 완성하였다. Restart 같은 버튼도 없이 다소 엉성하게 끝났지만 SpriteKit의 기본을 다뤄보는 게 목적이었기 때문에 만족스럽다. 부족한 부분은 공부 삼아 앞으로 추가해보면 되니까.

고양이 퍼즐 게임을 만들면서, 영상에 나오는 코드를 그대로 따라치는 것만으로는 바로 이해가 되지 않았고 여기 velog에 포스팅으로 정리하는 과정에서 오히려 제대로 공부하며 이해했다. 어렵지만 재밌고 좋았다.

여기서 끝내지 않고 앞으로 추가 기능을 더 구현해 볼 생각이다. 우선 버튼을 만들어보고 배경음악과 효과음도 넣어보려 한다.

profile
iOS Junior Developer

0개의 댓글