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. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
117 lines
4.2 KiB
Swift
117 lines
4.2 KiB
Swift
import SwiftUI
|
|
|
|
struct GameBoardView: View {
|
|
@Bindable var viewModel: GameViewModel
|
|
let theme: GameTheme
|
|
let cardFaceStyle: CardFaceStyle
|
|
let cardBackDesign: CardBackDesign
|
|
|
|
private var isReady: Bool {
|
|
viewModel.state.phase != .notStarted && !viewModel.state.tableaus.isEmpty
|
|
}
|
|
|
|
private var deepestColumn: (faceDown: Int, faceUp: Int) {
|
|
var worst = (faceDown: 0, faceUp: 1)
|
|
for column in viewModel.state.tableaus {
|
|
let faceUp = column.reversed().prefix(while: { $0.isFaceUp }).count
|
|
let faceDown = column.count - faceUp
|
|
let depth = faceDown * 15 + max(0, faceUp - 1) * 25 + 100
|
|
let worstDepth = worst.faceDown * 15 + max(0, worst.faceUp - 1) * 25 + 100
|
|
if depth > worstDepth {
|
|
worst = (faceDown, faceUp)
|
|
}
|
|
}
|
|
return worst
|
|
}
|
|
|
|
var body: some View {
|
|
if isReady {
|
|
VStack(spacing: 0) {
|
|
ScoreBarView(
|
|
moves: viewModel.state.moves,
|
|
score: viewModel.state.score,
|
|
time: viewModel.elapsedSeconds.formattedTime,
|
|
theme: theme
|
|
)
|
|
|
|
GeometryReader { geometry in
|
|
let layout = CardLayout(
|
|
availableSize: geometry.size,
|
|
variant: viewModel.variant,
|
|
deepestColumn: deepestColumn
|
|
)
|
|
|
|
ScrollView(.vertical) {
|
|
boardContent(layout: layout)
|
|
.padding(.bottom, layout.horizontalPadding)
|
|
.frame(maxWidth: .infinity,
|
|
minHeight: landscapeMinHeight(viewportHeight: geometry.size.height,
|
|
isLandscape: layout.isLandscape),
|
|
alignment: .top)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.scrollDisabled(!viewModel.draggedCards.isEmpty)
|
|
.coordinateSpace(name: "board")
|
|
.onPreferenceChange(DropTargetPreferenceKey.self) { targets in
|
|
viewModel.dropTargets = targets
|
|
}
|
|
.overlay {
|
|
DraggedCardsOverlay(
|
|
viewModel: viewModel,
|
|
layout: layout,
|
|
cardFaceStyle: cardFaceStyle,
|
|
cardBackDesign: cardBackDesign
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.background(theme.backgroundColor)
|
|
.overlay {
|
|
if viewModel.isWon {
|
|
VictoryOverlayView {
|
|
viewModel.newGame()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Color.clear
|
|
.background(theme.backgroundColor)
|
|
}
|
|
}
|
|
|
|
private func landscapeMinHeight(viewportHeight: CGFloat, isLandscape: Bool) -> CGFloat? {
|
|
#if os(iOS)
|
|
return isLandscape ? viewportHeight * 1.3 : nil
|
|
#else
|
|
return nil
|
|
#endif
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func boardContent(layout: CardLayout) -> some View {
|
|
switch viewModel.variant {
|
|
case .klondike:
|
|
KlondikeBoardView(viewModel: viewModel, layout: layout,
|
|
theme: theme, cardFaceStyle: cardFaceStyle,
|
|
cardBackDesign: cardBackDesign)
|
|
case .spider:
|
|
SpiderBoardView(viewModel: viewModel, layout: layout,
|
|
theme: theme, cardFaceStyle: cardFaceStyle,
|
|
cardBackDesign: cardBackDesign)
|
|
case .freeCell:
|
|
FreeCellBoardView(viewModel: viewModel, layout: layout,
|
|
theme: theme, cardFaceStyle: cardFaceStyle,
|
|
cardBackDesign: cardBackDesign)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Int {
|
|
var formattedTime: String {
|
|
let minutes = self / 60
|
|
let seconds = self % 60
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|