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

359 lines
14 KiB
Swift

//
// GameTableView.swift
// Blackjack
//
// Main game container view.
//
import SwiftUI
import CasinoKit
struct GameTableView: View, SherpaDelegate {
@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: - Walkthrough State
/// Whether the Sherpa walkthrough is active
@State private var isWalkthroughActive = false
/// 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.
private var state: GameState { gameState }
// MARK: - Body
init() {
let settings = GameSettings()
self._settings = State(initialValue: settings)
self._gameState = State(initialValue: GameState(settings: settings))
}
var body: some View {
mainGameView(state: state)
.onAppear {
checkForWelcomeSheet()
}
.sheet(isPresented: $showSettings) {
SettingsView(settings: settings, gameState: gameState)
}
.onChange(of: showSettings) { wasShowing, isShowing in
// When settings sheet dismisses, sync session and check welcome
if wasShowing && !isShowing {
// Sync current session with any settings changes (e.g., game style)
state.saveGameData()
checkForWelcomeSheet()
}
}
.sheet(isPresented: $showRules) {
RulesHelpView()
}
.sheet(isPresented: $showStats) {
StatisticsSheetView(state: state)
}
.sheet(isPresented: $showWelcome) {
WelcomeSheet(
gameName: "Blackjack",
gameEmoji: "🃏",
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: "clock.badge.checkmark.fill",
title: String(localized: "Track Sessions"),
description: String(localized: "See detailed stats for each play session, just like at a real casino")
),
WelcomeFeature(
icon: "gearshape.fill",
title: String(localized: "Customize Rules"),
description: String(localized: "Vegas Strip, Atlantic City, European, or create your own")
)
],
onboarding: state.onboarding,
onDismiss: { showWelcome = false },
onShowHints: startWalkthrough
)
}
.onChange(of: showWelcome) { wasShowing, isShowing in
// Handle swipe-down dismissal: treat as "Start Playing" (no walkthrough)
if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome {
state.onboarding.skipOnboarding()
}
}
// Sherpa walkthrough modifier
.sherpa(isActive: isWalkthroughActive, tags: BlackjackWalkthroughTags.self, delegate: self)
}
// 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 },
sherpaTags: TopBarSherpaTags(
balance: BlackjackWalkthroughTags.balance,
cardsRemaining: BlackjackWalkthroughTags.cardsRemaining,
stats: BlackjackWalkthroughTags.statsButton,
rules: BlackjackWalkthroughTags.rulesButton,
settings: BlackjackWalkthroughTags.settingsButton
)
)
.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
)
.sherpaTag(BlackjackWalkthroughTags.chipSelector)
.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)")
}
}
// 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
}
}
}
/// Starts the Sherpa walkthrough when user taps "Show Me How"
private func startWalkthrough() {
// Reset onboarding hints so walkthrough can be seen again
state.onboarding.reset()
state.onboarding.completeWelcome()
isWalkthroughActive = true
}
// MARK: - SherpaDelegate
/// Returns nil to hide skip button and progress indicator
func accessoryView(sherpa: Sherpa) -> AnyView? {
nil
}
func onWalkthroughComplete(sherpa: Sherpa) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) {
isWalkthroughActive = false
state.onboarding.completeWelcome()
}
}
// MARK: - Preview
#Preview {
GameTableView()
}