XCode-Claude-Workflow/SoliCards/Views/Game/GameBoardView.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

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)
}
}