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>
80 lines
2.6 KiB
Swift
80 lines
2.6 KiB
Swift
import SwiftUI
|
|
|
|
struct CardLayout {
|
|
let availableSize: CGSize
|
|
let variant: GameVariant
|
|
let deepestColumn: (faceDown: Int, faceUp: Int)
|
|
|
|
var columnCount: Int { variant.tableauCount }
|
|
var isLandscape: Bool { availableSize.width > availableSize.height }
|
|
|
|
/// Card size — constrained by the tighter of width or height.
|
|
var cardWidth: CGFloat {
|
|
let computed = min(widthConstrainedCardWidth, heightConstrainedCardWidth)
|
|
#if os(macOS)
|
|
// Cap card width on macOS so cards don't get absurdly large on big displays
|
|
return min(computed, 120)
|
|
#else
|
|
return computed
|
|
#endif
|
|
}
|
|
|
|
var cardHeight: CGFloat { cardWidth * 1.4 }
|
|
|
|
/// Padding between cards. In landscape, this expands to fill the available width
|
|
/// so cards spread evenly across the screen.
|
|
var horizontalPadding: CGFloat {
|
|
let basePadding = max(2, availableSize.width * 0.008)
|
|
|
|
// If height-constrained (landscape), distribute extra width as padding
|
|
if heightConstrainedCardWidth < widthConstrainedCardWidth {
|
|
let totalCardWidth = CGFloat(columnCount) * cardWidth
|
|
let availableForPadding = availableSize.width - totalCardWidth
|
|
let gaps = CGFloat(columnCount + 1)
|
|
return max(basePadding, availableForPadding / gaps)
|
|
}
|
|
|
|
return basePadding
|
|
}
|
|
|
|
var verticalOverlapFaceDown: CGFloat { cardHeight * 0.15 }
|
|
var verticalOverlapFaceUp: CGFloat { cardHeight * 0.25 }
|
|
var cornerRadius: CGFloat { cardWidth * 0.08 }
|
|
|
|
func cardSize() -> CGSize {
|
|
CGSize(width: cardWidth, height: cardHeight)
|
|
}
|
|
|
|
var touchTargetPadding: CGFloat {
|
|
max(0, (44 - cardWidth) / 2)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private var basePadding: CGFloat {
|
|
max(2, availableSize.width * 0.008)
|
|
}
|
|
|
|
private var widthConstrainedCardWidth: CGFloat {
|
|
let totalPadding = basePadding * CGFloat(columnCount + 1)
|
|
return (availableSize.width - totalPadding) / CGFloat(columnCount)
|
|
}
|
|
|
|
private var heightConstrainedCardWidth: CGFloat {
|
|
let d = CGFloat(deepestColumn.faceDown)
|
|
let u = CGFloat(max(0, deepestColumn.faceUp - 1))
|
|
let totalFactor = 1.0 + 0.1 + (d * 0.15 + u * 0.25 + 1.0)
|
|
|
|
// Use 92% of available height to leave room for VStack spacing,
|
|
// bottom padding, and any unmeasured platform chrome
|
|
let usableHeight = availableSize.height * 0.92
|
|
let base = max(30, usableHeight / (1.4 * totalFactor))
|
|
|
|
#if os(iOS)
|
|
return isLandscape ? base * 1.3 : base
|
|
#else
|
|
return base
|
|
#endif
|
|
}
|
|
}
|