Xcode의 iOS App 템플릿에 SwiftUI로 세로 스크롤 원자 게임을 붙이는 순서

같은 규칙의 안드로이드와 맥 버전은 「Atom walk」에 있다. 이 글은 그 게임을 iPhone의 SwiftUI로 만든 것이다. 주인공은 파란 핵, 그 주위를 도는 전자, 위를 향한 총으로 그린 원자다. 만화 주인공의 이름과 복장은 넣지 않는다.

게임이 하는 일

화면 아래의 원자는 저절로 앞으로 걷는다. 길 표시가 아래로 흘러 세로로 올라가는 것처럼 보인다. 좌우와 조금의 위아래만 움직이고, 걷는 동안 총은 0.16초마다 위쪽으로 나간다. 위에서 내려오는 덩어리에 맞으면 생명이 줄고, 세 번이면 그 판은 끝이다. 맞힌 덩어리마다 10점이다. 점수가 80을 넘으면 덩어리의 생명이 2가 되어 두 발을 맞아야 사라진다.

발 딛는 범위는 가로 10%에서 90%, 세로 62%에서 90%다. 맞는 순간 1.1초 동안 원자가 깜빡이며 그 사이에는 추가 피격을 받지 않는다.

프로젝트를 만든다

Xcode는 Mac App Store 또는 developer.apple.com/xcode 에서 받아 설치한다.

File → New → Project에서 iOS의 App을 고른다. 테스트 포함은 끄고, 저장 위치는 개발자 폴더로 둔다.

항목값
Product NameAtomWalk
Organization Identifierkr.thinktree
InterfaceSwiftUI
LanguageSwift
StorageNone
Minimum DeploymentsiOS 17.0

기기를 돌려도 판이 다시 만들어지지 않게 세로만 남긴다. 대상 AtomWalk를 고르고 General → Deployment Info에서 iPhone Orientation의 Portrait만 켠다.

첫 화면을 게임으로 바꾼다

ContentView.swift를 열고 파일 전체를 아래로 바꾼다. AtomWalkApp.swift의 WindowGroup은 마법사가 넣어 둔 ContentView()를 그대로 둔다. 이 템플릿에는 패키지 선언이 없다. 파일 이름과 구조체 이름만 맞으면 된다.

import SwiftUI
import UIKit

struct ContentView: View {
    var body: some View {
        AtomGame()
    }
}

private struct Mote {
    var x: CGFloat
    var y: CGFloat
    var hp: Int
    var alive = true
}

private struct Bolt {
    var x: CGFloat
    var y: CGFloat
    var alive = true
}

private final class AtomWalk {
    var playerX: CGFloat = 0.5
    var playerY: CGFloat = 0.78
    var time: CGFloat = 0
    var scroll: CGFloat = 0
    var score = 0
    var lives = 3
    var running = true
    var holdLeft = false
    var holdRight = false
    var holdUp = false
    var holdDown = false
    var bolts: [Bolt] = []
    var motes: [Mote] = []
    private var fire: CGFloat = 0
    private var spawn: CGFloat = 1.2
    private var invuln: CGFloat = 0

