iOS

DodgeVirus: Membuat Game dengan SpriteKit

· 8 menit untuk membaca
Dodge Virus Game Banner
Dodge Virus Game

Kali ini aku mau sharing terkait apa yang aku pelajari dari membuat game sederhana dengan menggunakan SpriteKit.

Introduction

DodgeVirus adalah sebuah game kasual yang dapat dimainkan dimana saja dan kapan saja. Pada permainan ini, kita sebagai player, harus menghindari virus yang berjatuhan dari atas dengan menggerakkan smartphone kita. Untuk memberikan efek feedback game ini memanfaatkan physics yang ada di SpriteKit dan juga Haptic dari CoreHaptics.  Tujuan dari game ini adalah bertahan selama mungkin sehingga mendapatkan high score.

Design

Untuk menampilkan kesederhanaan dari permainan ini, maka desain yang dipakai adalah flat 2D dengan memakai palet warna sebagai berikut.

Palet warna yang digunakan
Palet warna yang digunakan.

Berikut adalah aset-aset yang digunakan pada game ini.

Free Vector | Flat virus collection
Download this Free Vector about Flat virus collection, and discover more than 52 Million Professional Graphic Resources on Freepik. #freepik #vector #covidsymptoms #covid #coronavirusprevention
Desain Virus yang digunakan pada aplikasi.
Free Vector | How to wear a face mask illustration
Download this Free Vector about How to wear a face mask illustration, and discover more than 52 Million Professional Graphic Resources on Freepik. #freepik #vector #wearingmask #facemaskmasker #wearfacemask
Desain Kepala yang digunakan pada aplikasi.
audio-thumbnail
Victory Screen by LesiaKower (https://pixabay.com/music/video-games-victory-screen-150573/)
0:00
/1:37

Berikut adalah High-Fidelity Prototype dari game ini.

Hi-Fi Prototype
Hi-Fi Prototype
DodgeVirus
Shared on Sketch by Adryan Eka
Link Hi-Fi Prototype (Sketch)

Code

Enough talk, show me the code!

Game Scene

Bagian gameplay dari game ini dibuat menggunakan SpriteKit + SwiftUI. HUD dari game dibuat dengan SwiftUI dan bagian animasi game dibuat dengan SpriteKit.

Berikut adalah potongan code untuk menampilkan halaman gameplay dari game ini. Bagian kode ini digunakan untuk menampilkan halaman SpriteKit dan juga HUD berupa score dan tombol pause.

Sumber data dari tampilan ini berasal dari GamePlayViewModel yang bertindak sebagai State Manager.

GamePlayPage.swift

import SwiftUI

struct GamePlayPage: View {
	@State var isPaused = false
	@ObservedObject var viewModel: GamePlayViewModel
	@State var backToMainMenu: Bool = false
	@Environment(\.dismiss) var dismiss

	
    var body: some View {
		NavigationView {
			ZStack {
				GeometryReader { value in
					GamePlayView(size: value.size, viewModel: viewModel, isPaused: Binding<Bool>(get: {
						viewModel.gameState != GameState.played
					}, set: { value, _ in
						isPaused = value
					}))
				}
				
				VStack {
					Text("\(viewModel.score)")
						.font(Font(UIFont.systemFont(ofSize: 48, weight: .medium)))
						.padding(EdgeInsets(top: 72, leading: 32, bottom: 32, trailing: 32))
						.foregroundColor(Color("TitleColor"))
					Spacer()
				}
				VStack {
					HStack(alignment: .top) {
						Spacer()
						Button {
							viewModel.pauseGame()
							isPaused.toggle()
						} label: {
						Image(systemName: "pause.rectangle")
								.resizable()
								.frame(width: 32, height: 24)
								.foregroundColor(Color("Color3"))
							
							.padding(EdgeInsets(top: 64, leading: 32, bottom: 32, trailing: 32))
							
						}
					}
					Spacer()
				}

			}.customDialog(isShowing: Binding<Bool>(get: {
				viewModel.gameState != GameState.played
			}, set: { value, _ in
				isPaused = value
			})){
				if viewModel.gameState == GameState.paused {
					PauseView().environmentObject(viewModel)
				}
				if viewModel.gameState == GameState.defeated {
					GameOverView(backToMainMenu: $backToMainMenu).environmentObject(viewModel)
				}
				
			}
			
		.ignoresSafeArea()
		}
		.onChange(of: backToMainMenu) { value in
			if value {
				dismiss()
			}
		}
       
    }
}

Potongan kode di bawah ini digunakan untuk membungkus tampilan UIKit (SKView) menjadi UIViewRepresentable agar bisa ditampilkan pada SwiftUI.

GamePlayView.swift

import SwiftUI
import UIKit
import SpriteKit

struct GamePlayView: UIViewRepresentable {
	var size: CGSize
	var viewModel: GamePlayViewModel
	@Binding var isPaused: Bool

	func makeUIView(context: Context) -> SKView {
		let skView = SKView()
		skView.ignoresSiblingOrder = true
		let scene = GamePlayScene(viewModel: viewModel, size: size)
		skView.presentScene(scene)
		
		return skView
		
	}
	
	func updateUIView(_ uiView: SKView, context: Context) {
		uiView.isPaused = isPaused
	}
	
	typealias UIViewType = SKView
	
}

Berikut adalah potongan kode GamePlayViewModel yang digunakan untuk menampung data dan state management dari game ini. Perubahan skor, pengaturan musik juga dilakukan pada class ini.

import SwiftUI
import AVFoundation

class GamePlayViewModel: ObservableObject {
	
	static let shared = GamePlayViewModel()
	
	
	init () {
		musicPlayer = {
			guard let url = Bundle.main.url(forResource: "BackgroundSound", withExtension: "mp3") else {
				return nil
			}
			
			let player = try? AVAudioPlayer(contentsOf: url)
			player?.numberOfLoops = -1
			return player
		}()
	}
	@Published private(set) var score: Int = 0
	@Published private(set) var gameState: GameState = GameState.initiated
	
	var musicPlayer: AVAudioPlayer?
	
	lazy var scoreTimer: Timer? = {
		return .scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateScore), userInfo: nil, repeats: true)
	}()
	
		
	@objc func updateScore(){
		self.score = self.score + 1
	}
	
	func pauseGame() {
		scoreTimer?.invalidate()
		musicPlayer?.pause()
		self.gameState = .paused
	}
	func resumeGame() {
		if !(scoreTimer?.isValid ?? true) {
			self.scoreTimer = .scheduledTimer(timeInterval: 1, target: self, selector: #selector(updateScore), userInfo: nil, repeats: true)
		}
		musicPlayer?.play()
		self.gameState = .played
	}
	
	func gameOver() {
		scoreTimer?.invalidate()
		musicPlayer?.stop()
		self.gameState = .defeated
	}
	
	func resetScore() {
		scoreTimer?.invalidate()
		musicPlayer?.stop()
		self.score = 0
		self.gameState = .initiated
	}
	
}

