Last active
January 13, 2024 21:31
-
-
Save SLIB53/7e38e9735001263ac587a77cecc29457 to your computer and use it in GitHub Desktop.
This file contains 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
// | |
// MemorizeApp.swift | |
// Memorize | |
// | |
// Created by Akil Krishnan on 12/13/23. | |
// | |
import SwiftUI | |
@main | |
struct MemorizeApp: App { | |
var body: some Scene { | |
WindowGroup { | |
GameView(viewing: GamePresenter(presenting: Self.makeRandomGame())) | |
} | |
} | |
private static func makeRandomGame() -> Game { | |
[ | |
{ Game(Pet.self, Pair.self) }, | |
{ Game(Pet.self, Triplet.self) }, | |
{ Game(Pet.self, Quadruplet.self) }, | |
{ Game(Bakery.self, Pair.self) }, | |
{ Game(Bakery.self, Triplet.self) }, | |
{ Game(Bakery.self, Quadruplet.self) }, | |
{ Game(Fantasy.self, Pair.self) }, | |
{ Game(Fantasy.self, Triplet.self) }, | |
{ Game(Fantasy.self, Quadruplet.self) }, | |
{ Game(Ocean.self, Pair.self) }, | |
{ Game(Ocean.self, Triplet.self) }, | |
{ Game(Ocean.self, Quadruplet.self) } | |
].randomElement()!() | |
} | |
} | |
// MARK: - Views | |
struct GameView: View { | |
private let presenter: GamePresenter | |
var body: some View { | |
let cardPresentations = presenter.cardPresentations | |
return ScrollView { | |
LazyVGrid(columns: Array(repeating: GridItem(), count: 4)) { | |
ForEach( | |
cardPresentations, | |
content: makeCardView(from:) | |
) | |
} | |
.animation(.default, value: cardPresentations) | |
} | |
} | |
init(viewing presenter: GamePresenter) { | |
self.presenter = presenter | |
} | |
private func makeCardView( | |
from presentation: GamePresenter.CardPresentation | |
) -> CardView { | |
CardView( | |
viewing: presentation, | |
chooseIntention: { presenter.choose(cardPresentedBy: presentation) } | |
) | |
} | |
} | |
extension GameView { | |
struct CardView: View { | |
private static let cornerRadius = 10.0 | |
private static let frameHeight = 120.0 | |
private let presentation: GamePresenter.CardPresentation | |
private let chooseIntention: () -> Void | |
var body: some View { | |
if presentation.isFaceUp { faceUpView } else { faceDownView } | |
} | |
private var faceUpView: some View { | |
ZStack { | |
RoundedRectangle(cornerRadius: Self.cornerRadius) | |
.fill(.thickMaterial) | |
Text(presentation.icon) | |
.dynamicTypeSize(.xLarge) | |
} | |
.frame(height: Self.frameHeight) | |
} | |
private var faceDownView: some View { | |
ZStack { | |
RoundedRectangle(cornerRadius: Self.cornerRadius) | |
.fill(.tint) | |
} | |
.frame(height: Self.frameHeight) | |
.onTapGesture(perform: chooseIntention) | |
} | |
init( | |
viewing presentation: GamePresenter.CardPresentation, | |
chooseIntention: @escaping () -> Void | |
) { | |
self.presentation = presentation | |
self.chooseIntention = chooseIntention | |
} | |
} | |
} | |
// MARK: - View Models | |
@Observable | |
class GamePresenter { | |
private var game: Game | |
var cardPresentations: [CardPresentation] { | |
game.cards.map(CardPresentation.init(card:)) | |
} | |
init(presenting game: Game) { | |
self.game = game | |
} | |
// MARK: - Intents | |
func choose(cardPresentedBy presentation: CardPresentation) { | |
do { | |
game.maintain() | |
defer { game.maintainMatches() } | |
try game.flipCardFaceUp(presentation.card) | |
} catch Game.GameError.CardNotFound { | |
print("warning: attempted to flip non-existent card") | |
} catch { | |
print("error: unknown error", error) | |
} | |
} | |
} | |
extension GamePresenter { | |
struct CardPresentation: Identifiable, Equatable { | |
let card: any Card | |
var id: Int { card.id } | |
var isFaceUp: Bool { card.isFaceUp } | |
var icon: String { Self.makeIcon(from: card) } | |
static func == (lhs: Self, rhs: Self) -> Bool { | |
lhs.card.hashValue == rhs.card.hashValue | |
} | |
private static func makeIcon(from card: any Card) -> String { | |
func renderFace(_ face: any CardFace) -> String { | |
switch face { | |
case let face as Pet: | |
switch face { | |
case .dog: | |
"🐶" | |
case .cat: | |
"🐱" | |
case .bird: | |
"🐤" | |
case .fish: | |
"🐟" | |
} | |
case let face as Bakery: | |
switch face { | |
case .bread: | |
"🍞" | |
case .croissant: | |
"🥐" | |
case .cake: | |
"🍰" | |
case .cupcake: | |
"🧁" | |
} | |
case let face as Fantasy: | |
switch face { | |
case .fairy: | |
"🧚♀️" | |
case .elf: | |
"🧝♀️" | |
case .wizard: | |
"🧙♂️" | |
case .mermaid: | |
"🧜♀️" | |
} | |
case let face as Ocean: | |
switch face { | |
case .merman: | |
"🧜♂️" | |
case .whale: | |
"🐳" | |
case .waves: | |
"🌊" | |
case .sailboat: | |
"⛵️" | |
} | |
default: | |
"?" | |
} | |
} | |
return card.isMatched ? "🎉" : renderFace(card.face) | |
} | |
} | |
} | |
// MARK: - Models | |
struct Game { | |
private let occurrenceType: any CardOccurrence.Type | |
var cards: [any Card] | |
init<Occurrence, Face>( | |
_ faceType: Face.Type, | |
_ occurrenceType: Occurrence.Type | |
) | |
where Face: CardFace, Occurrence: CardOccurrence { | |
self.occurrenceType = occurrenceType | |
cards = BasicCard.makeOccurrences(faceType, occurrenceType) | |
.shuffled() | |
} | |
mutating func maintain() { | |
Self.groupDirtyCardIndexesByFace(cards, occurrenceType) | |
.forEach { | |
if $0.count == occurrenceType.allCases.count { | |
$0.forEach { index in | |
cards[index].isMatched = true | |
} | |
} else { | |
$0.forEach { index in | |
cards[index].isFaceUp = false | |
} | |
} | |
} | |
} | |
mutating func maintainMatches() { | |
Self.groupDirtyCardIndexesByFace(cards, occurrenceType) | |
.filter{ $0.count == occurrenceType.allCases.count } | |
.forEach { | |
$0.forEach { index in | |
cards[index].isMatched = true | |
} | |
} | |
} | |
mutating func flipCardFaceUp(_ card: any Card) throws { | |
guard let index = cards.firstIndex(where: { $0.id == card.id }) else { | |
throw GameError.CardNotFound | |
} | |
cards[index].isFaceUp = true | |
} | |
private static func groupDirtyCardIndexesByFace<Occurrence>( | |
_ cards: [any Card], | |
_ occurrenceType: Occurrence.Type | |
) -> [[Int]] | |
where Occurrence: CardOccurrence { | |
let maxOccurrences = occurrenceType.allCases.count | |
func isDirty(_ card: any Card) -> Bool { | |
card.isFaceUp && !(card.isMatched) | |
} | |
let candidate = Dictionary( | |
grouping: cards.enumerated().filter({ isDirty($0.1) }), | |
by: { $0.1.face.hashValue } | |
) | |
.values | |
.map { | |
$0.map { enumeratedCard in enumeratedCard.0 } | |
} | |
guard | |
candidate.count == 1 && candidate.first!.count >= maxOccurrences | |
|| candidate.count > 1 | |
else { | |
return [] | |
} | |
return candidate | |
} | |
enum GameError: Error { | |
case CardNotFound | |
} | |
} | |
protocol Card: Identifiable, Hashable { | |
var id: Int { get } | |
var face: any CardFace { get } | |
var occurrence: any CardOccurrence { get } | |
var isFaceUp: Bool { get set } | |
var isMatched: Bool { get set } | |
} | |
extension Card { | |
var id: Int { | |
var hasher = Hasher() | |
hasher.combine(face) | |
hasher.combine(occurrence) | |
return hasher.finalize() | |
} | |
} | |
struct BasicCard: Card { | |
let face: any CardFace | |
let occurrence: any CardOccurrence | |
var isFaceUp = false | |
var isMatched = false | |
static func makeOccurrences<Face, Occurrence>( | |
_ faceType: Face.Type, | |
_ occurrenceType: Occurrence.Type | |
) -> [BasicCard] | |
where Face: CardFace, Occurrence: CardOccurrence { | |
Face.allCases.flatMap { face in | |
Occurrence.allCases.map { occurrence in | |
BasicCard(face: face, occurrence: occurrence) | |
} | |
} | |
} | |
static func == (lhs: BasicCard, rhs: BasicCard) -> Bool { | |
lhs.face.hashValue == rhs.face.hashValue | |
&& lhs.occurrence.hashValue == rhs.occurrence.hashValue | |
} | |
func hash(into hasher: inout Hasher) { | |
hasher.combine(id) | |
hasher.combine(isFaceUp) | |
hasher.combine(isMatched) | |
} | |
} | |
protocol CardFace: CaseIterable, Hashable, Equatable { } | |
enum Pet: CardFace { | |
case dog, cat, bird, fish | |
} | |
enum Bakery: CardFace { | |
case bread, croissant, cake, cupcake | |
} | |
enum Fantasy: CardFace { | |
case fairy, elf, wizard, mermaid | |
} | |
enum Ocean: CardFace { | |
case merman, whale, waves, sailboat | |
} | |
protocol CardOccurrence: CaseIterable, Hashable, Equatable { } | |
enum Pair: CardOccurrence { | |
case first, second | |
} | |
enum Triplet: CardOccurrence { | |
case first, second, third | |
} | |
enum Quadruplet: CardOccurrence { | |
case first, second, third, fourth | |
} | |
#Preview("Pet | Pair") { | |
GameView(viewing: GamePresenter(presenting: Game(Pet.self, Pair.self))) | |
} | |
#Preview("Pet | Triplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Pet.self, Triplet.self))) | |
} | |
#Preview("Pet | Quadruplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Pet.self, Quadruplet.self))) | |
} | |
#Preview("Bakery | Pair") { | |
GameView(viewing: GamePresenter(presenting: Game(Bakery.self, Pair.self))) | |
} | |
#Preview("Bakery | Triplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Bakery.self, Triplet.self))) | |
} | |
#Preview("Bakery | Quadruplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Bakery.self, Quadruplet.self))) | |
} | |
#Preview("Fantasy | Pair") { | |
GameView(viewing: GamePresenter(presenting: Game(Fantasy.self, Pair.self))) | |
} | |
#Preview("Fantasy | Triplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Fantasy.self, Triplet.self))) | |
} | |
#Preview("Fantasy | Quadruplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Fantasy.self, Quadruplet.self))) | |
} | |
#Preview("Ocean | Pair") { | |
GameView(viewing: GamePresenter(presenting: Game(Ocean.self, Pair.self))) | |
} | |
#Preview("Ocean | Triplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Ocean.self, Triplet.self))) | |
} | |
#Preview("Ocean | Quadruplet") { | |
GameView(viewing: GamePresenter(presenting: Game(Ocean.self, Quadruplet.self))) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment