Swift

swiftUI로 게임 만들기(떨어지는 동전 잡기)

JDeoks 2023. 5. 1. 00:44

swiftUI로 게임을 제작해보려고 한다. 동전이 하늘에서 떨어지게 되면 동전을 터치해서  점수를 올리는 게임이다.
제한 시간 동안 동전이 하늘에서 랜덤으로 떨어지게 되고, 떨어지는 동전을 누르게 되면 점수가 올라가는 방식이다.
누른 동전은 1초동안 그 자리에 정지하고 그 후에는 사라지게 된다.

struct ContentView: View {
    @State var balls: [Ball] = []
    let ballCount = 5
    
    var body: some View {
        ZStack {
            ForEach(balls) { ball in
                Circle()
                    .fill(ball.color)
                    .frame(width: ball.size, height: ball.size)
                    .position(ball.position)
                    .gesture(
                        TapGesture(count: 1)
                            .onEnded {
                                ball.isStopped = true
                                ball.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                                    ball.isStopped = false
                                }
                            }
                    )
            }
        }
        .onAppear {
            // 뷰가 생성 되면 balls 배열에 ballCount만큼 ball 구조체 추가
            for _ in 0..<ballCount {
                balls.append(Ball())
            }
        }
    }
}

struct Ball {
    let position: CGPoint
    let size: CGFloat
    let color: Color
    var isStopped: Bool = false
    var timer: Timer?
    
    init() {
        position = CGPoint(x: CGFloat.random(in: 0...UIScreen.main.bounds.width), y: CGFloat.random(in: 0...UIScreen.main.bounds.height))
        size = CGFloat.random(in: 30...50)
        color = Color.random()
    }
}

GPT의 도움을 받아 기본 코드를 작성 했으나 오류가 몇 발생했다.

 

1. Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'Ball' conform to 'Identifiable'

 

ForEach 구조체에 들어갈 data인 Ball이 Identifiable하지 않다는 것인데, Ball 구조체가 Identifiable 프로토콜을 준수하게 하기 위해서 Ball 구조체에 id 프로퍼티를 추가해 주었다. 

https://medium.com/hcleedev/swift-swiftui%EC%9D%98-foreach-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0-%EC%A0%95%EC%9D%98-%EC%82%AC%EC%9A%A9-%ED%8C%81-8790117e6fd9

 

Swift: SwiftUI의 ForEach 알아보기(정의, 사용 팁)

View의 반복을 위한 ForEach란 무엇인지, 어떻게 사용해야 하는지 알아보자.

medium.com

struct Ball: Identifiable { // Identifiable 프로토콜 추가
    let id = UUID() // 고유 식별자
    let position: CGPoint
    let size: CGFloat
    let color: Color
    var isStopped: Bool = false
    var timer: Timer?
    
    init() {
        position = CGPoint(x: CGFloat.random(in: 0...UIScreen.main.bounds.width), y: CGFloat.random(in: 0...UIScreen.main.bounds.height))
        size = CGFloat.random(in: 30...50)
        color = Color.random()
    }
}

 

2. Cannot assign to property: 'ball' is a 'let' constant

 

ball 변수가 상수로 선언 되어 그 프로퍼티를 바꿀 수 없는 오류인데

 

ForEach(balls) { ball in
    var ball = ball
    Circle()
        .fill(ball.color)
        .frame(width: ball.size, height: ball.size)
        .position(ball.position)
        .gesture(
            TapGesture(count: 1)
                .onEnded {
                    ball.isStopped = true
                    ball.timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                        ball.isStopped = false
                    }
                }
        )
}

처음에는 ball을 변수로 새로 만들어서 해결하려 했다. 나중에 알게 된 오류이지만 이렇게 하면 공을 눌러도 공이 공중에 멈추지 않는 오류가 발생한다.

ForEach가 실제로 그리는 balls배열의 요소와는 다른 엉뚱하게 복사한 객체의 정보를 수정하는 방식이 되기 때문이라는 것 같다.

ForEach(balls.indices, id: \.self) { index in
    Circle()
        .fill(balls[index].color)
        .frame(width: balls[index].size, height: balls[index].size)
        .position(balls[index].position)
        .gesture(
            TapGesture(count: 1)
                .onEnded {
                    balls[index].isStopped = true
                    balls[index].timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                        balls[index].isStopped = false
                    }
                }
        )
}

따라서 balls.indices를 받고 그 index를 통해 배열에 직접 접근하는 방식으로 코드를 수정했다.

 

3. Cannot find 'UIScreen' in scope

 

UIKit에 있는 UIScreen을 swiftUI에서 사용하려고 해서 생긴 오류이다. swiftUI에서 view의 좌표를 얻는 방법인 GeometryReader를 대신 사용해야 한다.

https://medium.com/hcleedev/swift-geometryreader%EB%8A%94-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C-564896c6d6e0

 

Swift: GeometryReader는 무엇일까?

알면 알 수록 헷갈리는 특이한 Container View, GeometryReader에 대해 알아보자.

medium.com

import SwiftUI

struct ContentView: View {
    @State var balls: [Ball] = []
    
    let ballCount = 5
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                ForEach(balls.indices, id: \.self) { index in
                    Circle()
                        .fill(balls[index].color)
                        .frame(width: balls[index].size, height: balls[index].size)
                        .position(balls[index].position(in: geometry))
                        .gesture(
                            TapGesture(count: 1)
                                .onEnded {
                                    // 손가락을 떼었을 때 ball이 3초 동안 멈추도록 함
                                    balls[index].isStopped = true
                                    balls[index].timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                                        balls[index].isStopped = false
                                    }
                                }
                        )
                }
            }
            .onAppear {
                // 뷰가 생성 되면 balls 배열에 ballCount만큼 ball 구조체 추가
                for _ in 0..<ballCount {
                    balls.append(Ball(in: geometry))
                }
            }
        }

    }
}

struct Ball: Identifiable { // Identifiable 프로토콜 추가
    let id = UUID() // 고유 식별자
    let position: CGPoint// 구조체가 초기화 될 때의 좌표
    let size: CGFloat
    let color: Color
    var isStopped: Bool = false
    var timer: Timer?
    
    init(in geometry: GeometryProxy) {
            position = CGPoint(x: CGFloat.random(in: 0...geometry.size.width), y: CGFloat.random(in: 0...geometry.size.height))
            size = CGFloat.random(in: 30...50)
            color = Color.red
    }
    
    func position(in geometry: GeometryProxy) -> CGPoint {
        if isStopped {
            return position
        } else {
            return CGPoint(x: position.x, y: position.y + (geometry.size.height + size) / 100)
        }
    }
}

여기까지 하니 어찌저찌해서 빌드는 성공한다. 빌드를 해보니 원을 눌렀을때 원이 살짝 움직이기는 하지만 떨어지지 않는데, 이제 이 원들을 떨어지게 만들어야 한다.

import SwiftUI

struct ContentView: View {
    @State var balls: [Ball] = []
    let ballCount = 5
    
    var body: some View {
        // 할당된 view의 좌표 정보 알기 위해 감쌈
        GeometryReader { geometry in
            //  ZStack은 겹치는 뷰들 중 상위 뷰를 맨 위에 표시, z-index를 알 필요 없이 간단히 구현
            ZStack {
                ForEach(balls.indices, id: \.self) { index in
                    Circle()
                        // 새로 만들 원의 색, 크기, 포지션 대입
                        .fill(balls[index].color)
                        .frame(width: balls[index].size, height: balls[index].size)
                        .position(balls[index].position) // position 메서드에 geometry 전달
                        //터치하면 3초 정지 구현
                        .gesture(
                            TapGesture(count: 1)
                                .onEnded {
                                    balls[index].isStopped = true
                                    balls[index].timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in
                                        balls[index].isStopped = false
                                    }
                                }
                        )
                }
            }
            .onAppear {
                for _ in 0..<ballCount {
                    balls.append(Ball(in: geometry))
                }
                startTimer(geometry: geometry)
            }
        }
    }
    /// GeometryProxy를 받아
    func startTimer(geometry: GeometryProxy) {
        // 0.05초마다 반복, 항상 반복, 반복시마다 아래 클로저 실행
        Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
            // balls안의 모든 ball에 대해서 위치 업데이트 수행
            for i in 0..<balls.count {
                balls[i].updatePosition(in: geometry)
            }
        }
    }
}

