XCode-Claude-Workflow/SoliCards/ViewModels/GameViewModel.swift
idev2025 0f989f5c86 feat: SoliCards v1.2.0 — native SwiftUI solitaire for iOS, iPadOS, macOS
Complete native rewrite of the web-based SoliCards game as a SwiftUI
multiplatform app targeting iOS 17+, iPadOS 17+, and macOS 14+.

Three solitaire variants (Klondike, Spider, FreeCell) with full game
rules, drag & drop, smart zoom layout, 6 themes, 4 difficulty levels,
SwiftData persistence, VoiceOver accessibility, and 57 unit tests.

Key features:
- MVVM + Protocol-Oriented Strategy architecture
- DragGesture with coordinate-space hit-testing (long press + drag)
- Smart zoom: cards auto-size to fit screen based on deepest column
- Landscape: 30% bigger cards with scrollable overflow (iOS)
- macOS: 120pt card cap, 92% height buffer for window resizing
- Auto-save, game resume, statistics tracking via SwiftData
- Privacy manifest, app icon, String Catalog, zero dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:33:52 -04:00

373 lines
11 KiB
Swift

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<Void, Never>?
// 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) }
}
}