Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-17 17:37:46 -06:00
parent 3588c0679f
commit a7d550eef1
8 changed files with 87 additions and 27 deletions

View File

@ -110,6 +110,7 @@ struct GameTableView: View {
ChipSelectorView( ChipSelectorView(
selectedChip: $selectedChip, selectedChip: $selectedChip,
balance: state.balance, balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet maxBet: state.maxBet
) )
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)

View File

@ -101,8 +101,9 @@ final class GameState {
} }
/// Whether player can place a bet. /// Whether player can place a bet.
/// True if in betting phase, have balance, and haven't hit max bet.
var canBet: Bool { var canBet: Bool {
currentPhase == .betting && currentBet + settings.minBet <= balance currentPhase == .betting && balance > 0 && currentBet < settings.maxBet
} }
/// Whether the current hand can hit. /// Whether the current hand can hit.
@ -139,9 +140,9 @@ final class GameState {
return engine.canSurrender(hand: hand) 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 { var isGameOver: Bool {
balance < settings.minBet && currentPhase == .betting balance < settings.minBet && currentPhase == .betting && currentBet == 0
} }
/// Total rounds played. /// Total rounds played.
@ -250,9 +251,14 @@ final class GameState {
// MARK: - Dealing // MARK: - Dealing
/// Whether the player can deal (betting phase with valid bet).
var canDeal: Bool {
currentPhase == .betting && currentBet >= settings.minBet
}
/// Deals the initial cards. /// Deals the initial cards.
func deal() async { func deal() async {
guard currentBet >= settings.minBet else { return } guard canDeal else { return }
currentPhase = .dealing currentPhase = .dealing
playerHands = [BlackjackHand(bet: currentBet)] playerHands = [BlackjackHand(bet: currentBet)]
@ -598,13 +604,20 @@ final class GameState {
/// Starts a new round. /// Starts a new round.
func newRound() { func newRound() {
// Reset all hand state
playerHands = [] playerHands = []
dealerHand = BlackjackHand() dealerHand = BlackjackHand()
activeHandIndex = 0 activeHandIndex = 0
// Reset bets
currentBet = 0
insuranceBet = 0 insuranceBet = 0
// Reset UI state
showResultBanner = false showResultBanner = false
lastRoundResult = nil lastRoundResult = nil
currentPhase = .betting currentPhase = .betting
sound.play(.newRound) sound.play(.newRound)
} }

View File

@ -48,14 +48,16 @@ struct BlackjackHand: Identifiable, Equatable {
cards.count == 2 && value == 21 && !isSplit 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 { 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 { var canDoubleDown: Bool {
cards.count == 2 && !isDoubledDown && !isSplit cards.count == 2 && !isDoubledDown
} }
/// Whether this hand can hit. /// Whether this hand can hit.

View File

@ -1299,6 +1299,10 @@
} }
} }
}, },
"Max: $%@" : {
"comment" : "A label displaying the maximum bet amount. The argument is the maximum bet amount.",
"isCommentAutoGenerated" : true
},
"Maximum penetration" : { "Maximum penetration" : {
"comment" : "Description of a deck count option when the user selects 8 decks.", "comment" : "Description of a deck count option when the user selects 8 decks.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true

View File

@ -384,9 +384,15 @@ struct BettingZoneView: View {
.font(.system(size: labelFontSize, weight: .bold)) .font(.system(size: labelFontSize, weight: .bold))
.foregroundStyle(.white.opacity(Design.Opacity.medium)) .foregroundStyle(.white.opacity(Design.Opacity.medium))
HStack(spacing: Design.Spacing.medium) {
Text(String(localized: "Min: $\(minBet)")) Text(String(localized: "Min: $\(minBet)"))
.font(.system(size: Design.BaseFontSize.small, weight: .medium)) .font(.system(size: Design.BaseFontSize.small, weight: .medium))
.foregroundStyle(.white.opacity(Design.Opacity.light)) .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))
}
} }
} }
} }

View File

