Xcode의 iOS App 템플릿에 SwiftUI로 인베이더를 붙이는 순서

규칙은 Qt로 만든 인베이더 엔진과 같다. 그 엔진을 설명하는 글은 「인베이더 게임으로 배우는 휴리스틱과 AI 브리지」다. 이 글은 그 플레이 부분만 폰에서 돌리는 순서다. 휴리스틱과 VLM 브리지는 그 글에 그대로 둔다.

판이 돌아가는 방식

세계는 가로 480, 세로 640이다. 화면은 그 비율을 유지한 채 폰에 맞춘다. 적은 11열 6줄, 모두 66마리다. 한 마리를 맞히면 10점이다. 맨 아래 줄만 탄을 쏜다. 적이 줄면 걸음이 빨라진다. 66마리일 때 10틱, 36마리 이하이면 8틱, 18마리 이하이면 6틱마다 6픽셀을 움직인다. 벽에 닿으면 방향을 바꾸고 14픽셀 내려온다. 플레이어 머리까지 내려오거나 적의 탄에 맞으면 그 판은 끝이다.

플레이어 탄은 한 번에 5발까지다. 위를 지나가는 분홍 비행체를 플레이어 탄으로 맞히면 100점에서 299점 사이가 더해지고, 동시에 쏠 수 있는 탄이 2발 늘어난다. 적의 탄이 그 비행체를 맞히면 30점만 오르고 탄 수는 그대로다. 서로 다른 편의 탄이 부딪히면 둘 다 사라진다.

방어막은 여섯 채다. 지붕과 굴뚝, 문과 창이 뚫린 집 모양이다. 탄이 남은 픽셀에 맞으면 그 자리의 반지름 7픽셀이 지워지고 탄은 사라진다. 이미 빈 구멍으로는 탄이 지나간다. 적이 방어막에 겹치면 위쪽부터 한 틱에 네 줄까지 지워진다.

시작과 웨이브를 클리어한 뒤에는 10초를 센다. 그 동안에는 좌우로만 움직이고 발사는 되지 않는다. 적이 모두 사라지면 점수와 탄 상한은 유지한 채 적과 방어막만 다시 놓인다.

원 엔진은 한 틱에 키를 하나만 받는다. 폰에서는 이동 버튼을 누른 채로 발사할 수 있다. 틱은 48밀리초다.

안드로이드용 같은 게임은 「안드로이드 스튜디오로 인베이더 만드는 방법」에 있다.

프로젝트를 만든다

Xcode는 Mac App Store 또는 developer.apple.com/xcode 에서 받아 설치한다. File → New → Project에서 iOS의 App을 고른다. 테스트 포함은 끄고, 저장 위치는 개발자 폴더로 둔다.

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

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

첫 화면을 게임으로 바꾼다

ContentView.swift를 열고 파일 전체를 아래로 바꾼다. InvaderApp.swift의 WindowGroup은 마법사가 넣어 둔 ContentView()를 그대로 둔다.

import SwiftUI

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

private let worldW = 480
private let worldH = 640
private let hudH = 54
private let playTop = hudH
private let playBottom = worldH - 16
private let intermissionTicks = (10 * 1000 + 47) / 48

private enum Phase { case playing, intermission, lost }

private final class Box {
    var x: Int
    var y: Int
    let w: Int
    let h: Int
    init(_ x: Int, _ y: Int, _ w: Int, _ h: Int) {
        self.x = x
        self.y = y
        self.w = w
        self.h = h
    }
    var right: Int { x + w }
    var bottom: Int { y + h }
    var centerX: Int { x + w / 2 }
    var centerY: Int { y + h / 2 }
    func intersects(_ o: Box) -> Bool {
        x < o.right && right > o.x && y < o.bottom && bottom > o.y
    }
}

private final class Shot {
    let box: Box
    let vy: Int
    let fromPlayer: Bool
    init(_ box: Box, _ vy: Int, _ fromPlayer: Bool) {
        self.box = box
        self.vy = vy
        self.fromPlayer = fromPlayer
    }
}

