[Core Motion] 고양이 기울이기 게임

Emily·2025년 7월 14일
1

최근 SpriteKit이라는 프레임워크를 처음으로 접하고, 핵심 개념인 node를 통해 화면에 여러가지 구성요소를 넣을 수 있다는 걸 알았다. 시각적인 요소 뿐만 아니라 청각적인 요소 역시 노드를 통해 구현할 수 있다는 걸 고양이 퍼즐 게임을 만들어보면서 체험했다. 이번 프로젝트에서는 SKPhysicsBody를 통해 노드에 물리를 부여하고, Core Motion을 활용하여 실제 기기의 기울임에 따라 노드가 상호작용하도록 구현해볼 것이다.

고양이 기울이기 게임은 Tilt Ball Game이라는 앱을 참고하여 구현했다.

player 공을 화면 기울기를 통해 target 공의 위치로 보내는 매우 간단한 게임이다. 공이 목표점에 도달하면 색깔이 바뀌고, target은 무작위의 위치에 새로 생긴다.

내 게임은 이렇게 생겼다. 고양이 퍼즐 게임의 이미지 리소스를 이어 받았다. 기울기를 통해 고양이를 먹이에 도달시켜야 하고, 고양이 이미지와 먹이의 위치+이미지는 계속 무작위로 바뀐다.

01) 노드에 물리 부여하기

scene에 노드를 추가하는 과정은 이미 이전 포스팅 시리즈에서 많이 다뤘으므로 생략하겠다. physicsBody에 대한 설명 또한 따로 포스팅으로 다뤘으니, 여기서 사용하는 용어가 궁금하면 참고하면 된다.

우선 기기의 기울임에 따라 고양이(catFaceNode)를 움직일 건데, 고양이 노드는 화면 벽과는 충돌 인식이 되어야 한다.

이렇게 구현하기 위해서는 프레임에 물리 바디를 입혀야 한다. 이걸 하지 않으면 기울임에 따라 고양이가 화면 밖으로 벗어나버리고 다시 돌아오기 어려워질 수가 있다.

class GameScene: SKScene {

    override func didMove(to view: SKView) {
        // ... //

        setupFramePhysics()
    }

	private func setupFramePhysics() {
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)		// 프레임에 물리 추가
        physicsBody?.categoryBitMask = PhysicsCategory.frame	// 물리 카테고리
        physicsBody?.collisionBitMask = PhysicsCategory.cat		// 충돌 인식할 노드의 카테고리
        physicsBody?.contactTestBitMask = PhysicsCategory.none	// 접촉 시 감지할 대상의 카테고리
        physicsBody?.restitution = 0.0							// 충돌 시 반발력
    }
}

프레임의 경우 고양이 노드와는 서로 닿았을 때 물리적으로 충돌이 되어야하기 때문에 collisionBitMask에 고양이 카테고리를 할당했다. 간식 노드와는 충돌할 일도 없을 뿐더러, 서로 충돌 인식할 필요도 없다. contactTestBitMask에는 none을 할당했는데 cat을 할당하지 않은 이유는 고양이 노드가 프레임에 닿았을 때 물리적인 충돌 인식만 필요하지, 그 충돌을 감지함으로써 다른 동작을 더 구현할 것이 아니기 때문이다.

그리고 충돌 반발력은 0으로 함으로써 고양이가 벽에 부딪혀도 튕기지는 않도록 했다.

반발력(restitution)을 적용하면 이렇게 통통통 튄다.

다음은 고양이 이미지 노드에 물리를 적용한 코드다.

private func setupCatFacePhysicsBody(_ imageName: String) {
	catFaceNode.physicsBody = SKPhysicsBody(texture: SKTexture(imageNamed: imageName), size: catFaceNode.size)
    catFaceNode.physicsBody?.categoryBitMask = PhysicsCategory.cat
    catFaceNode.physicsBody?.collisionBitMask = PhysicsCategory.frame
    catFaceNode.physicsBody?.contactTestBitMask = PhysicsCategory.item
    catFaceNode.physicsBody?.isDynamic = true	// 화면 기울기에 따라 움직임 발생
    catFaceNode.physicsBody?.affectedByGravity = false	// 중력에 영향 받지 않음
    catFaceNode.physicsBody?.allowsRotation = false		// 이미지 회전하지 않음
    catFaceNode.physicsBody?.restitution = 0.0
}

