diff --git a/Baccarat/Views/GameTableView.swift b/Baccarat/Views/GameTableView.swift index 82eeda4..4bcd986 100644 --- a/Baccarat/Views/GameTableView.swift +++ b/Baccarat/Views/GameTableView.swift @@ -110,6 +110,7 @@ struct GameTableView: View { ChipSelectorView( selectedChip: $selectedChip, balance: state.balance, + currentBet: state.totalBetAmount, maxBet: state.maxBet ) .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) diff --git a/Blackjack/Engine/GameState.swift b/Blackjack/Engine/GameState.swift index 3f18f9f..e8d5690 100644 --- a/Blackjack/Engine/GameState.swift +++ b/Blackjack/Engine/GameState.swift @@ -101,8 +101,9 @@ final class GameState { } /// Whether player can place a bet. + /// True if in betting phase, have balance, and haven't hit max bet. var canBet: Bool { - currentPhase == .betting && currentBet + settings.minBet <= balance + currentPhase == .betting && balance > 0 && currentBet < settings.maxBet } /// Whether the current hand can hit. @@ -139,9 +140,9 @@ final class GameState { return engine.canSurrender(hand: hand) } - /// Whether the game is over (out of money). + /// Whether the game is over (out of money and no active bet). var isGameOver: Bool { - balance < settings.minBet && currentPhase == .betting + balance < settings.minBet && currentPhase == .betting && currentBet == 0 } /// Total rounds played. @@ -250,9 +251,14 @@ final class GameState { // MARK: - Dealing + /// Whether the player can deal (betting phase with valid bet). + var canDeal: Bool { + currentPhase == .betting && currentBet >= settings.minBet + } + /// Deals the initial cards. func deal() async { - guard currentBet >= settings.minBet else { return } + guard canDeal else { return } currentPhase = .dealing playerHands = [BlackjackHand(bet: currentBet)] @@ -598,13 +604,20 @@ final class GameState { /// Starts a new round. func newRound() { + // Reset all hand state playerHands = [] dealerHand = BlackjackHand() activeHandIndex = 0 + + // Reset bets + currentBet = 0 insuranceBet = 0 + + // Reset UI state showResultBanner = false lastRoundResult = nil currentPhase = .betting + sound.play(.newRound) } diff --git a/Blackjack/Models/Hand.swift b/Blackjack/Models/Hand.swift index c6e6f3f..a461ae9 100644 --- a/Blackjack/Models/Hand.swift +++ b/Blackjack/Models/Hand.swift @@ -48,14 +48,16 @@ struct BlackjackHand: Identifiable, Equatable { cards.count == 2 && value == 21 && !isSplit } - /// Whether this hand can be split (two cards of same rank). + /// Whether this hand has a splittable pair (two cards of same rank). + /// Note: Additional conditions (balance, max splits, resplit aces) are checked by the engine. var canSplit: Bool { - cards.count == 2 && cards[0].rank == cards[1].rank && !isSplit + cards.count == 2 && cards[0].rank == cards[1].rank } - /// Whether this hand can double down. + /// Whether this hand has the card count to double down. + /// Note: Additional conditions (balance, DAS rule) are checked by the engine. var canDoubleDown: Bool { - cards.count == 2 && !isDoubledDown && !isSplit + cards.count == 2 && !isDoubledDown } /// Whether this hand can hit. diff --git a/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Resources/Localizable.xcstrings index c31507f..df733a4 100644 --- a/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Resources/Localizable.xcstrings @@ -1299,6 +1299,10 @@ } } }, + "Max: $%@" : { + "comment" : "A label displaying the maximum bet amount. The argument is the maximum bet amount.", + "isCommentAutoGenerated" : true + }, "Maximum penetration" : { "comment" : "Description of a deck count option when the user selects 8 decks.", "isCommentAutoGenerated" : true diff --git a/Blackjack/Views/BlackjackTableView.swift b/Blackjack/Views/BlackjackTableView.swift index 3a6117a..bd5de08 100644 --- a/Blackjack/Views/BlackjackTableView.swift +++ b/Blackjack/Views/BlackjackTableView.swift @@ -384,9 +384,15 @@ struct BettingZoneView: View { .font(.system(size: labelFontSize, weight: .bold)) .foregroundStyle(.white.opacity(Design.Opacity.medium)) - Text(String(localized: "Min: $\(minBet)")) - .font(.system(size: Design.BaseFontSize.small, weight: .medium)) - .foregroundStyle(.white.opacity(Design.Opacity.light)) + HStack(spacing: Design.Spacing.medium) { + Text(String(localized: "Min: $\(minBet)")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + + Text(String(localized: "Max: $\(maxBet.formatted())")) + .font(.system(size: Design.BaseFontSize.small, weight: .medium)) + .foregroundStyle(.white.opacity(Design.Opacity.light)) + } } } } diff --git a/Blackjack/Views/GameTableView.swift b/Blackjack/Views/GameTableView.swift index 9cd9f2f..a516e9f 100644 --- a/Blackjack/Views/GameTableView.swift +++ b/Blackjack/Views/GameTableView.swift @@ -78,6 +78,7 @@ struct GameTableView: View { TopBarView( balance: state.balance, secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil, + secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, onReset: { state.resetGame() }, onSettings: { showSettings = true }, onHelp: { showRules = true }, @@ -98,6 +99,7 @@ struct GameTableView: View { ChipSelectorView( selectedChip: $selectedChip, balance: state.balance, + currentBet: state.currentBet, maxBet: state.settings.maxBet ) .frame(maxWidth: maxContentWidth) @@ -195,7 +197,7 @@ struct ActionButtonsView: View { state.clearBet() } - if state.currentBet >= state.settings.minBet { + if state.canDeal { ActionButton( String(localized: "Deal"), icon: "play.fill", diff --git a/Blackjack/Views/ResultBannerView.swift b/Blackjack/Views/ResultBannerView.swift index b0f93cf..f74e561 100644 --- a/Blackjack/Views/ResultBannerView.swift +++ b/Blackjack/Views/ResultBannerView.swift @@ -114,12 +114,6 @@ struct ResultBannerView: View { ) } } - .onAppear { - Task { - try? await Task.sleep(for: .milliseconds(300)) - SoundManager.shared.play(.gameOver) - } - } } else { // New Round button Button(action: onNewRound) { @@ -171,6 +165,14 @@ struct ResultBannerView: View { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) { showContent = true } + + // Play game over sound if out of chips (after a delay so it doesn't overlap with result sound) + if isGameOver { + Task { + try? await Task.sleep(for: .seconds(1)) + SoundManager.shared.play(.gameOver) + } + } } .accessibilityElement(children: .contain) .accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)")) diff --git a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift index 51984f1..0746a09 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Chips/ChipSelectorView.swift @@ -9,20 +9,31 @@ import SwiftUI /// A horizontal scrollable chip selector. /// Shows chips based on current balance - higher denomination chips unlock as you win more! +/// Chips are disabled when: +/// - Balance is less than the chip value +/// - Adding the chip would exceed the max bet (considering current bet) public struct ChipSelectorView: View { @Binding var selectedChip: ChipDenomination let balance: Int + let currentBet: Int let maxBet: Int let theme: any CasinoTheme + /// Remaining room before hitting max bet + private var remainingBetRoom: Int { + maxBet - currentBet + } + public init( selectedChip: Binding, balance: Int, + currentBet: Int = 0, maxBet: Int = 100_000, theme: any CasinoTheme = DefaultCasinoTheme() ) { self._selectedChip = selectedChip self.balance = balance + self.currentBet = currentBet self.maxBet = maxBet self.theme = theme } @@ -33,6 +44,11 @@ public struct ChipSelectorView: View { .filter { $0.rawValue <= maxBet } // Don't show chips larger than max bet } + /// Whether a chip can be used (affordable and within bet limit) + private func canUseChip(_ denomination: ChipDenomination) -> Bool { + balance >= denomination.rawValue && denomination.rawValue <= remainingBetRoom + } + public var body: some View { ScrollView(.horizontal) { HStack(spacing: CasinoDesign.Spacing.medium) { @@ -48,8 +64,8 @@ public struct ChipSelectorView: View { ) } .buttonStyle(.plain) - .opacity(balance >= denomination.rawValue ? 1.0 : CasinoDesign.Opacity.medium) - .disabled(balance < denomination.rawValue) + .opacity(canUseChip(denomination) ? 1.0 : CasinoDesign.Opacity.medium) + .disabled(!canUseChip(denomination)) } } .padding(.horizontal, CasinoDesign.Spacing.large) @@ -62,10 +78,23 @@ public struct ChipSelectorView: View { .accessibilityHint(String(localized: "Double tap a chip to select bet amount", bundle: .module)) .onChange(of: balance) { _, newBalance in // Auto-select highest affordable chip if current selection is now too expensive - if newBalance < selectedChip.rawValue { - if let affordable = availableChips.last(where: { newBalance >= $0.rawValue }) { - selectedChip = affordable - } + autoSelectAffordableChip(forBalance: newBalance) + } + .onChange(of: currentBet) { _, _ in + // Auto-select when remaining bet room changes + autoSelectAffordableChip(forBalance: balance) + } + } + + /// Auto-selects the highest affordable chip that fits within remaining bet room + private func autoSelectAffordableChip(forBalance newBalance: Int) { + let newRemainingRoom = maxBet - currentBet + // If current selection is unaffordable or exceeds remaining room, find a better chip + if newBalance < selectedChip.rawValue || selectedChip.rawValue > newRemainingRoom { + if let affordable = availableChips.last(where: { + newBalance >= $0.rawValue && $0.rawValue <= newRemainingRoom + }) { + selectedChip = affordable } } } @@ -77,13 +106,14 @@ public struct ChipSelectorView: View { .ignoresSafeArea() VStack(spacing: CasinoDesign.Spacing.xLarge) { - Text("Balance: $50,000") + Text("Balance: $50,000 | Bet: $1,000 | Max: $5,000") .foregroundStyle(.white) ChipSelectorView( - selectedChip: .constant(.fiveThousand), + selectedChip: .constant(.fiveHundred), balance: 50_000, - maxBet: 50_000 + currentBet: 1_000, + maxBet: 5_000 ) } }