    func update(dt: CGFloat) {
        guard running else { return }
        time += dt
        scroll += dt * 90
        let step = dt * 0.55
        if holdLeft { playerX -= step }
        if holdRight { playerX += step }
        if holdUp { playerY -= step }
        if holdDown { playerY += step }
        playerX = min(0.9, max(0.1, playerX))
        playerY = min(0.9, max(0.62, playerY))
        fire -= dt
        if fire <= 0 {
            fire = 0.16
            bolts.append(Bolt(x: playerX, y: playerY - 0.06))
        }
        spawn -= dt
        let gap = max(0.32, 0.85 - CGFloat(score) * 0.004)
        if spawn <= 0 {
            spawn = gap
            motes.append(
                Mote(
                    x: CGFloat.random(in: 0..<1) * 0.76 + 0.12,
                    y: -0.08,
                    hp: score > 80 ? 2 : 1
                )
            )
        }
        for index in bolts.indices {
            bolts[index].y -= dt * 0.95
        }
        let fall = dt * (0.2 + CGFloat(min(score, 150)) * 0.0012)
        for index in motes.indices {
            motes[index].y += fall
        }
        for boltIndex in bolts.indices {
            guard bolts[boltIndex].alive else { continue }
            guard let moteIndex = motes.firstIndex(where: { mote in
                mote.alive && hypot(mote.x - bolts[boltIndex].x, mote.y - bolts[boltIndex].y) < 0.05
            }) else { continue }
            bolts[boltIndex].alive = false
            motes[moteIndex].hp -= 1
            if motes[moteIndex].hp <= 0 {
                motes[moteIndex].alive = false
                score += 10
            }
        }
        if invuln > 0 { invuln -= dt }
        for index in motes.indices {
            guard motes[index].alive, invuln <= 0 else { continue }
            if hypot(motes[index].x - playerX, motes[index].y - playerY) < 0.07 {
                motes[index].alive = false
                lives -= 1
                invuln = 1.1
            }
        }
        bolts.removeAll { !$0.alive || $0.y < -0.1 }
        motes.removeAll { !$0.alive || $0.y > 1.08 }
        if lives <= 0 { running = false }
    }

    var hidden: Bool {
        invuln > 0 && Int(time * 12) % 2 == 0
    }
}

private struct AtomGame: View {
    @State private var game = AtomWalk()
    @State private var runID = 0
    @FocusState private var focused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Playfield(game: game, runID: runID)
            HStack(spacing: 8) {
                HoldPad(title: "왼쪽") { game.holdLeft = $0 }
                HoldPad(title: "오른쪽") { game.holdRight = $0 }
            }
            HStack(spacing: 8) {
                HoldPad(title: "위") { game.holdUp = $0 }
                HoldPad(title: "아래") { game.holdDown = $0 }
            }
            Button("다시") {
                game = AtomWalk()
                runID += 1
            }
            .font(.headline)
            .frame(maxWidth: .infinity)
            .frame(height: 44)
            .buttonStyle(.borderedProminent)
        }
        .padding(12)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .background(rgb(0x0B1020).ignoresSafeArea())
        .focusable()
        .focused($focused)
        .onAppear { focused = true }
        .onKeyPress(phases: [.down, .up]) { press in
            let down = press.phase == .down
            switch press.key {
            case .leftArrow:
                game.holdLeft = down
            case .rightArrow:
                game.holdRight = down
            case .upArrow:
                game.holdUp = down
            case .downArrow:
                game.holdDown = down
            default:
                switch press.characters.lowercased() {
                case "a": game.holdLeft = down
                case "d": game.holdRight = down
                case "w": game.holdUp = down
                case "s": game.holdDown = down
                default: return .ignored
                }
            }
            return .handled
        }
    }
}

private struct Playfield: View {
    let game: AtomWalk
    let runID: Int
    @State private var frame = 0

    var body: some View {
        let _ = frame
        VStack(alignment: .leading, spacing: 8) {
            Text("점수 \(game.score)   생명 \(game.lives)")
                .font(.system(size: 18, weight: .bold))
                .foregroundStyle(.white)
            GeometryReader { proxy in
                Canvas { context, size in
                    drawScene(context: context, size: size)
                }
                .frame(width: proxy.size.width, height: proxy.size.height)
                .background(rgb(0x12182B))
                .overlay(DragSurface(game: game))
            }
            Text(game.running ? "드래그하거나 방향 버튼으로 피하고, 총은 걷는 동안 알아서 나갑니다." : "세 번 맞았습니다.")
                .font(.system(size: 14))
                .foregroundStyle(.white.opacity(0.8))
        }
        .task(id: runID) {
            let current = game
            while !Task.isCancelled && current.running {
                try? await Task.sleep(nanoseconds: 16_000_000)
                if Task.isCancelled || !current.running { break }
                current.update(dt: 0.016)
                frame += 1
            }
        }
    }

