๐Ÿ‘พ 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 ๊ฐ€์ด๋“œ๋ผ์ธ

๐ŸŽฏ ์‹ค์ „ ํ™œ์šฉ

๐Ÿ“š ๋” ์•Œ์•„๋ณด๊ธฐ

โšก๏ธ ์„ฑ๋Šฅ ํŒ: SKView.showsFPS์™€ showsNodeCount๋ฅผ ํ™œ์„ฑํ™”ํ•˜์—ฌ ๊ฐœ๋ฐœ ์ค‘ ์„ฑ๋Šฅ์„ ๋ชจ๋‹ˆํ„ฐ๋งํ•˜์„ธ์š”. ํ…์Šค์ฒ˜ ์•„ํ‹€๋ผ์Šค(.atlas)๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋“œ๋กœ์šฐ ์ฝœ์ด ์ค„์–ด ์„ฑ๋Šฅ์ด ํฌ๊ฒŒ ํ–ฅ์ƒ๋ฉ๋‹ˆ๋‹ค.