프레임은 에지 기반 바디인 반면 이미지 노드에는 볼륨 기반 바디를 입히게 된다. texture를 통해 고양이 얼굴 모양 그대로 물리를 입혔다. 충돌을 인식할 대상은 프레임이고, 간식 이미지와는 접촉 시 동작을 구현할 것이기 때문에 contactTestBitMask에 간식 노드 카테고리를 할당했다. 간식 노드와 고양이 노드는 서로 부딪히는 물리 충돌은 일어나지 않는다.

다음은 간식 노드다.

대충 고양이가 좋아하는 간식 거리 이미지와 장난감인 털실 뭉치 이미지를 준비했다. 털실 뭉치는 간식은 아니지만 편의상 그렇게 부르겠다.

처음에는 간식 노드도 고양이 노드처럼 texture를 통해 이미지 모양 그대로 물리 바디를 생성하도록 했는데, 이렇게 하고나니 간식 노드 이미지가 랜덤으로 바뀔 때마다 물리 바디 모양도 새로 생성하느라(이게 expensive하다고 한다) 앱이 버벅이는 현상이 발생했다. 그래서 그냥 이미지 크기에 맞는 원 모양으로 물리 바디를 한번 생성하고, 계속 그 물리 바디를 갖고 있도록 구현했다.

private func setupItemPhysicsBody() {
    itemNode.physicsBody = SKPhysicsBody(circleOfRadius: itemNode.size.width * 0.4)
    itemNode.physicsBody?.categoryBitMask = PhysicsCategory.item
    itemNode.physicsBody?.collisionBitMask = PhysicsCategory.none
    itemNode.physicsBody?.contactTestBitMask = PhysicsCategory.cat
    itemNode.physicsBody?.isDynamic = false
}

isDynamictrue이면 affectedByGravityallowsRotation도 자연스럽게 true가 되어 고양이 노드에서는 따로 false 세팅을 해줘야했지만, 간식 노드에는 그렇게 할 필요가 없어 코드가 짧아졌다.

02) 기기 기울기를 물리 바디에 적용하기

우선 기기의 기울임을 인식하기 위해서는 CoreMotion 프레임워크를 import 하고, CMMotionManager 인스턴스를 활용해야 한다.

import SpriteKit
import CoreMotion

class GameScene: SKScene {
    // motion manager 인스턴스 생성
    private let motionManager = CMMotionManager()
    
    // scene이 화면에 나타났을 때 motion 인식 시작
    override func didMove(to view: SKView) {
    	// ... //
    	startDeviceMotionUpdates()
    }
    
    private func startDeviceMotionUpdates() {
    	// device motion 인식 가능한 상태인지 체크 (기기에 accelerometer가 있는지 여부)
        guard motionManager.isDeviceMotionAvailable else {
        	// error 처리 디버깅으로 대체
            print("[Error] Device motion is not available")
            return
        }
        
        // motion update 주기 설정 : 1초 당 60번
        motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
        
        // motion update 시작
        motionManager.startDeviceMotionUpdates(to: .main) { [weak self] (motion, error) in
            guard let deviceMotion = motion else {
                print("[Error] Failed to get device motion data")
                return
            }
            
            if let error = error {
                print("[Error] \(error.localizedDescription)")
                return
            }
            
            // 인식한 device motion을 메소드에 전달
            self?.handleDeviceMotion(deviceMotion)
        }
    }
    
