Xcode의 iOS App 템플릿에 SwiftUI로 10×20 블록 게임을 붙이는 순서
같은 규칙의 안드로이드 버전은 「안드로이드 스튜디오로 테트리스 만드는 방법」에 있다. 이 글은 그 게임을 iPhone의 SwiftUI로 만든 것이다. 줄을 지우는 놀이 자체는 「테트리스 게임 하는 방법」에 적혀 있다.
게임이 하는 일
보드는 가로 10칸, 세로 20칸이다. 블록은 I, O, T, S, Z, J, L 일곱 가지이고, 일곱 개가 한 묶음으로 섞여 나온 뒤에 다음 묶음이 시작된다. 한 모양이 묶음 안에서 두 번 나오지는 않는다.
블록은 0.7초마다 한 칸씩 내려온다. 열 줄을 지울 때마다 레벨이 오르고, 간격은 45밀리초씩 줄되 120밀리초보다 짧아지지 않는다. 바닥에 닿거나 쌓인 칸에 막히면 그 자리에 고정된다. 가로 한 줄이 채워지면 그 줄은 사라지고 위 칸이 내려온다. 한 번에 지운 줄 수에 따라 점수는 100, 300, 500, 800에 그 순간의 레벨을 곱한다. 바닥 버튼으로 내린 칸마다 2점이 더해진다. 새 블록이 나올 자리가 없으면 그 판은 끝이다.
프로젝트를 만든다
Xcode는 Mac App Store 또는 developer.apple.com/xcode 에서 받아 설치한다.
File → New → Project에서 iOS의 App을 고른다. 테스트 포함은 끄고, 저장 위치는 개발자 폴더로 둔다.
| 항목 | 값 |
|---|---|
| Product Name | HanJul |
| Organization Identifier | kr.thinktree |
| Interface | SwiftUI |
| Language | Swift |
| Storage | None |
| Minimum Deployments | iOS 17.0 |
기기를 돌려도 판이 다시 만들어지지 않게 세로만 남긴다. 대상 HanJul을 고르고 General → Deployment Info에서 iPhone Orientation의 Portrait만 켠다.
첫 화면을 게임으로 바꾼다
ContentView.swift를 열고 파일 전체를 아래로 바꾼다. HanJulApp.swift의 WindowGroup은 마법사가 넣어 둔 ContentView()를 그대로 둔다.
import SwiftUI
struct ContentView: View {
var body: some View {
TetrisScreen()
}
}
private struct Cell {
var x: Int
var y: Int
}
private struct Piece {
var kind: Int
var rot: Int
var x: Int
var y: Int
}
private let shapes: [[[Cell]]] = [
[
[Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 3, y: 1)],
[Cell(x: 2, y: 0), Cell(x: 2, y: 1), Cell(x: 2, y: 2), Cell(x: 2, y: 3)],
[Cell(x: 0, y: 2), Cell(x: 1, y: 2), Cell(x: 2, y: 2), Cell(x: 3, y: 2)],
[Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 1, y: 2), Cell(x: 1, y: 3)],
],
Array(repeating: [Cell(x: 1, y: 0), Cell(x: 2, y: 0), Cell(x: 1, y: 1), Cell(x: 2, y: 1)], count: 4),
[
[Cell(x: 1, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1)],
[Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 1, y: 2)],
[Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 1, y: 2)],
[Cell(x: 1, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 1, y: 2)],
],
[
[Cell(x: 1, y: 0), Cell(x: 2, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1)],
[Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 2, y: 2)],
[Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 0, y: 2), Cell(x: 1, y: 2)],
[Cell(x: 0, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 1, y: 2)],
],
[
[Cell(x: 0, y: 0), Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 2, y: 1)],
[Cell(x: 2, y: 0), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 1, y: 2)],
[Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 1, y: 2), Cell(x: 2, y: 2)],
[Cell(x: 1, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 0, y: 2)],
],
[
[Cell(x: 0, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1)],
[Cell(x: 1, y: 0), Cell(x: 2, y: 0), Cell(x: 1, y: 1), Cell(x: 1, y: 2)],
[Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 2, y: 2)],
[Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 0, y: 2), Cell(x: 1, y: 2)],
],
[
[Cell(x: 2, y: 0), Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1)],
[Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 1, y: 2), Cell(x: 2, y: 2)],
[Cell(x: 0, y: 1), Cell(x: 1, y: 1), Cell(x: 2, y: 1), Cell(x: 0, y: 2)],
[Cell(x: 0, y: 0), Cell(x: 1, y: 0), Cell(x: 1, y: 1), Cell(x: 1, y: 2)],
],
]
private let palette: [Color] = [
rgb(0x3EC8E8),
rgb(0xFFC44D),
rgb(0xC084FC),
rgb(0x4ADE80),
rgb(0xFB7185),
rgb(0x60A5FA),
rgb(0xFB923C),
]
private let lineScore = [0, 100, 300, 500, 800]
private let kicks = [(0, 0), (-1, 0), (1, 0), (-2, 0), (2, 0), (0, -1)]
@MainActor
private final class TetrisGame: ObservableObject {
let cols = 10
let rows = 20
var board: [[Int]]
var piece = Piece(kind: 0, rot: 0, x: 3, y: 0)
var score = 0
var lines = 0
var running = true
private var bag: [Int] = []
init() {
board = (0..<rows).map { _ in Array(repeating: 0, count: cols) }
spawn()
}
var level: Int { 1 + lines / 10 }
var interval: Int { max(120, 700 - (level - 1) * 45) }
func left() { slide(dx: -1) }
func right() { slide(dx: 1) }
func soft() {
guard running else { return }
change {
if !slideDown() { lock() }
}
}
func hard() {
guard running else { return }
change {
var distance = 0
while slideDown() { distance += 1 }
score += distance * 2
lock()
}
}
func rotate() {
guard running else { return }
change {
let next = (piece.rot + 1) % 4
for (dx, dy) in kicks {
let moved = Piece(kind: piece.kind, rot: next, x: piece.x + dx, y: piece.y + dy)
if fits(moved) {
piece = moved
return
}
}
}
}
func gravity() {
guard running else { return }
change {
if !slideDown() { lock() }
}
}
func reset() {
change {
board = Array(repeating: Array(repeating: 0, count: cols), count: rows)
score = 0
lines = 0
running = true
bag.removeAll()
spawn()
}
}
func ghost() -> Piece {
var dropped = piece
while fits(Piece(kind: dropped.kind, rot: dropped.rot, x: dropped.x, y: dropped.y + 1)) {
dropped.y += 1
}
return dropped
}
func blocks(_ target: Piece) -> [Cell] {
shapes[target.kind][target.rot].map { Cell(x: $0.x + target.x, y: $0.y + target.y) }
}
private func slide(dx: Int) {
guard running else { return }
change {
let moved = Piece(kind: piece.kind, rot: piece.rot, x: piece.x + dx, y: piece.y)
if fits(moved) { piece = moved }
}
}
private func slideDown() -> Bool {
let moved = Piece(kind: piece.kind, rot: piece.rot, x: piece.x, y: piece.y + 1)
if !fits(moved) { return false }
piece = moved
return true
}
private func lock() {
for cell in blocks(piece) where cell.y >= 0 && cell.y < rows && cell.x >= 0 && cell.x < cols {
board[cell.y][cell.x] = piece.kind + 1
}
let cleared = clearLines()
if cleared > 0 { score += lineScore[cleared] * level }
lines += cleared
spawn()
}
private func clearLines() -> Int {
var cleared = 0
var y = rows - 1
while y >= 0 {
if board[y].allSatisfy({ $0 != 0 }) {
for row in stride(from: y, through: 1, by: -1) {
board[row] = board[row - 1]
}
board[0] = Array(repeating: 0, count: cols)
cleared += 1
} else {
y -= 1
}
}
return cleared
}
private func spawn() {
piece = Piece(kind: nextKind(), rot: 0, x: 3, y: 0)
if !fits(piece) { running = false }
}
private func nextKind() -> Int {
if bag.isEmpty { bag = Array(0..<7).shuffled() }
return bag.removeFirst()
}
private func fits(_ target: Piece) -> Bool {
blocks(target).allSatisfy { cell in
cell.x >= 0 && cell.x < cols && cell.y < rows && (cell.y < 0 || board[cell.y][cell.x] == 0)
}
}
private func change(_ body: () -> Void) {
objectWillChange.send()
body()
}
}
private struct TetrisScreen: View {
@StateObject private var game = TetrisGame()
@State private var runID = 0
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("점수 \(game.score) 줄 \(game.lines) 레벨 \(game.level)")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(.white)
GeometryReader { proxy in
let cell = min(
proxy.size.width / CGFloat(game.cols),
proxy.size.height / CGFloat(game.rows)
)
Canvas { context, size in
drawBoard(context: context, size: size)
}
.frame(width: cell * CGFloat(game.cols), height: cell * CGFloat(game.rows))
.background(rgb(0x1B212C))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
if !game.running {
Text("새 블록이 나올 자리가 없습니다.")
.foregroundStyle(.white)
}
HStack(spacing: 8) {
GameButton(title: "왼쪽") { game.left() }
GameButton(title: "회전") { game.rotate() }
GameButton(title: "오른쪽") { game.right() }
}
HStack(spacing: 8) {
GameButton(title: "아래") { game.soft() }
GameButton(title: "바닥") { game.hard() }
GameButton(title: "다시") {
game.reset()
runID += 1
}
}
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(rgb(0x12151C).ignoresSafeArea())
.focusable()
.focused($focused)
.onAppear { focused = true }
.onKeyPress(phases: [.down, .repeat]) { press in
switch press.key {
case .leftArrow: game.left()
case .rightArrow: game.right()
case .upArrow: game.rotate()
case .downArrow: game.soft()
case .space: game.hard()
default: return .ignored
}
return .handled
}
.task(id: runID) {
let current = game
while !Task.isCancelled && current.running {
try? await Task.sleep(nanoseconds: UInt64(current.interval) * 1_000_000)
if Task.isCancelled || !current.running { break }
current.gravity()
}
}
}
private func drawBoard(context: GraphicsContext, size: CGSize) {
let cell = size.width / CGFloat(game.cols)
for y in 0..<game.rows {
for x in 0..<game.cols {
let value = game.board[y][x]
let color = value == 0 ? Color.white.opacity(0.04) : palette[value - 1]
fill(context, color, x, y, cell)
}
}
let ghost = game.ghost()
if game.running && ghost.y != game.piece.y {
for block in game.blocks(ghost) where block.y >= 0 {
fill(context, palette[game.piece.kind].opacity(0.25), block.x, block.y, cell)
}
}
if game.running {
for block in game.blocks(game.piece) where block.y >= 0 {
fill(context, palette[game.piece.kind], block.x, block.y, cell)
}
}
}
}
private struct GameButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(title, action: action)
.font(.headline)
.frame(maxWidth: .infinity)
.frame(height: 44)
.buttonStyle(.borderedProminent)
}
}
private func fill(_ context: GraphicsContext, _ color: Color, _ x: Int, _ y: Int, _ cell: CGFloat) {
let rect = CGRect(x: CGFloat(x) * cell, y: CGFloat(y) * cell, width: cell - 2, height: cell - 2)
context.fill(Path(rect), 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()
}O 블록의 모양은 네 회전이 같다. `Array(repeating:count:)`가 그 네 벌을 만든다. 나머지 블록은 회전마다 칸 좌표를 따로 적었다. 보드 값은 0이 빈칸이고, 블록이 고정되면 종류 번호에 1을 더한 값이 남는다.
점수 글자 안의 역슬래시와 괄호는 Swift 문자열 보간이다. 붙여 넣을 때 그 역슬래시를 지우지 않는다.
회전이 벽에 닿으면 제자리만 보지 않는다. 좌우 한 칸, 좌우 두 칸, 위 한 칸을 밀어 보며 빈 곳을 찾는다. 그 범위 안에 자리가 없으면 회전은 취소된다. 옅은 칸은 지금 블록이 떨어질 바닥을 보여 준다.
보드는 버튼 위에 남은 화면 안에서 10:20 비율로 맞춘다. 그래서 폰이 달라도 왼쪽, 회전, 오른쪽, 아래, 바닥, 다시 버튼이 화면 안에 남는다.
| 조작 | 하는 일 |
|---|---|
| 왼쪽, 오른쪽 | 한 칸 이동 |
| 회전 | 시계 방향. 벽 옆이면 한두 칸을 밀어 본다 |
| 아래 | 한 칸 내리기. 더 못 내리면 그 자리에 고정 |
| 바닥 | 갈 수 있는 맨 아래까지 붙인 뒤 바로 고정 |
| 다시 | 점수와 줄을 처음으로 되돌림 |
| ← → ↑ ↓, Space | 시뮬레이터에서 버튼과 같은 동작. 화면을 한 번 눌러 포커스를 둔다 |
시뮬레이터와 iPhone에서 실행한다
창 위쪽 Scheme에서 iPhone 시뮬레이터를 고르고 Run을 누른다. 빌드가 끝나면 그 기기에 앱이 켜진다.
내 iPhone에서 보려면 케이블로 연결하고, 대상 HanJul의 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로 배포한다. 스토어 이름과 스크린샷에는 테트리스 상표를 쓰지 않고, 프로젝트 이름처럼 한줄이라는 자기 이름을 쓴다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.
댓글
0개아직 댓글이 없습니다.