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 프로퍼티를 추가해 주었다.
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를 대신 사용해야 한다.
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
'Swift' 카테고리의 다른 글
SwiftUI에서 UIImagePickerController 사용 (0) | 2023.05.01 |
---|---|
GeometryReader, Gesture, AVAudioPlayer로 진동 구현 OE - 2 (0) | 2023.05.01 |
Watch Connectivity로 폰-워치 통신 구현 OE - 1 (0) | 2023.05.01 |
Swift 흐름제어 구문 (0) | 2023.05.01 |
Swift 기본 문법 (0) | 2023.05.01 |