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(
selectedChip: $selectedChip,
balance: state.balance,
currentBet: state.totalBetAmount,
maxBet: state.maxBet
)
.frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity)

View File

@ -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)
}

View File

@ -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.

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" : {
"comment" : "Description of a deck count option when the user selects 8 decks.",
"isCommentAutoGenerated" : true

View File

@ -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))
}
}
}
}

View File

@ -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",

View File

@ -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)"))

View File

@ -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<ChipDenomination>,
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
)
}
}