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