private final class Bunker {
    let x: Int
    let y: Int
    let w = 58
    let h = 42
    var solid: [[Bool]]

    init(_ x: Int, _ y: Int) {
        self.x = x
        self.y = y
        solid = Array(repeating: Array(repeating: false, count: 58), count: 42)
        carve()
    }

    func rect() -> Box { Box(x, y, w, h) }

    func hits(_ shot: Box) -> Bool {
        guard let area = overlap(rect(), shot) else { return false }
        for yy in area.y..<area.bottom {
            for xx in area.x..<area.right where solid[yy - y][xx - x] { return true }
        }
        return false
    }

    func bite(_ shot: Box) {
        guard let area = overlap(rect(), shot) else { return }
        var px = area.centerX
        var py = area.centerY
        for yy in area.y..<area.bottom {
            for xx in area.x..<area.right where solid[yy - y][xx - x] {
                px = xx
                py = yy
            }
        }
        let radius = 7
        for yy in (py - radius)...(py + radius) {
            for xx in (px - radius)...(px + radius) {
                let dx = xx - px
                let dy = yy - py
                if dx * dx + dy * dy <= radius * radius { _ = erase(xx, yy) }
            }
        }
    }

    func chew(_ alien: Box) {
        guard let area = overlap(rect(), alien) else { return }
        var rows = 0
        var yy = area.y
        while yy < area.bottom && rows < 4 {
            var any = false
            for xx in area.x..<area.right where erase(xx, yy) { any = true }
            if any { rows += 1 }
            yy += 1
        }
    }

    private func erase(_ xx: Int, _ yy: Int) -> Bool {
        let lx = xx - x
        let ly = yy - y
        guard ly >= 0, ly < h, lx >= 0, lx < w, solid[ly][lx] else { return false }
        solid[ly][lx] = false
        return true
    }

    private func carve() {
        fill(w - 15, 0, 8, 16, true)
        for i in 0..<10 { fill(i * 2, i * 2, w - 4 * i, 2, true) }
        fill(0, 20, w, h - 20, true)
        fill(w / 2 - 7, h - 17, 14, 17, false)
        fill(9, 27, 10, 9, false)
        fill(w - 19, 27, 10, 9, false)
    }

    private func fill(_ left: Int, _ top: Int, _ width: Int, _ height: Int, _ on: Bool) {
        for yy in top..<(top + height) where yy >= 0 && yy < h {
            for xx in left..<(left + width) where xx >= 0 && xx < w {
                solid[yy][xx] = on
            }
        }
    }
}

private func overlap(_ a: Box, _ b: Box) -> Box? {
    let x = max(a.x, b.x)
    let y = max(a.y, b.y)
    let right = min(a.right, b.right)
    let bottom = min(a.bottom, b.bottom)
    if right <= x || bottom <= y { return nil }
    return Box(x, y, right - x, bottom - y)
}

@MainActor
private final class InvaderGame {
    var phase = Phase.intermission
    var tick = 0
    var score = 0
    var maxBullets = 5
    var intermissionLeft = intermissionTicks
    let player = Box(worldW / 2 - 17, playBottom - 14, 34, 14)
    var aliens: [Box] = []
    var bullets: [Shot] = []
    var bunkers: [Bunker] = []
    var alienDir = 1
    var alienStepEvery = 10
    var alienShootIn = 16
    var ufo: Box?
    var ufoVx = 3
    var ufoSpawnIn = 320 + Int.random(in: 0..<480)
    var holdLeft = false
    var holdRight = false
    var holdFire = false

    init() {
        placeAliens()
        placeBunkers()
    }

    func seconds() -> Int {
        guard phase == .intermission, intermissionLeft > 0 else { return 0 }
        return max(1, (intermissionLeft * 48 + 999) / 1000)
    }

