오늘 만들기로 목표하는 부분은 상단에 있는 score
와 moves
레이블이다. 점수
는 깨뜨린 퍼즐만큼 늘어나고, 남은 이동 수
는 퍼즐을 누를 때마다 1씩 줄어든다. moves가 0이 되면 게임 오버
가 되도록 구현할 것이다.
이번 챕터에서는 점수와 이동 수 계산 로직보단 레이블 위치를 고정시키는 레이아웃 로직
이 더 중요하다. UIKit
이나 SwiftUI
의 오토레이아웃과는 다르게 작동하는 것을 체득할 수 있기 때문이다. 내용을 정리하면서 SpriteKit
의 노드마다 레이아웃 속성이 다르다는 것을 알았다. 쉬운 이해를 위해 트러블을 겪은 내용부터 해결한 내용까지 순서대로 다룰 것이다.
레이블은 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개의 레이블 노드가 추가된 모습
func scoreUp() {
let matchCount = matchedItems.count
let finalCount = min(matchCount, 6) // 한번에 64점 초과 방지
let scoreToAdd = pow(2, Double(finalCount))
score += Int(scoreToAdd)
}
func moveCountDown() {
moves -= 1
if moves == 0 {
// ... game over action ... //
}
}
고양이를 1개만 눌렀을 시 호출을 막아야하기 때문에, guard
처리가 되어있는 removeMatches
메소드 내부에서 호출한다.
func removeMatches() {
guard matchedItems.count > 1 else { return } // 고양이 노드 1개만 탭했을 시 스코프 탈출
// ... 노드 제거 로직 ... //
moveCountDown()
scoreUp()
}
증감 로직까지 구현한 뒤 레이블 노드의 텍스트가 잘 변경되는지 확인하다가 트러블이 발생했다.
UIKit
의 오토레이아웃처럼 equalTo: grid.leadingAnchor
만 해도 되면 얼마나 편할까? 안타깝게도 SpriteKit
의 노드 레이아웃은 그렇게 작동하지 않는다. 고양이 그리드를 만들면서 그 정도는 깨달았다고 생각했는데, 사고가 여전히 UIKit
에 머물러 있었나보다. 문제상황을 눈으로 보고 나서야 노드의 정중앙을 기준으로 offset
을 잡았을 때의 문제점을 깨달았다.
scoreLabel은 길이가 길어짐에 따라 leading이 화면 밖으로 나가고,
movesLabel은 길이가 짧아짐에 따라 trailing이 화면 안쪽으로 이동한다.
createLabels()
메소드에서 두 레이블의 위치를 잡을 때
score : 0
, moves : 10
일 때의 레이블 길이 기준으로 노드의 중심 위치(앵커포인트)에 position
을 잡았고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
인 것이 SKSpriteNode
의 anchorPoint
가 (0.5, 0.5)
인 것과 같이 작동하고 있었던 것이다.
SKLabelNode
의 horizontalAlignmentMode
프로퍼티를 활용하여 기준점을 중앙이 아닌 양 끝으로 조정한다.
enum LabelType {
case score
case moves
// 텍스트 정렬 프로퍼티
var horizontalAlignmentMode: SKLabelHorizontalAlignmentMode {
switch self {
case .score:
return .left
case .moves:
return .right
}
}
}
GameLabel
의 init
에 horizontalAlignmentMode
설정값 추가class GameLabel: SKLabelNode {
init(type: LabelType) {
super.init()
// ... //
horizontalAlignmentMode = type.horizontalAlignmentMode
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
GameLabel
생성 시 레이블 타입 할당class GameScene: SKScene {
// ... //
private let scoreLabel = GameLabel(type: .score)
private let movesLabel = GameLabel(type: .moves)
// ... //
}
scoreXOffset
과 movesXOffset
값 수정 : 이제 레이블 크기의 절반을 신경쓰지 않고, 화면의 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에 잘 고정된 모습
게임오버 화면 이미지를 프로젝트에 추가해준 뒤 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)
}
SKSpriteNode
가 anchorPoint
를 프로퍼티로 갖고 있으며, 여기에 새로운 값을 할당함으로써 기준점을 바꿀 수 있다는 걸 알게되었다. 그 점을 활용하여 게임오버 화면을 구현하였다.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
에 포스팅으로 정리하는 과정에서 오히려 제대로 공부하며 이해했다. 어렵지만 재밌고 좋았다.
여기서 끝내지 않고 앞으로 추가 기능을 더 구현해 볼 생각이다. 우선 버튼을 만들어보고 배경음악과 효과음도 넣어보려 한다.