@ -78,6 +78,7 @@ struct GameTableView: View {
TopBarView( TopBarView(
balance: state.balance, balance: state.balance,
secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil, secondaryInfo: settings.showCardsRemaining ? "\(state.engine.cardsRemaining)" : nil,
secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil,
onReset: { state.resetGame() }, onReset: { state.resetGame() },
onSettings: { showSettings = true }, onSettings: { showSettings = true },
onHelp: { showRules = true }, onHelp: { showRules = true },
@ -98,6 +99,7 @@ struct GameTableView: View {
ChipSelectorView( ChipSelectorView(
selectedChip: $selectedChip, selectedChip: $selectedChip,
balance: state.balance, balance: state.balance,
currentBet: state.currentBet,
maxBet: state.settings.maxBet maxBet: state.settings.maxBet
) )
.frame(maxWidth: maxContentWidth) .frame(maxWidth: maxContentWidth)
@ -195,7 +197,7 @@ struct ActionButtonsView: View {
state.clearBet() state.clearBet()
} }
if state.currentBet >= state.settings.minBet { if state.canDeal {
ActionButton( ActionButton(
String(localized: "Deal"), String(localized: "Deal"),
icon: "play.fill", icon: "play.fill",

View File

@ -114,12 +114,6 @@ struct ResultBannerView: View {
) )
} }
} }
.onAppear {
Task {
try? await Task.sleep(for: .milliseconds(300))
SoundManager.shared.play(.gameOver)
}
}
} else { } else {
// New Round button // New Round button
Button(action: onNewRound) { Button(action: onNewRound) {
@ -171,6 +165,14 @@ struct ResultBannerView: View {
withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) { withAnimation(.spring(duration: Design.Animation.springDuration, bounce: 0.3)) {
showContent = true 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) .accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)")) .accessibilityLabel(String(localized: "Round result: \(result.mainHandResult.displayText)"))

View File

@ -9,20 +9,31 @@ import SwiftUI
/// A horizontal scrollable chip selector. /// A horizontal scrollable chip selector.
/// Shows chips based on current balance - higher denomination chips unlock as you win more! /// 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 { public struct ChipSelectorView: View {
@Binding var selectedChip: ChipDenomination @Binding var selectedChip: ChipDenomination
let balance: Int let balance: Int
let currentBet: Int
let maxBet: Int let maxBet: Int
let theme: any CasinoTheme let theme: any CasinoTheme
/// Remaining room before hitting max bet
private var remainingBetRoom: Int {
maxBet - currentBet
}
public init( public init(
selectedChip: Binding<ChipDenomination>, selectedChip: Binding<ChipDenomination>,
balance: Int, balance: Int,
currentBet: Int = 0,
maxBet: Int = 100_000, maxBet: Int = 100_000,
theme: any CasinoTheme = DefaultCasinoTheme() theme: any CasinoTheme = DefaultCasinoTheme()
) { ) {
self._selectedChip = selectedChip self._selectedChip = selectedChip
self.balance = balance self.balance = balance
self.currentBet = currentBet
self.maxBet = maxBet self.maxBet = maxBet
self.theme = theme self.theme = theme
} }
@ -33,6 +44,11 @@ public struct ChipSelectorView: View {
.filter { $0.rawValue <= maxBet } // Don't show chips larger than max bet .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 { public var body: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
HStack(spacing: CasinoDesign.Spacing.medium) { HStack(spacing: CasinoDesign.Spacing.medium) {
@ -48,8 +64,8 @@ public struct ChipSelectorView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.opacity(balance >= denomination.rawValue ? 1.0 : CasinoDesign.Opacity.medium) .opacity(canUseChip(denomination) ? 1.0 : CasinoDesign.Opacity.medium)
.disabled(balance < denomination.rawValue) .disabled(!canUseChip(denomination))
} }
} }
.padding(.horizontal, CasinoDesign.Spacing.large) .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)) .accessibilityHint(String(localized: "Double tap a chip to select bet amount", bundle: .module))
.onChange(of: balance) { _, newBalance in .onChange(of: balance) { _, newBalance in
// Auto-select highest affordable chip if current selection is now too expensive // Auto-select highest affordable chip if current selection is now too expensive
if newBalance < selectedChip.rawValue { autoSelectAffordableChip(forBalance: newBalance)
if let affordable = availableChips.last(where: { newBalance >= $0.rawValue }) {
selectedChip = affordable
} }
.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() .ignoresSafeArea()
VStack(spacing: CasinoDesign.Spacing.xLarge) { VStack(spacing: CasinoDesign.Spacing.xLarge) {
Text("Balance: $50,000") Text("Balance: $50,000 | Bet: $1,000 | Max: $5,000")
.foregroundStyle(.white) .foregroundStyle(.white)
ChipSelectorView( ChipSelectorView(
selectedChip: .constant(.fiveThousand), selectedChip: .constant(.fiveHundred),
balance: 50_000, balance: 50_000,
maxBet: 50_000 currentBet: 1_000,
maxBet: 5_000
) )
} }
} }