    private func drawScene(context: GraphicsContext, size: CGSize) {
        let stripe: CGFloat = 48
        var mark = -stripe + game.scroll.truncatingRemainder(dividingBy: stripe)
        while mark < size.height {
            let rect = CGRect(x: size.width * 0.47, y: mark, width: size.width * 0.06, height: stripe * 0.4)
            context.fill(Path(rect), with: .color(.white.opacity(0.08)))
            mark += stripe
        }
        func at(_ x: CGFloat, _ y: CGFloat) -> CGPoint {
            CGPoint(x: x * size.width, y: y * size.height)
        }
        let unit = min(size.width, size.height)
        for mote in game.motes where mote.alive {
            fillCircle(context, rgb(0xFB7185), unit * 0.035, at(mote.x, mote.y))
        }
        for bolt in game.bolts where bolt.alive {
            fillCircle(context, rgb(0xFDE68A), unit * 0.012, at(bolt.x, bolt.y))
        }
        if !game.hidden {
            drawWalker(context: context, center: at(game.playerX, game.playerY), radius: unit * 0.055, time: game.time)
        }
    }
}

private struct DragSurface: UIViewRepresentable {
    let game: AtomWalk

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.pan(_:)))
        view.addGestureRecognizer(pan)
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        context.coordinator.game = game
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(game: game)
    }

    final class Coordinator: NSObject {
        var game: AtomWalk
        var lastX: CGFloat = 0
        var lastY: CGFloat = 0

        init(game: AtomWalk) {
            self.game = game
        }

        @objc func pan(_ gesture: UIPanGestureRecognizer) {
            guard let view = gesture.view, view.bounds.width > 0, view.bounds.height > 0 else { return }
            let point = gesture.translation(in: view)
            let dx = point.x / view.bounds.width
            let dy = point.y / view.bounds.height
            if gesture.state == .began || gesture.state == .changed {
                game.playerX = min(0.9, max(0.1, game.playerX + dx - lastX))
                game.playerY = min(0.9, max(0.62, game.playerY + dy - lastY))
                lastX = dx
                lastY = dy
            }
            if gesture.state == .ended || gesture.state == .cancelled || gesture.state == .failed {
                lastX = 0
                lastY = 0
            }
        }
    }
}

private struct HoldPad: View {
    let title: String
    let press: (Bool) -> Void

    var body: some View {
        Text(title)
            .font(.headline)
            .foregroundStyle(.white)
            .frame(maxWidth: .infinity)
            .frame(height: 44)
            .background(Color.white.opacity(0.14))
            .clipShape(RoundedRectangle(cornerRadius: 8))
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { _ in press(true) }
                    .onEnded { _ in press(false) }
            )
    }
}

private func drawWalker(context: GraphicsContext, center: CGPoint, radius: CGFloat, time: CGFloat) {
    let bob = sin(time * 10) * radius * 0.12
    let swing = sin(time * 10) * radius * 0.45
    let body = CGPoint(x: center.x, y: center.y + bob)
    stroke(
        context,
        from: CGPoint(x: body.x - radius * 0.25, y: body.y + radius * 0.55),
        to: CGPoint(x: body.x - radius * 0.55 + swing, y: body.y + radius * 1.25),
        color: rgb(0xE2E8F0),
        width: 4
    )
    stroke(
        context,
        from: CGPoint(x: body.x + radius * 0.25, y: body.y + radius * 0.55),
        to: CGPoint(x: body.x + radius * 0.55 - swing, y: body.y + radius * 1.25),
        color: rgb(0xE2E8F0),
        width: 4
    )
    stroke(
        context,
        from: CGPoint(x: body.x + radius * 0.15, y: body.y - radius * 0.05),
        to: CGPoint(x: body.x + radius * 0.15, y: body.y - radius * 1.15),
        color: rgb(0x94A3B8),
        width: 5
    )
    fillCircle(context, rgb(0x38BDF8), radius * 0.55, body)
    fillCircle(context, rgb(0xE0F2FE), radius * 0.2, body)
    let angle = time * 3.2
    fillCircle(
        context,
        rgb(0xFDE68A),
        radius * 0.12,
        CGPoint(x: body.x + cos(angle) * radius, y: body.y + sin(angle) * radius * 0.42)
    )
    fillCircle(
        context,
        rgb(0xFDBA74),
        radius * 0.1,
        CGPoint(x: body.x + cos(angle + 3.1) * radius * 0.85, y: body.y + sin(angle + 3.1) * radius)
    )
}

