CasinoGames/Baccarat/Baccarat/Views/Game/GameTableView.swift

430 lines
17 KiB
Swift

//
// GameTableView.swift
// Baccarat
//
// The main baccarat table view with all game elements.
//
import SwiftUI
import CasinoKit
/// The main game table view containing all game elements.
struct GameTableView: View {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .hundred
@State private var showSettings = false
@State private var showRules = false
@State private var showStats = false
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
// MARK: - Computed Properties
/// Whether we're on iPad or large screen
private var isLargeScreen: Bool {
horizontalSizeClass == .regular
}
/// Whether we're in landscape mode (compact vertical on iPad)
private var isLandscape: Bool {
verticalSizeClass == .compact
}
/// Extra bottom padding for landscape mode where vertical space is tight
private var bottomPadding: CGFloat {
isLandscape ? Design.Spacing.medium : Design.Spacing.xSmall
}
/// Smaller spacer height - reduced in landscape
private var smallSpacerHeight: CGFloat {
isLandscape ? Design.Spacing.xxSmall : Design.Spacing.small
}
/// Maximum width for game content on large screens
private var maxContentWidth: CGFloat {
isLandscape ? CasinoDesign.Size.maxContentWidthLandscape : CasinoDesign.Size.maxContentWidthPortrait
}
/// Whether we're on a small screen (like iPhone SE) where space is tight
private var isSmallScreen: Bool {
screenSize.height < 700
}
private var state: GameState {
gameState ?? GameState(settings: settings)
}
private var playerIsWinner: Bool {
state.lastResult == .playerWins
}
private var bankerIsWinner: Bool {
state.lastResult == .bankerWins
}
private var isTie: Bool {
state.lastResult == .tie
}
/// Whether we're in a dealing/result phase (vertical layout) vs betting phase (horizontal)
private var isDealing: Bool {
state.currentPhase != .betting
}
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Body
var body: some View {
GeometryReader { geometry in
ZStack {
// Table background - measures screen size for card sizing
TableBackgroundView()
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { size in
screenSize = size
}
// Main content
mainContent(geometry: geometry)
// Overlays
overlays
}
}
.onAppear {
if gameState == nil {
gameState = GameState(settings: settings)
}
}
.sheet(isPresented: $showSettings) {
if let state = gameState {
SettingsView(settings: settings, gameState: state) {
gameState?.applySettings()
}
}
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(results: state.roundHistory)
}
}
// MARK: - Private Views
@ViewBuilder
private func mainContent(geometry: GeometryProxy) -> some View {
// Use geometry to detect landscape on iPad (width > height and large screen)
let isLandscapeLayout = isLargeScreen && geometry.size.width > geometry.size.height
if isLandscapeLayout {
// Landscape iPad: RoadMap on left, game content on right
landscapeLayout
} else {
// Portrait or iPhone: vertical stack with RoadMap inline
portraitLayout
}
}
/// Landscape layout with TopBar spanning full width, RoadMap grid on left below TopBar
private var landscapeLayout: some View {
VStack(spacing: 0) {
// Top bar spans full width
TopBarView(
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
// Main content area with optional sidebar
HStack(spacing: 0) {
// Left side: Road map history grid
if settings.showHistory {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
// Header with reading instructions
VStack(alignment: .leading, spacing: 5) {
Text("HISTORY")
.font(.system(size: Design.BaseFontSize.xLarge, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text("↓ then →")
.font(.system(size: Design.BaseFontSize.large, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(.leading, Design.Spacing.small)
.padding(.horizontal, Design.Spacing.small)
.padding(.top, Design.Spacing.small)
// Grid-based road map (rows calculated dynamically)
RoadMapGridView(
results: state.recentResults,
dotSize: 32
)
.frame(maxHeight: .infinity)
Spacer(minLength: 0)
}
.frame(width: 240)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.light))
.padding(Design.Spacing.xSmall)
)
.padding(.leading, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
}
// Right side: Main game content
VStack(spacing: 0) {
Spacer(minLength: 0)
// Cards display area
CardsDisplayArea(
playerCards: state.visiblePlayerCards,
bankerCards: state.visibleBankerCards,
playerCardsFaceUp: state.playerCardsFaceUp,
bankerCardsFaceUp: state.bankerCardsFaceUp,
playerValue: state.playerHandValue,
bankerValue: state.bankerHandValue,
playerIsWinner: playerIsWinner,
bankerIsWinner: bankerIsWinner,
isTie: isTie,
showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed,
bettedOnPlayer: state.bettedOnPlayer,
isDealing: isDealing,
screenSize: screenSize
)
.frame(maxWidth: maxContentWidth)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .red, label: "CardsArea")
Spacer(minLength: 0)
// Betting table
BettingTableView(
gameState: state,
selectedChip: selectedChip
)
.frame(maxWidth: maxContentWidth)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .blue, label: "BetTable")
// Betting hint (static, below table, above chips)
if let hintInfo = state.currentHintInfo {
BettingHintView(
hint: hintInfo.text,
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
}
Spacer(minLength: Design.Spacing.xSmall)
// Chip selector - only shown during betting phase
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet
)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
Spacer(minLength: Design.Spacing.xSmall)
// Action buttons - hidden on small screens during dealing to save space
if !isDealing {
// Action buttons
ActionButtonsView(
gameState: state,
onDeal: {
Task {
await state.deal()
}
},
onClear: { state.clearBets() },
onNewRound: { state.newRound() }
)
.frame(maxWidth: maxContentWidth * 0.8)
.padding(.horizontal)
.padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
}
}
.frame(maxWidth: .infinity)
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.debugBorder(showDebugBorders, color: .white, label: "GameContent")
}
}
.safeAreaPadding(.bottom, Design.Spacing.small)
}
/// Portrait layout - vertical card layout, no RoadMap (shown only in landscape)
private var portraitLayout: some View {
VStack(spacing: 0) {
// Top bar with balance and info (from CasinoKit)
TopBarView(
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.shoe.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
// Cards display area - animates from horizontal to vertical when dealing
CardsDisplayArea(
playerCards: state.visiblePlayerCards,
bankerCards: state.visibleBankerCards,
playerCardsFaceUp: state.playerCardsFaceUp,
bankerCardsFaceUp: state.bankerCardsFaceUp,
playerValue: state.playerHandValue,
bankerValue: state.bankerHandValue,
playerIsWinner: playerIsWinner,
bankerIsWinner: bankerIsWinner,
isTie: isTie,
showAnimations: settings.showAnimations,
dealingSpeed: settings.dealingSpeed,
bettedOnPlayer: state.bettedOnPlayer,
isDealing: isDealing,
screenSize: screenSize
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .red, label: "CardsArea")
Spacer(minLength: smallSpacerHeight)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer2")
// Road map history - show in portrait before deal on larger screens
if settings.showHistory && !isSmallScreen && !isDealing {
RoadMapView(results: state.recentResults)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .orange, label: "RoadMap")
Spacer(minLength: smallSpacerHeight)
.debugBorder(showDebugBorders, color: .yellow, label: "Spacer3")
}
// Betting table
BettingTableView(
gameState: state,
selectedChip: selectedChip
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)
.padding(.horizontal, Design.Spacing.medium)
.debugBorder(showDebugBorders, color: .blue, label: "BetTable")
// Betting hint (static, below table, above chips)
if let hintInfo = state.currentHintInfo {
BettingHintView(
hint: hintInfo.text,
secondaryInfo: hintInfo.secondaryText,
style: hintInfo.style
)
.transition(.opacity)
.padding(.vertical, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .purple, label: "Hint")
}
// Chip selector - only shown during betting phase
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet
)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
// Action buttons - hidden on small screens during dealing to save space
if isSmallScreen && isDealing {
Spacer()
.frame(height: 5)
} else {
ActionButtonsView(
gameState: state,
onDeal: {
Task {
await state.deal()
}
},
onClear: { state.clearBets() },
onNewRound: { state.newRound() }
)
.frame(maxWidth: isLargeScreen ? maxContentWidth * 0.8 : .infinity)
.padding(.horizontal)
.padding(.bottom, bottomPadding)
.debugBorder(showDebugBorders, color: .green, label: "ActionBtns")
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.safeAreaPadding(.bottom)
.debugBorder(showDebugBorders, color: .white, label: "MainContent")
}
@ViewBuilder
private var overlays: some View {
// Result banner overlay
if state.showResultBanner, let result = state.lastResult {
ResultBannerView(
result: result,
totalWinnings: state.lastWinnings,
betResults: state.betResults,
playerHadPair: state.playerHadPair,
bankerHadPair: state.bankerHadPair,
currentBalance: state.balance,
minBet: state.minBet,
onNewRound: { state.newRound() },
onGameOver: {
state.resetGame()
}
)
.transition(.opacity)
// Confetti for wins (from CasinoKit)
if state.lastWinnings > 0 {
ConfettiView()
}
}
// Game Over overlay
if state.balance == 0 && state.currentBets.isEmpty && !state.isAnimating {
GameOverView(
roundsPlayed: state.roundHistory.count,
onPlayAgain: { state.resetGame() }
)
.transition(.opacity)
}
}
}
// MARK: - Previews
#Preview("Game Table") {
GameTableView()
}