CasinoGames/Blackjack/Views/GameTableView.swift

346 lines
10 KiB
Swift

//
// GameTableView.swift
// Blackjack
//
// Main game container view.
//
import SwiftUI
import CasinoKit
struct GameTableView: View {
@State private var settings = GameSettings()
@State private var gameState: GameState?
@State private var selectedChip: ChipDenomination = .twentyFive
// MARK: - Sheet State
@State private var showSettings = false
@State private var showRules = false
@State private var showStats = false
// MARK: - Environment
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// Whether we're on iPad
private var isIPad: Bool {
horizontalSizeClass == .regular
}
/// Maximum content width based on device
private var maxContentWidth: CGFloat {
if isIPad {
return verticalSizeClass == .compact
? Design.Size.maxContentWidthLandscape
: Design.Size.maxContentWidthPortrait
}
return .infinity
}
// MARK: - Body
var body: some View {
Group {
if let state = gameState {
mainGameView(state: state)
} else {
ProgressView()
.task {
gameState = GameState(settings: settings)
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
if let state = gameState {
StatisticsSheetView(state: state)
}
}
}
// MARK: - Main Game View
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
ZStack {
// Background
TableBackgroundView()
VStack(spacing: 0) {
// Top bar
TopBarView(
balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() },
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.frame(maxWidth: maxContentWidth)
// Table layout
BlackjackTableView(
state: state,
onPlaceBet: { placeBet(state: state) }
)
.frame(maxWidth: maxContentWidth)
Spacer()
// Chip selector
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.currentBet,
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.small)
// Action buttons
ActionButtonsView(state: state)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.medium)
}
.frame(maxWidth: .infinity)
// Result banner overlay
if state.showResultBanner, let result = state.lastRoundResult {
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
}
// Confetti for wins (matching Baccarat pattern)
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
ConfettiView()
}
// Game over
if state.isGameOver && !state.showResultBanner {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
}
}
// MARK: - Betting
private func placeBet(state: GameState) {
state.placeBet(amount: selectedChip.rawValue)
}
}
// MARK: - Action Buttons View
struct ActionButtonsView: View {
@Bindable var state: GameState
// Scaled metrics
@ScaledMetric(relativeTo: .headline) private var buttonFontSize: CGFloat = Design.BaseFontSize.large
@ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = Design.IconSize.large
// Fixed height to prevent layout shifts
private let containerHeight: CGFloat = 120
var body: some View {
ZStack {
Color.clear
.frame(height: containerHeight)
VStack(spacing: Design.Spacing.medium) {
// Primary actions
HStack(spacing: Design.Spacing.medium) {
switch state.currentPhase {
case .betting:
bettingButtons
case .playerTurn:
playerTurnButtons
case .roundComplete:
// Empty - handled by result banner
EmptyView()
default:
// Dealing, dealer turn - show nothing
EmptyView()
}
}
.animation(.spring(duration: Design.Animation.quick), value: state.currentPhase)
}
}
.padding(.horizontal, Design.Spacing.large)
}
// MARK: - Betting Phase Buttons
@ViewBuilder
private var bettingButtons: some View {
if state.currentBet > 0 {
ActionButton(
String(localized: "Clear"),
icon: "xmark.circle",
style: .destructive
) {
state.clearBet()
}
if state.canDeal {
ActionButton(
String(localized: "Deal"),
icon: "play.fill",
style: .primary
) {
Task { await state.deal() }
}
}
}
}
// MARK: - Player Turn Buttons
@ViewBuilder
private var playerTurnButtons: some View {
// Top row: Hit, Stand
HStack(spacing: Design.Spacing.medium) {
if state.canHit {
ActionButton(
String(localized: "Hit"),
style: .custom(Color.Button.hit)
) {
Task { await state.hit() }
}
}
if state.canStand {
ActionButton(
String(localized: "Stand"),
style: .custom(Color.Button.stand)
) {
Task { await state.stand() }
}
}
}
// Bottom row: Double, Split, Surrender
HStack(spacing: Design.Spacing.medium) {
if state.canDouble {
ActionButton(
String(localized: "Double"),
style: .custom(Color.Button.doubleDown)
) {
Task { await state.doubleDown() }
}
}
if state.canSplit {
ActionButton(
String(localized: "Split"),
style: .custom(Color.Button.split)
) {
Task { await state.split() }
}
}
if state.canSurrender {
ActionButton(
String(localized: "Surrender"),
style: .custom(Color.Button.surrender)
) {
Task { await state.surrender() }
}
}
}
}
}
// MARK: - Action Button
struct ActionButton: View {
let title: String
let icon: String?
let style: ButtonStyle
let action: () -> Void
enum ButtonStyle {
case primary // Gold gradient (Deal, New Round)
case destructive // Red (Clear)
case secondary // Subtle white
case custom(Color) // Game-specific colors (Hit, Stand, etc.)
var foregroundColor: Color {
switch self {
case .primary: return .black
case .destructive, .secondary, .custom: return .white
}
}
}
init(_ title: String, icon: String? = nil, style: ButtonStyle = .primary, action: @escaping () -> Void) {
self.title = title
self.icon = icon
self.style = style
self.action = action
}
var body: some View {
Button(action: action) {
HStack(spacing: Design.Spacing.small) {
if let icon = icon {
Image(systemName: icon)
}
Text(title)
}
.font(.system(size: Design.BaseFontSize.large, weight: .semibold))
.foregroundStyle(style.foregroundColor)
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.medium + Design.Spacing.xxSmall)
.background(backgroundView)
}
.accessibilityLabel(title)
}
@ViewBuilder
private var backgroundView: some View {
switch style {
case .primary:
Capsule()
.fill(
LinearGradient(
colors: [Color.Button.goldLight, Color.Button.goldDark],
startPoint: .top,
endPoint: .bottom
)
)
case .destructive:
Capsule()
.fill(Color.red.opacity(Design.Opacity.heavy))
case .secondary:
Capsule()
.fill(Color.white.opacity(Design.Opacity.hint))
case .custom(let color):
Capsule()
.fill(color)
}
}
}
// MARK: - Preview
#Preview {
GameTableView()
}