Last active
September 20, 2022 07:42
-
-
Save tarrouye/d384492b8e9f5dec031f9de614a586a5 to your computer and use it in GitHub Desktop.
Simple SwiftUI Charades game
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// CharadeView.swift | |
// | |
// | |
// Created by Théo Arrouye on 9/14/22. | |
// | |
import SwiftUI | |
// MARK: Custom Clip Shape | |
let RRCLIPSHAPE = RoundedRectangle( | |
cornerRadius: 5, | |
style: .continuous | |
) | |
// MARK: CardView | |
struct CardView: View { | |
let prompt: String | |
@Binding var time: String | |
@Binding var result: CardResult | |
private var bgColor: Color { | |
result.cardColor | |
} | |
var body: some View { | |
ZStack { | |
RRCLIPSHAPE | |
.strokeBorder(Color.primary, lineWidth: 4) | |
.background(RRCLIPSHAPE.fill(bgColor.opacity(0.7))) | |
Text(prompt) | |
.font(.largeTitle) | |
.lineLimit(2) | |
.multilineTextAlignment(.center) | |
.frame(maxWidth: .infinity, alignment: .center) | |
Text(time) | |
.font(.title) | |
.lineLimit(1) | |
.multilineTextAlignment(.center) | |
.frame(maxWidth: .infinity, alignment: .center) | |
.frame(maxHeight: .infinity, alignment: .bottom) | |
.padding(.bottom) | |
} | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
} | |
} | |
// MARK: Enums | |
enum GameState { | |
case start | |
case playing | |
case finished | |
} | |
enum CardResult { | |
case unknown | |
case correct | |
case pass | |
var iconColor: Color { | |
switch self { | |
case .unknown: | |
return .gray | |
case .correct: | |
return .green | |
case .pass: | |
return .orange | |
} | |
} | |
var cardColor: Color { | |
switch self { | |
case .unknown: | |
return .blue | |
case .correct: | |
return .green | |
case .pass: | |
return .orange | |
} | |
} | |
} | |
// MARK: CharadeView | |
struct CharadeView: View { | |
let prompts = ["Ellen", "Your", "Game", "Is", "So", "Copyable"] | |
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() | |
@State var time: Float = 60.0 { | |
didSet { | |
updateTimeString() | |
} | |
} | |
@State var timeString = "" | |
@State var gameState: GameState = .start | |
@State var currentPrompt = 0 { | |
didSet { | |
resetTime() | |
} | |
} | |
@State var cardResults: [CardResult] = [] | |
private var correctResults: Int { | |
cardResults.filter { $0 == .correct }.count | |
} | |
@State var wasGestureTriggered: Bool = true /* start true so device has to be reset into starting position before first trigger */ | |
// MARK: Timer | |
private func timerRunLoop() { | |
guard gameState == .playing else { return } | |
decrementTime() | |
} | |
private func decrementTime() { | |
time -= 1.0 | |
guard time < 0 else { return } /* lose game if time runs out */ | |
loseCard() | |
} | |
private func resetTime() { | |
time = 60.0 | |
} | |
private func updateTimeString() { | |
timeString = "\(time.formattedString(maxPrecision: 2))s" | |
} | |
// MARK: - Game State | |
private func loseCard() { | |
moveToNextCardSettingResultAs(.pass) | |
} | |
private func winCard() { | |
moveToNextCardSettingResultAs(.correct) | |
} | |
private func moveToNextCardSettingResultAs(_ result: CardResult) { | |
/* set result */ | |
cardResults[currentPrompt] = result | |
/* move to next card, or end game if last card */ | |
guard currentPrompt < prompts.count - 1 else { | |
finishGame() | |
return | |
} | |
withAnimation { | |
currentPrompt += 1 | |
} | |
} | |
private func startGame() { | |
/* reset game state */ | |
currentPrompt = 0 | |
gameState = .playing | |
cardResults = [CardResult](repeating: .unknown, count: prompts.count) | |
MotionManager.shared.startMotionUpdates() | |
} | |
private func finishGame() { | |
withAnimation { | |
gameState = .finished | |
} | |
MotionManager.shared.stopMotionUpdates() | |
} | |
// MARK: - Subviews | |
var startScreen: some View { | |
VStack { | |
AsyncImage(url: URL(string: "https://static.wikia.nocookie.net/characters/images/6/6b/Latest_%281%29-3.jpg")) { image in | |
image.resizable().scaledToFit() | |
} placeholder: { | |
ProgressView() | |
} | |
.frame(width: 100) | |
.padding(.top) | |
Text("Sandy Cheeks") | |
.font(.largeTitle) | |
.frame(maxWidth: .infinity, alignment: .center) | |
.padding() | |
Text("Tap to start the game") | |
.font(.headline) | |
.frame(maxWidth: .infinity, alignment: .center) | |
.padding() | |
} | |
} | |
var gameView: some View { | |
ZStack { | |
CardView(prompt: prompts[currentPrompt], time: $timeString, result: $cardResults[currentPrompt]) | |
.id("CRD\(currentPrompt)") | |
.padding([.horizontal, .top], 5) | |
.transition(.slideUp) | |
statusView | |
.frame(maxWidth: .infinity, alignment: .center) | |
.frame(maxHeight: .infinity, alignment: .top) | |
.padding(.top, 15) | |
} | |
.transition(.slideUp) | |
} | |
var endView: some View { | |
VStack { | |
Text("Game over") | |
.font(.largeTitle) | |
.frame(maxWidth: .infinity, alignment: .center) | |
.padding() | |
statusView | |
.frame(maxWidth: .infinity, alignment: .center) | |
.padding() | |
Text("You got \(correctResults)/\(cardResults.count)") | |
.font(.title) | |
.frame(maxWidth: .infinity, alignment: .center) | |
.padding() | |
} | |
.transition(.slideUp) | |
} | |
var statusView: some View { | |
HStack(spacing: 10) { | |
ForEach(cardResults, id: \.self) { res in | |
Circle() | |
.strokeBorder(.primary, lineWidth: 1) | |
.background( | |
Circle() | |
.fill(res.iconColor) | |
) | |
.frame(width: 25, height: 25) | |
} | |
} | |
} | |
// MARK: - Roll Gesture Handling | |
private func checkCardGesture(_ rollAmount: Double) { | |
/* we set < -2.25 as the down gesture and > -0.25 as the up gesture */ | |
/* and [-1.5, -1] as the reset area */ | |
guard !wasGestureTriggered else { | |
checkForResetGesture(rollAmount) | |
return | |
} | |
let correct = checkForCorrectGesture(rollAmount) | |
let pass = checkForPassGesture(rollAmount) | |
guard correct || pass else { return } | |
wasGestureTriggered = true | |
} | |
private func checkForResetGesture(_ rollAmount: Double) { | |
guard rollAmount > -1.5 && rollAmount < -1 else { return } | |
wasGestureTriggered = false | |
} | |
private func checkForCorrectGesture(_ rollAmount: Double) -> Bool { | |
guard rollAmount < -2.25 else { return false } | |
winCard() | |
return true | |
} | |
private func checkForPassGesture(_ rollAmount: Double) -> Bool { | |
guard rollAmount > -0.25 else { return false } | |
loseCard() | |
return true | |
} | |
// MARK: - Body | |
var body: some View { | |
switch gameState { | |
case .start: | |
startScreen | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.contentShape(Rectangle()) | |
.onTapGesture { | |
startGame() | |
} | |
case .playing: | |
gameView | |
.onReceive(timer) { _ in | |
timerRunLoop() | |
} | |
.onReceive(MotionManager.shared.$x) { roll in | |
checkCardGesture(roll) | |
} | |
case .finished: | |
endView | |
.frame(maxWidth: .infinity, maxHeight: .infinity) | |
.contentShape(Rectangle()) | |
.onTapGesture { | |
gameState = .start | |
} | |
} | |
} | |
} | |
// MARK: Custom Slide Animation | |
extension AnyTransition { | |
static var slideUp: AnyTransition { | |
AnyTransition.asymmetric( | |
insertion: .move(edge: .bottom), | |
removal: .move(edge: .top) | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment