import Foundation import Observation import SwiftUI @MainActor @Observable final class GameViewModel { // MARK: - Dependencies private(set) var rules: GameRules private let soundManager = SoundManager() private let timerService = TimerService() // MARK: - Persistence var persistenceManager: PersistenceManager? private var autoSaveTask: Task? // MARK: - State private(set) var state = GameState() private(set) var variant: GameVariant var difficulty: Difficulty // MARK: - Drag state var draggedCards: [Card] = [] var dragSource: CardLocation? var dragPosition: CGPoint = .zero var dropTargets: [DropTargetData] = [] // MARK: - Hint state var currentHint: HintResult? var isShowingHint: Bool = false // MARK: - Timer var elapsedSeconds: Int { timerService.elapsedSeconds } // MARK: - Computed var canUndo: Bool { state.canUndo && undosRemaining > 0 } var isWon: Bool { state.phase == .won } private var undosRemaining: Int // MARK: - Init init(variant: GameVariant = .klondike, difficulty: Difficulty = .medium) { self.variant = variant self.difficulty = difficulty self.rules = GameRulesFactory.rules(for: variant) self.undosRemaining = difficulty.settings.maxUndos } // MARK: - Game Actions func newGame() { // Record loss for previous game if it was in progress if state.phase == .playing { persistenceManager?.recordLoss(variant: variant, difficulty: difficulty) } rules = GameRulesFactory.rules(for: variant) let deck = variant.deckCount == 2 ? Deck.double() : Deck.standard() let snapshot = rules.deal(deck: deck) state.reset(from: snapshot, variant: variant) undosRemaining = difficulty.settings.maxUndos timerService.reset() timerService.start() playSound(.cardFlip) scheduleAutoSave() } func changeVariant(to newVariant: GameVariant) { variant = newVariant newGame() } /// Resume a saved game from a GameRecord. func resumeGame(from record: GameRecord) { guard let snapshot = record.decodedSnapshot, let savedVariant = record.gameVariant, let savedDifficulty = record.gameDifficulty else { return } variant = savedVariant difficulty = savedDifficulty rules = GameRulesFactory.rules(for: variant) state.restore(from: snapshot) state.phase = .playing if variant.hasFreeCells && state.freeCells.isEmpty { state.freeCells = Array(repeating: nil, count: variant.freeCellCount) } undosRemaining = difficulty.settings.maxUndos timerService.reset() timerService.elapsedSeconds = record.elapsedSeconds timerService.start() } func tapCard(at location: CardLocation, cardIndex: Int) { guard state.phase == .playing else { return } switch location { case .stock: drawFromStock() case .waste: guard let card = state.waste.last else { return } if let dest = findBestDestination(for: [card], from: .waste) { executeMove(cards: [card], from: .waste, to: dest) } case .tableau(let tabIndex): let tableau = state.tableaus[tabIndex] guard cardIndex == tableau.count - 1, let card = tableau.last, card.isFaceUp else { return } if let dest = findBestDestination(for: [card], from: location) { executeMove(cards: [card], from: location, to: dest) } case .freeCell(let cellIndex): guard let card = state.freeCells[cellIndex] else { return } if let dest = findBestDestination(for: [card], from: location) { executeMove(cards: [card], from: location, to: dest) } case .foundation: break } } func beginDrag(cards: [Card], from: CardLocation) { guard state.phase == .playing else { return } guard rules.canPickUp(cards: cards, from: from, state: state.snapshot()) else { return } draggedCards = cards dragSource = from } func drop(at destination: CardLocation) -> Bool { guard let source = dragSource, !draggedCards.isEmpty else { cancelDrag() return false } let snapshot = state.snapshot() guard rules.canMove(cards: draggedCards, from: source, to: destination, state: snapshot) else { cancelDrag() return false } executeMove(cards: draggedCards, from: source, to: destination) clearDrag() return true } func cancelDrag() { clearDrag() } /// Attempt to drop at the given point in the board coordinate space. /// Falls back to cancel if no valid target found. func endDrag(at point: CGPoint) { guard !draggedCards.isEmpty else { clearDrag() return } // Find the drop target under the finger let candidates = dropTargets.filter { $0.frame.contains(point) } let target = candidates.min(by: { $0.frame.width * $0.frame.height < $1.frame.width * $1.frame.height }) if let target { _ = drop(at: target.location) } else { cancelDrag() } } func undo() { guard canUndo, let snapshot = state.popHistory() else { return } state.restore(from: snapshot) undosRemaining -= 1 playSound(.cardFlip) scheduleAutoSave() } func requestHint() { let hints = rules.findHints(state: state.snapshot(), settings: difficulty.settings) currentHint = hints.first if currentHint != nil { isShowingHint = true } } func drawFromStock() { guard state.phase == .playing else { return } state.pushHistory() var snapshot = state.snapshot() if rules.drawFromStock(state: &snapshot, drawCount: difficulty.settings.drawCount) != nil { state.restore(from: snapshot) state.moves += 1 playSound(.cardFlip) scheduleAutoSave() } } func autoComplete() { guard state.phase == .playing else { return } guard rules.canAutoComplete(state: state.snapshot()) else { return } state.phase = .autoCompleting Task { while state.phase == .autoCompleting { guard let (from, to) = AutoCompleter.findNextAutoMove(state: state.snapshot(), rules: rules) else { break } guard let card = cardAt(location: from) else { break } executeMove(cards: [card], from: from, to: to) try? await Task.sleep(for: .milliseconds(150)) } if rules.isWon(state: state.snapshot()) { handleWin() } else { state.phase = .playing } } } func pause() { guard state.phase == .playing else { return } state.phase = .paused timerService.stop() saveGameNow() } func resume() { guard state.phase == .paused else { return } state.phase = .playing timerService.start() } /// Save game state immediately (called on app background/close). func saveGameNow() { guard state.phase == .playing || state.phase == .paused else { return } persistenceManager?.saveGame( variant: variant, difficulty: difficulty, snapshot: state.snapshot(), elapsedSeconds: timerService.elapsedSeconds ) } var isSoundEnabled: Bool { get { soundManager.isEnabled } set { soundManager.isEnabled = newValue } } // MARK: - Private private func cardAt(location: CardLocation) -> Card? { switch location { case .tableau(let i): state.tableaus[i].last case .waste: state.waste.last case .freeCell(let i): state.freeCells[i] case .foundation(let i): state.foundations[i].last case .stock: state.stock.last } } private func executeMove(cards: [Card], from: CardLocation, to: CardLocation) { state.pushHistory() // Remove cards from source switch from { case .tableau(let index): state.tableaus[index].removeLast(cards.count) if let lastIndex = state.tableaus[index].indices.last, !state.tableaus[index][lastIndex].isFaceUp { state.tableaus[index][lastIndex].isFaceUp = true } case .waste: state.waste.removeLast() case .freeCell(let index): state.freeCells[index] = nil case .foundation(let index): state.foundations[index].removeLast() case .stock: break } // Add cards to destination switch to { case .tableau(let index): state.tableaus[index].append(contentsOf: cards) case .foundation(let index): state.foundations[index].append(contentsOf: cards) case .freeCell(let index): state.freeCells[index] = cards.first case .waste: state.waste.append(contentsOf: cards) case .stock: break } state.moves += 1 state.score += rules.scoreForMove(from: from, to: to) // Check for Spider complete sequences if variant == .spider, let spiderRules = rules as? SpiderRules { var snapshot = state.snapshot() if spiderRules.checkAndMoveCompleteSequences(state: &snapshot) { state.restore(from: snapshot) playSound(.cardPlace) } } // Check win if rules.isWon(state: state.snapshot()) { handleWin() } else { playSound(.cardPlace) scheduleAutoSave() } } private func handleWin() { state.phase = .won timerService.stop() playSound(.victory) // Record win in statistics persistenceManager?.recordWin( variant: variant, difficulty: difficulty, score: state.score, time: timerService.elapsedSeconds ) // Delete saved game (game is over) persistenceManager?.deleteSavedGame(for: variant) } /// Debounced auto-save: waits 2 seconds of inactivity before saving. private func scheduleAutoSave() { autoSaveTask?.cancel() autoSaveTask = Task { try? await Task.sleep(for: .seconds(2)) guard !Task.isCancelled else { return } saveGameNow() } } private func findBestDestination(for cards: [Card], from: CardLocation) -> CardLocation? { let destinations = rules.validDestinations(for: cards, from: from, state: state.snapshot()) return destinations.first { if case .foundation = $0 { return true }; return false } ?? destinations.first } private func clearDrag() { draggedCards = [] dragSource = nil dragPosition = .zero } private nonisolated func playSound(_ effect: SoundEffect) { Task { await soundManager.play(effect) } } }