struct Ball: Identifiable {
    let id = UUID()
    /// 원의 위치
    var position: CGPoint
    let size: CGFloat
    let color: Color
    /// 움직이는 상태 여부
    var isStopped: Bool = false
    /// 각각의 객체에 할당된 타이머
    var timer: Timer?
    
    /// 초기화 함수 상위 view의 GeometryProxy를 받아 초기화 함
    init(in geometry: GeometryProxy) {
        // 처음 위치를 초기화
        position = CGPoint(x: CGFloat.random(in: 0...geometry.size.width), y: CGFloat.random(in: 0...geometry.size.height))
        // 사이즈 랜덤 설정
        size = CGFloat.random(in: 30...50)
        color = .red
    }

    mutating func updatePosition(in geometry: GeometryProxy) {
        // isStopped상태에 따라 움직일지 말자 결정
        if  !isStopped {
            position = CGPoint(x: position.x, y: position.y + (geometry.size.height) / 100)
        }
    }

}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

실행되면 ball 객체의 position프로퍼티를 업데이트 하는 updatePosition(in:)함수를 추가했다. 원래 구조체는 메소드 내에서 프로퍼티값을 변경 시킬 수 없는데, mutating이라는 키워드를 붙여 변경 가능하게 했다.

startTimer(geometry:)함수는 ZStack이 처음 appear할때 실행되는데 0.05초 마다 balls배열 안의 모든 ball에 대해서 위치 업데이트를 수행한다.

 

이제 화면에서 벗어난 뷰를 다시 랜덤한 위치로 돌려놓는 코드를 작성해야한다.

 

/// struct Ball 메서드

/// ball 이 geometry view에서 벗어났는지 확인하는 함수
func isInside(geometry: GeometryProxy) -> Bool {
    let minX = -size
    let maxX = geometry.size.width + size
    let minY = -size
    let maxY = geometry.size.height + size

    return (minX...maxX).contains(position.x) && (minY...maxY).contains(position.y)
}

mutating func updatePosition(in geometry: GeometryProxy) {
    // isStopped상태에 따라 움직일지 말지 결정
    if  !isStopped {
        position = CGPoint(x: position.x, y: position.y + (geometry.size.height) / 100)
        if !isInside(geometry: geometry) {
            position = Ball.generateRandomPosition(in: geometry)
        }
    }
}

/// GeometryProxy를 받아서 랜덤한 위치를 반환
static func generateRandomPosition(in geometry: GeometryProxy) -> CGPoint {
    return CGPoint(x: CGFloat.random(in: 0...geometry.size.width), y: CGFloat.random(in: 0...geometry.size.height/3))
}

우선 isInside 메서드로 view가 범위 내에 있는지 확인한다. 만약 범위 밖에 있다면 updatePosition이 일어날 때 뷰를 y좌표값을 올리지 않고 새로운 위치를 generateRandomposition으로 부터 반환받아 대입한다.

import SwiftUI

struct ContentView: View {
    @State var balls: [Ball] = []
    @State var score: Int = 0
    let ballCount = 7
    
