CasinoGames/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift

242 lines
10 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 selectedChip: ChipDenomination
/// Full screen size passed from parent (stable - measured from TableBackgroundView)
let fullScreenSize: CGSize
// MARK: - Environment
@Environment(\.verticalSizeClass) private var verticalSizeClass
// MARK: - Computed from stable screen size
private var screenWidth: CGFloat { fullScreenSize.width }
private var screenHeight: CGFloat { fullScreenSize.height }
/// 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
// 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 based on full screen height (stable - doesn't change with content)
private var cardWidth: CGFloat {
let maxDimension = screenHeight
let percentage: CGFloat = 0.18 // ~10% of screen
return maxDimension * percentage
}
/// Card overlap scales proportionally with card width
private var cardSpacing: CGFloat {
// Overlap ratio: roughly -55% of card width
cardWidth * -0.55
}
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Hint Toast Helper
/// Shows the hint toast with auto-dismiss timer.
private func showHintToastWithTimer(state: GameState) {
// Generate new ID to invalidate any pending dismiss tasks
let currentID = UUID()
state.hintDisplayID = currentID
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay, but only if this is still the active hint session
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
// Only dismiss if no newer hint has arrived
if state.hintDisplayID == currentID {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
}
var body: some View {
VStack(spacing: 0) {
// Dealer area
DealerHandView(
hand: state.dealerHand,
showHoleCard: state.shouldShowDealerHoleCard,
showCardCount: showCardCount,
showAnimations: state.settings.showAnimations,
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCount: state.dealerVisibleCardCount
)
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Top spacer
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "TopSpacer")
// Card count view centered between dealer and player
if showCardCount {
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
.debugBorder(showDebugBorders, color: .mint, label: "CardCount")
}
// Bottom spacer
Spacer(minLength: Design.Spacing.small)
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
// Player hands area - only show when there are cards dealt
if state.playerHands.first?.cards.isEmpty == false {
ZStack {
PlayerHandsContainer(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount,
showAnimations: state.settings.showAnimations,
dealingSpeed: state.settings.dealingSpeed,
cardWidth: cardWidth,
cardSpacing: cardSpacing,
visibleCardCounts: state.playerHandsVisibleCardCount,
currentHint: state.currentHint,
showHintToast: state.showHintToast
)
// Side bet toasts (positioned on left/right sides to not cover cards)
if state.settings.sideBetsEnabled && state.showSideBetToasts {
HStack {
// PP on left
if state.perfectPairsBet > 0, let ppResult = state.perfectPairsResult {
SideBetToastView(
title: "PP",
result: ppResult.displayName,
isWin: ppResult.isWin,
amount: ppResult.isWin ? state.perfectPairsBet * ppResult.payout : -state.perfectPairsBet,
showOnLeft: true
)
}
Spacer()
// 21+3 on right
if state.twentyOnePlusThreeBet > 0, let topResult = state.twentyOnePlusThreeResult {
SideBetToastView(
title: "21+3",
result: topResult.displayName,
isWin: topResult.isWin,
amount: topResult.isWin ? state.twentyOnePlusThreeBet * topResult.payout : -state.twentyOnePlusThreeBet,
showOnLeft: false
)
}
}
.padding(.horizontal, Design.Spacing.small)
}
}
.onChange(of: state.currentHint) { oldHint, newHint in
// Show toast when a new hint appears
if let hint = newHint, hint != oldHint {
showHintToastWithTimer(state: state)
} else if newHint == nil {
// Hide immediately when no hint
state.hintDisplayID = UUID() // Invalidate any pending dismiss tasks
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
.onChange(of: state.playerHands.count) { _, _ in
// Show hint when hands are added (split occurred)
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
.onChange(of: state.activeHandIndex) { _, _ in
// Show hint when active hand changes (moved to next hand after split)
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
.onChange(of: state.activeHand?.cards.count) { _, newCount in
// Show hint when a card is added to the active hand (after hit)
guard let count = newCount, count > 2, state.currentHint != nil else { return }
// Small delay to let card animation settle before showing hint
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(250))
if state.currentHint != nil {
showHintToastWithTimer(state: state)
}
}
}
.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(
state: state,
selectedChip: selectedChip
)
.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 {
BlackjackBettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity)
.padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
}
}
}
.padding(.horizontal, Design.Spacing.large)
.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)
}
}