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>
373 lines
11 KiB
Swift
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) }
|
|
}
|
|
}
|