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

228 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 }
var body: some View {
let currentCardWidth = cardWidth // Capture for logging
let _ = Design.debugLog("🃏 Card sizing: screenHeight=\(Int(screenHeight)), cardWidth=\(Int(currentCardWidth)), fullScreenSize=\(Int(fullScreenSize.width))×\(Int(fullScreenSize.height))")
VStack(spacing: 0) {
// Dealer area - fixedSize prevents expanding beyond intrinsic content size
DealerHandView(
hand: state.dealerHand,
showHoleCard: state.shouldShowDealerHoleCard,
showCardCount: showCardCount,
cardWidth: currentCardWidth,
cardSpacing: cardSpacing
)
.fixedSize(horizontal: false, vertical: true)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
Design.debugLog("📦 Dealer hand frame: \(Int(size.width))×\(Int(size.height))")
}
.debugBorder(showDebugBorders, color: .red, label: "Dealer")
// Top spacer - limited height to prevent layout jumping
Spacer(minLength: Design.Spacing.small)
.frame(maxHeight: Design.Spacing.xxLarge)
.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 - limited height to prevent layout jumping
Spacer(minLength: Design.Spacing.small)
.frame(maxHeight: Design.Spacing.xxLarge)
.debugBorder(showDebugBorders, color: .yellow, label: "BottomSpacer")
// Player hands area - only show when there are cards dealt
// fixedSize prevents the view from expanding beyond its intrinsic content size
if state.playerHands.first?.cards.isEmpty == false {
ZStack {
PlayerHandsView(
hands: state.playerHands,
activeHandIndex: state.activeHandIndex,
isPlayerTurn: state.isPlayerTurn,
showCardCount: showCardCount,
cardWidth: currentCardWidth,
cardSpacing: cardSpacing
)
.fixedSize(horizontal: false, vertical: true)
// 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)
}
}
.overlay {
// Hint toast overlaying player hands (auto-dismisses)
if state.showHintToast, let hint = state.currentHint {
HintView(hint: hint)
.transition(.scale.combined(with: .opacity))
}
}
.onChange(of: state.currentHint) { oldHint, newHint in
// Show toast when a new hint appears
if let hint = newHint, hint != oldHint {
// 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
}
}
}
} 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
}
}
}
.padding(.bottom, 5)
.transition(.opacity)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
Design.debugLog("📦 Player hands frame: \(Int(size.width))×\(Int(size.height))")
}
.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 {
BettingHintView(hint: hint, trueCount: state.engine.trueCount)
.transition(.opacity)
.padding(.vertical, 10)
.debugBorder(showDebugBorders, color: .purple, label: "BetHint")
}
}
}
.padding(.horizontal, Design.Spacing.large)
.onGeometryChange(for: CGSize.self) { $0.size } action: { size in
Design.debugLog("📦 BlackjackTableView frame: \(Int(size.width))×\(Int(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)
}
}