228 lines
10 KiB
Swift
228 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 }
|
||
|
||
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)
|
||
}
|
||
}
|