430 lines
17 KiB
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()
|
|
}
|