diff --git a/Memorize/Cardify.swift b/Memorize/Cardify.swift index f69fb0b..0da4f8f 100644 --- a/Memorize/Cardify.swift +++ b/Memorize/Cardify.swift @@ -7,24 +7,35 @@ import SwiftUI -struct Cardify: ViewModifier { - var isFaceUp: Bool +struct Cardify: AnimatableModifier { + init(isFaceUp: Bool) { + self.rotation = isFaceUp ? 0 : 180 + } + + var animatableData: Double { + get { rotation } + set { rotation = newValue } + } + + var rotation: Double func body(content: Content) -> some View { ZStack { let shape = RoundedRectangle(cornerRadius: const.DrawingConstants.cornerRadius) - if isFaceUp { + if rotation < 90 { shape.fill().foregroundColor(.white) shape.strokeBorder(lineWidth: const.DrawingConstants.lineWidth) - content } else { shape.fill() } + content + .opacity(rotation < 90 ? 1 : 0) } + .rotation3DEffect(Angle.degrees(rotation), axis: (x: 0, y: 1, z: 0)) } } extension View { func cadify(isFaceUp: Bool) -> some View { - self.modifier(Cardify(isFaceUp: isFaceUp)) + modifier(Cardify(isFaceUp: isFaceUp)) } } diff --git a/Memorize/Constans.swift b/Memorize/Constans.swift index aca16ac..6d3281c 100644 --- a/Memorize/Constans.swift +++ b/Memorize/Constans.swift @@ -10,14 +10,21 @@ import SwiftUI struct const { enum DrawingConstants { + static let cardColor = Color.red static let cornerRadius: CGFloat = 10 static let lineWidth: CGFloat = 3 static let fontScale: CGFloat = 0.6 + static let fontSize: CGFloat = 32 static let gridWidth: CGFloat = 80 static let gridAspectRatio: CGFloat = 2 / 3 static let matchedCardOpacity: Double = 0 static let gridPadding: CGFloat = 4 static let piePadding: CGFloat = 4 static let pieOpacity: CGFloat = 0.5 + static let undealtHeight: CGFloat = 90 + static let undealtWidth: CGFloat = undealtHeight * gridAspectRatio + static let dealDuration: Double = 1 + static let totalDealDuration: Double = 1.5 + static let bonusTimeLimit: TimeInterval = 6 } } diff --git a/Memorize/EmojiMemoryGame.swift b/Memorize/EmojiMemoryGame.swift index 47361c2..e35a8f2 100644 --- a/Memorize/EmojiMemoryGame.swift +++ b/Memorize/EmojiMemoryGame.swift @@ -27,4 +27,12 @@ class EmojiMemoryGame: ObservableObject { func choose(_ card: Card) { model.choose(card) } + + func shuffle() { + model.shuffle() + } + + func restart() { + model = EmojiMemoryGame.createMemoryGame() + } } diff --git a/Memorize/EmojiMemoryGameView.swift b/Memorize/EmojiMemoryGameView.swift index ed438b3..1332e1a 100644 --- a/Memorize/EmojiMemoryGameView.swift +++ b/Memorize/EmojiMemoryGameView.swift @@ -9,40 +9,144 @@ import SwiftUI struct EmojiMemoryGameView: View { @ObservedObject var game: EmojiMemoryGame + @Namespace private var dealingNamespace var body: some View { + ZStack(alignment: .bottom) { + VStack { + gameBody + HStack { + restart + Spacer() + shuffle + } + .padding(.horizontal) + } + .padding() + deckBody + } + } + + @State private var dealt = Set() + + private func deal(_ card: EmojiMemoryGame.Card) { + dealt.insert(card.id) + } + + private func isUndealt(_ card: EmojiMemoryGame.Card) -> Bool { + !dealt.contains(card.id) + } + + private func dealAnimation(for card: EmojiMemoryGame.Card) -> Animation { + var delay = 0.0 + if let index = game.cards.firstIndex(where: { $0.id == card.id }) { + delay = Double(index) * (const.DrawingConstants.totalDealDuration / Double(game.cards.count)) + } + + return Animation.easeInOut(duration: const.DrawingConstants.dealDuration).delay(delay) + } + + private func zIndex(of card: EmojiMemoryGame.Card) -> Double { + -Double(game.cards.firstIndex(where: { $0.id == card.id }) ?? 0) + } + + var gameBody: some View { AspectVGrid(items: game.cards, aspectRatio: const.DrawingConstants.gridAspectRatio) { card in - if card.isMatched && !card.isFaceUp { - Rectangle().opacity(0) + if isUndealt(card) || (card.isMatched && !card.isFaceUp) { + Color.clear } else { CardView(card: card) + .matchedGeometryEffect(id: card.id, in: dealingNamespace) .padding(const.DrawingConstants.gridPadding) + .zIndex(zIndex(of: card)) + .transition(AnyTransition.asymmetric(insertion: .identity, removal: .scale)) .onTapGesture { - game.choose(card) + withAnimation { + game.choose(card) + } } } } - .foregroundColor(.red) - .padding(.horizontal) + .foregroundColor(const.DrawingConstants.cardColor) + } + + var deckBody: some View { + ZStack { + ForEach(game.cards.filter(isUndealt)) { + card in CardView(card: card) + .matchedGeometryEffect(id: card.id, in: dealingNamespace) + .transition(AnyTransition.asymmetric(insertion: .opacity, removal: .identity)) + .zIndex(zIndex(of: card)) + } + } + .frame(width: const.DrawingConstants.undealtWidth, height: const.DrawingConstants.undealtHeight) + .foregroundColor(const.DrawingConstants.cardColor) + .onTapGesture { + for card in game.cards { + withAnimation(dealAnimation(for: card)) { + deal(card) + } + } + } + } + + var shuffle: some View { + Button("Shuffle") { + withAnimation { + game.shuffle() + } + } + } + + var restart: some View { + Button("Restart") { + withAnimation { + dealt = [] + game.restart() + } + } } } struct CardView: View { let card: EmojiMemoryGame.Card + @State private var animatedBonusRemaining: Double = 0 + var body: some View { GeometryReader { geometry in ZStack { - Pie(startAngle: Angle(degrees: 270), - endAngle: Angle(degrees: 380)) - .padding(const.DrawingConstants.piePadding) - .opacity(const.DrawingConstants.pieOpacity) - Text(card.content).font(font(in: geometry.size)) + Group { + if card.isConsumingBonusTime { + Pie(startAngle: Angle(degrees: 270), + endAngle: Angle(degrees: (1 - animatedBonusRemaining) * 360 - 90)) + .onAppear { + animatedBonusRemaining = card.bonusRemaining + withAnimation(.linear(duration: card.bonusTimeRemaining)) { + animatedBonusRemaining = 0 + } + } + } else { + Pie(startAngle: Angle(degrees: 0 - 90), + endAngle: Angle(degrees: (1 - card.bonusRemaining) * 360 - 90)) + } + } + .padding(const.DrawingConstants.piePadding) + .opacity(const.DrawingConstants.pieOpacity) + Text(card.content) + .rotationEffect(Angle.degrees(card.isMatched ? 360 : 0)) + .animation(Animation.easeInOut) + .font(Font.system(size: const.DrawingConstants.fontSize)) + .scaleEffect(scale(thatFits: geometry.size)) }.cadify(isFaceUp: card.isFaceUp) } } + private func scale(thatFits size: CGSize) -> CGFloat { + min(size.width, size.height) / const.DrawingConstants.fontSize * const.DrawingConstants.fontScale + } + private func font(in size: CGSize) -> Font { Font.system(size: min(size.width, size.height) * const.DrawingConstants.fontScale) } diff --git a/Memorize/MemoryGame.swift b/Memorize/MemoryGame.swift index 5e7626b..10d1579 100644 --- a/Memorize/MemoryGame.swift +++ b/Memorize/MemoryGame.swift @@ -29,6 +29,10 @@ struct MemoryGame where CardContent: Equatable { } } + mutating func shuffle() { + cards.shuffle() + } + func index(of card: Card) -> Int? { for index in 0 ..< cards.count { if cards[index].id == card.id { @@ -46,13 +50,67 @@ struct MemoryGame where CardContent: Equatable { cards.append(Card(content: content, id: pairIndex * 2)) cards.append(Card(content: content, id: pairIndex * 2 + 1)) } + cards.shuffle() } struct Card: Identifiable { - var isFaceUp = false - var isMatched = false + var isFaceUp = false { + didSet { + if isFaceUp { + startUsingBonusTime() + } else { + stopUsingBonusTime() + } + } + } + + var isMatched = false { + didSet { + stopUsingBonusTime() + } + } + let content: CardContent let id: Int + + var bonusTimeLimit = const.DrawingConstants.bonusTimeLimit + + private var faceUpTime: TimeInterval { + if let lastFaceUpDate = lastFaceUpDate { + return pastFaceUpTime + Date().timeIntervalSince(lastFaceUpDate) + } else { + return pastFaceUpTime + } + } + + var lastFaceUpDate: Date? + var pastFaceUpTime: TimeInterval = 0 + var bonusTimeRemaining: TimeInterval { + max(0, bonusTimeLimit - faceUpTime) + } + + var bonusRemaining: Double { + (bonusTimeLimit > 0 && bonusTimeRemaining > 0) ? bonusTimeRemaining / bonusTimeLimit : 0 + } + + var hasEarnedBonus: Bool { + isMatched && bonusTimeRemaining > 0 + } + + var isConsumingBonusTime: Bool { + isFaceUp && !isMatched && bonusTimeRemaining > 0 + } + + private mutating func startUsingBonusTime() { + if isConsumingBonusTime, lastFaceUpDate == nil { + lastFaceUpDate = Date() + } + } + + private mutating func stopUsingBonusTime() { + pastFaceUpTime = faceUpTime + lastFaceUpDate = nil + } } } diff --git a/Memorize/Pie.swift b/Memorize/Pie.swift index 2c8ef15..6fd43be 100644 --- a/Memorize/Pie.swift +++ b/Memorize/Pie.swift @@ -12,6 +12,16 @@ struct Pie: Shape { var endAngle: Angle var clockwise = false + var animatableData: AnimatablePair { + get { + AnimatablePair(startAngle.radians, endAngle.radians) + } + set { + startAngle = Angle.radians(newValue.first) + endAngle = Angle.radians(newValue.second) + } + } + func path(in rect: CGRect) -> Path { let center = CGPoint(x: rect.midX, y: rect.midY) let radius = min(rect.height, rect.width) / 2