    var body: some View {
        // 할당된 view의 좌표 정보 알기 위해 감쌈
        ZStack {
            Color(hex: "f8ede3")
            VStack{
                Text("CatchCoinMac")
                    .font(.largeTitle)
                    .foregroundColor(Color(hex: "#ff8882"))
                Text("\(score)")
                    .font(.headline)
                    .foregroundColor(Color(hex: "#ff8882"))
                GeometryReader { geometry in
                    //  ZStack은 겹치는 뷰들 중 상위 뷰를 맨 위에 표시, z-index를 알 필요 없이 간단히 구현
                    ZStack {
                        ForEach(balls.indices, id: \.self) { index in
                            // ball이 구역안에 있으면
                            if balls[index].isInside(geometry: geometry){
                                ZStack {
                                    Circle()
                                        .fill(balls[index].color)
                                        .frame(width: balls[index].size, height: balls[index].size)
                                    balls[index].label // 라벨 추가
                                }
                                    .position(balls[index].position) // position 메서드에 geometry 전달
                                    //터치하면 3초 정지 구현
                                    .gesture(
                                        TapGesture(count: 1)
                                            .onEnded {
                                                // 각 ball은 여러번 눌러도 한번만 적용 되어야 함
                                                if balls[index].touched == false{
                                                    // ball의 점수만큼 점수+
                                                    score += balls[index].point
                                                    balls[index].touched = true
                                                    // ball 멈추기
                                                    balls[index].isStopped = true

                                                    balls[index].timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { _ in
                                                        balls[index].reproduceBall(geometry: geometry)
                                                        balls[index].isStopped = false
                                                }

                                                }
                                            }
                                    )
                            }

                        }
                    }
                    .onAppear {
                        for _ in 0..<ballCount {
                            balls.append(Ball(in: geometry))
                        }
                        startTimer(geometry: geometry)
                    }
                    .onDisappear {
                        //GeometryReader뷰가 화면에서 사라지면 ball 배열 초기화
                        balls.removeAll()
                    }
                }

            }
            
        }
    }
    /// GeometryProxy를 받아
    func startTimer(geometry: GeometryProxy) {
        // 0.05초마다 반복, 항상 반복, 반복시마다 아래 클로저 실행
        Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
            // balls안의 모든 ball에 대해서 위치 업데이트 수행
            for i in 0..<balls.count {
                balls[i].updatePosition(in: geometry)
            }
        }
    }
}

struct Ball: Identifiable {
    var touched: Bool = false
    let id = UUID()
    /// 원의 위치
    var position: CGPoint
    var size: CGFloat
    var color: Color
    /// 움직이는 상태 여부
    var isStopped: Bool = false
    /// 각각의 객체에 할당된 타이머
    var timer: Timer?
    var point: Int
    var label: Text
    var speed: CGFloat = CGFloat((Int.random(in: 5...15)))
    
    /// 초기화 함수 상위 view의 GeometryProxy를 받아 초기화 함
    init(in geometry: GeometryProxy) {
        // 처음 위치를 초기화
        position = Ball.getRandomPositon(in: geometry)
        // 사이즈 랜덤으로 설정
        size = CGFloat.random(in: 50...100)
        color = .random
        point = (Int.random(in: 1...10))*10
        label = Text(String(point))
    }
    /// 원을 내려가게 하는 함수, 각 객체에 설정된 speed 만큼 y좌표값을 더해준다.
    mutating func updatePosition(in geometry: GeometryProxy) {
        // isStopped상태에 따라 움직일지 말지 결정, 뷰가 화면을 벗어났다면 다시 생성함
        if  !isStopped {
            position = CGPoint(x: position.x, y: position.y + speed)
            if !isInside(geometry: geometry) {
                //reproduceBall
                reproduceBall(geometry: geometry)
            }
        }
    }
    
    /// ball 이 geometry view에서 벗어났는지 확인하는 함수
    func isInside(geometry: GeometryProxy) -> Bool {
            let minX = -size
            let maxX = geometry.size.width + size
            let minY = -size
            let maxY = geometry.size.height + size
            
            return (minX...maxX).contains(position.x) && (minY...maxY).contains(position.y)
    }
    
    /// ball 객체의 프로퍼티를 전부 새로 바꾸는 함수
    mutating func reproduceBall(geometry: GeometryProxy) {
        position = Ball.getRandomPositon(in: geometry)
        point = (Int.random(in: 1...10))*10
        size = CGFloat.random(in: 50...100)
        label = Text(String(point))
        color = .random
        touched = false
    }
    
    /// GeometryProxy를 받아서 랜덤한 위치를 반환
    static func getRandomPositon(in geometry: GeometryProxy) -> CGPoint {
        return CGPoint(x: CGFloat.random(in: 0...geometry.size.width), y: CGFloat.random(in: 0...geometry.size.height/3))
    }

}


// 컬러 랜덤
extension Color {
    static var random: Color {
        return Color(
            red: .random(in: 0...1),
            green: .random(in: 0...1),
            blue: .random(in: 0...1)
        )
    }
    init(hex: String) {
      let scanner = Scanner(string: hex)
      _ = scanner.scanString("#")
      
      var rgb: UInt64 = 0
      scanner.scanHexInt64(&rgb)
      
      let r = Double((rgb >> 16) & 0xFF) / 255.0
      let g = Double((rgb >>  8) & 0xFF) / 255.0
      let b = Double((rgb >>  0) & 0xFF) / 255.0
      self.init(red: r, green: g, blue: b)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

여러가지를 추가했다.

1. ball 구조체에 speed, point 프로퍼티 추가

 - ball을 터치하면 점수판의 점수가 point만큼 상승

2. reproduceBall 메서드 추가 

 - 새로 생성될때 위치, 속력, 색, 크기, 점수 등이 모두 변하게 함

 

 

더 구현할 것

1. 시간 제한을 설정

2. 점수판

 - 제한 시간이 끝나면 중앙에 점수판이 뜨도록 함, 가능하다면 역대 기록들의 리스트도 출력

    var body: some View {
        // 할당된 view의 좌표 정보 알기 위해 감쌈
        ZStack {
            Color(hex: "f8ede3")
                .ignoresSafeArea()
            VStack{
                HStack{
                    Spacer()

                    VStack{
                        Text("score")
                            .font(.title)
                            .foregroundColor(Color(hex: "#ff8882"))
                        Text("\(score)")
                            .font(.title)
                            .foregroundColor(Color(hex: "#ff8882"))
                    }
                    VStack{
                        Text("Time")
                            .font(.title)
                            .foregroundColor(Color(hex: "#ff8882"))
                        Text("\(time)")
                            .font(.title)
                            .foregroundColor(Color(hex: "#ff8882"))
                            .onAppear{
                                Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
                                    if time > 0 {
                                        time -= 1
                                    } else {
                                        // 시간이 0이면 ScoreboardView 표시
                                        isGameEnded = true
                                    }
                                }
                                
                            }
                    }
                    .padding()

                    
                }

점수와 제한 시간을 표시하게 만들었다.

남은 시간 이 0이하이면 isGameEnded 변수에 true를 대입하고

.fullScreenCover(isPresented: $isGameEnded) {
    scoreboard(isGameEnded: $isGameEnded, score: $score, time: $time, lastScore: score)
}

점수판인 scoreboard 뷰가 fullScreenCover로 나타나게 된다.

struct scoreboard: View {
    @Binding var isGameEnded: Bool
    @Binding var score: Int
    @Binding var time: Int
    var lastScore: Int
    
    var body: some View {

        ZStack {
            Color(hex: "f8ede3")
                .ignoresSafeArea()
            VStack{
                HStack {
                    Spacer()
                    VStack{
                        Text("Score")
                            .font(.title)
                        Text("\(lastScore)")
                            .font(.title)
                    }
                    VStack{
                        Text("Good Job!")
                            .font(.title)
                        Image(systemName: "hand.thumbsup.fill")
                            .font(.title)
                    }

                    Spacer()

                }
                Button("Close") {
                    // 닫기 버튼을 누르면 isGameEnded 값을 false로 설정
                    isGameEnded = false
                    score = 0
                    time = 10
                }
                    .padding()
            }
            .foregroundColor(Color(hex: "#ff8882"))
        }
    }
}

점수와 함께 리셋 버튼을 두었다.

필요한 모든 변수는 @Binding으로 건네주었는데 더 나은 방법이 있을 것 같다.

게임 실행중
점수판

https://github.com/JDeoks/SwiftUI/tree/main/Games/CatchCoin

 

GitHub - JDeoks/SwiftUI

Contribute to JDeoks/SwiftUI development by creating an account on GitHub.

github.com