enum GameState {
	case played
	case paused
	case defeated
	case initiated
}

Untuk bagian logic dari game ini, berada pada file GamePLayScene.swift yang berisi rendering, collision logic, movement logic, obstacle spawning, dan juga game phyisics.

Rendering

Pada bagian rendering kita deklarasikan semua node yang dibutuhkan sebagai lazy var agar instance dibuat ketika pertama kali diakses.

// Background
let background = SKSpriteNode(color: UIColor(named: "Color2")!, size: CGSize(width: ScreenSize.width, height: ScreenSize.heigth))

// Player
lazy var player: SKSpriteNode = {
	let head = SKSpriteNode(imageNamed: "Head")
	head.name = "Player"
	return head
}()

// Obstacle
lazy var obstacle: SKSpriteNode = {
	let node = SKSpriteNode(imageNamed: "Virus")
	node.size = CGSize(width: 96, height: 80)
	
	return node
}()

Setelah itu di dalam function didMove kita render node di atas ke dalam SKView.

override func didMove(to view: SKView) {
    viewModel.resumeGame()
    let frame = scene?.frame
    scene?.size = CGSize(width: ScreenSize.width, height: ScreenSize.heigth)
    background.position = CGPoint(x: size.width / 2, y: size.height / 2)
    background.zPosition = 1
    addChild(background)
    initPlayer()
}