    // device motion을 속도로 전환하여 노드의 물리에 적용하는 메소드 정의
    private func handleDeviceMotion(_ motion: CMDeviceMotion) {
        let attitude = motion.attitude	// motion의 방향 데이터에 접근
        
        let sensitivity: CGFloat = 500.0	// 움직임 민감도
        let deadZone: Double = 0.1			// 데드존 : 작은 움직임 인식 정조(손떨림 등)
        
        var roll = attitude.roll	// 좌우 기울기
        var pitch = attitude.pitch	// 앞뒤 기울기
        
        // 데드존 적용 : 작은 움직임은 인식하지 않도록
        if abs(roll) < deadZone { roll = 0 }
        if abs(pitch) < deadZone { pitch = 0 }
        
        // 민감도를 적용하여 기울기를 속도로 변환
        let velocityX = CGFloat(roll) * sensitivity
        let velocityY = CGFloat(-pitch) * sensitivity * 1.5	// 기기 윗부분이 뒤로 눕도록 기울였을 때 노드가 위로 움직이도록 하기 위한 음수 처리
        
        // 노드의 물리 바디에 속도 적용
        if let physicsBody = catFaceNode.physicsBody {
            physicsBody.velocity = CGVector(dx: velocityX, dy: velocityY)
        }
    }
    
    // scene이 화면에서 사라질 때 motion 인식 중지
    override func willMove(from view: SKView) {
        stopDeviceMotionUpdates()
    }
    
    private func stopDeviceMotionUpdates() {
        motionManager.stopDeviceMotionUpdates()
    }
    
    // ... //
    
    private func setupCatFacePhysicsBody(_ imageName: String) {
    	// ... //
        catFaceNode.physicsBody?.linearDamping = 0.7
    }
}

이렇게 적용해주고 나서 실제 기기로 시뮬레이팅 해보면 기기를 기울임에 따라 고양이 이미지가 움직이는 것을 확인할 수 있다. 코드 설명은 주석에 해두었지만 주요 코드를 한번 더 짚자면 didMovedevice motion 업데이트를 시작하고, willMove에 중지하여 메모리를 절약한다. 또한, 시뮬레이팅을 반복하며 원하는 정도의 민감도를 적용하고 부드러운 동작을 위해 physicsBodydamping을 적용해주는 게 좋다. 그리고 pitch 값의 경우 기본값을 그대로 사용하면(음수 적용을 하지 않으면) 기기 윗부분이 뒤로 눕혀졌을 때 노드가 화면상 아래로 이동하는 부자연스러운 동작이 나오게 되어 음수 처리를 해줘야 한다.

사실 이 코드대로 적용했을 때 100% 만족스러운 물리가 적용되진 않아 이리저리 값을 바꿔가며 자연스러운 물리를 찾으려 했지만 쉽지 않았다. 그래도 core motion을 처음으로 학습하기에 적당한 경험이었다.

03) 노드끼리 contact 했을 때 동작 넣기

기울기까지 적용했으니 고양이가 간식 노드와 접촉했을 때 동작을 적용해보겠다. 넣을 동작은 여러 개가 있다. 1. 야옹 효과음이 울린다. 2. 간식 노드의 위치가 무작위로 바뀐다. 3. 간식 노드의 이미지가 무작위로 바뀐다. 4. 고양이 노드의 이미지가 무작위로 바뀐다. 5. 레이블의 count가 올라간다.

우선, contactTestBitMask로 접촉을 감지한 노드에 접근하기 위해서는 SKPhysicsContactDelegate를 채택하여 didBegin 메소드를 활용해야 한다. 접촉이 인식되는 시점에 호출된다. 이 때, 노드끼리 겹쳐지는 동안 didBegin 메소드가 여러번 호출되는 것을 방지하기 위해 플래그 변수를 활용해야 한다. (없이 동작을 넣었다가 원치 않는 상황을 맞닥뜨렸다)

class GameScene: SKScene, SKPhysicsContactDelegate {
	// ... //
    
    // 레이블 노드 : 고양이가 간식을 먹은 횟수 표시
    private let countLabel: SKLabelNode = {
        let node = SKLabelNode(text: "0 meow")
        
        node.fontColor = .white
        node.fontName = "HelveticaNeue-Bold"
        node.fontSize = 27
        
        return node
    }()
    
    // 레이블에 표시할 노드 접촉 횟수
    private var count: Int = 0 {
        didSet {
            countLabel.text = "\(count) meow"
        }
    }
    
    // 고양이가 간식을 먹을 때마다 재생할 사운드 노드
    private let meowSound: SKAudioNode = {
        let node = SKAudioNode(fileNamed: "meow.mp3")
        
        node.isPositional = true
        node.autoplayLooped = false
        
        return node
    }()
    
