CasinoGames/Blackjack/Blackjack/Views/Game/GameTableView.swift

373 lines
14 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
@State private var showWelcome = false
// MARK: - Onboarding State
/// Tooltip manager for contextual hints
@State private var tooltipManager: TooltipManager?
/// Screen size for card sizing (measured from TableBackgroundView)
@State private var screenSize: CGSize = CGSize(width: 375, height: 667)
// 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
? CasinoDesign.Size.maxContentWidthLandscape
: CasinoDesign.Size.maxContentWidthPortrait
}
return .infinity
}
/// Provides the current game state, creating one if needed (fallback for initial render).
private var state: GameState {
gameState ?? GameState(settings: settings)
}
// MARK: - Body
var body: some View {
mainGameView(state: state)
.onAppear {
if gameState == nil {
gameState = GameState(settings: settings)
}
if tooltipManager == nil {
tooltipManager = TooltipManager(onboarding: state.onboarding)
}
checkForWelcomeSheet()
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.onChange(of: showSettings) { wasShowing, isShowing in
// When settings sheet dismisses, check if we should show welcome
if wasShowing && !isShowing {
checkForWelcomeSheet()
}
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(state: state)
}
.sheet(isPresented: $showWelcome) {
WelcomeSheet(
gameName: "Blackjack",
features: [
WelcomeFeature(
icon: "target",
title: String(localized: "Beat the Dealer"),
description: String(localized: "Get closer to 21 than the dealer without going over")
),
WelcomeFeature(
icon: "lightbulb.fill",
title: String(localized: "Learn Strategy"),
description: String(localized: "Built-in hints show optimal plays based on basic strategy")
),
WelcomeFeature(
icon: "dollarsign.circle",
title: String(localized: "Practice Free"),
description: String(localized: "Start with $1,000 and play risk-free")
),
WelcomeFeature(
icon: "gearshape.fill",
title: String(localized: "Customize Rules"),
description: String(localized: "Change table limits, rules, and side bets in settings")
)
],
onStartTutorial: {
showWelcome = false
state.onboarding.completeWelcome()
checkOnboardingHints()
},
onStartPlaying: {
showWelcome = false
state.onboarding.completeWelcome()
}
)
}
.onChange(of: state.currentBet) { _, newBet in
if newBet > 0, state.onboarding.shouldShowHint("dealButton") {
showDealHintWithDelay()
}
}
.onChange(of: state.currentPhase) { oldPhase, newPhase in
if case .playerTurn = newPhase, oldPhase != newPhase {
if state.onboarding.shouldShowHint("playerActions") {
showActionsHintWithDelay()
}
}
}
}
// Use global debug flag from Design constants
private var showDebugBorders: Bool { Design.showDebugBorders }
// MARK: - Toolbar Buttons
/// Returns hint toolbar button when a hint is available.
private func hintToolbarButtons(for state: GameState) -> [TopBarButton] {
guard state.currentHint != nil else { return [] }
return [
TopBarButton(
icon: "lightbulb.fill",
accessibilityLabel: String(localized: "Show Hint")
) {
// Generate new ID to invalidate any pending dismiss tasks
let currentID = UUID()
state.hintDisplayID = currentID
// Show the toast with animation
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = true
}
// Auto-dismiss after delay, but only if this is still the active hint session
Task { @MainActor in
try? await Task.sleep(for: Design.Toast.duration)
// Only dismiss if no newer hint has arrived
if state.hintDisplayID == currentID {
withAnimation(.spring(duration: Design.Animation.springDuration)) {
state.showHintToast = false
}
}
}
}
]
}
// MARK: - Main Game View
@ViewBuilder
private func mainGameView(state: GameState) -> some View {
ZStack {
// Background - measures screen size for card sizing
TableBackgroundView()
.onGeometryChange(for: CGSize.self) { proxy in
proxy.size
} action: { size in
screenSize = size
}
mainContent(state: state)
}
}
@ViewBuilder
private func mainContent(state: GameState) -> some View {
ZStack {
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,
leadingButtons: hintToolbarButtons(for: state),
onSettings: { showSettings = true },
onHelp: { showRules = true },
onStats: { showStats = true }
)
.frame(maxWidth: maxContentWidth)
.debugBorder(showDebugBorders, color: .cyan, label: "TopBar")
// Table layout
BlackjackTableView(
state: state,
selectedChip: selectedChip,
fullScreenSize: screenSize
)
.frame(maxWidth: maxContentWidth)
// Flexible spacer absorbs extra space when chip selector is hidden
Spacer(minLength: 0)
// Chip selector - only shown during betting phase AND when result banner is NOT showing
// Full width on iPad so all chips are tappable
if state.currentPhase == .betting && !state.showResultBanner {
ChipSelectorView(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.minBetForChipSelector,
maxBet: state.settings.maxBet
)
.transition(.opacity.combined(with: .move(edge: .bottom)))
.debugBorder(showDebugBorders, color: .pink, label: "ChipSelector")
}
// Action buttons - minimal spacing during player turn
ActionButtonsView(state: state)
.frame(maxWidth: maxContentWidth)
.padding(.bottom, Design.Spacing.small)
.debugBorder(showDebugBorders, color: .blue, label: "ActionBtns")
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.animation(.easeInOut(duration: Design.Animation.quick), value: state.currentPhase)
.zIndex(1)
.onChange(of: state.currentPhase) { oldPhase, newPhase in
Design.debugLog("🔄 Phase changed: \(oldPhase)\(newPhase)")
}
// Reshuffle notification overlay (centered, floating)
if state.showReshuffleNotification {
ReshuffleNotificationView(showCardCount: settings.showCardCount)
.transition(.scale.combined(with: .opacity))
.zIndex(50)
}
// Insurance popup overlay (covers entire screen)
if state.currentPhase == .insurance {
Color.clear
.overlay(alignment: .center) {
InsurancePopupView(
betAmount: state.currentBet / 2,
balance: state.balance,
onTake: { Task { await state.takeInsurance() } },
onDecline: { state.declineInsurance() },
onNeverAsk: { state.neverAskInsurance() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
.zIndex(100)
}
// Result banner overlay
if state.showResultBanner, let result = state.lastRoundResult {
Color.clear
.overlay(alignment: .center) {
ResultBannerView(
result: result,
currentBalance: state.balance,
minBet: state.settings.minBet,
onNewRound: { state.newRound() },
onPlayAgain: { state.resetGame() }
)
.onAppear {
Design.debugLog("🎯 RESULT BANNER APPEARED")
}
.onDisappear {
Design.debugLog("❌ RESULT BANNER DISAPPEARED")
}
}
.ignoresSafeArea()
.allowsHitTesting(true)
.zIndex(100)
}
// Confetti for wins (matching Baccarat pattern)
if state.showResultBanner && (state.lastRoundResult?.totalWinnings ?? 0) > 0 {
ConfettiView()
.zIndex(101)
}
// Game over
if state.isGameOver && !state.showResultBanner {
Color.clear
.overlay(alignment: .center) {
GameOverView(
roundsPlayed: state.roundsPlayed,
onPlayAgain: { state.resetGame() }
)
}
.ignoresSafeArea()
.allowsHitTesting(true)
.zIndex(100)
}
}
.onChange(of: state.playerHands.count) { oldCount, newCount in
Design.debugLog("👥 Player hands count: \(oldCount)\(newCount)")
}
.onChange(of: state.balance) { oldBalance, newBalance in
Design.debugLog("💰 Balance: \(oldBalance)\(newBalance)")
}
// Dynamic tooltip display
.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding))
}
// MARK: - Onboarding Helpers
private func checkForWelcomeSheet() {
if !state.onboarding.hasCompletedWelcome {
// Delay slightly so view has time to layout
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
showWelcome = true
}
}
}
private func checkOnboardingHints() {
// Show betting hint if not yet shown
if state.onboarding.shouldShowHint("bettingZone") {
tooltipManager?.show(
key: "bettingZone",
message: String(localized: "Select a chip and tap the bet area"),
icon: "hand.tap.fill",
position: .bottom,
delay: 1.0
)
}
}
private func showDealHintWithDelay() {
tooltipManager?.show(
key: "dealButton",
message: String(localized: "Tap Deal to start the round"),
icon: "play.fill",
position: .bottom,
delay: 0.5
)
}
private func showActionsHintWithDelay() {
tooltipManager?.show(
key: "playerActions",
message: String(localized: "Choose your action based on the hint above"),
icon: "hand.point.up.left.fill",
position: .bottom,
delay: 1.0
)
}
}
// MARK: - Preview
#Preview {
GameTableView()
}