func initPlayer() {
    player.position = CGPoint(x: size.width / 2, y: size.height * 0.2)
	player.physicsBody = SKPhysicsBody(circleOfRadius: player.frame.size.width / 2)
    player.zPosition = 5
    addChild(player)
}
		

Untuk bagian obstacle bisa kita lakukan dengan membuat obstacle dengan interval 1.5 detik.

    // inside class declaration
    
	var obstacleTimer: Timer?
    
    func generateObstacle() {
		obstacleTimer = .scheduledTimer(timeInterval: 1.5, target: self, selector: #selector(generateObstacleObject), userInfo: nil, repeats: true)
	}
    
    @objc func generateObstacleObject() {
		if (self.view?.isPaused ?? true) {
			return
		}
		let random = GKRandomDistribution(lowestValue: Int(ScreenSize.width * 0.1), highestValue: Int(ScreenSize.width * 0.9))
		obstacle = .init(imageNamed: "Virus")
		obstacle.name = "Obstacle"
		obstacle.position = CGPoint(x: random.nextInt() , y: Int((view?.frame.height ?? ScreenSize.heigth)))
		obstacle.zPosition = 5
		addChild(obstacle)
		
		let moveAction  = SKAction.moveTo(y: 10, duration: 5)
		let deleteAction = SKAction.removeFromParent()
		let combine = SKAction.sequence([moveAction, deleteAction])
		obstacle.run(combine)
	}

Jangan lupa panggil generateObstacle() setelah initPlayer() di function didMove.
Potongan kode dibawah ini digunakan untuk membuat obstacle jatuh dari atas dan menghilang jika sudah sampai di bawah.

let moveAction  = SKAction.moveTo(y: 10, duration: 5)
let deleteAction = SKAction.removeFromParent()
let combine = SKAction.sequence([moveAction, deleteAction])
obstacle.run(combine)

Player Control

Untuk menggerakkan player dengan menggunakan CoreMotion kita harus mendapatkan data accelerometer dan bereaksi terhadapnya.
Agar bisa bereaksi terhadap gaya, kita harus menerapkan PhysicBody terhadap node yang ada.
Pada function didMove tambahkan kode berikut untuk menerapkan frame pada SKView agar player bisa memantul.

override func didMove(to view:SKView) {
...
self.physicsBody = SKPhysicsBody(edgeLoopFrom: frame!)
...

}

Pada function initPlayer() tambahkan kode berikut.

player.physicsBody = SKPhysicsBody(circleOfRadius: player.frame.size.width / 2)
player.physicsBody?.isDynamic = true
player.physicsBody?.affectedByGravity = false // not affected by gravity so, it doesn't fall
player.physicsBody?.allowsRotation = true // object rotation
player.physicsBody?.restitution = 0.5 // bouncyness

Berikut adalah potongan kode untuk membaca data dari accelerometer dan membuatnya menjadi input untuk pergerakan player.

// deklarasi motionManager
private let motionManager = CMMotionManager()

override func didMove(to view: SKView) {
...
// insiasi motionManager
motionManager.startAccelerometerUpdates()
...
}

override func update(_ currentTime: TimeInterval) {
		if let accelerometerData = motionManager.accelerometerData {
        // Berikan dorongan pada player
			player.physicsBody?.applyImpulse(CGVector(dx: accelerometerData.acceleration.x * 9.8, dy: accelerometerData.acceleration.y * 9.8))
		}
	}

Physics & Collision

Setelah itu kita bisa menerapkan collision dengan menggunakan collision yang ada pada physicsBody. Berikut cara menerapkan collision pada SpriteKit.

Pertama kita buat dahulu kumpulan BitMask yang digunakan untuk identifier collision.

struct CBitMask {
	static let player: UInt32 = 0b1 // 1
	static let obstacle: UInt32 = 0b10 // 2
	static let world: UInt32 = 0b100 //4
}

Setelah itu kita terapkan collision pada physicsBody player dan obstacle.

@objc func generateObstacleObject() {
	...
	obstacle.physicsBody = SKPhysicsBody(rectangleOf: obstacle.size)
	obstacle.physicsBody?.allowsRotation = false
	obstacle.physicsBody?.affectedByGravity = false
	obstacle.physicsBody?.categoryBitMask = CBitMask.obstacle
	obstacle.physicsBody?.contactTestBitMask = CBitMask.player
	obstacle.physicsBody?.collisionBitMask = CBitMask.player
    ...
}

func initPlayer() {
	...
	player.physicsBody?.categoryBitMask = CBitMask.player
	player.physicsBody?.contactTestBitMask = CBitMask.obstacle | CBitMask.world
	player.physicsBody?.collisionBitMask = CBitMask.obstacle | CBitMask.world
    ...
}

Untuk aksi ketika terjadi collision kita harus menambahkan SKPhysicsContactDelegate pada class GamePlayScene, dan mengisi pada function didBegin.

func didBegin(_ contact: SKPhysicsContact) {
	let contactA: SKPhysicsBody
	let contactB: SKPhysicsBody
	if contact.bodyA.categoryBitMask < contact.bodyB.categoryBitMask {
		contactA = contact.bodyA
		contactB = contact.bodyB
	}
	else {
		contactA = contact.bodyB
		contactB = contact.bodyA
	}		
	if (contactA.categoryBitMask == CBitMask.player && contactB.categoryBitMask == CBitMask.obstacle) {
		gameOver()
		
	}
	if (contactA.categoryBitMask == CBitMask.obstacle && contactB.categoryBitMask == CBitMask.player) {
		gameOver()
			
	}
		
}

func gameOver() {
	obstacle.removeFromParent()
	player.removeFromParent()
	obstacleTimer?.invalidate()
	obstacleTimer = nil
	viewModel.gameOver()
		
}

Haptic & Sound Effect

Untuk menambahkan efek Haptic dan suara ketika terjadi collision perlu ditambahkan potongan kode berikut.

// declaration
private lazy var haptic: CHHapticEngine? = {
		return try? CHHapticEngine()
}()

lazy var worldCollisionSound: SKAction =  {
	let audio = SKAction.playSoundFileNamed("WorldCollision", waitForCompletion: false)
	return audio
}()
	
lazy var obstacleCollisionSound: SKAction = {
	let audio = SKAction.playSoundFileNamed("VirusCollision", waitForCompletion: false)
	return audio
}()


func playHaptic(_ intent: Float, _ sharp: Float ) {
	guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
		
	let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: intent)
	let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: sharp)
	let event = CHHapticEvent(eventType: .hapticTransient, parameters: [intensity, sharpness], relativeTime: 0)
		
	do {
		let pattern = try CHHapticPattern(events: [event], parameters: [])
		let player = try haptic?.makePlayer(with: pattern)
		try haptic?.start()
		try player?.start(atTime: 0)
	} catch {
		print("Failed to play pattern: \(error.localizedDescription).")
	}
}