    override func didMove(to view: SKView) {
    	// delegate 지정
        physicsWorld.contactDelegate = self
        
        // ... //
    }
    
    // ... //
    
    // 노드끼리 겹치는 동안 단 한번만 감지하기 위한 플래그 변수
    private var isContactProcessing = false
    
    func didBegin(_ contact: SKPhysicsContact) {
    	// 플래스 변수가 false 일 때만 실행하도록 방지
    	guard !isContactProcessing else { return }
        
        // 접촉 중인 물리 바디 추출
        let bodyA = contact.bodyA
        let bodyB = contact.bodyB
        
        // 접촉 중인 노드가 고양이와 간식일 경우 = body A가 고양이고 B가 간식이거나 A가 간식이고 B가 고양이일 경우
        if ((bodyA.categoryBitMask == PhysicsCategory.cat && bodyB.categoryBitMask == PhysicsCategory.item) ||
            (bodyA.categoryBitMask == PhysicsCategory.item && bodyB.categoryBitMask == PhysicsCategory.cat)) {
        	isContactProcessing = true
            
            // 레이블에 표시하는 count +1
            count += 1
            
            // 사운드 노드 재생
            meowSound.run(SKAction.play())
            
            // 간식 노드 위치 무작위로 변경
            repositionItemNode()
            
            // 간식 노드 이미지 무작위로 변경 - enum에서 랜덤 추출
            let itemImageName = Item.allCases.randomElement()?.rawValue ?? Item.tuna.rawValue
	        itemNode.texture = SKTexture(imageNamed: itemImageName)
        	
            // 고양이 노드 이미지 무작위로 변경 - enum에서 랜덤 추출
            let catImageName = Cat.allCases.randomElement()?.rawValue ?? Cat.cheese.rawValue
    	    catFaceNode.texture = SKTexture(imageNamed: catImageName)
        }
    }
    
    // 간식 노드 위치 무작위로 변경
    private func repositionItemNode() {
    	// 적절한 범위에서 노드 위치 무작위 추출
        let randomX = CGFloat.random(in: 50...(size.width - 50))	// 양옆에 50씩 margin
        let randomY = CGFloat.random(in: 100...(countLabel.position.y - 100))	// 위아래로 100씩 margin
        
        // 물리 노드 재설정을 위해 저장해뒀다가, position 변경 후 다시 적용
        let originalPhysicsBody = itemNode.physicsBody
        itemNode.physicsBody = nil
        
        itemNode.position = CGPoint(x: randomX, y: randomY)
        itemNode.physicsBody = originalPhysicsBody
        
        // 플래그 변수를 약간의 딜레이 후에 초기화 - 바로 초기화 할 경우 노드의 빠른 움직임으로 인식이 한번 더 될 수 있음
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
            self?.isContactProcessing = false
        }
    }
}

간식 노드(itemNode)의 위치가 변경되고 나서 물리 바디를 재설정 해주지 않으면 고양이 노드가 이동한 간식 노드와 접촉해도 서로를 인식하지 않기 때문에 다시 설정해주는 것이 필요하다.

또, 오디오 노드를 적용하고 나서 야옹 소리가 여러번 재생되는 버그를 겪었는데, 이것은 플래그 변수 초기화를 바로 해줄 경우 노드의 이동속도가 빠르면 didBeginguard문을 통과해 간식 노드가 이동하기 전에 contact 판정이 여러번 되어 발생한 것이었다. asyncAfter를 활용한 방법은 그 현상을 막아준다.(정확히는 높은 확률로 완화해준다)


시연 영상에서 효과음과 함께 동작을 확인할 수 있다.

너무 간단한 게임이지만, SpriteKit에 물리를 적용하고, 기기의 움직임을 적용하는 경험을 할 수 있는 좋은 프로젝트였다. 추후에 기회가 생긴다면 이번에 공부한 내용을 응용하여 더 다양하고 정교한 물리를 구현할 수 있을 것 같다.

profile
iOS Junior Developer

0개의 댓글