private func fillCircle(_ context: GraphicsContext, _ color: Color, _ radius: CGFloat, _ center: CGPoint) {
    let rect = CGRect(x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2)
    context.fill(Path(ellipseIn: rect), with: .color(color))
}

private func stroke(_ context: GraphicsContext, from: CGPoint, to: CGPoint, color: Color, width: CGFloat) {
    var path = Path()
    path.move(to: from)
    path.addLine(to: to)
    context.stroke(path, with: .color(color), lineWidth: width)
}

private func rgb(_ hex: UInt32) -> Color {
    Color(
        red: Double((hex >> 16) & 0xFF) / 255,
        green: Double((hex >> 8) & 0xFF) / 255,
        blue: Double(hex & 0xFF) / 255
    )
}

#Preview {
    ContentView()
}

점수 글자 안의 역슬래시와 괄호는 Swift 문자열 보간이다. 붙여 넣을 때 그 역슬래시를 지우지 않는다.

화면이 그리는 것

파란 원이 핵이고, 그 안의 작은 원이 핵의 중심이다. 노란 점 두 개는 서로 다른 궤도로 도는 전자다. 다리는 걸음에 맞춰 흔들리고, 오른쪽의 회색 선은 위쪽을 향한 총이다. 길 한가운데의 흰 조각이 아래로 움직여 앞으로 걷는 느낌을 만든다. 붉은 원은 적이고, 노란 작은 점은 탄이다.

조작하는 일
드래그발 딛는 위치를 바꿈
왼쪽, 오른쪽, 위, 아래누르고 있는 동안 그 방향으로 이동
방향키, W A S D시뮬레이터에서 이동. 화면을 한 번 눌러 포커스를 둔다
다시점수와 생명을 처음으로 되돌림

총은 버튼 없이 걷는 동안 나간다. 다시를 누르면 같은 규칙으로 새 판이 시작된다.

시뮬레이터와 iPhone에서 실행한다

창 위쪽 Scheme에서 iPhone 시뮬레이터를 고르고 Run을 누른다. 빌드가 끝나면 그 기기에 앱이 켜진다.

내 iPhone에서 보려면 케이블로 연결하고, 대상 AtomWalk의 Signing & Capabilities에서 Automatically manage signing을 켠 뒤 Team에 본인 Apple ID를 고른다. 기기 목록에 폰이 보이면 같은 Run으로 설치된다. 폰에서 개발자 모드를 묻으면 설정의 안내에 따른다. Apple ID 암호, 인증서, 프로비저닝 파일은 프로젝트 소스에 적지 않는다.

빌드가 실패하면 ContentView.swift 안의 ContentView가 하나인지 먼저 본다. 대상 버전이 iOS 17보다 낮으면 onKeyPress에서 빨간 줄이 난다. Minimum Deployments를 iOS 17.0으로 올린다.

스토어에 낼 때

디버그 실행만으로도 게임은 완성이다. App Store에 올리려면 Apple Developer Program에 가입한 계정으로 Product → Archive를 실행하고, Organizer에서 App Store Connect로 배포한다. 스토어 이름과 아이콘에는 만화 주인공의 얼굴과 이름을 쓰지 않는다. 화면에 있는 것은 핵과 전자로 그린 원자다. 앱 이름은 아톰워크처럼 이 게임의 이름으로 둔다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.