CasinoGames/Blackjack/Views/GameTableView.swift

492 lines
17 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)
// Card count display (when enabled)
if settings.showCardCount {
CardCountView(
runningCount: state.engine.runningCount,
trueCount: state.engine.trueCount
)
.frame(maxWidth: maxContentWidth)
}
// Reshuffle notification
if state.showReshuffleNotification {
ReshuffleNotificationView(showCardCount: settings.showCardCount)
.frame(maxWidth: maxContentWidth)
.transition(.move(edge: .top).combined(with: .opacity))
}
// Table layout
BlackjackTableView(
state: state,
onPlaceBet: { placeBet(state: state) }
)
.frame(maxWidth: maxContentWidth)
Spacer()
// Chip selector - only shown during betting phase
if state.currentPhase == .betting {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.currentBet,
maxBet: state.settings.maxBet
)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.small)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
// Action buttons
ActionButtonsView(state: state)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.medium)
}
.frame(maxWidth: .infinity)
// Insurance popup overlay (covers entire screen)
if state.currentPhase == .insurance {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() }
)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// 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
/// Whether the current bet meets the minimum requirement
private var isBetBelowMinimum: Bool {
state.currentBet > 0 && state.currentBet < state.settings.minBet
}
/// Amount needed to reach minimum bet
private var amountNeededForMinimum: Int {
state.settings.minBet - state.currentBet
}
@ViewBuilder
private var bettingButtons: some View {
if state.currentBet > 0 {
VStack(spacing: Design.Spacing.small) {
// Show hint if bet is below minimum
if isBetBelowMinimum {
Text(String(localized: "Add $\(amountNeededForMinimum) more to meet minimum"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.orange)
.transition(.opacity)
}
HStack(spacing: Design.Spacing.medium) {
ActionButton(
String(localized: "Clear"),
icon: "xmark.circle",
style: .destructive
) {
state.clearBet()
}
// Always show Deal button, but disable if below minimum
ActionButton(
String(localized: "Deal"),
icon: "play.fill",
style: .primary
) {
Task { await state.deal() }
}
.opacity(state.canDeal ? 1.0 : Design.Opacity.medium)
.disabled(!state.canDeal)
}
}
}
}
// 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: - Card Count View
/// Displays the Hi-Lo running count for card counting practice.
struct CardCountView: View {
let runningCount: Int
let trueCount: Double
var body: some View {
HStack(spacing: Design.Spacing.large) {
// Running count
VStack(spacing: Design.Spacing.xxSmall) {
Text("Running")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(runningCount >= 0 ? "+\(runningCount)" : "\(runningCount)")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: runningCount))
}
Divider()
.frame(height: Design.Spacing.xLarge)
.background(Color.white.opacity(Design.Opacity.hint))
// True count
VStack(spacing: Design.Spacing.xxSmall) {
Text("True")
.font(.system(size: Design.Size.cardCountLabelSize, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text(trueCount >= 0 ? "+\(trueCount, format: .number.precision(.fractionLength(1)))" : "\(trueCount, format: .number.precision(.fractionLength(1)))")
.font(.system(size: Design.Size.cardCountValueSize, weight: .bold, design: .monospaced))
.foregroundStyle(countColor(for: Int(trueCount.rounded())))
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.small)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.black.opacity(Design.Opacity.subtle))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "Card Count"))
.accessibilityValue(String(localized: "Running \(runningCount), True \(trueCount, format: .number.precision(.fractionLength(1)))"))
}
private func countColor(for count: Int) -> Color {
if count > 0 {
return .green // Positive count favors player
} else if count < 0 {
return .red // Negative count favors house
} else {
return .white // Neutral
}
}
}
// MARK: - Reshuffle Notification View
/// Shows a notification when the shoe is reshuffled.
struct ReshuffleNotificationView: View {
let showCardCount: Bool
var body: some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "shuffle")
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text("Shoe Reshuffled")
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.white)
if showCardCount {
Text("Count reset to 0")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
}
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(
Capsule()
.fill(Color.blue.opacity(Design.Opacity.heavy))
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(showCardCount
? String(localized: "Shoe reshuffled, count reset to zero")
: String(localized: "Shoe reshuffled"))
}
}
// MARK: - Preview
#Preview {
GameTableView()
}