169 lines
6.4 KiB
Swift
169 lines
6.4 KiB
Swift
//
|
||
// BlackjackTableView.swift
|
||
// Blackjack
|
||
//
|
||
// The main table layout showing dealer and player hands.
|
||
//
|
||
|
||
import SwiftUI
|
||
import CasinoKit
|
||
|
||
struct BlackjackTableView: View {
|
||
@Bindable var state: GameState
|
||
let onPlaceBet: () -> Void
|
||
|
||
// MARK: - Environment
|
||
|
||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||
|
||
// MARK: - State
|
||
|
||
/// Screen dimensions measured from container for responsive sizing
|
||
@State private var screenHeight: CGFloat = 800
|
||
@State private var screenWidth: CGFloat = 375
|
||
|
||
/// Whether to show Hi-Lo card count values on cards.
|
||
var showCardCount: Bool { state.settings.showCardCount }
|
||
|
||
// MARK: - Scaled Metrics
|
||
|
||
@ScaledMetric(relativeTo: .headline) private var labelFontSize: CGFloat = Design.BaseFontSize.medium
|
||
@ScaledMetric(relativeTo: .title) private var valueFontSize: CGFloat = Design.BaseFontSize.xLarge
|
||
@ScaledMetric(relativeTo: .caption) private var hintFontSize: CGFloat = Design.BaseFontSize.small
|
||
|
||
// MARK: - Dynamic Card Sizing
|
||
|
||
/// Whether we're in landscape mode
|
||
/// - iPhones: use verticalSizeClass == .compact
|
||
/// - iPads: use screen dimensions (since iPads always report .regular)
|
||
private var isLandscape: Bool {
|
||
if DeviceInfo.isPad {
|
||
return screenWidth > screenHeight
|
||
}
|
||
return verticalSizeClass == .compact
|
||
}
|
||
|
||
/// Card width derived from screen height percentage and card aspect ratio
|
||
/// cardHeight = maxHeight × percentage, then cardWidth = cardHeight / aspectRatio
|
||
private var cardWidth: CGFloat {
|
||
let maxHeight = max(screenWidth, screenHeight)
|
||
let heightPercentage: CGFloat = 0.19 // Card takes 12% of screen height
|
||
let cardHeight = maxHeight * heightPercentage
|
||
return cardHeight
|
||
}
|
||
|
||
/// Card overlap scales proportionally with card width
|
||
private var cardSpacing: CGFloat {
|
||
// Overlap ratio: roughly -55% of card width
|
||
cardWidth * -0.55
|
||
}
|
||
|
||
/// Fixed height for the hint area to prevent layout shifts
|
||
private let hintAreaHeight: CGFloat = 44
|
||
|
||
// Use global debug flag from Design constants
|
||
private var showDebugBorders: Bool { Design.showDebugBorders }
|
||
|
||
/// Dynamic spacer height based on screen size.
|
||
/// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max)
|
||
/// This produces smooth scaling across all device sizes:
|
||
/// - iPhone SE (~667pt): ~20pt
|
||
/// - iPhone Pro Max (~932pt): ~76pt
|
||
/// - iPad Mini (~1024pt): ~95pt
|
||
/// - iPad Pro 12.9" (~1366pt): ~150pt (capped)
|
||
private var dealerPlayerSpacing: CGFloat {
|
||
let baseline: CGFloat = 550 // Below this, use minimum
|
||
let scale: CGFloat = 0.18 // 20% of height above baseline
|
||
let minSpacing: CGFloat = 10 // Floor for smallest screens
|
||
let maxSpacing: CGFloat = 150 // Ceiling for largest screens
|
||
|
||
let calculated = (screenHeight - baseline) * scale
|
||
return min(maxSpacing, max(minSpacing, calculated))
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: Design.Spacing.small) {
|
||
// Dealer area
|
||
DealerHandView(
|
||
hand: state.dealerHand,
|
||
showHoleCard: state.shouldShowDealerHoleCard,
|
||
showCardCount: showCardCount,
|
||
cardWidth: cardWidth,
|
||
cardSpacing: cardSpacing
|
||
)
|
||
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
|
||
|
||
// Flexible space between dealer and player - scales with screen size
|
||
Spacer(minLength: dealerPlayerSpacing)
|
||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))")
|
||
|
||
// Player hands area - only show when there are cards dealt
|
||
if state.playerHands.first?.cards.isEmpty == false {
|
||
PlayerHandsView(
|
||
hands: state.playerHands,
|
||
activeHandIndex: state.activeHandIndex,
|
||
isPlayerTurn: state.isPlayerTurn,
|
||
showCardCount: showCardCount,
|
||
cardWidth: cardWidth,
|
||
cardSpacing: cardSpacing
|
||
)
|
||
.padding(.bottom, 5)
|
||
.transition(.opacity)
|
||
.debugBorder(showDebugBorders, color: .green, label: "Player")
|
||
}
|
||
|
||
// Betting zone (when betting)
|
||
if state.currentPhase == .betting {
|
||
Spacer()
|
||
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
|
||
|
||
BettingZoneView(
|
||
betAmount: state.currentBet,
|
||
minBet: state.settings.minBet,
|
||
maxBet: state.settings.maxBet,
|
||
onTap: onPlaceBet
|
||
)
|
||
.transition(.scale.combined(with: .opacity))
|
||
.debugBorder(showDebugBorders, color: .blue, label: "BetZone")
|
||
|
||
// Betting hint based on count (only when card counting enabled)
|
||
if let hint = state.bettingHint {
|
||
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
|
||
.transition(.opacity)
|
||
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
|
||
}
|
||
} else {
|
||
// Fixed-height hint area to prevent layout shifts during player turn
|
||
ZStack {
|
||
if let hint = state.currentHint {
|
||
HintView(hint: hint)
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.frame(height: hintAreaHeight)
|
||
.debugBorder(showDebugBorders, color: .orange, label: "HintArea")
|
||
}
|
||
}
|
||
.padding(.horizontal, Design.Spacing.large)
|
||
.padding(.vertical, Design.Spacing.medium)
|
||
.onGeometryChange(for: CGSize.self) { proxy in
|
||
proxy.size
|
||
} action: { size in
|
||
screenWidth = size.width
|
||
screenHeight = size.height
|
||
}
|
||
.debugBorder(showDebugBorders, color: .white, label: "TableView")
|
||
.animation(.spring(duration: Design.Animation.springDuration), value: state.currentPhase)
|
||
}
|
||
}
|
||
|
||
// MARK: - Previews
|
||
|
||
#Preview {
|
||
ZStack {
|
||
Color.Table.felt.ignoresSafeArea()
|
||
Text("Use GameTableView for full preview")
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|