373 lines
14 KiB
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()
|
|
}
|
|
|