Quick game made as a reply to a Reddit post.
This is only meant to demonstrate ForEach and LazyXGrid, not game logic...
Video:
Image:
// | |
// 20200728_ColorFinderGameExample.swift | |
// | |
// Created by Anthony Rosario on 7/28/20. | |
// | |
import Combine | |
import SwiftUI | |
struct CardView: View { | |
@Environment(\.colorScheme) var colorScheme | |
@Binding var winningCardID: Int | |
@Binding var selectedCardID: Int? | |
@State private var saturation: Double = 1 | |
@State private var animation: Animation = Animation.easeInOut(duration: 0.3) | |
let cardID: Int | |
let cardColor: Color | |
let opacity: Double | |
var body: some View { | |
Button(action: { | |
guard selectedCardID == nil else { return } | |
checkWinner() | |
}, label: { | |
RoundedRectangle(cornerRadius: 25.0, style: .continuous) | |
.fill(Color.white) | |
.colorMultiply((selectedCardID == nil ? cardColor : postSelectionColor).opacity(opacity)) | |
.saturation(saturation) | |
.animation(animation) | |
.frame(width: 200, height: 250) | |
.shadow(color: Color.black.opacity(0.3), radius: 3, x: 10, y: 10) | |
.shadow(color: Color.white.opacity(colorScheme == .dark ? 0 : 0.7), radius: 10, x: -5, y: -5) | |
}) | |
.buttonStyle(PlainButtonStyle()) | |
.allowsHitTesting(selectedCardID == nil) | |
} | |
var baseAnimation: Animation { | |
Animation.easeIn(duration: 0.1) | |
} | |
var loserAnimation: Animation { | |
Animation.easeInOut.repeatCount(8) | |
} | |
var postSelectionColor: Color { | |
guard let selectedID = selectedCardID else { return cardColor } | |
if winningCardID == selectedID { | |
if winningCardID == cardID { | |
return .green | |
}else { | |
return cardColor.opacity(0.1) | |
} | |
}else { | |
if selectedID == cardID { | |
return .red | |
}else if winningCardID == cardID { | |
return Color.green.opacity(0.7) | |
}else { | |
return cardColor.opacity(0.1) | |
} | |
} | |
} | |
func checkWinner() { | |
if winningCardID != cardID { | |
animation = loserAnimation | |
}else { | |
animation = baseAnimation | |
} | |
withAnimation { | |
selectedCardID = cardID | |
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { | |
animation = baseAnimation | |
} | |
} | |
} | |
} | |
class ColorFinderViewModel: ObservableObject { | |
var colorScheme: ColorScheme = { | |
UIScreen.main.traitCollection.userInterfaceStyle == .dark ? .dark : .light | |
}() | |
var baseOpacityRange: ClosedRange<Double> { | |
colorScheme == .dark ? 0.8...0.95 : 0.5...0.9 | |
} | |
@Published var mainColor: Color = .blue | |
@Published var streak: Int = 0 | |
@Published var longestStreak: Int = 0 | |
@Published var roundWinningCardID: Int = Int.random(in: 0..<4) | |
@Published var difficultyMultiplier: Double = 1 | |
@Published var roundWinningCardOpacity: Double = Double.random(in: 0.5...0.9) | |
@Published var selectedCardID: Int? = nil | |
@Published var roundResultText: Text? = nil | |
var cancelBucket = Set<AnyCancellable>() | |
init() { | |
$selectedCardID | |
.receive(on: DispatchQueue.main) | |
.filter({ $0 != nil }) | |
.sink(receiveValue: {[weak self] selectedID in | |
guard let self = self else { return } | |
DispatchQueue.main.async { | |
if self.roundWinningCardID == selectedID { | |
self.nextRound() | |
}else { | |
self.reset() | |
} | |
} | |
}) | |
.store(in: &cancelBucket) | |
$streak | |
.receive(on: DispatchQueue.main) | |
.filter({[weak self] in $0 > self?.longestStreak ?? 0 }) | |
.sink { [weak self] newRecord in | |
DispatchQueue.main.async { | |
self?.longestStreak = newRecord | |
} | |
} | |
.store(in: &cancelBucket) | |
} | |
private func randomize(quickly: Bool = false) { | |
DispatchQueue.main.asyncAfter(deadline: .now() + (quickly ? 1.5 : 2.5)) { | |
withAnimation { | |
let R = Double.random(in: 0...255) | |
let G = Double.random(in: 0...255) | |
let B = Double.random(in: 0...255) | |
self.roundResultText = nil | |
self.selectedCardID = nil | |
self.mainColor = Color(red: R / 255, green: G / 255, blue: B / 255) | |
self.roundWinningCardOpacity = { | |
let lower = self.baseOpacityRange.lowerBound * self.difficultyMultiplier | |
let upper = min(self.baseOpacityRange.upperBound * self.difficultyMultiplier, 0.99) | |
return Double.random(in: lower...upper) | |
}() | |
self.roundWinningCardID = Int.random(in: 0..<4) | |
} | |
} | |
} | |
private func nextRound() { | |
difficultyMultiplier = max(1.0, (Double(streak) / 5.0)) | |
withAnimation { | |
var text: String = "WINNER!" | |
switch streak { | |
case 4: | |
text = "NICE!" | |
case 8, 12: | |
text = "WOW!" | |
case 15: | |
text = "INCREDIBLE!" | |
case 18: | |
text = "....cool." | |
default: break | |
} | |
roundResultText = Text(text).font(.largeTitle).foregroundColor(.green).italic() | |
streak += 1 | |
randomize(quickly: true) | |
} | |
} | |
private func reset() { | |
difficultyMultiplier = 1 | |
withAnimation { | |
roundResultText = Text("YOU LOSE.").font(.largeTitle).foregroundColor(.red).bold() | |
streak = 0 | |
randomize() | |
} | |
} | |
} | |
struct ColorFinderView: View { | |
@Environment(\.colorScheme) var colorScheme | |
@StateObject var model = ColorFinderViewModel() | |
var backgroundColor: Color { | |
if colorScheme == .dark { | |
return Color(.systemBackground) | |
}else { | |
return Color(UIColor(red: 225 / 255, green: 225 / 255, blue: 235 / 255, alpha: 1)) | |
} | |
} | |
var body: some View { | |
VStack { | |
title | |
topScore | |
resultText | |
cardStack | |
score | |
Spacer() | |
} | |
.background(backgroundColor.edgesIgnoringSafeArea(.all)) | |
.onChange(of: colorScheme) { value in | |
model.colorScheme = value | |
} | |
.onAppear { | |
model.colorScheme = colorScheme | |
} | |
} | |
var title: some View { | |
Text("Color Finder!") | |
.font(.system(size: 65)) | |
.fontWeight(.bold) | |
.foregroundColor(.white) | |
.colorMultiply(model.mainColor) | |
.animation(.easeInOut(duration: 0.3)) | |
.shadow(color: Color.black.opacity(0.3), radius: 3, x: 10, y: 10) | |
.shadow(color: Color.white.opacity(colorScheme == .dark ? 0 : 0.7), radius: 10, x: -5, y: -5) | |
.frame(maxWidth: .infinity) | |
.fixedSize(horizontal: false, vertical: true) | |
} | |
var topScore: some View { | |
HStack { | |
Text("Top score: ") | |
.padding(.leading, 4) | |
Text("\(model.longestStreak)") | |
.bold() | |
Spacer() | |
} | |
.padding([.horizontal, .top]) | |
} | |
var resultText: some View { | |
HStack { | |
Rectangle() | |
.fill(backgroundColor) | |
.frame(maxHeight: 20) | |
.overlay( | |
VStack { | |
if model.roundResultText != nil { | |
model.roundResultText | |
.opacity(model.roundResultText == nil ? 0 : 1) | |
.scaleEffect(model.streak > 0 ? 2.0 : 1) | |
.offset(y: model.streak > 0 ? -8 : -12) | |
.transition(AnyTransition.asymmetric(insertion: .scale(scale: 5), removal: .opacity)) | |
} | |
} | |
.padding(.top) | |
.rotationEffect(.degrees(model.roundResultText == nil ? 720 : 0), anchor: .center) | |
.animation(.easeIn) | |
, alignment: .top) | |
} | |
} | |
var cardStack: some View { | |
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], | |
alignment: .center, spacing: 4, pinnedViews: []) { | |
ForEach(0..<4) { i in | |
CardView(winningCardID: $model.roundWinningCardID, | |
selectedCardID: $model.selectedCardID, | |
cardID: i, | |
cardColor: model.mainColor, | |
opacity: model.roundWinningCardID == i ? model.roundWinningCardOpacity : 1) | |
} | |
} | |
.padding(.horizontal, 8) | |
.padding(.bottom) | |
.scaleEffect(model.roundResultText == nil ? 1 : 0.85) | |
} | |
var score: some View { | |
VStack(spacing: 0) { | |
Spacer() | |
Text("Score") | |
.font(.system(size: 40)) | |
.textCase(.uppercase) | |
.padding(.leading, 4) | |
.lineLimit(1) | |
.layoutPriority(1) | |
Divider() | |
.frame(width: 200) | |
Text("\(model.streak)") | |
.font(.system(size: 60)) | |
.bold() | |
.transition(.identity) | |
.animation(nil) | |
.padding(.horizontal) | |
.frame(maxWidth: .infinity) | |
.foregroundColor(.green) | |
Spacer() | |
} | |
.frame(maxWidth: .infinity) | |
.padding([.horizontal, .top]) | |
} | |
} | |
struct ColorFinder_Previews: PreviewProvider { | |
static var previews: some View { | |
ColorFinderView() | |
} | |
} |
Quick game made as a reply to a Reddit post.
This is only meant to demonstrate ForEach and LazyXGrid, not game logic...
Video:
Image: