344 lines
10 KiB
Swift
344 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,
|
|
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,
|
|
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.currentBet >= state.settings.minBet {
|
|
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()
|
|
}
|
|
|