    func tick(move: Int, fire: Bool) {
        guard phase != .lost else { return }
        tick += 1
        if phase == .intermission {
            slide(move)
            if intermissionLeft > 0 { intermissionLeft -= 1 }
            if intermissionLeft <= 0 { phase = .playing }
            return
        }
        stepUfo()
        slide(move)
        if fire { shoot() }
        stepBullets()
        resolve()
        guard phase == .playing else { return }
        if tick % alienStepEvery == 0 {
            alienStepEvery = aliens.count <= 18 ? 6 : aliens.count <= 36 ? 8 : 10
            stepAliens()
            if lostByTouch() { return }
        }
        alienShoot()
        resolve()
        for alien in aliens {
            for bunker in bunkers { bunker.chew(alien) }
        }
        _ = lostByTouch()
    }

    private func slide(_ move: Int) {
        player.x += move * 7
        if player.x < 8 { player.x = 8 }
        if player.right > worldW - 8 { player.x = worldW - 8 - player.w }
    }

    private func shoot() {
        guard bullets.filter(\.fromPlayer).count < maxBullets else { return }
        bullets.append(Shot(Box(player.centerX - 2, player.y - 12, 4, 10), -12, true))
    }

    private func stepBullets() {
        var next: [Shot] = []
        for shot in bullets {
            shot.box.y += shot.vy
            if shot.box.bottom < playTop || shot.box.y > playBottom { continue }
            next.append(shot)
        }
        bullets = next
    }

    private func stepAliens() {
        guard !aliens.isEmpty else { return }
        let hitWall = aliens.contains { alien in
            let nx = alien.x + alienDir * 6
            return nx < 8 || nx + alien.w > worldW - 8
        }
        if hitWall {
            alienDir = -alienDir
            for alien in aliens { alien.y += 14 }
        } else {
            for alien in aliens { alien.x += alienDir * 6 }
        }
    }

    private func alienShoot() {
        guard !aliens.isEmpty else { return }
        alienShootIn -= 1
        guard alienShootIn <= 0 else { return }
        alienShootIn = 10 + Int.random(in: 0..<18)
        let bottoms = aliens.filter { alien in
            !aliens.contains { other in other.x == alien.x && other.y > alien.y }
        }
        let pick = (bottoms.isEmpty ? aliens : bottoms).randomElement()!
        bullets.append(Shot(Box(pick.centerX - 2, pick.bottom + 2, 4, 10), 10, false))
    }

    private func stepUfo() {
        if let flying = ufo {
            flying.x += ufoVx
            if flying.right < -14 || flying.x > worldW + 14 { ufo = nil }
            return
        }
        ufoSpawnIn -= 1
        guard ufoSpawnIn <= 0 else { return }
        ufoSpawnIn = 360 + Int.random(in: 0..<560)
        ufoVx = Int.random(in: 0..<2) == 1 ? 3 : -3
        ufo = ufoVx > 0 ? Box(-52, playTop + 5, 42, 20) : Box(worldW + 10, playTop + 5, 42, 20)
    }

    private func resolve() {
        var dead = Array(repeating: false, count: bullets.count)
        for i in bullets.indices {
            for j in (i + 1)..<bullets.count {
                if dead[i] || dead[j] || bullets[i].fromPlayer == bullets[j].fromPlayer { continue }
                if bullets[i].box.intersects(bullets[j].box) {
                    dead[i] = true
                    dead[j] = true
                }
            }
        }
        var kept: [Shot] = []
        for i in bullets.indices {
            if dead[i] { continue }
            let shot = bullets[i]
            if let bunker = bunkers.first(where: { $0.hits(shot.box) }) {
                bunker.bite(shot.box)
                continue
            }
            if shot.fromPlayer {
                if let alien = aliens.firstIndex(where: { $0.intersects(shot.box) }) {
                    aliens.remove(at: alien)
                    score += 10
                    continue
                }
                if let flying = ufo, flying.intersects(shot.box) {
                    score += 100 + Int.random(in: 0..<200)
                    maxBullets += 2
                    ufo = nil
                    continue
                }
            } else {
                if let flying = ufo, flying.intersects(shot.box) {
                    score += 30
                    ufo = nil
                    continue
                }
                if shot.box.intersects(player) {
                    phase = .lost
                    continue
                }
            }
            kept.append(shot)
        }
        bullets = kept
        if phase == .playing && aliens.isEmpty { nextWave() }
    }

