๐พ SpriteKit
2D ๊ฒ์๊ณผ ์ ๋๋ฉ์ด์ ๋ง๋ค๊ธฐ
iOS 7+Metal ์ต์ ํ
โจ SpriteKit์ด๋?
SpriteKit์ Apple์ 2D ๊ฒ์ ์์ง์ผ๋ก, ์คํ๋ผ์ดํธ ์ ๋๋ฉ์ด์ , ๋ฌผ๋ฆฌ ์๋ฎฌ๋ ์ด์ , ํํฐํด ํจ๊ณผ ๋ฑ์ ์ ๊ณตํฉ๋๋ค. Metal๋ก ๊ฐ์๋์ด ๋์ ์ฑ๋ฅ์ ์ ๊ณตํ๋ฉฐ, SwiftUI์ ํตํฉ์ด ์ฝ์ต๋๋ค.
๐ก ํต์ฌ ๊ธฐ๋ฅ: ์คํ๋ผ์ดํธ ์ ๋๋ฉ์ด์
ยท 2D ๋ฌผ๋ฆฌ ์์ง ยท ํํฐํด ์์คํ
ยท ํ์ผ๋งต ยท ์ค๋์ค ยท ์ก์
์์คํ
ยท ์ฌ ์ ํ ยท ๊ฒ์ ์ปจํธ๋กค๋ฌ ์ง์
๐ฏ 1. ๊ธฐ๋ณธ ์ฌ ์ค์
๊ฒ์ ์ฌ์ ์์ฑํ๊ณ ์คํ๋ผ์ดํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค.
GameScene.swift โ ๊ธฐ๋ณธ ์ฌ
import SpriteKit class GameScene: SKScene { var player: SKSpriteNode! override func didMove(to view: SKView) { // ์ฌ ๋ฐฐ๊ฒฝ์ backgroundColor = .init(white: 0.2, alpha: 1.0) // ํ๋ ์ด์ด ์์ฑ setupPlayer() // ๋ฌผ๋ฆฌ ์๋ ์ค์ physicsWorld.gravity = CGVector(dx: 0, dy: -9.8) physicsWorld.contactDelegate = self } func setupPlayer() { // ์์ ์คํ๋ผ์ดํธ player = SKSpriteNode(color: .blue, size: CGSize(width: 50, height: 50)) player.position = CGPoint(x: frame.midX, y: frame.midY) // ๋๋ ์ด๋ฏธ์ง ์คํ๋ผ์ดํธ // player = SKSpriteNode(imageNamed: "player") // z-์์น (๋ ์ด์ด ์์) player.zPosition = 10 addChild(player) } // ๋งค ํ๋ ์ ์ ๋ฐ์ดํธ override func update(_ currentTime: TimeInterval) { // ๊ฒ์ ๋ก์ง } }
๐ฌ 2. ์ก์ (Actions)
์คํ๋ผ์ดํธ์ ์์ง์๊ณผ ํจ๊ณผ๋ฅผ ์ถ๊ฐํฉ๋๋ค.
Actions.swift โ ์ก์
์์คํ
import SpriteKit extension GameScene { // 1. ์ด๋ ์ก์ func movePlayerTo(_ position: CGPoint) { let moveAction = SKAction.move(to: position, duration: 1.0) player.run(moveAction) } // 2. ํ์ ์ก์ func rotatePlayer() { let rotateAction = SKAction.rotate(byAngle: .CGFloat.pi * 2, duration: 1.0) let repeatAction = SKAction.repeatForever(rotateAction) player.run(repeatAction) } // 3. ์ค์ผ์ผ ์ก์ func pulseEffect(on node: SKNode) { let scaleUp = SKAction.scale(to: 1.2, duration: 0.5) let scaleDown = SKAction.scale(to: 1.0, duration: 0.5) let sequence = SKAction.sequence([scaleUp, scaleDown]) let repeatAction = SKAction.repeatForever(sequence) node.run(repeatAction) } // 4. ํ์ด๋ ํจ๊ณผ func fadeInOut(node: SKNode) { let fadeOut = SKAction.fadeOut(withDuration: 1.0) let fadeIn = SKAction.fadeIn(withDuration: 1.0) let sequence = SKAction.sequence([fadeOut, fadeIn]) node.run(SKAction.repeatForever(sequence)) } // 5. ๋ณตํฉ ์ก์ (๊ทธ๋ฃน) func complexAnimation(node: SKNode) { let move = SKAction.moveBy(x: 100, y: 0, duration: 2.0) let rotate = SKAction.rotate(byAngle: .CGFloat.pi, duration: 2.0) let scale = SKAction.scale(to: 1.5, duration: 2.0) // ๋์์ ์คํ let group = SKAction.group([move, rotate, scale]) node.run(group) } // 6. ์ ๋๋ฉ์ด์ ์คํ๋ผ์ดํธ func setupAnimatedSprite() { // ํ ์ค์ฒ ๋ฐฐ์ด let textures = [ SKTexture(imageNamed: "frame1"), SKTexture(imageNamed: "frame2"), SKTexture(imageNamed: "frame3") ] // ์ ๋๋ฉ์ด์ ์ก์ let animation = SKAction.animate(with: textures, timePerFrame: 0.1) let repeatAnimation = SKAction.repeatForever(animation) let sprite = SKSpriteNode(texture: textures.first) sprite.run(repeatAnimation) addChild(sprite) } // 7. ์๋ฃ ํธ๋ค๋ฌ๊ฐ ์๋ ์ก์ func moveWithCompletion() { let move = SKAction.moveTo(y: 100, duration: 1.0) player.run(move) { print("์ด๋ ์๋ฃ!") } } }
โ๏ธ 3. ๋ฌผ๋ฆฌ ์์ง
์ถฉ๋ ๊ฐ์ง์ ๋ฌผ๋ฆฌ ์๋ฎฌ๋ ์ด์ ์ ๊ตฌํํฉ๋๋ค.
Physics.swift โ ๋ฌผ๋ฆฌ ์๋ฎฌ๋ ์ด์
import SpriteKit // ๋ฌผ๋ฆฌ ์นดํ ๊ณ ๋ฆฌ struct PhysicsCategory { static let none: UInt32 = 0 static let player: UInt32 = 0b1 // 1 static let enemy: UInt32 = 0b10 // 2 static let wall: UInt32 = 0b100 // 4 } extension GameScene { // ๋ฌผ๋ฆฌ ๋ฐ๋ ์ค์ func setupPhysics() { // ํ๋ ์ด์ด ๋ฌผ๋ฆฌ ๋ฐ๋ player.physicsBody = SKPhysicsBody(rectangleOf: player.size) player.physicsBody?.isDynamic = true player.physicsBody?.categoryBitMask = PhysicsCategory.player player.physicsBody?.contactTestBitMask = PhysicsCategory.enemy player.physicsBody?.collisionBitMask = PhysicsCategory.wall // ์ง๋๊ณผ ๋ง์ฐฐ player.physicsBody?.mass = 1.0 player.physicsBody?.friction = 0.2 player.physicsBody?.restitution = 0.5 // ํ์ฑ } // ๋ฒฝ ์์ฑ func createWall(at position: CGPoint, size: CGSize) { let wall = SKSpriteNode(color: .gray, size: size) wall.position = position // ์ ์ ๋ฌผ๋ฆฌ ๋ฐ๋ (์์ง์ด์ง ์์) wall.physicsBody = SKPhysicsBody(rectangleOf: size) wall.physicsBody?.isDynamic = false wall.physicsBody?.categoryBitMask = PhysicsCategory.wall addChild(wall) } // ํ ์ ์ฉ func jumpPlayer() { let impulse = CGVector(dx: 0, dy: 500) player.physicsBody?.applyImpulse(impulse) } // ์๋ ์ค์ func movePlayerRight() { player.physicsBody?.velocity = CGVector(dx: 200, dy: 0) } } // ์ถฉ๋ ๊ฐ์ง ๋ธ๋ฆฌ๊ฒ์ดํธ extension GameScene: SKPhysicsContactDelegate { func didBegin(_ contact: SKPhysicsContact) { let collision = contact.bodyA.categoryBitMask | contact.bodyB.categoryBitMask if collision == PhysicsCategory.player | PhysicsCategory.enemy { print("ํ๋ ์ด์ด์ ์ ์ถฉ๋!") handlePlayerEnemyCollision(contact) } } func handlePlayerEnemyCollision(_ contact: SKPhysicsContact) { // ์ถฉ๋ ์ฒ๋ฆฌ (๊ฒ์ ์ค๋ฒ, ์ ์ ๊ฐ์ ๋ฑ) } }
โจ 4. ํํฐํด ์์คํ
๋ถ, ์ฐ๊ธฐ, ํญ๋ฐ ๋ฑ์ ํํฐํด ํจ๊ณผ๋ฅผ ๋ง๋ญ๋๋ค.
Particles.swift โ ํํฐํด ํจ๊ณผ
import SpriteKit extension GameScene { // 1. ํํฐํด ์ด๋ฏธํฐ ๋ก๋ (.sks ํ์ผ) func addFireEffect(at position: CGPoint) { if let fire = SKEmitterNode(fileNamed: "Fire") { fire.position = position addChild(fire) } } // 2. ์ฝ๋๋ก ํํฐํด ์์ฑ func createExplosion(at position: CGPoint) { let emitter = SKEmitterNode() // ํํฐํด ํ ์ค์ฒ emitter.particleTexture = SKTexture(imageNamed: "spark") // ์์ฑ ์๋ emitter.particleBirthRate = 100 // ์๋ช emitter.particleLifetime = 1.0 // ์๋ emitter.particleSpeed = 200 emitter.particleSpeedRange = 50 // ์์ emitter.particleColor = .orange emitter.particleColorBlendFactor = 1.0 // ํฌ๊ธฐ emitter.particleScale = 0.5 emitter.particleScaleRange = 0.2 emitter.particleScaleSpeed = -0.3 // ์ํ emitter.particleAlpha = 1.0 emitter.particleAlphaSpeed = -1.0 // ๊ฐ๋ emitter.emissionAngle = 0 emitter.emissionAngleRange = .CGFloat.pi * 2 emitter.position = position addChild(emitter) // 1์ด ํ ์ ๊ฑฐ emitter.run(SKAction.sequence([ .wait(forDuration: 1.0), .removeFromParent() ])) } // 3. ํธ๋ ์ผ ํจ๊ณผ func addTrailEffect(to node: SKNode) { if let trail = SKEmitterNode(fileNamed: "Trail") { trail.targetNode = self node.addChild(trail) } } }
๐ฎ 5. ํฐ์น ๋ฐ ์ ๋ ฅ ์ฒ๋ฆฌ
์ฌ์ฉ์ ์ ๋ ฅ์ ์ฒ๋ฆฌํฉ๋๋ค.
Input.swift โ ์
๋ ฅ ์ฒ๋ฆฌ
import SpriteKit extension GameScene { // ํฐ์น ์์ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } let location = touch.location(in: self) // ํฐ์นํ ๋ ธ๋ ์ฐพ๊ธฐ let touchedNodes = nodes(at: location) for node in touchedNodes { if node.name == "button" { handleButtonTap() } else if node == player { jumpPlayer() } } // ๋๋ ํ๋ ์ด์ด๋ฅผ ํฐ์น ์์น๋ก ์ด๋ movePlayerTo(location) } // ํฐ์น ์ด๋ override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) { guard let touch = touches.first else { return } let location = touch.location(in: self) player.position = location } // ํฐ์น ์ข ๋ฃ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { // ํฐ์น ์ข ๋ฃ ์ฒ๋ฆฌ } func handleButtonTap() { print("๋ฒํผ ํญ๋จ") } }
๐ฑ SwiftUI ํตํฉ
GameView.swift โ SwiftUI ํตํฉ
import SwiftUI import SpriteKit struct GameView: View { @State private var score = 0 @State private var isPaused = false var scene: SKScene { let scene = GameScene() scene.size = CGSize(width: 300, height: 400) scene.scaleMode = .aspectFill return scene } var body: some View { ZStack { // SpriteKit ๋ทฐ SpriteView(scene: scene) .ignoresSafeArea() // UI ์ค๋ฒ๋ ์ด VStack { // ์ ์ Text("์ ์: \(score)") .font(.title) .foregroundStyle(.white) .padding() Spacer() // ์ปจํธ๋กค HStack { Button { isPaused.toggle() scene.isPaused = isPaused } label: { Image(systemName: isPaused ? "play.circle" : "pause.circle") .font(.largeTitle) } } .padding() } } } }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ํ๋ ์ ๋ ์ดํธ: 60 FPS ์ ์ง
- ํ ์ค์ฒ ์ํ๋ผ์ค: ํ ์ค์ฒ๋ฅผ ํ๋๋ก ํฉ์ณ ์ฑ๋ฅ ํฅ์
- ๋ ธ๋ ์ฌ์ฌ์ฉ: ์์ฃผ ์์ฑ/์ญ์ ๋๋ ๋ ธ๋๋ ํ ์ฌ์ฉ
- z-Position: ๋ ์ด์ด ์์ ๋ช ํํ ๊ด๋ฆฌ
- ๋ฌผ๋ฆฌ ์ต์ ํ: ๋ถํ์ํ ๋ฌผ๋ฆฌ ๋ฐ๋ ๋นํ์ฑํ
๐ฏ ์ค์ ํ์ฉ
- ์บ์ฃผ์ผ ๊ฒ์: ํผ์ฆ, ํ๋ซํฌ๋จธ, ์ํ ๊ฒ์
- ๊ต์ก ์ฑ: ์ธํฐ๋ํฐ๋ธ ํ์ต ์ฝํ ์ธ
- ๋ฐ์ดํฐ ์๊ฐํ: ์ ๋๋ฉ์ด์ ์ฐจํธ์ ๊ทธ๋ํ
- ๋ฏธ๋ ๊ฒ์: ์ฑ ๋ด ๋ณด๋์ค ๊ฒ์
๐ ๋ ์์๋ณด๊ธฐ
โก๏ธ ์ฑ๋ฅ ํ:
SKView.showsFPS์ showsNodeCount๋ฅผ ํ์ฑํํ์ฌ ๊ฐ๋ฐ ์ค ์ฑ๋ฅ์ ๋ชจ๋ํฐ๋งํ์ธ์. ํ
์ค์ฒ ์ํ๋ผ์ค(.atlas)๋ฅผ ์ฌ์ฉํ๋ฉด ๋๋ก์ฐ ์ฝ์ด ์ค์ด ์ฑ๋ฅ์ด ํฌ๊ฒ ํฅ์๋ฉ๋๋ค.