From 7e16e67826d5f9fe22fd55f29bfcdc900fc55827 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 22 Dec 2025 15:11:18 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Blackjack/Blackjack.xcodeproj/project.pbxproj | 4 +- .../Blackjack/Engine/BlackjackEngine.swift | 57 ++++++++------- Blackjack/Blackjack/Engine/GameState.swift | 31 +++++++- Blackjack/Blackjack/Models/GameResult.swift | 24 +++++++ .../Blackjack/Theme/DesignConstants.swift | 8 ++- .../Blackjack/Views/Game/GameTableView.swift | 18 +++-- .../Views/Sheets/ResultBannerView.swift | 71 +++++++++++++++++-- .../Views/Table/BlackjackTableView.swift | 26 ++++++- 8 files changed, 193 insertions(+), 46 deletions(-) diff --git a/Blackjack/Blackjack.xcodeproj/project.pbxproj b/Blackjack/Blackjack.xcodeproj/project.pbxproj index 85ed42e..215134c 100644 --- a/Blackjack/Blackjack.xcodeproj/project.pbxproj +++ b/Blackjack/Blackjack.xcodeproj/project.pbxproj @@ -411,7 +411,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( @@ -443,7 +443,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Blackjack/Blackjack/Engine/BlackjackEngine.swift b/Blackjack/Blackjack/Engine/BlackjackEngine.swift index d6fd41c..3207e2f 100644 --- a/Blackjack/Blackjack/Engine/BlackjackEngine.swift +++ b/Blackjack/Blackjack/Engine/BlackjackEngine.swift @@ -403,96 +403,99 @@ final class BlackjackEngine { let dealerValue = dealerUpCard.blackjackValue let isSoft = playerHand.isSoft - // Check for count-based deviations from basic strategy + // Helper to format true count with sign + let tcDisplay = tc >= 0 ? "+\(tc)" : "\(tc)" - // 16 vs 10: Stand at TC 0+ (basic says Hit) + // Check for count-based deviations from basic strategy (Illustrious 18) + + // 16 vs 10: Stand when TC ≥ 0 (basic strategy says Hit) if playerValue == 16 && !isSoft && dealerValue == 10 { if tc >= 0 { - return String(localized: "Stand (Count: 16v10 at TC≥0)") + return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is neutral/rich)") } } - // 15 vs 10: Stand at TC +4+ (basic says Hit) + // 15 vs 10: Stand when TC ≥ +4 (basic strategy says Hit/Surrender) if playerValue == 15 && !isSoft && dealerValue == 10 { if tc >= 4 { - return String(localized: "Stand (Count: 15v10 at TC≥+4)") + return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is very rich)") } } - // 12 vs 2: Stand at TC +3+ (basic says Hit) + // 12 vs 2: Stand when TC ≥ +3 (basic strategy says Hit) if playerValue == 12 && !isSoft && dealerValue == 2 { if tc >= 3 { - return String(localized: "Stand (Count: 12v2 at TC≥+3)") + return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)") } } - // 12 vs 3: Stand at TC +2+ (basic says Hit) + // 12 vs 3: Stand when TC ≥ +2 (basic strategy says Hit) if playerValue == 12 && !isSoft && dealerValue == 3 { if tc >= 2 { - return String(localized: "Stand (Count: 12v3 at TC≥+2)") + return String(localized: "Stand instead of Hit (TC \(tcDisplay), dealer likely to bust)") } } - // 12 vs 4: Hit at TC < 0 (basic says Stand) + // 12 vs 4: Hit when TC < 0 (basic strategy says Stand) if playerValue == 12 && !isSoft && dealerValue == 4 { if tc < 0 { - return String(localized: "Hit (Count: 12v4 at TC<0)") + return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is poor)") } } - // 13 vs 2: Hit at TC < -1 (basic says Stand) + // 13 vs 2: Hit when TC < -1 (basic strategy says Stand) if playerValue == 13 && !isSoft && dealerValue == 2 { if tc < -1 { - return String(localized: "Hit (Count: 13v2 at TC<-1)") + return String(localized: "Hit instead of Stand (TC \(tcDisplay), deck is very poor)") } } - // 16 vs 9: Stand at TC +5+ (basic says Hit) + // 16 vs 9: Stand when TC ≥ +5 (basic strategy says Hit) if playerValue == 16 && !isSoft && dealerValue == 9 { if tc >= 5 { - return String(localized: "Stand (Count: 16v9 at TC≥+5)") + return String(localized: "Stand instead of Hit (TC \(tcDisplay), deck is extremely rich)") } } - // 10 vs 10: Double at TC +4+ (basic says Hit) + // 10 vs 10: Double when TC ≥ +4 (basic strategy says Hit) if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 10 { if tc >= 4 { - return String(localized: "Double (Count: 10v10 at TC≥+4)") + return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)") } } - // 10 vs A: Double at TC +4+ (basic says Hit) + // 10 vs Ace: Double when TC ≥ +4 (basic strategy says Hit) if playerValue == 10 && !isSoft && playerHand.cards.count == 2 && dealerValue == 1 { if tc >= 4 { - return String(localized: "Double (Count: 10vA at TC≥+4)") + return String(localized: "Double instead of Hit (TC \(tcDisplay), high cards favor you)") } } - // 9 vs 2: Double at TC +1+ (basic says Hit) + // 9 vs 2: Double when TC ≥ +1 (basic strategy says Hit) if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 2 { if tc >= 1 { - return String(localized: "Double (Count: 9v2 at TC≥+1)") + return String(localized: "Double instead of Hit (TC \(tcDisplay), slight edge to double)") } } - // 9 vs 7: Double at TC +3+ (basic says Hit) + // 9 vs 7: Double when TC ≥ +3 (basic strategy says Hit) if playerValue == 9 && !isSoft && playerHand.cards.count == 2 && dealerValue == 7 { if tc >= 3 { - return String(localized: "Double (Count: 9v7 at TC≥+3)") + return String(localized: "Double instead of Hit (TC \(tcDisplay), deck favors doubling)") } } - // Pair of 10s vs 5: Split at TC +5+ (basic says Stand) + // Pair of 10s vs 5: Split when TC ≥ +5 (basic strategy says Stand) if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 5 { if tc >= 5 { - return String(localized: "Split (Count: 10,10v5 at TC≥+5)") + return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)") } } - // Pair of 10s vs 6: Split at TC +4+ (basic says Stand) + // Pair of 10s vs 6: Split when TC ≥ +4 (basic strategy says Stand) if playerHand.canSplit && playerHand.cards[0].blackjackValue == 10 && dealerValue == 6 { if tc >= 4 { - return String(localized: "Split (Count: 10,10v6 at TC≥+4)") + return String(localized: "Split instead of Stand (TC \(tcDisplay), dealer very likely to bust)") } } diff --git a/Blackjack/Blackjack/Engine/GameState.swift b/Blackjack/Blackjack/Engine/GameState.swift index f485c38..6430204 100644 --- a/Blackjack/Blackjack/Engine/GameState.swift +++ b/Blackjack/Blackjack/Engine/GameState.swift @@ -661,6 +661,7 @@ final class GameState { var roundWinnings = 0 var wasBlackjack = false var hadBust = false + var perHandWinnings: [Int] = [] // Evaluate each hand for i in 0.. 0 { + if dealerHand.isBlackjack { + insResult = .insuranceWin + insWinnings = insuranceBet * 2 // Insurance pays 2:1 + balance += insuranceBet * 3 // Return bet + 2x winnings + roundWinnings += insWinnings + } else { + insResult = .insuranceLose + insWinnings = -insuranceBet // Lost insurance bet + roundWinnings += insWinnings } } @@ -704,11 +727,13 @@ final class GameState { bustCount += 1 } - // Create round result with all hand results + // Create round result with all hand results and per-hand winnings let allHandResults = playerHands.map { $0.result ?? .lose } lastRoundResult = RoundResult( handResults: allHandResults, - insuranceResult: insuranceBet > 0 ? (dealerHand.isBlackjack ? .insuranceWin : .insuranceLose) : nil, + handWinnings: perHandWinnings, + insuranceResult: insResult, + insuranceWinnings: insWinnings, totalWinnings: roundWinnings, wasBlackjack: wasBlackjack ) diff --git a/Blackjack/Blackjack/Models/GameResult.swift b/Blackjack/Blackjack/Models/GameResult.swift index 529a674..b83c4f0 100644 --- a/Blackjack/Blackjack/Models/GameResult.swift +++ b/Blackjack/Blackjack/Models/GameResult.swift @@ -68,10 +68,34 @@ enum HandResult: Equatable { struct RoundResult: Equatable { /// Results for all player hands (index 0 = Hand 1, index 1 = Hand 2, etc.) let handResults: [HandResult] + /// Net winnings for each hand (parallel to handResults) + let handWinnings: [Int] let insuranceResult: HandResult? + /// Insurance winnings (positive if won, negative if lost) + let insuranceWinnings: Int let totalWinnings: Int let wasBlackjack: Bool + /// Convenience initializer without per-hand winnings (backwards compatibility) + init(handResults: [HandResult], insuranceResult: HandResult?, totalWinnings: Int, wasBlackjack: Bool) { + self.handResults = handResults + self.handWinnings = [] // Empty means don't show per-hand amounts + self.insuranceResult = insuranceResult + self.insuranceWinnings = 0 + self.totalWinnings = totalWinnings + self.wasBlackjack = wasBlackjack + } + + /// Full initializer with per-hand winnings + init(handResults: [HandResult], handWinnings: [Int], insuranceResult: HandResult?, insuranceWinnings: Int, totalWinnings: Int, wasBlackjack: Bool) { + self.handResults = handResults + self.handWinnings = handWinnings + self.insuranceResult = insuranceResult + self.insuranceWinnings = insuranceWinnings + self.totalWinnings = totalWinnings + self.wasBlackjack = wasBlackjack + } + /// The main/best result for display purposes (first hand, or best if split) var mainHandResult: HandResult { // Return the best result for the headline diff --git a/Blackjack/Blackjack/Theme/DesignConstants.swift b/Blackjack/Blackjack/Theme/DesignConstants.swift index 235b83c..a42047b 100644 --- a/Blackjack/Blackjack/Theme/DesignConstants.swift +++ b/Blackjack/Blackjack/Theme/DesignConstants.swift @@ -36,12 +36,16 @@ enum Design { enum Size { // Hand scaling factor (1.5 = 50% larger hands) - static let handScale: CGFloat = 1.5 + static let handScale: CGFloat = 1.75 // Cards - scaled for better visibility static let cardWidth: CGFloat = 60 * handScale // 90pt at 1.5x static let cardWidthSmall: CGFloat = CasinoDesign.Size.cardWidthSmall - static let cardOverlap: CGFloat = CasinoDesign.Size.cardOverlap * handScale // Scaled overlap + + /// Card overlap (negative = cards stack left over right). + /// More negative = more overlap (less card visible). + /// With 90pt cards: -40 = ~44% overlap, -50 = ~55% overlap + static let cardOverlap: CGFloat = -50 // Player hands container height (accommodates larger cards + labels) // Reduced from 180 to fit content more snugly diff --git a/Blackjack/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Blackjack/Views/Game/GameTableView.swift index 8b9ac32..20806f6 100644 --- a/Blackjack/Blackjack/Views/Game/GameTableView.swift +++ b/Blackjack/Blackjack/Views/Game/GameTableView.swift @@ -72,10 +72,19 @@ struct GameTableView: View { @ViewBuilder private func mainGameView(state: GameState) -> some View { + GeometryReader { geometry in + ZStack { + // Background + TableBackgroundView() + + mainContent(state: state, screenHeight: geometry.size.height) + } + } + } + + @ViewBuilder + private func mainContent(state: GameState, screenHeight: CGFloat) -> some View { ZStack { - // Background - TableBackgroundView() - VStack(spacing: 0) { // Top bar TopBarView( @@ -110,7 +119,8 @@ struct GameTableView: View { // Table layout - fills available space BlackjackTableView( state: state, - onPlaceBet: { placeBet(state: state) } + onPlaceBet: { placeBet(state: state) }, + screenHeight: screenHeight ) .frame(maxWidth: maxContentWidth) diff --git a/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift b/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift index dc8a394..6f2ae08 100644 --- a/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift +++ b/Blackjack/Blackjack/Views/Sheets/ResultBannerView.swift @@ -68,19 +68,31 @@ struct ResultBannerView: View { .font(.system(size: amountFontSize, weight: .bold, design: .rounded)) .foregroundStyle(winningsColor) - // Breakdown - all hands + // Breakdown - all hands with amounts for splits VStack(spacing: Design.Spacing.small) { ForEach(result.handResults.indices, id: \.self) { index in let handResult = result.handResults[index] + let handWinnings = index < result.handWinnings.count ? result.handWinnings[index] : nil // Hand numbering: index 0 = Hand 1 (played first, displayed rightmost) let handLabel = result.handResults.count > 1 ? String(localized: "Hand \(index + 1)") : String(localized: "Main Hand") - ResultRow(label: handLabel, result: handResult) + // Show amounts for split hands, or for single hand if there are winnings + let showAmount = result.hadSplit && handWinnings != nil + ResultRow( + label: handLabel, + result: handResult, + amount: showAmount ? handWinnings : nil + ) } if let insuranceResult = result.insuranceResult { - ResultRow(label: String(localized: "Insurance"), result: insuranceResult) + let showInsAmount = result.insuranceWinnings != 0 + ResultRow( + label: String(localized: "Insurance"), + result: insuranceResult, + amount: showInsAmount ? result.insuranceWinnings : nil + ) } } .padding(Design.Spacing.medium) @@ -189,6 +201,25 @@ struct ResultBannerView: View { struct ResultRow: View { let label: String let result: HandResult + var amount: Int? = nil + + private var amountText: String? { + guard let amount = amount else { return nil } + if amount > 0 { + return "+$\(amount)" + } else if amount < 0 { + return "-$\(abs(amount))" + } else { + return "$0" + } + } + + private var amountColor: Color { + guard let amount = amount else { return .white } + if amount > 0 { return .green } + if amount < 0 { return .red } + return .blue + } var body: some View { HStack { @@ -198,9 +229,18 @@ struct ResultRow: View { Spacer() + // Show amount if provided + if let amountText = amountText { + Text(amountText) + .font(.system(size: Design.BaseFontSize.body, weight: .semibold, design: .rounded)) + .foregroundStyle(amountColor) + .frame(width: 70, alignment: .trailing) + } + Text(result.displayText) .font(.system(size: Design.BaseFontSize.body, weight: .bold)) .foregroundStyle(result.color) + .frame(width: 100, alignment: .trailing) } } } @@ -209,7 +249,9 @@ struct ResultRow: View { ResultBannerView( result: RoundResult( handResults: [.blackjack], + handWinnings: [150], insuranceResult: nil, + insuranceWinnings: 0, totalWinnings: 150, wasBlackjack: true ), @@ -224,11 +266,30 @@ struct ResultRow: View { ResultBannerView( result: RoundResult( handResults: [.bust, .win, .push], + handWinnings: [-100, 100, 0], insuranceResult: nil, - totalWinnings: 25, + insuranceWinnings: 0, + totalWinnings: 0, wasBlackjack: false ), - currentBalance: 1025, + currentBalance: 1000, + minBet: 10, + onNewRound: {}, + onPlayAgain: {} + ) +} + +#Preview("Split with Insurance") { + ResultBannerView( + result: RoundResult( + handResults: [.lose, .win], + handWinnings: [-100, 200], + insuranceResult: .insuranceWin, + insuranceWinnings: 100, + totalWinnings: 200, + wasBlackjack: false + ), + currentBalance: 1200, minBet: 10, onNewRound: {}, onPlayAgain: {} diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index 3c51410..b1ad001 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -12,6 +12,9 @@ struct BlackjackTableView: View { @Bindable var state: GameState let onPlaceBet: () -> Void + /// Screen height passed from parent for responsive sizing + var screenHeight: CGFloat = 800 + /// Whether to show Hi-Lo card count values on cards. var showCardCount: Bool { state.settings.showCardCount } @@ -32,6 +35,23 @@ struct BlackjackTableView: View { // Use global debug flag from Design constants private var showDebugBorders: Bool { Design.showDebugBorders } + /// Dynamic spacer height based on screen size. + /// Formula: spacing = clamp((screenHeight - baseline) * scale, min, max) + /// This produces smooth scaling across all device sizes: + /// - iPhone SE (~667pt): ~20pt + /// - iPhone Pro Max (~932pt): ~76pt + /// - iPad Mini (~1024pt): ~95pt + /// - iPad Pro 12.9" (~1366pt): ~150pt (capped) + private var dealerPlayerSpacing: CGFloat { + let baseline: CGFloat = 550 // Below this, use minimum + let scale: CGFloat = 0.2 // 20% of height above baseline + let minSpacing: CGFloat = 20 // Floor for smallest screens + let maxSpacing: CGFloat = 150 // Ceiling for largest screens + + let calculated = (screenHeight - baseline) * scale + return min(maxSpacing, max(minSpacing, calculated)) + } + var body: some View { VStack(spacing: Design.Spacing.small) { // Dealer area @@ -44,9 +64,9 @@ struct BlackjackTableView: View { ) .debugBorder(showDebugBorders, color: .red, label: "Dealer") - // Flexible space between dealer and player (minimum 60pt) - Spacer(minLength: 60) - .debugBorder(showDebugBorders, color: .yellow, label: "Spacer") + // Flexible space between dealer and player - scales with screen size + Spacer(minLength: dealerPlayerSpacing) + .debugBorder(showDebugBorders, color: .yellow, label: "Spacer \(Int(dealerPlayerSpacing))") // Player hands area - only show when there are cards dealt if state.playerHands.first?.cards.isEmpty == false {