    private func lostByTouch() -> Bool {
        guard phase == .playing else { return true }
        if aliens.contains(where: { $0.bottom >= player.y - 2 }) {
            phase = .lost
            return true
        }
        return false
    }

    private func nextWave() {
        placeAliens()
        placeBunkers()
        bullets.removeAll()
        ufo = nil
        ufoSpawnIn = 320 + Int.random(in: 0..<480)
        alienDir = 1
        alienStepEvery = 10
        alienShootIn = 16
        phase = .intermission
        intermissionLeft = intermissionTicks
    }

    private func placeAliens() {
        aliens.removeAll()
        for row in 0..<6 {
            for col in 0..<11 {
                aliens.append(Box(40 + col * 34, playTop + 22 + row * 26, 22, 16))
            }
        }
    }

    private func placeBunkers() {
        bunkers.removeAll()
        let maskW = 58
        let pad = 18
        let gap = (worldW - pad * 2 - 6 * maskW) / 5
        let y = playBottom - 110
        for i in 0..<6 {
            bunkers.append(Bunker(pad + i * (maskW + gap), y))
        }
    }
}

private struct InvaderScreen: View {
    @State private var game = InvaderGame()
    @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.holdFire = $0 }
                HoldPad(title: "오른쪽") { game.holdRight = $0 }
            }
            Button("다시") {
                game = InvaderGame()
                runID += 1
            }
            .font(.headline)
            .frame(maxWidth: .infinity)
            .frame(height: 44)
            .buttonStyle(.borderedProminent)
        }
        .padding(12)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .background(rgb(0x0A0C1C).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, .space: game.holdFire = down
            default:
                switch press.characters.lowercased() {
                case "a": game.holdLeft = down
                case "d": game.holdRight = down
                case "w": game.holdFire = down
                default: return .ignored
                }
            }
            return .handled
        }
    }
}

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

    var body: some View {
        let _ = frame
        VStack(alignment: .leading, spacing: 8) {
            Text("점수 \(game.score)   탄 \(game.maxBullets)")
                .font(.system(size: 18, weight: .bold))
                .foregroundStyle(.white)
            ZStack {
                Canvas { context, size in
                    drawWorld(context: context, size: size)
                }
                .background(rgb(0x101426))
                if game.phase == .intermission && game.seconds() > 0 {
                    Text("\(game.seconds())")
                        .font(.system(size: 96, weight: .bold))
                        .foregroundStyle(rgb(0xFFEB3C))
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            if game.phase == .lost {
                Text("적이 닿았거나 탄에 맞았습니다.")
                    .foregroundStyle(.white)
            }
        }
        .task(id: runID) {
            let current = game
            while !Task.isCancelled && current.phase != .lost {
                try? await Task.sleep(nanoseconds: 48_000_000)
                if Task.isCancelled || current.phase == .lost { break }
                let move = current.holdLeft ? -1 : current.holdRight ? 1 : 0
                current.tick(move: move, fire: current.holdFire)
                frame += 1
            }
        }
    }

    private func drawWorld(context: GraphicsContext, size: CGSize) {
        let scale = min(size.width / CGFloat(worldW), size.height / CGFloat(worldH))
        let ox = (size.width - CGFloat(worldW) * scale) / 2
        let oy = (size.height - CGFloat(worldH) * scale) / 2
        func px(_ v: Int) -> CGFloat { ox + CGFloat(v) * scale }
        func py(_ v: Int) -> CGFloat { oy + CGFloat(v) * scale }
        var line = Path()
        line.move(to: CGPoint(x: ox, y: py(hudH)))
        line.addLine(to: CGPoint(x: ox + CGFloat(worldW) * scale, y: py(hudH)))
        context.stroke(line, with: .color(rgb(0x28325A)), lineWidth: 1)
        for bunker in game.bunkers {
            for y in 0..<bunker.h {
                var x = 0
                while x < bunker.w {
                    if !bunker.solid[y][x] {
                        x += 1
                        continue
                    }
                    let start = x
                    while x < bunker.w && bunker.solid[y][x] { x += 1 }
                    let color = y < 16 && start >= bunker.w - 15 ? rgb(0x60606E) : y < 20 ? rgb(0xBC4E44) : rgb(0x3AA852)
                    let rect = CGRect(x: px(bunker.x + start), y: py(bunker.y + y), width: CGFloat(x - start) * scale, height: scale)
                    context.fill(Path(rect), with: .color(color))
                }
            }
        }
        let phase = (game.tick / 8) % 2
        for alien in game.aliens {
            fill(context, rgb(0x4EDC7D), px(alien.x), py(alien.y), CGFloat(alien.w) * scale, CGFloat(alien.h) * scale)
            fill(context, rgb(0x143C23), px(alien.x + 4), py(alien.y + 3), 5 * scale, 5 * scale)
            fill(context, rgb(0x143C23), px(alien.right - 8), py(alien.y + 3), 5 * scale, 5 * scale)
            let skew = phase == 0 ? 0 : 3
            fill(context, rgb(0x268248), px(alien.x + 2 + skew), py(alien.bottom - 4), 6 * scale, 4 * scale)
            fill(context, rgb(0x268248), px(alien.right - 7 - skew), py(alien.bottom - 4), 6 * scale, 4 * scale)
        }
        if let ufo = game.ufo {
            let rect = CGRect(x: px(ufo.x), y: py(ufo.y), width: CGFloat(ufo.w) * scale, height: CGFloat(ufo.h) * scale)
            context.fill(Path(ellipseIn: rect), with: .color(rgb(0xAA3764)))
        }
        for shot in game.bullets {
            let color = shot.fromPlayer ? rgb(0xFFE650) : rgb(0x78C8FF)
            fill(context, color, px(shot.box.x), py(shot.box.y), CGFloat(shot.box.w) * scale, CGFloat(shot.box.h) * scale)
        }
        fill(context, rgb(0xB4B4DC), px(game.player.centerX - 4), py(game.player.y - 6), 8 * scale, 6 * scale)
        fill(context, rgb(0xDCDCF0), px(game.player.x), py(game.player.y), CGFloat(game.player.w) * scale, CGFloat(game.player.h) * scale)
    }
}

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))
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { _ in press(true) }
                    .onEnded { _ in press(false) }
            )
    }
}

