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>
201 lines
7.9 KiB
Swift
201 lines
7.9 KiB
Swift
import Foundation
|
|
|
|
struct KlondikeRules: GameRules {
|
|
let variant: GameVariant = .klondike
|
|
|
|
func deal(deck: [Card]) -> GameSnapshot {
|
|
var remaining = deck
|
|
var tableaus: [[Card]] = []
|
|
|
|
// Deal 7 tableaus: column i gets i+1 cards, top card face-up
|
|
for i in 0..<7 {
|
|
var column: [Card] = []
|
|
for j in 0...i {
|
|
var card = remaining.removeFirst()
|
|
card.isFaceUp = (j == i)
|
|
column.append(card)
|
|
}
|
|
tableaus.append(column)
|
|
}
|
|
|
|
return GameSnapshot(
|
|
tableaus: tableaus,
|
|
foundations: [[], [], [], []],
|
|
stock: remaining,
|
|
waste: [],
|
|
freeCells: [],
|
|
moves: 0,
|
|
score: 0
|
|
)
|
|
}
|
|
|
|
func canMove(cards: [Card], from: CardLocation, to: CardLocation, state: GameSnapshot) -> Bool {
|
|
guard let firstCard = cards.first else { return false }
|
|
|
|
switch to {
|
|
case .foundation(let index):
|
|
guard cards.count == 1 else { return false }
|
|
guard index >= 0, index < state.foundations.count else { return false }
|
|
let foundation = state.foundations[index]
|
|
return MoveValidator.canPlaceOnFoundation(firstCard, topCard: foundation.last)
|
|
|
|
case .tableau(let index):
|
|
guard index >= 0, index < state.tableaus.count else { return false }
|
|
let tableau = state.tableaus[index]
|
|
if tableau.isEmpty {
|
|
return MoveValidator.isKing(firstCard)
|
|
}
|
|
guard let topCard = tableau.last, topCard.isFaceUp else { return false }
|
|
return MoveValidator.isAlternatingColor(firstCard, with: topCard)
|
|
&& MoveValidator.isDescending(firstCard, onto: topCard)
|
|
|
|
case .waste, .stock, .freeCell:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func drawFromStock(state: inout GameSnapshot, drawCount: Int) -> MoveAction? {
|
|
if state.stock.isEmpty {
|
|
guard !state.waste.isEmpty else { return nil }
|
|
// Reset: move waste back to stock, reversed
|
|
state.stock = state.waste.reversed().map { card in
|
|
var c = card
|
|
c.isFaceUp = false
|
|
return c
|
|
}
|
|
state.waste = []
|
|
return MoveAction(cards: [], from: .waste, to: .stock, didFlipCard: false, scoreChange: 0)
|
|
}
|
|
|
|
var drawn: [Card] = []
|
|
for _ in 0..<min(drawCount, state.stock.count) {
|
|
var card = state.stock.removeLast()
|
|
card.isFaceUp = true
|
|
drawn.append(card)
|
|
}
|
|
state.waste.append(contentsOf: drawn)
|
|
return MoveAction(cards: drawn, from: .stock, to: .waste, didFlipCard: true, scoreChange: 0)
|
|
}
|
|
|
|
func scoreForMove(from: CardLocation, to: CardLocation) -> Int {
|
|
switch (from, to) {
|
|
case (.waste, .foundation): return 10
|
|
case (.tableau, .foundation): return 10
|
|
case (.waste, .tableau): return 5
|
|
case (.foundation, .tableau): return -15
|
|
default: return 0
|
|
}
|
|
}
|
|
|
|
func isWon(state: GameSnapshot) -> Bool {
|
|
state.foundations.allSatisfy { $0.count == 13 }
|
|
}
|
|
|
|
func canAutoComplete(state: GameSnapshot) -> Bool {
|
|
guard state.stock.isEmpty, state.waste.isEmpty else { return false }
|
|
// All tableau cards must be face-up
|
|
return state.tableaus.allSatisfy { column in
|
|
column.allSatisfy { $0.isFaceUp }
|
|
}
|
|
}
|
|
|
|
func findHints(state: GameSnapshot, settings: DifficultySettings) -> [HintResult] {
|
|
guard settings.hintsEnabled else { return [] }
|
|
var hints: [HintResult] = []
|
|
|
|
// Priority 1: Aces to foundation
|
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
|
guard let topCard = tableau.last, topCard.isFaceUp, MoveValidator.isAce(topCard) else { continue }
|
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 1: Ace from waste
|
|
if let wasteTop = state.waste.last, MoveValidator.isAce(wasteTop) {
|
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
|
if MoveValidator.canPlaceOnFoundation(wasteTop, topCard: foundation.last) {
|
|
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .foundation(fIndex), priority: 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 2: Other foundation moves
|
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
|
guard let topCard = tableau.last, topCard.isFaceUp, !MoveValidator.isAce(topCard) else { continue }
|
|
for (fIndex, foundation) in state.foundations.enumerated() {
|
|
if MoveValidator.canPlaceOnFoundation(topCard, topCard: foundation.last) {
|
|
hints.append(HintResult(cards: [topCard], from: .tableau(tabIndex), to: .foundation(fIndex), priority: 2))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 3: Tableau moves that reveal face-down cards
|
|
for (tabIndex, tableau) in state.tableaus.enumerated() {
|
|
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
|
|
guard !faceUpCards.isEmpty else { continue }
|
|
let hasFaceDown = tableau.count > faceUpCards.count
|
|
|
|
guard hasFaceDown else { continue }
|
|
|
|
for (destIndex, _) in state.tableaus.enumerated() {
|
|
guard destIndex != tabIndex else { continue }
|
|
let cardsToMove = Array(faceUpCards)
|
|
if canMove(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), state: state) {
|
|
hints.append(HintResult(cards: cardsToMove, from: .tableau(tabIndex), to: .tableau(destIndex), priority: 3))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 4: Waste to tableau
|
|
if let wasteTop = state.waste.last {
|
|
for (tabIndex, _) in state.tableaus.enumerated() {
|
|
if canMove(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), state: state) {
|
|
hints.append(HintResult(cards: [wasteTop], from: .waste, to: .tableau(tabIndex), priority: 4))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 5: Draw from stock
|
|
if !state.stock.isEmpty {
|
|
hints.append(HintResult(cards: [], from: .stock, to: .waste, priority: 5))
|
|
}
|
|
|
|
return hints.sorted { $0.priority < $1.priority }
|
|
}
|
|
|
|
func canStackOnTableau(card: Card, onto target: Card) -> Bool {
|
|
MoveValidator.isAlternatingColor(card, with: target)
|
|
&& MoveValidator.isDescending(card, onto: target)
|
|
}
|
|
|
|
func canPickUp(cards: [Card], from: CardLocation, state: GameSnapshot) -> Bool {
|
|
guard !cards.isEmpty else { return false }
|
|
|
|
switch from {
|
|
case .waste:
|
|
return cards.count == 1 && cards[0] == state.waste.last
|
|
|
|
case .tableau(let index):
|
|
guard index >= 0, index < state.tableaus.count else { return false }
|
|
let tableau = state.tableaus[index]
|
|
guard cards.allSatisfy({ $0.isFaceUp }) else { return false }
|
|
// Cards must be a valid sequence from the bottom of the face-up portion
|
|
let faceUpCards = tableau.trailingSuffix(while: { $0.isFaceUp })
|
|
guard cards.count <= faceUpCards.count else { return false }
|
|
let startIdx = faceUpCards.count - cards.count
|
|
let expectedCards = Array(faceUpCards.dropFirst(startIdx))
|
|
return cards == expectedCards
|
|
|
|
case .foundation(let index):
|
|
guard cards.count == 1, index >= 0, index < state.foundations.count else { return false }
|
|
return cards[0] == state.foundations[index].last
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|