Jangan lupa tambahkan pada pengecekan collision.

    ...
	if (contactA.categoryBitMask == CBitMask.player && contactB.categoryBitMask == CBitMask.world) {		
		playHaptic(0.5, 0.5)
		run(worldCollisionSound)
	}
		
	if (contactA.categoryBitMask == CBitMask.player && contactB.categoryBitMask == CBitMask.obstacle) {			
		playHaptic(1, 1)
		run(obstacleCollisionSound)
		gameOver()		
	}
	if (contactA.categoryBitMask == CBitMask.obstacle && contactB.categoryBitMask == CBitMask.player) {
		playHaptic(1, 1)
		run(obstacleCollisionSound)
		gameOver()
		
	}
    ...
    
override func didMove(to view: SKView) {
    ...
	self.physicsBody?.categoryBitMask = CBitMask.world
	player.physicsBody?.contactTestBitMask = CBitMask.player
	player.physicsBody?.collisionBitMask = CBitMask.player
    ...
	}

Berikut hasil gameplay yang kita buat.

0:00
/
Video GamePlay

Repository

GitHub - adryanev/DodgeVirus: SpriteKit game with SwiftUI
SpriteKit game with SwiftUI. Contribute to adryanev/DodgeVirus development by creating an account on GitHub.

Conclusion

Untuk membuat game pada platform Apple, kita bisa menggunakan Framework yang sudah disediakan seperti: SpriteKit, AVFoundation, CoreMotion, CoreHaptics, dll. Implementasi dan dokumentasi yang cukup memungkinkan kita untuk mengimplementasi framework yang ada dengan baik dan benar.