private func fill(_ context: GraphicsContext, _ color: Color, _ x: CGFloat, _ y: CGFloat, _ w: CGFloat, _ h: CGFloat) {
    context.fill(Path(CGRect(x: x, y: y, width: w, height: h)), with: .color(color))
}

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 문자열 보간이다. 붙여 넣을 때 그 역슬래시를 지우지 않는다.

조작하는 일
왼쪽, 오른쪽누르고 있는 동안 이동
발사누르고 있는 동안 탄을 쏨. 동시에 낼 수 있는 수까지만
다시점수와 판을 처음으로 되돌림
← →, A D시뮬레이터에서 이동. 화면을 한 번 눌러 포커스를 둔다
Space, ↑, W시뮬레이터에서 발사

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

창 위쪽 Scheme에서 iPhone 시뮬레이터를 고르고 Run을 누른다. 내 iPhone에서 보려면 케이블로 연결하고, 대상 Invader의 Signing & Capabilities에서 Automatically manage signing을 켠 뒤 Team에 본인 Apple ID를 고른다. 폰에서 개발자 모드를 묻으면 설정의 안내에 따른다. Apple ID 암호, 인증서, 프로비저닝 파일은 프로젝트 소스에 적지 않는다.

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

스토어에 낼 때

디버그 실행만으로도 게임은 완성이다. App Store에 올리려면 Apple Developer Program에 가입한 계정으로 Product → Archive를 실행하고, Organizer에서 App Store Connect로 배포한다. 스토어 이름과 아이콘에는 상용 슈팅 게임의 로고와 이름을 쓰지 않는다. 앱 이름은 이 프로젝트처럼 Invader로 둔다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.