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 Name | Invader |
| Organization Identifier | kr.thinktree |
| Interface | SwiftUI |
| Language | Swift |
| Storage | None |
| Minimum Deployments | iOS 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로 둔다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.
댓글
0개아직 댓글이 없습니다.