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.

Berikut adalah aset-aset yang digunakan pada game ini.


Berikut adalah High-Fidelity Prototype dari game ini.


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.
Repository
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.